🏛️

AndroidのテンプレートをVersion catalog対応にする

2023/04/15に公開

Version catalogとは

Gradle 7.0から追加されたバージョン管理を一元管理できる仕組みです。
https://docs.gradle.org/7.0/release-notes.html

今まではbuildSrc内でobjectクラスに定義していたのですが、dependabotでは上手く反応してくれなかったりしていました。
Version catalogではTomlファイルで管理できるところが便利そうです。

少し前から気になっていたのですがGradle 7.4からStableになったことと、Android studio Flamingoからアプリ生成のテンプレートがGradle 8.0になったことをきっかけに、使ってみようと思います。
https://android-developers.googleblog.com/2023/04/android-studio-flamingo-is-stable.html

Let's 実装

導入の練習としてAndroid studioのプロジェクト作成のテンプレートからVersion catalogに移行します。
今回はGroovyのままで記述してきますがKTSでも利用可能です。
ゴールはRenovateを使用してライブラリーを更新してもらうことです。
https://github.com/renovatebot/renovate

環境

  • Mac OS Venture(13.3)
  • Android studio Flamingo(2022.2.1)

1. Android studioでプロジェクトを作成する

今回はFlamingoのテンプレートの[Empty Activity]を使用します。
New Project
Android studioのテンプレート
(Android studio FlamingoからMaterial3が推奨されているらしい)
作業内容を後に残せるように作成したプロジェクトはGitHubにpushしておきます。

2. Tomlファイルの作成

今回はversion-catalog-updateを導入して移行しています。
結果的に自分で作成した方がよかったかもと思いましたが、ものは試しでつかって見ます。

  1. ルート直下のbuild.gradleに下記を追加
build.gradle
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
    id 'com.android.application' version '8.0.0' apply false
    id 'com.android.library' version '8.0.0' apply false
    id 'org.jetbrains.kotlin.android' version '1.7.20' apply false
+   id "com.github.ben-manes.versions" version "0.41.0"
+   id "nl.littlerobots.version-catalog-update" version "0.8.0"
}
  1. syncしてbuild
  2. コマンド実行
./gradlew versionCatalogUpdate --create

を実行します。
そうするとgradleディレクトリにlibs.versions.tomlが生成されます。

生成されたTomlファイル
[versions]
androidx-activity = "1.5.1"
androidx-arch-core = "2.1.0"
androidx-compose-animation = "1.3.0"
androidx-compose-foundation = "1.3.0"
androidx-compose-material = "1.3.0"
androidx-compose-runtime = "1.3.0"
androidx-compose-ui = "1.3.0"
androidx-core = "1.8.0"
androidx-lifecycle = "2.5.1"
androidx-savedstate = "1.2.0"
org-jetbrains-kotlin = "1.7.20"
org-jetbrains-kotlinx = "1.6.4"

