ANDPADアプリのDaggerからHiltへのライブラリ移行

こんにちは、アンドパッドで ANDPAD施工管理 アプリのプロダクトエンジニアをしています松川です。

ANDPAD施工管理アプリ(以後、施工管理アプリと言います)は依存性注入(Dependency Injection、DI)ライブラリとしてDaggerを今まで利用してきましたがHiltに移行し、ユーザ影響なく無事にリリースできました。

Daggerはとても便利ですが、よりAndroidアプリ開発のために改良されたHiltを利用することで冗長な設定ファイルをアノテーションによる自動生成に置き換えることができます。

Hiltへの移行はドキュメントを読めば大きく躓くことはありませんでしたが少し苦労した点もありました。

この記事では移行の際に少し苦労した点を共有し、読者の方のHiltへの移行の手助けになれば幸いです。

developer.android.com

アノテーションを付ける作業が大変

Hiltの移行ではApplicationクラスに@HiltAndroidApp, ActivityやFragmentに@AndroidEntryPoint, Moduleクラスでcontextを使う時は@ApplicationContextを付けるなどアノテーションを付ける作業が発生します。

施工管理アプリは100近くのActivityやFragmentで構成されているのでこのアノテーションを付ける作業は非常に大変なものとなります。

InjectをしないActivityには@AndroidEntryPointを付ける必要がないためその見極めを目で判断するのは大変です。付け忘れが発生するとアクティビティ起動時にクラッシュするため漏れがないかのチェックも大変です。

私は簡単なスクリプトを書いてこの移行作業をしました。

スクリプトの中身としてはActivityやFragmentでありjavax.inject.Injectをimportしているクラスには@AndroidEntryPointをクラスに付与するという簡単なものです。 このようなスクリプトを書いておくと便利で、他のメンバーの修正とコンフリクトが発生してもスクリプトを実行すれば良いので書いてよかったと感じています。

SubComponentとして定義されたDaggerの移行

アンドパッドはマルチアプリ戦略をとっており機能毎にアプリを分けて提供しています。 アプリ共通で利用したい機能(カメラ機能・報告機能・写真選択機能)などは社内ライブラリとして切り出して共通化を行なっています。 ライブラリはaarとしてMavenRepositoryに送信しており、Gradleファイルの依存関係に含めることで利用できます。

アプリと社内ライブラリの関係

この図のカメラ機能が施工管理アプリのSubComponentとしてDIの設定が行われていました。

移行には以下の選択肢がありました。

  1. カメラ機能もHiltに移行する
  2. カメラ機能はDaggerのままSubComponentからComponentに変更する

今回は2の方針で移行作業を行いました。

理由は仮に今後Hiltから別のDIライブラリに移行するとなった場合にまた同じカメラ機能のDI移行の問題に直面するためです。 アプリのDIライブラリを変更するために社内ライブラリのDIライブラリも変更しないといけない状況を改善できる方針2を取りました。

カメラ機能はアプリが作るOkHttpClient等を必要としており、アプリはカメラ機能が作るRepositoryを必要としている状況でそれらがDaggerの中でうまく処理されている状態でした。

修正内容としてはアプリとライブラリが相互に必要としているものをDaggerの中で解決せず、明示的に渡し合うように修正しました。

object AndpadCamera {
    internal lateinit var component: CameraComponent

    // アプリが必要としているものをカメラ機能から公開する
    fun getPhotoHistoryRepository(): PhotoHistoryRepository =
        component.getPhotoHistoryRepository()

    // カメラ機能の初期化処理
    // カメラ機能が必要なものを外から渡す
    fun init(
        okHttpClient: OkHttpClient,
        baseUrl: String,
        applicationContext: Context,
    ) {
        component = DaggerCameraComponent.builder()
            .cameraModule(CameraModule(applicationContext, okHttpClient, baseUrl))
            .build()
    }
}

またライブラリ側でSubCompoentとして定義していた設定をSingletonのComponentに変更しました。

@Subcomponent
interface CameraComponent {

↓

@Singleton
@Component(modules = [CameraModule::class])
interface CameraComponent {

@AndroidEntryPointを付けたFragment内でのcontextへのアクセス

@AndroidEntryPoint を付けたFragmentでcontextにアクセスした場合、ContextクラスではなくFragmentContextWrapperクラスのインスタンスが返却されます。

FragmentContextWrapperはHiltが生成したコードから利用するためのものであるためアプリで使うことはなく、FragmentContextWrapperのコメントにもDo not useと記載されています。

context as Activitycontext is MainActivityなどをした場合はCastに失敗しクラッシュしたり想定外の挙動をします。

もし単にActivityが欲しい場合はactivityでActivityを取得することにより回避できます。

contextが欲しい場合は(context as FragmentContextWrapper).baseContextでContextを取得できます。

移行の際にはFragment内でcontextrequireContext()をしている箇所を確認することをお勧めします。

github.com

FragmentContextWrapperに関してissueに上がっていました。

In general, you shouldn't assume that a context is exactly a particular object like the Activity. You should only assume that a context eventually wraps the Activity. This is true even without Hilt since Android does have context wrappers for themes for example. To get the activity from the view, you should take the context, check if it is the Activity, and if not, unwrap it by getting its base context, and repeating and until you find the Activity. 一般に、コンテキストがアクティビティのような特定のオブジェクトであると想定すべきではありません。コンテキストが最終的にアクティビティをラップするとのみ想定する必要があります。たとえば、Android にはテーマのコンテキスト ラッパーがあるため、これは Hilt がなくても当てはまります。ビューからアクティビティを取得するには、コンテキストを取得し、それがアクティビティであるかどうかを確認し、そうでない場合は、その基本コンテキストを取得してラップを解除し、アクティビティが見つかるまで繰り返します。 issue のコメント を意訳

確かに私自身もContextThemeWrapperなどcontextをWrapしたものに遭遇したことがあります。 Contextは抽象クラスでありそれを実装したクラスが多数あります。contextがActivityだと前提とする実装はそもそも避けた方が良いかもしれません。

まとめ

施工管理アプリのDIをHiltに移行した際のTipsを共有しました。 読者の方の移行の手助けになれば幸いです。   アンドパッドではムリ・ムダ・ムラを少しでも無くして開発したい Android エンジニアを募集しています。 engineer.andpad.co.jp