Mavenリポジトリの指定は記述順序を考えないと解決時のパフォーマンスがじわじわ下がっていく

Androidアプリ開発などをしていれば、大概のプロジェクトはGradleを用いてライブラリの依存関係を解決しようとすることになる。そして開発・運用期間の長いアプリになればなるほど、後追いで導入されたサードパーティ製ライブラリとその取得元のMavenリポジトリ定義が肥大化していくケースが散見される。

普段なんとなく設定しているMavenリポジトリの定義だが、実はビルド時のパフォーマンス低下の原因となるケースがあるため、注意が必要だ。


たとえば、以下のようなスクリプトがあるとする。追加してあるライブラリ郡にはさほど意味がなく、単に大量のライブラリが必要な状態を再現するための記述である:

apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'

android {
    ...
}

repositories {
    google()
    jcenter()
}

dependencies {
    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"

    implementation 'androidx.annotation:annotation:1.1.0'
    implementation 'androidx.appcompat:appcompat:1.0.2'
    implementation 'androidx.arch.core:core-common:2.0.1'
    implementation 'androidx.asynclayoutinflater:asynclayoutinflater:1.0.0'
    implementation 'androidx.browser:browser:1.0.0'
    implementation 'androidx.cardview:cardview:1.0.0'
    implementation 'androidx.collection:collection:1.1.0'
    implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
    implementation 'androidx.contentpager:contentpager:1.0.0'
    implementation 'androidx.coordinatorlayout:coordinatorlayout:1.0.0'
    implementation 'androidx.cursoradapter:cursoradapter:1.0.0'
    implementation 'androidx.customview:customview:1.0.0'
    implementation 'androidx.documentfile:documentfile:1.0.1'
    implementation 'androidx.drawerlayout:drawerlayout:1.0.0'
    implementation 'androidx.dynamicanimation:dynamicanimation:1.0.0'
    implementation 'androidx.emoji:emoji:1.0.0'
    implementation 'androidx.exifinterface:exifinterface:1.0.0'
}

これをビルドしてみる。

export TZ="Asia/Tokyo"
$(rm ./local.properties || true) && ./gradlew clean && date +%T && ./gradlew build --debug && date +%T
# rm: cannot remove './local.properties': No such file or directory
# Downloading https://services.gradle.org/distributions/gradle-5.1.1-all.zip
# ...
# 13:48:45
# ... 
# 13:50:50

125秒。


ここに、参照するライブラリは変えずに "リポジトリだけ" 追加する。今回は仮に実在しないリポジトリを追加してみた:

...
repositories {
    maven { url 'https://example.com/m2-repo-1' }
    google()
    jcenter()
}
...

この状態で、さきほど同様に実行する:

export TZ="Asia/Tokyo"
$(rm ./local.properties || true) && ./gradlew clean && date +%T && ./gradlew build --debug && date +%T
# rm: cannot remove './local.properties': No such file or directory
# Downloading https://services.gradle.org/distributions/gradle-5.1.1-all.zip
# ...
# 14:00:41
# ... 
# 14:03:49

188秒。この程度なら誤差かもしれない。


では、大量に存在するとどうだろう。今回も、仮に実在しないリポジトリを追加する:

...
repositories {
    maven { url 'https://example.com/m2-repo-1' }
    maven { url 'https://example.com/m2-repo-2' }
    maven { url 'https://example.com/m2-repo-3' }
    maven { url 'https://example.com/m2-repo-4' }
    maven { url 'https://example.com/m2-repo-5' }
    google()
    jcenter()
}
...
export TZ="Asia/Tokyo"
$(rm ./local.properties || true) && ./gradlew clean && date +%T && ./gradlew build --debug && date +%T
# rm: cannot remove './local.properties': No such file or directory
# Downloading https://services.gradle.org/distributions/gradle-5.1.1-all.zip
# ...
# 14:07:55
# ...
# 14:13:33

338秒。元の125秒比較すると実に2.7倍近い時間が掛かるようになってしまった。