[libraries]
androidx-activity = { module = "androidx.activity:activity", version.ref = "androidx-activity" }
androidx-activity-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
androidx-activity-activity-ktx = { module = "androidx.activity:activity-ktx", version.ref = "androidx-activity" }
androidx-annotation = "androidx.annotation:annotation:1.5.0"
androidx-annotation-annotation-experimental = "androidx.annotation:annotation-experimental:1.1.0"
androidx-arch-core-core-common = { module = "androidx.arch.core:core-common", version.ref = "androidx-arch-core" }
androidx-arch-core-core-runtime = { module = "androidx.arch.core:core-runtime", version.ref = "androidx-arch-core" }
androidx-autofill = "androidx.autofill:autofill:1.0.0"
androidx-collection = "androidx.collection:collection:1.0.0"
androidx-compose-animation = { module = "androidx.compose.animation:animation", version.ref = "androidx-compose-animation" }
androidx-compose-animation-animation-core = { module = "androidx.compose.animation:animation-core", version.ref = "androidx-compose-animation" }
androidx-compose-compiler = "androidx.compose.compiler:compiler:1.3.2"
androidx-compose-compose-bom = "androidx.compose:compose-bom:2022.10.00"
androidx-compose-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "androidx-compose-foundation" }
androidx-compose-foundation-foundation-layout = { module = "androidx.compose.foundation:foundation-layout", version.ref = "androidx-compose-foundation" }
androidx-compose-material = { module = "androidx.compose.material:material", version.ref = "androidx-compose-material" }
androidx-compose-material-material-icons-core = { module = "androidx.compose.material:material-icons-core", version.ref = "androidx-compose-material" }
androidx-compose-material-material-ripple = { module = "androidx.compose.material:material-ripple", version.ref = "androidx-compose-material" }
androidx-compose-material3 = "androidx.compose.material3:material3:1.0.0"
androidx-compose-runtime = { module = "androidx.compose.runtime:runtime", version.ref = "androidx-compose-runtime" }
androidx-compose-runtime-runtime-saveable = { module = "androidx.compose.runtime:runtime-saveable", version.ref = "androidx-compose-runtime" }
androidx-compose-ui = { module = "androidx.compose.ui:ui", version.ref = "androidx-compose-ui" }
androidx-compose-ui-ui-geometry = { module = "androidx.compose.ui:ui-geometry", version.ref = "androidx-compose-ui" }
androidx-compose-ui-ui-graphics = { module = "androidx.compose.ui:ui-graphics", version.ref = "androidx-compose-ui" }
androidx-compose-ui-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "androidx-compose-ui" }
androidx-compose-ui-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "androidx-compose-ui" }
androidx-compose-ui-ui-text = { module = "androidx.compose.ui:ui-text", version.ref = "androidx-compose-ui" }
androidx-compose-ui-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "androidx-compose-ui" }
androidx-compose-ui-ui-tooling-data = { module = "androidx.compose.ui:ui-tooling-data", version.ref = "androidx-compose-ui" }
androidx-compose-ui-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "androidx-compose-ui" }
androidx-compose-ui-ui-unit = { module = "androidx.compose.ui:ui-unit", version.ref = "androidx-compose-ui" }
androidx-compose-ui-ui-util = { module = "androidx.compose.ui:ui-util", version.ref = "androidx-compose-ui" }
androidx-concurrent-concurrent-futures = "androidx.concurrent:concurrent-futures:1.0.0"
androidx-core = { module = "androidx.core:core", version.ref = "androidx-core" }
androidx-core-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
androidx-customview-customview-poolingcontainer = "androidx.customview:customview-poolingcontainer:1.0.0"
androidx-lifecycle-lifecycle-common = { module = "androidx.lifecycle:lifecycle-common", version.ref = "androidx-lifecycle" }
androidx-lifecycle-lifecycle-common-java8 = { module = "androidx.lifecycle:lifecycle-common-java8", version.ref = "androidx-lifecycle" }
androidx-lifecycle-lifecycle-livedata-core = { module = "androidx.lifecycle:lifecycle-livedata-core", version.ref = "androidx-lifecycle" }
androidx-lifecycle-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime", version.ref = "androidx-lifecycle" }
androidx-lifecycle-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidx-lifecycle" }
androidx-lifecycle-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel", version.ref = "androidx-lifecycle" }
androidx-lifecycle-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle" }
androidx-lifecycle-lifecycle-viewmodel-savedstate = { module = "androidx.lifecycle:lifecycle-viewmodel-savedstate", version.ref = "androidx-lifecycle" }
androidx-profileinstaller = "androidx.profileinstaller:profileinstaller:1.2.0"
androidx-savedstate = { module = "androidx.savedstate:savedstate", version.ref = "androidx-savedstate" }
androidx-savedstate-savedstate-ktx = { module = "androidx.savedstate:savedstate-ktx", version.ref = "androidx-savedstate" }
androidx-startup-startup-runtime = "androidx.startup:startup-runtime:1.1.1"
androidx-test-espresso-espresso-core = "androidx.test.espresso:espresso-core:3.5.1"
androidx-test-ext-junit = "androidx.test.ext:junit:1.1.5"
androidx-tracing = "androidx.tracing:tracing:1.0.0"
androidx-versionedparcelable = "androidx.versionedparcelable:versionedparcelable:1.1.1"
junit = "junit:junit:4.13.2"
org-apache-logging-log4j-log4j-core = "org.apache.logging.log4j:log4j-core:2.17.1"
org-jetbrains-annotations = "org.jetbrains:annotations:13.0"
org-jetbrains-kotlin-kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "org-jetbrains-kotlin" }
org-jetbrains-kotlin-kotlin-stdlib-common = { module = "org.jetbrains.kotlin:kotlin-stdlib-common", version.ref = "org-jetbrains-kotlin" }
org-jetbrains-kotlin-kotlin-stdlib-jdk7 = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk7", version.ref = "org-jetbrains-kotlin" }
org-jetbrains-kotlin-kotlin-stdlib-jdk8 = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", version.ref = "org-jetbrains-kotlin" }
org-jetbrains-kotlinx-kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "org-jetbrains-kotlinx" }
org-jetbrains-kotlinx-kotlinx-coroutines-bom = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-bom", version.ref = "org-jetbrains-kotlinx" }
org-jetbrains-kotlinx-kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "org-jetbrains-kotlinx" }
org-jetbrains-kotlinx-kotlinx-coroutines-core-jvm = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm", version.ref = "org-jetbrains-kotlinx" }

