讀懂 gradle dependencies

雲音樂技術團隊發表於2022-12-15
圖片來自:https://unsplash.com
本文作者: lizongjun

前言

gradle 中的 dependencies 命令算是日常開發使用比較多的一個命令,可以幫助我們定位一些二方、三方庫版本依賴的問題。

不過在使用 dependencies 時有一些細節之前一直沒有搞清楚,遂研究了一下部分細節。本文整體參考 gradle 官方文件,大家感興趣也可以自己深入研究下。

比如隨便找一份 dependencies 輸出如下,

image

可以發現,除了我們熟知的樹狀展開結構,表示依賴的層級;在版本號前後是有一些特殊標識的:->(c)(*)

這些特殊標識分別有什麼作用,對我們分析版本依賴會有什麼影響?本文會依次分析一些這些場景的版本識別符號號。

Dependency resolution

-> 標識代表 依賴衝突,也是在 dependencies graph 中最常見的一種標識。

比如 1.3.2 -> 1.6.0,表示當前依賴樹中依賴的版本是 1.3.2,但由於全域性的依賴衝突,最終被升級到了 1.6.0 版本。gradle 處理依賴衝突的總體原則是取衝突中的最高版本,但有很多特例。

特例情況我們本次不具體展開,只看常規情況,實際上僅是常規情況也有讓人迷惑之處。

我們假設下面這樣一個demo,

// module A, tag 1.0.0, build.gradle
dependencies {
    implementation 'com.netease.cloudmusic.android:module_c:1.0.0'
    implementation 'com.netease.cloudmusic.android:module_d:1.0.0'
}

// app, build.gradle
dependencies {
    implementation 'com.netease.cloudmusic.android:module_a:1.0.0'
}

當我們檢視 app module 的 dependencies 輸出時(下文 dependencies 的輸出都是基於 app module的),結果如下,

\--- com.netease.cloudmusic.android:module_a:1.0.0
     +--- com.netease.cloudmusic.android:module_c:1.0.0
     \--- com.netease.cloudmusic.android:module_d:1.0.0

現在引入依賴衝突,

// module A, tag 1.0.0, build.gradle
dependencies {
    implementation 'com.netease.cloudmusic.android:module_c:1.0.0'
    implementation 'com.netease.cloudmusic.android:module_d:1.0.0'
}

// module B, tag 1.0.0, build.gradle
dependencies {
    implementation 'com.netease.cloudmusic.android:module_c:1.1.0'
}

// app, build.gradle
dependencies {
    implementation 'com.netease.cloudmusic.android:module_a:1.0.0'
    implementation 'com.netease.cloudmusic.android:module_b:1.0.0'
}

此時也比較簡單,因為 module B 中依賴了 1.1.0 版本的 module C,依賴發生衝突以最高版本為準,所以最終 dependencies 的輸出如下,此時 -> 表示了衝突的結果,

+--- com.netease.cloudmusic.android:module_a:1.0.0
|    +--- com.netease.cloudmusic.android:module_c:1.0.0 -> 1.1.0
|    \--- com.netease.cloudmusic.android:module_d:1.0.0
\--- com.netease.cloudmusic.android:module_b:1.0.0
     \--- com.netease.cloudmusic.android:module_c:1.1.0

再複雜一點,引入一個間接衝突,

// module A, tag 1.0.0, build.gradle
dependencies {
    implementation 'com.netease.cloudmusic.android:module_c:1.0.0'
    implementation 'com.netease.cloudmusic.android:module_d:1.0.0'
}

// module A, tag 1.1.0, build.gradle
dependencies {
    implementation 'com.netease.cloudmusic.android:module_c:1.1.0'
    implementation 'com.netease.cloudmusic.android:module_d:1.1.0'
}

// module B, tag 1.0.0, build.gradle
dependencies {
    implementation 'com.netease.cloudmusic.android:module_a:1.1.0'
}

// app, build.gradle
dependencies {
    implementation 'com.netease.cloudmusic.android:module_a:1.0.0'
    implementation 'com.netease.cloudmusic.android:module_b:1.0.0'
}

