iOS SwiftUI mobile mvvm architecture minne

SwiftUI時代におけるminneのリアクティブアーキテクチャ

iOS SwiftUI mobile mvvm architecture minne

山奥の砦(八王子市)からminneのiOSチームでアーキテクチャや開発環境を色々と見ているjoshです。SwiftUIとCombineが発表されたWWDC 2019から、SwiftUIとUIKitの混在状態を前提としたminneアプリに最適なアーキテクチャについて検討・議論した結果、最近色々と決まったので、紹介したいと思います。

まず、アーキテクチャ決めの目的ですが、テストしやすい形で迷わずに開発でき、機能開発を効率よくしつつ、今後の保守や変更も楽に行い、生産性と品質を上げることです。minneは特徴として、ネットワーク通信の多いアプリで、Universal Linksやプッシュ通知など、数多くの起動経路を持っています。また、minneマーケットプレイスの作品をさまざまな購入方法で提供しているので、開発をする中で特にその周りで不具合が出ないように日々開発しています。作家、購入者・ゲストごとたくさんの機能を提供しているため、140個以上の画面があり、平均に比べて、大きめな中規模アプリかなと思います。

背景

背景として、minne iOSの開発は2013年に本格的に始まり、100% Objective-CでエンティティとViewControllerとたまにManagerと呼ばれるクラスでコードを書き、典型的なFat View Controllerアーキテクチャになっていました。機能が増え、様々な課題が出てきたので、2017年くらいからアーキテクチャについて議論し、Model View Presenter(MVP)を採用することになりました。画面遷移やモデル層のアーキテクチャについて、特に決まったことはありませんでした。

2019年にSwiftUIとCombineが発表され、また、minneが90%以上Swift製になりました。SwiftUIでは、明示的に画面更新ができず、また、Combineの依存によりリアクティブなアプローチが求められ、MVPのままだと、SwiftUIへ移行しにくいのではないかという疑問がありました。そのため、違うGUIアーキテクチャを検討し、MVVMにするとチームで決めました。

ただ、残っている課題として画面遷移や画面間のデータ共有方法の決まったパターンがなく、モデル層があやふやだったので、minneに合う設計について引き続き調査し議論しました。VIPER、Clean Architecture、Flux、The Composable Architecture (TCA)など、モデル層がよりはっきりと決まったアーキテクチャを参考にし、プロトタイプアプリで実験しました。

また、MVVMをやめてTCAにするということも一応検討し、実際プロダクション機能で試しましたが、以下の理由で、TCAの採用を最近見送りました。

  • プッシュ通知などの起動経路が非常に多いので、ビュー階層を一新しない限り、AppState, AppAction, AppEnvironmentがそれぞれ膨大になってしまう
  • フレームワークを使うことになるので、わりとTCAの実装の細部まで知る必要があり、テストやビューコード、ビジネスロジックがかなり外部ライブラリに依存してしまう
  • 一気にTCAに乗り換えることができず、MVP/MVVM/TCA混在の期間が長そうで、脳内のスイッチングコストが高いと予想される
  • Store/Reducer/Environmentの細かい実装で画面間のデータ共有方法は思ったより考えることがある

最終的に、画面間のデータ共有を目的としたRepository層を定義し、GUI設計を引き続きMVVMで実装するように決めました。Repository層とは、複数画面で使われるロジックを集約し、データベース、ネットワーク、UserDefaults、KeychainのデータをIn-Memoryで保持します。詳細は後述します。

全体像

まず層ごとに説明します。

レイヤー図

アーキテクチャ図のレイヤー

依存フロー図

依存フロー

View Layer

UIViewController、UIView、UITableViewCell、UICollectionViewCell、SwiftUI.Viewなど、UIKitまたはSwiftUIの型を扱う最上位レイヤーです。画面遷移の実装やUIKit Delegate + DataSource準拠をここでします。UIKitとSwiftUIを唯一扱えるレイヤーで、基本、スナップショットの統合テストのみで品質担保を行います。ViewController/ScreenViewとCell & Subview間のデータ共有は基本コールバックまたはBindingで行います。

