親と子の間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 とか知る必要がある物あります、どちらも理解しやすいと思います。