この記事は『ドワンゴ Advent Calendar 2021』 16日目の記事です。
はじめに
今年5月31日にiOS版NicoBoxがデザインの刷新とサビメドレーやカバーMIXなどの新機能を加えた大型リニューアル1されました。
そのリニューアルに携わったのですが、元々のコードがObjective-CでしかもModernですらない10年以上前の古い記法で、とてもじゃないがそれをベースに開発は厳しかったのでSwiftでフルスクラッチすることになりました。
フルスクラッチということでアーキテクチャの選定からとなり、開発経験のあるMVPからさらに細かく役割を分割した形のVIPERが気になっていたので未経験ながら採用しました。
VIPERに関するWEB記事を参考に実装していきましたが、記事に掲載されているサンプルコードがシンプル故に実際に適用しようとすると難しかったり、記事によって実装方法が結構違っていたりと悩む点が多かったです。
本稿ではどのような点が悩んだのかとそれに対してどう対処したかを記していきます。
これもまた我流VIPERとなってしまうかと思いますが、これからVIPERを導入する際の一助となれば嬉しいです。
VIPERとは
VIPERとはクリーンアーキテクチャをiOS開発向けにアレンジしたもので、View,Interactor,Presenter,Entity,Routerの5つのコンポーネントで構成されます。(これらの頭文字をとってVIPER)
各コンポーネントの役割は以下のようになります。
- View: 描画とユーザー操作の受け付け
- Interactor: ビジネスロジックの実行
- Presenter: View, Interactor, Routerの橋渡し
- Entity: 単なる構造体
- Router: 画面遷移と依存性注入
VIPERの特徴としては以下の点が挙げられます。
- Entity以外のコンポーネントはProtocolを定義し、各々はProtocolを参照して実体に関心を持たない
- Routerが画面単位で関係する全てのコンポーネントの依存性注入を行う
- Interactor, PresenterはUIKitのimportを禁止する
実践録
ここからは実践して出てきた課題とどのように対処したかを書いていきます。
命名がややこしい
VIPERの一般的に各コンポーネントのProtocolと実体のクラス名を以下のように命名するとなっています。
Protocol | 実体 | |
---|---|---|
View | {ModuleName}View | {ModuleName}ViewController |
Interactor | {ModuleName}UseCase | {ModuleName}Interactor |
Presenter | {ModuleName}Presentation | {ModuleName}Presenter |
Router | {ModuleName}Wireframe | {ModuleName}Router |
Appleのガイドライン(Collectionの例)に沿った形だと考えますが、Presenterは良いとしてInteractorとRouterがProtocolと実体で命名がかけ離れていてややこしいです。
ただでさえコンポーネントが多くて学習コストの高いVIPERをさらに高くしていると感じます。
NicoBoxでは以下のようにProtocolと実体でSuffixは揃えるように変更しました(Viewは除く)。
Protocol | 実体 | |
---|---|---|
View | {ModuleName}ViewProtocol | {ModuleName}ViewController |
Interactor | {ModuleName}InteractorProtocol | {ModuleName}Interactor |
Presenter | {ModuleName}PresenterProtocol | {ModuleName}Presenter |
Router | {ModuleName}RouterProtocol | {ModuleName}Router |
ProtocolのほうのSuffixにProtocol
をつけるのは所属しているチームの慣習からで、実体の方にImpl
を付けるというのもあるかと思います。
画面間の通知をどのようにするか
一つ前の画面にイベントを通知したいという場面はよく出てきますが、この場合どのように通知すべきなのか。これに関する知見が(当時2)見当たらず悩みました。
最初は全てのイベントはViewが受け取るべきという考えの元に下の図のようにDelegateでViewを経由したフローにしていました。
しかし、これだと通知するために3箇所のProtocolにメソッドを定義しないといけなくとかなり面倒な設計となってしまいました。
最終的には画面固有のDelegateを定義し、そのDelegateを実装した前画面のPresenterをPresenterに渡すことで、PresenterからPresenterへ直接通知できるようになりました。
このことでメソッドの定義も1箇所になって実装も楽になりました。
Notificationはどこが受けるか
同じような課題でNotificationはどこが受けるかも紆余曲折ありました。
最初は前述の通りイベントは全てViewが受け取るべきとしてViewにしていましたが、UIApplication以外のNotificationがViewから伝わるのに違和感がありました。
Interactorに伝わって欲しいNotificationはInteractorが直接受け取ったほうがシンプルということで、NotificationはViewかInteractorの適当なところが受け取るようにしました。
この課題については正直まだ考えが固まっていないところです。
UIAlertControllerの表示をするのはViewかRouterか
UIAlertControllerを表示するのはViewなのかRouterなのかは地味に難しい問題だと思います。
present
メソッドで表示するし規模は小さいながらも画面遷移と言われれば画面遷移だしということでRouterのような気もしますが、表示した後に選択肢をタップするというユーザーアクションを受け取ることになるのでViewのような気もします。
そもそもカスタマイズ性の薄いUIコンポーネントをVIPERの1画面として扱おうとするのは無理があり、ユーザーアクションを受け取る関係上Viewで扱うのが都合が良いという観点から、NicoBoxではUIAlertControllerは描画と割り切ってViewで表示するようにしました。
画面とInteractorの関係は1対1か1対多か
多くのサンプルコードではアプリの規模が小さいためか画面とinteractorが1対1の関係が大半です。
NicoBoxでも最初はそれに倣っていましたが、画面が増えていくと複数の画面で同じビジネスロジックを実装したいという場面が増えていきました。
Interactorとは別にビジネスロジックを実装するコンポーネントを作ってInteractorはそれらを利用するというのが最初思いつきましたが、それだとInteractorがただの土管になってしまうので辞めました。(後述で結局共通ロジックを実装する別コンポーネントができましたが)
結果としてInteractorを再利用する形に、つまり1対多の関係になりました。
Interactorが出力を返す手段をどれにするか
InteractorのPresenterへの出力は基本的に非同期に行われます。
Swiftでの非同期実行の手段としてDelegateやClosure、Combine、外部ライブラリも含めればRxSwiftなどが挙げられます。Swift5.5からはasync/awaitも選択肢に入りそうですね
この選択肢の多さから記事によってその選択は様々です。
NicoBoxではPresenterを外部ライブラリに依存させたくなかった3のでRxSwiftは候補から外し、アプリはiOS12をサポートするのでiOS13以上のCombineもなし。DelegateとClosureが残り、最終的にDelegateを選択しました。
Delegateを選択した理由は以下の3点です。
1. ビジネスロジックをInteractor内で完結できる
基本的にInteractorはデータを操作して完了したら出力するという流れなので、その場合だけを考えればClosureのほうが記述が短くなるし取り回しも良いです。
しかし、完了以外でも出力をしたい場合にはDelegateが勝っていると思います。
例えば、リストをページングして表示する画面があったとします。
リストの最下部に到達したら次のページがあるか判定し、ある場合はインジケータを表示して次のページをAPIリクエストする。最後のページに到達していて次のページがない場合は何もしないという仕様です。
Delegateであれば次のページがあるかの判定とAPIリクエストを1メソッドで行うことができます。
class FetchInteractor: FetchInteractorProtocol {
func fetchNextPage() {
guard hasNext else {
// 最後のページで次がない場合は終了する
return
}
output.willFetch() // PresenterはViewにインジケータを表示させる
apiClient.get() { [weak self] result in
output.didFetch(result)
}
}
}
Closureでも愚直に上記のwillFetch
とdidFetch
にあたるClosureをInteractorのメソッドの引数に定義することもできますが、ビジネスロジックが利用側に漏れ出していて綺麗とは言えません。
protocol FetchInteractorProtocol {
func fetchNextPage(willFetch: (() -> Void), didFetch: @escaping (() -> Void))
}
あとはhasNext
を外出ししてPresenter側でハンドリングするという方法が取れるでしょうか。
protocol FetchInteractorProtocol {
// PresenterはこのプロパティからfetchNextPageを実行するかハンドリングする
var hasNext: Bool { get }
func fetchNextPage()
}
先程よりも漏れ出しは少ないですが、毎回Presenterでのハンドリングが必要となるのでDelegateと比べると再利用性に欠けます。
2. 連続してInteractorを実行する場合に見づらくない
例えばA Interactor
で成功レスポンスが出力されたらB Interactor
を実行するというように連続してInteractorを実行するようなケースが出てきます。
これは前述したPresenterには外部ライブラリを依存させない方針が影響していますが、この方針でいくとClosureでは入れ子入れ子になってしまう所謂コールバック地獄に陥ってしまって見づらくなってしまいます。
Delegateではこのような心配はありません。(ただ、シーケンス全体を把握するにはコードを追っていかないといけないので面倒ではあります)
3. 循環参照を気にしなくて済むようにできる
PresenterとInteractorはお互い参照し合う関係にあるのでInteractorからPresenterへの参照は弱参照にしないと循環参照になってしまいます。
DelegateではOutputをweakで持つという実装をボイラーテンプレートに組み込むことができるので基本的に循環参照は気にしなくて済むようにできます。
ClosureではInteractorのメソッド毎に弱参照にしないといけない、Closureの実行時に場合によってはself
をunwrapしないといけないといろいろと気をつけないといけません。
以上がDelegateを選択した理由となります。
特に1の理由が強くて、例え期待のasync/awaitが正式に採用できるという状況になったとしてもDelegateを選択するのではないかと思います。
Interactorの責務が重い
ビジネスロジックを担当するInteractorはWebAPIへのリクエストやDBへのアクセスして様々な処理を行うのでInteractorだけだと実装が煩雑になってしまいます。
NicoBoxではGateway、Translator、Serviceの3つのコンポーネントを増やしてInteractorの責務を軽くしました。
Gateway
NicoBoxではニコニコ動画のWebAPIへのリクエストにはiOSのニコニコ動画アプリと共通の社内ライブラリを使っています。
Interactorが直接ライブラリを扱ってしまうとそのライブラリに依存することになってしまい、クリーンアーキテクチャ的にご法度となります。
ライブラリをGatewayでwrapすることでInteractorがライブラリに依存することを回避します。
また、ライブラリからのレスポンスをライブラリ側で定義されたデータオブジェクトで受け取ることになるので、それをEntityに変換するのもGatewayの責務となります。
Interactorから実際のリクエスト処理をGatewayに移すことでInteractorは純粋にビジネスロジックの実行に注力することができるようになります。(所謂リポジトリパターンですね)
永続層へのアクセスもGatewayを通して行っています。
これも同じ理由でNicoBoxでの永続化には主にUserDefaultsとRealmを利用していますが、Interactorがこれら(特にRealm)に依存させないためと永続層のデータオブジェクトとの変換のためです。
EntityをそのままDBに保存すれば良いのではと思われるかもしれませんが、UserDefaultsやRealmを利用するには対象のオブジェクトにCodable
やRealmSwift.Object
に準拠する必要があります。
これらに準拠するとプロパティの型はプリミティブ型にしないといけなかったり、RealmSwift.Object
に関してはプロパティをmutableにしないといけなかったりといった制約が課されます。
永続化を考慮しなければSwift本来の表現力を出せるので、永続化するEntityには別途永続層でデータオブジェクトを定義することにしました。
あと他にはReachabilityなどの外部ライブラリ、Apple製のフレームワーク(StoreKit等)、果てはDateもGatewayでWrapすることでInteractorの依存をなくし、テスタブルにすることができました。
Translator
Gatewayの変換処理を再利用できるように切り出したコンポーネントで、GatewayはTranslatorを使って変換します。
例えば、ニコニコ動画のWebAPIのレスポンスには動画情報が頻出するので、各レスポンスのTranslatorは動画情報のTranslatorを使うといったことで再利用性が生かされています。
Service
Interactorが増えてくると複数のInteractorで同じ実装になっているところを共通化したかったり、複雑化してきて実装の一部を別のクラスとして切り出したいといった欲求が出てきました。
InteractorがInteractorを呼ぶというのも考えましたが、新しくコンポーネントとしたほうがわかりやすいだろうとServiceとして切り出しました。
ServiceはIntractorと違い、出力は使い勝手を重視してClosureで返します。
と以上が増やしたコンポーネントで、結果としてNicoBoxでは以下のような関係図となりました。
導入してみた感想
最後にVIPERを導入してみた感想ですが
Pros
各画面がどういう機能を有していて、どこに遷移できるのかがわかりやすい
Routerが画面単位の依存性の注入を行うため、そこを見ればその画面がどんな機能(Interactor)を持っているかが把握しやすいです。
逆にこの機能を持っている画面はどれほどあるのか調べる時にInteractorを検索すれば良く、それが保守していて助かっています。
また、Routerが実装しているメソッドを見ればその画面がどの画面に遷移できるのかも把握できます。
これは逆に言うと実装しようと思えばどこにでも遷移できるところをメソッドで制限できているという安心感もあります。
インスタンスの生存期間が画面と同じでクラッシュが少ない
これも画面単位で依存性の注入を行う利点で、各コンポーネントのインスタンスの生存期間がその画面の生存期間と同じ(シングルトンは除く)であるため、参照先が解放されていてクラッシュするというiOSあるあるのクラッシュがリリースしてからほとんど起きていなくて驚きました。
テストが書きやすい
DIを基本とし、Entity以外はProtocolを必ず定義する関係でライブラリを利用したモック自動生成ができることからテストが書きやすいです。
モック自動生成ライブラリはいくつかありますが、NicoBoxでは軽量のMockoloを利用しています。
Cons
ファイル数が膨大になる
VIPERでよく言われることではありますが、とにかくファイル数が増えます。NicoBoxではコンポーネントを増やしたのでさらに増えます。
ボイラーテンプレートを使ってファイルを作成するライブラリを利用するのが常套手段ですが(NicoBoxではKuriを利用しています)、それを用いたとしてもファイルの新規作成はカロリーが高いものです。
特に開発の途中で実装方針を変えたいとなった場合の損失が大きいので最初の設計がより重要になったように感じます。
SwiftUIへの完全移行が厳しそう
UIKitに比べるとまだまだ機能が足りていないSwiftUIですが、iOSDCを見ていると一部画面からでもSwiftUIを採用する事例も増えてきて、NicoBoxでもいずれSwiftUIの採用を考える時が来ることでしょう。
しかし、VIPERと組み合わせた場合の問題としてSwiftUIの画面遷移(NavigationLink
)はSwiftUI.View
にがっつり組み込まれる形となっており、UIKitのように描画と遷移をそれぞれViewとRouterに分けれないという点です。
VIPER+SwiftUIの事例としてクックパッド社がありますが、描画だけSwiftUIを使って遷移は従来通りUIKitを使っているように見受けられます。
SwiftUIの画面遷移方法がこのまま変わらない場合、現状PresenterがRouterを持っているところをViewがRouterを持つように構成を変えるといったことが考えられるでしょうか。
そうなるともうRouterの存在意義が薄いので、いっそRouterを抜かしたVIPEにして、Routerが持つ画面のDI機能は新コンポーネントに移すといったほうが良さそうです。
こう考えるとUIKitあってのVIPERなんだなぁと思わされます。
おわりに
以上がNicoBoxでVIPERを実践してきた記録です。
リリースされて保守フェイズになった今こそがVIPERの真価が試されています。
まだまだテストが足りていない状況ですので、とりあえず直近の目標はInteractorのテストを全部書くといったところでしょうか。
また保守していて気づきがあったら記事として書きたいです。
-
今回記事を作成するにあたり改めて探したところ@fr0g_fr0さんの記事でUIAlertControllerを含めて同じことが書いてありました https://qiita.com/fr0g_fr0g/items/f6e67793c7fb0331528f ↩
-
外すとなった場合に大変だし、Combineやasync/await等のApple製非同期処理フレームワークが出ている現状で導入するモチベーションは低い ↩