import SwiftUI
import UIKit

struct ProductListView: View {
  enum TransitionItem { case productDetail(product: Product, repository: ProductRepository) }

  @ObservedObject private var viewModel: ProductListViewModel
  // 画面遷移は外側のUIHostingControllerで行うので、pushの処理をConstructor Injectionで提供する
  private let pushAction: (UIViewController) -> Void

  init(
    viewModel: ProductListViewModel = ProductListViewModel,
    pushAction: @escaping (UIViewController) -> Void
  ) {
    self.viewModel = viewModel
    self.pushAction = pushAction
  }

  var body: some View {
    CustomList(viewModel.products) { product in
      ProductRow(product: product)
    }
    .onAppear(perform: viewModel.onAppear)
    .onReceive(viewModel.$transitionItem, perform: transition)
  }

  private func transition(to transitionItem: TransitionItem?) {
    guard let transitionItem = transitionItem else { return }
    switch transitionItem {
    case let .productDetail(product, repository):
      let productDetail = ProductDetailViewController(product: product, repository: repository)
      pushAction(vc)
    }
  }
}

View-Specific Model Layer

ViewModelで構成されており、1画面に対して1モデルです。CombineとFoundationを扱い、UIKitとSwiftUIを扱いません。View Layerへのアウトプットは、オブサーバー同期(Combine Publisher)でデータ更新を行い、インプットは単純なメソッド呼び出しです。ViewModelはMVVMの仕様どおり、直接ビューを操作しません。

ViewModel

UIKitでは、プロトコルで抽象化したパターンを使っており、双方向バインディングを行わない方針です。

import Combine

protocol ProductViewModelInputs {
  func hogeButtonDidTap()
}
protocol ProductListViewModelOutputs {
  var shouldDismissKeyboard: AnyPublisher<Bool, Never> { get }
  var hogeButtonIsEnabled: AnyPublisher<Bool, Never> { get }
  var transitionItem: AnyPublisher<ProductListViewController.TransitionItem?, Never> { get }
}
protocol ProductListViewModelType {
  var inputs: ProductListViewModelInputs { get }
  var outputs: ProductListViewModelOutputs { get }
}

final class ProductListViewModel: ProductListViewModelType {
  typealias Dependency = HasErrorHandler
  typealias TransitionItem = ProductListViewController.TransitionItem

  // MARK: - ProductListViewModelType
  var inputs: ProductListViewModelInputs { self }
  var outputs: ProductListViewModelOutputs { self }

  // 常に状態を持たない、イベント系のデータなので、PassthroughSubjectを使う
  // Equatable準拠なので、Voidではなく、Boolを使う
  private let _shouldDismissKeyboard = PassthroughSubject<Bool, Never>()
  // 常に、enable状態が存在するので、`@Published`または`CurrentValueSubject`を使う
  // @Publishedのほうが若干記述量少ないので、便利
  @Published private var _hogeButtonIsEnabled = false
  @Published private var _transitionItem: TransitionItem?

  private let dependency: Dependency
  private let repository: ProductRepositoryProtocol
  private var cancellables: Set<AnyCancellable> = []

  init(
    dependency: Dependency = AppDependency(),
    repository: ProductListRepositoryProtocol = ProductListRepository()
  ) {
    self.dependency = dependency
    self.repository = repository
  }

}

// MARK: - ProductListViewModelOutputs
extension ProductListViewModel: ProductListViewModelOutputs {

  var shouldDismissKeyboard: AnyPublisher<Bool, Never> { _shouldDismissKeyboard.eraseToAnyPublisher() }

  var hogeButtonIsEnabled: AnyPublisher<Bool, Never> { $_hogeButtonIsEnabled.eraseToAnyPublisher() }

