0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

[TCA] Stack-based navigation のStoreはメモリリークする

Posted at

わかったこと

  • Stack-based navigationを使った画面遷移では子画面から親画面へpopしても子Storeが残ったまま、消えない
  • 大きなデータ/大量データを取り扱う場合、現状、Stack-based navigationは採用しない方が良さそう。採用する場合は、無視できるサイズの値しかStateには保持しない、といった設計が必要

※間違い/回避方法をご指摘いただけると助かります🙇

関連してそうなTCAのissue

検証環境

Xcode 15.4 / TCA 1.11.2 / iOS 17.5

検証結果

  • Stateに格納したオブジェクトは StackState からそのStateを削除しても deinit は呼ばれない

    • Debug Memory GraphからもStoreが残っていることを確認できる。push/popの繰り返しでStoreが溜まっていく
      xxx.png
  • 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
  }
}

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?