Galapagos Tech Blog

株式会社ガラパゴスのメンバーによる技術ブログです。

UIKitでTCAを使おう!

初めまして、2022年11月1日に入社した、
AIR Design for Apps事業部 iOSチームの東秀斗(ひがしひでと)です。
今回は研修で作成しているアプリ(UIKitベース)でTCA(The Composable Architecture)を使用しましたので紹介させていただきます。

以前、同メンバーがSwiftUIでTCAを使用した記事もございますので併せて紹介しておきます。
TCAの概要を知りたい方は以下の記事を先に読んだ方が良いです。

techblog.glpgs.com

techblog.glpgs.com

実行環境

  • Xcode 14.2
  • Swift 5.7.2
  • UIKit
  • TCA 0.47.2

サンプルプロジェクトを基に紹介します。

ディレクトリ構成

※初期ファイルなどの不要なファイルは非表示

.
├── SampleProject
│   ├── API
│   │   └── WeatherAPI.swift
│   ├── AppDelegate.swift
│   ├── Client
│   │   └── WeatherClient.swift
│   ├── Customs
│   │   └── CustomUITextField.swift
│   ├── Model
│   │   ├── AreaModel.swift
│   │   ├── OfficeModel.swift
│   │   └── WeatherModel.swift
│   ├── Store
│   │   └── WeatherStore.swift
│   ├── SceneDelegate.swift
│   ├── ViewControllers
│   │   ├── MainViewController.swift
│   │   └── WeatherViewController.swift
│   ├── Views
│   │   └── AreaCollectionViewCell.swift
├── SampleProjectTests
│   └── SampleProjectTests.swift
└── SampleProjectUITests
    ├── SampleProjectUITests.swift
    └── SampleProjectUITestsLaunchTests.swift

ゴール

github.com

サンプルプロジェクトの概要

  1. 起動時にAPI通信を行い、気象庁より地点のリストを受け取る
  2. 地点リストをUICollectionViewを使用して一覧表示する
  3. 検索フィールドを用意し地点の検索ができるようにする
  4. トグルを使用して検索フィールドの使用状態を変更する
  5. セルをタップしたらその地点の詳細をモーダルで表示する

※部品の初期化、配置は省略させていただきます。

1.initを追加してStoreを受け取る

final class MainViewController: UIViewController {
    // 省略
    init(store: StoreOf<WeatherStore>) {
        self.viewStore = ViewStore(store)
        super.init(nibName: nil, bundle: nil)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    // 省略
}
  • UIViewControllerにinitを追加する際はrequire initが必須です。
  • このViewStoreを使用してStore内のStateやActionにアクセスします

2.Storeを作成する

struct WeatherStore: ReducerProtocol {
    // viewで使用する変数を宣言する
    struct State: Equatable {
        var areaOffices: [OfficeModel] = []
        var isLoading: Bool = false
        var currentIndex: Int = 0
        var alert: AlertState<Action>?
        var searchBarText: String?
        var placeholder: String? = "都道府県を検索"
        var isEnabled: Bool = true
    }

    // viewStoreからsendされるActionを列挙型で定義する
    enum Action: Equatable {
        case fetchAreaList
        case fetchAreaListResponse(TaskResult<AreaModel>)
        case onTapCell(Int)
        case alertDismiss
        case searchBarChanged(String)
        case onTappedSwitchButton
        case onTappedClearButton
    }

    // 別ファイルで定義したconcurrencyな関数をDependencyから呼び出す(詳細は後述)
    @Dependency(\.weatherClient) var weatherClient

    // API通信をキャンセルする用
    enum CancelID {}