[plugins]
com-android-application = "com.android.application:8.0.0"
com-android-library = "com.android.library:8.0.0"
com-github-ben-manes-versions = "com.github.ben-manes.versions:0.41.0"
nl-littlerobots-version-catalog-update = "nl.littlerobots.version-catalog-update:0.8.0"
org-jetbrains-kotlin-android = "org.jetbrains.kotlin.android:1.7.20"

3. libs.versions.tomlについて

セクション

  • [versions]:バージョンの定義
  • [libraries]:ライブラリの定義
  • [plugins]:プラグインの定義
  • [bundles][libraries]で定義したライブラリをまとめられる

個人的には[bundles]でよく使うライブラリ群をまとめられるのが便利かと思います。

Alias

記述には少し気をつける点がありますが、かいつまんで説明すると

  1. エイリアスは- _ .で区切られたもので構成される
  2. #などの記号は使えない
  3. 文字列のみ

Aliases and their mapping to type safe accessors

1. エイリアスは- _ .で区切られたもので構成される

バージョンを定義したものはタイプセーフなアクセッサーに変換されるので、区切られたものは.で連結されます。
もちろんなくても大丈夫です。

  • android-pluginandroid.plugin
  • android_plugin_coreandroid.plugin.core
  • kotlinkotlin
2. #などの記号は使えない

公式でも書かれているのですが、基本は英数字で書きましょう。
先頭でなければ数字も使えたはず。

  • this.#is.not!NG!
3. 文字列のみ

入れられる値は文字列のみになります。
AndroidではsdkのバージョンなどをInt型で入れなければならないのですが、文字列しか利用できないため少し記述が必要です。

例としてcompileSdkの場合

[versions]
app-compileSdk = "33"

と記述しておき、使用する際に

Groovyの場合
android {
    compileSdk libs.versions.app.compileSdk.get().toIntger()
}
Ktsの場合
android {
    compileSdk = libs.versions.app.compileSdk.get().toInt()
}

Int型に変換する必要があります。

各使用例

定義したものにアクセスするにはlibsから参照できます。

[versions]
[versions]
app-versionCode = "1"
app-versionName = "1.0"
android {
    versionCode libs.versions.app.targetSdk.get().toIntger()
    versionName libs.versions.app.versionName.get()
}
[libraries]

BOMも利用できます

[versions]
androidx-core = "1.8.0"
compose = "2022.10.00"

[libraries]
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }

# バージョンは直接でも書ける
# androidx-core-ktx = "androidx.core:core-ktx:1.10.0"

# BOMを利用する場合
compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose" }
compose-material3 = { module = "androidx.compose.material3:material3" }
dependencies {
    implementation libs.bundles.android.core

    implementation platform(libs.compose.bom)
    implementation libs.compose.material3
}
[plugins]
[versions]
android-plugin = "8.0.0"

[plugins]
android-application = { id = "com.android.application", version.ref = "android-plugin" }
plugins {
    alias(libs.plugins.android.application)

    // 適応しない場合
    alias(libs.plugins.android.application) apply false
}
[bundles]
[versions]
compose = "2022.10.00"

[libraries]
compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose" }
compose-material3 = { module = "androidx.compose.material3:material3" }
compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }

[bundles]
compose-core = ["compose-material3", "compose-ui-tooling-preview"]
dependencies {
    implementation platform(libs.compose.bom)
    implementation libs.bundles.compose.core
}

4. 置換後

libs.versions.toml
[versions]
app-compileSdk = "33"
app-minSdk = "27"
app-targetSdk = "33"
app-versionCode = "1"
app-versionName = "1.0"

android-plugin = "8.0.0"
androidx-activity = "1.5.1"
androidx-core = "1.8.0"
androidx-lifecycle = "2.5.1"
androidx-savedstate = "1.2.0"

compose = "2022.10.00"
compose-compiler = "1.3.2"

espresso-core = "3.5.1"

junit = "4.13.2"
junit-ext = "1.1.5"

kotlin = "1.7.20"
kotlin-coroutine = "1.6.4"

[libraries]
androidx-activity-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidx-lifecycle" }
androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso-core" }
androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "junit-ext" }

compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose" }
compose-material3 = { module = "androidx.compose.material3:material3" }
compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" }
compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }

