初めまして、2022年11月1日に入社した、
AIR Design for Apps事業部 iOSチームの東秀斗(ひがしひでと)です。
今回は研修で作成しているアプリ(UIKitベース)でTCA(The Composable Architecture)を使用しましたので紹介させていただきます。
以前、同メンバーがSwiftUIでTCAを使用した記事もございますので併せて紹介しておきます。
TCAの概要を知りたい方は以下の記事を先に読んだ方が良いです。
実行環境
- 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
ゴール
サンプルプロジェクトの概要
- 起動時にAPI通信を行い、気象庁より地点のリストを受け取る
- 地点リストをUICollectionViewを使用して一覧表示する
- 検索フィールドを用意し地点の検索ができるようにする
- トグルを使用して検索フィールドの使用状態を変更する
- セルをタップしたらその地点の詳細をモーダルで表示する
※部品の初期化、配置は省略させていただきます。
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)
のto
はroot
に指定する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)
の場合、検索フィールドをクリアするボタンが押された時にWeatherStore
のreduce
内の以下の処理が実行されます。
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