iOS TCAを新アーキテクチャとして導入する際に既存のアーキテクチャと同居させる
こちらはiOS Advent Calendar 2022の6日目の記事です。
こんにちは。リサーチ・アンド・イノベーションの小川(J-ogawa)と申します。 弊社サービス CODEのiOS版アプリの開発をしています。
CODEのiOS版ではアーキテクチャを刷新しようとしていまして、候補としてはTCAを考えています。
技術選定の判断要素として、移行期間に新旧を並行して同居させられるかどうかがあると思います。 CODEはアプリの規模が大きく新機能の追加開発中でもあり、一度に全てを移行することが難しかったのでこの点について調査しました。
TCAとは
The Composable Architecture の略。pointfree.coが開発。
システムアーキテクチャの一種で、Reduxとかなり近い構成です。 現時点ではSwift用のライブラリで実現可能な状況なので「TCA」とは現状ではほぼSwift用の用語となっています。
スマホアプリは画面の生存期間が長く、Webアプリと同等以上にRedux的アーキテクチャの恩恵を受けられると思ったので採用の候補にしました。
TCAのメリット
公式より
このライブラリは、さまざまな目的や複雑さのアプリケーションを構築するために使用できる、いくつかのコアツールを提供します。アプリケーションを構築する際に日々遭遇する多くの問題を解決するために、以下のような説得力のあるストーリーが提供されています。
状態管理
- シンプルな値型を使用してアプリケーションの状態を管理し、多くの画面で状態を共有して、ある画面での状態の変更を別の画面ですぐに observe できるようにする方法。
-
- 大きな機能を小さな Component に分解し、それぞれを独立したモジュールに抽出し、簡単にそれらを繋いで機能を形成する方法。
副作用
- 可能な限りテスト可能で理解しやすい方法で、アプリケーションの特定の部分を外界と対話させる方法。
テスト
開発者にとっての使いやすさ
- 上記の全てを、できるだけ少ないコンセプトと動作するパーツからなるシンプルな API で実現する方法。
リソース
公式の動画
TCAだけでなく、Swift・関数型プログラミングがテーマ。 毎週更新されています。無料と有料の動画があり全て見るには月額$14ほどの料金です。
サンプルコード
公式リポジトリに6種類のアプリサンプルがあります。
yimajoさんの記事
日本語で一番詳しく紹介されている方です。
TCAと既存パーツの同居
TCAの新規導入については公式に有益なサンプルがありますので割愛し、この記事では既存プロダクトと並行してTCAを導入する際の実現方法について書きます。
以下の2点を行います。
TCAをUIKit使用の画面に適用
TCAのStateと既存のデータを同期
TCAをUIKit使用の画面に適用
CODEではTCAの導入と同時にSwiftUIの本格導入を行う予定です。しかし、既存画面では主にUIKitを使っていて、すぐにその画面構成を変更することは難しいです。
そこで、データ部分だけでもTCAのStateを使うようにしたいと考えました。
TCAはUIKitでも実は使用可能です(公式リポジトリでも軽く紹介されています)
State, Action, Environmentの例
状態として1つ数値があり、それを インクリメント|デクリメント するアクションがあるものとします。
import Foundation import ComposableArchitecture struct AppState: Equatable { var localState = LocalState() } struct LocalState: Equatable { var amount = 100 } enum AppAction: Equatable { case decrement case increment } struct AppEnvironment { } let appReducer = Reducer<AppState, AppAction, AppEnvironment> { state, action, environment in switch action { case .decrement: state.localState.amount -= 1 return .none case .increment: state.localState.amount += 1 return .none } }
表示画面の例
単純な値表示のラベルと +
, -
で値を増減させるボタンを表示します。
- 値のUIへの紐付けは
Combine
経由でviewStore.publisher.map { ... }.assign(to:)
で行います。 - 値の変更はボタンアクション時に
viewStore.send
でActionを実行します。
import UIKit import Combine import ComposableArchitecture class TCAMoneyViewController: UIViewController { let store: Store<AppState, AppAction> let viewStore: ViewStore<AppState, AppAction> var cancellables: Set<AnyCancellable> = [] init(store: Store<AppState, AppAction>) { self.store = store self.viewStore = ViewStore(store) super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() let label = UILabel() label.font = UIFont.preferredFont(forTextStyle: .title2) label.numberOfLines = 0 let incrementButton = UIButton(type: .system) incrementButton.setTitle("+", for: .normal) incrementButton.addTarget(self, action: #selector(incrementButtonTapped(sender:)), for: .touchUpInside) let decrementButton = UIButton(type: .system) decrementButton.setTitle("-", for: .normal) decrementButton.addTarget(self, action: #selector(decrementButtonTapped(sender:)), for: .touchUpInside) let rootStackView = UIStackView(arrangedSubviews: [ label, incrementButton, decrementButton, ]) rootStackView.isLayoutMarginsRelativeArrangement = true rootStackView.layoutMargins = UIEdgeInsets(top: 0, left: 32, bottom: 0, right: 32) rootStackView.translatesAutoresizingMaskIntoConstraints = false rootStackView.axis = .vertical rootStackView.spacing = 24 view.addSubview(rootStackView) NSLayoutConstraint.activate([ rootStackView.leadingAnchor.constraint(equalTo: view.leadingAnchor), rootStackView.trailingAnchor.constraint(equalTo: view.trailingAnchor), rootStackView.centerYAnchor.constraint(equalTo: view.centerYAnchor), ]) viewStore.publisher .map { "Amount: \($0.state.amount)" } .assign(to: \.text, on: label) .store(in: &self.cancellables) } @objc private func incrementButtonTapped(sender: UIButton) { self.viewStore.send(.increment) } @objc private func decrementButtonTapped(sender: UIButton) { self.viewStore.send(.decrement) } }
簡単な例ですが、これでUIKit上でTCAのStateを扱うことができました。
TCAのStateと既存のデータを同期
次に、以下の順でデータ面での同期を実現していきます。
- TCAでの変更を既存データに反映
- 既存データの変更をTCAに反映
TCAでの変更を既存データに反映
強引ですが、TCAのState内の変数を監視し、そこで処理をしました。
struct LocalState: Equatable { var amount = 100 { didSet { OldDataModel.sharedInstance().setValue(amount, forKey: "amount") } } }
CODEの既存アーキテクチャではこの OldDataModel
のプロパティを監視していてここを書き換えるとUIが変更されるようにしています。
実際にこれでTCA側のStateの変更に旧画面の変更を同期させることができました。
既存データの変更をTCAに反映
- TCAのStoreをシングルトンにして既存パーツから扱えるようにする
- 値更新用のアクションを作成する
- シングルトンに対し該当アクションを実行できるメソッドを作って旧データソース更新時に叩く
値同期用のアクション(setAmount)と外部から叩けるメソッド(syncLocalState)を用意
enum AppAction: Equatable { case decrement case increment case setAmount } let appReducer = Reducer<AppState, AppAction, AppEnvironment> { state, action, environment in switch action { case .decrement: state.localState.amount -= 1 return .none case .increment: state.localState.amount += 1 return .none case .setAmount(let value): state.localState.amount = value return .none } } class TCACore: NSObject { private static let store = Store(initialState: AppState(), reducer: appReducer, environment: AppEnvironment()) static func syncLocalState(amount: Int) { ViewStore(store).send(.setAmount(value: value)) } }
旧データソースが値(amountとする)を更新したタイミングで以下を呼び出す
TCACore.syncLocalState(amount: amount)
こちらでも旧データソース変更処理とTCA適用の画面の表示を同期させることができました。
まとめ
TCAは既存の実装と同居させることが可能でした。
TCAはSwiftUI用の便利な機能を多く提供しているのでSwiftUIでこそ真価を発揮すると思いますが、一度に全面刷新するのが無理な場合でも導入できそうです。
自分としては単一のデータソースにアプリの状態が集約されるところが魅力なので、まずは同居させ、徐々にTCAの洗練された書き方を導入していければと考えています。
リサーチ・アンド・イノベーションではエンジニアを募集中です。
興味を持っていただけた方はお気軽にご連絡ください!