kateinoigakukunのブログ

思考垂れ流しObserver

Inside SwiftUI @State編

WWDCでSwiftUIが発表されてから数日が経ちました。一気に世界が変わった気がしますね。 ただ、UIKitと同様にSwiftUIはオープンソースでは無いため、我々開発者は依然挙動をエスパーしながら開発する必要があります。

その中でも、SwiftUIのチュートリアルを試している中で不思議な仕組みがいくつかあったので、僕が調べたSwiftUIの内部構造について書き留めておきます。

(あくまで考察なので間違っていても悪しからず)

追記(2022-09-15)

未だにアクセスがあるので追記。

TokamakというSwiftWasmでSwiftUI互換なコードを書くためのUIフレームワークがあり、 ここで考察した内容とほぼ同じようなテクニックを使ってStateを実装しています。 具体的な実装が気になる方は読んでみてください。

github.com

目次

  • @State編(今ココ)
  • DynamicViewProperty編(来週くらいには書きます)
  • 差分更新編(調査中)

@State

@State var value: Int

チュートリアルをこなした方であれば何度も書いたことでしょう。

Swift5.1からのProperty Delegatesを使った記法で、プロパティの値のgetterとsetterを別の型(ここではState)に委譲できます。

StateというのはSwiftUIに定義された@propertyDelegateなstructです。変更されるとStateが定義されているViewが再レンダリングされる、という振る舞いをします。

ではこの振る舞いからStateの実装を予想してみます。

@propertyDelegate
struct State<Value> {
    var storage: Value
    var value: Value {
        get { storage }
        set {
            storage = newValue
            renderView()
        }
    }
    init(initialValue value: Value) {
        self.storage = value
    }
    
    func renderView() {
        // ???
    }
}

struct ContentView: View {
    @State var text: String
}

(本当はvalueのsetterがnonmutatingなのでbox化されてるはずですが説明のため省略してます。)

簡単に実装できましたがViewを再レンダリングする部分だけは想像できません。keyWindow配下のViewを全て再レンダリングしているのか?、と一瞬思いましたが、Stateが変更されてもルートのViewから全て再レンダリングされる訳では無さそうです。 では、どうやってtext: State<String>ContentViewを紐付けてContentViewだけ再レンダリングしているのでしょうか。

Stateの内部構造

とりあえずStateのpublicなフィールドにはそれっぽい物は無いので、内部フィールドをdumpで調べてみます。

struct ContentView: View {
    var text = State<String>(initialValue: "Hello")
    
    init() {
        print("Init:")
        dump(text)
    }
    
    var body: some View {
        print("Body:")
        dump(text)
        return Text("Hello, world")
    }
}

(StatedelegateValueを使っており$textStateを返さないのでProperty Delegateを使わない記法にあえてしています。)

initbodyでそれぞれtext: State<String>をdumpしましたが、実は違った結果が出力されます。

Init:
▿ SwiftUI.State<Swift.String>
  - _value: "Hello"
  - _location: nil

Body:
▿ SwiftUI.State<Swift.String>
  - _value: "Hello"
  ▿ _location: Optional(SwiftUI.StoredLocation<Swift.String>)
    ▿ some: SwiftUI.StoredLocation<Swift.String> #0
      - super: SwiftUI.AnyLocation<Swift.String>
        - super: SwiftUI.AnyLocationBase
      ▿ viewGraph: Optional(SwiftUI.ViewGraph)
        ...

ViewGraphという怪しいオブジェクトが見えるようになりました。initとbodyの間に何かしらの方法でStateにViewGraphが注入されています。

Internalな型なので名前から推測するしかありませんが、どうやらViewGraphはViewのツリー構造を管理するオブジェクトのようです。このViewGraphがContentViewのみを選択的にレンダリングできる、と仮定すると再レンダリングの仕組みは説明できそうです。 では、StateがViewGraphを持つことを考慮して最初のStateの実装の???を埋めてみます。

 @propertyDelegate
 struct State<Value> {
     var storage: Value
+    var viewGraph: ViewGraph?
     var value: Value {
         get { storage }
         set {
             storage = newValue
             renderView()
         }
     }
     init(initialValue value: Value) {
         self.storage = value
     }
 
+    func setViewGraph(_ viewGraph: ViewGraph) {
+        self.viewGraph = viewGraph
+    }
 
     func renderView() {
+        viewGraph.render()
     }
 }

