📲

Androidアプリの機能そのままにAndroid View→Jetpack Composeの書き換えをした話

2022/12/14に公開

概要

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

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

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

このうち、本記事では 3 を解説します。
1 と 2 は、前編のAndroidアプリのDIをKoinからHiltへ、Gradleビルド構成をGroovyからKTSへチェンジした話で解説しています。

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

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

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

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

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

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

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

それでは見ていきます。

3. Jetpack Composeを導入

Android View から Jetpack Compose への移行です。
きっかけは説明するまでもないと思います。
全変更を載せるのは非現実的なので、要点だけ紹介します。
フル版は以下のコミットを見てください!
https://github.com/tonionagauzzi/UnusedAppFinder/commit/6aba1dd27bc96a1d99ba6fb106ba4a3e6eee2c34
ただし、この時点ではまだ不完全な状態です。

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

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

Gradle のバージョンが古すぎたので、不要なエラーで詰まるのを懸念してそちらも更新しました。
いくつ以上が必須とかいう話は、この記事では省略します。

build.gradle.kts
dependencies {
-   classpath("com.android.tools.build:gradle:4.1.3")
-   classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.10")
+   classpath("com.android.tools.build:gradle:7.2.2")
+   classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.7.20")
    ()
}
app/build.gradle.kts
  android {
-     compileSdkVersion(31)
+     compileSdk = 33
      defaultConfig {
          applicationId = "com.vitantonio.nagauzzi.unusedappfinder"
-         minSdkVersion(22)
-         targetSdkVersion(31)
+         minSdk = 22
+         targetSdk = 33
          versionCode = 1
          versionName = "1.0"
          testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
      }
      buildFeatures {
-         dataBinding = true
-         viewBinding = true
+         compose = true
      }
+     composeOptions {
+         kotlinCompilerExtensionVersion = "1.3.2"
+     }
      ()
}

  dependencies {
      implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar"))))
-     implementation("androidx.appcompat:appcompat:1.3.1")
-     implementation("androidx.constraintlayout:constraintlayout:2.1.0")
-     implementation("androidx.legacy:legacy-support-v4:1.0.0")
+     implementation("androidx.activity:activity-compose:1.6.0")
+     implementation("androidx.compose.material:material:1.2.1")
+     implementation("androidx.compose.ui:ui:1.2.1")
+     implementation("androidx.compose.ui:ui-tooling-preview:1.2.1")
-     implementation("androidx.core:core-ktx:1.6.0")
+     implementation("androidx.core:core-ktx:1.9.0")
+     implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.5.1")
-     implementation("androidx.navigation:navigation-fragment-ktx:2.5.2")
+     implementation("androidx.navigation:navigation-compose:2.5.2")
+     implementation("androidx.navigation:navigation-ui-ktx:2.5.2")
+     implementation("com.google.accompanist:accompanist-drawablepainter:0.25.1")
+     implementation("com.google.accompanist:accompanist-swiperefresh:0.25.1")
      implementation("com.google.dagger:hilt-android:2.44")
-     implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.5.30")
*     implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.7.20")
      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")
  
      ()
  }
  (略)
- distributionUrl=https\://services.gradle.org/distributions/gradle-6.5-all.zip
* distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-all.zip

リソースのKotlinコード化

消したものたち

以下はバッサリ消しました。

  • app/src/main/res/layout
  • app/src/main/res/menu
  • app/src/main/res/navigation
  • app/src/main/res/values/colors.xml
  • app/src/main/res/values/styles.xml

追加したものたち

BasicStateCodeLabに倣って以下を作成しました。

  • app/src/main/java/com/vitantonio/nagauzzi/unusedappfinder/view/theme/Color.kt
  • app/src/main/java/com/vitantonio/nagauzzi/unusedappfinder/view/theme/Shape.kt
  • app/src/main/java/com/vitantonio/nagauzzi/unusedappfinder/view/theme/Theme.kt
  • app/src/main/java/com/vitantonio/nagauzzi/unusedappfinder/view/theme/Type.kt

今回は Material Design 2 で作ったので、Theme.kt 内で以下に倣い MaterialTheme を定義しました。

https://developer.android.com/jetpack/compose/designsystems/material?hl=ja

既存コードの修正

AppTheme が使えなくなったので、 AndroidManifest.xml 上ではデフォルトで用意されたテーマを使うように直しておきます。

AndroidManifest.xml
<application
        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">
+       android:theme="@style/Theme.AppCompat.Light.NoActionBar">

レイアウト、メニュー、ナビゲーションも消しましたが、この時点で頑張るのではなく、消してビルドエラーになる場所を MainActivity 修正後に1つずつ解決していく作戦にしました。

Android View 時代のレイアウトを Jetpack Compose で再現

さて、ここから元々 Kotlin で書かれていた部分の対応です。

消したものたち

以下はバッサリ消しました。

  • app/src/main/java/com/vitantonio/nagauzzi/unusedappfinder/adapter/UnusedAppListAdapter.kt
  • app/src/main/java/com/vitantonio/nagauzzi/unusedappfinder/view/UnusedAppListFragment.kt
  • app/src/main/java/com/vitantonio/nagauzzi/unusedappfinder/view/WebViewFragment.kt
  • app/src/main/java/com/vitantonio/nagauzzi/unusedappfinder/viewmodel/WebViewViewModel.kt

前2つは Android View 向けのクラスだからですが、後2つは別の事情です。
WebView は OSS ライブラリ一覧を表示するために使っていましたが、そのために使っていたcom.cookpad.android.licensetools:license-tools-pluginの更新が止まっていたので、他のライブラリを探さなきゃと思っていました。
なので、この機会に一旦 OSS ライブラリ一覧表示機能自体を廃止しました。
最終的にcom.google.android.gms:oss-licenses-pluginを使うことにしましたが、その話はここでは省略します。

追加したものたち

  • app/src/main/java/com/vitantonio/nagauzzi/unusedappfinder/view/composable/AppUsageTextGroup.kt
  • app/src/main/java/com/vitantonio/nagauzzi/unusedappfinder/view/composable/HowToPermitAppUsage.kt
  • app/src/main/java/com/vitantonio/nagauzzi/unusedappfinder/view/composable/UnusedAppDropdownMenu.kt
  • app/src/main/java/com/vitantonio/nagauzzi/unusedappfinder/view/composable/UnusedAppList.kt
  • app/src/main/java/com/vitantonio/nagauzzi/unusedappfinder/view/composable/UnusedAppRoot.kt
  • app/src/main/java/com/vitantonio/nagauzzi/unusedappfinder/view/composable/UnusedAppTopBar.kt

これらは全て Composable な View パーツで、関係はこうなっています。

  • UnusedAppTopBar: タイトルバー
    • UnusedAppDropdownMenu: 右上のメニュー
  • UnusedAppRoot: メイン部分
    • HowToPermitAppUsage: アプリ一覧が取れないときに出す権限許可要求画面
    • UnusedAppList: アプリ一覧が取れたときに表示するグリッドビュー
      • AppUsageTextGroup: アプリ1個の情報を表示するテキストの集まり

これが移行後の画面です。

移行前とほとんど変わってないですね!

要点としてグリッドビューの部分を載せます。

