every Tech Blog

株式会社エブリーのTech Blogです。

SwiftUIのBinding型の裏側 ~ PropertyWrapperとDynamicMemberLookup + KeyPath ~

はじめに

こんにちは。デリッシュキッチン開発部でiOSエンジニアをしている谷口恭一です。

デリッシュキッチンでは新規画面のUI実装は主にSwiftUIを使用していて、@State、@Publishedなどを使って状態管理の仕組みを学びながら日々実践しています。 SwiftUIの状態管理に関連する言語機能は便利な機能ですが、使い方を誤ってコンパイルエラーになったときに全く意味がわからないというような状況になることがあります。 そこで、これらの言語仕様を調査してみようと考えました。特に@Bindingがどのように動作しているのかを調査したので、それを解説します。

目次

  1. Bindingとは
  2. PropertyWrapper
  3. DynamicMemberLookup + KeyPath
  4. Bindingの詳細な動作
  5. まとめ

Bindingとは

まずSwiftUIでよく見るBindingについて簡単に説明します。Appleのドキュメントには以下のような例があります。

struct PlayButton: View { 
    @Binding var isPlaying: Bool 
    
    var body: some View { 
        Button(isPlaying ? "Pause" : "Play") { 
            isPlaying.toggle() 
        } 
    } 
}

struct PlayerView: View { 
    var episode: Episode 
    @State private var isPlaying: Bool = false 
    
    var body: some View { 
        VStack { 
            Text(episode.title) 
                .foregroundStyle(isPlaying ? .primary : .secondary)
            PlayButton(isPlaying: $isPlaying) // Pass a binding. 
        } 
    } 
}

/// 以下structとPreviewは自分で定義

struct Episode {
    let title: String
}

#Preview {
    PlayerView(episode: .init(title: "エピソード1"))
}

https://developer.apple.com/documentation/swiftui/binding

このように親ViewであるPlayerViewでは@StateでisPlayingを定義して、この値の状態の変更によって画面を再描画できるようにしています。 子ViewであるPlayButtonではisPlayingを@Bindingで定義することにより、子Viewでの値の変更でも親Viewが更新されるようにしています。

isPlaying = false isPlaying = true

一見すると、@Stateと同様、@Bindingを付与した変数も変更時にUI更新が行われるような気がします。つまり、@Bindingとは、@StateのようなSwiftUIのView再描画用機能の1つであると思われます。 しかし、@Bindingはそのような機能を提供していません。最終的に、その理由を理解することをゴールとして解説していきます。

AppleのドキュメントによるとBindingは以下のように定義されています。

@frozen @propertyWrapper @dynamicMemberLookup 
struct Binding<Value>

ここから読み取れることとして、

  • Binding型は、ある型をラップするための型であるということ
  • frozen: 構造体が将来変更されないということ
  • propertyWrapper、dynamicMemberLookupという機能が付与されているということ

実際に実装を確認すると以下のようになっています。

@frozen @propertyWrapper @dynamicMemberLookup public struct Binding<Value> {
    public var wrappedValue: Value { get nonmutating set }
    
    public var projectedValue: Binding<Value> { get }
    
    public init(projectedValue: Binding<Value>)
    
    public subscript<Subject>(dynamicMember keyPath: WritableKeyPath<Value, Subject>) -> Binding<Subject> { get }
}

まずはこれらにどのような意味があり、背景の言語仕様がどのようなものであるかについて1つ1つ解説していきます。

PropertyWrapper

まず、Binding型に付与されているものとして@propertyWrapperがあります。 swift.orgのドキュメントによるとPropertyWrapperとは、ある型のラッパーを作ったときに、そのプロパティ自体の定義と、値の保存方法の管理を分離する機能だと示されています。

A property wrapper adds a layer of separation between code that manages how a property is stored and the code that defines a property.

https://docs.swift.org/swift-book/documentation/the-swift-programming-language/properties/

つまり、以下の例の場合、Bool型というのがプロパティ自体の定義ですが、これに加えて値の保存方法を指定された方法で管理できますよ、という仕組みのことです。

