わかったこと
- Stack-based navigationを使った画面遷移では子画面から親画面へpopしても子Storeが残ったまま、消えない
- 大きなデータ/大量データを取り扱う場合、現状、Stack-based navigationは採用しない方が良さそう。採用する場合は、無視できるサイズの値しかStateには保持しない、といった設計が必要
※間違い/回避方法をご指摘いただけると助かります🙇
関連してそうなTCAのissue
- StackState/StackReducer related leak
-
Store scoping issue with ForEach
→ このissueと原因が同じならリークを避けたい場合はTCA1.5未満にするかTCA2.0を待つかということになる
検証環境
Xcode 15.4 / TCA 1.11.2 / iOS 17.5
検証結果
-
Stateに格納したオブジェクトは
StackState
からそのStateを削除しても deinit は呼ばれない -
Tree-based navigationの場合も子画面から戻った後、子Storeは残ったまま。ただし、再度、子画面を表示するタイミング(Stateに子Stateを設定するタイミングで)、前回のStateのオブジェクトは解放される。
検証コード
import ComposableArchitecture
import SwiftUI
@Reducer
struct StateLeakDemo {
@ObservableState
struct State: Equatable {
// for Stack-based navigation
var path = StackState<Path.State>()
// for Tree-based navigation
@Presents var destination: Destination.State?
}
enum Action {
// for Stack-based navigation
case path(StackActionOf<Path>)
// for Tree-based navigation
case destination(PresentationAction<Destination.Action>)
case tapStackBasedNavigation
case tapTreeBasedNavigation
}
// for Stack-based navigation
@Reducer(state: .equatable)
enum Path {
case screen1(Screen1)
case screen2(Screen2)
}
// for Tree-based navigation
@Reducer(state: .equatable)
enum Destination {
case screen3(Screen3)
}
var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
case .tapStackBasedNavigation:
state.path.append(.screen1(.init()))
return .none
case let .path(action):
switch action {
case .element(id: _, action: .screen1(.tapScreen2Button)):
state.path.append(.screen2(.init()))
return .none
case .element(id: _, action: .screen1(.tapBackButton)),
.element(id: _, action: .screen2(.tapBackButton)):
state.path.removeLast()
return .none
default:
return .none
}
case .tapTreeBasedNavigation:
state.destination = .screen3(.init())
return .none
case .destination(.presented(.screen3(.tapBackButton))):
state.destination = nil
return .none
case .destination:
return .none
}
}
.forEach(\.path, action: \.path)
.ifLet(\.$destination, action: \.destination)
}
}
struct StateLeakDemoView: View {
@Bindable var store: StoreOf<StateLeakDemo>
var body: some View {
NavigationStack(path: $store.scope(state: \.path, action: \.path)) {
Form {
Section {
Button(action: { store.send(.tapStackBasedNavigation )}, label: { Text("Stack-based navigation") })
Button(action: { store.send(.tapTreeBasedNavigation )}, label: { Text("Tree-based navigation") })
}
}
.navigationTitle("Root")
.navigationDestination(
item: $store.scope(state: \.destination?.screen3, action: \.destination.screen3)
) { store in
Screen3View(store: store)
}
} destination: { store in
switch store.case {
case let .screen1(store):
Screen1View(store: store)
case let .screen2(store):
Screen2View(store: store)
}
}
}
}
// MARK: - subscreen 1 (Stack-based用)
@Reducer
struct Screen1 {
@ObservableState
struct State: Equatable {
var hoge: Hoge = .init()
}
enum Action {
case tapBackButton
case tapScreen2Button
}
var body: some Reducer<State, Action> {
Reduce { _, _ in
return .none
}
}
}
struct Screen1View: View {
let store: StoreOf<Screen1>
var body: some View {
Form {
Button("Back") {
store.send(.tapBackButton)
}
Button("Screen 2") {
store.send(.tapScreen2Button)
}
}
.navigationTitle("Screen 1")
}
}
// MARK: - subscreen 2 (Stack-based用)
@Reducer
struct Screen2 {
@ObservableState
struct State: Equatable {
var hoge: Hoge = .init()
}
enum Action {
case tapBackButton
}
var body: some Reducer<State, Action> {
Reduce { _, _ in
return .none
}
}
}
struct Screen2View: View {
let store: StoreOf<Screen2>
var body: some View {
Form {
Button("Back") {
store.send(.tapBackButton)
}
}
.navigationTitle("Screen 2")
}
}
// MARK: - subscreen 3 (Tree-based用)
@Reducer
struct Screen3 {
@ObservableState
struct State: Equatable {
var hoge: Hoge = .init()
}
enum Action {
case tapBackButton
}
var body: some Reducer<State, Action> {
Reduce { _, _ in
return .none
}
}
}
struct Screen3View: View {
let store: StoreOf<Screen3>
var body: some View {
Form {
Button("Back") {
store.send(.tapBackButton)
}
}
.navigationTitle("Screen 3")
}
}
// MARK: - Stateに保持するオブジェクト
class Hoge: Equatable {
init() {
self.uuid = UUID().uuidString
print("Hoge init uuid[\(self.uuid)]")
}
deinit {
print("Hoge deinit uuid[\(self.uuid)]")
}
private var uuid: String
static func == (lhs: Hoge, rhs: Hoge) -> Bool {
lhs.uuid == rhs.uuid
}
}