はじめに
こんにちは、iOSエンジニアの牟田です。
2019年に登場し界隈を賑わせたSwiftUIも3歳になり徐々に業務でも扱いやすくなってきていますね(もちろんまだまだ課題は山積みですが)。 また、2021年にはSwift Concurrencyの登場により非同期処理のパラダイムシフトが起こりました。
ウェルスナビでも重い腰を上げてSwiftUIとSwift Concurrencyをベースとした設計の検討を始め、ようやくその目処が立ったので共有します。
現状のアーキテクチャ
ウェルスナビでは初期リリースから5年以上経過しており、その間に追加された時期によって画面のアーキテクチャがバラバラ(ある画面ではMVP、別の画面ではMVVM、またある画面ではClean Architecture etc...)という課題がありました。
そこで複数実装されていたアーキテクチャの中から最も相性の良かったStatefulアーキテクチャを採用し、2019年頃から2年程かけて全画面のアーキテクチャを統一するという取り組みを行ってきました。
考え方的にはReduxやTCAが近いですが異なる部分も多いのでここで解説していきます。
※ ReduxやTCAについての解説はここでは省略します。
全体像と要素
State
画面の状態を保持するstructViewStore
Stateを保持するオブジェクトCommand
画面からユーザアクションなどを介して発行されるイベントAction
Stateを更新するためのイベントEffect
Stateを更新せず画面に影響を与えるイベント- ProgressHUDの表示/非表示
- アラート表示
- 画面遷移
※前提として画面レイアウト(Auto Layout設定)は基本Storyboard/XIBで実装、ViewControllerとState
のbindingや非同期処理はRxSwiftを使用しています。
① 画面の描画やユーザの操作によるイベントに対してCommand
が発行されViewStore
に送られます。
② ViewStore
はそのCommand
に応じて通信したりメモリやUserDefaults、Keychain等に保存しているデータを読み書きしたりします。
③ State
を変更する必要があればAction
を、④ 不要であればEffect
を発行します。
⑤ Effect
はそのままPublishRelay
を介してViewControllerがハンドリングします。
⑥ Action
はdispatchAction
(ReduxのReducer
に相当するstatic func)に送られ、新しいState
を生成します。
⑦ 生成されたState
はBehaviorRelay
を介してViewControllerがハンドリングします。
実際のコード(一部抜粋・改変)
以下はログイン画面のコードを一部抜粋・改変したものになります。
LoginViewController
final class LoginViewController: UIViewController { struct Dependency { var viewStore: LoginViewStore var coordinator: Coordinator } private var dependency: Dependency private let disposeBag = DisposeBag() // MARK: Outlets // (中略) // MARK: Initializers // (中略) // MARK: life cycles override func viewDidLoad() { super.viewDidLoad() bind() } } private extension LoginViewController { func bind() { let state = viewStore.state.asObservable() disposeBag.insert { state.map(\.loginId) .distinctUntilChanged() .bind(to: loginIdTextField.rx.text) state.map(\.password) .distinctUntilChanged() .bind(to: passwordTextField.rx.text) viewStore.effect .emit(with: self, onNext: { $0.handleEffect($1) }) loginButton.rx.tap .bind(with: self, onNext: { $0.viewStore.dispatchCommand(.logIn) }) } loginIdTextField.onEditing = { [weak self] in guard let self else { return } if !$0 { self?.dispatchCommand(.inputLoginId(self.loginIdTextField.text)) } } passwordTextField.onEditing = { [weak self] in guard let self else { return } if !$0 { self?.dispatchCommand(.inputPassword(self.passwordTextField.text)) } } } } // MARK: - Handling private extension LoginViewController { func handleEffect(_ effect: LoginViewEffect) { switch effect { case .showProgress: // ProgressHUDを表示 case .dismissProgress: // ProgressHUDを非表示 case let .presentError(error): // エラーダイアログを表示 case let .complete(userId): // 次の画面へ } } }
LoginViewStore
// MARK: - State struct LoginViewState: Sendable { var loginId: String? var password: String? } // MARK: - Command enum LoginViewCommand { case inputLoginId(String?) case inputPassword(String?) case logIn } // MARK: - Effect enum LoginViewEffect { case showProgress case dismissProgress case presentError(Error) case complete(userId: String) } // MARK: - Action enum LoginViewAction { case loginIdInput(String?) case passwordInput(String?) } extension LoginViewAction { static func transform(_ state: LoginViewState, action: LoginViewAction) -> LoginViewState { switch action { case let .loginIdInput(loginId): return state.mutate(keyPath: \.loginId, value: loginId) case let .passwordInput(password): return state.mutate(keyPath: \.password, value: password) } } } // MARK: - ViewStore protocol LoginViewStore { var state: Driver<LoginViewState> { get } var effect: Signal<LoginViewEffect> { get } var currentState: LoginViewState { get } func dispatchCommand(_ command: LoginViewCommand) } final class LoginViewStoreImpl { private let _effect: PublishRelay<LoginViewEffect> = .init() private let _state: BehaviorRelay<LoginViewState> private let disposeBag = DisposeBag() // Middlewares init(/* inject middlewares and initial state */) { } } extension LoginViewStoreImpl: LoginViewStore { var state: Driver<LoginViewState> { _state.asDriver() } var effect: Signal<LoginViewEffect> { _effect.asSignal() } var currentState: LoginViewState { _state.value } func dispatchCommand(_ command: LoginViewCommand) { handleCommand(command) } } private extension LoginViewStoreImpl { func dispatchAction(_ action: LoginViewAction) { let newState = LoginViewAction.transform(currentState, action: action) _state.accept(newState) } func dispatchEffect(_ effect: LoginViewEffect) { _effect.accept(effect) } func handleCommand(_ command: LoginViewCommand) { switch command { case let .inputLoginId(loginId): dispatchAction(.loginIdInput(loginId)) case let .inputPassword(password): dispatchAction(.passwordInput(password)) case .logIn: guard let loginId = currentState.loginId, let password = currentState.password else { return } logIn(loginId: loginId, password: password) } } } private extension LoginViewStoreImpl { func logIn(loginId: String, password: String) { let loginRequest = // create request requestService.rx.response(request: loginRequest) .map { ($0.accessToken, $0.refreshToken) } .flatMap { /* Fetch user-id */ } .do( with: self, onSubscribe: { $0.dispatchEffect(.showProgress) }, onDispose: { $0.dispatchEffect(.dismissProgress) } ) .map(\.userId) .subscribe( with: self, onSuccess: { $0.dispatchEffect(.complete(userId: $1)) }, onFailure: { $0.dispatchEffect(.presentError($1)) } ) .disposed(by: disposeBag) } }
本アーキテクチャを採用したメリット
Statefulなアーキテクチャの特徴として、同じStateを流せば必ず同じ画面になることが挙げられます。 そのため想定される画面に応じたStateを用意してあげるだけでsnapshot testが作れます。
また、画面の状態とビジネスロジックが切り離されているためViewStoreのモックが非常にシンプルになります。
また、Command
とEffect
をenumで定義することによって処理に変更が入ってもモックに手を加える必要が一切なくなります。
Snapshot
@MainActor final class LoginViewController_Snapshot: SnapshotTestCase { func testSnapshot_初期状態() { let vc = makeVC(with: .init()) verifyViewController(vc) } func testSnapshot_ログインID_パスワード入力() { let state = LoginViewState( loginId: "login-test@wealthnavi.com", password: "testpass" ) let vc = makeVC(with: state) verifyViewController(vc) } } @MainActor private func makeVC(with state: LoginViewState) -> LoginViewController { let dependency = LoginViewController.Dependency( viewStore: MockViewStore(state: state), coordinator: FakeCoordinator() ) return LoginViewController(dependency: dependency) }
MockViewStore
private final class MockViewStore: LoginViewStore { private let _state: BehaviorRelay<LoginViewState> var state: Driver<LoginViewState> { _state.asDriver() } var effect: Signal<LoginViewEffect> { .empty() } var currentState: LoginViewState { _state.value } init(state: LoginViewState) { _state = .init(value: state) } func dispatchCommand(_ command: LoginViewCommand) { // no-op } }
課題とSwiftUI+Swift Concurrencyに移行するモチベーション
先述の通りStoryboard/XIBで実装しているのはAutoLayoutの設定のみで各ラベルのテキストやボタンの装飾等は全てコード上で実装しています。これは動的に変更するラベルと固定のラベルで実装箇所が変わることを防ぐためです。
しかし、その弊害としてStoryboard上でUIの確認ができないためアプリを実行して画面を確認するかsnapshotを撮るまで画面の確認ができないという課題があります。
また、StoryboardやXIBはXMLベースのためレビューがしにくいという難点や1コンポーネントに対して2ファイル(.swift
, .xib
)追加するためproject.pbxproj
ファイルの肥大化を助長しています。
SwiftUIの登場はこれらの課題を一挙に解決してくれるのではないかと考えました。 また、結果論ですがStatefulなアーキテクチャはSwiftUI等の宣言的UIと相性が良いため他のアーキテクチャと比べて小さいコストで移行ができるのではないかという仮説を立てました。
一方、ライブラリの管理をCocoaPodsからSwift Package Manager(以降SwiftPM)に移行する計画も並行しているのですがSwiftPMに未解決の致命的な問題1がありRxSwiftがSwiftPMに移行できていません。
また、RxSwiftは非常に優秀なフレームワークであることは間違いないのですが習得難度が高いという弊害もあります。
Swift Concurrencyの登場によって非同期処理をRxSwiftで書く必要が無くなること、SwiftUIによってRxCocoaによるバインディングが不要になることからRxSwiftへの依存を剥がせる展望が見えました。
Swift Concurrencyへの移行
まずはこのアーキテクチャのうちViewStore
をSwift Concurrencyベースに置き換えることを考えます。
しかし、一気にSwift Concurrencyへ移行しようとするとViewController
にも手をいれないといけず影響範囲が多岐にわたるので、取り急ぎ通信部分のみ移行します。
requestService.rx.response
はRxSwiftのSingle
を返すfuncですが、これを予め async throws
に置き換えられるようfuncを実装します。
RequestService
// Before private extension RequestService { func responseCodable<R: Request>(request: R, completion: @escaping (Result<R.Response, Error>) -> Void) -> DataRequest? { // 実際の通信処理 } } extension Reactive where Base: RequestService { func response<R: Request>(request: R) -> Single<R.Response> where R.Response: Codable { Single.create { [base] event in let task = base.response(request: request) { result in switch result { case .success(let entity): event(.success(entity)) case .failure(let error): event(.failure(error)) } } return Disposables.create { task?.cancel() } } } } // After extension RequestService { func response<R: Request>(request: R) async throws -> R.Response { // 実際の通信処理(responseCodableの内部をコピペしつつasync/awaitで改変) } }
この時、BeforeとAfterの結果が同じになることを保証するためRx版とSwift Concurrency版それぞれで同じテストを書いておきます。
RequestServiceTests
/// Test for Rx func testRxSuccess() throws { try createStub() let result = try requestService.rx .response(request: TestRequest()).toBlocking().single() XCTAssertTrue(result.hoge) XCTAssertEqual(result.fuga, 1) XCTAssertEqual(result.foo, "bar") } /// Test for Swift Concurrency func testSuccess() async throws { try createStub() let result = try await requestService.response(request: TestRequest()) XCTAssertTrue(result.hoge) XCTAssertEqual(result.fuga, 1) XCTAssertEqual(result.foo, "bar") }
これで通信処理をSwift Concurrencyに置き換える準備が整いました。 それでは実際に置き換えます。
Before
func logIn(loginId: String, password: String) { let loginRequest = // create request requestService.rx.response(request: loginRequest) .map { ($0.accessToken, $0.refreshToken) } .flatMap { /* Fetch user-id */ } .do( with: self, onSubscribe: { $0.dispatchEffect(.showProgress) }, onDispose: { $0.dispatchEffect(.dismissProgress) } ) .map(\.userId) .subscribe( with: self, onSuccess: { $0.dispatchEffect(.complete(userId: $1)) }, onFailure: { $0.dispatchEffect(.presentError($1)) } ) .disposed(by: disposeBag) }
After
func logIn(loginId: String, password: String) async throws { dispatchEffect(.showProgress) defer { dispatchEffect(.dismissProgress) } let loginRequest = // create request let res = try await requestService.response(request: loginRequest) let (accessToken, refreshToken) = (res.accessToken, res.refreshToken) // (中略) let userId = try await /* Fetch user-id */ dispatchEffect(.complete(userId: userId)) }
RxSwiftの map
や flatMap
等を挟んでいたためにコードが追いづらかったりデバッグしづらかったりと苦労させられていたのが、 async/await
で書き直すことで一気に見通しがよくなりました。
またViewControllerとのバインディング部分以外からRxSwiftの要素を取り除いたことでSwiftUIへの移行も容易になりました。
SwiftUIへの移行
さて、下準備がある程度済んだので本格的に画面のSwiftUI化を考えていきます。
SwiftUIに移行した後の全体像は次のようになります。
変更点は以下の通りです。
ViewStore
をObservedObject
にstate
をBehaviorRelay
から@Published
にeffect
をPublishRelay
からPassthroughSubject
にMiddlewares
とのやり取りをSingle
からasync/await
ベースに
ViewController
->HostingController
+SwiftUI.View
- HostingControllerはViewへのDIと画面遷移を受け持つ
実際のコードを見た方が分かりやすいと思うので以下に抜粋したものを記載しておきます。
LoginView
protocol LoginViewDelegate: AnyObject { func transit(forEvent event: LoginTransitionEvent) } struct LoginView<ViewStore: LoginViewStore>: View { @StateObject private var viewStore: ViewStore weak var delegate: (any LoginViewDelegate)? init(viewStore: ViewStore) { _viewStore = .init(wrappedValue: viewStore) } var body: some View { VStack { if case let .completed(content) = viewStore.state { // (簡略化) TextField( "", text: Binding { content.loginId } set: { viewStore.dispatchCommand(.inputLoginId($0)) } ) SecureField( "", text: Binding { content.password } set: { viewStore.dispatchCommand(.inputPassword($0)) } ) Button("Log in") { viewStore.dispatchCommand(.logIn) } } }.onReceive(viewStore.effect) { switch $0 { case .showProgress: // ProgressHUDを表示 case .dismissProgress: // ProgressHUDを非表示 case let .presentError(error): // エラーダイアログを表示 case let .complete(userId): delegate?.transit(forEvent: .complete) } } } }
ViewState
はそのままにして双方向バインディングにしても良かったのですが、単一方向データフローとしての一貫性を持たせるためにローカルBinding
を使用しています。
また、ViewStore
を View
の型パラメータとして定義し、snapshot testやPreview用のコードを書きやすくしています。
LoginViewController
final class LoginViewController: UIHostingController<LoginView<LoginViewStoreImpl>> { typealias ViewStore = LoginViewStoreImpl struct Dependency { var viewStore: ViewStore weak var coordinator: Coordinator? } private var dependency: Dependency // MARK: Initializers init(dependency: Dependency) { self.dependency = dependency super.init(rootView: LoginView(viewStore: dependency.viewStore)) rootView.delegate = self // `super.init` の後でないと `self` を渡せない } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) should never been called directly.") } } // MARK: - Dependency extension LoginViewController: LoginViewDelegate { func transit(forEvent event: LoginTransitionEvent) { // 画面遷移 } }
画面遷移の機構をここに集約することでViewで持たせる状態を減らしています。
また、ここに実際の型パラメーターを持たせることでDIコンテナのような役割も果たしています。 100%SwiftUIに移行する際に負債となる可能性がありますが、一旦割り切っています。
LoginViewStore
// MARK: - State struct LoginViewContent: Sendable { var loginId = "" var password = "" } /* * enum ViewState<Content, Failure: Error> { * /// 画面描画に必要な情報を読み込む(必要な場合) * case loading * /// 読み込み成功時または初期状態 * case completed(Content) * /// 読み込み失敗時にAlert以外の特別な画面が必要な場合 * case failed(Failure) * } */ typealias LoginViewState = ViewState<LoginViewContent, Never> // MARK: - Command enum LoginViewCommand { case inputLoginId(String) case inputPassword(String) case logIn } // MARK: - Effect // 変更無いため省略 // MARK: - Action enum LoginViewAction { case loginIdInput(String) case passwordInput(String) } extension LoginViewAction { static func transform(_ state: LoginViewState, action: LoginViewAction) -> LoginViewState { switch action { case let .loginIdInput(loginId): return state.mutate(keyPath: \.loginId, value: loginId) case let .passwordInput(password): return state.mutate(keyPath: \.password, value: password) } } } // MARK: - ViewStore // Genericsで汎用化したprotocolを用意 protocol LoginViewStore: ViewStore<LoginViewState, LoginViewCommand, LoginViewEffect> { } // MARK: - ViewStoreImpl final class LoginViewStoreImpl: LoginViewStore { @Published var state: LoginViewState = .completed(.init()) private let _effect: PassthroughSubject<LoginViewEffect, Never> = .init() // Middlewares init(/* inject middlewares and initial state */) { } } extension LoginViewStoreImpl { var effect: AnyPublisher<LoginViewEffect, Never> { _effect.eraseToAnyPublisher() } func dispatchCommand(_ command: LoginViewCommand) async { do { try await handleCommand(command) } catch { guard !Task.isCancelled else { return } dispatchEffect(.presentError(error)) } } } private extension LoginViewStoreImpl { func dispatchAction(_ action: LoginViewAction) { state = LoginViewAction.transform(state, action: action) } func dispatchEffect(_ effect: LoginViewEffect) { _effect.accept(effect) } func handleCommand(_ command: LoginViewCommand) async throws { switch command { case let .inputLoginId(loginId): dispatchAction(.loginIdInput(loginId)) case let .inputPassword(password): dispatchAction(.passwordInput(password)) case .logIn: try await logIn() } } } private extension LoginViewStoreImpl { func logIn() async throws { guard case let .completed(content) = state else { assertionFailure("Invalid state") return } let loginId = content.loginId let password = content.password dispatchEffect(.showProgress) defer { dispatchEffect(.dismissProgress) } let loginRequest = // create request let res = try await requestService.response(request: loginRequest) let (accessToken, refreshToken) = (res.accessToken, res.refreshToken) // (中略) let userId = try await /* Fetch user-id */ dispatchEffect(.complete(userId: userId)) } }
UILabel
の仕様上Optionalにせざるを得なかった項目がnon-Optionalになりました。
また、dispatchCommand
を async
にすることでiOS15以上から使えるようになるtask
に備えています。
この画面では不要ですが、onAppear
で画面情報をロードするような画面のリファクタリングを簡素化できます。
まとめ
ウェルスナビにおけるiOSアプリの現状とそれを段階的にリファクタリングしていくことでSwiftUIへの移行を進めていく手順をご紹介しました。
全体的にRxSwiftへの依存が無くなりSwiftUI+Swift Concurrencyベースのコードに変換されただけでView
以外の大筋はほぼ変わっていないことが分かります。
Viewについてはまだ改良の余地がありますが、他の画面も同様に進めていくことで将来的には完全な脱RxSwiftを目指していきます。
ウェルスナビでは、一緒に働く仲間を募集しています。
https://hrmos.co/pages/wealthnavi/
筆者プロフィール
牟田 拓広(むた たくひろ)
2019年9月ウェルスナビにiOSエンジニアとして入社。
現在はプロダクト開発だけでなくCI周りの整備も担当。
Auto LayoutやARCが登場したiOS6くらいの頃からiOS開発に触れてきた。
プライベートではAndroidアプリやRuby・Python等のスクリプトも書いてたり。