本記事は、iOS向けArchitectureである VIPER
を使ったときの失敗談を書いた内容となります。
年の瀬も近く、一度振り返りをすすのもいいかな... とおもってつらつら書いておきます。
現在、僕が担当してるVIPERArchitectureを使っているアプリのModule数が271を越えました。(2年目突入🎉)
基本的にはうまく動いているのですが、メンテナンスや追加開発をしていくなかで、これは良くない実装だったなーと思う箇所がいくつかあります。(orありました。)
これからVIPER Architectureを採用される方がこの失敗をせぬようにと思いを元に書きます。
本記事の位置づけ
VIPER に関する概要を大まかに「知っている方」をターゲットとしています。
簡単な短い説明については以下をご覧ください。
iOS Project Architecture : Using VIPER [和訳]
失敗談
具体的なソースコードは載せていません。
気になる場合はコメントください。
失敗談1 - AppDelegate
AppDelegateの立ち位置が開発当初から微妙でした。
沢山書くことが出来るし、そこに書く必要があるケースも多い、
ほとんどのプロジェクトで自動生成されており重要な機能がすべてそこに詰まっています。
チーム内ではAppDelegateにはコードを書かない方がいいと同意していたものの、限界が見えるまで具体的な対策案がとられていない状態でした。
限界が見えた瞬間
以下機能が入ったため、1000行を越え始めました。
- コアとなる起動処理
- 画面遷移
- Routing関係の処理全て
- Push通知関係の処理全て
更に、UIWindowを触る機能を追加する必要が出てしまいました。
そのため、コードが爆発するorデバッグしずらいコードが出来ることが、現実のものとなりリファクタしよう...となりました。
リファクタの方向性
1週間程度かかりました。
テストは画面遷移や起動処理のInteractor部分のみ書きました。2
1. UIWindowを扱う想定をしたModuleの誕生
僕はUIWindowをいくつかの種類に分けました。
起動直後にWebViewを表示する可能性があり、Viewの上に何か「もの」を置きたかったためです。3
そのため
- UIWindowはModule側からも動的に追加できるようにしたい
- Module側が任意のUIWindowに対してUIViewを追加したい。
- これはRoutingの場所で要望が変わることとなりました。(Routingを参照)
というニーズがありましたので実装しました。キレイ。
ここでAppDelegateからWindowに関する記述を初期化のみ4残して他は消せるようになりました。(便宜上、UIWindowModuleと呼びます。)
2. RoutingをするためのModuleの誕生
UIWindowModuleが誕生したことにより、各ModuleがどのWindowに表示するのかという事が問題となりました。
compass を使いここは解決しました。
詳しくは、既に資料になってるので、こちらをご覧下さい。
ざっくり説明:
- Routing処理を簡単に呼び出せるように変更
- 各Moduleは呼び出されて欲しい名前をRouterにて定義
- 別Moduleはその定義されたものしか呼び出せない
- サーバーから受信した内容も当然動的に追加可能
- WebViewで表示する内容とか。
3. 起動処理をするためのModuleの誕生
起動処理を一括しておこなうModuleを作りました。(便宜上、LoadingModuleって呼びます)
- データ取得
- データの整合性確認
- ユーザー認証
- DeviceToken関係の処理
- 取得したデータ応じて表示する画面を決める
TabbarやNavigationBarに関しての初期化処理はUIWindowModuleに書いたままとなってます。
(別軸で、TabbarはModule化、NavigationBarはEntityにしました)
このModuleがUIWindowに対して起動処理完了の通知を投げると、一番手前にあるLoadingModuleが表示されてるUIWindowが削除され裏側で勝手にロードされていたWebViewが瞬時に表示されます。
サーバー側へのDeviceToken登録に関しては初期状態ではここでやっていましたが、これとは別の段階で別Moduleにすることとなりました。 5
4. Push通知はEntityとして起動処理に渡すように変更
Himotokiを使い受信したPush通知は扱いやすい形に瞬時に変換されます。
それをLoadingModuleに渡します。
もし特定のページを開くというPush通知が届いたのであれば、LoadingModuleはUIWindowModuleに対してそのページを開くリクエストを出し、自身を消すリクエストを出します。
ちなみに、ExtensionでNotificationを実装していますが、そちらは小規模故にVIPERっぽく書いていません。
5. DeviceTokenを管理するためのModule作成
ユーザー情報に関しては機密性の高い情報のため、Keychainを使っています。
そのため、若干ソースコードが長くなるということが分かっているためViewを持たないModuleを定義しました。
処理をがっつり分けるという点では成功していますが、EntityとModuleの分け方の曖昧さがやはりあります。
妥協点だと思っています。
失敗談2 - フォームパーツの多い画面で作ったスパゲッティ 🍝
ユーザー登録やログインの要件は開発当初からあり、勉強に丁度よいと初期段階に作りました。
そのため、各パーツ(ViewやInteractor)に対する理解が曖昧さを含んだまま実装したため「単体テストがしずらい」「コードが追いづらい」ものが出来てしまいました。🍝
InteractorがUIKitのオブジェクトを触る
「入力をViewが感知して、ValidationをInteractorが実施して、その結果をViewが出す」という処理の流れを作りました。
View 「このUITextFieldに入力されたよ」
Presenter「おっす。入力されたって」
Interactor「おっす」
Interactor「UITextFieldから値とってチェックするわ」
Interactor「値間違ってる。だめって出して」
Presenter「おっす。値間違ってるからダメだって」
View「おっす。」
InteractorがUITextFieldを扱うことにより、運用しづらいものが出来ました。6
後々修正しましたが、ここは入力されたテキストのみPresenterに渡したほうがよかったです。
また、InteractorがUIKitのオブジェクトを使うと、出来る事が増えてしまい、Viewに書けばいいのかInteractorに書けばいいのかメンテしていく段階でチーム内で差異がでてしまいました。
ある程度締めるところは締めた方が良いと知りました。
IntreractorとViewの主体性を誤った
ViewとInteractorのどちらが主体的であるか間違っていました。
View 「利用規約ページに遷移するためのボタンおされたよ」
Presenter「おっす、利用規約ページにいくためのボタン押されたって」
Interactor「じゃぁ、利用規約表示する処理しなよ」
Presenter「続けろってさ」
View「おっす」
View「利用規約のURL欲しいんだけどー(Interactorしか知らないんじゃね?)」
Presenter「おっす、Interactorに聞くわ」
Interactor「これで」
Presenter「これだって」
View「そのURLを開きたいんだけどー」
Presenter「おっす、Routerに依頼しとくわ」
Router「表示します」
我ながら素晴らしい🍝だと思います。
上記例では、擬人化することにより、Viewが表示したがっていることが分かりやすくなったかと思います。
本来、重要な処理を担うのはInteractorです。
しかし、全ロジックをInteractorに任せると遠回りな処理が多発してしまうことが分かりました。
修正は、
- 初めの方(viewDidLoadとか)で、InteractorからViewにURLを渡しておく
- Viewが利用規約ボタン押下のイベントをPresenterに渡したらInteractorを噛まさずに、Routerに投げる
ようにしました。
しかし、細かく機能毎に分かれていることにより、この修正が瞬時にできました。
分からなければ取り上えず細かく書いて、あとで方向が定まったらチョッチョッと治すというのもありだと思います。
失敗談3 - 1画面=1Moduleという制約に縛られすぎた
これはまだ解決していません。なので問題点だけ書いておきます。
現在は複合モジュールを採用してみればよかったなーと思っています。
前提として、ラジオ体操で1日1スタンプ溜まる出席カードを想像してください。
出席カードは以下要素をもっています。
- 表紙
- タイトル
- 参加者名
- 主催
- 広告
- スタンプシート
- スタンプが押されるところ(カレンダー)
- 貯まったときの報酬
- 注意事項
アプリにしたときに1画面に表紙とスタンプシートがあるとします。
表紙は必ず1枚ですが、値域よっては夏休みの長さは短くなったりしますのでスタンプシートのサイズは可変である必要がありました。(1枚とは限らず、2枚かも!)
僕たちは、これに近い機能を1つのStoryboardのファイルに複数のZIBファイルを利用してスタンプを作りました。
表紙は1アプリ1つであるのに、スタンプシートは1アプリに対して複数ある可能性がある違いがあるのに同一のModuleで作ってしまったためInteractorやViewのコードが長くなり、メンテが面倒になりました。
更に「皆勤賞ではないけど、数日も行った子供に対しての報酬がないなんておかしい!」といった感じのクレーム対応すべく「貯まったときの報酬」に対する条件が増えました。
例えば「10日行った」とか「10日行って交換して、追加で20日行った」とか。
状態が爆発的に増え、結果スタンプシートのデザインも増え大変なことになりました。
Interactorが長くなるにつれ、不具合こそでないものの7、SRPの考え方から外れたものとなってしまいました。
Methodの名前もわかりにくくなり、分かりやすくするために長くなったり... とか。
失敗談4 - 間違ってClass名を小文字から始めちゃった✌
本当にどうかしてたんですが、誰も気が付いてなくて、ある時、ふと「なんでAじゃなくてaから始まってるんだ...?」ってプロジェクトを見たら全クラスファイルが小文字から始まってることに気が付きました。
何らかのポリシーがあるのではと疑うレベルで間違っていました。
Generamba を使って自動生成してて気が付いたときにはModuleが8個程ありましたので、間違ったファイル数は80ファイル(+テストコードのファイル)はありました。
ファイル名変えたり、クラス名変えたり等々が手間過ぎるので、これらのModuleに関してはリファクタ対象として利用してます。(リファクタしたらクラス名も整えるという決まり)
残り3Module程度ありますが、大きくないのでこのままでいいかなって思ってます。
ファイル名/クラス名、間違うととても面倒だという知見を得ました。
失敗談5 - NavigationBarをModule化しなかった
TabbarはModuleとして用意してあります。
しかし、NavigationBarは何故かEntityとして実装されています。(謎ですね)
Entityとして存在することにより、以下デメリットがありました。
- NavigationBarだけはVIPERの形でない
- EntityなのにInteractor程のロジックが書かれている
- Presenterが無いこともあり、外部からどのmethodを呼び出していいのか分からない。
- カスタマイズするときに外部に依存してる箇所が不具合を出すことが多い
- 例えば、外部の状態に応じてボタンの色を変えるとか
- これは外部側にも問題ありそう
- 例えば、外部の状態に応じてボタンの色を変えるとか
- コードが長い
- 処理が追いづらい
なので、書き直したいと思ってます。
他
- 1ModuleでStoryboardを2個、ViewControllerも2個みたいな構成であるときに、INITIALIZERの中でUIViewControllerのクラスが一致しておらずIB上でOutlet引けずに苦しんだ。
- Buildは通るから、実行時にpresenterが見つからずにassertで処理が停まるようになってて、悩んだ。とても悩んだ。
- StoryBoardの名前のIB上でのtypo
- 実行したときにCrashする
- swifty_viperを利用していて思ったこと
最後に
VIPERに関して、まとめたものを技術書典に出すのが目標ですφ('ᴗ'」)
-
共用のEntityがあるのですが、そっちは50以上あると思います。大きくなってきました! ↩
-
Viewはデザインを変えたときに手間だし、Presenterがバグってたら開発時に気が付くということでスルー。Interactorの不具合は気が付きづらいということで書くことにしました。 ↩
-
WebViewでちょっとずつでてくる・・・というのを簡単にいつでも防ぎたかった。綺麗に一瞬で出てくるようにするならこれが一番楽だった。当然裏側でキャッシュをするというのも考えたのですが工数の割に安定的に動作させるのが難しいと判断しました。 ↩
-
だいだい4行程度です。 ↩
-
サーバー側に登録するとか、DeviceTokenの更新をするとか、ユーザー登録機能と組み合わせるとか、AWS SNSのSubscriptionArnと一緒に管理するとか、結構大きなModuleになりました。Moduleにしてよかったと思ってます。 ↩
-
出来るけど、テストしにくい。やろうと思えば、テキスト以外の情報を取得してしまい何でもできるようになってしまう。 ↩
-
人力テストがっつりやりましたってのもありますが。 ↩
-
例えば、viewからpresenterに対してどのようなmethodの呼び出しがあるのかというのをProtocolで定義しています。view->Presenter, Presenter->View, Presenter->Interactorなどなどあります。 ↩
-
ただロングランテストをやってみて、Crashしたり重くなったりしてないから大丈夫っぽい。 ↩