大規模なリファクタリングに備えて、Androidアプリのアーキテクチャーに対する今までの考え方を変えました
概要
こんにちは!Androidエンジニアのトニオ(@tonionagauzzi)です。
ACCESS Advent Calendar 2023のDay 3の記事を書かせていただきます。
さて、今年の大半、私は某大規模プロダクトのAndroidアプリの開発に携わりました。
そのアプリは初版リリースから数年が経ち、その間にAndroid ViewからJetpack Compose、RxJavaからKotlin Coroutinesといった技術の移り変わりがありました。
そのなかで、変更しづらい既存コードのメンテナンスコストが増大していました。
そして今年、いよいよ大規模なコードのリファクタリングが始まったのです。
それについては、別の機会に自社記事として発信する予定です。
今回は、日々コードの書き直しを進めるなかで、私がモバイルアプリアーキテクチャーに対して今まで抱いていた自分の考えを改めたことについて書きます。
依存関係の見直し
昨年までは、UI層からドメイン層、そしてデータ層からドメイン層の方向に依存関係が向くクリーンアーキテクチャー風の設計を、私は特別な理由がない限り推奨していました。
詳しくは過去の記事や、昨年出版したACCESSテックブックなどで上梓しました。
しかし、今年はデータ層からドメイン層ではなく、ドメイン層からデータ層に依存関係が向く戦略を取りました。依存関係再逆転です。
これをすると抽象であるドメイン層が具象であるデータに依存できます。するとシステムの各部分が密結合となり、変更しづらくなることが懸念されます。
SOLID原則が謳う良いソフトウェアの設計に従うとデメリットとなります。
ではどういうところにメリットを見出したのか、それをこの記事で紹介するのですが、一言で言えば、開発とテストの効率向上とレガシーコードの置き換えのしやすさです。
詳細
背景
Androidのアプリアーキテクチャーガイドは、アプリの堅牢性、品質の向上、そしてテストの容易さを重視しています。
アーキテクチャの基本原則として関心の分離がありますが、推奨アーキテクチャではUI層→ドメイン層→データ層の依存関係となっています。
ドメイン層は省略してもよいです。
感じたこと
Android公式ドキュメントの推奨するアプリアーキテクチャーが、私の推奨してきたものと違っていました。
ドメインレイヤはデータレイヤクラスに依存していますと、バッチリ書かれています。
正直、わかっているつもりで普段読んでいなかったページだったので、読んだときは結構衝撃でした。
私が今まで経験してきた多くのアプリでは、データ層からドメイン層方向に依存する戦略が取られていました。
私自身が決めたのも数多くありますし、一時期のトレンドだったのかもしれません。
しかし、その戦略の利益であるはずの、データの保存方法を丸ごと差し替えるような機会にはあまり恵まれませんでした。
逆に、普段の開発で作りにくさを感じていました。
作りにくさは、具体的には以下のようなところです。
1. Mapperを用意する労力
技術レイヤーでモジュール分けしていると、データ層でライブラリなどが扱うクラスをドメイン層から参照できないので、ドメイン層が用意したクラスに変換するためのtoDomainModel()
みたいな変換Mapperを度々作らなければなりません。
それが一度二度ならまだしも、新しい機能やユースケースが増えるたびに毎回考えてMapperを実装する必要があります。
これは労力だけでなくレガシーデータ層とモダンUI層の組み合わせになったときの型変換の複雑さ、実行時のオーバーヘッドも気になるところでした。
2. テストを書く労力
UIのViewModelをテストしようと思うと、ドメイン層を介してデータ層の実体を取得することはできないため、データ層をモックしなければなりません。
ドメイン層のUseCaseやRepositoryをテストする場合も、データ層のモックが必要です。
ということは、各モジュールでモックを用意して、小さな別々のテストを書く必要があります[1]。
これは合理的に思える反面、1つの機能を増やす毎に各モジュールにテストを書いてまわる必要があるので、スムーズに開発できなかったり、度々テストを書き漏らしたりしていました。
テンプレート化したり、モックを自動生成するMockitoやMockkを使うなどで実装時間を短縮する手段もありますが、それらを取り入れることで学習コストが上がりますし、できるなら実体のデータクラスを用いて簡単にテストできるのが理想的です。
3. いつまでもリファクタリングできない
レガシーコードの存在もネックになっていました。
ここでいうレガシーコードとは、古いアーキテクチャを採用していてテストがあまり書かれていないコードのことです。
たとえば、MVVMで実装されているとします。
これをAndroidのアプリアーキテクチャガイドに沿って書き直し、ModelをUseCaseにして、UIに公開するインターフェースやデータ層との接合の仕方を大きく変えるとします。
変更後のコードに対してUseCaseのテストを追加しても、元々のModel時代のコードが壊れていないことは保証できません。
元々Modelのテストがあってテストエラーを直せば良いとも言えますが、厄介なのは元々テストがなかった場合です。
新規にテストを書いたとき、書いた人は自信を持って壊れていないと言えるのですが、おそらく多くのチーム開発では「本当に元通りの動作なの?」と不安に思われ、スムーズに承認されません。
こうなると、やる気があって着手した人も途中で匙を投げ、レガシーコードをいつまでも書き直せない状況に陥ってしまいます。
この状況を言い表すと、
- テストが書きやすいようにリファクタリングしたい
- けど、テストが書かれていないから壊れるかもしれないので変更できない
というデッドロックが発生しているのです。
決断したこと
1. ドメイン層からデータ層の向きに依存させる
ドメイン層からデータ層を参照できるようにしたことで、データ層のクラスをUI層やドメイン層でそのまま扱うことができ、Mapperを書く労力を削減できました。
データのCRUDを扱うロジックをドメイン層に実装しないことなどは、開発ツールでは防げなくなったので、レビューで徹底することにしました。
2. UI層のテストを充実させる
テストを書く労力を抑えながら自信を持ってリファクタリングを行うために、最も有力だと感じたのがUI層で実際のデータを使ったテストを書くこと、そしてそのテストを他の層のテストよりも優先することでした。
UIに対する本物の入出力のテストが充実していれば、後でドメイン層やデータ層のコードを変更したとしても、UIのテストがパスし続ける限り壊れていないことが保証できるからです。
3. できるだけ脱モックして実体を使う
ここで、データにモックを使っていたりすると、ドメイン層より奥が壊れてしまっても気づくのが難しくなります。
できるだけ実際のものを使ってテストを書くには、UI層もドメイン層もデータ層の実体にアクセスできる必要があります。
そのため、冒頭の依存関係再逆転が大事だと思いました。
また、この流れを後押しする要素として、UIのテストが以前よりだいぶしやすくなったこともあります。
AndroidにはRobolectricがあり、JUnitのテストでもShadowを使うことで主要なAndroid APIをシミュレートできます。
従って、必ずしも実機やエミュレーターで動かす必要がありません。
これはローカルなら起動の手間を省けますし、クラウドCIになると利用金額的に大きな差となるので、見逃せないメリットです。
もっとも、RobolectricのShadowもモックなので、費用や実行負荷を考慮しなければ、実機やエミュレーターでテストできるのが一番理想ではあります。
そしてJetpack Composeは、今までのAndroid Viewよりもテストを容易に書けるようになりました。
何度か書いて慣れると、ドメイン層などとほとんど変わらない労力でUIテストを書くことができます。
さらにブラックボックステストの手段として、MaestroやAppium、Magicpodなど複数の選択肢を目的に合わせて選択できるようになりました。
これらの恩恵を最大限受けるためには、できるだけ自分たちで用意したモックを使わず、実際のものでテストをできる設計にしたほうがよいと私は考えるようになりました。
そのトレードオフとして、抽象的なモジュールに対して実装を隠蔽できる設計を落としています。
おわりに
私の所属するプロダクトでは、リファクタリングの前哨戦として、既存コードを適切なモジュールに振り分けた上でモジュール同士の依存関係を整理しました。
その全貌については別途発信したいと思います。
コードの書き換え自体は、まだ始まったばかりな気がします。
今回は、古いアーキテクチャの刷新とリファクタリングを行うにあたってアーキテクチャーの考え方を変えたことを発信しました。
ドメイン層からデータ層を見れるようにしたことで、データ層で定義したクラスをドメイン層やUI層でも使用できるようになり、コーディングルールを守っていれば開発効率が向上することと、ユニットテストで実体を使った通しのテストができるためアプリ全体が壊れていないことを保証しやすくなりました。
レガシーコードの改善と新しい機能開発がスムーズに進むよう、引き続き尽力していきます!
-
抜け道としてappモジュールでUI層からデータ層までカバーしたテストを書くことはできます ↩︎
Discussion