Swiftで遊ぼう! on Hatena

あしたさぬきblogでやってた初心者オヤジのiOSプログラミング奮闘記がHatenaに来ました

Swiftで遊ぼう! - 1019 - EnvironmentObject実装で疑問❓

2021年2月15日追記:AppDelegateもSceneDelegateも使わない「SwiftUI App」スタイルの実装法の説明は:Swiftで遊ぼう! - 1023 - EnvironmentObjectの取り扱い - Swiftで遊ぼう! on Hatena

SwiftUIプログラミングで重要な設計パターンはMVVMである。Model、View、そしてViewModelの3つのパターンを考えてアプリを開発していくやり方だ。Swift言語が世の中に出てきた頃のiOS開発の中心はMVCだった。しかし、オブジェクト指向のクラス中心の開発から、プロトコール指向で構造体を中心に扱うプログラミング開発に時代がシフトしていくなか、SwiftUIを使ったMVVM設計が重要になってきている。

実は、このSwiftUIを使ったMVVM設計、やみくもに重要と言いたいわけではなく、プログラミング初心者にとって重要、と言うべきかもしれない。概念が理解しやすいからだ。たぶん、ベテランプログラマーは、開発環境が未熟なSwiftUI環境に100%シフトすることはできないだろう。それでも、従来のリファレンス型のクラス設計の中、メモリの参照関係を意識しながら、ユーザーインターフェイズをAutoLayoutを使って考えながらコードするのは、初心者にとって難しい。また、効率よくプログラミングするために色々あるデザインパターンも意識しながら設計しないといけないので、頭がこんがらがって挫折した初心者も多いだろう。私も悪戦苦闘を続けていたところで、SwiftUIが舞い降りてきた。まさに救世主が現れた感じだった。

プログラミング初心者に優しいといいながら、全くの初心者には、やはり概念的に難しいところもある。どの分野にしろ、過去の知識を土台にして、新しい知識が積み上がっているから仕方ない。オブジェクト指向プログラミング開発を勉強したから、プロトコール指向の構造体プログラミング開発が楽に感じるだけ。MVCを知っているからMVVMがシンプルに感じるだけかもしれない。

MVVMを使って開発する時に重要な概念が1つある。Viewの状態を監視するために使うプロパティ・ラッパーだ。概念的な説明は省く、というか私が人に説明できるほどちゃんと理解していないからできない、と言う方が正しい。wrappedValueとprojectedValueを包み隠して利用する型を宣言して使うって感じで理解しているぐらいだ。まあ、重要なことはいかに利用するかだ。数学の公式でも理解してなくても利用して問題が解ければいいんだ。全ての概念を理解する必要はない。理解しなければならないのは、コンピュータサイエンスを専攻している人たちだろう。そう考えてプログラミングに取り組む方が気が楽なんで、何でも完全に理解して取り組もうとしない方がいいんじゃないかと思っている。

そうは言うもののある程度理解して取り組まないと、エラーが発生した時に全く自分で解決できないことになる。ある程度自分で解決できるようになっているが分からないことも多い。ここまで読んでくださったベテランプログラマーの人にお訊ねしたいことがあります。助言していただけないでしょうか?

まず、Navigationを使ったアプリで、ContentViewから、リスト化されたボタンを押すことで、内容の異なるDetailViewに遷移する単純なアプリを設計しました。同じDetail Viewを使って、内容だけ変更したいので、内容を保持するModel構造体を用意するのですが、表示をコントロールするためにViewModelも組み込むので、次のようなObservableObjectクラスを用意した。

import Foundation

// Model
struct Person {
    var name: String
    var age: Int
}

// ViewModel
struct PersonViewModel {
    var person: Person
    var description: String {
        return person.name + " " + String(person.age) + "歳"
    }
}

class AppData: ObservableObject {
    @Published var userData: [PersonViewModel]
    
    init() {
        userData = [
            PersonViewModel(person: Person(name: "Taro", age: 5)),
            PersonViewModel(person: Person(name: "Hana", age: 7)),
            PersonViewModel(person: Person(name: "Chie", age: 12))
        ]
    }
}