此時,module B 不再直接依賴 module C,而是透過依賴高版本的 module A,間接引入了 1.1.0 版本的 module C,dependencies 輸出如下,

+--- com.netease.cloudmusic.android:module_a:1.0.0 -> 1.1.0
|    +--- com.netease.cloudmusic.android:module_c:1.1.0
|    \--- com.netease.cloudmusic.android:module_d:1.1.0
\--- com.netease.cloudmusic.android:module_b:1.0.0
     \--- com.netease.cloudmusic.android:module_a:1.1.0 (*)

注意此時 module A 到 module C 這條引用鏈上的版本標識:對於 module A,由於依賴衝突,版本變為 1.0.0 -> 1.1.0 ;但對於 module C,版本並不是 1.0.0 -> 1.1.0,而直接是 1.1.0。也就是說葉子節點的版本是以父節點版本的右值為準的。

如果我們再修改一下 demo,可以更清晰的解釋這裡的邏輯。我們把 demo 調整成如下這樣,

// module A, tag 1.0.0, build.gradle
dependencies {
    implementation 'com.netease.cloudmusic.android:module_c:1.0.0'
    implementation 'com.netease.cloudmusic.android:module_d:1.0.0'
}

// module A, tag 1.1.0, build.gradle
dependencies {
    implementation 'com.netease.cloudmusic.android:module_c:1.1.0'
    implementation 'com.netease.cloudmusic.android:module_d:1.1.0'
}

// module B, tag 1.0.0, build.gradle
dependencies {
    implementation 'com.netease.cloudmusic.android:module_a:1.1.0'
}

// app, build.gradle
dependencies {
    implementation 'com.netease.cloudmusic.android:module_a:1.0.0'
    implementation 'com.netease.cloudmusic.android:module_b:1.0.0'
    implementation 'com.netease.cloudmusic.android:module_c:1.2.0'
}

module A、B、C、D 之間的依賴關係不變,但我們在 app module 直接依賴 1.2.0 版本的 module C,此時 dependencies 是怎樣的呢?

+--- com.netease.cloudmusic.android:module_a:1.0.0 -> 1.1.0
|    +--- com.netease.cloudmusic.android:module_c:1.1.0 -> 1.2.0
|    \--- com.netease.cloudmusic.android:module_d:1.1.0
+--- com.netease.cloudmusic.android:module_b:1.0.0
|    \--- com.netease.cloudmusic.android:module_a:1.1.0 (*)
\--- com.netease.cloudmusic.android:module_c:1.2.0

可以很清晰的看到,對於發生衝突的版本:從父節點找子節點,看的是父節點的右值;而從子節點向父節點追溯,看的子節點的左值。

但單純從視覺的直覺上看,我們可能會誤以為 1.2.0 版本的 module C 是由 module A 引入的,導致排查問題時南轅北轍,特別在排查大型專案的 dependencies 輸出時,一定要注意每一層節點之間的衝突版本的左值與右值。

Dependency omitted

在前面的 demo 裡,(*) 這個標識已經出現過了,這個標識由於跟常規語境下的含義不太一樣,所以也具有一定的迷惑性。(*) 符號字面意思是刪減,但並不是依賴關係上的刪減,僅僅是展示層面的刪減。

還是以如下 demo 為例,

// module A, tag 1.0.0, build.gradle
dependencies {
    implementation 'com.netease.cloudmusic.android:module_c:1.0.0'
    implementation 'com.netease.cloudmusic.android:module_d:1.0.0'
}

// module B, tag 1.0.0, build.gradle
dependencies {
    implementation 'com.netease.cloudmusic.android:module_a:1.0.0'
}

// module E, tag 1.0.0, build.gradle
dependencies {
    implementation 'com.netease.cloudmusic.android:module_a:1.0.0'
}

// module F, tag 1.0.0, build.gradle
dependencies {
    implementation 'com.netease.cloudmusic.android:module_a:1.0.0'
}

