iOS TCAを新アーキテクチャとして導入する際に既存のアーキテクチャと同居させる

こちらはiOS Advent Calendar 2022の6日目の記事です。

こんにちは。リサーチ・アンド・イノベーションの小川(J-ogawa)と申します。 弊社サービス CODEiOS版アプリの開発をしています。

CODEのiOS版ではアーキテクチャを刷新しようとしていまして、候補としてはTCAを考えています。

技術選定の判断要素として、移行期間に新旧を並行して同居させられるかどうかがあると思います。 CODEはアプリの規模が大きく新機能の追加開発中でもあり、一度に全てを移行することが難しかったのでこの点について調査しました。

TCAとは

公式リポジトリ

The Composable Architecture の略。pointfree.coが開発。

システムアーキテクチャの一種で、Reduxとかなり近い構成です。 現時点ではSwift用のライブラリで実現可能な状況なので「TCA」とは現状ではほぼSwift用の用語となっています。

スマホアプリは画面の生存期間が長く、Webアプリと同等以上にRedux的アーキテクチャの恩恵を受けられると思ったので採用の候補にしました。

TCAのメリット

公式より

このライブラリは、さまざまな目的や複雑さのアプリケーションを構築するために使用できる、いくつかのコアツールを提供します。アプリケーションを構築する際に日々遭遇する多くの問題を解決するために、以下のような説得力のあるストーリーが提供されています。

  • 状態管理

    • シンプルな値型を使用してアプリケーションの状態を管理し、多くの画面で状態を共有して、ある画面での状態の変更を別の画面ですぐに observe できるようにする方法。
  • コンポジション

    • 大きな機能を小さな Component に分解し、それぞれを独立したモジュールに抽出し、簡単にそれらを繋いで機能を形成する方法。
  • 副作用

    • 可能な限りテスト可能で理解しやすい方法で、アプリケーションの特定の部分を外界と対話させる方法。
  • テスト

    • アーキテクチャで構築された機能をテストするだけでなく、多くの Component で構成された機能の Integration test を書いたり、副作用がアプリケーションに与える影響を理解するために E2E テストを書いたりする方法。これにより、ビジネスロジックが期待通りに動作していることを強く保証することができる。
  • 開発者にとっての使いやすさ

    • 上記の全てを、できるだけ少ないコンセプトと動作するパーツからなるシンプルな 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の洗練された書き方を導入していければと考えています。


リサーチ・アンド・イノベーションではエンジニアを募集中です。

興味を持っていただけた方はお気軽にご連絡ください!

採用情報