Skip to content

Instantly share code, notes, and snippets.

@YusukeHosonuma
Last active November 11, 2024 15:25
Show Gist options
  • Save YusukeHosonuma/96099b8ce34940c49b9a28ee8b5800ff to your computer and use it in GitHub Desktop.
Save YusukeHosonuma/96099b8ce34940c49b9a28ee8b5800ff to your computer and use it in GitHub Desktop.
あなたが求めていた SwiftUI 入門(未完結)

昔に書いていた未完結の記事ですが、それでも宜しければどうぞです🙏

あなたが求めていた SwiftUI 入門(未完結)

あなたは今年こそ SwiftUI を学ぶ必要があると感じている。

それは今年の WWDC20 で発表された Widget と呼ばれる機能が SwiftUI でしか作成できないことを聞いたからかもしれないし、SwiftUI 100% でマルチプラットフォームのアプリを作成できるようになったからかもしれないし、あるいはいつまでも UIKit に依存しているのはリスクだと感じ取ったのかもしれない。

学習の上で一番難しい部分は、SwiftUI で考えるということだ。従来の命令的でステートフルなプログラミングから離れて、宣言的でステートレスに考えるように脳を強制しなくてはならない。すでに日本でも SwiftUI の書籍はいくつか発売されているが、その多くは使い方に関することが中心で、SwiftUI が**どのように動くのか(How it works)**についての解説は不足しているものが多いように感じる。

このガイドは手短に、SwiftUI がどのように動くのかについて解説するものだ。ライブラリのドキュメントは後から道を照らしてくれるだろう。このガイドが役に立つことを願う。

SwiftUI とは何か?

SwiftUI は WWDC19 で発表された、UIKit に代わる新しい UI フレームワークだ。マルチプラットフォームもサポートされており、1つの SwiftUI コードは各プラットフォームに特化した UI を表示させる。WWDC20 では 100% SwiftUI でアプリを開発できるようになり、iOSアプリ開発者にとってはおなじみの AppDelegate すら不要になった。

SwiftUI の実態は、React などに代表される宣言的な UI フレームワークで、UI は Swift のコードとして記述する形となっている。例を見てみよう。

@main
struct CountApp: App {
    var body: some Scene {
        WindowGroup {
            CounterView()
        }
    }
}

struct CounterView: View {
    @State var count = 0
    
    var body: some View {
        VStack {
            Button("タップしてね") { count += 1 }
            
            if count == 0 {
                Text("まだタップされてないよ。")
            } else {
                Text("\(count)回タップされたよ。")
            }
        }
    }
}

このコードはおそらくあなたの想像どおりに動く。

3F0AFDF2-F9D0-41FC-B05C-4409AE83E181

これはマルチプラットフォーム対応アプリの完全なコードの例となっており、macOS Big Sur にアップデートすれば mac でも動作する。

このコードを見ると、とてもシンプルに見える。実際のところ SwiftUI で画面を作る唯一の方法は View を作成することのみで、UIKit のように Xib / Storyboard / コードレイアウトなど複数の方法がサポートされているわけではない。言い換えると View の構造を定義することでアプリ全体の画面表示が決定する。

問題は、これがどのように動作するかだ。順に見ていこう。

アプリ起動・初期表示

@main
struct CountApp: App {
    var body: some Scene {
        WindowGroup {
            CounterView()
        }
    }
}

@main はエントリポイントの宣言で、実際にはAppに定義された static な main 関数がコールされる。この関数こそが SwiftUI を起動する部分であり、システムはイベントループを開始し、SwiftUI のライフサイクルを機能させるようにセットアップする。

次にシステムは body プロパティを呼び出す。これは App プロトコルによって宣言されたもので、Scene プロトコルに準拠した値を返す必要がある。WindowGroup の詳細についてはここでは触れないが、ここでは1つの画面を表すとだけ覚えておけばいい。

WindowGroup のクロージャ内では我々の View 定義である CounterView を生成している。

struct CounterView: View {
    @State var count = 0
    