    func reduce(into state: inout State, action: Action) -> EffectTask<Action> {
        switch action {
        case .fetchAreaList:
            state.isLoading = true
            state.areaOffices = []
            return .task {
                await .fetchAreaListResponse(TaskResult{ try await weatherClient.fetchAreaList() })
            }
            // ①
            .cancellable(id: CancelID.self)
        case .fetchAreaListResponse(.success(let response)):
            state.isLoading = false
            var responseOffices: [OfficeModel] = []
            response.offices.forEach {
                responseOffices.append(OfficeModel(areaCode: $0.key, office: $0.value))
            }
            responseOffices = responseOffices.sorted(by: {
                $0.areaCode < $1.areaCode
            })
            if let searchBarText = state.searchBarText, !searchBarText.isEmpty {
                state.areaOffices = responseOffices.filter{ $0.office.name.contains(searchBarText) }
            } else {
                state.areaOffices = responseOffices
            }
            return .none
        case .fetchAreaListResponse(.failure):
            state.alert = AlertState(title: TextState("エラー"), message: TextState("データ読み込みが失敗しました"))
            state.isLoading = false
            return .none
        case .onTapCell(let indexPathRow):
            state.currentIndex = indexPathRow
            return .none
        case .alertDismiss:
            state.alert = nil
            return .none
        case .searchBarChanged(let text):
            state.searchBarText = text
            // ②
            return .merge(
                .cancel(id: CancelID.self),
                .task {
                    .fetchAreaList
                }
            )
        case .onTappedSwitchButton:
            state.isEnabled = !state.isEnabled
            state.placeholder = state.isEnabled ? "都道府県を検索" : "使用不可"
            return .none
        case .onTappedClearButton:
            guard let searchBarText = state.searchBarText,
                  !searchBarText.isEmpty
            else { return .none }
            state.searchBarText = nil
            return .task {
                .fetchAreaList
            }
        }
    }
}

// UICollectionViewのdataSourceに使用するために定義
extension WeatherStore {
    enum Section: Hashable, CaseIterable {
        case areaOffices
    }

    enum Item: Hashable, Equatable {
        case areaOffices(OfficeModel)
    }
}

extension WeatherStore.State {
    typealias Section = WeatherStore.Section
    typealias Item = WeatherStore.Item
    typealias Snapshot = NSDiffableDataSourceSnapshot<Section, Item>

    var snapshot: Snapshot {
        var snapshot = Snapshot()
        snapshot.appendSections([.areaOffices])
        snapshot.appendItems(areaOffices.map(Item.areaOffices))
        return snapshot
    }
}
  • .cancellable(id: AnyHashable)はキャンセルする場合があるEffectTaskに付与する
  • .merge()はEffectPublisherを配列で受け取り、それらを同時実行するEffectTask。 EffectPublisher -> .none.task.run.fireAndForgetなど

3.部品にStoreのStateを紐づける

class MainViewController: UIViewController {
    // 省略
    
    override func viewDidLoad() {
        super.viewDidLoad()

        // ①
        viewStore.publisher.placeholder
            .assign(to: \.placeholder, on: searchTextField)
            .store(in: &cancellables)

        viewStore.publisher.isEnabled
            .assign(to: \.isEnabled, on: searchTextField)
            .store(in: &cancellables)

        viewStore.publisher.isEnabled
            .assign(to: \.isEnabled, on: clearButton)
            .store(in: &cancellables)

        viewStore.publisher.searchBarText
            .assign(to: \.text, on: searchTextField)
            .store(in: &cancellables)

        // ②
        viewStore.publisher.isLoading.map({!$0})
            .assign(to: \.isHidden, on: activityIndicator)
            .store(in: &cancellables)

        viewStore.publisher.alert
            .sink { [weak self] alert in
                guard let self = self else { return }
                guard let alert = alert else { return }
                let alertController = UIAlertController(title: String(state: alert.title),
                                                        message: String(state: alert.message ?? TextState("")),
                                                        preferredStyle: .alert)
                alertController.addAction(
                    UIAlertAction(title: "Ok", style: .default) { _ in
                        self.viewStore.send(.alertDismiss)
                    }
                )
                self.present(alertController, animated: true)
            }
            .store(in: &cancellables)
    }
    // 省略
}
  • .assign(to: ReferenceWritableKeyPath<Root, Bool>, on: Root)torootに指定するViewのメンバーを指定します。
  • また、toがオプショナルの場合、StoreのStateもオプショナルである必要があります。①の場合、UITextViewのplaceholderはオプショナル型のStringなので、
  • StoreのStateで宣言したplaceholderも併せてオプショナル型のStringにしています。
  • ②ではisLoadingをactivityIndicatorのisHiddenに設定していますが、isLoadingは通信中にtrue、それ以外にfalseになるので、isHiddenにそのまま設定してしまうと通信中にactivityIndicatorが非表示になり、それ以外の時に表示されてしうので.mapを使用してisLoadingを反転させてから設定しています。

4.API通信を行うClientを作成する

struct WeatherClient {
    var fetchAreaList: @Sendable () async throws -> AreaModel
    var fetchWeatherInfomation: @Sendable (_ areaCode: String) async throws -> WeatherModel
}

extension DependencyValues {
    var weatherClient: WeatherClient {
        get { self[WeatherClient.self] }
        set { self[WeatherClient.self] = newValue }
    }
}

extension WeatherClient: DependencyKey {
    static var liveValue: WeatherClient {
        Value(fetchAreaList: {
            let areApiUrl: String = "https://www.jma.go.jp/bosai/common/const/area.json"
            let (data, _) = try await APICaller.shared.fetch(url: areApiUrl)
            let jsonDecoder = JSONDecoder()
            try await Task.sleep(nanoseconds: 500_000_000)
            return try jsonDecoder.decode(AreaModel.self, from: data)
        }, fetchWeatherInfomation: { areaCode in
            let weatherApiUrl: String = "https://www.jma.go.jp/bosai/forecast/data/forecast/\(areaCode).json"
            let (data, _) = try await APICaller.shared.fetch(url: weatherApiUrl)
            let dictData = try JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed) as! [Any]
            let json = try JSONSerialization.data(withJSONObject: dictData)
            return try APICaller.shared.decode(data: json)
        })
    }

