iOSアプリにおけるアーキテクチャの現状とSwiftUI + Swift Concurrencyへの移行の展望

2024/05/08に公開

はじめに

こんにちは、iOSエンジニアの牟田です。

2019年に登場し界隈を賑わせたSwiftUIも3歳になり徐々に業務でも扱いやすくなってきていますね(もちろんまだまだ課題は山積みですが)。
また、2021年にはSwift Concurrencyの登場により非同期処理のパラダイムシフトが起こりました。

ウェルスナビでも重い腰を上げてSwiftUISwift Concurrencyをベースとした設計の検討を始め、ようやくその目処が立ったので共有します。

現状のアーキテクチャ

ウェルスナビでは初期リリースから5年以上経過しており、その間に追加された時期によって画面のアーキテクチャがバラバラ(ある画面ではMVP、別の画面ではMVVM、またある画面ではClean Architecture etc...)という課題がありました。

そこで複数実装されていたアーキテクチャの中から最も相性の良かったStatefulアーキテクチャを採用し、2019年頃から2年程かけて全画面のアーキテクチャを統一するという取り組みを行ってきました。

考え方的にはReduxTCAが近いですが異なる部分も多いのでここで解説していきます。
※ ReduxやTCAについての解説はここでは省略します。

全体像と要素


現状のアーキテクチャの全体像

  • State
    画面の状態を保持するstruct
  • ViewStore
    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がハンドリングします。
ActiondispatchAction(ReduxのReducerに相当するstatic func)に送られ、新しいStateを生成します。
⑦ 生成されたStateBehaviorRelayを介して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のモックが非常にシンプルになります。
また、CommandEffectを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]がありRxSwiftSwiftPMに移行できていません。

また、RxSwiftは非常に優秀なフレームワークであることは間違いないのですが習得難度が高いという弊害もあります。

Swift Concurrencyの登場によって非同期処理をRxSwiftで書く必要が無くなること、SwiftUIによってRxCocoaによるバインディングが不要になることからRxSwiftへの依存を剥がせる展望が見えました。

Swift Concurrencyへの移行

まずはこのアーキテクチャのうちViewStoreSwift Concurrencyベースに置き換えることを考えます。
しかし、一気にSwift Concurrencyへ移行しようとするとViewControllerにも手をいれないといけず影響範囲が多岐にわたるので、取り急ぎ通信部分のみ移行します。
requestService.rx.responseRxSwiftSingleを返す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))
}

RxSwiftmapflatMap 等を挟んでいたためにコードが追いづらかったりデバッグしづらかったりと苦労させられていたのが、 async/await で書き直すことで一気に見通しがよくなりました。
またViewControllerとのバインディング部分以外からRxSwiftの要素を取り除いたことでSwiftUIへの移行も容易になりました。

SwiftUIへの移行

さて、下準備がある程度済んだので本格的に画面のSwiftUI化を考えていきます。

SwiftUIに移行した後の全体像は次のようになります。

移行後のアーキテクチャ

変更点は以下の通りです。

  • ViewStoreObservedObject
    • stateBehaviorRelay から @Published
    • effectPublishRelay から 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を使用しています。

また、ViewStoreView の型パラメータとして定義し、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になりました。

また、dispatchCommandasync にすることで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等のスクリプトも書いてたり。

脚注
  1. https://github.com/ReactiveX/RxSwift#swift-package-manager ↩︎

WealthNavi Engineering Blog

Discussion