TCA で Recursive Navigation 実装してみた
はじめに
TCA (The Composable Architecture)
本記事では、Recursive Navigation (再帰的ナビゲーション)は
View A -> View B -> View A -> View B -> …
みたいに延々とナビゲートしていくパターンを指します。
これをどうやって TCA で実装するのかについて解説したいと思います。🙇♂️
簡単なところから
1
init(@ViewBuilder destination: () -> Destination, @ViewBuilder label: () -> Label)
この NavigationLink
イニシャライザを使えば、isActive
や selection
を指定せずに、SwiftUI に任せることで簡単に作れます。僕のプロジェクトでも TCA が導入された前は使っていました。
しかし、その簡単さゆえに、できることも限られます。ナビゲーション情報がないから、ユニットテストや DeepLink はまず無理でしょう。🙅♂️
とりあえずの試み
ではナビゲーション情報を state に保存してみましょう。
TCA で最も汎用的な実装ですが、
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
struct AaaState: Equatable {
enum Route {
case bbb
}
@BindableState var route: Route?
var bbbState = BbbState()
}
enum AaaAction: BindableAction {
case binding(BindingAction<AaaState>)
case setNavigation(AaaState.Route?)
case bbb(BbbAction)
}
struct AaaEnvironment {}
let aaaReducer = Reducer<AaaState, AaaAction, AaaEnvironment>.combine(
.init { state, action, _ in
switch action {
case .binding:
return .none
case .setNavigation(let route):
state.route = route
return .none
case .bbb:
return .none
}
}
.binding(),
bbbReducer.pullback(
state: \.bbbState,
action: /AaaAction.bbb,
environment: { _ in .init() }
)
)
// BbbState, BbbAction, BbbEnvironment, bbbReducer は空っぽです
これで View A の方で viewStore.send(.setNavigation(.bbb))
を使えば View B にナビゲートされて、View A -> View B
片方のルートは確保完了だと思います。
それで BbbState
、BbbAction
や bbbReducer
にも Aaa を入れてみますと、
- ❌ Circular reference
- ❌ Recursive enum ‘BbbAction’ is not marked ‘indirect’
- ❌ Value type ‘BbbState’ cannot have a stored property that recursively contains it
コンパイラにめっちゃ怒られました。ですよね…入れちゃまずいですよね…
BbbAction
に indirect
マークを入れれば二つ目のエラーは解消されるんですけど、他の二つはやはりこの構造だと無理でしょう。
辿り着いたかも
ググって最初に見つけたのは TCA 公式の CaseStudies でした。
なるほど、最初からお互いを含める構造ではなければ大丈夫ですね。
では BbbState
と BbbAction
を少し変更してみますと、
1
2
3
4
5
6
7
8
9
struct BbbState: Equatable {
// ...
var aaaStates = IdentifiedArrayOf<AaaState>()
}
enum BbbAction: BindableAction {
// ...
indirect case aaa(id: String, action: AaaAction)
}
AaaState
の方も Identifiable
に conform して、
1
2
3
4
5
6
7
struct AaaState: Equatable, Identifiable {
// ...
let id: String
init(id: String = UUID().uuidString) {
self.id = id
}
}
ひとまずコンパイラ鎮めることはできました。
それで bbbReducer
にも aaaReducer
入れますと、
1
2
3
4
5
6
7
8
9
10
11
let bbbReducer = Reducer<BbbState, BbbAction, BbbEnvironment>.combine(
.init { state, action, _ in
// ...
}
.binding(),
aaaReducer.forEach(
state: \BbbState.aaaStates,
action: /BbbAction.aaa(id:action:),
environment: { _ in AaaEnvironment() }
)
)
ひとまずコンパイラは鎮め、てない?
- ❌ Circular reference
またお前か…🤨
reducer たちがお互いを含めていて、また無限にループしますのでダメです。
では、reducer も Optional 型にしてみましょうか。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
struct BbbState: Equatable {
// ...
// Reducer は Equatable ではないので
static func == (lhs: BbbState, rhs: BbbState) -> Bool {
lhs.route == rhs.route
&& lhs.aaaStates == rhs.aaaStates
&& (lhs.aaaReducer == nil) == (rhs.aaaReducer == nil)
}
var aaaReducer: Reducer<AaaState, AaaAction, AaaEnvironment>?
}
let bbbReducer = Reducer<BbbState, BbbAction, BbbEnvironment> { state, action, environment in
switch action {
// ...
case .aaa:
guard let aaaReducer = state.aaaReducer else { return .none }
return aaaReducer.forEach(
state: \BbbState.aaaStates,
action: /BbbAction.aaa(id:action:),
environment: { (environment: BbbEnvironment) in .init() }
)
.run(&state, action, environment)
}
}
.binding()
こうやって reducer が何か値がある場合だけ action を処理できるようにすれば、循環参照エラーは解消されます。
値を与えるための action も入れてみましょう。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
enum BbbAction: BindableAction {
// ...
case initializeRecursiveStates(String)
}
// 直接書くとやはりダメらしいです
// この workaround で特に問題無さそう
var anyAaaReducer: Reducer<AaaState, AaaAction, AaaEnvironment> {
aaaReducer
}
let bbbReducer = Reducer<BbbState, BbbAction, BbbEnvironment> { state, action, environment in
switch action {
// ...
case .initializeRecursiveStates(let identifier):
state.aaaReducer = anyAaaReducer
state.aaaStates = .init(uniqueElements: [.init(id: identifier)])
return .none
}
}
これで setNavigation
を使う前に initializeRecursiveStates
を使えば、View B -> View A
のナビゲーションも可能になります。実装完了です。🎉
クラッシュ対応策
本来上記の「実装完了です」のところで終わりましたが、そのあと実機でこの実装のあるところにいくとクラッシュすることがわかりましたので、対応策についても解説したいと思います。
クラッシュの原因は構造の複雑さによるスタックオーバーフローだと思います。下記リンクのように、スタックに負担の大きい state をヒープに保存させれば、クラッシュはある程度抑えられるでしょう。 Potential for stack overflow with large app states
まずはプロパティをヒープに保存させるためのラッパーを用意しましょう。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
private final class Reference<T: Equatable>: Equatable {
var value: T
init(_ value: T) {
self.value = value
}
static func == (lhs: Reference<T>, rhs: Reference<T>) -> Bool {
lhs.value == rhs.value
}
}
@propertyWrapper struct Heap<T: Equatable>: Equatable {
private var reference: Reference<T>
init(_ value: T) {
reference = .init(value)
}
var wrappedValue: T {
get { reference.value }
set {
if !isKnownUniquelyReferenced(&reference) {
reference = .init(newValue)
return
}
reference.value = newValue
}
}
var projectedValue: Heap<T> {
self
}
}
ラッパーの使い方は下記の感じです。
1
2
3
4
5
6
7
8
9
10
struct AaaState: Equatable {
// ...
init() {
_bbbState = .init(nil)
_cccState = .init(.init())
}
@Heap var bbbState: BbbState?
@Heap var cccState: CccState!
}
循環参照にならないように必要に応じて一般な Optional 型(値の代入を忘れずに)か暗黙的アンラップ型を使ってください。
Identifiable
の conform と IdentifiedArray
はもう不要です。
次は recurse-navigation-tco のようにして、Optional 型の reducer プロパティも捨てて == メソッドを自分で書かなくて済むようにしましょう。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
static func recurse(_ reducer: @escaping (Reducer) -> Reducer) -> Reducer {
var `self`: Reducer!
self = Reducer { state, action, environment in
reducer(self).run(&state, action, environment)
}
return self
}
let aaaReducer = Reducer<AaaState, AaaAction, AaaEnvironment>.recurse { (self) in
.combine(
.init { state, action, environment in
switch action {
// ...
case .bbb(.aaa(let recursiveAction)):
guard state.bbbState != nil else { return .none }
return self.run(&state.bbbState!.aaaState, recursiveAction, environment)
.map({ AaaAction.bbb(.aaa($0)) })
case .bbb:
return .none
}
}
.binding(),
bbbReducer.optional().pullback(
state: \.bbbState,
action: /AaaAction.bbb,
environment: { _ in .init() }
)
)
}
要は次回ループの自分の action の reduce 方法を予め定義して、reducer たちがお互いを含めないようにするんですね。🤔
リンク先の実装方法では bbbReducer
の方も recursiveAction
の対応も必要ですけど、使ってみたところなくても問題なさそうです。
ただし、ナビゲーションの順番を間違えると action が全く処理してもらえないらしいです。(bbbReducer
で上記の対応をしても発生するらしい)
全く別物になりましたが、これでクラッシュ対応策の実装も完了です。🎉
テストしたところ、ループ 15 回以内は大丈夫そうです。
終わりに
EhPanda というプロジェクトの開発・保守をやっています。
本記事の実装も含まれていますので、興味がある方はぜひご覧になってください。
(
DetailView
が View A でCommentsView
やDetailSearchView
が View B です)
最後までお読みいただき、ありがとうございました!