    var body: some View {
        VStack {
            Button("タップしてね") { count += 1 }
            
            if count == 0 {
                Text("まだタップされてないよ。")
            } else {
                Text("\(count)回タップされたよ。")
            }
        }
    }
}

@State は対象のプロパティ値を SwiftUI に保持させるという意味を持ち、内部的には Property wrapper という Swift の機能で実装されている。ここでは初期値として 0 を与えており、それが SwiftUI によって状態として管理される。なぜ、通常のプロパティで問題になるのかは後ほど分かる。

次にシステムは CountApp と同様に body プロパティを呼び出す。body プロパティは View プロトコルで定義されたもので、View プロトコルに準拠した値を返す必要がある。

VStack から始まるコードブロックは Swift のクロージャのように見える。しかし、実際は Functions builder という Swift の機能が利用されており、記述できるコードは制限されている。Xcode 12 時点でサポートされている制御構文は if / if-else / if-let / switch の4種類だけであり、それ以外は View の定義しか記述することができない。

スマートな DSL として記述できるように設計されているため、一見すると Swift のコードに見えないが、body プロパティは通常の Swift のプロパティ定義と変わらない。つまり、前述したように View プロトコルに準拠した値を返す。

次のように書き換えてみれば、実際に返却している型が確認できる。

var body: some View {
    let view = VStack { ... }
    print(Mirror(reflecting: view))
    return view
}

print の出力結果は次のようになる。

Mirror for VStack<TupleView<(Button<Text>, _ConditionalContent<Text, Text>)>>

OK、このままでは見づらいので可視化することにしよう。

BC251DA4-1D38-4DBD-926B-845DFB8CEA30

こうしてみると、静的に型付けされた値を返却しているという事実が分かりやすいはずだ。if-else は単純な制御構文に見えるが、実際には _ConditionalContent という内部的な View 型に変換されているのも分かる。これこそが View の構造を定義する という意味で、静的に型付けされたレイアウト情報を SwiftUI に伝えているということになる。

さて、システムに我々が望む View 構造を伝えることができたが、次は何が起こるのだろう。システムは受け取った View 構造を、そのプラットフォームに適したコンポーネント構造に変換した上で、実際のデバイスの画面サイズと照らし合わせて具体的な座標系に落とし込み、最後に Pixel としてレンダリングする。

6E89A6D6-2E4B-49B0-9085-0798192A95D7

View 構造は、Web における HTML とよく似ている。どちらも構造を定義するが、実際にそれをどのように表示(レンダリング)するかはブラウザに依存する。

状態変更・画面更新

さて、初期表示までのプロセスが分かったので、次はボタンがクリックされた時にどのように画面が更新されるのかを見ていこう。

struct CounterView: View {
    @State var count = 0
    
    var body: some View {
        VStack {
            Button("タップしてね") { count += 1 }
            
            if count == 0 {
                Text("まだタップされてないよ。")
            } else {
                Text("\(count)回タップされたよ。")
            }
        }
    }
}

ボタンクリック時に実行されるアクションはクロージャで指定している(一見するとこれも Function builders に見えるかもしれないが、通常のクロージャなので任意のコードを記述できる)。ここでは @State が付与された count プロパティをインクリメントして更新している。

ここでシステムは count プロパティを利用している View を見つけ出し、そのすべての View の再描画を行おうとする。通常のプロパティが利用できないのはこのためで、@State Property wrapper は値の代入(set)を監視することで、更新を検知して必要な View を再描画する。

実際にどのように更新が行われるかというと、まず既存の CounterView 構造体の値がコピーされる。次に body プロパティが呼び出されて新しい View 構造が生成されるのだが、count プロパティは 0 から 1に変化しているため、if-else の分岐にしたがって画面に出力されるテキスト内容が変化する。言い換えると、現在の状態(ステート)に基づいて View 構造が再計算される。

40248158-D4B1-4D18-8912-E62FEE59FDAB

さて、システムは新しい View 構造に基づいて再レンダリングをする必要があるが、CounterView 全体を再レンダリングする必要はあるのだろうか?