当然のようにViewとViewModelのようなユーザーインターフェイスを含まないコードをする場合、SwiftUIフレームワークを組み込まないようにする。本来なら、保存したデータベースやインターネットから初期値をダウンロードする設計が現実的ではあるが、これはサンプルコードなんで初期化ステップで固定されたデータを組み込むようにした。

これをEnvironmentObjectとして読み込むのが次のDetailViewである。それを次のように実装する。

import SwiftUI

struct DetailView: View {
    @EnvironmentObject var appData: AppData
    var index: Int
    
    var body: some View {
        VStack {
            Text(appData.userData[index].description)
            Spacer()
            }.padding()
            .navigationBarTitle(Text("Detail info"))
    }
}

struct DetailView_Previews: PreviewProvider {
    static var previews: some View {
        NavigationView {
            DetailView(index: 1).environmentObject(AppData())
        }
    }
}

ここで、「@EnvironmentObject var appData: AppData」と宣言だけしている。ここで、この構造体の中でインスタンス化はしていない。そりゃ当然ですよね。この構造体の中でインスタンス化すれば、この構造体のスコープ内でしか使えないからだ。EnvironmantObjectとして宣言しているということは、同じアプリ内のView全体で共通して使用するという意味なので、どこでインスタンス化すべきか考えないといけないんです。

まさに、それが私の疑問である。

まず、その問題提起をする前に、ContentViewのコードもここに紹介する。

import SwiftUI

struct ContentView: View {
    @State var selected: Int? = nil
    
    var body: some View {
        NavigationView {
            VStack(spacing: 15) {
                NavigationLink("Person 1", destination: DetailView(index: 0), tag: 0, selection: $selected)
                NavigationLink("Person 2", destination: DetailView(index: 1), tag: 1, selection: $selected)
                NavigationLink("Person 3", destination: DetailView(index: 2), tag: 2, selection: $selected)
                Spacer()
            }.padding()
            .navigationBarTitle("Main Menu")
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        NavigationView {
            ContentView()
        }
    }
}

この3つのファイルを用意して、プレビューを確認するとちゃんとプレビューはみることができる。そりゃ当然。でも、実際にシミュレーターを立ち上げようとするとエラーが出現する。それも当然。AppDataがインスタンス化されていないからだ。

じゃあ、どこでインスタンス化すればいいのか?

ここが今日の疑問である

まず1つ目の実装法は次のようにAppDelegateで次のようにコーディング。

import UIKit

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    var myData: AppData!

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        // Override point for customization after application launch.
        myData = AppData()
        return true
    }

// 後に続く
...

そして、SceneDelegate内のsceneメソッドに、AooDelegateでインスタンス化したインスタンスを組み込む。

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
        // If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
        // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).

        // Create the SwiftUI view that provides the window contents.
        let delegate = UIApplication.shared.delegate as! AppDelegate
        let contentView = ContentView().environmentObject(delegate.myData)

        // Use a UIHostingController as window root view controller.
        if let windowScene = scene as? UIWindowScene {
            let window = UIWindow(windowScene: windowScene)
            window.rootViewController = UIHostingController(rootView: contentView)
            self.window = window
            window.makeKeyAndVisible()
        }
    }

これも納得のいく実装法だと思っている。でも、次のようにSceneDelegate内だけでインスタンス化して組み込みも完了するやり方もあるんですよね。

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
        // If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
        // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).

        // Create the SwiftUI view that provides the window contents.
        let myData = AppData()
        let contentView = ContentView()

        // Use a UIHostingController as window root view controller.
        if let windowScene = scene as? UIWindowScene {
            let window = UIWindow(windowScene: windowScene)
            window.rootViewController = UIHostingController(rootView: contentView.environmentObject(myData))
            self.window = window
            window.makeKeyAndVisible()
        }
    }

どちらでもちゃんと動くんです。でも、どちらの実装法が理想的なのか、私には分からないんだ。ベテランプログラマーの皆さん、どちらの実装法がいいのか教えてください。理由もあれば、些細なことでもいいのでアドバイスをお願いします。

よろしくお願い申し上げます。いつまで経って素人から脱却できないド素人オヤジプログラマーです。はやくアプリを販売できる人間になりたい!