@Binding var isPlaying: Bool 

PropertyWrapperはwrappedValueという計算プロパティを実装する必要があります。この計算プロパティ内で実装者は値の保存方法、更新時の処理などを追加することができます。 また、任意でprojectedValueという計算プロパティを実装すると、この値には$という記号で簡易アクセスする機能が付与されます。

以下の例では、@Wrapper var a: Int = 1と定義すると、$aと書くとa.projectedValueと同等の意味になり、projectedValue: 1というStringを返すというような動作になります。

@propertyWrapper struct Wrapper<Value> {     
    private var value: Value          
    
    init(wrappedValue: Value) {         
        self.value = wrappedValue     
    }          
    
    var wrappedValue: Value {         
        get { value }         
        set { value = newValue }     
    }          
    
    var projectedValue: String {         
        return "projectedValue: \(value)"     
    } 
}

$の簡易アクセサはSwiftUIでよく見る、

PlayButton(isPlaying: $isPlaying)

このような書き方の正体です。

また、PropertyWrapperには、アンダースコア(_)プレフィックスを使うことでWrapper自体にアクセスできる機能もあります。 例えば、@Wrapper var a: Int = 1と定義した場合:

  • awrappedValueInt型の値そのもの)にアクセス
  • $aprojectedValue(この例ではString)にアクセス
  • _a → Wrapper自体(Wrapper<Int>)にアクセス

SwiftUIでは通常、structで実装されたViewはイニシャライザを省略できるため、_を使う機会は少ないですが、カスタムイニシャライザを実装する際などに使用します。

では、@Bindingというプロパティラッパーが提供する「保存方法」とは何でしょうか? Appleのドキュメントによると、

A property wrapper type that can read and write a value owned by a source of truth.

つまり、「A Source of Truthな値を読み書きできる」という保存方法です。

「A Source of Truth」はエンジニアならお馴染みの「Single Source of Truth」と同じ概念です。 「Single Source of Truth(SSoT)」とは、単一の信頼できる情報源という意味です。 最初の例で言うと、isPlayingという情報は2種類あります

  • PlayerViewのisPlaying
  • PlayButtonのisPlaying

このとき、信頼できる情報源はもちろん親ViewであるPlayerViewのisPlayingです。(親ViewのisPlayingがどのように単一の信頼できる情報源を提供しているかについては後述します。)

PlayButtonで管理しているisPlayingは常に必ず親ViewのisPlayingと同じでなければなりません。そうでないと、同じ画面に2種類の状態が混在して、どちらが正しく再生状態を表しているかわからなくなってしまいます。

そこで、親ViewのPlayerViewの情報源を単一の信頼できる情報源として、それを参照し、いつでも子ViewのisPlayingが親Viewのものと同じ状態であり続ける機能が欲しくなると思います。

しかし、SwiftUIのViewはstructであり、その中で定義されたプロパティは値型です。 よって、子Viewに渡されるisPlayingの実態は、初期化時に作成された親ViewのisPlayingのコピーであり、親Viewのプロパティとは別のメモリ領域に格納されます。 したがって、通常の方法では親Viewのプロパティを直接参照したり値を更新することはできません。

そこでBindingという機能を付与することによって、このような値を読み書きできるようにしています。

Bniding型が読み書きしている親ViewのisPlayingは @Stateを付けることによって、「単一の信頼できる情報源」を提供しています。

@StateのPropertyWrapperの「保存方法の管理」とは何かをAppleのドキュメントから確認すると、

A property wrapper type that can read and write a value managed by SwiftUI. Use state as the single source of truth for a given value type that you store in a view hierarchy. SwiftUI manages the property’s storage. When the value changes, SwiftUI updates the parts of the view hierarchy that depend on the value.

https://developer.apple.com/documentation/swiftui/state

つまり、

  • View階層内でSwiftUIが管理している、Single Source of Truthな値を読み書きできる
  • 値が変更されるとSwiftUIはその値に依存するView階層の箇所を更新する

という保存方法であるということがわかります。

