山奥の砦(八王子市)から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 ApplicationsやPatterns 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
一方で定められたインターフェースに対してもう一方を満たすようにするものです。具体的にはライブラリの処理に対して下記を行います。
- Wrap
- アプリのコードをSwiftの標準ライブラリとFoundation以外のライブラリ使用から守るため
- 例えば、PassKitのコードをラップして、minneのあちこちでインポートする必要をなくす
- 変換
- ViewModelかどこで、通常のコードで使いやすく、間違いにくくするために、より一般的なインターフェイスにするため
- 例えば、C言語の使いにくい型をSwiftの型に変換する
- 共通化する
- 特定のオブジェクトを作らなかったら、同じ処理を何回も書かないといけない処理
- 例えば、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
//...
}
残りの課題
残りの課題は主に以下のとおりです。
-
SwiftUIのViewModelをプロトコルで抽象化できないので、スナップショットテストなどのビューテストがしにくいです。ViewをViewModelTypeというジェネリックを持たせる方法はありますが、画面のビューをすべてジェネリックにすると、扱いにくくなり、バイナリーサイズなどで問題が出てくる可能性が高まるので、一旦プロトコルで抽象化しない方針です。根本的には、SwiftUI自体のテストしにくさの問題だと考えているので、今後のSwiftUIバージョンに期待しています。とりあえず、スナップショットテストでは、プレビューデータを含んだRepositoryをViewModelに渡すようにしています。この辺りは、TCAが非常に優れており、状態をViewStoreに注入できるので、TCAがこの点において勝ります。
-
ViewModelとRepositoryで、処理をどれに置くかが決まっていない場合があります。これはあえて今定義せず、一年以上試行錯誤してから、決めが必要ならチームで決めようと思っています。今のところ、まだ困っているという実感はありません。
-
とても薄く、ほぼいらないRepositoryまで定義する必要があります。アーキテクチャが煩雑であればあるほど、ボイラープレートが増えるのは避けられないと考えています。しばらく使ってみて、まだ課題に感じたら、RequesterをViewModelで直接持っても良いようにするか、簡易なRepositoryをコード生成できるようにしようと思っています。とりあえず、多少ボイラープレートが増えたとしても、Repositoryの定義を義務化することで、考えることを減らし、Repository層の定着を図れます。
標準化方法
アーキテクチャはチームで議論し詳細を決めるだけでは浸透しません。
なぜなら、アーキテクチャ実装の再現性が高くなく、参考にできる情報を探したり、既に決まったことについてチームメンバーにいちいち聞く必要が出てくると、アーキテクチャの浸透が難しくなり、ばらつきが増えて、コードレビューのやりとりが多くなり、せっかく生産性向上を見通したのに、本末転倒な結果になりうるからです。
そのような悲しい結果を避けたく、詳細なドキュメントを用意しました。ドキュメントの中で図面、決めた背景、責務、全体の構成、細かい実装例などを載せています。さらに、より細かい実装例が見たいときがあるので、特に参考になりそうなソースファイルへのリンクを盛り込んでいます。
ドキュメントのほか、Scaffoldテンプレートを作成し、簡単にコピー・ペーストできるように、テンプレートから生成するためのコマンドをドキュメントに記載しました。ViewModelやテストなどのテンプレートを用意することによって、実装時間をさらに短縮し、慣れていなくても基本のパターンに沿って簡単に実装できるようになっています。
アーキテクチャをチームで議論する中で、アーキテクチャパターンの認識の違いや知識のばらつきがないように、毎週の社内アーキテクチャ勉強会を開催しました。そこでTCAのREADMEやソースコード、そして様々なアーキテクチャのOSSアプリのソースコードをみんなで読んで、データフローについて話し合いました。
まとめ
もうすぐ2021年のWWDCが始まるので、SwiftUIがよりテストしやすくなる発表の有無が特に気になり、わくわくしていますが、大きな変化がない限り、minne iOSは当分MVVM + Repositoryのアーキテクチャで開発し、スムーズに機能開発と保守できると考えています。そうでなければ、調査と議論を続けます。
とりあえず、何よりアーキテクチャが決まって、明文化されているのが一番で、これが落ち着いたので、長年の登りを終え、山頂に着いた気分です。