[SwiftUI Memo] アプリとしてどのように状態を管理すべきか から始める考察

Updated
Sep 19, 2019 2:00 PM
Created
Sep 16, 2019 7:31 AM
Tags
SwiftUIMemo
Keywords
Date

SwiftUIでは、Fluxの場合、どこにStoreを置いておくかが問題になるか今までは画面の参照が管理していたけど、これからは原則として画面の参照は持てない

今まで自分のプロジェクトではFluxアーキテクチャに相当するStore(ViewModel)をViewControllerに強参照させていたが、そのアプローチは使えなくなる。

使っていたライブラリ

というわけで、SwiftUIに対応するためのいい感じの設計を考える。

まずは、必ず使う必要があるであろう、ObservedObjectの挙動に詳しくなる。

[SwiftUI] To get Binding<T> and mutate from ObservedObject

とりあえず、Stateの塊を持つStoreを作ってみる

final class Store<State>: ObservableObject {
  
  @Atomic var state: State
  
  init(_ initialState: State) {
    self.state = initialState
  }
  
}

@Atomic について
@propertyWrapper
public final class Atomic<T> {
  
  private let lock: NSLock = .init()
  
  public var wrappedValue: T {
    get {
      lock.lock(); defer { lock.unlock() }
      return _value
    }
    set {
      lock.lock(); defer { lock.unlock() }
      _value = newValue
    }
  }
  
  private var _value: T
  
  public init(wrappedValue value: T) {
    self._value = value
  }
}

こんな感じでStateへのBindingが取れるようになる

struct State {
  
  var value1: String = ""
  
}

class Sample {
    
  @ObservedObject var store: Store<State> = .init(.init())
  
  func run() {
    $store.state.value1
  }
  
}

Bindingへの対応は必須ではないけど、あってもいいかなという感じ 多分、本来のFlux等であれば、BindingによってStateが変更されるのは避けたいはず。

変更してみる

$store.state.value1.wrappedValue = "Hello"

Atomic部分が結構な回数呼び出されてるのがきになる

image

とりあえず、基本となるStoreを再考

参考にすべきはやはり、ReduxかVuexかなと。 好きなのはVuexでVergeも参考にした実装になっている。

がしかし、両者ともに Actionなどをオブジェクト定義することがあまり好きじゃない。

Swiftでいう

enum Action {
  case fetchSomething(page: Int)
}

みたいなもの

ログを行う際に、オブジェクト化されている方がトラッキングしやすいというメリットなどがあるが、

定義し、switch-caseで分岐というのが、なんだかなーという感じ。

なので、別のデメリットは出るものの、Action, Mutationの内容をその場で書けるスタイルを考えた。

今の所、スレッドセーフは考慮してない

protocol Mutations {
  associatedtype State
  typealias Mutation = (inout State) -> Void
  
}

protocol Actions {
  associatedtype MutationsType: Mutations
  associatedtype State where MutationsType.State == State
  typealias Action = (DispatchContext<State, MutationsType, Self>) -> Void
}

final class Store<State, MutationsType, ActionsType: Actions> where MutationsType.State == State, ActionsType.State == State, ActionsType.MutationsType == MutationsType {
  
  private(set) var state: State {
    didSet {
      print(state)
    }
  }
  private let mutations: MutationsType
  private let actions: ActionsType
  
  init(state: State, mutations: MutationsType, actions: ActionsType) {
    self.state = state
    self.mutations = mutations
    self.actions = actions
  }
         
  func dispatch(_ makeAction: (ActionsType) -> ActionsType.Action) {
    let context = DispatchContext<State, MutationsType, ActionsType>.init(store: self)
    makeAction(actions)(context)
  }
  
  func commit(_ makeMutation: (MutationsType) -> MutationsType.Mutation) {
    makeMutation(mutations)(&state)
  }
}

final class DispatchContext<State, MutationsType, ActionsType: Actions> where MutationsType.State == State, ActionsType.State == State, ActionsType.MutationsType == MutationsType {
  
  private let store: Store<State, MutationsType, ActionsType>
  