答えは No であり、実際に Pixel が変化する部分だけを更新すればよい。その方がずっと効率的だ。今回のコードでは、変化する部分は if-else の部分、すなわち _ConditionalContent が専有する領域のみであり、画面が変化しないボタンなどのその他の領域を更新する必要はない。

しかし、それは簡単にできることなのだろうか?ここで body プロパティが返却する型について思い出して欲しい。

VStack<TupleView<(Button<Text>, _ConditionalContent<Text, Text>)>>

これはコンパイル時に静的に確定している型情報であり、何度 body プロパティが呼び出されても変化することはない。つまり、システムは同じ構造の View ツリーの diff を簡単に計算できるということになる。

C0982F29-F67F-476D-9B2A-EF8406EDC694

React や Vue.js などの技術に詳しい方は、私がいわゆる Virtual DOM の話をしようとしていることに気づいたかもしれない。ブラウザは DOM と呼ばれるページ構造を持っており、DOM の変更に従ってページを再レンダリングする。Virtual DOM はその DOM を仮想的にフレームワークが表現したもので、その手のフレームワークは Virtual DOM の diff を計算し、実際に変更があった部分だけ DOM に反映することで、再レンダリングのコストを下げている。

View 構造を Virtual DOM、実際にレンダリングされる内容を DOM と対応させて考えると、両者の構造は驚くほど似ていることに気づく。

おそらくこれは偶然ではない。SwiftUI は React のアーキテクチャに大きな影響を受けているのは間違いない。React が発表された当時、「あなたに必要なことはコンポーネントを定義するだけです」といったようなコピーがあったように記憶している。SwiftUI でも View というコンポーネントを定義するという見方をすれば、その点でも似ている。

実際のところ、SwiftUI が内部的にどのように処理しているかは仕様として公開されたものではない。SwiftUI が仕様として言及しているのは、変更された @State プロパティが依存している View が再計算されるということだけであり、利用者が意識する部分ではない。

SwiftUI のライフサイクル

あとはこれの繰り返しになる。何らかのアクションによって、状態が変更され、その状態に依存した View 構造が再計算され、システムは必要な領域を再レンダリングする。

325D430B-7ACB-4D02-A950-E45D147C8E28

これと似た図は、WWDC20 の Data Essentials in SwiftUI のセッションでも見ることができる。

グレーの部分こそが、あなたが定義すべきコードとなる。あなたがやるべきは、① 保持すべき状態を決め、② 状態によって算出される View 構造を定義し、③ 特定のイベントに応じて状態を変更することだけだ。あとはシステムが適切な形で再レンダリングしてくれる。

この一連のループに「レンダリング」が含まれていることから分かるように、このループのいずれかの部分で重い処理を行うとアプリはハングする。アプリをハングさせる最も簡単なコードの1つは次のようになる。

struct CounterView: View {
    @State var count = 0
    
    var body: some View {
        Thread.sleep(forTimeInterval: 10) // 10秒停止させる
        return VStack { ... }
    }
}

これだけであなたのアプリは10秒間はイベントを受け付けなくなり、場合によっては OS によってプロセスが kill される。これが意味するところは body プロパティで重い処理を行ってはならず、必要であればバックグラウン処理を検討すべきということだ。この点は UIKit においてメインスレッドをブロックすべきでない理由と同様だ。

ところで、UIKit において注意すべき点の1つに「UI の状態変更を必ずメインスレッドで行う必要がある」という制約があった。これは UIKit の各コンポーネントがスレッドセーフで無いため、複数のスレッドからアクセスした場合にデータ競合が発生するためで、データ競合が発生すると UIKit の内部ステートが不正になり、運が悪ければ(あるいは良ければ)アプリはクラッシュする。

これは SwiftUI でも気をつけるべきことなのだろうか?例えば、次のようにバックグラウンドスレッドで状態を更新するのは避けるべきなのだろうか?

struct CounterView: View {
    @State var count = 0
    
