Androidエンジニアが始めた、MVVM設計でのiOSアプリ開発
Qrunchのサービス終了に伴い、移行してきた記事です。
元記事の公開日は、2019/07/08です。
はじめに
今までAndroidの開発をずっとやってきたのですが、最近iOS開発をはじめました。
iOSでどのような感じでアプリを開発するか、をAPI通信をして情報表示する、簡単なサンプルアプリを作って試してみました。
Androidでは、MVVM+レイヤードアーキテクチャでのアプリ作成が手に馴染んでいるので、同じようにiOSアプリをMVVM設計で作ってみて、設計/実装方法のイメージをつかむことを目的としています。
サンプルアプリについて
i-Punks
コードはGitHubで公開しています。
ビールの情報を表示するアプリです。 一覧画面と詳細画面があります。
Punk API (https://punkapi.com/) という、Brewdogが出しているビールの情報が取得できるAPIを使いました。
Android/Flutter実装
ちなみに、同じ仕様のアプリをAndroidとFlutterでも作っています。
こちらも興味がありましたらご覧ください。
Punks
Android版 (MVVM+レイヤードアーキテクチャ, Coroutines)
解説
Plaidの設計を参考にした, Kotlin coroutines + Retrofit2 でAPI通信をするアプリの実装
F-Punks
Flutter版 (BLoCパターン+レイヤードアーキテクチャ)
全体設計
MVVM + レイヤードアーキテクチャ
1つ1つの説明は後述しますが、全体的な設計の構成を説明します。
MVVM
通常のMVVMと同じく、責務によってView/ViewModel/Modelに分けています。
パッケージ的にはViewとViewModelは関連が強いため、UIという同パッケージに所属させています。
レイヤードアーキテクチャ
Modelレイヤーは責務によって、UseCase/Domain/Infraの3層に分けています。
CQRS
データフローを単方向にするため、Command(入力イベント)とQuery(出力イベント)を分けています。Domainレイヤーで変換を行っています。
Rx
Modelレイヤーのデータ受け渡しには、RxSwiftのObservableを使っています。
ViewModel/View間のデータ受け渡しには、UIイベントの処理に便利なRxCocoaのDriverを使っています。
DI
各レイヤーのインスタンスは外部から依存を注入しています。
DIをするのに、DIフレームワークのSwinjectを使いました。
Dagger2のようにコンパイル時に依存関係の正当性のチェックはしてくれないですが、手軽に使うことができました。
SwiftのDIフレームワークを見てみると、はじめにSwinjectやCleanseといったruntimeに依存のチェックをするものが出てきて、後にDIKitやneedleといった、コード生成してコンパイル時に正当性がチェックできるタイプのものが出てきたようです。
(参考)
iOSDCで「コード生成による静的なDependency Injection」について話した & 口頭原稿を公開
Dagger2の難しさから、コンパイル時チェックはできないけど簡単にDIできるKoinやKodeinが出てきたAndroidとは逆の流れで、興味深かったです。
iOSのDIで、コード生成型のものも今後試してみたいと思います。
以上がざっくりとした全体の設計についての説明です。
それでは、各レイヤーの実装について、下のレイヤーから見ていきます。
Infraレイヤー
- 登場クラス
- APIService(DataSource protocolの実装)
データ取得の実際の詳細を行います。
プラットフォーム依存の処理など、技術的な詳細の実装が入ります。
今回はAPI通信を担当する、APIServiceクラスを実装しました。
APIService
- 役割
- APIを介したデータの取得/保存
- 依存関係
- プラットフォーム固有のクラス
- 入力
- データ取得/保存に必要な情報 (idなど)
- 出力
- Result<T>
class BeerApiService: BeerDataSource {
func searchBeerList(page: Int) -> Single<Result<Array<Beer>, Error>> {
return Single<Result<Array<Beer>, Error>>.create { singleEvent in
let request = AF.request("https://api.punkapi.com/v2/beers",
method: .get,
parameters: ["page": page])
.responseDecodable { (response: DataResponse<[BeerResponse]>) in
switch response.result {
case .success(let beerResponseList):
let beerList = beerResponseList.map({ $0.toBeer() })
singleEvent(.success(Result.success(beerList)))
case .failure(_):
singleEvent(.error(PunksError.apiError))
}
}
return Disposables.create { request.cancel() }
}
}
}
API呼び出しを行い、結果を返却します。
その後の処理に繋げやすくするために、返り値の型はRxのSingleで返します。
APIの形そのままを返すことも考えましたが、Domainのprotocolに従う、という制約から、Domainモデルに必要な形に変換するようにしました。
API通信部分はAlmofireを使いました。
データクラスにCodable protocolを実装しておくことで、パース処理も含んだ通信処理を簡潔に書くことができました。
Domainレイヤー
- 登場クラス
- Repository
- (Protocol)DataSource
Modelロジックの核心となります。
プラットフォーム依存の処理が入らないよう、実際の処理(通信処理など)は、Domainレイヤーに定義したprotocolに従うInfraレイヤーのクラスに任せます。
クリーンアーキテクチャの考え方に従い、他のレイヤーには依存しないようにします。
Repository
- 役割
- データの取得と保存
- In-memoryキャッシュ
- 依存関係
- Repositoryレイヤーで定義したDataSource protocol に依存
- 入力
- データ取得/保存に必要な情報 (idなど)
- 出力
- Result<T>
class BeerRepository {
let dataSource: BeerDataSource
init(dataSource: BeerDataSource) {
self.dataSource = dataSource
}
let disposeBag = DisposeBag()
private var beerCache: [Int: Beer] = [:]
private let beerListSubject = PublishSubject<Result<Array<Beer>, Error>>()
private let beerDetailSubject = PublishSubject<Result<Beer, Error>>()
func fetchBeerList(page: Int) {
dataSource.searchBeerList(page: page)
.subscribeOn(SerialDispatchQueueScheduler(qos: .default))
.subscribe(
onSuccess: { result in
switch result {
case .success(let beerList):
self.cache(beerList: beerList)
case .failure(_):
break
}
self.beerListSubject.onNext(result)
},
onError: { error in
self.beerListSubject.onNext(Result.failure(error))
}).disposed(by: disposeBag)
}
func fetchBeerDetail(beerId: Int) {
if let beer = beerCache[beerId] {
beerDetailSubject.onNext(Result.success(beer))
} else {
beerDetailSubject.onNext(Result.failure(PunksError.detailError))
}
}
func observeBeerList() -> Observable<Result<Array<Beer>, Error>> {
return beerListSubject.asObservable()
}
func observeBeerDetail() -> Observable<Result<Beer, Error>> {
return beerDetailSubject.asObservable()
}
private func cache(beerList: Array<Beer>) {
beerList.forEach { beer in
self.beerCache[beer.id] = beer
}
}
}
CQRSを実現するために、ここで入力イベントを受けてAPIServiceで取得したデータを、出力用のstream変換します。
入力は戻り値のないメソッドにし、出力をPublish/BehaviorSubjectで行います。
注意点として、Subjectは一度onError/onCompleteが実行されてしまうと、その後に結果を流すことができなくなってしまうため、APIServiceのonErrorはonNext(Result.Failure)として流してあげるようにします。
今回は、一覧取得で取れた値で詳細表示も行うため、キャッシュ機構をRepositoryクラスに実装し、idと紐づけて詳細の取得をするようにしました。
UseCaseレイヤー
- 登場クラス
- UseCase
UIレイヤーからModelレイヤーへの境界に位置し、アプリケーションの仕様を実現するためにデータの加工などを行います。(DDDのレイヤードアーキテクチャでは、Applicationレイヤーと呼んでいます)
今回のアプリではRepositoryから取得したデータをそのまま表示するため特に加工は行っていませんが、業務での経験や他の方の意見でも、UseCaseがあったほうがアプリ仕様に柔軟に対応できることが多いため、あったほうが良いと感じています。
UseCase
- 役割
- UIレイヤーからModelレイヤーへの境界に位置し、アプリケーションの仕様を実現するためにデータの加工などを行う
- 依存関係
- Repositoriesに依存
- 入力
- データ取得/保存に必要な情報 (idなど)
- 出力
- Result<T>
ViewModelからの入力メソッド(戻り値は無し)
ViewModelが購読する出力用のObservable
があります。
class BeerUseCase {
let repository: BeerRepository
init(repository: BeerRepository) {
self.repository = repository
}
func fetchBeerList(page: Int) {
repository.fetchBeerList(page: page)
}
func observeBeerList() -> Observable<Result<Array<Beer>, Error>> {
repository.observeBeerList()
}
}
UIレイヤー
- 登場クラス/ファイル
- ViewModel
- ViewController
- Storyboard
ViewModel
- 役割
- データをViewに表示する形で公開する
- ユーザーアクションに基づいて、UseCaseのアクションを実行する
- 依存関係
- UseCaseに依存
- 入力
- 情報取得に必要なid等(options)
- Viewからのメソッド呼び出し
- 出力
- Driver<Value>
final class BeerDetailViewModel {
let useCase: BeerUseCase
init(useCase: BeerUseCase) {
self.useCase = useCase
}
private let _loadState = BehaviorRelay<LoadState>(value: LoadState.preload)
lazy var loadState = _loadState.asDriver()
private lazy var _beerDetail: Observable<Beer> = self.useCase.observeBeerDetail()
.do(onNext: { (_) in self._loadState.accept(LoadState.complete) },
onError: { (_) in self._loadState.accept(LoadState.error) })
.map { result in
switch result {
case .success(let beerDetail):
return beerDetail
case .failure(let error):
throw error
}
}
lazy var beerImageUrl: Driver<String> = _beerDetail.map { $0.imageUrl! }
.asDriver(onErrorDriveWith: Driver.empty())
lazy var beerName: Driver<String> = _beerDetail.map { $0.name }
.asDriver(onErrorDriveWith: Driver.empty())
lazy var tagline: Driver<String> = _beerDetail.map { $0.tagline }
.asDriver(onErrorDriveWith: Driver.empty())
lazy var abv: Driver<String> = _beerDetail.map { $0.abv }
.asDriver(onErrorDriveWith: Driver.empty())
lazy var ibu: Driver<String> = _beerDetail.map { $0.ibu }
.asDriver(onErrorDriveWith: Driver.empty())
lazy var og: Driver<String> = _beerDetail.map { $0.targetOg }
.asDriver(onErrorDriveWith: Driver.empty())
lazy var description: Driver<String> = _beerDetail.map { $0.description }
.asDriver(onErrorDriveWith: Driver.empty())
lazy var foodPairing: Driver<String> = _beerDetail.map { $0.foodPairing.joined(separator: "\n") }
.asDriver(onErrorDriveWith: Driver.empty())
lazy var brewersTips: Driver<String> = _beerDetail.map { $0.brewersTips }
.asDriver(onErrorDriveWith: Driver.empty())
func fetchBeerDetail(beerId: Int) {
useCase.fetchBeerDetail(beerId: beerId)
}
}
- 入力
ユーザーアクションを受け、Viewからメソッド呼び出しを行います。
UseCaseのメソッド呼び出しにつながります。
func fetchBeerDetail(beerId: Int) {
useCase.fetchBeerDetail(beerId: beerId)
}
- 出力
UseCaseからの出力を購読します。
private lazy var _beerDetail: Observable<Beer> = self.useCase.observeBeerDetail()
.do(onNext: { (_) in self._loadState.accept(LoadState.complete) },
onError: { (_) in self._loadState.accept(LoadState.error) })
.map { result in
switch result {
case .success(let beerDetail):
return beerDetail
case .failure(let error):
throw error
}
}
購読した出力を表示するための値に変換し、Driverで公開します。
lazy var beerImageUrl: Driver<String> = _beerDetail.map { $0.imageUrl! }
.asDriver(onErrorDriveWith: Driver.empty())
lazy var beerName: Driver<String> = _beerDetail.map { $0.name }
.asDriver(onErrorDriveWith: Driver.empty())
Driverについて
-
Driver
RxCocoaが提供するクラスで、Observableに似ていますが、以下のような性質があるとのことです。 -
メインスレッドで通知
-
shareReplayLatestWhileConnected を使った Cold-Hot 変換
-
onErrorを通知しない
(参考)
RxCocoaが提供するDriverって何?
https://qiita.com/k5n/items/44ef2ab400f47fb66731
Driverのこのような性質は、ObservableをViewで扱う際に役立つものとなります。
-
メインスレッドで通知
-
Android同様、UIを触る際にはメインスレッドで行う必要があります。Driverを使うことで、Viewでの操作をメインスレッドで行うことを強制することができます。
-
shareReplayLatestWhileConnected を使った Cold-Hot 変換
-
Viewが購読するstreamがColdな場合、同じストリームが複数回購読されると以下のような問題が起こります。
-
裏で多重にストリームが生成されてしまいメモリとCPUを無駄遣いする
-
Subscribeしたタイミングによって流れてくる結果が違う
(参考)
【Reactive Extensions】 Hot変換はどういう時に必要なのか?
https://qiita.com/toRisouP/items/c955e36610134c05c860 -
そこで、ViewModelでの値の公開にDriverを使うことで、Hotなストリームに変換され、Viewでその値を複数回購読しても安全なように強制できます。今回のサンプルアプリでも同じ文字を2箇所に出すために、ViewModelの同じ値を複数回購読しています。
// name
viewModel?.beerName
.drive(beerNameLabel.rx.text)
.disposed(by: disposeBag)
viewModel?.beerName
.drive(navigationItem.rx.title)
.disposed(by: disposeBag)
- onErrorを通知しない
- Viewでは、ViewModelで保持するView表示用の値を、継続的に監視しています。Rxのストリームの性質として、onErrorが通知されると、それ以降ストリームの値を流すことができなくなってしまいます。
- Driverを使うことで、onErrorが通知されることがなくなるため、上記のことを考える必要がなくなります。
- DriverではonErrorを通知する代わりに、Errorが流れてきた時どのように扱うかを、ObservableからDriverに変換する際に決めることができます。
// エラー発生時に、代わりにDriverのイベントを通知
observable.asDriver(onErrorDriveWith: Driver.empty())
// エラー発生時に、代わりの値を指定
observable.asDriver(onErrorJustReturn: "データの取得に失敗しました")
// エラー発生時に、エラー内容に応じて処理分け
observable..asDriver(onErrorRecover: { error in
if ( error is RepositoryError ) {
return Driver.just("データの取得に失敗しました")
}else{
return Driver.empty()
}
})
View
- 役割
- ViewModelが公開している値を画面上に表示
- ユーザーアクションを受け付け、ViewModelのメソッド呼び出し
- 依存関係
- ViewModelに依存
- 入力
- 情報取得に必要なid等(options)
- ユーザーアクション
- 出力
- 画面表示
画面の作成方法は、Storyboardを使う方法とコードで書く方法があるようですが、今回はStoryboardで作ってみました。(今後、Swift UIで書くのが主流になるかもしれません)
1Storyboard-1ViewControllerにするのが良い、というのをネットで見たり、同僚に聞いていたのでそのような構成にしています。
(参考)
iOSアプリを作るときのおすすめ構成
ViewController
class BeerDetailViewController: UIViewController {
static func createViewController(beerId: Int) -> UIViewController {
let storyBoard = UIStoryboard(name: "BeerDetail", bundle: nil)
let nc = storyBoard.instantiateViewController(withIdentifier: "beerDetailNavigationController") as! UINavigationController
let vc = (nc.topViewController as! BeerDetailViewController)
vc.beerId = beerId
return vc
}
private var beerId: Int!
var viewModel: BeerDetailViewModel?
let disposeBag = DisposeBag()
@IBOutlet weak var beerImageView: UIImageView!
@IBOutlet weak var beerNameLabel: UILabel!
@IBOutlet weak var taglineLabel: UILabel!
@IBOutlet weak var abvValue: UILabel!
@IBOutlet weak var ibuValue: UILabel!
@IBOutlet weak var ogValue: UILabel!
@IBOutlet weak var beerDescriptionBody: UILabel!
@IBOutlet weak var foodPairingBody: UILabel!
@IBOutlet weak var brewersTips: UILabel!
override func viewDidLoad() {
setupViewBinding()
viewModel?.fetchBeerDetail(beerId: beerId)
}
private func setupViewBinding() {
// image
viewModel?.beerImageUrl
.map { UIImage(url: $0) }
.drive(beerImageView.rx.image)
.disposed(by: disposeBag)
// name
viewModel?.beerName
.drive(beerNameLabel.rx.text)
.disposed(by: disposeBag)
viewModel?.beerName
.drive(navigationItem.rx.title)
.disposed(by: disposeBag)
// tagline
viewModel?.tagline
.drive(taglineLabel.rx.text)
.disposed(by: disposeBag)
// abv/ibu/og
viewModel?.abv
.drive(abvValue.rx.text)
.disposed(by: disposeBag)
viewModel?.ibu
.drive(ibuValue.rx.text)
.disposed(by: disposeBag)
viewModel?.og
.drive(ogValue.rx.text)
.disposed(by: disposeBag)
// description
viewModel?.description
.drive(beerDescriptionBody.rx.text)
.disposed(by: disposeBag)
// food pairing
viewModel?.foodPairing
.drive(foodPairingBody.rx.text)
.disposed(by: disposeBag)
// brewer's tips
viewModel?.brewersTips
.drive(brewersTips.rx.text)
.disposed(by: disposeBag)
}
}
ViewContorllerの生成
上記はビール詳細画面のViewControllerのコードです。
Androidの、Activity/Fragmentの生成をcreateIntent/newInstanceで行うように、自身のViewControllerを生成するFactoryメソッドを持つようにしています。
static func createViewController(beerId: Int) -> UIViewController {
let storyBoard = UIStoryboard(name: "BeerDetail", bundle: nil)
let nc = storyBoard.instantiateViewController(withIdentifier: "beerDetailNavigationController") as! UINavigationController
let vc = (nc.topViewController as! BeerDetailViewController)
vc.beerId = beerId
return vc
}
Viewの参照
@IBOutlet weak var beerImageView: UIImageView!
@IBOutlet weak var beerNameLabel: UILabel!
@IBOutlet weak var taglineLabel: UILabel!
@IBOutlet weak var abvValue: UILabel!
@IBOutlet weak var ibuValue: UILabel!
@IBOutlet weak var ogValue: UILabel!
@IBOutlet weak var beerDescriptionBody: UILabel!
@IBOutlet weak var foodPairingBody: UILabel!
@IBOutlet weak var brewersTips: UILabel!
AndroidのfindViewByIdでViewの参照を取得する代わりに、Storyboardからコードに線を引っ張ると、@IBOutletが付いたプロパティが記述され、コードからViewの参照ができるようになります。
Viewの値の変更はそのプロパティに値をセットすることで行うことができます。
ViewModelの処理呼び出し
override func viewDidLoad() {
setupViewBinding()
viewModel?.fetchBeerDetail(beerId: beerId)
}
画面表示のタイミングで、詳細情報を取得するため、viewDidLoad内で、ViewModelのfetchBeerDetailを呼び出しています。
View→ViewModelの入力部分もRxのストリームにして流している例が多かったですが、慣れていなかったので普通にViewModelのメソッドを呼び出しているだけです。
連打防止や複数のUI入力のバリデーション等、ストリームにしたほうが都合の良い場合もあると思います。AndroidでもRxBindingを使ってUI入力をストリームにする場合がありますが、シンプル要件であれば、入力をRxに強制する必要はないかなと思います。
ViewModelの値を監視し、Viewの更新を行う
private func setupViewBinding() {
// image
viewModel?.beerImageUrl
.map { UIImage(url: $0) }
.drive(beerImageView.rx.image)
.disposed(by: disposeBag)
// name
viewModel?.beerName
.drive(beerNameLabel.rx.text)
.disposed(by: disposeBag)
viewModel?.beerName
.drive(navigationItem.rx.title)
.disposed(by: disposeBag)
// tagline
viewModel?.tagline
.drive(taglineLabel.rx.text)
.disposed(by: disposeBag)
// abv/ibu/og
viewModel?.abv
.drive(abvValue.rx.text)
.disposed(by: disposeBag)
viewModel?.ibu
.drive(ibuValue.rx.text)
.disposed(by: disposeBag)
viewModel?.og
.drive(ogValue.rx.text)
.disposed(by: disposeBag)
// description
viewModel?.description
.drive(beerDescriptionBody.rx.text)
.disposed(by: disposeBag)
// food pairing
viewModel?.foodPairing
.drive(foodPairingBody.rx.text)
.disposed(by: disposeBag)
// brewer's tips
viewModel?.brewersTips
.drive(brewersTips.rx.text)
.disposed(by: disposeBag)
}
ViewModelが公開している表示用の値を監視し、Viewに反映させます。
Driver.driveメソッドに、表示したいViewのControlPropertyを渡すことで、Viewの更新ができます。
(参考)
let disposeBag = DisposeBag()
...
// name
viewModel?.beerName
.drive(beerNameLabel.rx.text)
.disposed(by: disposeBag)
...
また、メンバ変数に置いた、DisposeBagにsubscriptionを登録しておくことによって、DisposeBagが破棄されるタイミングで購読の解除も行われるようです。
AndroidでCompositeDisposable.clear()を呼び出すように、ライフサイクルに合わせてdispoase処理の呼び出しが必要かと思っていましたが、いい感じに行ってくれるようでした。
(参考)
まとめ
簡単なサンプルではありますが、iOSアプリをMVVM+レイヤードアーキテクチャ設計で作ってみました。
入力/出力に関して以下のような流れになっています。
入力
Viewのユーザーアクションから、ViewModel→UseCase→Repository→DataSourceでのデータ取得
出力
DataSourceでデータ取得から、RepositoryでのObservable変換→UseCase→ViewModelでのDriver変換→Viewが変更を監視→画面表示の更新
アプリの要件次第で、最適な設計も変わってくるとは思いますが、iOSアプリ開発をこれから始めていくにあたって、なんとなく、やっていけそうなイメージができました。
次はSwiftUI、Combineにチャレンジしてみようと思います。
参考
iOSDCで「コード生成による静的なDependency Injection」について話した & 口頭原稿を公開
RxCocoaが提供するDriverって何?
【Reactive Extensions】 Hot変換はどういう時に必要なのか?
iOSアプリを作るときのおすすめ構成
RxSwiftについてようやく理解できてきたのでまとめることにした(1)
Discussion