  var transitionItem: AnyPublisher<TransitionItem?, Never> { $_transitionItem.eraseToAnyPublisher() }

}

// MARK: - ProductListViewModelInputs
extension ProductListViewModel: ProductListViewModelInputs {

  func hogeButtonDidTap() {
    makeNetworkRequest()
  }

}

// MARK: - Private
private extension ProductListViewModel {

  func makeNetworkRequest() {...}

}

SwiftUIでは、ObservableObjectがProtocol with Associated Type (PAT)なので、ViewModelをプロトコルで抽象化できず、そのまま具体的な型として使っています。また、SwiftUIの仕組み上、Bindingなどの双方向バインディングが基本になっているので、UIKitと違い、やむを得ず双方向になる場合があります。

final class ProductListViewModel: ObservableObject {
  typealias TransitionItem = ProductListView.TransitionItem
  typealias Dependency = HasLogger & HasErrorHandler

  // MARK: - Outputs
  @Published var isLoading = true // @Bindingとして扱うために`private(set)`にできない
  @Published private(set) var products: [Product] = []
  @Published private(set) var transitionItem: TransitionItem?

  private let repository: ProductRepositoryProtocol
  private let dependency: Dependency
  private var cancellables: Set<AnyCancellable> = []

  init(
    repository: ProductRepositoryProtocol = ProductRepository(),
    dependency: Dependency = AppDependency()
  ) {
    self.repository = repository
    self.dependency = dependency
  }
}

// MARK: - Inputs
extension ProductListViewModel {

  func onAppear() {
    dependency.logger.post(tag: .screen(.productList))
    downloadProduct()
  }

  func didTapProduct(_ product: Product) {
    transitionItem = .productDetail(id: product.id)
  }

  func didShowProduct(at index: Int) {
    guard repository.shouldPreDownload(displayedCount: index) else { return }
    downloadNextProductItems()
  }
}

// MARK: - Private
private extension ProductListViewModel {
  func downloadNextInfoItems() {...}
}

Repository Layer

複数のViewModelで使われるロジックを持ち、ビュー間の状態共有もしやすくします。ネットワーク層・データベース層・UserDefaultsなどの処理のビジネスロジックを持ち、データのキャッシュとして機能することもあります。

.NET Microservices: Architecture for Containerized .NET ApplicationsPatterns of Enterprise Application Architectureで定義されているように、Repositoryは、データソースにアクセスするためのロジックを抽象し、エンティティの集合体を保持して、取得クエリーの処理をしやすくします。

import Combine

protocol ProductRepositoryProtocol {
  func fetchNewestProducts() -> AnyPublisher<Product, RepositoryError>
}

final class ProductRepository: ProductRepositoryProtocol {
  // テストしやすいように、UserDefaultsやデータベースなどを直接保持しない
  private let userDefaultsProvider: UserDefaultsProviderProtocol
  private let db: ProductDatabaseProtocol
  private let requester: ProductRequesterProtocol

  init(...) {...}

  /// 最新の作品を取得し、dbにキャッシュする
  func fetchNewestProducts() -> AnyPublisher<Product, RepositoryError> {...}
}

Entity

一種類のcompound typeでビジネスエンティティを意味します。値型の構造体または列挙型で定義します。

struct Product {
  let name: String
  let cost: Int
}

Network Layer

APIClient/GraphQLClient

RESTまたはGraphQLのネットワークオペレーションを行うクラスです。

Endpoint/Operation

APIEndpointなどのEndpoint/Operationプロトコルに準拠する構造体です。1つのRESTエンドポイントまたはGraphQL Operation (Query/Mutationなど)を表します。

Requester

APIリクエストのみを行うもので、APIClientの呼び出しを抽象化することで、Repository層をテストしやすくします。状態は持ちません。

import Combine

protocol ProductRequesterProtocol {
  func getProduct(id: String) -> NetworkPublisher<GETProductEndpoint>
}