junit = { module = "junit:junit", version.ref = "junit" }

kotlin-coroutines-bom = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-bom", version.ref = "kotlin-coroutine" }
kotlin-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core" }
kotlin-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android" }

[bundles]
android-core = ["androidx-core-ktx", "androidx-lifecycle-runtime-ktx"]
compose-core = ["compose-material3", "compose-ui-tooling-preview"]
compose-debug = ["compose-ui-tooling", "compose-ui-test-manifest"]

[plugins]
android-application = { id = "com.android.application", version.ref = "android-plugin" }
android-library = { id = "com.android.library", version.ref = "android-plugin" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
app/build.gradle
plugins {
    alias(libs.plugins.android.application)
    alias(libs.plugins.kotlin.android)
}

android {
    namespace 'com.example.sampleapp'
    compileSdk libs.versions.app.compileSdk.get().toInteger()

    defaultConfig {
        applicationId "com.example.sampleapp"
        minSdk libs.versions.app.minSdk.get().toInteger()
        targetSdk libs.versions.app.targetSdk.get().toInteger()
        versionCode libs.versions.app.targetSdk.get().toInteger()
        versionName libs.versions.app.versionName.get()

        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
        vectorDrawables {
            useSupportLibrary true
        }
    }

    buildTypes {
        release {
            minifyEnabled true
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }
    kotlinOptions {
        jvmTarget = '1.8'
    }
    buildFeatures {
        compose true
    }
    composeOptions {
        kotlinCompilerExtensionVersion libs.versions.compose.compiler.get()
    }
    packagingOptions {
        resources {
            excludes += '/META-INF/{AL2.0,LGPL2.1}'
        }
    }
}

dependencies {
    implementation libs.bundles.android.core
    implementation libs.androidx.activity.activity.compose
    implementation platform(libs.compose.bom)
    implementation libs.bundles.compose.core
    testImplementation libs.junit
    androidTestImplementation libs.junit
    androidTestImplementation libs.androidx.test.espresso.core
    androidTestImplementation platform(libs.compose.bom)
    androidTestImplementation libs.compose.ui.test.junit4
    debugImplementation libs.bundles.compose.debug
}

自分はこの後Groovy → KTSに移行しています

Renovateの導入

  1. https://github.com/apps/renovate からInstall
  2. Repositoryを選択(全てのRepositoryも可能)
    Renovate select repository
  3. しばらく待っていると該当のRepositoryにPRが建てられます
    Renovate PR
  4. 差分はこれだけ
renovate.json
+ {
+   "$schema": "https://docs.renovatebot.com/renovate-schema.json",
+   "extends": [
+     "config:base"
+   ]
+ }
  1. マージすれば定期的にライブラリにアップデートがないかチェックしてくれるようになります。
    Renovate PRs

今回は現状のAndroid studio Flamingoがgradle 8.1に対応していないのでcloseしています
最少であって8.1には対応してるかも
https://developer.android.com/build/releases/gradle-plugin#8-0-0

今回はVersion catalog+Renovateの導入練習だったので、設定やらなんやらは今回は省きますが、MonorepoでPRをグルーピングしたり、オートマージ、特定バージョンのスキップなど様々な設定ができそうです。

雑感

良かった部分

  • 意外とすんなり移行できた。
    • 今回はシングルモジュールだし、シンプルなテンプレートで移行しただけかもしれないが、Tomlファイルを用意しただけでバージョン管理ができるようになったのは便利だと感じました。
  • [bundles]でライブラリをまとめられるのが良い。

嬉しくない部分

  • Tomlファイル内をリファクタしたらリネームやコードジャンプが効かないのがやや面倒ではあった。
  • Tomlファイルだけでは新しいアップデートが来ているか分かりずらい。
    • 今回はRenovateを導入して回避できている。
    • buildSrcの時も分かりずらいからお互い様感はまだある。
  • libs.versions.xxxで少し記述が長い気がする。
    • 以前はDependencies.composeの様な書き方で実装していた分、少し読み取りずらい気がしなくもない。
      以前はこんな感じの実装だった↓
object Versions {
    const val compose = "2023.03.00"
}

object Dependencies {
    object Compose {
        const val bom = "androidx.compose:compose-bom:${Versions.compose}"
    }
}

今後の目標

この記事書きながらアップデートはしており、この後Dangerを突っ込んだりして自分の今後のテンプレート化を目指しています。
いずれはCIの導入して自動テストとかLint、カバレッジの計測とかも書きたい。

Discussion