📌

PresentationStateとCoW

2023/10/16に公開

はじめに

これまでにActionがsendされてから実際に状態に反映されるまでを見てきました。今回からはナビゲーション周りを見ていきます。

単純な例としてalertを取り上げてalertが表示されて消えるまでのTCAの内部処理を理解していきます。

アラートなどのナビゲーションはPresentationState、PresentationAction、_PresentationReducerの3つで成り立っており、これを順番に見ていきます。今回は最初にPresentationStateの役割を確認し、TCAにおいてスタックメモリの観点からに効率的かつ安全なアプリの状態管理に寄与している点を見ます。

PresentationState

概要

PresentationStateはナビゲーションにおいて、親から子に対するドメインの拡張を効率的に可能にするものです。TCAのナビゲーションにはsheet、alert、fullScreenCoverなど幅広いものが含まれれます。

利用方法を確認すると、親から子に対して遷移する場合は@PresentationStateとPresentationActionを使用して状態/アクションを定義します。

struct Parent: ReducerProtocol {
  struct State {
    @PresentationState var child: Child.State?}
  enum Action {
    case child(PresentationAction<Child.Action>)}}

ifLetリデューサーを使用して、子の状態がアクティブなときにReducerを利用可能なようにします。

var body: some ReducerProtocolOf<Self> {
  Reduce {}
  .ifLet(\.$child, action: /Action.child) {
    Child()
  }
}

内部的にこのifLetは_PresentationReducerを呼び出しており、ナビゲーションは、PresentationState、PresentationAction、_PresentationReducerが1セットになって機能します。

最後にView側でTCAでオーバーロードされたsheetなどの関数にstoreを渡します。

struct ParentView: View {
  let store: StoreOf<Parent>

  var body: some View {
    List {}
    .sheet(
      store: self.store.scope(state: \.$child, action: Parent.Action.child)
    ) { store in
      ChildView(store: store)
    }
  }
}

子の状態管理