    static var testValue = liveValue
}
  • StoreでClientを使用するにはDependencyValuesに追加する必要があります。
  • 追加すると、Store内で@Dependency(\.WeatherClient)で参照することができるようになります。
  • testValueは必須ではありませんが、単体テストを行うときに必要になります。

5.タップや文字入力時のイベントにStoreのActionを紐づける

extension MainViewController {
    @objc private func searchBarTextFieldChanged(sender: UITextField) {
        viewStore.send(.searchBarChanged(sender.text ?? ""))
    }

    @objc private func onTapClearButton(sender: UIButton) {
        viewStore.send(.onTappedClearButton)
    }

    @objc private func onTapSwitchButton(sender: UISwitch) {
        viewStore.send(.onTappedSwitchButton)
    }
}
  • sendを使用してViewからReducerにイベントを送信します。

viewStore.send(.onTappedClearButton)の場合、検索フィールドをクリアするボタンが押された時にWeatherStorereduce内の以下の処理が実行されます。

struct WeatherStore: ReducerProtocol {
    // 省略

    func reduce(into state: inout State, action: Action) -> EffectTask<Action> {
        switch action {
        // 省略
        case .onTappedClearButton:
            guard let searchBarText = state.searchBarText,
                  !searchBarText.isEmpty
            else {
                return .none
            }
            state.searchBarText = nil
            return .task {
                .fetchAreaList
            }
        }
    }
}

以上がUIKitでTCAを利用する基本となります。
SwiftUIベースで一部をUIKitで作るというようなケースで多少は役に立つかと思います。

小ネタ

reduceを分割する

TCAの気になるポイントはreduceの肥大化です。
分割のメリットとして、関連する処理(API通信の送受信のActionなど)をひとまとめにできることだと思います。
※ベストプラクティスではないので採用するかは、個人またはチームの判断になると思いますので注意 先ほどのWeatherStoreを変更します。

enum Action: Equatable {
    // - case fetchAreaList
    // - case fetchAreaListResponse(TaskResult<WeatherModel>)
    case fetchAreaAction(FetchAreaAction)
    // 省略

    enum FetchAreaAction: Equatable {
        case fetchAreaList
        case fetchAreaListResponse(TaskResult<WeatherModel>)
    }
}

Action内にまた小さなActionを作成します。
作成したActionを処理する小さなReducerを作成します。

extension WeatherStore {
    func fetchAreaReduce(state: inout State, action: Action.FetchAreaAction) -> EffectTask<Action> {
        switch action {
        case .fetchAreaList:
            // 分割する前のreduceと同じ処理をかく
        case .fetchAreaListResponse(.success(let response)):
            // 分割する前のreduceと同じ処理をかく
        case .fetchAreaListResponse(.failure):
            // 分割する前のreduceと同じ処理をかく
        }
    }
}

reduce内にあった分割する前のactionを削除し、新しく作った小さいactionを追加します。

func reduce(into state: inout State, action: Action) -> EffectTask<Action> {
    switch action {
    // - case .fetchAreaList:
    // - case .fetchAreaListResponse(.success):
    // - case .fetchAreaListResponse(.failure):
    case .fetchAreaList(let action):
        return fetchAreaReduce(state: &state, action: action)
    }
}

これで、メインのreduceからまとまった処理を抜き取り、分割することができました。

以上でUIKitでTCAを使用する例の紹介を終わります。 詳細は以下のサンプルコードをご確認ください。 github.com

気象庁 Japan Meteorological Agency