上図のように、View内で@Stateで宣言された値は、単にスタックメモリに保存されるわけではなく、SwiftUIが提供する特別な保存領域で管理されるというわけです。@Stateは読み書きできることに加えて、値の変更時に依存するViewを再計算するという機能も備わっています。 そして、そのような値を子Viewから読み書きするためにBindingを提供する必要があったという背景です。

以下にState型の実装を示しました。ここから、State型のpropertyWrapperのprojectedValueはBinding<Value>であることがわかります。

@frozen @propertyWrapper public struct State<Value> : DynamicProperty {
    public var wrappedValue: Value { get nonmutating set }
    
    public var projectedValue: Binding<Value> { get }
}

よって、

@State private var isPlaying: Bool = false 
...
PlayButton(isPlaying: $isPlaying)

というように@Stateが付与された値(State<Bool>)に$をつけてアクセスしたときは、Binding<Bool>が返されるという挙動になり、PlayButtonの@Binding var isPlaying: Boolで定義されたBinding<Bool>型に値を渡せることに納得がいくかと思います。

このように、State型、Binding型におけるPropertyWrapperは、SwiftUIの階層的にViewを構築していくという設計思想を実現するための最重要機能であることがわかると思います。

DynamicMemberLookup + KeyPath

次に、Bindingの定義に付与されていた@dynamicMemberLookupについて解説します。swift.orgのドキュメントによると、

Apply this attribute to a class, structure, enumeration, or protocol to enable members to be looked up by name at runtime. The type must implement a subscript(dynamicMember:) subscript.

つまり、DynamicMemberLookupとは実行時にメンバーを名前で検索できるようにする機能です。メンバーとは、その型に紐づくプロパティやメソッドなどです。

https://docs.swift.org/swift-book/documentation/the-swift-programming-language/attributes/

@dynamicMemberLookup struct DynamicStruct {
    subscript(dynamicMember member: String) -> String {
        return "\(member) was accessed"
    }
}

let obj = DynamicStruct()
print(obj.someProperty) // "someProperty was accessed"
print(obj.anyName) // "anyName was accessed"

上の例では、objは全くメンバーを持っていませんが、somePropertyanyNameにアクセスすることができます。アクセス時の動作はsubscriptで実装することができます。アクセスできるメンバーは実行時に動的に決定されるので、型安全性は失われます。

検索には任意の型を用いることができ、Bindingではこの型としてKeyPathという型を使用しています。KeyPathとはプロパティ自体を変数として使用できる機能です。以下の例のように、\.titleというように書くと、メンバー自体を変数にできます。

struct Recipe {
    var title: String
}

var recipe1 = Recipe(title: "レシピ1")

// 読み取り
let keyPath: KeyPath<Recipe, String> = \.title

// 書き込み
let writableKeyPath: WritableKeyPath<Recipe, String> = \.title
recipe1[keyPath: writableKeyPath] = "ハンバーグ"
print(recipe1.title) // ハンバーグ

KeyPathをダイナミックメンバーの検索時の型として用いる例は以下のようになります

@dynamicMemberLookup struct Wrapper<T> {
    let value: T

    subscript<U>(dynamicMember keyPath: KeyPath<T, U>) -> U {
        return value[keyPath: keyPath]
    }
}

let wrapper = Wrapper(value: Recipe(title: "レシピ1"))

print(wrapper.title) // "レシピ1"

Wrapperという型はvalueというプロパティしか持っていませんが、titleというプロパティに直接アクセスすることができています。また、KeyPathはアクセスするときに存在するプロパティかどうかをコンパイル時にチェックするので型安全にdynamicMemberLookupを使用することができます。 つまり、最初の例のようにDynamicMemberとしてStringを受け取っていたときと違って、somePropertyなどの存在しないメンバーにアクセスしようとするとコンパイルエラーになります。

ここで、Bindingの定義を再度見てみます。

@frozen @propertyWrapper @dynamicMemberLookup public struct Binding<Value> {
    public var wrappedValue: Value { get nonmutating set }
    
    public var projectedValue: Binding<Value> { get }
    
    public init(projectedValue: Binding<Value>)
    
