Upgrade to Pro — share decks privately, control downloads, hide ads and more …

TCA入門したてなので、自分が馴染みのある実装と比較しながらキャッチアップしてみる

 TCA入門したてなので、自分が馴染みのある実装と比較しながらキャッチアップしてみる

3/18にuzabase様で開催された、「【iOS】TCAでわいわいLT会」での登壇資料になります。

Redux処理機構を利用したアーキテクチャを利用した開発経験や、MVVMで構成された実装を置き換える(またはその逆)実装経験から、TCAにキャッチアップしていく上でのポイントになり得そうな点をまとめた物になります。本発表では、自作して試したRedux処理機構と比較した上でTCAを利用した特徴的な部分や収録サンプルでの実装例等についても簡単ではありますが触れております。

※ 内容は一部、2/7に登壇しました「Overviewing TCA v1.7 & Back and forth with MVVM」とも重複する点があります。

Fumiya Sakai

March 17, 2024
Tweet

More Decks by Fumiya Sakai

Other Decks in Technology

Transcript

  1. 自己紹介 ・Fumiya Sakai ・Mobile Application Engineer アカウント: ・Twitter: https://twitter.com/fumiyasac ・Facebook:

    https://www.facebook.com/fumiya.sakai.37 ・Github: https://github.com/fumiyasac ・Qiita: https://qiita.com/fumiyasac@github 発表者: ・Born on September 21, 1984 これまでの歩み: Web Designer 2008 ~ 2010 Web Engineer 2012 ~ 2016 App Engineer 2017 ~ Now iOS / Android / sometimes Flutter
  2. 今回のスライドにつきまして 個人的に最近TCAやその他Architectureに触れる機会があるので調査しています 自作したReduxベースの処理機構を事例にTCAでの処理機構の概要を中心に紹介できればと思います。 1. 以前にも業務や個人活動の中でReduxを利用した経験があり以前からも関心はあった: Reduxの機構を利用したアーキテクチャを利用した開発経験を通じて、特に複雑かつ構成要素が複雑な入力を伴う画面等において は力を発揮する場面もありました。Reduxの考え方を採用しているTCAの動向は個人的にも気になっています。 2. MVVM ⇔

    TCA or Reduxの処理を置き換える経験をした際に感じてた事: 以前の経験でMVVMで作られてたものをReduxへ置き換える、あるいはその逆をする経験をした際に、どの様に考えると結構わかり やすかったかという観点についても簡単ではありますがご紹介できると思います。 3. その他Unidirectionalな形を取るArchitectureにも触れた上で比較した際の所感: 今回はReduxを利用した実装例やMVVMから置き換え処理を題材にしながらTCAでの実装をキャッチアップをする際に、個人的にヒ ントにした事例を紹介していますが、他にもUnidirectionalな形についても簡単ではありますが触れられればと思います。
  3. このサンプル実装におけるReduxと各層での処理 今回は画面ごとにそれぞれのStateが対応している形を取っている点がポイント 1. Storeから受け取った画面用State値を反映する: 2. ボタン押下処理等の部分に画面用Stateを変更するAction発行処理を記載する: 画面用State変化とUI変化をうまく結びつけるためには、できる だけ「Stateの値 = アプリのUI要素の状態」という形となる様

    に、State構造やUI関連処理に関する設計をする点がポイントに なると考えております。 すなわち、「各状態におけるデータとUIのあるべき姿を整理す る」 点が重要になると思います。 Loading Success Failure 1. 画面が表示されたタイミングでAPIリクエスト用Action発行 例. Favorite画面における画面表示時の表示パターン 2. 処理結果に応じて変更されたStateに応じた画面表示を実施
  4. アプリ全体の状態管理をするためのStore定義 // MARK: - Typealias // 👉 Dispatcher・Reducer・Middlewareのtypealiasを定義する typealias Dispatcher

    = (Action) -> Void typealias Reducer<State: ReduxState> = (_ state: State, _ action: Action) -> State typealias Middleware<StoreState: ReduxState> = (StoreState, Action, @escaping Dispatcher) -> Void final class Store<StoreState: ReduxState>: ObservableObject { @Published private(set) var state: StoreState private var reducer: Reducer<StoreState> private var middlewares: [Middleware<StoreState>] init(reducer: @escaping Reducer<StoreState>, state: StoreState, middlewares: [Middleware<StoreState>] = []) { self.reducer = reducer self.state = state self.middlewares = middlewares } func dispatch(action: Action) { // MEMO: Actionを発行するDispatcherの定義 Task { @MainActor in self.state = reducer( self.state, action ) } // MEMO: 利用する全てのMiddlewareを適用 middlewares.forEach { middleware in middleware(state, action, dispatch) } } } // MARK: - Protocol protocol ReduxState {} // 👉 このProtocolにを準拠した各画面に対応するActionをStructで定義する形になる protocol Action {} 各画面に対応するStateはImmutableな形になる その他Store内に定義しているTypealiasやProtocol類
  5. 一番おおもとの画面にStoreを適用して各画面へ伝える @main struct SwiftUIAndReduxExampleApp: App { // MARK: - Body

    var body: some Scene { // 👉 このアプリで利用するStoreを初期化する // ※ middlewaresの配列内にAPI通信/Realm/UserDefaultを操作するための関数を追加する // ※ TestCodeやPreview画面ではmiddlewaresの関数にはMockを適用する let store = Store( reducer: appReducer, state: AppState(), middlewares: [ // MEMO: 正規の処理を実行するMiddlewareを登録する // OnBoarding/Home/Archive/Favorite/Profile用Middleware ] ) // 👉 ContentViewには.environmentObjectを経由してstoreを適用する WindowGroup { ContentView() .environmentObject(store) } } } // OnBoarding onboardingMiddleware(), onboardingCloseMiddleware(), // Home homeMiddleware(), // Archive archiveMiddleware(), addArchiveObjectMiddleware(), deleteArchiveObjectMiddleware(), // Favorite favoriteMiddleware(), // Profile profileMiddleware(), API通信やデータ永続化処理経由でActionを発行するための関数 Storeを下層Viewへ渡す @EnvironmentObject var store: Store<AppState> 下層View画面要素でStoreを利用したい場合はEnvironmentObject経由で利用する
  6. シンプルな画面から紐解くState&Action定義部分 Loading Success Failure struct RequestFavoriteAction: Action {} struct SuccessFavoriteAction:

    Action { let favoriteSceneEntities: [FavoriteSceneEntity] } struct FailureFavoriteAction: Action {} 1. 画面が表示されたタイミングでAPIリクエスト用Action発行 おさらい. Favorite画面における画面表示時の表示パターン 2. 処理結果に応じて変更されたStateに応じた画面表示を実施 1. Action定義例 2. State定義例 struct FavoriteState: ReduxState, Equatable { // MEMO: 読み込み中状態 var isLoading: Bool = false // MEMO: エラー状態 var isError: Bool = false // MEMO: Favorite画面で利用する情報として必要なViewObject情報 var favoritePhotosCardViewObjects: [FavoritePhotosCardViewObject] = [] static func == (lhs: FavoriteState, rhs: FavoriteState) -> Bool { return lhs.isLoading == rhs.isLoading && lhs.isError == rhs.isError && lhs.favoritePhotosCardViewObjects == rhs.favoritePhotosCardViewObjects } } 画面表示要素との関連部分 Action Protocolへ準拠
  7. シンプルな画面から紐解くReducer定義部分 func favoriteReducer(_ state: FavoriteState, _ action: Action) -> FavoriteState

    { var state = state switch action { case _ as RequestFavoriteAction: state.isLoading = true state.isError = false case let action as SuccessFavoriteAction: state.favoritePhotosCardViewObjects = action.favoriteSceneEntities.map { FavoritePhotosCardViewObject( id: $0.id, photoUrl: URL(string: $0.photoUrl) ?? nil, author: $0.author, title: $0.title, category: $0.category, shopName: $0.shopName, comment: $0.comment, publishedAt: DateLabelFormatter.getDateStringFromAPI(apiDateString: $0.publishedAt) ) } state.isLoading = false state.isError = false case _ as FailureFavoriteAction: state.isLoading = false state.isError = true default: break } return state } 3. Reducer定義例 現在のStateを受け取って新しいStateを生成する処理 favoriteMiddleware() -> Middleware<AppState> ① RequestFavorite実行時: - 新しいFavoriteStateが生成  👉 Loading状態表示 - favoriteMiddleware()も同時に実行 ② favoriteMiddleware()実行結果: - 結果に応じたUI表示処理  👉 成功:お気に入り一覧画面表示  👉 失敗:共通エラー画面表示 View要素に必要なObjectに置き換える Middlewareの処理結果に応じて実行されるReducer内処理
  8. // APIリクエスト結果に応じたActionを発行する func favoriteMiddleware() -> Middleware<AppState> { return { state,

    action, dispatch in switch action { case let action as RequestFavoriteAction: // 👉 RequestFavoriteActionを受け取ったらその後にAPIリクエスト処理を実行する requestFavoriteScenes(action: action, dispatch: dispatch) default: break } } } シンプルな画面から紐解くMiddleware定義部分 4. Middleware定義例 画面をLoading状態にするActionを発行を受け取ったらAPIリクエスト処理を実行 // 👉 APIリクエスト処理を実行するためのメソッド private func requestFavoriteScenes(action: RequestFavoriteAction, dispatch: @escaping Dispatcher) { Task { @MainActor in do { let favoriteResponse = try await FavioriteRepositoryFactory.create().getFavioriteResponse() if let favoriteSceneResponse = favoriteResponse as? FavoriteSceneResponse { dispatch(SuccessFavoriteAction(favoriteSceneEntities: favoriteSceneResponse.result)) } else { throw APIError.error(message: "No FavoriteSceneResponse exists.") } dump(favoriteResponse) } catch APIError.error(let message) { dispatch(FailureFavoriteAction()) } } } async / awaitをベースとしたAPIリクエスト処理 favoriteMiddleware()実行結果: - 結果に応じたUI表示処理  👉 成功:お気に入り一覧画面表示 SuccessFavoriteAction  👉 失敗:共通エラー画面表示 FailureFavoriteAction 成功 / 失敗に応じて発行するActionが異なる
  9. シンプルな画面から紐解くView定義部分 5. View要素定義例 @EnvironmentObject var store: Store<AppState> private struct Props

    { // Immutableに扱うProperty 👉 画面状態管理用 let isLoading: Bool let isError: Bool // Immutableに扱うProperty 👉 画面表示要素用 let favoritePhotosCardViewObjects: [FavoritePhotosCardViewObject] // Action発行用のClosure let requestFavorite: () -> Void let retryFavorite: () -> Void } private func mapStateToProps(state: FavoriteState) -> Props { Props( isLoading: state.isLoading, isError: state.isError, favoritePhotosCardViewObjects: state.favoritePhotosCardViewObjects, requestFavorite: { store.dispatch(action: RequestFavoriteAction()) }, retryFavorite: { store.dispatch(action: RequestFavoriteAction()) } ) } var body: some View { // 該当画面で利用するStateをこの画面用のPropsにマッピングする let props = mapStateToProps(state: store.state.favoriteState) // 表示に必要な値をPropsから取得する let isLoading = mapToIsLoading(props: props) let isError = mapToIsError(props: props) NavigationStack { Group { if isLoading { // ローディング画面を表示 ExecutingConnectionView() } else if isError { // エラー画面を表示 ConnectionErrorView(tapButtonAction: props.retryFavorite) } else { // Favorite画面を表示 showFavoriteContentsView(props: props) } } .navigationTitle("Favorite") .navigationBarTitleDisplayMode(.inline) // 画面が表示された際に一度だけAPIリクエストを実行する形にしています。 .onFirstAppear(props.requestFavorite) } } 受け取ったStateで画面表示に必要なものを詰め直す 画面表示に必要なものを抜き出して利用する
  10. Viewにおける状態変化と更新手段は共通点がある Actionを発行して副作用を伴うReducer処理で新たなStateを作成する流れは同様 1. ReduxでのView更新までの流れ: 2. TCAでのView更新までの流れ: Unidirectionalなデータの流れを作る方針はとても類似しているが副作用に関する考え方が特徴的に感じる。 View要素から実行された Actionを発行する Middleware(副作用)が

    処理前後で実行される 該当するAction合致時は 内部処理を利用して別の Actionを発行する Reducer処理内でState内 のPropertyを更新する Middleware(副作用)がな い場合は直接Reducerへ 全体のStateが更新され View要素を更新する View要素から実行された Actionを発行する Effect(副作用)が Reducer内で実行される Reducer内処理において Effectを利用して内部で 別のActionを発行する Reducer処理内でState内 のPropertyを更新する Effect(副作用)がない場 合は直接Reducerへ 全体のStateが更新され View要素を更新する
  11. case .fetchRecentNews: // (省略)Loading状態を表現するためにStateの内容を更新する // (処理例)API経由で最新情報データを取得する処理 return .run { send

    in await send( .fetchRecentNewsResponse( Result { try await self.newsRepository.fetchRecent() } ) ) } ReduxのMiddlewareとTCAのEffectのイメージを整理 APIリクエスト結果で成功・失敗のAction発行からStateの更新処理における事例 case let .fetchRecentNewsResponse(.success(response)): // (省略)成功時の状態を表現するためにStateの内容を更新する return .none case .fetchRecentNewsResponse(.failure): // (省略)失敗時の状態を表現するためにStateの内容を更新する return .none 同じReducer内で .send(…) を実行してActionを発行  @Dependency経由で取得したRepositoryの処理を実行 func resentNewsMiddleware() -> Middleware<AppState> { return { state, action, dispatch in switch action { case let action as FetchRecentNewsAction: requestFetchRecentNews(action: action, dispatch: dispatch) default: break } } } Task { @MainActor in do { let response = try await NewsRepositoryFactory.create().fetchRecent() dispatch(SuccessFetchRecentNewsResponseAction(response)) } catch APIError.error(let message) { dispatch(FailureFetchRecentNewsResponseAction()) } } } 1. TCAのEffectを利用した処理: 2. ReduxのMiddlewareを利用した処理: Middleware関数内でRepositoryをインスタンス化して処理を実行 関数内で dispatch() を実行してActionを発行 private func requestFetchRecentNews(action: FetchRecentNewsAction, dispatch: @escaping Dispatcher)
  12. 自作したRedux処理からTCAだったらなと感じた点 アーキテクチャ部分の提供だけではなくかゆい所に手が届く様な配慮がある 1. Reduxを自作した際のつらみ?の部分: 2. もしTCAだと嬉しく感じる部分: 両方を試した事で「この部分を提供してくれるのは本当にありがたい」と感じる事は多かった。 Storeを分割できる機構を持っている .scope(state: \•••,

    action: \◆◆◆) で制限する Stateの親子関係を意識したState構造 特に異なる画面にStoreを渡したい様な場合には、実装処理や構造を利 用してうまく範囲を制限したりする等の配慮が必要 @EnvironmentObjectを利用したDI機構 APIリクエストやデータ永続化処理をそれぞれMiddlewareに分割して Storeに定義するので、ファイルが多くなると結構大変な印象 Swift Concurrencyのサポート対応 async / awaitでの処理機構・@Sendableへのケアが自前で必要 Viewにおいて直接Store全体を観察する形にすると、ある状態が変化し た場合、SwiftUIはすべてのUI更新を要求するため全体を再計算する DI機構・APIClient等をはじめとしたサポートが充実 @Dependency / liveValue / point-free製のOSS等 PropertyWrapperの形で提供されている便利な機構や自前で作成すると 大変そうな部分や処理等を提供してくれている
  13. 元々MVVMで作成された画面をTCAに置き換えるアイデア どちらも共に良いと思うが(今回はあえて)置き換える事を考えてみます ① 初期状態 : メールマガジン登録をするための画面: メールを送信する メールマガジン登録 📩 Mail:

    [email protected] メールを送信する メールマガジン登録 📩 Mail: abcdefc123456 Invalid. メールマガジン登録処理に関する仕様 この時はメールを送信するためのボタンは非活性状態となる ② 入力中の状態 : 形式が正しくない場合のボタンは非活性状態となる テキストフィールドの左下にエラーメッセージが表示される ③ 入力完了の状態 : 形式が正しい場合のボタンは活性状態となる テキストフィールドの左下にエラーメッセージが表示されない ※ 目のボタンを押下すると入力内容がクリアされる 👀 👀
  14. SwiftUIでのMVVM構成に関するポイントをまずは確認 MVVM構成を考える際はViewModel内部実装とSwiftUIのStateに関する事 1. 基本方針と構成のおさらい : View Components ViewModel UseCase・Repository Infrastructure

    ※ AndroidではStateもViewと分離 Button活性・非活性状態を管理する: ※基本的にはasync/awaitでLogicを作成して、Viewとの連結時に必要に合わせてCombineで補う方針 2. SwiftUIのView要素の特徴を踏まえてポイントを整理する : ① SwiftUIのVIew要素ではStateが含まれる形にそもそもなっている ② ViewModelはObservableObjectを継承し、View要素では @ObservedObject / @StateObject で連結する @Published private(set) var sendButtonDisabled: Bool = true @Published var inputEmail: String = "" 入力用TextFieldと連結する: ※ Binding<String>で渡す必要があるため Input Output ViewModel内に定義したメソッド: viewModel.doSomething() ViewModel内の@Publishedで定義したProperty 双方向Bindingの様な形のイメージ
  15. MVVMでの処理をTCAに置き換える際のイメージ(1) Inputは定義したメソッドの実行・Outputは@Publishedで定義した変数 ① 入力するTextField要素とBindingするProperty : メールを送信する メールマガジン登録 📩 Mail: [email protected]

    ViewModel処理における組み立て方のヒント ※ didSet {…} を利用して関連する値を更新したり、Validation処理を実行させる様にするのがポイント メールマガジン登録画面をMVVMで実装する場合のViewModelにおける見通し: 👀 @Published var inputEmail: String = "" ② Button要素の活性状態をHandlingするProperty : ※ Input用のメソッドやTextFieldの入力状態によってこの値が変化する様に調整する @Published private(set) var sendButtonDisabled: Bool = true 双方向Binding前提の処理 ③ 送信要素を押下した時の処理実行メソッド : func postInputEmail() { … 入力データをPOSTで送信する … } Output Input ※ APIリクエストやデータ永続化の様なBusiness Logic(Domain Logic)の処理を実行する
  16. MVVMでの処理をTCAに置き換える際のイメージ(2) ViewModelに定義したInput・Outputをヒントにして置き換えていくと良さそう ① @Published で定義したOutput用のPropertyがStateのヒントになり得る : メールを送信する メールマガジン登録 📩 Mail:

    [email protected] この画面で利用すつStateを定義する 入力TextFieldと連動する & Button状態のHandling用のProperty → StateのPropertyになる? メールマガジン登録画面をTCAで実装する場合のポイントになりそうな部分: 👀 Reducer処理とAction名を定義する State ② Action名はInput用のメソッド名がヒントになり得る : Recucerに定義している各種case名(すなわちAction)は @Published を更新するためのメソッ ド名を命名や内容を参考に置き換えてみる Dependencyを利用する事でBusiness Logic(Domain Logic)の定義を利用可能にする Reducer Action @Dependency(\.mailMagazineRepository.postInputEmail) var postInputEmail 入力データをPOSTで送信する
  17. TODOリストの動作を組み立てる際におけるポイント @Reducer struct Todo { // Todo要素1個分のState & Action定義 @ObservableState

    struct State: Equatable, Identifiable { var description = "" let id: UUID var isComplete = false } enum Action: BindableAction, Sendable { case binding(BindingAction<State>) } var body: some Reducer<State, Action> { BindingReducer() } } struct TodoView: View { @Bindable var store: StoreOf<Todo> var body: some View { HStack {   // (1) 完了状態を更新するButton Button { store.isComplete.toggle() } label: { Image(systemName: store.isComplete ? "checkmark.square" : "square") } .buttonStyle(.plain) // (2) タイトル入力用のTextField TextField("Untitled Todo", text: $store.description) } .foregroundColor(store.isComplete ? .gray : nil) } } TextFieldの入力値と 直接接続する様な形 List { ForEach(   store.scope( state: \.filteredTodos, action: \.todos ) ) { todoStore in TodoView(store: todoStore) } .onDelete { store.send(.delete($0)) } .onMove { store.send(.move($0, $1)) } } Todoリスト一覧を表示する部分の抜粋 var body: some Reducer<State, Action> { BindingReducer() Reduce { state, action in // Todos(Todo要素一覧表示部分)のReducer処理 } .forEach(\.todos, action: \.todos) { Todo() } } Todosに対するReducer処理抜粋 StoreOf<Todo>を生成 Todo要素1個分のReducerを画面全体の Reducerに対して動作する様にする
  18. 一覧表示画面の中で任意のTODOを完了状態にする処理 @Reducer struct Todos { @ObservableState struct State: Equatable {

       // … 途中省略 … var todos: IdentifiedArrayOf<Todo.State> = [] } enum Action: BindableAction, Sendable {    // … 途中省略 … case todos(IdentifiedActionOf<Todo>) } var body: some Reducer<State, Action> { BindingReducer() Reduce { state, action in    // … 途中省略 … switch action {   // 一覧処理を実行する } } .forEach(\.todos, action: \.todos) { Todo() } } } 登録したTodoリストを完了した場合 ① TodoViewの見た目を変更する case .sortCompletedTodos: state.todos.sort { $1.isComplete && !$0.isComplete } return .none case .todos(.element(id: _, action: .binding(\.isComplete))): return .run { send in try await self.clock.sleep(for: .seconds(1)) await send(.sortCompletedTodos, animation: .default) } .cancellable(id: CancelID.todoCompletion, cancelInFlight: true) ② 1秒間待った後に一番後ろにSortする 参考資料: case .fetchRecentNews: return .run { send in await send(.fetchRecentNewsResponse( Result { try await self.newsRepository.fetchRecent() } )) } StoreOf<Todo>の処理と繋がっている 参考: APIリクエスト処理結果時 https://qiita.com/kalupas226/items/11d136620cb1886e2ab7 https://zenn.dev/kalupas226/articles/5b0bf98c922aa0 本資料では割愛していますが、追加・ 編集・削除処理も実装されています。 action: .binding(\.isComplete)をとする事で、TodoView に指定したstore.isComplete.toggle()を実行する。
  19. Unidirectionalな処理構造の「Clean Swift」を見る MVPアーキテクチャに近い印象はあるがInput・Outputを厳密に関連付けている ViewController Interactor Presenter interactor?.fetchArticles() Router View Worker

    (SwiftUI Screen) ViewModel Input presenter?.fetchArticlesSuccess(data: articles) Output presenter?.fetchArticlesFailure(error: error) viewController?.fetchArticlesSuccess(data: articles) viewController?.fetchArticlesFailure(error: error) Output BusinessLogic - Service (APIRequest) - Service (LocalStore) ※Service ≒ Repository ? - Send action to ViewController within Delegate in ViewModel ① Clean Swift (VIP) iOS Architecture Pattern: https://www.netguru.com/blog/clean-swift-ios-architecture-pattern ② Clean Swift iOS Architectural Design with SwiftUI: https://www.netguru.com/blog/clean-swift-with-swiftui-ios Model.Request Model.Response Model.ViewModel 画面全体と更新処理ががっちり結びつく形
  20. まとめ TCAの変化スピードは早く激しいが興味深い点も多く楽しいと感じている 1. Reduxの流れを汲む点と副作用に対する考え方を知ると理解がし易いと思います: Unidirectional(単方向)なデータの流れを実現する基本的な流れについては、共通点も多い印象がありますが、その一方で副 作用的な処理をする機構については考え方が大きく異なる点を知る事が、最初の理解を深める足掛かりになると思います。 2. MVVMでの実装からどの様に整理と分解をするかを考えてみるのも1つの手かと思います: 構成だけを一見すると全く別物の様に思いますが、InputとOutputのPropertyやMethodを整理していくと、TCAで必要な要素であ る「Action

    / Reducer / State」へ置き換えて考える事の手助けになる場合も多いと思います。 最近業務でも少し触れる機会があったり、他のArchitectureと見比べみて、完成度や便利さを実感できた気がします。 3. TCAは全体的にAppleが提供しているAPIを使うための良い形を模索している様に見えました: スピーディーかつ大きな変更やバージョンアップも頻繁にあるものの、まさしく「かゆい所に手が届く」対処が施されている点 やより便利かつ直感的な形を実現できる様に、最新iOSの変更も積極的に取り組んでいる点は本当に興味深く感じております。