// app, build.gradle
dependencies {
    implementation 'com.netease.cloudmusic.android:module_a:1.0.0'
    implementation 'com.netease.cloudmusic.android:module_b:1.0.0'
    implementation 'com.netease.cloudmusic.android:module_e:1.0.0'
    implementation 'com.netease.cloudmusic.android:module_f:1.0.0'
}

輸出如下,

+--- com.netease.cloudmusic.android:module_a:1.0.0
|    +--- com.netease.cloudmusic.android:module_c:1.0.0
|    \--- com.netease.cloudmusic.android:module_d:1.0.0
+--- com.netease.cloudmusic.android:module_b:1.0.0
|    \--- com.netease.cloudmusic.android:module_a:1.0.0 (*)
+--- com.netease.cloudmusic.android:module_e:1.0.0
|    \--- com.netease.cloudmusic.android:module_a:1.0.0 (*)
\--- com.netease.cloudmusic.android:module_f:1.0.0
     \--- com.netease.cloudmusic.android:module_a:1.0.0 (*)

這裡 (*) 代表省略了 module A 以下的依賴關係子樹,因為假設我們按照 demo 來輸出一個完整的依賴關係圖,應該是下面這樣的,

+--- com.netease.cloudmusic.android:module_a:1.0.0
|    +--- com.netease.cloudmusic.android:module_c:1.0.0
|    \--- com.netease.cloudmusic.android:module_d:1.0.0
+--- com.netease.cloudmusic.android:module_b:1.0.0
|    \--- com.netease.cloudmusic.android:module_a:1.0.0
|         +--- com.netease.cloudmusic.android:module_c:1.0.0
|         \--- com.netease.cloudmusic.android:module_d:1.0.0
+--- com.netease.cloudmusic.android:module_e:1.0.0
|    \--- com.netease.cloudmusic.android:module_a:1.0.0
|         +--- com.netease.cloudmusic.android:module_c:1.0.0
|         \--- com.netease.cloudmusic.android:module_d:1.0.0
\--- com.netease.cloudmusic.android:module_f:1.0.0
     \--- com.netease.cloudmusic.android:module_a:1.0.0
          +--- com.netease.cloudmusic.android:module_c:1.0.0
          \--- com.netease.cloudmusic.android:module_d:1.0.0

如果都按這種方式展示,顯然冗餘資訊太多了,特別對於一個大型專案,依賴關係複雜時,幾乎是不可閱讀的。所以為了簡潔、方便理解,dependencies 命令會預設縮略重複的依賴關係子樹,只在它第一次出現時,才完整展示;後續出現都以 (*) 符號代替。

這也解釋了為什麼我們在從上向下閱讀一個 dependencies graph 時,會感覺越靠近開頭,依賴關係越複雜、層級越深,越靠近末尾依賴關係越簡單。其實並不是因為 gradle 對依賴關係做了排序,僅僅是因為靠近尾部,大部分子樹都被縮略掉了。

Dependency constraint

(c) 這個標識對應 dependecy constraint,這部分邏輯的具體解釋可以參考 這個章節,它對應 gradle 中的 constraints 閉包(如下),

dependencies {
    constraints {
        implementation('com.netease.cloudmusic.android:module_c:1.1.0') {
            because 'previous versions have a bug impacting this application'
        }
    }
}

constraints 閉包的作用可以簡單解釋成不透過直接依賴來升級某個間接依賴的版本,比如下面這個 demo,

// module A, tag 1.0.0, build.gradle
dependencies {
    implementation 'com.netease.cloudmusic.android:module_c:1.0.0'
    implementation 'com.netease.cloudmusic.android:module_d:1.0.0'
}

// module A, tag 1.1.0, build.gradle
dependencies {
    implementation 'com.netease.cloudmusic.android:module_c:1.1.0'
    implementation 'com.netease.cloudmusic.android:module_d:1.1.0'
}

// module A, tag 1.2.0, build.gradle
dependencies {
    implementation 'com.netease.cloudmusic.android:module_d:1.2.0'
}

// app, build.gradle
dependencies {
    implementation 'com.netease.cloudmusic.android:module_a:1.0.0'
}