    public subscript<Subject>(dynamicMember keyPath: WritableKeyPath<Value, Subject>) -> Binding<Subject> { get }
}

ここのsubscript()を見ると、Bindingは保持するValue型の任意のメンバー(Subject型)に直接アクセスすることができて、アクセスした結果、アクセスしたメンバーのBinding(Binding<Subject>)が取得できるということを表しています。

Bindingの詳細な動作

以上の説明によって、Bindingがどんな言語仕様を用いているかがわかりました。ここで、最初のPlayerの例に戻りたいと思います。 以下のコード例では、PlayerViewにおいて、最初の例とデータの持ち方を少し変更しました。 具体的には、isPlayingを直接定義するのではなく、PlayStateという構造体を使って再生状態を管理するようにしました。

struct PlayButton: View {
    @Binding var isPlaying: Bool
    
    // ここでは、前述のPropertyWrapperの`_`プレフィックスを使って、Wrapper自体(`Binding<Bool>`)を直接代入しています。
    //通常、SwiftUIが自動的にinitを生成するため、このコードは省略可能です。
    init(isPlaying: Binding<Bool>) {
        self._isPlaying = isPlaying
    }
    
    var body: some View {
        Button(isPlaying ? "Pause" : "Play") {
            isPlaying.toggle()
        }
    }
}

struct PlayerView: View {
    var episode: Episode
    @State private var playState: PlayState = .init(isPlaying: false)
    
    var body: some View {
        VStack {
            Text(episode.title)
                .foregroundStyle(playState.isPlaying ? .primary : .secondary)
            PlayButton(isPlaying: $playState.isPlaying) // Pass a binding.
        }
    }
}

struct Episode {
    let title: String
}

struct PlayState {
    var isPlaying: Bool
}

#Preview {
    PlayerView(episode: .init(title: "エピソード1"))
}

ここで

PlayButton(isPlaying: $playState.isPlaying)

というようにアクセスしている部分は

PlayButton(isPlaying: playState.projectedValue.isPlaying)

と解釈できます。 playState.projectedValueBinding<PlayState>型です。Binding型には当然isPlayingというメンバーは存在しません。しかし、Binding<Value>のdynamicMemberLookupのsubscriptが返却する型はBinding<Subject>であることから、Binding<PlayState>.isPlayingBinding<Bool>になります。 よって、子ViewのPlayButtonのイニシャライザに渡すことができます。

次にPlayButtonの

Button(isPlaying ? "Pause" : "Play") { 
    isPlaying.toggle() 
} 

この部分で、isPlayingの値の変更は、Bindingが参照する単一の信頼できる情報源である親ViewのisPlayingを変更します。この変数は@Stateで定義されているため、SwiftUIが管理する保存領域が変更され、この値に依存しているUIが更新されるという仕組みになっています。

今回の例では、@Binding自体はこのような特殊な値の保存管理方法をする@Stateな変数を読み書きできるという能力を持っていることがわかります。

逆に@Binding自体はSwiftUIのViewに作用してViewを更新したりする能力は持っていないことに注意する必要があります。@Stateと違って、@BindingはSwiftUIとは一切関係ない機能であると捉えることもできると思います。

まとめ

  • SwiftUIのBinding型はPropertyWrapper、DynamicMemberLookup + KeyPathという言語機能が使われている。
  • PropertyWrapperは値の定義と値の「保存方法の管理」を分離する機能である。
  • DynamicMemberLookupは動的にメンバーにアクセスできる仕組みで、KeyPathをダイナミックメンバーに用いると、型安全にメンバーにアクセスできる。
  • Binding型は「Single Source of Truthな値を読み書きできる」機能が付与されていて、SwiftUIの@Stateなどで定義された値を読み書きするために主に使われている。しかしBinding自体は、SwiftUIのViewに作用する機能ではない。

SwiftUIは非常に記述量が少なく、簡単に階層的な状態管理を実装することができますが、裏側の仕組みとしてものすごく複雑で面白い言語仕様が使われていることに気がつきました。Swiftのコミュニティと言語仕様に感謝ですね。