📱

AndroidアプリのDIをKoinからHiltへ、Gradleビルド構成をGroovyからKTSへチェンジした話

2022/12/14に公開

概要

こんにちは! Android / iOS エンジニアのトニオ(@tonionagauzzi)です。
ACCESS Advent Calendar の Day 14 へ本記事を投稿します。

Android アプリ UnusedAppFinder をリファクタリングした話です。
今年は以下の変更を行いました。

  1. DI ツールを Koin → Hilt に差し換え
  2. Gradle を Groovy → Kotlin に変換
  3. Jetpack Compose を導入

このうち、本記事では 1 と 2 を解説します。
3 は、後編のAndroidアプリの機能そのままにKoin→Hiltの書き換えをした話で解説します。

以下が移行前の原型です。

移行後の画面は後編に出てきますが、レイアウトと主要な機能はほぼ変わっていません。

インストールアプリが最近起動したものから順に表示されます。
使わなくなったアプリを可視化して、断捨離するための手助けをしようというアプリです。
各アプリアイコンをタップするとアンインストールできる設定画面が開きます。

設計はこのような単方向データフローになっています。

図では省略していますが、Interactor の下に Repository があり、アプリ一覧やパッケージ名の取得を担っています。
また、Interactor は実際には UseCase というパッケージ名で実装しています。

本記事の元になった Twitter の投稿です。

https://twitter.com/tonionagauzzi/status/1580588313614094336

それでは見ていきます。

1. DI ツールを Koin → Hilt に差し換え

まず DI ツールを 3rd Party である Koin から、Dagger ベースであり Google 製で Android との親和性がとても高い Hilt へ移行しました。

移行のきっかけは、

  1. 新しめの純正ツールだから 3rd Party への依存を減らすチャンス
  2. 自社でも使われはじめていた
  3. DroidKaigi でスタッフのそば屋さん(@sobaya15)と話したら数ヶ月前やって良かったとおっしゃっていた
  4. iOSのモブプログラミング中に「Kotlin ならこう書けるのに〜!」みたいな話をしていて、Kotlin書きたい欲が高まり、そのとき個人アプリの一番上にあったTODOがこれだった

みたいな感じでした。

公式ドキュメントに従って移行しました。
https://developer.android.com/training/dependency-injection/hilt-android?hl=ja

以下が該当するコミットです。ただし、この時点ではいくつか誤りを含んでいます。
https://github.com/tonionagauzzi/UnusedAppFinder/commit/c7f0e3b7f6df80628faf0bab9d8d3e2142922e72

ライブラリ依存関係の更新

まず、Hilt を使うために必要なものを入れました。

build.gradle
+ plugins {
+     id 'com.google.dagger.hilt.android' version '2.44' apply false
+ }
app/build.gradle
+ apply plugin: 'com.google.dagger.hilt.android'

  android {
      ()
  }

  dependencies {
      ()
-     // Koin の依存関係
-     implementation "androidx.lifecycle:lifecycle-extensions:2.2.0"
-     implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1"
-     implementation "io.insert-koin:koin-androidx-scope:2.0.1"
-     implementation "io.insert-koin:koin-androidx-viewmodel:2.0.1"
+     // Hilt の依存関係
+     implementation "com.google.dagger:hilt-android:2.44"
+     kapt "com.google.dagger:hilt-compiler:2.44"
  }

+ kapt {
+     correctErrorTypes true
+ }

Java 8 はもともと有効だったのでスキップしました。

コード側の土台の変更

Koin で必要だったコードを削除し、代わりに Hilt で必要なコードを追加しました。

Application クラスは@HiltAndroidAppを付けるだけでした。