TCAのナビゲーションにおいて、子の管理はPresentationStateではなく、次のように単にオプショナルなStateで説明されていました。

  func popover<ChildState: Identifiable, ChildAction>(
    store: Store<
      ChildState?, PresentationAction<ChildAction>
    >,
    @ViewBuilder child: @escaping
      (Store<ChildState, ChildAction>) -> some View
  ) -> some View {

https://www.pointfree.co/collections/composable-architecture/navigation/ep226-composable-navigation-unification

PresentationStateが存在しなくても、オプショナルなStateによってalertやsheetなどさまざまなナビゲーションを統合するAPIをTCAは構築できていました。

それではなぜPresentationStateが必要になったのでしょうか?

PresentationStateの実装

少し内部実装を見てみます。PresentationStateの実装を見ると、冒頭からCoWの機能が入っているのが分かります。

@propertyWrapper
public struct PresentationState<State> {
  private class Storage: @unchecked Sendable {
    var state: State?
    init(state: State?) {
      self.state = state
    }
  }

  private var storage: Storage
  @usableFromInline var isPresented = false

  public init(wrappedValue: State?) {
    self.storage = Storage(state: wrappedValue)
  }

  public var wrappedValue: State? {
    _read { yield self.storage.state }
    _modify {
      if !isKnownUniquelyReferenced(&self.storage) {
        self.storage = Storage(state: self.storage.state)
      }
      yield &self.storage.state
    }
  }

  ...
}

TCAでの状態管理はstructで行われており、値型であるstructのフィールドの肥大化はランタイム時にスタックオーバーフロー引き起こす可能性があり、これらの機能はその対策として用意されています。

スタックメモリ

classなどの参照型がヒープメモリで管理されるのと異なり、structはスタックメモリで管理されます。スタックメモリは非常に制限されていますが、その代わりヒープに格納されたオブジェクトに比べて非常に高速にアクセスできます。

例えば私の環境(MacBook Air M1, OS13.4.1)だとメインスレッドのスタックメモリは8MBで、それ以外のスレッドだともっと小さくなります。iPhoneなどの端末ではさらに小さく1MBほどとされています。

このようにスタックメモリは非常に小さいので、簡単な方法でスタックメモリを破壊することができます。

スタックオーバーフローの再現

テストを用意します。

final class alert_tcaTests: XCTestCase {

    func testExample() throws {
	...

巨大なフィールドを作るために適当な構造体を用意します。

    struct Ten<A: Equatable>: Equatable {
      var a, b, c, d, e, f, g, h, i, j: A
    }

スタックメモリを落とすにはもう少し大きくする必要があるのでもう少し巨大にします。

    typealias HugeTen<T: Equatable> = Ten<Ten<Ten<Ten<Ten<T>>>>>

Thread.main.stackSizeでスタックのサイズを確認すると524288byteですが、hugeStructのメモリサイズを確認しながら落ちるタイミングを探すと大体想定通り8MBで落ちます。

final class alert_tcaTests: XCTestCase { 
    ...
    func testExample() throws {
        print(Thread.main.stackSize)
        let hugeStruct0: HugeTen<String>? = nil
        let hugeStruct1: HugeTen<String>? = nil
        let hugeStruct2: HugeTen<String>? = nil
        let hugeStruct3: HugeTen<String>? = nil
        let hugeStruct4: HugeTen<String>? = nil
        let hugeStruct5: HugeTen<String>? = nil
        print("size of hugeStruct:", MemoryLayout.size(ofValue: hugeStruct0))
        let copy = hugeStruct5
    }
}

セカンダリースレッドはさらに小さく、こちらは0.5MBを超えるタイミングで落ちます。

PresentationStateのCoWの仕組み

先ほどの事例は大袈裟ですが、アプリの機能・要求が増えてそこそこの大きさのフィールドが作られ、さらにそこに深いネストが形成されると、データが各層を通過する際にコピーが発生するようになりスタックを破壊する可能性が出てきます。

これらの問題は初期のTCAでも遭遇していたようで、以下のスレッドでmbrandonwがやり取りをしているのが分かります。

https://forums.swift.org/t/large-structs-and-stack-overflow/44820/8

同スレッドでは、冒頭でスタックにあるデータをヒープに逃すために配列を使用していましたが、途中でCoWの機能を持ったプロパティラッパーが提案されました。プロパティラッパーはプロパティの格納方法を管理するコードを再利用することができるため、アプリ内で頻繁に使用される可能性があるこの機能を実装するのに最適です。

@propertyWrapper
struct CopyOnWriteBox<Value> {

    private var ref: Ref<Value>

    init(wrappedValue: Value) {
        self.ref = Ref(wrappedValue)
    }

    var wrappedValue: Value {
        get {
            ref.value
        }
        set {
            if isKnownUniquelyReferenced(&ref) {
                ref.value = newValue
            } else {
                ref = Ref(newValue)
            }
        }
    }

    private final class Ref<T> {
        var value: T

        init(_ value: T) {
            self.value = value
        }
    }
}

extension CopyOnWriteBox: Equatable where Value: Equatable {
    static func == (lhs: Self<Value>, rhs: Self<Value>) -> Bool {
        lhs.ref.value == rhs.ref.value
    }
}

CoWを実装するには実際に書き込みが行われる際にコピーを行う必要があります。従ってこのプロパティラッパーの値を管理する実装は、セッターを介して値を設定する前に isKnownUniquelyReferenced をチェックして参照カウントが1かどうかをチェックして、複数から参照されていれば値をコピーします。

https://developer.apple.com/documentation/swift/isknownuniquelyreferenced(_:)-98zpp

PresentationStateの実装はこの実装を大きく踏襲していて(get/setがread/modifiyになっているなどの違いはあるが)、これにより子ドメインの状態はヒープで管理され、ナビゲーション内をデータが通過してもコピーは省略されるため、効率的かつ安全に状態管理を行えるようになってます。

まとめ

TCAのナビゲーションをまずPresetationStateから確認し始めました。

structによって状態管理を行うTCAでは、状態の巨大化/ナビゲーションのネストが深行することによってスタックオーバーフローが発生する可能性が高まります。

PresentationStateを利用することで子ドメインの状態はヒープメモリに移動して、スタックメモリの枯渇を防ぎます。これによってTCAでは安全にナビゲーションを実行できることが分かりました。

参考資料

https://forums.swift.org/t/increase-size-of-stack/14493/19

https://forums.swift.org/t/large-structs-and-stack-overflow/44820/8

https://www.youtube.com/watch?v=iLDldae64xE

Discussion