【Android】Gradleのタスクを利用してVersion Catalogへチョット楽に移行する

# はじめに
**Version Catalog** 使っていますか？

Version Catalog と言えば、依存関係とプラグインを効率的に管理するための仕組みですが、最近のAndroid開発において重要になってきそうな雰囲気を感じています。

・Android Studio Giraffe からサポートされたり…（まだ色々と制限はあるようですが）

https://developer.android.com/studio/preview/features#gradle-version-catalogs

・移行ガイドができてたり…

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

・ナウいAndroidのリファレンスのアプリである `nowinandroid` でも普通に使われていたり…

https://github.com/android/nowinandroid


そんな Version Catalog ですが、既存のプロジェクトで移行するとなると `libs.versions.toml` にライブラリのバージョン定義を移すのがチョット面倒だと思います。
（※ `settings.gradle`に定義する方法もあります。）

この記事では、**Gradleのタスクを利用してVersion Catalogへチョット楽に移行する方法** をご紹介したいと思います。

※ 一応OSSのプラグインで `libs.versions.toml` を生成できるものがあったのですが、実際に試してみると**思ってたのと違った**ので自作するに至りました。

https://github.com/littlerobots/version-catalog-update-plugin

※ 自作すると言いつつGradleは全然詳しくないので、ChatGPTに手伝ってもらったりしました。 :robot: 

# :elephant: Gradleのタスク
早速ですが、タスクのコードとしては下記になります。

これをルートの `build.gradle` に書き、タスクを実行（`./gradlew generateVersionCatalog`）すると `gradle` ディレクトリに `libs.versions.toml` が生成されます。

```gradle
task generateVersionCatalog {
    // groupの置換用定義
    def groupReplaceMap = [
            "com.google.android.": "",
            "com.google."        : "",
    ]

    // 対象となるConfiguration定義
    def targetConfigurationNames = [
            "implementation",
            "kapt",
    ]

    doLast {
        def libraries = []
        def versions = []

        subprojects.each { project ->
            // #1 Configurationの取得
            project.configurations.each { configuration ->
                if (!targetConfigurationNames.contains(configuration.name)) {
                    return
                }

                // #2 Dependencyのチェック
                configuration.dependencies.each { dependency ->
                    if (dependency.group == null || dependency.version == "unspecified") {
                        return
                    }

                    // #3 groupの置換
                    def group = dependency.group
                    groupReplaceMap.each {
                        if (group.contains(it.key)) {
                            group = group.replace(it.key, it.value)
                        }
                    }

                    // #4 versionの調整
                    // BOMを使っている場合など、versionがnullになるため、空文字で対応
                    def version = dependency.version ?: ""

                    // #5 tomlのkeyのベースとなる文字列の生成
                    def key = ""
                    if (!group.isEmpty()) {
                        // groupがあれば「.」を「-」に置換して付与（例： androidx.core → androidx-core）
                        key += "${group.replaceAll('\\.', '-')}-"
                    }
                    key += dependency.name
                    // groupとvalueで文字が重複するのを避ける（例： androidx-core-core-ktx → androidx-core-ktx）
                    key = key.split("-").toUnique().join("-")

                    // #6 versionのkeyはcamelCase
                    def versionKey = key.toLowerCase().replaceAll("(-)([A-Za-z0-9])", { Object[] s -> s[2].toUpperCase() })
                    if (!version.isEmpty()) {
                        versions.add("${versionKey} = \"${version}\"")
                    }

                    // #7 libraryのkeyはkebab-case
                    def libraryKey = key.toLowerCase()
                    def libraryValue = "{ group = \"${dependency.group}\", name = \"${dependency.name}\""
                    if (!version.isEmpty()) {
                        libraryValue += ", version.ref = \"${versionKey}\""
                    }
                    libraryValue += " }"
                    libraries.add("${libraryKey} = ${libraryValue}")
                }
            }
        }

        // 8
        versions.unique().sort()
        libraries.unique().sort()

        // 9
        def file = file("gradle/libs.versions.toml")
        def fileContent = ""
        fileContent += "[versions]\n"
        fileContent += versions.join("\n")
        fileContent += "\n\n"
        fileContent += "[libraries]\n"
        fileContent += libraries.join("\n")
        fileContent += "\n\n"
        fileContent += "[plugins]\n"
        file.write(fileContent)
    }
}
```