  init(store: Store<State, MutationsType, ActionsType>) {
    self.store = store
  }
  
  func dispatch(_ makeAction: (ActionsType) -> ActionsType.Action) {
    store.dispatch(makeAction)
  }
  
  func commit(_ makeMutation: (MutationsType) -> MutationsType.Mutation) {
    store.commit(makeMutation)
  }
}

次のような感じでStoreを初期化

let dep = Dependency() // API DBなど

let store = Store(
  state: AppState(),
  mutations: AppMutations(),
  actions: AppActions(dep: dep)
)

store.dispatch { $0.fetch() }

store.commit { $0.setName("I'm Muukii") }

StateとMutationとActionの定義は

struct AppState {
  var name: String = ""
}

struct AppActions: Actions {
  typealias MutationsType = AppMutations
  typealias State = AppState
  
  private let dep: Dependency
  
  init(dep: Dependency) {
    self.dep = dep
  }
    
  func fetch() -> Action {
    return { context in
      DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
        context.commit { $0.setName("Delayed Name") }
      }
    }
  }
  
}

struct AppMutations: Mutations {
  typealias State = AppState
    
  func setName(_ name: String) -> Mutation {
    return {
      $0.name = name
    }
  }
}

StateがMutation, Actionの型を持っても良いと思ったが、組み合わせられてもいいかなと思って、別パラメータとしてStoreに渡す。

しかし、ActionはMutationのタイプを限定しているので、これら二つはセットでいい気もする。

別の型のメリットは commit(), dispatch()でコード補完を分けられること。

とりあえずgistにしておいた

Store自体の実装はこのぐらいにしておいて、本来の目的である、Storeのモジュール化を考える。

今までは、おおよそViewController - ViewModel(Store) という関係で、ViewControllerがViewModelをretainする形だった。

しかし、SwiftUIの画面はView protocolを使った設計図による構築であり、ViewControllerのような画面上に存在する実体にはアクセスできない。

つまり、struct SomeView: View {} は揮発性のもので、すぐに無くなるかもしれないし、大量に作られ続けるかもしれない。

それをトリガーにViewModelやStoreを作るわけには行かない。と

なので、仮にDomainや画面ごとにStoreを作るのであれば、それらを管理するが必要。

Sep 17, 2019

MutationとActionをまとめたProtocolにしたい

Reducerと呼んでもいいかもしれない

Sep 18, 2019

APIで取得したデータはStoreに直接っていうより、もっと、保存を対象としたDBに入れることを前提に考えたいんだよね

DB == 永続化とは限らず

BackendレイヤーはFlux reduxとか関係ない状態が望ましく、ViewとBackendを繋ぐため と考えたい

External Referencesは誰が持つ?

WWDC19で発表されたSwiftUIとアプリが持つデータ(External)の連携について

発表の中ではおもむろに ~StoreやManagedOjbect DatModelなどのObsevableなクラスインスタンスが登場していたが、コードはstruct Viewの中身のみで、クラスインスタンスは外から渡されている状態。外から渡さないといけないことは理解できるが、問題は誰がオーナーなのか。

大規模なアプリケーションを作っていく上で、singletonオブジェクトのような管理方法は最低限に抑えながら依存性を少なく保つことを考え続けなければならない。

最低限のstatic varなインスタンスから必要な依存物を渡していくことが大切。 そのために EnviromentObjectというPropertyWrapperが用意されているとも言えるはず。 話は逸れるけど、EnviromentObjectはかなりダイナミックな渡し方で、参照が見つからなければクラッシュする。ここはもう少しどうにかならないものかと思う。 今の僕なら怖くて全てinit引数で参照を渡していきたいなーと考えてしまう。

Androidのケースだと、Daggerでインスタンス管理が行われているという手法もあるようで、それはそれで解決している様子。

UIViewControllerのツリーがその役目を負っていたようなもので、そうでなくなった場合どうなるのかを考えてみたい。

良い方法が見つかればUIKitベースのこれまで通りの開発にも持ち込めるはず。

Other Notes

📖
Notes