なるほど、Viewを再レンダリングする方法は分かりました。しかし、未だどうやってContentViewStateを紐付けてContentViewViewGraphを注入するのか、は謎のままです。

先ほどの実験からinitbodyの間に呼ばれることは分かっていますが、どこから呼ばれるかは不明です。

また、@StateフィールドはViewに対して生えていますが、protocolでフィールド名が縛られている訳では無いのでtext: State<String>にアクセスする方法がありません。 例えば以下のようにprotocolで縛られていれば注入できると思いますが、実際フィールド名は自由に付けられるので他の方法で実現されているようです。

protocol ViewGraphInjectable {
    var state: State<Value> { get }
}

struct ContentView: View, ViewGraphInjectable {
    @State var state: String
}

Reflection API

protocolを使わずに任意の構造体から動的に値を取り出す、となると真っ先に思いつくのがMirrorです。ViewMirrorにかけて、childrenを取り出して、その中にState型があればsetViewGraphを呼ぶ、という比較的単純な方法です。

それでは本当にMirrorを使っているか実験してみましょう。CustomReflectableが実装されている場合、MirrorにかけるとcustomMirrorが呼ばれるはずなので、"Mirror is used!"が出力されるはずです。

struct ContentView: View, CustomReflectable {
    @State var text: String
    var customMirror: Mirror {
        print("Mirror is used!")
        return Mirror(reflecting: self)
    }
}

しかし、実際はcustomMirrorは呼ばれません。念のため、Mirrorが使っているランタイムAPIswift_reflectionMirror_countブレークポイントを貼ってみましたがヒットしませんでした。

つまり、SwiftUIは内部でMirrorを使わずにViewのフィールドを取得している訳です。

Mirrorを使わないReflection

Mirrorを使わずともフィールドを取得する方法はまだあります。そうですメタデータを利用する方法です。メタデータについては僕のtry! Swiftの発表を見てもらえると雰囲気が掴めると思います。

メタデータにはField Descriptorという、その型のフィールドのアクセサ群が格納されています。それを利用すればフィールドの一覧を取得できるのでMirrorの代わりに使っている可能性は十分にあります。

色々と試行錯誤した結果1、SwiftUIが内部でViewGraphを組み立てるのに使っているAttributeGraph.frameworkというプライベートフレームワークが内部でメタデータを使っている事が分かりました。

では早速AttributeGraph.frameworkのシンボルをnmコマンドで見てみましょう。

$ nm /System/Library/PrivateFrameworks/AttributeGraph.framework/AttributeGraph

AG::swift::metadata_visitor::visit_field というシンボルが見えると思います。アセンブリを全て読んだ訳ではありませんが、名前から察するにメタデータからフィールドをVisitorパターンで回していそうです。ブレークポイントを貼ってみると実際に動いている様子が確認できます。

なぜMirrorを使わないのか

ではなぜMirrorでもできることをメタデータで実装しているのでしょうか。完全に私の考察ですが、パフォーマンスが主な理由だと思っています。

Mirrorchildren[(label: String, value: Any)]で表現します。任意の型を表現するのにAnyを使っていますが、Anyは値をラップしているため、実際の値を使うには毎回取り出す必要があります。Viewが多くなればなるほどこのオーバーヘッドは無視できなくなってくるため、フィールドをポインタで直接操作できるField Descriptorを使っている、というのが予想です。

StateがViewに更新されるまで

ここまでの実験でStateが変更された際にどうやってViewが再レンダリングされるか予想できたのでまとめます。

  1. Viewに対してField DescriptorStateを探してViewGraphを注入する
  2. View.bodyを評価してレンダリングする。
  3. Stateが変更される
  4. StateViewGraphに変更を通知する
  5. View.bodyが再評価されViewが更新される。

実際の実装はAppleのみぞ知るところですが、これ以上のアイディアは思いつきませんでした。ただ、AttributeGraph.frameworkが独自にリフレクションの仕組みを使っているのは確実です。

try! Swiftの発表で「メタデータを使ったライブラリをみんな作ってみてね」と言いましたが、まさかAppleがABI安定化を期に率先して使ってくるとは思いませんでした。今年のWWDCは驚くことばかりですね。

次はDynamicViewPropertyについて書きます。


  1. これは先に調査していたDynamicViewPropertyの仕組みを考えた上でのエスパーです。