app/src/main/java/com/vitantonio/nagauzzi/unusedappfinder/view/composable/UnusedAppList.kt
@Composable
fun UnusedAppList(
    modifier: Modifier,
    unusedAppListViewModel: UnusedAppListViewModel = viewModel(),
) {
    val context = LocalContext.current
    val showingList by unusedAppListViewModel.showingList.collectAsState()

    LazyVerticalGrid(
        modifier = modifier,
        columns = GridCells.Adaptive(minSize = 128.dp),
        contentPadding = PaddingValues(4.dp),
    ) {
        items(showingList) { showingItem ->
            Column(
                modifier = modifier.clickable {
                    context.startActivity(Intent().apply {
                        action = Settings.ACTION_APPLICATION_DETAILS_SETTINGS
                        data = Uri.parse("package:${showingItem.packageName}")
                    })
                },
                horizontalAlignment = Alignment.CenterHorizontally,
            ) {
                Image(
                    modifier = modifier
                        .size(60.dp)
                        .padding(top = 8.dp, bottom = 4.dp),
                    contentDescription = showingItem.name,
                    painter = rememberDrawablePainter(showingItem.icon),
                )
                AppUsageTextGroup(
                    modifier = modifier,
                    unusedApp = showingItem,
                )
            }
        }
    }
}

LazyVerticalGridを使ってアプリ1個1個の情報をタイルで並べています。
modifier.clickable { ... }によってタイルを押したとき該当アプリの設定画面が開くようにしています。

既存コードの修正

