Inside SwiftUI @State編
WWDCでSwiftUIが発表されてから数日が経ちました。一気に世界が変わった気がしますね。 ただ、UIKitと同様にSwiftUIはオープンソースでは無いため、我々開発者は依然挙動をエスパーしながら開発する必要があります。
その中でも、SwiftUIのチュートリアルを試している中で不思議な仕組みがいくつかあったので、僕が調べたSwiftUIの内部構造について書き留めておきます。
(あくまで考察なので間違っていても悪しからず)
追記(2022-09-15)
未だにアクセスがあるので追記。
TokamakというSwiftWasmでSwiftUI互換なコードを書くためのUIフレームワークがあり、
ここで考察した内容とほぼ同じようなテクニックを使ってState
を実装しています。
具体的な実装が気になる方は読んでみてください。
目次
@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") } }
(State
がdelegateValue
を使っており$text
がState
を返さないのでProperty Delegateを使わない記法にあえてしています。)
init
とbody
でそれぞれ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を再レンダリングする方法は分かりました。しかし、未だどうやってContentView
とState
を紐付けてContentView
のViewGraph
を注入するのか、は謎のままです。
先ほどの実験からinit
とbody
の間に呼ばれることは分かっていますが、どこから呼ばれるかは不明です。
また、@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
です。View
をMirror
にかけて、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
が使っているランタイムAPI、swift_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
でもできることをメタデータで実装しているのでしょうか。完全に私の考察ですが、パフォーマンスが主な理由だと思っています。
Mirror
はchildren
を[(label: String, value: Any)]
で表現します。任意の型を表現するのにAny
を使っていますが、Any
は値をラップしているため、実際の値を使うには毎回取り出す必要があります。View
が多くなればなるほどこのオーバーヘッドは無視できなくなってくるため、フィールドをポインタで直接操作できるField Descriptor
を使っている、というのが予想です。
StateがViewに更新されるまで
ここまでの実験でState
が変更された際にどうやってView
が再レンダリングされるか予想できたのでまとめます。
View
に対してField Descriptor
でState
を探してViewGraph
を注入するView.body
を評価してレンダリングする。State
が変更されるState
がViewGraph
に変更を通知するView.body
が再評価されView
が更新される。
実際の実装はAppleのみぞ知るところですが、これ以上のアイディアは思いつきませんでした。ただ、AttributeGraph.framework
が独自にリフレクションの仕組みを使っているのは確実です。
try! Swiftの発表で「メタデータを使ったライブラリをみんな作ってみてね」と言いましたが、まさかAppleがABI安定化を期に率先して使ってくるとは思いませんでした。今年のWWDCは驚くことばかりですね。
次はDynamicViewPropertyについて書きます。