N予備校 iOSアプリのViewState列挙体を使ったSwiftUIの状態管理

はじめに

N予備校 iOSアプリ開発チームに所属しているcoffmarkです。

iOSチームではSwiftUIを使って開発を進めています。 SwiftUI導入までの経緯については、ブログ記事(N予備校iOSアプリへ SwiftUI を導入するまでの道のりについて)で説明しています。

SwiftUI導入を進めていく中で、導入後に改善した点がいくつかあります。

今回はその中でもViewState列挙体を使ったSwiftUIの状態管理についてお話しします。

前提 (プロジェクト構成・SwiftUI実装方針のおさらい)

N予備校 iOSアプリチームでは以下のような構成でiOSアプリを開発しています。

詳細はブログ記事(iOSチームではリファレンスレポジトリを運用しています)をご覧ください。

大まかな概要としては下記の通りのプロジェクト構成・SwiftUI実装方針です。

プロジェクト構成

SwiftUI実装方針

  • SwiftUI.Viewは UIHostingController から利用しています。
  • SwiftUI.Viewで ObservableObject に準拠したDataSourceクラスを実装しています。
  • 画面遷移やアラートはUIKitを使って出し分けています。

ViewState列挙体を用いたSwiftUI実装

概要

今回は架空の設定画面(SettingView)を取り上げます。

以前はDataSourceクラスメンバを参照し、Viewを出し分けていました。 修正後は画面の状態を管理するViewState列挙体で画面の状態を管理するように修正し、実装の見通しが良くなりました。

画面によってViewState列挙体は異なりますが、SettingViewでは以下のような列挙体を定義しています。

enum ViewState {
  /// 初回ローディング
  case initialLoading
  /// 画面表示成功
  case success(property: Property)
  /// エラー表示
  case error(isLoading: Bool)
}

得られた知見をSwiftUI.View及びViewModelの実装を踏まえつつ、お話しします。

修正前

修正前のコードでは、DataSourceクラスに画面の表示要素や各フラグ実装していました。 画面の不整合が起きないことを担保するために、テストコードで全てのDataSourceクラスメンバの挙動を確認する必要がありました。

また複数のフラグで画面を出し分けているので、可読性も良くありません。

struct SettingView: View {
  final class DataSource: ObservableObject, ReactiveCompatible {
    /// SwiftUI.Viewの表示要素
    @Published var property: Property
    /// 初回読み込み完了フラグ
    @Published var isInitialLoaded: Bool
    /// エラー発生フラグ
    @Published var hasError: Bool

    // 他にもフラグがあります

    init() { ... }
  }

  // ...

  var body: some View {
    if dataSource.isInitialLoaded {
      if dataSource.hasError {
        // エラー画面を表示
        errorView
      } else {
        // 設定画面を表示
        settingView
      }
    }
  }
}

ViewModelの実装では、DataSourceクラスメンバを全てDriver変換し外部に公開しています。

前述した通りDataSourceクラスメンバをSwiftUI.Viewは参照しているので、各DataSourceクラスメンバを更新するたびにSwiftUI.Viewも更新されてしまいます。

final class SettingViewModel: ... {
  /// SwiftUI.Viewに表示する画面要素
  let property: Driver<Property>
  /// 初回読み込み完了フラグ
  let isInitialLoaded: Driver<Bool>
  /// エラー発生フラグ
  let hasError: Driver<Bool>

  // ...

  private let _property = BehaviorRelay<Property?>(value: nil)
  private let _isInitialLoaded = BehaviorRelay<Bool>(value: false)

  self.property = _property
    // 何らかの処理
    .asDriver(onErrorDriveWith: .empty())

  self.isInitialLoaded = _isInitialLoaded
    // 何らかの処理
    .asDriver(onErrorDriveWith: .empty())

  // 他のDataSourceクラスメンバも同様にDriver変換して外部に公開しています。
}

修正後

変更後のコードでは、DataSourceクラスメンバは viewState のみになりました。 画面の状態管理をViewState列挙体で行うことで、簡潔に実装できました。

struct SettingView: View {
  enum ViewState {
    /// 初回ローディング
    case initialLoading
    /// 画面表示成功
    case success(property: Property)
    /// エラー表示
    case error(isLoading: Bool)
  }

  final class DataSource: ObservableObject, ReactiveCompatible {
    // 修正後はクラスメンバが `viewState`のみ
    @Published var viewState: ViewState

    init() { ... }
  }

  var body: some View {
    switch dataSource.viewState {
      case .initialLoading:
        loadingView
      case let .success(property):
        settingView(property: property)
      case let .error(isLoading):
        errorView(isLoading: isLoading)
    }
  }
}

ViewModelの実装も同様にSwiftUI.Viewの構築に必要なViewModelが公開するメンバ変数は viewState のみになりました。

final class SettingViewModel: ... {
  // 修正後はクラスメンバが `viewState`のみ
  let viewState: Driver<SettingView.ViewState>

  // ViewState列挙体の状態を保持する変数を定義
  private let _viewState = BehaviorRelay<SettingView.ViewState>(value: .initialLoading)
  private let _disposeBag = DisposeBag()

  // Driver変換します。
  self.viewState = _viewState.asDriver(onErrorDriveWith: .empty())

  load
    .withLatestFrom(_viewState)
    // 何らかの処理
    .bind(to: _viewState)
    .disposed(by: _disposeBag)

  underlyingError
    .withLatestFrom(_viewState)
    // 何らかの処理
    .bind(to: _viewState)
    .disposed(by: _disposeBag)

}

またViewModelのテストでも viewState の更新回数 = Viewの更新回数と一致するため、Viewの更新回数を意識してテストを書くことができるようになりました。

補足として1画面に対して1つのViewState列挙体で管理するのが難しい場面もあります。 その場合では子ViewごとにViewState列挙体を実装しています。

課題

ViewModelでRxSwiftの withLatestFrom() 関数を多用し過ぎている点が課題として挙げられます。

多くの場合実装時に気づくことができますが、意図せずViewState列挙体を書き換える実装ミスが発生することがあります。

RxSwiftの withLatestFrom() 関数を用いたメソッドチェーンではアクセスできる変数のスコープが狭まる点などのRxSwiftの実装の利点もありますが、データ競合をコンパイル時に防いでくれるSwift Concurrencyを用いて簡潔に実装できないかを現在チームで検討している途中です。

今後に向けて

SwiftUI導入結果後様々な問題もありましたが1、結果的には画面更新回数を意識して、画面を短期間で実装できるようになりました!

また、Swift Concurrencyの導入に向けて検討を進めていこうと考えています。

N予備校 iOSチームは、iOSDC 2024に参加しています。iOSDC 2024 のチャレンジトークンは #SwiftUIの状態管理 です!

参考

Hatena Developer Blog GigaViewer for Appsで使っている便利SwiftUIコンポーネント5連発!

We are hiring!

株式会社ドワンゴの教育事業では、一緒に未来の当たり前の教育をつくるメンバーを募集しています。カジュアル面談も行っています。お気軽にご連絡ください!

カジュアル面談応募フォームはこちら

pitta.me

www.nnn.ed.nico

開発チームの取り組み、教育事業の今後については、他の記事や採用資料をご覧ください。

speakerdeck.com



  1. SwiftUIの標準Listを用いた実装でパフォーマンス上の懸念が発生しました。 (ブログ記事: N予備校iOSアプリへ SwiftUI を導入してみて List編)