試しに順序を変えてみる。実在しないリポジトリたちは後ろへ移動する:

...
repositories {
    google()
    jcenter()
    maven { url 'https://example.com/m2-repo-1' }
    maven { url 'https://example.com/m2-repo-2' }
    maven { url 'https://example.com/m2-repo-3' }
    maven { url 'https://example.com/m2-repo-4' }
    maven { url 'https://example.com/m2-repo-5' }
}
...
export TZ="Asia/Tokyo"
$(rm ./local.properties || true) && ./gradlew clean && date +%T && ./gradlew build --debug && date +%T
# rm: cannot remove './local.properties': No such file or directory
# Downloading https://services.gradle.org/distributions/gradle-5.1.1-all.zip
# ...
# 14:17:31
# ...
# 14:19:34

123秒。リポジトリ自体は定義されているにも関わらず、元の定義に匹敵する速度まで回復した。


なぜこんな現象が起こるのかは、ログを確認すればわかる。 338秒という最も長い時間の必要だったビルドにおけるログを確認してみると、下記のような出力が散見される:

...
05:39:19.831 [DEBUG] [org.apache.http.impl.execchain.MainClientExec] Executing request GET /m2-repo-1/androidx/annotation/annotation/1.1.0/annotation-1.1.0.pom HTTP/1.1
...
05:39:19.955 [INFO] [org.gradle.internal.resource.transport.http.HttpClientHelper] Resource missing. [HTTP GET: https://example.com/m2-repo-1/androidx/annotation/annotation/1.1.0/annotation-1.1.0.pom]
...
05:39:20.465 [DEBUG] [org.apache.http.impl.execchain.MainClientExec] Executing request GET /m2-repo-2/androidx/annotation/annotation/1.1.0/annotation-1.1.0.pom HTTP/1.1
...
05:39:20.586 [INFO] [org.gradle.internal.resource.transport.http.HttpClientHelper] Resource missing. [HTTP GET: https://example.com/m2-repo-2/androidx/annotation/annotation/1.1.0/annotation-1.1.0.pom]
...

m2-repo-1, m2-repo-2... と順にリポジトリを手繰っている。repositories {}で定義するリポジトリは、純粋に上から下へ優先順位を持っており、それを順々に参照するのである。
これを自身の参照したいライブラリ毎に行うため、当然ながらじわじわと解決の速度を低下させる要因になりうるのである。


広告系のSDKなどの特定の事業に依存したライブラリの多くは、Maven CentralやJCenterのようなパブリックリポジトリではなく 何かしらのプライベートリポジトリを利用している。特に複数のライブラリを併用する場合は、この参照先リポジトリがどんどん肥大化していくことが多い。

依存解決のためのリクエストは 成功するまで順々に投げられていくため、もし上位に(たとえば1つのライブラリしか置かれていないような)ほとんど使われないリポジトリが指定されていれば、すべての依存関係解決のために毎回無駄なリクエストを投げることになってしまう。


よって、このリポジトリ定義の順序は、下記の優先順位で決定するとよい:

  1. 特別な理由で最優先にしなければならないリポジトリ / 信頼性の高いリポジトリ
  2. 自身のアプリが持つ依存関係の中で最も利用数が多いリポジトリ

この記事の趣旨で言うと、本来であれば2だけを気にすればよい。


が、Android開発などの場合には特別な事情があるため併記しておく。

JCenter(JFrog Bintray)ではGoogle Maven Repositoryのミラーリング(Proxyではない)を独自に行っているが、一時Google Maven Repository内に配置してあるChecksumが不正な内容になってしまった時がある。
JCenterは仕組み上Checksumが不正な場合には404ではなく409を返すようになっている。その結果、google()よりもjcenter()の優先順位を高く設定していた場合には依存関係の解決に失敗してしまう、という問題が発生したことが過去にある

ライブラリの参照元は原則として発行元の推奨するものを利用すべきであるため、Android開発においては(たとえほとんどのライブラリが他のリポジトリ由来であっても)Google Maven Repositoryを最優先にすべきである。