假設,現在我們發現 module C 在 1.0.0 版本有一個 bug,需要升級 module C 到 1.1.0 版本來修復;但囿於種種原因我們不能直接使用 1.1.0 版本的 module A,比如我們暫時不希望升級 module D 到 1.1.0 版本。

面對這種問題時,我們可能會按下面這種寫法來規避,

// app, build.gradle
dependencies {
    implementation 'com.netease.cloudmusic.android:module_a:1.0.0'
    implementation 'com.netease.cloudmusic.android:module_c:1.1.0'
}

此時依賴關係如下,

+--- com.netease.cloudmusic.android:module_a:1.0.0
|    +--- com.netease.cloudmusic.android:module_c:1.0.0 -> 1.1.0
|    \--- com.netease.cloudmusic.android:module_d:1.0.0
\--- com.netease.cloudmusic.android:module_c:1.1.0

但這種寫法的缺點是:我們引入了一個不必要的依賴,在 app module 直接依賴了 module C。

假設當 module A 升級到 1.2.0 版本,此時 module A 不再依賴 module C 了,

// app, build.gradle
dependencies {
    implementation 'com.netease.cloudmusic.android:module_a:1.2.0'
    implementation 'com.netease.cloudmusic.android:module_c:1.1.0'
}

但由於我們顯式的依賴了 module C,導致 module C 不再是由 module A 來引入,依賴關係發生了錯亂,這並不符合我們的預期,特別在複雜專案中會引入很多不必要的麻煩。

+--- com.netease.cloudmusic.android:module_a:1.2.0
|    \--- com.netease.cloudmusic.android:module_d:1.2.0
\--- com.netease.cloudmusic.android:module_c:1.1.0

如果換成使用 constraints 閉包來實現上面的 demo 就不同了,

// app, build.gradle
dependencies {
    implementation 'com.netease.cloudmusic.android:module_a:1.0.0'
    constraints {
        implementation 'com.netease.cloudmusic.android:module_c:1.1.0'
    }
}

此時,就會產生 (c) 這個標識,

+--- com.netease.cloudmusic.android:module_a:1.0.0
|    +--- com.netease.cloudmusic.android:module_c:1.0.0 -> 1.1.0
|    \--- com.netease.cloudmusic.android:module_d:1.0.0
\--- com.netease.cloudmusic.android:module_c:1.1.0 (c)

當 module A 升級到 1.2.0 之後,

// app, build.gradle
dependencies {
    implementation 'com.netease.cloudmusic.android:module_a:1.2.0'
    constraints {
        implementation 'com.netease.cloudmusic.android:module_c:1.1.0'
    }
}

對 module C 的依賴會自動失效,

+--- com.netease.cloudmusic.android:module_a:1.2.0
     \--- com.netease.cloudmusic.android:module_d:1.2.0

而如果將 demo 改成這樣,繼續升級 module A,

// module A, tag 1.3.0, build.gradle
dependencies {
    implementation 'com.netease.cloudmusic.android:module_c:1.3.0'
    implementation 'com.netease.cloudmusic.android:module_d:1.3.0'
}

// app, build.gradle
dependencies {
    implementation 'com.netease.cloudmusic.android:module_a:1.3.0'
    constraints {
        implementation 'com.netease.cloudmusic.android:module_c:1.2.0'
    }
}

此時輸出如下,依然會展示 (c) 標識,但最終版本選取了更高的 1.3.0 版本。

+--- com.netease.cloudmusic.android:module_a:1.3.0
|    +--- com.netease.cloudmusic.android:module_c:1.3.0 
|    \--- com.netease.cloudmusic.android:module_d:1.3.0
\--- com.netease.cloudmusic.android:module_c:1.2.0 -> 1.3.0 (c)

同時對於 constraints 閉包,也可以用來實現 dependency version alignment

以文章開頭展示的那個結果為例,這裡 kotlinx-coroutines-bom 顧名思義是 kotlin 協程的 bom(Bill Of Materials)模組,

