注意: この記事はv0.39.0以降に対応していません。すでに必要なくなったテクニックです。
はじめに
この記事では、iOSアプリ開発に使われるThe Composable Architecture(略してTCA)のサンプルコードにおいてさまざまな型や関数がグローバルな定義をしているのを、自分で使うときにはどうやって利用しているかということを書いてみます。基本的にTCAを使う人の自由にすればいいことなんですが、これをグローバルでなくすることでまとまりが出ますし、まとめかたとしてcaseなしのenumによりnamespace的にすることで型推論が有効になったり、IDEが補間してくれることで楽できますという話です。
最初に結論
私はこういうふうな定義を使ってCoreを定義しています。
import ComposableArchitecture
enum MyAwesomeFeatureCore { // MyAwesomeFeatureStoreでもいい
struct State: Equatable {
}
enum Action: Equatable {
case onAppear
}
struct Environment {
}
enum Error: Swift.Error {
}
static let reducer = Reducer<State, Action, Environment> { state, action, environment in
switch action {
case .onAppear
return .none
}
}
}
MyAwesomeFeature
というのは適当です。これが具体的にLoginする機能ならLoginCoreやLoginStoreです。
具体例
ログインサンプルから
具体例としてTCAのログインサンプルのStoreから swift-composable-architecture/Examples/TicTacToe/Sources/Core/LoginCore.swift を省略しつつ引用します。
...
public struct LoginState: Equatable {
...
}
public enum LoginAction: Equatable {
...
}
public struct LoginEnvironment {
...
}
public let loginReducer = ...
以上がLoginCoreの型の定義について引用したものです。Coreって何?というのはいまいち明かされていませんがState, Action, Environment, Reducerが1つのファイルにあるのでだいたいそういうものなんでしょう。
次にこれの利用例としてView側 https://github.com/pointfreeco/swift-composable-architecture/blob/0.16.0/Examples/TicTacToe/Sources/Views-SwiftUI/LoginSwiftView.swiftも省略しつつ引用します。
public struct LoginView: View {
...
let store: Store<LoginState, LoginAction>
public init(store: Store<LoginState, LoginAction>) {
self.store = store
}
...
}
struct LoginView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
LoginView(
store: Store(
initialState: LoginState(),
reducer: loginReducer,
environment: LoginEnvironment(
...
)
)
)
}
}
}
だいたいの雰囲気わかったでしょうか、これは小さなコードベースではいいのですがアプリが巨大になってくると大変だな〜と私は感じていて自分がやってる方法を提示しようと思っているわけです。
私が大変だな〜と感じるのは
- PreviewProvider実装時のStoreの引数指定時にloginReducerとか自分でタイピングする
- これログインなんでわかりやすいんですが、実際はCoreを分割してると名前がにかよってくると迷いが出る
- そもそもloginReducerはLoginStateとLoginEnvironmentに依存していて入れ替える必要もないのだからまとめればいい
- これログインなんでわかりやすいんですが、実際はCoreを分割してると名前がにかよってくると迷いが出る
ログインのenum化による改善
やり方としてはLoginCore.swiftにあるコードをenum LoginCore
でまとめてしまおうというのが案です。名前についてはenum LoginStore
でもいいです。
...
public enum LoginCore { // public enum LoginStore でもいい
public struct State: Equatable {
...
}
public enum Action: Equatable {
...
}
public struct Environment {
...
}
// Actionなどそのまま型を書いてますが、これはコンパイラがLoginCore.Actionと解釈します
public let reducer = Reducer<State, Action, Environment> { state, action, environment in
switch action {
...
}
}
}
- メリット
- ~Stateや~Action、~Environmentや~reducerと名付けてしまうとだいたい長い
- リネームしたいときに楽
- enumの場合
- LoginStoreをSigninStoreにするだけ
- enumでない場合
- SigninStateとリネームし、さらにSigninActionとリネームとしていく手間がある
- これ片方をSign i nStateにして片方をSign I nActionにしちゃうとかありますからねー
- SigninStateとリネームし、さらにSigninActionとリネームとしていく手間がある
- enumの場合
- ReducerはState, Action, Environmentと言われると推論できる
- 他のStore/Coreを作るときにこれコピペすればいいだけなので楽です
- その他
- enum名はStoreでもCoreでもいい
- Storeという名前にするのは、永続化の部品をStoreという名前をつかってない場合に使える
- 例えば CoreDataStore みたいな何かがないからStoreという名前を使ってる
- もしStoreが嫌ならCoreのほうがいいかも
- MyAwesomeFeatureCore
- Storeという名前にするのは、永続化の部品をStoreという名前をつかってない場合に使える
- enum名はStoreでもCoreでもいい
ただ、これをやってしまうと任意のStoreのStateと別のStoreのActionを組み合わせるということに抵抗があるでしょう。例えばLogin.StateとSignUp.Actionを組み合わせるのは抵抗があります。しかしねー、実際そんなの組み合わせたいとは私は思えないんです。組み合わせたいのは小さいCoreなんです。なのでLogin.StateとSignUp.Actionは組み合わせられますが、それを組み合わせるケースには出会うことはなく、別の方法で解決したいと私は思います。
言い換えると、StateとActionやEnvironmetはそれぞれに依存関係にないんですが、実質的に利用時には別々の組み合わせを使わないのでまとめちゃったほうが私には使いやすいです。
Viewでどのように使うかの例
public struct LoginView: View {
...
let store: Store<LoginCore.State, LoginCore.Action>
public init(store: Store<LoginCore.State, LoginCore.Action>) {
self.store = store
}
...
}
struct LoginView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
LoginView(
store: Store(
initialState: LoginCore.State(),
reducer: LoginCore.reducer,
environment: LoginCore.Environment(
...
)
)
)
}
}
}
応用: エラーもまとめておく
Store内部で実行された結果エラーとして起こり得てハンドリングしたい場合に、それを定義して使うこともあると思います。例としてLoginStore.Errorを定義したものを下記に示します。
public enum LoginCore { // public enum LoginStore でもいい
...
enum Failure: Error { // enum Error: Swift.Error としてもいい
case inputNameIsInvalid
// enumの `Associated Values` 使う例
case networkError(AFError) // Alamofireが良いかどうかは別として、そのエラーもそのまま使える
case saveTokenError(Swift.Error) // Core DataとかでToken保存失敗したときのSwift.Error
}
...
}
こうやっておけば、Reducerがなんのエラーをハンドリングできるのかがわかります。
補足 enumの Associated Values
使う例について
TCAとは直接的には関係ないですが、ライブラリで実行されたエラーなんかもenumのAssociated Values
で利用できます。例えばAlamofireのAFErrorとかです(Alamofireが良いと思ってるわけじゃないんですがよく使う例なのでそうしてるだけです)。AFErrorをLoginStore.Error
の一つにマッピングすることもできなくはないですが、AFErrorは数が多すぎるのでそれを自前のエラーに変換するのはかなり大変です。なのでそのままcase networkError(AFError)
としています。
あとはCore Dataなんかで保存しようとしてSwift.Error
返してくるのもとりあえずはそのままcase saveTokenError(Swift.Error)
とできます。もちろん、保存時のエラーというのは数があってDBが存在しないぜーということもあり、そういうのはリリースしているアプリとしてハンドリングする必要はないでしょう(リリースしてるってことはQA作業してるので、そしたらDBあるでしょっていう前提)。
つまりハンドリングできるものとハンドリングしたいものとは別なので、先程のコードを改善していくと次のように最適化されていくことはあるかもしれません。
public enum LoginCore { // public enum LoginCore でもいい
...
enum Error: Swift.Error {
case inputNameIsInvalid
// 通信による結果
case userNotExist
case networkTimeout
case networkCancelled
// 通信結果のトークンを保存しようとしてDBの制約違反
case tokenIsInvalid
}
...
}
話の流れでenumのAssociated Values
について言及しましたが、それはあくまで最初にそれで進めやすいですよねっていうことです。リリースして改善していけばそれを利用しない方が良いこともあるでしょうね。
応用: preview時にそれ用のメソッドを用意するのもenumでまとめているから楽
プレビューする際にも楽ですよという話です。いきなり例を見た方がよいでしょう。
enum PhotosListCore {
struct State {
let photoAuthorized: PhotoPermissionRequest.Auth
...
}
...
}
extension PhotosListCore.State {
static func preview(
photoAuthorized: PhotoPermissionRequest.Auth = .authorized
...
) -> Self {
...
}
}
TCAを使っていると複数のCoreを組み合わせるので、もともとのViewをプレビューすることと、組み合わせた際にその組み合わせた側でもプレビューすることが出てきます。つまりプレビューすることが多いのでその際にプレビュー用のデータを初期値としたコードを書くときが多いです。その都度コピペしてたんですが、Stateの微調整ごとにそのコピペされたコードを変更するのは面倒だったりします。
なのでextensionのメソッド化しておくと便利に使えます。上記の例では写真閲覧の権限が許可されたパターンであり、許可されてないパターンのプレビューも見たくなったりします。そういう場合はさらに改善できるでしょう。そのパターンを網羅したいならenumを作ってpreviewメソッドを複数にするのがいいかもしれません。
まとめ
- この話で処理をまとめるというのはnamespaceでグルーピングすればいいという話であって、型安全(型安心?)にしたいわけじゃない
- Reducerなどはグローバルな関数として定義するが、あんまり増やしたいとは思えない
- グローバルな関数にすべきものは何かというのは難しい
- リファクタリングでリネームするとき楽にやりたい
- Reducerなどはグローバルな関数として定義するが、あんまり増やしたいとは思えない
- グルーピングする際の名前はCoreにするのかStoreにするのかは悩ましい
- サンプルコード見るとファイル名がCoreなんでCoreがいいとは思う
- Store型がありそれと紛らわしいのでCoreのほうがいいかもしれない
- モジュール分割するとそもそもnamespaceはできる
- 例えばUserProfileFeatureというモジュールを作るとして
-
UserProfileFeature.UserProfileCore.State
みたいになる- フルで書いたら長いかなと思うけどフルで書くことはほぼない
- namespaceを作らんかったら?
-
UserProfileFeature.UserProfileState
みたいになってそもそも長いのでnamespace作ればいいのでは?
-
- namespaceを作らんかったら?
- フルで書いたら長いかなと思うけどフルで書くことはほぼない
-
- 例えばUserProfileFeatureというモジュールを作るとして
以上です。TCAの良いところは利用の仕方の自由度はそこそこあって人によって違うんじゃないかと思っています。もし他のやり方もあったら教えてください。
他の人はどうしてるか
Viewもnamepaceに入れる