親と子の間Stateを共有する
長い間TCAを使っている方は親と子の間Stateを共有したいシチュエーションに遭遇したと思います。自分の方でも少し整理したので、この記事書くことにしました。間違いがありましたら遠慮なくコメントで指摘お願いします!
親と子の間でStateを共有
これは一番シンプルな使い方ですね:
@Reducer
struct ParentFeature {
@ObservableState
struct State {
@Shared var count: Int // 1
var child: ChildFeature.State
// Other properties
}
// ...
}
@Reducer
struct ChildFeature {
@ObservableState
struct State {
@Shared var count: Int // 2
// Other properties
}
// ...
}
case .presentButtonTapped:
state.child = ChildFeature.State(count: state.$count) // 3
// ...
- ParentFeatureの中で@Sharedのcountを定義します
- ChildFeatureも同じcountを定義します
- ParentFeatureが持つ子のFeature(この場合はchild)を作成する時、$countを使ってParentFeatureのcountの参照をChildFeatureに渡す事ができます。
ChildFeatureのcountを変更するとParentFeatureのcountも変更され、もちろんParentFeatureのcountを変更するとChildFeatureのcountも変更されます(当たり前ですね、参照渡してますので)
アプリの何処でもstateを取れるにしたい(永続性あり)
上の例は参照を子に渡していますが、場合によっては渡さなくてもstateをシェアした、下の例をみましょう:
@Reducer
struct ChildFeature {
@ObservableState
struct State {
@Shared(.inMemory("count")) var count = 0 // 1
// Other properties
}
// ...
}
case .onAppear:
return .publisher { // 2
state.$count.publisher
.map(Action.countUpdated)
}
case .countUpdated(let count):
// Do something with count
return .none
case .onAppear:
return .publisher {
state.$count.publisher
.map(Action.countUpdated)
}
case .countUpdated(let count):
state.count = count + 1 // 3 ❌
return .none
- default値を設定する必要があり、最初の値とします。ここの例は
.inMemory
使っていますが、TCAは.appStorage
と.fileStorage
複数のパターン用意されてます。.appStorage
はUserDefault
内のデータを取り、.fileStorage
は指定したファイル内のデータを取ります。色々用意されてますね - ChildFeatureがcountの変更を受けたい場合、
.publisher
を使ってcount
を観測でき、そして呼び起こすAction
も指定できます。.map
なのでType
が同じであれば指定できる - この場合は無限ループが発生するので要注意ですね(Feature内
.publisher
と@Shared
は同時に入れるのをやめましょう)
これで参照をわざわざ渡さなくても、stateをシェアできる
@Shared stateの初期値
まず@Sharedの定義を見てみると:
/// A property wrapper type that shares a value with multiple parts of an application.
///
/// See the <doc:SharingState> article for more detailed information on how to use this property
/// wrapper.
@dynamicMemberLookup
@propertyWrapper
public struct Shared<Value: Sendable>: Sendable {
private let reference: any Reference
private let keyPath: _SendableAnyKeyPath
init(reference: any Reference, keyPath: _SendableAnyKeyPath) {
self.reference = reference
self.keyPath = keyPath
}
...
protocol Reference<Value>: AnyObject, CustomStringConvertible, Sendable {
associatedtype Value: Sendable
var value: Value { get set }
func access()
func withMutation<T>(_ mutation: () throws -> T) rethrows -> T
#if canImport(Combine)
var publisher: AnyPublisher<Value, Never> { get }
#endif
}
Shared
内はreference: Reference
で定義されてますね、そしてReference
の中にvalue
が見つかりました。Reference
はAnyObject
ですね!
となるとShared
の内部は実際Reference
の参照を持っていて、Reference
の中に本当のvalue
が存在しています。
@Shared state初期化する
通常Stateの初期化メソッドは我々で定義する必要はなく、自動生成されたinit
を使えばいいのですが、@Shared含めたStateには自動生成されたinit
では不十分です。その原因は@Sharedのstateは実際value typeではなく、参照型です。親が持ってた子供の状態は、親からあげたのか?それとも子供自身が作れた状態を分けたいですね。なのでcustom initで明白にする必要があります。
valueの根本は親からの場合
public struct State {
@Shared public var count: Int
// other fields
public init(count: Shared<Int>, /* other fields */) { // 1
self._count = count
// other assignments
}
}
- この場合custom initは親の参照で初期化します
valueの初期値は自分からの場合
public struct State {
@Shared public var count: Int
// other fields
public init(count: Int, /* other fields */) { // 1
self._count = Shared(count) // 2
// other assignments
}
}
- この場合custom initは本当のvalue(Int)で初期化します
- そして初期化メソッド内で
Shared
を作成します
永続性ありの@Shared初期化
public struct State {
@Shared public var count: Int // 2
// other fields
public init(count: Int, /* other fields */) { // 1
self._count = Shared(wrappedValue: count, .appStorage("count")) // 3
// other assignments
}
}
- 永続性ありの@Sharedのcustom initも本当のValue(Int)を渡します。
- 既にお気づきになられたと思いますが、ここの
.appStorage
は省略可能です、永続性設定は初期化メソッドで指定しましたので(参照だからですかね ) - custom init内で永続性ありの@Shared初期化する場合は
wrappedValue:
がある初期化メソッドを使用する必要があります。実際ここのcountは@autoclosure () -> Int
にする方がわかりやすくなると思います。以下の例を考えましょう:-
.appStorage
内は実際count
がありました(例えば前回アプリ起動した時値が保存された) - 当然、初期化メソッドが渡された
count
を使わずに、.appStorage
内の値を使ってShared
を作成するのが正しい
-
なので以下の書き方もできます:
public struct State {
@Shared public var count: Int
// other fields
public init(count: @autoclosure () -> Int, /* other fields */) {
self._count = Shared(wrappedValue: count(), .appStorage("count"))
// other assignments
}
}
まとめ
Shared state(永続性ありのパターン)を使う時の手順:
- Feature内共有したいstateを
@Shared
を付ける - Stateのcustom initを追加(適切なcustom initを作りましょう)
- stateを取りたいFeature内に
.publisher
を追加して値を取る
これくらい分かれば大体問題無いと思いますが、他にも@SharedReader
とか、withLock
とか知る必要がある物あります、どちらも理解しやすいと思います。