struct ProductRequester: ProductRequesterProtocol {
  private let apiClient: APIClientProtocol
  init(apiClient: APIClientProcotol = APIClient()) {
    self.apiClient = apiClient
  }

  func getProduct(id: String) -> NetworkPublisher<GETProductEndpoint> {
    let endpoint = GETProductEndpoint(id: id)
    return apiClient.request(endpoint: endpoint)
  }
}

Utility Layer

View Specificではない、基本的に状態を持たない便利ユーティリティのレイヤーです。

Adaptor

一方で定められたインターフェースに対してもう一方を満たすようにするものです。具体的にはライブラリの処理に対して下記を行います。

  1. Wrap
    • アプリのコードをSwiftの標準ライブラリとFoundation以外のライブラリ使用から守るため
    • 例えば、PassKitのコードをラップして、minneのあちこちでインポートする必要をなくす
  2. 変換
    • ViewModelかどこで、通常のコードで使いやすく、間違いにくくするために、より一般的なインターフェイスにするため
    • 例えば、C言語の使いにくい型をSwiftの型に変換する
  3. 共通化する
    • 特定のオブジェクトを作らなかったら、同じ処理を何回も書かないといけない処理
    • 例えば、UIApplicationShortcutItemの初期化処理は一箇所で共通化しないと、初期化コードがあちこちに散らばる
import WebKit

final class WKNavigationDelegateAdaptor: NSObject, WKNavigationDelegate {
  typealias DecidePolicyFor = ((URL, WKNavigationAction, (WKNavigationActionPolicy) -> Void) -> Void)
  private let decidePolicyFor: DecidePolicyFor

  typealias DidFail = ((URL, WKNavigation, Error) -> Void)
  private let didFail: DidFail

  typealias DidFailProvisionalNavigation = ((URL, WKNavigation, Error) -> Void)
  private let didFailProvisionalNavigation: DidFailProvisionalNavigation

  init(
    decidePolicyFor: @escaping DecidePolicyFor = { _, _, decisionHandler in decisionHandler(.allow) },
    didFail: @escaping DidFail = { _, _, _ in },
    didFailProvisionalNavigation: @escaping DidFailProvisionalNavigation = { _, _, _ in }
  ) {
    self.decidePolicyFor = decidePolicyFor
    self.didFail = didFail
    self.didFailProvisionalNavigation = didFailProvisionalNavigation
  }

  func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
    if let url = webView.url {
      decidePolicyFor(url, navigationAction, decisionHandler)
    } else {
      assertionFailure("urlがない")
      decisionHandler(.cancel)
    }
  }

  func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
    if let url = webView.url {
      didFail(url, navigation, error)
    } else {
      assertionFailure("urlがない")
    }
  }

  func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation, withError error: Error) {
    if let url = webView.url {
      didFailProvisionalNavigation(url, navigation, error)
    } else {
      assertionFailure("urlがない")
    }
  }
}

依存性の注入(DI)

DIコンテナやDIライブラリを使用せず、constructor injectionを活用しています。Swiftの一般的なconstructor injectionでは、デフォルト値を渡し、テスト時にモックに差し替えます。

private let hoge: HogeProtocol

init(hoge: HogeProtocol = Hoge()) {
  self.hoge = hoge
}

ただ、頻繁に利用する依存性については、 Sourceryの元開発者が提唱するProtocol Compositionを活用した、constructor injectionのパターンを使います。これにより、記述量を減らし、リファクタリング時の変更箇所を減らしています。 P1 & P2 というふうに、 Protocol Compositionで、利用側に見える依存性を減らしています。目安として、20個の画面以上で使われる依存性をAppDependencyに追加するようにしています。

// 利用側

final class ViewModel {
  typealias Dependency = HasLogger & HasAppUser

  let repository: ProductRepositoryProtocol
  let dependency: Dependency

