はじめに
こんにちは、@ko2icです。今回はAndroidのアーキテクトとして書きます。
ニューズピックスのモバイル開発は古い技術で古いアーキテクチャをいまだに使っていると思っていませんか?たとえば、Androidは昔のブログでMVPを利用しているとの記述が...。
そこからほぼ発信できていなかったのでそう思われるのも無理はありません。ただ、そんなわけはありません、日々、改善を続けています。
ちなみにiOSは、SwiftUIでThe Composable Architecture(TCA)をメイン機能であるニュースフィードで利用するなど、なかなかのチャレンジングなことをしています。他社でもSwiftUIの事例はたくさん聞きますが、ニューズピックスほど多様な一覧アイテム(カルーセルタイプやそれ以前に複数デザインがあり)で上タブ(横スワイプ)があり、メニューをスワイプで開き、縦・横ともにスクロールリスナーを多様していて、さらにTCAを使っているアプリはあまりない気がしています。
Androidは、Jetpack Compose... と言いたいところですが、MVVMでDatabindingを使いBindingAdapterをフル活用、Dagger HiltでDIをし、Coroutine Flow、Roomなどを利用、MockKでテストを書いています。Composeを使っていないアプリとしては最新の技術ではないでしょうか。Composeも導入したいですが、現在のアーキテクチャの生産性が非常に高いので、たとえば、一覧アイテムの一部だけをCompose化するなど、生産性とチャレンジングの両輪ができるようには考えてはいます。
ただし、正直にいうとニューズピックスのアプリは長年、ビジネスの成長に振り切っていたために、技術的負債がたくさんあります。
今日は、それをどのように解消していってるのかを書こうと思います。内容はAndroidです。
アプリの技術的負債を段階的に解消している事例の発信はそれほどないので、同じ状況で悩んでいる組織ではきっと役に立つ気がします。
当時のAndroidの技術的負債
技術的負債の解消をするためにAndroidのリアーキをし始めたのが、2020年12月です。その前も優秀なAndroidエンジニアが課題感を持っていて、少しづつ改善はしていました。そして、彼らが辞めていき限界が来たという感じです。
リアーキの話はこちらの記事も参照してください。
当時のアプリの技術的負債はざっと考えると以下の通りです。正直、晒すのは恥ずかしいのですが、技術的負債を解消することは全く無理ではないという事例になるかと思います。
- それぞれのクラスの依存度が高く、依存度の方向性がバラバラ。循環参照もあったりする
- Applicationクラスが肥大化している。また、さまざまなクラスもそのApplicationクラスに依存している
- シングルトンクラスだらけで、それが状態を持っていて、手続き的に処理している。グローバル変数を扱ってるのと同じなんじゃ疑惑
- staticメソッドも大量でオブジェクト指向ってなんだっけ?状態
- カスタムViewの内部からAPI通信のクラスを呼び出しているため、Viewに再利用性がない
- ViewModelというクラスが存在しているが、ViewModelがViewクラスに依存しているときがある
- 通信基盤がVolley,Ktorなど複数存在している
- 非同期処理はコールバックインターフェイスを利用しているところが多く可読性が悪い
- ルールなくEventBusを多様していて、ソースを追いづらい。それ以前に複数スレッドが色々動いていて、どうなってるのかよくわからない
- キャッシュ・SharedPreference・Realmなどの利用が隠蔽されていないために実装者はそれぞれでどれを使えばいいかを考えながら作る必要がある
- ActivityやFragmentにベースクラスがあり、その中で色々な処理をしている。
- ライブラリのバージョンを上げようとすると途端に動かなくなる
- ソースが複雑で過去を知っている人でないと気づかないことが多い。つまり属人性が高い
- コンパイル時間がめちゃくちゃ長い
フルスクラッチではなくビジネスを止めずに少しづつ改善していていることが、非常に困難なポイントです。どこの組織でもビジネスを止めずに将来の開発スピードを上げたいと考えているのではないでしょうか。
それを実践できています。ちなみに生産性を観測しているのですが、その数値は昔からいた人でも1.5倍 (iOSは1.7倍) ほど上がっています。これはまた別の記事で書こうと思います。過去を知らない人で測ると10倍ぐらい違うと言っても過言ではないかもしれません。
技術的負債解消方法
- それぞれのクラスの依存度が高く、依存度の方向性がバラバラ。循環参照もあったりする
- コンパイル時間がめちゃくちゃ長い
これの解消についてです。
依存の方向の解消
まずは、依存の方向。ここを改善するのが何よりも一番大事です。これをしないと簡単にスパゲティコードになるし、クラスの責務もわからなくなります。PRをレビューもうまくできません。
これを改善するためにまずは、シングルモジュール構成だったのをマルチモジュール構成に変えていきました。
レイヤーでモジュールを切ります。これにより、コンパイラーが逆方向の参照ができてないようにしてくれます。
とはいえ、「元々のソース同士の依存度が高すぎて、そんなの簡単にできないよ!」って思うかもしれません。それは当然そうです。
なので、古いコードは、appモジュールに置き、これから新しく作る機能をuiモジュールなどに作っていきます。
もし、Applicationクラスへの依存度がそれぞれのクラスにない場合は、appモジュールには、Applicationクラスだけを置き、legacyモジュールに古いコードを突っ込めば、新しく作るところだけはコンパイル時間が爆速になります。
弊社の場合は、大量のコードがApplicationクラスに依存しているので、そこはできなかったです。
依存性逆転の原則
依存の方向を簡単に解消するのに必要になるのが 依存性逆転の原則です。このために、DIコンテナを入れます。ニューズピックスではDagger Hiltを入れました。
具体的には、依存性の観点で最上位にdomainsモジュールが位置していますが、処理の方向としては、domainsモジュールのクラスがInfrastructuresモジュールのRepositoryを呼び出す形になっています。
これは、domainモジュールにRepositoryインターフェイスを持っていて、InfrastructuresモジュールにRepositoryの実装を持っています。それをDIを使って依存性を注入しているのです。
ここまでは、よく見ると思いますが、なぜ、依存性逆転を使うと依存の方向性を簡単に解決できるかです。
たとえば、legacyモジュールによくわからないけど、動いている古いコードがあります。これを新アーキテクチャでも使いたい。(本当は使いたくないけど、実装時間を考えると利用した方が早くリリースできる)
その古いコードが、UserLogic
というクラスだったとして、processImportantLogic()
メソッドを新アーキのあるdomainモジュールから使いたい。でも、domainモジュールは、legacyモジュールを参照できない。
class UserLogic private constructor() { companion object { @JvmStatic val instance = UserLogic() } fun processImportantLogic() { // なんだがよくわからないけど、動いている処理 } }
そんなときは、domainモジュールに UserLogicInterface
インターフェイスを用意し、
interface UserLogicInterface { fun processImportantLogic() }
そのインターフェイスをUserLogicに実装すれば、
class UserLogic private constructor() : UserLogicInterface { ・・・ override fun processImportantLogic() {
domainモジュールにあるクラスで利用できます。
@Singleton class AnalyticsImpl @Inject constructor(private val userLogic: UserLogicInterface) { fun hoge() { ・・・・ userLogic.processImportantLogic() ・・・・ } }
ちなみにDagger Hiltを使えば、以下のようなクラスをlegacyモジュールで定義しておけば、DIで簡単に利用できます。
@Module @InstallIn(SingletonComponent::class) class LegacyModule { @Provides @Singleton fun provideUserLogicInterface(): UserLogicInterface = UserLogic.instance
画面遷移する際も依存性逆転をすれば、新アーキのあるuiモジュールから旧コードのActivityに簡単に遷移することができます。なので少しづつ改善をしていくことができます。
Utils系のクラスの場合
- staticメソッドも大量でオブジェクト指向ってなんだっけ?状態
これの一部解消についてです。
DDDでいうところのValue Objectを作り、その中に隠蔽します。
たとえば、画像URLを扱うのだったら、以下のようなクラスを用意しておけば、size()
メソッドをこのクラスに隠蔽できます。
data class ImageUrl(private val url: String?) { fun size(width: Int, height: Int): ImageUrl { if (url.isNullOrBlank()) { return ImageUrl(url) } if (url.contains("?")) { return ImageUrl("$url&width=$width&height=$height") } return ImageUrl("$url?width=$width&height=$height") } fun valueOrDefault(default: ImageUrl) = if (url.isNullOrBlank()) { default } else { this } fun isNull(): Boolean { return url.isNullOrBlank() } val value get() = url ?: "" }
Value ObjectにするまでもないUtils系は、基本、Kotllinの拡張関数で置き換え可能なので、できるところは書き換えていきます。
実装し直せるところはし直します。が、複雑すぎて、すぐに作り直すのが危険な場合もあります。
インスタンスクラスの場合はDIで依存性を注入すればいいですが、Utils系のクラスを新アーキテクチャで使いたい場合は、どうしましょう。
これも依存性逆転の原則を使い、新アーキにインターフェイスをおき、実装クラスをlegacyに置いて、その中でUtilsメソッドを呼び出せば良いでしょう。
または、割り切って、新アーキテクチャに持っていくのも一つの手です。持っていくときに、古いクラスに依存している場合は、それを外せるなら外す、無理なら一緒に持っていくなどの割り切りは必要になってきます。
ただし、極力不要なものは持ってこないことです。
また、既存コードに影響を絶対に出したくない場合は、クラスをコピーして、新アーキに合わせたクラスを作るなども割り切りも必要です。
ただし、その場合は、古いコードには、@Deprecated("Aクラスに依存をさせたくないため", ReplaceWith("com.newspicks.Hoge"))
のようにアノテーションをつけておき、極力使わせないようにし、いつかくる大量リファクタ時代に備えておきます。
また、新にコピーしたクラスもlegacyパッケージなどに置いておき、決して納得して作ったクラスではないことがわかる様にしておきます。
なんでもかんでも初めから完璧に移行しようと考えないことです。なにせ、ビジネスを止めずに負債を解消していくということが、短期的、長期的の両視点で、ユーザ/Bizサイド/技術者の全員がwin-winになる方法だからです。
通信基盤などで複数ある場合
- 通信基盤がVolley,Ktorなど複数存在している
これの解消です。
これは、もう決めるだけです。弊社ではRetrofit2だけにし、それ以外はどんどん作り直しています。
ここの選択はデファクトスタンダードを使うのが良いと思います。
Ktorは、Kotlin Multiplatform Mobile (KMM)化するとしたらこれしかないですが、そうでない場合は、実装方法がシンプルである意味枯れたライブラリのRetrfit2でいいかなと。
KMMにする予定はないですが、もしやるなら違う作り方をするのでこの判断をしています。
Repositoryで実装を隠蔽
- キャッシュ・SharedPreference・Realmなどの利用が隠蔽されていないために実装者はそれぞれでどれを使えばいいかを考えながら作る必要がある
- 非同期処理はコールバックインターフェイスを利用しているところが多く可読性が悪い
これの解消方法です。
SharedPreferenceがActivityやViewなど色々なところで利用されていたので、これをinfrastructuresレイヤーのDaoクラスに持っていきます。
既存のコードを削除して持っていけない場合は、キー名を同じにしてDaoクラスにも実装します。新アーキでは、必ずRepository経由で取得します。
メモリ内のキャッシュはRepositoryに持たせます。
旧コードでは、たとえば、getCacheData()
、fetchApi()
、findByPreference()
のようにどこから取得しているのかがわかるように実装してありました。
これらの実装の詳細など知らなくてよく、全ての戻り値をFlow
にします。Repositoryクラスが、それらを隠蔽しておきます。
基本的にキャッシュになければ、APIから取得するし、キャッシュがあればキャッシュを使えばいい。プロセスが死んでも永続化しておきたい場合は、DataStoreやRoomで保持しておきます。キャッシュをクリアしたいときはすればいい。それだけです。
呼び出すときに同じ実装にしておけば、Repositoryの利用者が、「キャッシュにないからAPI経由で、それは非同期にして、でもキャッシュにある場合は同期の実装にして...」など考えなくてすみます。
Single Source of TruthとObserver
- シングルトンクラスだらけで、それが状態を持っていて、手続き的に処理している。ほぼそれってグローバル変数を頑張ってメンテしている。
これの解消方法です。
色々な箇所で情報を保持しているから難しくなります。変更があったら、その変更をそれぞれで利用する箇所に伝えないと画面の整合性が取れなくなります。
いわゆる「いいね問題」です。詳細画面でいいねしたら、一覧画面でもいいねしている状態ですね。
旧コードは、シングルトンクラスに状態が変わるクラスが保持されており、その値を使う側が、表示するタイミングで取りに行きます。そして、その状態はひとつのクラスにあるわけではなく、複数存在してます。変更があってもそれを感知してないので、自分で取り直さないと以前と同じ状態になります。
新アーキでは、AppStateManager
にStateFlow
を持たせて、それぞれ利用する側は監視しておくだけにしています。以下は弊社のフォローの実装です。
class AppStateManagerImpl : AppStateManager { private var _followState: MutableStateFlow<Set<Int>> = MutableStateFlow(emptySet()) override val followState: StateFlow<Set<Int>> get() = _followState.asStateFlow()
以下の様にオブザーブしておけば、値の変更があっても勝手に画面が変わってくれます。
viewLifecycleOwner.lifecycleScope.launch {
appStateManager.likeState.collect {
// イベントが発生したら更新
}
}
現状では、全てのクラスでDIができる状態ではない(全然やろうと思えばできるが)ので、AppStateManager
では、グローバルで使う値を保持し、画面固有の値はViewModelで扱っています。
本来であれば、シングルトンのAppState
クラスに全ての状態を持たせて、ViewModelでDIし、そこで状態を書き換えていくのが、Single Source of Truthとして良さそうなので、今後そうしていこうと思っています。
DataBindingとBindingAdapterをフル活用
- カスタムViewの内部からAPI通信のクラスを呼び出しているため、Viewに再利用性がない
- ViewModelというクラスが存在しているが、ViewModelがViewクラスに依存しているときがある
これの解消方法です。
カスタムビューは作成しないようにします。ほとんどが、xmlだけで共通化できます。
たとえば、likeボタンの共通のデザインがあったとして、そのファイル名がparts_counter_tapped_like_button.xmlだったとします。それを以下のコードのようにinclude
で呼び出すだけです。
<include layout="@layout/parts_counter_tapped_like_button" app:count="@{likeCount}" app:isOn="@{isLiked}" app:onClick="@{onClickLike}" />
UIロジックでViewに関するものは、全てBindingAdapterで実現します。
xmlだけで、プレビューできるように xmlns:tools
を使います。RecylcerViewの場合もそれぞれのアイテムごとにxmlを作成しておきます。こうしておけば、簡単な画面の修正で毎回ビルドして確認する必要がなくなります。
そして、ViewModelは完全にViewに依存しないコードにでき、単体テストも容易に記述できるようになります。
includeファイルを利用し、外から値を渡すだけという考え方は、「Viewに状態を持たせないで引数で渡した方が再利用性がある」とJetpack Composeでも言われているのと同じだと思います。
ライブラリのアップデート
- ライブラリのバージョンを上げようとすると途端に動かなくなる
これの解消は、上げられるものは全てあげておき、上げると動かなくなるものは、後で調査してあげていく時間を取るのがいいでしょう。 一度全部あげてみて、全部テストをしておきます。
今後は、ライブラリを一気に上げることがないように少しづつあげていくようにしています。
Wikiや仕様書の記述
- ソースが複雑で過去を知っている人でないと気づかないことが多い。つまり属人性が高い
これの一部の解消です。
wikiに新アーキテクチャの説明と思想は書いておき、New Joinerが来てもすぐに入れるように、汎用的に使える便利なクラスの説明などを書いておくのが良いでしょう。
古い箇所は、wikiに書くのが難しいので、頑張らなくて良い気がしていますが、仕様はないと危険です。
弊社ではQAチームが発足されたので、そこでテストケースと仕様書を作成してもらっています。
複雑なソースは、少しづつ改善していくことで無くなっていくはずです。
まとめ
このようにできるところを少しづつ改善していけば、技術的負債は解消していきます。
以下、この記事では扱っていなく、まだ改善はできていませんが、いずれこの部分も無くなっていくでしょう。
また、この記事の通り改善していますが、全てのソースから負債を取り除けている話ではありません。今後、改善を続けることで負債ゼロを目指しています。
- Applicationクラスが肥大化している。また、さまざまなクラスもそのApplicationクラスに依存している
- ルールなくEventBusを多様していて、ソースを追いづらい。それ以前に複数スレッドが色々動いていて、どうなってるのかよくわからない
- ActivityやFragmentにベースクラスがあり、その中で色々な処理をしている。
最初に書いた通り、なかなかの技術的負債がありながらもビジネスを止めることなく、少しづつ改善を続けることで生産性も1.5倍に上げることができています。
技術的負債の山で途方に暮れている組織もあるかもしれませんが、どんなアプリでも必ず改善する意思があれば改善できていきます。
その時に少しでもこの記事が役立つといいなと思います。
最後にお知らせです。
ユーザベースではテクノロジー・カンパニーを推進するための新たな取り組み「Play Engineering」を発表しました。
そして、事業を成長させる実感を持って働きたいエンジニアを大募集しています!
「Play Engineering」の特設サイトを ぜひご覧いただき、まずは、カジュアル面談に来ていただければと思います。