\--- org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.6.0
     +--- org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.6.0
     |    +--- org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.0 (c)
     |    +--- org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.0 (c)
     |    +--- org.jetbrains.kotlinx:kotlinx-coroutines-jdk8:1.6.0 (c)
     |    \--- org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.6.0 (c)
     +--- org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.6.0 (*)
     \--- org.jetbrains.kotlin:kotlin-stdlib-common:1.6.0

在這個 bom 庫的 build.gradle 檔案 中,有如下邏輯,

dependencies {
    constraints {
        rootProject.subprojects.each {
            if (rootProject.ext.unpublished.contains(it.name)) return
            if (it.name == name) return
            if (!it.plugins.hasPlugin('maven-publish')) return
            evaluationDependsOn(it.path)
            it.publishing.publications.all {
                ...
                api(group: it.groupId, name: it.artifactId, version: it.version)
            }
        }
    }
}

本質上就是透過 constraints 閉包,來保證 kotlinx-coroutines-androidkotlinx-coroutines-corekotlinx-coroutines-jdk8kotlinx-coroutines-core-jvm 這幾個子模組的版本一致。

可見,類似協程這種一對多的庫,可以透過抽取一個 bom 模組,利用 constraints 閉包來約束各子 module 版本一致,避免由於版本不一致而引發的問題。

Downgrading versions

與 dependecy constraint 對應的方案是 downgrading versions,用來處理依賴版本的降級,這裡不過多介紹它們的用法,只看它們對dependencies輸出的影響。

這裡重點看一下 forcestrictly 關鍵字的區別,還是如下 demo,比如我們想降級 module C 的版本,

// module A, tag 1.1.0, build.gradle
dependencies {
    implementation 'com.netease.cloudmusic.android:module_c:1.1.0'
    implementation 'com.netease.cloudmusic.android:module_d:1.1.0'
}

// app, build.gradle
dependencies {
    implementation 'com.netease.cloudmusic.android:module_a:1.1.0'
    implementation('com.netease.cloudmusic.android:module_c') {
        version {
            strictly '1.0.0'
        }
    }
}

此時 dependencies 輸出如下,

+--- com.netease.cloudmusic.android:module_a:1.1.0
|    +--- com.netease.cloudmusic.android:module_c:1.1.0 -> 1.0.0
|    \--- com.netease.cloudmusic.android:module_d:1.1.0
\--- com.netease.cloudmusic.android:module_c:{strictly 1.0.0} -> 1.0.0

可以看到,在 dependencies 中有一個 strictly 關鍵字。

但如果使用 force 屬性,寫一個類似的 demo,

// module A, tag 1.1.0, build.gradle
dependencies {
    implementation 'com.netease.cloudmusic.android:module_c:1.1.0'
    implementation 'com.netease.cloudmusic.android:module_d:1.1.0'
}

// app, build.gradle
dependencies {
    implementation 'com.netease.cloudmusic.android:module_a:1.1.0'
    implementation('com.netease.cloudmusic.android:module_c:1.0.0') {
        force = true
    }
}

得到 dependencies 輸出如下,

+--- com.netease.cloudmusic.android:module_a:1.1.0
|    +--- com.netease.cloudmusic.android:module_c:1.1.0 -> 1.0.0
|    \--- com.netease.cloudmusic.android:module_d:1.1.0
\--- com.netease.cloudmusic.android:module_c:1.0.0

可讀性則不如 strictly 關鍵字,沒有任何標識能夠區分,並且在 gradle 的較高版本,force 關鍵字已經被標記廢棄了。

總結

透過對 dependencies graph 中幾個常見的版本識別符號進行分析,尤其是發生依賴衝突時的具體表現,我們已經能夠區分 dependencies 中發生依賴衝突、依賴升級、依賴降級時版本標記的差異。利用這種差異,可以更好的協助我們分析、定位版本依賴的問題。

參考資料

本文釋出自網易雲音樂技術團隊,文章未經授權禁止任何形式的轉載。我們常年招收各類技術崗位,如果你準備換工作,又恰好喜歡雲音樂,那就加入我們 grp.music-fe(at)corp.netease.com!

相關文章