# :mag: コードの説明
大事そうな箇所のコードの説明をしたいと思います。

## #1 Configurationの取得

バージョン定義を移すに当たり、 `build.gradle` の `dependencies` に定義されているものを取ってくる必要があります。

例えばこのようなやつ ↓
```gradle:build.gradle
dependencies {
  implementation "androidx.core:core-ktx:1.9.0"
  implementation "com.google.dagger:hilt-android:2.4.3"

  kapt 'androidx.hilt:hilt-compiler:1.0.0'
}
```

`project.configurations` で取得できる `configuration` に、 `implementation` や `kapt` などの情報が管理されているのでそれを使うことができます。


イメージはこのような感じです。
| configuration | dependencies |
|:-:|:-:|
| implementation  | [androidx.core:core-ktx:1.9.0, com.google.dagger:hilt-android:2.4.3]  |
|  kapt |  [androidx.hilt:hilt-compiler:1.0.0] |
|  ... |  ... |


ただ、対象としなくてもいい `configuration` があったりするので、必要なものだけ `targetConfigurationNames` として定義して、それ以外はスキップするようにしてます。

## #2 Dependencyのチェック

`configuration.dependencies` で取得できる `dependency` に、依存情報（ `androidx.core:core-ktx:1.9.0` や `com.google.dagger:hilt-android:2.4.3`）が含まれています。 

`androidx.core:core-ktx:1.9.0` の例でいうとこのような感じです。
|  | 値 |
|:-:|:-:|
| dependency.group  | androidx.core  |
|  dependency.name | core-ktx  |
|  dependency.version |  1.9.0 |

この際、`dependency.group` が `null` だったり、`dependency.version` が `unspecified` のものはスキップします。
（プロジェクトのサブモジュールを `implementation` している場合などに該当するようでした。）

## #3~7 `toml` 形式に変換
**#2**までで、ライブラリの依存関係を取得することができたので、あとはこねくり回して `libs.versions.toml` を生成していきます。:amphora:

このような定義があった場合、

```gradle
implementation("androidx.core:core-ktx:1.9.0")
implementation("com.google.dagger:hilt-android:2.4.3")
```

下記のような `toml` が生成できるといいのかなと考えて、ロジックを作成しています。
コードにコメントを書いているので興味があれば覗いてみてください。

```toml
[versions]
androidXCoreKtx = "1.9.0"
hiltAndroid = "2.4.3"

[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidxCoreKtx" }
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hiltAndroid" }
```

## #8~9 `libs.versions.toml` を出力
`libraries` と `versions` にそれぞれ値が入ったので、重複を取り除き、並び替えしてから、Version Catalogのフォーマットに沿うようにして出力します。

```toml:libs.versions.toml
[versions]

[libraries]

[plugins]
```

# :arrow_forward: 実行してみる  
実際にタスクを実行してみた様子はこちら ↓

無事 `libs.versions.toml` が生成されるかと思います。

![gen_version_catalog.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/995/fec58920-8e48-959e-b66a-8fe11df1f264.gif)



# :writing_hand: 移行を完了する

後は手作業で `implementation` の箇所を `libs.xxx.xxx` の形に置き換えて行けばOKです。
ここもうまいことすれば自動化できるのかもしれませんが、コード補完を駆使すればそこまで苦ではないかなという印象です。

```gradle
implementation("androidx.core:core-ktx:1.9.0")

// ↓

implementation(libs.androidx.core.ktx)
```

---

また、記載したタスクではプラグイン（Gradle Plugin）は未対応となっています。
ただ、ライブラリの定義に比べ、プラグインの定義はそこまで数が多くないと思うので、手作業 or 下記のツールを使うことで十分対応可能かなと思います。

**takahirom / gradle-version-catalog-converter** :pray: 

https://github.com/takahirom/gradle-version-catalog-converter

# :robot: まとめ
今回の記事では、Version Catalog への移行を手軽に行うためのGradleタスクをご紹介しました。このタスクを使って、libs.versions.tomlファイルを簡単に生成できるようになり、依存関係の管理がスムーズになります。ただし、プラグインに関しては対応していないので、手作業や別のツールを使って対応する必要があります。

この方法を試してみることで、Android開発での依存関係の管理が楽になり、プロジェクトのメンテナンス性が向上することを期待しています。ぜひお試しください！

（もっといい方法やツールをご存知でしたらコメントいただけると幸いです :pray: ）