    var body: some View {
        VStack {
            Button("タップしてね") {
                DispatchQueue.global().async { count += 1 } // バックグラウンドスレッドで更新
            }
...

答えは No だ。@State プロパティは任意のスレッドで安全に更新できるように設計されており、APIドキュメントでもそれが明記されている。

You should only access a state property from inside the view’s body, or from methods called by it. For this reason, declare your state properties as private, to prevent clients of your view from accessing them. It is safe to mutate state properties from any thread.

しかし、あなたが SwiftUI の通常のライフサイクル以外の方法で @State プロパティを更新しようとしたり、明示的に body プロパティを呼び出すような真似をすれば、その安全性は保証されないかもしれない。

バインディング・Source of Truth

@Binding

SwiftUI は独立したコンポーネントも簡単に作成することができる。例えば、次のコードは+ボタンがタップされる度に ★ の数が増えていく View だ。

struct RateView: View {
    @State var count: Int
    
    var body: some View {
        HStack {
            Button("") { count += 1 }
                .frame(width: 30, height: 30)
                .border(Color.blue, width: 1)
            ForEach(0..<count, id: \.self) { _ in
                Image(systemName: "star.fill")
            }
        }
    }
}

C568A1FC-26B1-450B-A786-1F032FB573AC

この RateView は次のように他の View から利用できる。

struct ContentView: View {
    @State var count = 1
    
    var body: some View {
        VStack {
            Text("スター数:\(count)").padding()
            RateView(count: count)
        }
    }
}

実にかんたんだ。自作した View も View プロトコルを実装する構造体にすぎないので、標準のコントロールと同様に扱うことができる。

E8E8E992-7D39-4D2D-9866-A12F737655FC

ところで1つ困ったことがある。上記のキャプチャから分かるとおり、私はスター数が変化した場合に画面上の「スター数:x」というテキストも更新したいのだが、データの同期はどうやってやるのだろう?

ボタンがクリックされたときにコールバックを呼び出し、親である ContentView に通知するやり方も考えられるが、コードとしてはやや煩雑になりそうだ。それに現状だと「スター数」という1つの事実に対して、それぞれの View で状態を持ってしまっている。これはいかにもバグに繋がりそうだ。

SwiftUI では Source of Truth、すなわち信頼できる唯一の値から View を構築するというコンセプトが大切にされており、それを実現するためのツールセットが備わっている。その中で最も重要なのがバインディングで、今回の例では RateView に @State で状態を持つ代わりに、@Binding でデータへの参照を受け取ることで解決できる。

struct ContentView: View {
    @State var count = 1
    
    var body: some View {
        VStack {
            Text("スター数:\(count)").padding()
            RateView(count: $count) // 先頭に$をつける
        }
    }
}

struct RateView: View {
    @Binding var count: Int // @State → @Binding に変更
    
    var body: some View { ... }
}

@Binding は参照を通じて、真実の値を読み書き可能にする Property wrapper とみなせる。古典的には一種のポインタと考えると分かりやすいかもしれない。データの実態を持つのではなく、ポインタを通じて本来の値にアクセスするのだ。

02038AF3-39CB-4BE0-BDB8-EE9F1E3F3F90

RateView は実際の値の代わりにバインディングを受け取ることになったが、引数として渡す時に$を先頭に付けているのが分かる。これは Projected value と呼ばれる Swift の機能で、Property wrapper に宣言された projectedValue プロパティを取得するものだ。projectedValue が実際に何を返すかはその Property wrapper に依存するが、SwiftUI に用意された Property wrapper では Binding を取得するように統一されている。

なお、@Binding は概念的には双方向バインディングを実現している。今回の例では、値の書き込みは RateView からしか行っていないが、ContentView にて @State な count プロパティを書き込んだ場合、Binding を通して参照している View についても再描画が行われる。

ボタンがクリックされたときにコールバックを呼び出し、親である ContentView に通知するやり方も考えられるが、コードとしてはやや煩雑になりそうだ。それに現状だと「スター数」という1つの事実に対して、それぞれの View で状態を持ってしまっている。これはいかにもバグに繋がりそうだ。


つづきは...ないです。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment