VIPER設計(+RxSwift)でサービスのコードを置き換えた
( iOS )iOS (その2) Advent Calendar 2018 17日目です!
本記事はプロダクトのコードをRxSwiftをうまく活用しながらVIPERの設計に置き換えていった話です。
なぜ置き換えたのか
現在、Polcaをリリースをして一年が立ちました。開発をし始めて二年は経過しました。 自分はリリースして半年が経過しているときに入社したのですが、当初もある程度機能はある状態で、コード量も1万に突破していく頃でした。 自分は開発していく上で下記の問題を抱くようになりました。
- 責務がいたるところに分散している
- コード量がある程度多くなってきた
- テストが書きにくい
- RxSwiftが既に導入されているのでもっと活用したい。そして、ちゃんと使いたい。
そこで、今後開発をしていく上である程度運用しやすい状態にしたい、変えるタイミングとしては今がやりやすいと考え、VIPERの導入に踏み切りました。
VIPERとは
そもそもVIPERとはどんなものでしょうか簡単におさらいしていきます。 VIPERはiOSにおけるクリーンアーキテクチャの手法の一つとなり、責務を大きく5つに分けています。 ざっくりまとめると以下の通り。
- View
- プレゼンターによって何かを伝える
- プレゼンターにユーザの入力を受け取る
- Interactor
- ユースケースによって必要なビジネスロジックが含まれている
- Presenter
- インタラクタから受け取った、ユーザの入力により受け取った情報を準備するためのロジック
- Entity
- インタラクたによって利用された基本的なモデルオブジェクトが含まれている
- Routing
- 画面がどの順番で表示されるのかをナビゲーションロジックが含まれている
https://www.objc.io/issues/13-architecture/viper/ 詳細はこちらを参照してください
どのようにしていったか
polcaではVIPERを意識した設計を導入していますが、全てを適用したわけではありません。 Routingの部分はまだ適用していません。それは、今のフェーズで費用対効果あまり感じないように思ったことが一つと 必要に迫られたとしてこれは、後からでもリファクタリングが可能だと思ったからです。
実装編
ではどのように実装していったか。
まず、Presenterを軸にインターフェースなる部分を作成していきました。ここでは、それぞれの役割が何を伝えて、何を受け取るのかを定義していきます。
Presenterから作成しその引数をprotocolにしていくことでViewControllerの実装をすることなしにビルドであったりテストが可能になるので
まずはここから始めています。その後、Presenterを実装しつつ、Interactor、Viewと実装していっています。
別レイヤーのやりとりでは、基本的にはRxSwiftを利用しています。単純なイベントの送受信だったらDriver<Void>
であったり
情報の取得時にはSingle<PaginationResponse<ProjectPickup.Project>>
を利用し、状況に応じて型を変更して流れを変化させています。
Presenter
protocol WatchedProjectListViewProtocol {
var scrollViewReachedBottom: Driver<Void> { get }
var refreshTrigger: Driver<Void> { get }
}
protocol WatchedProjectListInteractorProtocol {
func getWatchedProjects(maxID: Int64) -> Single<PaginationResponse<ProjectPickup.Project>>
}
class WatchedProjectListPresenter: ProjectListPreseterBase {
init(view: WatchedProjectListViewProtocol, interactor: WatchedProjectListInteractorProtocol) {
// something
}
}
一覧を表示する時の情報受け渡しは以下のように実装しています。
class WatchedProjectListPresenter: ProjectListPreseterBase {
init(view: WatchedProjectListViewProtocol, interactor: WatchedProjectListInteractorProtocol) {
super.init()
let shouldRequestNextData: Observable<Void> = view
.scrollViewReachedBottom
.filter { !self.isComplete }
.asObservable()
.flatMapFirst { _ in return Observable<Void>.just(()) }
let shouldRequestFirstData: Observable<Void> = Observable
.of(
.just(())
view.refreshTrigger.asObservable(),
PolcaSubject.shouldRefreshWatchedProjectList.asObserver()
)
.merge()
.do(onNext: { [weak self] _ in
self?.maxID = 0
self?.isComplete = false
})
items = Observable.of(
shouldRequestFirstData,
shouldRequestNextData
)
.merge()
.flatMapFirst { [weak self] _ -> Observable<PaginationResponse<ProjectPickup.Project>> in
guard let strongSelf = self else {
return .empty()
}
if strongSelf.isLoading {
return .empty()
}
strongSelf.isLoading = true
return interactor
.getWatchedProjects(maxID: self?.maxID ?? 0)
.asObservable()
.do(onNext: { pagination in
if pagination.isFirstLoad {
strongSelf.loadedFirstDataSubject.onNext(())
}
strongSelf.maxID = pagination.nextMaxId
strongSelf.isComplete = pagination.isComplete
strongSelf.isLoading = false
})
.catchError({ [weak self] error in
self?.errorSubject.onNext(error)
return .empty()
})
}
.asDriver(onErrorDriveWith: .empty())
}
}
Interactor
InteractorではEntityをPresenterに渡すロジックが実装されています。 ここでは複数Entityに関連したビジネスロジックが含まれており、何を取得して、返すかを決める。 また、UIから完全に分離されます。(なのでInteractorに関連する実装でUI系のframeworkが入っていると設計を考えるがあるかもしれません。。)
class WatchedProjectListInteractor: WatchedProjectListInteractorProtocol {
let projectInfoProvider: ProjectInfoProvider
init(projectInfoProvider: ProjectInfoProvider) {
self.projectInfoProvider = projectInfoProvider
}
func getWatchedProjects(maxID: Int64) -> Single<PaginationResponse<ProjectPickup.Project>> {
return projectInfoProvider.getWatchedProjects(maxID: maxID)
}
}
本来は、Providerを利用して情報を返すときには情報をどのように取得したかはここで隠蔽させること(どのように取得したかは意識させない)が責務にしています。 しかし、情報を取得するときは現状は、ほとんどネットワークを介して情報を取得することが多いのでシンプルな設計になっています。場合によってはローカルDB から情報を取得する必要がある場合はここで隠蔽すると良さそうです。 以下にProviderの実際の処理が実装されています。
PolcaではMoyaという通信処理を抽象化したライブラリを使っていて通信処理を実装しています。
import Moya
import RxSwift
import RxCocoa
class ProjectInfoProvider: BaseProvider {
func getWatchedProjects(maxID: Int64) -> Single<PaginationResponse<Project>> {
return self.request(PolcaAPI.getWatchedProjects(maxID: maxID))
.filterSuccessfulStatusCodes()
.retryAuthenticationIfNeeded()
.generateAPIErrorIfNeeded()
.map { response in
// JSONからEntityを生成するロジックが実装される
return PaginationResponse<ProjectPickup.Project>(elements: items, maxID: maxID)
}
}
}
Entity
Entityはそこまでこだわりのある実装はしていません。Viewがその情報を表示させやすいようにデータ構造作ってあげています。 基本はDBの構造になっているものがほとんどです。
struct Project: Codable, PaginationResponseProtocol {
var id: Int64 = 0
var user: User
var body = ""
enum CodingKeys: String, CodingKey {
case id
case user
case body
}
}
View
ViewではPresenterから受け取った情報・イベントをどのようにしてそれぞれのUIコンポーネントに伝えるかという実装と、Presenterで必要なイベント(どのタイミングで情報取得してもらうのか)という実装をしています。 行なっていることとしては以下になります。
- Presenterから何かを受け取った
- 初期ロード時に取得した情報を表示させる
- ページングした時に取得した情報を表示させる
- リロードが行われた時に取得した情報を表示させる
- Presenterへイベントを伝える
- リストの最後まで来た
- リロードされた
- 初期ロード時
このロジックをRxSwiftを利用して表現すると情報の流れを段階的に記述していけるので実装しやすく、コードの見通しが良くなります。
class PickupListViewController: UIViewController, PickupListViewProtocol {
private let scrollViewReachedBottomSubject = PublishSubject<Void>()
var scrollViewReachedBottom: Driver<Void> {
return scrollViewReachedBottomSubject.asDriver(onErrorDriveWith: .never())
}
private let refreshTriggerSubject = PublishSubject<Void>()
var refreshTrigger: Driver<Void> {
return refreshTriggerSubject.asDriver(onErrorJustReturn: ())
}
override func viewDidLoad() {
super.viewDidLoad()
let projectProvider = ProjectInfoProvider()
let interactor = WatchedProjectListInteractor(projectInfoProvider: projectProvider)
presenter = WatchedProjectListPresenter(view: self, interactor: interactor)
dataSource.presenter = presenter
// UIRefreshControlの値が変更された
refreshControl.rx.controlEvent(.valueChanged)
.map { _ in }
.bind(to: refreshTriggerSubject)
.disposed(by: rx.disposeBag)
// List情報を取得した
presenter.items
.drive(tableView.rx.items(dataSource: dataSource))
.disposed(by: rx.disposeBag)
// 初期ロードのイベントが送信された
presenter.loadedFirstData.map { _ in false }
.drive(refreshControl.rx.isRefreshing)
.disposed(by: rx.disposeBag)
// ページングのイベントが送信された
presenter.selectedTrigger
.asDriver(onErrorDriveWith: .empty())
.drive(onNext: { [weak self] project in
// セルを選択したときの処理
})
.disposed(by: rx.disposeBag)
}
}
まとめ
実のところ、現状のサービスの規模・フェーズを考えるとまだ早いように思うことがありましたが、自分もこのタイミングでこれは過剰かと最初は多少不安に感じるものはありましたが運用してみてよかったと思っています。
よかったところ
- 責務の把握がしやすくなった
- どこに何が実装されているのか段階をおって把握することができる。三日前の自分は他人。
- テストが書きやすくなった
- ロジックテストだけではなく、ユーザの操作などで変化する状態のテストもかけるようになった
課題
- テストの量が全体としてまだまだ少ない。
- 確実に描いて行ける状況にはなった
- これはスピードとのバランスもあるのでどのくらいのバランスでテスト実装していくのかは今後の課題
- ファイルつくるのが現状だと手作業で自動化されていない
- するぞ!!
ある程度、時間の経過に伴い、サービスの仕様やコードの量は次第に多くなっていきます。さらに、僕らは本当に小さいチームでサービスを開発しています。 単純にモバイルの仕様だけじゃなくサーバサイドやデザインに関してもいろいろな知識を保持していかなければいけません。そんな中、 実装をその場かぎりのものとして実装してしまうと、数日たてばどういう仕様なのか影響範囲はどの程度なのかを把握しにくくなってしまいます。 今では、サービスのコード量は2倍になり影響範囲も以前よりも考えていかなければいかなくなったと言えるので、導入したタイミングとしてはよかったのか思っています。
現状、まだまだ発展途中で課題はたくさんありますが、導入してよかったと感じる部分もあるので今後はこれをベースにしてどのようにして運用していくか考え、地道に改善していければと思います。