app/src/main/java/com/vitantonio/nagauzzi/unusedappfinder/KoinApp.kt
- package com.vitantonio.nagauzzi.unusedappfinder
- 
- class KoinApp : Application() {
-     override fun onCreate() {
-         super.onCreate()
-         startKoin {
-             androidContext(applicationContext)
-             modules(module)
-         }
-     }
- 
-     private val module = module {
-         single { AppUsageRepositoryImpl(androidContext()) as AppUsageRepository }
-         single { GetAppUsages(get()) }
-         viewModel { WebViewViewModel() }
-         viewModel { UnusedAppListViewModel(get()) }
-     }
- }
app/src/main/java/com/vitantonio/nagauzzi/unusedappfinder/HiltApp.kt
+ package com.vitantonio.nagauzzi.unusedappfinder
+ 
+ @HiltAndroidApp
+ class HiltApp : Application()

Koin のときは依存関係を Application クラスの中で設定していましたが、Hilt ではそれはしません。
代わりに後述の@Inject@Module@InstalledInを撒く対応をします。

AndroidManifest.xmlも忘れず直しましょう。

app/src/main/AndroidManifest.xml
<application
-       android:name=".KoinApp"
+       android:name=".HiltApp"
        android:allowBackup="true"
        android:fullBackupContent="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name_short"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/AppTheme">

コード側の各所の変更

Activity

View 層では基本的に@AndroidEntryPointを付ける対応になります(参考)。

app/src/main/java/com/vitantonio/nagauzzi/unusedappfinder/view/MainActivity.kt
+ @AndroidEntryPoint
  class MainActivity : AppCompatActivity() {
      ()
  }

Fragment

Fragment には ViewModel や UseCase との依存関係があったので、まず ViewModel を Hilt で取得できるようにしました。

app/src/main/java/com/vitantonio/nagauzzi/unusedappfinder/view/WebViewFragment.kt
+ @AndroidEntryPoint
  class WebViewFragment : Fragment() {
  
-     private val viewModel: WebViewViewModel by viewModel()
+     private val viewModel: WebViewViewModel by viewModels()

      ()
  }

ViewModel への依存関係は、viewModels()を使った委譲を行えば自動的に繋げてくれます。
この例だとわかりづらいですが、viewModel()は Koin が提供する機能で、viewModels()androidx.fragment:fragmentに含まれる機能です。

ちなみに既に Jetpack Composeを使用している場合、androidx.fragment:fragmentは使えないので、代わりにandroidx.lifecycle:lifecycle-viewmodel-composeが提供するviewModel()を使うことになります(参考)。
Koin のviewModel()と同名ですが別物なのでご注意ください。

続いて UseCase を Hilt で取得できるようにします。

app/src/main/java/com/vitantonio/nagauzzi/unusedappfinder/view/UnusedAppListFragment.kt
+ @AndroidEntryPoint
  class UnusedAppListFragment : Fragment() {

-     private val viewModel: UnusedAppListViewModel by viewModel()
+     private val viewModel: UnusedAppListViewModel by viewModels()
+ 
+     @Inject lateinit var getAppUsages: GetAppUsages

      ()

      private fun getAppUsages() {
          viewLifecycleOwner.lifecycleScope.launchWhenCreated {
-             val useCase: GetAppUsages by inject()
-             useCase.execute()
+ 	        getAppUsages.execute()
          }
      }
  }

UseCase への依存関係は、@Injectを使ったフィールドインジェクションで繋げます。
Hiltにはコンストラクタインジェクションとフィールドインジェクションしかないっぽいので(他にあれば教えてください)、getAppUsagesインスタンスはフィールドに持たせるように書き換えました。

ViewModel

ViewModel 層では、先述のviewModels()で View 層へ渡せるように@HiltViewModelを付けました。

app/src/main/java/com/vitantonio/nagauzzi/unusedappfinder/viewmodel/WebViewViewModel.kt
+ @HiltViewModel
  class WebViewViewModel: ViewModel() {
      val url = MutableLiveData<String>()
  }