  init(
    repository: ProductRepositoryProtocol = ProductRepository(), // ここだけで使うので、従来どおりのでconstructor injection
    dependency: Dependency = AppDependency()
  ) {
    self.repository = repository
    self.dependency = dependency
  }
}

// 定義

protocol HasLogger {
  var logger: LoggerProtocol { get }
}
protocol HasAppUser {
  var appUser: AppUserProtocol { get }
}
struct AppDependency: HasLogger, HasAppUser {
  let logger: LoggerProtocol
  let appUser: AppUserProtocol
  //...
}

残りの課題

残りの課題は主に以下のとおりです。

  1. SwiftUIのViewModelをプロトコルで抽象化できないので、スナップショットテストなどのビューテストがしにくいです。ViewをViewModelTypeというジェネリックを持たせる方法はありますが、画面のビューをすべてジェネリックにすると、扱いにくくなり、バイナリーサイズなどで問題が出てくる可能性が高まるので、一旦プロトコルで抽象化しない方針です。根本的には、SwiftUI自体のテストしにくさの問題だと考えているので、今後のSwiftUIバージョンに期待しています。とりあえず、スナップショットテストでは、プレビューデータを含んだRepositoryをViewModelに渡すようにしています。この辺りは、TCAが非常に優れており、状態をViewStoreに注入できるので、TCAがこの点において勝ります。

  2. ViewModelとRepositoryで、処理をどれに置くかが決まっていない場合があります。これはあえて今定義せず、一年以上試行錯誤してから、決めが必要ならチームで決めようと思っています。今のところ、まだ困っているという実感はありません。

  3. とても薄く、ほぼいらないRepositoryまで定義する必要があります。アーキテクチャが煩雑であればあるほど、ボイラープレートが増えるのは避けられないと考えています。しばらく使ってみて、まだ課題に感じたら、RequesterをViewModelで直接持っても良いようにするか、簡易なRepositoryをコード生成できるようにしようと思っています。とりあえず、多少ボイラープレートが増えたとしても、Repositoryの定義を義務化することで、考えることを減らし、Repository層の定着を図れます。

標準化方法

アーキテクチャはチームで議論し詳細を決めるだけでは浸透しません。

なぜなら、アーキテクチャ実装の再現性が高くなく、参考にできる情報を探したり、既に決まったことについてチームメンバーにいちいち聞く必要が出てくると、アーキテクチャの浸透が難しくなり、ばらつきが増えて、コードレビューのやりとりが多くなり、せっかく生産性向上を見通したのに、本末転倒な結果になりうるからです。

そのような悲しい結果を避けたく、詳細なドキュメントを用意しました。ドキュメントの中で図面、決めた背景、責務、全体の構成、細かい実装例などを載せています。さらに、より細かい実装例が見たいときがあるので、特に参考になりそうなソースファイルへのリンクを盛り込んでいます。

ドキュメントのほか、Scaffoldテンプレートを作成し、簡単にコピー・ペーストできるように、テンプレートから生成するためのコマンドをドキュメントに記載しました。ViewModelやテストなどのテンプレートを用意することによって、実装時間をさらに短縮し、慣れていなくても基本のパターンに沿って簡単に実装できるようになっています。

アーキテクチャをチームで議論する中で、アーキテクチャパターンの認識の違いや知識のばらつきがないように、毎週の社内アーキテクチャ勉強会を開催しました。そこでTCAのREADMEやソースコード、そして様々なアーキテクチャのOSSアプリのソースコードをみんなで読んで、データフローについて話し合いました。

まとめ

もうすぐ2021年のWWDCが始まるので、SwiftUIがよりテストしやすくなる発表の有無が特に気になり、わくわくしていますが、大きな変化がない限り、minne iOSは当分MVVM + Repositoryのアーキテクチャで開発し、スムーズに機能開発と保守できると考えています。そうでなければ、調査と議論を続けます。

とりあえず、何よりアーキテクチャが決まって、明文化されているのが一番で、これが落ち着いたので、長年の登りを終え、山頂に着いた気分です。