デジタルイノベーションラボPayチームのゴルゴです。 先日Androidアプリで利用するDIライブラリをKoinからDagger-Hilt(移行Hilt)へ移行したので、今回はその件について書こうと思います。
背景
Androidアプリの初期実装は主に2名のエンジニアで行ったのですが、2018年の当時はまだどちらもDaggerの利用経験がなくDaggerの学習コストが高いことがネックとなり、導入が容易なKoinを採用しました。 Koinを採用してから最近まで特に大きな問題はなかったものの、
- 近年Hiltがリリースされたことで導入コストが下がった
- Koinのバージョン変更に伴いコード改修を必要とした
- Koinが実行時に依存性解決するのに対し、Hiltはビルド時に解決するため問題の検出が早く安全に使える
などを考慮してHiltに移行することにしました。
進め方
準備
移行にあたってはまずはスクラムのスパイクタスクとして試験的に1画面の移行を試し、その中で課題の洗い出しと調査を実施することにしました。 Developersガイドに従って作業すると共に、Hiltのコンポーネントの分け方やライフサイクル、Koinで特殊な使い方をしていた箇所の移行方法についてまとめを行いチーム内で共有しました。 途中で躓いた点もあったのですがそれは後述しています。
本対応
2週間の1スプリントで気合を入れて一気に移行作業を実施しました。 Hiltのコンポーネントファイルは、アプリがMVVM+UseCase/Repository/DataStoreという構成なので、UseCase/Repository/DataStoreの各レイヤ毎に作成し、移行作業を進めました。 ただFirebaseAnalyticsのLoggerのような特定レイヤに属さず全般で使うようなオブジェクトもあるので、そういったものはApplicationModulesを作成してそこに属させることにしました。
また作業中に特に気をつけた点として、途中でリファクタリングやDeprecatedの対応をしたくなったのですが、これをしてしまうと万一問題があった際に原因切り分けに時間を要してしまうので、やむを得ない箇所を除きぐっとこらえて進めました。
躓いた点
途中躓いて調査を要したポイントです
ContextからEntryPoint取得
ActivityやFragment等のHilt既定のAndroidEntryPointと、依存オブジェクトを使いたい箇所が離れていてオブジェクトを引き回すのが難しいケースというのがどうしても出てきました。そのようなケースのためにContextさえあれば独自に定義したEntryPointを取得し、依存オブジェクトを取得する方法が用意されています。
https://dagger.dev/hilt/entry-points
例えば任意の場所でFooBarを欲しい場合は
@EntryPoint @InstallIn(SingletonComponent::class) interface FooBarEntryPoint { fun get(): FooBar }
という定義をしておけば、
val fooBar = EntryPointAccessors.fromApplication<FooBarEntryPoint>(application).get()
でオブジェクトを取得することができます。
ActivityとFragmentでViewModelを共有
Activityとその内部に配置しているFragmentで同じViewModelを参照し、Activityからの操作をFragmentの画面に反映したい場面がありました。
このケースではActivity/Fragmentそれぞれで通常通りby viewModels()
でオブジェクトを取得すると、別インスタンスになってしまいActivityからの操作が画面に反映されない事態が発生してしまいます。
こういった場合、Fragmentからは
// Fragment内 private val viewModel: FooBarViewModel by activityViewModels()
で取得することで、Activity側と同じインスタンスを共有することができます。
https://developer.android.com/kotlin/ktx?hl=ja#fragment
ViewModelのコンストラクタで動的なパラメータを引き渡し
例えば、一覧から詳細画面に遷移する際に、詳細画面のViewModeにIDを渡すようなケースがあると思います。ViewModelにミュータブルなプロパティやセッターメソッドを用意し、Activity/Fragmentからセットしてもいいのですが、次のようにSavedStateHandleを使うことでイミュータブルにすることができます。
@HiltViewModel class DetailViewModel @Inject constructor( savedStateHandle: SavedStateHandle ) : ViewModel() { val id = requireNotNull(savedStateHandle.get<ItemId>("id")) … }
なお、savedStateHandle
にはHiltによって
activity.getIntent().getExtras()
あるいはfragment.getArguments()
が渡されますが、任意の値を設定したい場合にはAbstractSavedStateViewModelFactory
を継承したFactoryクラスをつくり、このクラスを使ってViewModelを生成することで実現できました。
fun provideFactory( owner: SavedStateRegistryOwner, defaultArgs: Bundle? = null, foo: Foo ): AbstractSavedStateViewModelFactory = object : AbstractSavedStateViewModelFactory(owner, defaultArgs) { @Suppress("UNCHECKED_CAST") override fun <T : ViewModel> create(key: String, modelClass: Class<T>, handle: SavedStateHandle): T { handle.set("key_foo", foo) return TopicViewModel(handle) as T } }
やってみてどうだったか
移行作業の中で単純な記述ミスも時折あったのですが、コード内にEntryPointを定義した時点で依存解決できずにビルドエラーとなる、つまりHiltのメリットとして期待していたとおり、コード実行せずとも設定ミスがわかるので効率的にすすめることが出来ました。 動かしてから初めてエラーになるようなミスはなかったと記憶しています。 実はHiltに移行したアプリをリリースしてから既に数ヶ月経過しているのですが、今のところ移行に起因した問題は出ていません。広範囲の修正をおこなったのでリリース時は不安はありましたが、無事に移行できてよかったです。
この記事がどなたかの助けになれば幸いです。