app/src/main/java/com/vitantonio/nagauzzi/unusedappfinder/viewmodel/UnusedAppListViewModel.kt
+ @HiltViewModel
- class UnusedAppListViewModel(
-     private val context: Context
+ class UnusedAppListViewModel @Inject constructor(
+     packageNameRepository: PackageNameRepository
  ) : ViewModel() {
  
      ()
  
-     it.enableUninstall && it.packageName != context.packageName
+     it.enableUninstall && it.packageName != packageNameRepository.get()

      ()
  }
  

ViewModel から他への依存関係がある場合、@Injectでコンストラクターインジェクションを行います。

UnusedAppListViewModelはもともとContextへの参照を持っていましたが、これは ViewModel が OS の都合に依存しているという点で微妙に感じていたので、Contextを Repository 層に隠蔽し、packageNameRepository.get()でパッケージ名だけを取れるように直しました。
それに伴い、UnusedAppListViewModelには依存関係としてPackageNameRepositoryがあることを@Injectで Hilt に教えるようにしました。

Repository

Repository 層にも Context との依存関係があるので繋げてあげる必要があります。

app/src/main/java/com/vitantonio/nagauzzi/unusedappfinder/repository/PackageNameRepository.kt
+ interface PackageNameRepository {
+     fun get(): String
+ }
+ 
+ class PackageNameRepositoryImpl @Inject constructor(
+     @ApplicationContext private val context: Context
+ ) : PackageNameRepository {
+ 
+     override fun get(): String = context.packageName
+ }
app/src/main/java/com/vitantonio/nagauzzi/unusedappfinder/repository/AppUsageRepository.kt
  interface AppUsageRepository {
      fun get(): List<AppUsage>
  }
  
- class AppUsageRepositoryImpl(
-     private val context: Context
+ class AppUsageRepositoryImpl @Inject constructor(
+     @ApplicationContext private val context: Context
  ) : AppUsageRepository {

他の多くのアプリなら通信モジュールやデータベースとの依存関係も発生すると思いますが、このアプリの場合は取り急ぎ Context だけが欲しいです。
なんと、@ApplicationContextを付けるだけで得られました。
ちなみに@ActivityContextもあります。用途に応じて使い分けましょう(参考)。

あとは、Repository が何回バインディングされても1回しか生成されず同じインスタンスを返すように、以下を定義しました。

app/src/main/java/com/vitantonio/nagauzzi/unusedappfinder/repository/RepositoryModule.kt
+ @Module
+ @InstallIn(SingletonComponent::class)
+ abstract class RepositoryModule {
+     @Binds
+     @Singleton
+     abstract fun bindAppUsageRepository(
+         appUsageRepositoryImpl: AppUsageRepositoryImpl
+     ): AppUsageRepository
+ 
+     @Binds
+     @Singleton
+     abstract fun bindPackageNameRepository(
+         packageNameRepositoryImpl: PackageNameRepositoryImpl
+     ): PackageNameRepository
+ }

今回は@InstallIn(SingletonComponent::class)を選びましたが、Activity が生きている間だけ使い回す場合は@InstallIn(ActivityComponent::class)を選びます。こちらも用途に応じて使い分けましょう(参考

UseCase

app/src/main/java/com/vitantonio/nagauzzi/unusedappfinder/usecase/GetAppUsages.kt
- class GetAppUsages(
+ class GetAppUsages @Inject constructor(
+     private val repository: AppUsageRepository
+ ) {

UseCase層は、設計図で Interactor となっていた部分です。
View 層に注入するために、ここにも忘れず@Injectを付けます。
Repository 層と同じように生成は1回でよいので@Module@InstallInを定義したほうがよいですが、忘れていたので今回は省略します。

以上で、Koin から Hilt への移行ができました。
思ったよりも簡単でした。

移行で感じた変化

最初は、アノテーションを使いまくるので一見分かりづらいなと思っていました。
Koin は Application 拡張クラスの中を見れば個人的にモジュール同士の結合度合いを把握しやすかったです。
その前は Kodein を使っていた(Kodein → Koin の移行)ので、ライブラリが提供するクロージャーの中に依存関係を書くことに慣れていました。
しかし、Hilt の学習コストはそこまで高くなく、公式ドキュメントも充実しているので、すぐに慣れることができました。

そして思わぬ利点として、ApplicationContextが欲しい部分はコンストラクタに@ApplicationContextを書くだけで済みました。
View 層への ViewModel 注入も androidx が提供するものを使えば良いので、より OS 標準の書き方に近づいたのではと思います。

2. Gradle を Groovy → KTS に変換

Jetpack Compose 対応に先立ち、All Kotlin 化を進めるためにbuild.gradlebuild.gradle.ktsに書き換えました。

https://developer.android.com/studio/build/migrate-to-kts?hl=ja

書き換えと言っても、ファイル名を変えてから File → Sync Project with Gradle Files を実行し、エラーになる箇所を手直ししただけです。

以下が該当するコミットです。
https://github.com/tonionagauzzi/UnusedAppFinder/commit/1faa0c0aee5c02f4104411c7ec0bc511adedd2fe

修正ポイント

  1. カッコやイコールを明確に付ける
  2. 文字リテラルはダブルクォーテーションで囲う
  3. task cleanapply pluginのようなエラーになる箇所は KTS のドキュメントや Android Studio の補完を参考に書き換える

やったこと

build.gradle.kts
  // Top-level build file where you can add configuration options common to all sub-projects/modules.
  buildscript {
      repositories {
          google()
          mavenCentral()
          jcenter().mavenContent {
              includeGroup("com.cookpad.android.licensetools")
          }
      }
      dependencies {
-         classpath 'com.android.tools.build:gradle:4.1.3'
-         classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.10"
-         classpath 'com.cookpad.android.licensetools:license-tools-plugin:1.7.0'
+         classpath("com.android.tools.build:gradle:4.1.3")
+         classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.10")
+         classpath("com.cookpad.android.licensetools:license-tools-plugin:1.7.0")
          // NOTE: Do not place your application dependencies here; they belong
          // in the individual module build.gradle files
      }
  }
  
  plugins {
-     id 'com.google.dagger.hilt.android' version '2.44' apply false
+     id("com.google.dagger.hilt.android") version "2.44" apply false
  }
  
  allprojects {
      repositories {
          google()
          mavenCentral()
          jcenter().mavenContent {
              includeGroup("com.cookpad.android.licensetools")
          }
      }
  }
  
- task clean(type: Delete) {
-     delete rootProject.buildDir
+ tasks.register<Delete>("clean").configure {
+     delete(rootProject.buildDir)
  }
app/build.gradle.kts
- apply plugin: 'com.android.application'
- apply plugin: 'com.cookpad.android.licensetools'
- apply plugin: 'com.google.dagger.hilt.android'
- apply plugin: 'kotlin-android'
- apply plugin: 'kotlin-kapt'
+ plugins {
+     id("com.android.application")
+     id("com.cookpad.android.licensetools")
+     id("com.google.dagger.hilt.android")
+     id("kotlin-android")
+     id("kotlin-kapt")
+ }
  
  android {
-     compileSdkVersion 31
+     compileSdkVersion(31)
      defaultConfig {
-         applicationId "com.vitantonio.nagauzzi.unusedappfinder"
-         minSdkVersion 22
-         targetSdkVersion 31
-         versionCode 1
-         versionName "1.0"
-         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+         applicationId = "com.vitantonio.nagauzzi.unusedappfinder"
+         minSdkVersion(22)
+         targetSdkVersion(31)
+         versionCode = 1
+         versionName = "1.0"
+         testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
      }
      buildFeatures {
-         dataBinding true
-         viewBinding true
+         dataBinding = true
+         viewBinding = true
      }
      buildTypes {
-         release {
-             minifyEnabled false
-             proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
-         }
+         named("release") {
+             isMinifyEnabled = false
+             setProguardFiles(listOf(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"))
+         }
      }
      compileOptions {
-         sourceCompatibility JavaVersion.VERSION_1_8
-         targetCompatibility JavaVersion.VERSION_1_8
+         sourceCompatibility(JavaVersion.VERSION_1_8)
+         targetCompatibility(JavaVersion.VERSION_1_8)
      }
      kotlinOptions {
          jvmTarget = "1.8"
      }
  }

  dependencies {
      // Add missing dependencies for JDK 9+
-     if (JavaVersion.current().ordinal() >= JavaVersion.VERSION_1_9.ordinal()) {
-         annotationProcessor 'javax.xml.bind:jaxb-api:2.3.1'
-         annotationProcessor 'com.sun.xml.bind:jaxb-core:2.3.0.1'
-         annotationProcessor 'com.sun.xml.bind:jaxb-impl:2.3.2'
-         kapt "com.sun.xml.bind:jaxb-core:2.3.0.1"
-         kapt "javax.xml.bind:jaxb-api:2.3.1"
-         kapt "com.sun.xml.bind:jaxb-impl:2.3.2"
-     }
+     if (JavaVersion.current() >= JavaVersion.VERSION_1_9) {
+         annotationProcessor("javax.xml.bind:jaxb-api:2.3.1")
+         annotationProcessor("com.sun.xml.bind:jaxb-core:2.3.0.1")
+         annotationProcessor("com.sun.xml.bind:jaxb-impl:2.3.2")
+         kapt("com.sun.xml.bind:jaxb-core:2.3.0.1")
+         kapt("javax.xml.bind:jaxb-api:2.3.1")
+         kapt("com.sun.xml.bind:jaxb-impl:2.3.2")
+     }
  
-     implementation fileTree(dir: 'libs', include: ['*.jar'])
-     implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.5.30"
-     implementation "androidx.navigation:navigation-fragment-ktx:2.5.2"
-     implementation "androidx.appcompat:appcompat:1.3.1"
-     implementation "androidx.core:core-ktx:1.6.0"
-     implementation "androidx.constraintlayout:constraintlayout:2.1.0"
-     implementation "androidx.legacy:legacy-support-v4:1.0.0"
-     implementation "com.google.dagger:hilt-android:2.44"
-     kapt "com.google.dagger:hilt-compiler:2.44"
-     testImplementation "junit:junit:4.13.2"
-     androidTestImplementation "androidx.test:runner:1.4.0"
-     androidTestImplementation "androidx.test.espresso:espresso-core:3.4.0"
+     implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar"))))
+     implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.5.30")
+     implementation("androidx.navigation:navigation-fragment-ktx:2.5.2")
+     implementation("androidx.appcompat:appcompat:1.3.1")
+     implementation("androidx.core:core-ktx:1.6.0")
+     implementation("androidx.constraintlayout:constraintlayout:2.1.0")
+     implementation("androidx.legacy:legacy-support-v4:1.0.0")
+     implementation("com.google.dagger:hilt-android:2.44")
+     kapt("com.google.dagger:hilt-compiler:2.44")
+     testImplementation("junit:junit:4.13.2")
+     androidTestImplementation("androidx.test:runner:1.4.0")
+     androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0")
}
  
  kapt {
-     correctErrorTypes true
+     correctErrorTypes = true
  }

移行で感じた変化

これに関しては Kotlin 化が進んで気持ちいいな〜くらいの感覚でした。
アプリの規模が小さく CI/CD も利用していないため、便利になった!って感じではありません。

ただし、KTS を使うと Groovy よりもビルドが遅くなる傾向があるらしいので、大規模なプロジェクトの場合は移行しない判断もあるかもしれません。

後編への案内

続きは、後編のAndroidアプリの機能そのままにKoin→Hiltの書き換えをした話で解説します。

さいごに

本記事は株式会社ACCESS の Advent Calender にエントリーしています。
ACCESS では多様なエンジニア職を募集されていますので、チェックよろしくお願いします!

キャリア採用ページ

https://www.access-company.com/recruit/mid-career/

Discussion