入口の MainActivity には以下の対応を行いました。

  1. ComponentActivity を継承する
  2. setContentComposable を使用するための入り口を作る(参考
  3. Theme.kt 内で定義した MaterialTheme で囲い、その中で Modifier を定義する(参考
  4. デザインを共通にしたい下層の Composable View には Modifier を渡していく
  5. 3.の中でさらにタイトルバーなど一般的な見た目を簡単にはめ込むための Scaffold で囲う(参考
  6. 5.の中で Pull-to-Refresh を実装するための SwipeRefresh で囲う
  7. 画面表示時のonResume()と Pull-to-Refresh 実行時のonRefresh { ... }で、UseCase のgetAppUsagesを実行する
/app/src/main/java/com/vitantonio/nagauzzi/unusedappfinder/view/MainActivity.kt
@AndroidEntryPoint
- class MainActivity : AppCompatActivity() {
+ class MainActivity : ComponentActivity() {
  
+     @Inject lateinit var getAppUsages: GetAppUsages

      override fun onCreate(savedInstanceState: Bundle?) {
          super.onCreate(savedInstanceState)
  
-         setContentView(R.layout.activity_main)
-         setSupportActionBar(getToolBar())
+         setContent {
+             val isRefreshing by remember { mutableStateOf(false) }
+   
+             UnusedAppListTheme {
+                 val modifier = Modifier
+ 
+                 Scaffold(
+                     modifier = modifier.fillMaxSize(),
+                     topBar = {
+                         UnusedAppTopBar(
+                             modifier = modifier
+                         )
+                     },
+                 ) { contentPadding ->
+                     val coroutineScope = rememberCoroutineScope()
+                     SwipeRefresh(
+                         state = rememberSwipeRefreshState(isRefreshing),
+                         onRefresh = {
+                             coroutineScope.launch {
+                                 getAppUsages.execute()
+                             }
+                         },
+                     ) {
+                         UnusedAppRoot(
+                             modifier = modifier
+                                 .fillMaxSize()
+                                 .padding(contentPadding)
+                         )
+                     }
+                 }
+             }
+         }
      }
+ 
+     override fun onResume() {
+         super.onResume()
+ 
+         CoroutineScope(Dispatchers.Default).launch {
+             getAppUsages.execute()
+         }
+     }
  }

主要機能の ViewModel である UnusedAppListViewModel には以下の対応を行いました。

  1. LiveData を StateFlow へ移行
app/src/main/java/com/vitantonio/nagauzzi/unusedappfinder/viewmodel/UnusedAppListViewModel.kt
  @HiltViewModel
  class UnusedAppListViewModel @Inject constructor(
      packageNameRepository: PackageNameRepository
  ) : ViewModel() {
  
      private val mutableShowingList = MutableStateFlow(emptyList<AppUsage>())
      val showingList: StateFlow<List<AppUsage>> = mutableShowingList
-     val requestingPermission = MutableLiveData(false)
+ 
+     private val mutableRequestingPermission = MutableStateFlow(false)
+     val requestingPermission: StateFlow<Boolean> = mutableRequestingPermission
  
      init {
          AppUsageState.now.onEach { new ->
              when (new) {
                  is Success -> {
-                     requestingPermission.value = false
+                     mutableRequestingPermission.emit(false)
                      mutableShowingList.emit(new.list.filter {
                          it.enableUninstall && it.packageName != packageNameRepository.get()
                          true
                      }.sortedByDescending {
                          if (it.lastUsedTime > 0) it.lastUsedTime else it.installedTime
                      })
                  }
                  is Error -> {
                      if (new.exception is SecurityException) {
-                         requestingPermission.value = true
+                         mutableRequestingPermission.emit(true)
                      }
                  }
                  else -> {
                  }
              }
          }.launchIn(viewModelScope)
      }
  }

StateFlow へ移行したのは、 collectAsState() で簡単に Compose の State 型に変換できるためです。
引き続き LiveData を使うこともできますし、 Android のライフサイクルを安全にサポートするためには ViewModel には LiveData を置いたほうがよいとも言われています。
今回は個人開発アプリなので、より新しい StateFlow を挑戦的に使っており、ライフサイクルサポートは残件になっています。
androidx.lifecycle:lifecycle-viewmodel-compose:2.6.0がリリースされたらcollectAsStateWithLifecycle()に置き換える予定です(参考)。

設計思想

設計は冒頭の View → UseCase → State → ViewModel → View の単方向データフローを守りました。

Compose には ViewModel が必要なのか?なくてもcollectAsStateで繋げればよいのでは?という話はよく聞きますが、このアプリの場合、取得したデータの一部(プリインアプリと自分自身)を非表示にしたりソートしたりと View の都合で表示を変える処理があり、Composable な View のコード量は極力抑えたかったので、ViewModel ありきが良いと判断しました。

ただし、UseCase を呼び出す場所は ViewModel に移すかどうか悩みました。
ViewModel に持たせると View 層は UseCase をフィールドインジェクションする必要がなく = viewModel()で取得したインスタンスだけ使えばよい反面、View と ViewModel の間が双方向になるので個人的にイマイチだと考えていました。
しかし、現状は View 階層が深くなれば MainActivity からリレーで UseCase を渡していくような設計になってしまっています。
UseCase を Composable な View 内で取得しようとすると、Composable 内でonResumeを検知するためのひと工夫が必要だったので、それが面倒臭くて避けたんですが、代わりに ViewModel にexecute()を移すのはありな気がしました。

移行で感じた変化

今までレイアウトXMLとGridViewで多量のコードを書いて頑張っていたグリッドビュー表示処理が、先述のUnusedAppList内でLazyVerticalGridを使うことですごく読みやすくなりました。
Coroutines Flow を用いた単方向データフローと Jetpack Compose の相性もよく、データの流れがわかりやすくなりテストのしやすさも上がったと思います。

一方で、collectAsState()by remember { ... }など状態を扱うための書き方が多彩で、知識が浅いうちはどれを選べばよいかわからなかったり、状況に応じて最適なものを選定するのに時間を要したりと、高機能すぎるゆえに悩ましい部分もあります。
とはいえ Android View 時代も十分高機能でしたが。

あと、Android View のほうができるカスタマイズが多いという状態はしばらく続きそうです。とくに WebView を使いたいときは Composable な View に WebView を埋め込む方向で頑張るか Activity を分離して XML なレイアウトを使い続けるか悩ましいところですね。

さいごに

現在、私はサイボウズ株式会社のモバイルチームに在籍しています。
kintone、サイボウズ Office、Garoon といったサイボウズのプロダクトを一緒に育てていく仲間を募集しています!

キャリア採用/ポテンシャル採用 Androidエンジニア

https://cybozu.co.jp/recruit/entry/career/android-engineer.html

キャリア採用/ポテンシャル採用 iOSエンジニア

https://cybozu.co.jp/recruit/entry/career/ios-engineer.html

Discussion