郭神更新啦!Jetpack新成員,一篇文章帶你玩轉Hilt和依賴注入!爺青回!

本文轉自公衆號:郭霖

前言

各位小夥伴們大家好。

終於要寫這樣一篇我自己都比較怕的文章了。

雖然今年的 Google I/O 大會由於疫情的原因沒能開成,但是 Google 每年要發佈的各種新技術可一樣都沒少。

隨着 Android 11 系統的發佈,Jetpack 家族又迎來了不少新成員,包括 Hilt、App Startup、Paging3 等等。

關於 App Startup,我在之前已經寫過一篇文章進行講解了,感興趣的朋友可以參考 Jetpack 新成員,App Startup 一篇就懂 這篇文章

本篇文章的主題是 Hilt。

Hilt 是一個功能強大且用法簡單的依賴注入框架,同時也可以說是今年 Jetpack 家族中最重要的一名新成員。

那麼爲什麼說這是一篇我自己都比較怕的文章呢?因爲關於依賴注入的文章太難寫了。我覺得如果只是向大家講解 Hilt 的用法倒還算是簡單,但是如果想要讓大家弄明白爲什麼要使用 Hilt?或者再進一步,爲什麼要使用依賴注入?這就不是一個非常好寫的話題了。

本篇文章我會嘗試將以上幾個問題全部講清楚,希望我可以做得到。

另外請注意,依賴注入這個話題本身是不分語言的,但由於我還要在本文中講解 Hilt 的知識,所以文中所有的代碼都會使用 Kotlin 來演示。對 Kotlin 還不熟悉的朋友,可以去參考我的新書 《第一行代碼 Android 第 3 版》

依賴注入的英文名是 Dependency Injection,簡稱 DI。事實上這並不是什麼新興的名詞,而是軟件工程學當中比較古老的概念了。

如果要說對於依賴注入最知名的應用,大概就是 Java 中的 Spring 框架了。Spring 在剛開始其實就是一個用於處理依賴注入的框架,後來才慢慢變成了一個功能更加廣泛的綜合型框架。

我在學生時代學習 Spring 時產生了和絕大多數開發者一樣的疑惑,就是爲什麼我們要使用依賴注入呢?

現在的我或許可以給出更好的答案了,一言以蔽之:解耦。

耦合度過高可能會是你的項目中一個比較嚴重的隱患,它會讓你的項目到了後期變得越來越難以維護。

爲了讓大家更容易理解,這裏我準備通過一個具體的例子來講述一下。

假設我們開了一家卡車配送公司,公司裏目前有一輛卡車每天用來送貨,並以此賺錢維持公司運營。

今天接到了一個配送訂單,有客戶委託我們公司去配送兩臺電腦。

爲了完成這個任務,我們可以編寫出如下代碼:

class Truck {

    val computer1 = Computer()
    val computer2 = Computer()

    fun deliver() {
        loadToTruck(computer1)
        loadToTruck(computer2)
        beginToDeliver()
    }

}

這裏有一輛卡車 Truck,卡車中有一個 deliver() 函數用於執行配送任務。我們在 deliver() 函數中先將兩臺電腦裝上卡車,然後開始進行配送。

這種寫法可以完成任務嗎?當然可以,我們的任務是配送兩臺電腦,現在將兩臺電腦都配送出去了,任務當然也就完成了。

但是這種寫法有沒有問題呢?有,而且很嚴重。

具體問題在哪裏呢?明眼的小夥伴應該已經看出來了,我們在 Truck 類當中創建了兩臺電腦的實例,然後纔對它們進行的配送。也就是說,現在我們的卡車不光要會送貨,還要會生產電腦纔行。

這就是剛纔所說的耦合度過高所造成的問題,卡車和電腦這兩樣原本不相干的東西耦合到一起去了。

如果你覺得目前這種寫法問題還不算嚴重,第二天公司又接到了一個新的訂單,要求我們去配送手機,因此這輛卡車還要會生產手機纔行。第三天又接到了一個配送蔬果的訂單,那麼這輛卡車還要會種地。。。

最後你會發現,這已經不是一輛卡車了,而是一個全球商品製造中心。

現在我們都意識到了問題的嚴重性,那麼回過頭來反思一下,我們的項目到底是從哪裏開始跑偏的呢?

這就是一個結構設計上的問題了。仔細思考一下,卡車其實並不需要關心配送的貨物具體是什麼,它的任務就只是負責送貨而已。因此你可以理解成,卡車是依賴於貨物的,給了卡車貨物,它就去送貨,不給卡車貨物,它就待命。

那麼根據這種說法,我們就可以將剛纔的代碼進行如下修改:

class Truck {

    lateinit var cargos: List<Cargo>

    fun deliver() {
        for (cargo in cargos) {
            loadToTruck(cargo)
        }
        beginToDeliver()
    }

}

現在 Truck 類當中添加了 cargos 字段,這就意味着,卡車是依賴於貨物的了。經過這樣的修改之後,我們的卡車不再關心任何商品製造的事情,而是依賴了什麼貨物,就去配送什麼貨物,只做本職應該做的事情。

這種寫法,我們就可以稱之爲:依賴注入。

目前 Truck 類已經設計得比較合理了,但是緊接着又會產生一個新的問題。假如我們的身份現在發生了變化,變成了一家電腦公司的老闆,我該如何讓一輛卡車來幫我運送電腦呢?

這還不好辦?很多人自然而然就能寫出如下代碼:

class ComputerCompany {

    val computer1 = Computer()
    val computer2 = Computer()

    fun deliverByTruck() {
        val truck = Truck()
        truck.cargos = listOf(computer1, computer2)
        truck.deliver()
    }

}

這段代碼同樣是可以正常工作的,但是這段代碼同樣也存在比較嚴重的問題。

問題在哪兒呢?就是在 deliverByTruck() 函數中,爲了讓卡車幫我們送貨,這裏自己製造了一輛卡車。這很明顯是不合理的,電腦公司應該只負責生產電腦,它不應該去生產卡車。

因此,更加合理的做法是,我們通過撥打卡車配送公司的電話,讓他們派輛空閒的卡車過來,這樣就不用自己去造車了。當卡車到達之後,我們再將電腦裝上卡車,然後執行配送任務即可。

這個過程可以用如下示意圖來表示:

使用這種結構設計出來的項目,將會擁有非常出色的擴展性。假如現在又有一家蔬果公司需要找一輛卡車來送菜,我們完全可以使用同樣的結構來完成任務:

注意,重點的地方來了。呼叫卡車公司並讓他們安排空閒車輛的這個部分,我們可以通過自己手寫來實現,也可以藉助一些依賴注入框架來簡化這個過程。

因此,如果你想問依賴注入框架的作用是什麼,那麼實際上它就是爲了替換下圖所示的部分。

看到這裏,希望你已經能明白爲什麼我們要使用依賴注入,以及依賴注入框架的作用是什麼了。

有不少人會存在這樣的觀點,他們認爲依賴注入框架主要是應用在服務器這用複雜度比較高的程序上的,Android 開發通常根本就用不到依賴注入框架。

這種觀點在我看來可能並沒有錯,不過我更希望大家把依賴注入框架當成是一個幫助我們簡化代碼和優化項目的工具,而不是一個額外的負擔。

所以,不管程序的複雜度是高是低,既然依賴注入框架可以幫助我們簡化代碼和優化項目,那麼就完全可以使用它。

說到優化項目,大家可能覺得我剛纔舉的讓卡車去生產電腦的例子太搞笑了。可是你信不信,在我們實際的開發過程中,這樣的例子簡直每天都在上演。

思考一下,你平時在 Activity 中編寫的代碼,有沒有創建過其實並不應該由 Activity 去創建的實例呢?

比如說我們都會使用 OkHttp 來進行網絡請求,你有沒有在 Activity 中創建過 OkHttpClient 的實例呢?如果有的話,那麼恭喜你,你相當於就是在讓卡車去生產電腦了(Activity 是卡車,OkHttpClient 是電腦)。

當然,如果只是一個比較簡單的項目,我們確實可以在 Activity 中去創建 OkHttpClient 的實例。不考慮代碼耦合度的話,即使真的讓卡車去生產電腦,也不會出現什麼太大的問題,因爲它的確可以正常工作。至少暫時可以。

我第一次清晰地意識到自己迫切需要一個依賴注入框架,是我在使用 MVVM 架構來搭建項目的時候。

在 Android 開發者官網有一張關於 MVVM 架構的示意圖,如下圖所示。

這就是現在 Google 最推薦我們使用的 Android 應用程序架構。

爲防止有些同學還沒接觸過 MVVM,我來對這張圖做一下簡單的解釋。

這張架構圖告訴我們,一個擁有良好架構的項目應該要分爲若干層。

其中綠色部分表示的是 UI 控制層,這部分就是我們平時寫的 Activity 和 Fragment。

藍色部分表示的是 ViewModel 層,ViewModel 用於持有和 UI 元素相關的數據,以及負責和倉庫之間進行通訊。

橙色部分表示的是倉庫層,倉庫層要做的工作是判斷接口請求的數據應該是從數據庫中讀取還是從網絡中獲取,並將數據返回給調用方。簡而言之,倉庫的工作就是在本地和網絡數據之間做一個分配和調度的工作。

另外,圖中所有的箭頭都是單向的,比方說 Activity 指向了 ViewModel,表示 Activity 是依賴於 ViewModel 的,但是反過來 ViewModel 不能依賴於 Activity。其他的幾層也是一樣的道理,一個箭頭就表示一個依賴關係。

還有,依賴關係是不可以跨層的,比方說 UI 控制層不能和倉庫層有依賴關係,每一層的組件都只能和它的相鄰層交互。

使用這套架構設計出來的項目,結構清晰、分層明確,一定會是一個代碼質量非常高的項目。

但是在按照這張架構示意圖具體實現的過程中,我卻發現了一個問題。

UI 控制層當中,Activity 是四大組件之一,它的實例創建是不用我們去操心的。

而 ViewModel 層當中,Google 在 Jetpack 中提供了專門的 API 來獲取 ViewModel 的實例,所以它的實例創建也是不用我們去操心的。

但是到了倉庫層,一個尷尬的事情出現了,誰應該去負責創建倉庫的實例呢?ViewModel 嗎?不對,ViewModel 只是依賴了倉庫而已,它不應該負責創建倉庫的實例,並且其他不同的 ViewModel 也可能會依賴同一個倉庫實例。Activity 嗎?這就更扯了,因爲 Activity 和 ViewModel 通常都是一一對應的。

所以最後我發現,沒人應該負責創建倉庫的實例,最簡單的方式就是將倉庫設置成單例類,這樣就不需要操心實例創建的問題了。

但是設置成單例類之後又會出現一個新的問題,就是依賴關係不可以跨層這個規則被打破了。因爲倉庫已經設置成了單例類,那麼自然相當於誰都擁有它的依賴關係了,UI 控制層可以繞過 ViewModel 層,直接和倉庫層進行通訊。

從代碼設計的層面來講,這是一個非常不好解決的問題。但如果我們藉助依賴注入框架,就可以很靈活地解決這個問題。

從剛纔的示意圖中已經可以看出,依賴注入框架就是幫助我們呼叫和安排空閒卡車的,我並不關心這個卡車是怎麼來的,只要你能幫我送貨就行。

因此,ViewModel 層也不應該關心倉庫的實例是怎麼來的,我只需要聲明 ViewModel 是需要依賴倉庫的,剩下的讓依賴注入框架幫我去解決就行了。

通過這樣一個類比,你是不是對於依賴注入框架的理解又更加深刻了一點呢?

接下來我們聊一聊 Android 有哪些常用的依賴注入框架。

在很早的時候,絕大部分的 Android 開發者都是沒有使用依賴注入框架這種意識的。

大名鼎鼎的 Square 公司在 2012 年推出了至今仍然知名度極高的開源依賴注入框架:Dagger。

Square 公司有許多非常成功的開源項目,OkHttp、Retrofit、LeakCanary 等等大家都耳熟能詳,而且幾乎所有的 Android 項目都在使用。但是 Dagger 卻空有知名度,現在應該沒有任何項目還在使用它了,爲什麼呢?

這就是一個很有意思的故事了。

Dagger 的依賴注入理念雖然非常先進,但是卻存在一個問題,它是基於 Java 反射去實現的,這就導致了兩個潛在的隱患。

第一,我們都知道反射是比較耗時的,所以用這種方式會降低程序的運行效率。當然這個問題並不大,因爲現在的程序中到處都在用反射。

第二,依賴注入框架的用法總體來說是非常有難度的,除非你能相當熟練地使用它,否則很難一次性編寫正確。而基於反射實現的依賴注入功能,使得在編譯期我們無法得知依賴注入的用法到底對不對,只能在運行時通過程序有沒有崩潰來判斷。這樣測試的效率就很低,而且容易將一些 bug 隱藏得很深。

接下來就到了最有意思的地方,我們現在都知道 Dagger 的實現方式存在問題,那麼 Dagger2 自然是要去解決這些問題的。但是 Dagger2 並不是由 Square 開發的,而是由 Google 開發的。

這就很奇怪了,正常情況下一個庫的 1 版和 2 版應該都是由同一個公司或者同一批開發者維護的,怎麼 Dagger1 到 Dagger2 會變化這麼大呢?我也不知道爲什麼,但是我注意到,Google 現在維護的 Dagger 項目是從 Square 的 Dagger 項目 Fork 過來的。

所以我猜測,大概是 Google Fork 了一份 Dagger 的源碼,然後在此基礎上進行修改,併發布了 Dagger2 版本。Square 看到了之後,認爲 Google 的這個版本做得非常好,自己沒有必要再重做一遍,也沒有必要繼續維護 Dagger1 了,所以就發佈了這樣一條聲明:

那麼 Dagger2 和 Dagger1 不同的地方在哪裏呢?最重要的不同點在於,實現方式完全發生了變化。剛纔我們已經知道,Dagger1 是基於 Java 反射實現的,並且列舉了它的一些弊端。而 Google 開發的 Dagger2 是基於 Java 註解實現的,這樣就把反射的那些弊端全部解決了。

通過註解,Dagger2 會在編譯時期自動生成用於依賴注入的代碼,所以不會增加任何運行耗時。另外,Dagger2 會在編譯時期檢查開發者的依賴注入用法是否正確,如果不正確的話則會直接編譯失敗,這樣就能將問題儘可能早地拋出。也就是說,只要你的項目正常編譯通過,基本也就說明你的依賴注入用法沒什麼問題了。

那麼 Google 的這個 Dagger2 有沒有取得成功呢?簡直可以說是大獲成功。

根據 Google 官方給出的數據,在 Google Play 排名前 1000 的 App 當中,有 74% 的 App 都使用了 Dagger2。

這裏我要提一句,海外和國內的 Android 開發者喜歡研究的技術棧不太一樣。在海外,沒有人去研究像熱修復或插件化這種國內特有的 Android 技術。那麼你可能想問了,海外開發者們都是學什麼進階的呢?

答案就是 Dagger2。

是的,Dagger2 在海外是非常受到歡迎和廣泛認可的技術棧,如果你能用得一手好 Dagger2,基本也就說明你是水平比較高的開發者了。

不過有趣的是,在國內反倒沒有多少人願意去使用 Dagger2,我在公衆號之前也推送過幾篇關於 Dagger2 的文章,但是從反饋上來看感覺這項技術在國內始終比較小衆。

雖然 Dagger2 在海外很受歡迎,但是其複雜程度也是衆所周知的,如果你不能很好地使用它的話,反而可能會拖累你的項目。所以一直也有聲音說,使用 Dagger2 會將一些簡單的項目過度設計。

根據 Android 團隊發佈的調查,49% 的 Android 開發者希望 Jetpack 中能夠提供一個更加簡單的依賴注入解決方案。

於是,Google 在今年發佈了 Hilt。

你是不是覺得我講了這麼多的長篇大論,現在才終於講到主題?不要這麼想,我認爲了解以上這些綜合的內容,比僅僅只是掌握了 Hilt 的用法要更加重要。

我們都知道,Dagger 是匕首的意思,依賴注入就好像是把匕首直接插入了需要注入的地方,直擊要害。

而 Hilt 是刀把的意思,它把匕首最鋒利的地方隱藏了起來,因爲如果你用不好匕首的話反而可能會誤傷自己。Hilt 給你提供了一個安穩的把手,確保你可以安全簡單地使用。

事實上,Hilt 和 Dagger2 有着千絲萬縷的關係。Hilt 就是 Android 團隊聯繫了 Dagger2 團隊,一起開發出來的一個專門面向 Android 的依賴注入框架。相比於 Dagger2,Hilt 最明顯的特徵就是:1. 簡單。2. 提供了 Android 專屬的 API。

那麼接下來,就讓我們開始學習一下 Hilt 的具體用法。

在開始使用 Hilt 之前,我們需要先將 Hilt 引入到你當前的項目當中。這個過程稍微有點繁瑣,所以請大家一步步按照文章中的步驟操作。

第一步,我們需要在項目根目錄的 build.gradle 文件中配置 Hilt 的插件路徑:

buildscript {
    ...
    dependencies {
        ...
        classpath 'com.google.dagger:hilt-android-gradle-plugin:2.28-alpha'
    }
}

可以看到,目前 Hilt 最新的插件版本還在 alpha 階段,但是沒有關係,我自己用下來感覺已經是相當穩定了,等正式版本發佈之後升級一下就可以了,用法上不會有什麼太大變化。

接下來,在 app/build.gradle 文件中,引入 Hilt 的插件並添加 Hilt 的依賴庫:

...
apply plugin: 'kotlin-kapt'
apply plugin: 'dagger.hilt.android.plugin'

dependencies {
    implementation "com.google.dagger:hilt-android:2.28-alpha"
    kapt "com.google.dagger:hilt-android-compiler:2.28-alpha"
}

這裏同時還引入了 kotlin-kapt 插件,是因爲 Hilt 是基於編譯時註解來實現的,而啓用編譯時註解功能一定要先添加 kotlin-kapt 插件。如果你還在用 Java 開發項目,則可以不引入這個插件,同時將添加註解依賴庫時使用的 kapt 關鍵字改成 annotationProcessor 即可。

最後,由於 Hilt 還會用到 Java 8 的特性,所以我們還得在當前項目中啓用 Java 8 的功能,編輯 app/build.gradle 文件,並添加如下內容即可:

android {
  ...
  compileOptions {
    sourceCompatibility JavaVersion.VERSION_1_8
    targetCompatibility JavaVersion.VERSION_1_8
  }
}

好了,要配置的內容總共就這麼多。現在你已經成功將 Hilt 引入到了你的項目當中,下面我們就來學習一下如何使用它吧。

我們先從最簡單的功能學起。

相信大家都知道,每個 Android 程序中都會有一個 Application,這個 Application 可以自定義,也可以不定義,如果你不定義的話,系統會使用一個默認的 Application。

而到了 Hilt 當中,你必須要自定義一個 Application 纔行,否則 Hilt 將無法正常工作。

這裏我們自定義一個 MyApplication 類,代碼如下所示:

@HiltAndroidApp
class MyApplication : Application() {
}

你的自定義 Application 中可以不寫任何代碼,但是必須要加上一個 @HiltAndroidApp 註解,這是使用 Hilt 的一個必備前提。

接下來將 MyApplication 註冊到你的 AndroidManifest.xml 文件當中:

<application
    android:
    ...>

</application>

這樣準備工作就算是完成了,接下來的工作就是根據你具體的業務邏輯使用 Hilt 去進行依賴注入。

Hilt 大幅簡化了 Dagger2 的用法,使得我們不用通過 @Component 註解去編寫橋接層的邏輯,但是也因此限定了注入功能只能從幾個 Android 固定的入口點開始。

Hilt 一共支持 6 個入口點,分別是:

  • Application
  • Activity
  • Fragment
  • View
  • Service
  • BroadcastReceiver

其中,只有 Application 這個入口點是使用 @HiltAndroidApp 註解來聲明的,這個我們剛纔已經看過了。其他的所有入口點,都是用 @AndroidEntryPoint 註解來聲明的。

以最常見的 Activity 來舉例吧,如果我希望在 Activity 中進行依賴注入,那麼只需要這樣聲明 Activity 即可:

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
    }

}

接下來我們嘗試向 Activity 中注入點東西吧。注入什麼呢?還記得剛纔的那輛卡車嗎,我們試着看把它注入到 Activity 當中吧。

定義一個 Truck 類,代碼如下所示:

class Truck {

    fun deliver() {
        println("Truck is delivering cargo.")
    }

}

可以看到,目前這輛卡車有一個 deliver() 方法,說明它具備送貨功能。

然後修改 Activity 中的代碼,如下所示:

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

    @Inject
    lateinit var truck: Truck

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        truck.deliver()
    }

}

這裏的代碼可能乍一看上去稍微有點奇怪,我來解釋一下。

首先 lateinit 是 Kotlin 中的關鍵字,和 Hilt 無關。這個關鍵字用於對變量延遲初始化,因爲 Kotlin 默認在聲明一個變量時就要對其進行初始化,而這裏我們並不想手動初始化,所以要加上 lateinit。如果你是用 Java 開發的話,那麼可以無視這個關鍵字。

接下來我們在 truck 字段的上方聲明瞭一個 @Inject 註解,表示我希望通過 Hilt 來注入 truck 這個字段。如果讓我類比的話,這大概就相當於電腦公司打電話讓卡車配送公司安排卡車的過程。我們可以把 MainActivity 看作電腦公司,它是依賴於卡車的,但是至於這個卡車是怎麼來的,電腦公司並不關心。而 Hilt 在這裏承擔的職責就類似於卡車配送公司,它負責想辦法安排車輛,甚至有義務造一輛出來。

另外提一句,Hilt 注入的字段是不可以聲明成 private 的,這裏大家一定要注意。

不過代碼寫到這裏還是不可以正常工作的,因爲 Hilt 並不知道該如何提供一輛卡車。因此,我們還需要對 Truck 類進行如下修改:

class Truck @Inject constructor() {

    fun deliver() {
        println("Truck is delivering cargo.")
    }

}

這裏我們在 Truck 類的構造函數上聲明瞭一個 @Inject 註解,其實就是在告訴 Hilt,你是可以通過這個構造函數來安排一輛卡車的。

好了,就是這麼簡單。現在可以運行一下程序了,你將會在 Logcat 中看到如下內容:

說明卡車真的已經在好好送貨了。

有沒有覺得很神奇?我們在 MainActivity 中並沒有去創建 Truck 的實例,只是用 @Inject 聲明瞭一下,結果真的可以調用它的 deliver() 方法。

這就是 Hilt 給我們提供的依賴注入功能。

必須承認,剛纔我們所舉的例子確實太簡單了,在真實的編程場景中用處應該非常有限,因爲真實場景中不可能永遠是這樣的理想情況。

那麼下面我們就開始逐步學習如何在各種更加複雜的場景下使用 Hilt 進行依賴注入。

首先一個很容易想到的場景,如果我的構造函數中帶有參數,Hilt 要如何進行依賴注入呢?

我們對 Truck 類進行如下改造:

class Truck @Inject constructor(val driver: Driver) {

    fun deliver() {
        println("Truck is delivering cargo. Driven by $driver")
    }

}

可以看到,現在 Truck 類的構造函數中增加了一個 Driver 參數,說明卡車是依賴一位司機的,畢竟沒有司機的話卡車自己是不會開的。

那麼問題來了,既然卡車是依賴司機的,Hilt 現在要如何對卡車進行依賴注入呢?畢竟 Hilt 不知道這位司機來自何處。

這個問題其實沒有想象中的困難,因爲既然卡車是依賴司機的,那麼如果我們想要對卡車進行依賴注入,自然首先要能對司機進行依賴注入纔行。

所以可以這樣去聲明 Driver 類:

class Driver @Inject constructor() {
}

非常簡單,我們在 Driver 類的構造函數上聲明瞭一個 @Inject 註解,如此一來,Driver 類就變成了無參構造函數的依賴注入方式。

然後就不需要再修改任何代碼了,因爲 Hilt 既然知道了要如何依賴注入 Driver,也就知道要如何依賴注入 Truck 了。

總結一下,就是 Truck 的構造函數中所依賴的所有其他對象都支持依賴注入了,那麼 Truck 纔可以被依賴注入。

現在重新運行一下程序,打印日誌如下所示:

可以看到,現在卡車正在被一位司機駕駛,這位司機的身份證號是 de5edf5。

解決了帶參構造函數的依賴注入,接下來我們繼續看更加複雜的場景:如何對接口進行依賴注入。

毫無疑問,我們目前所掌握的技術是無法對接口進行依賴注入的,原因也很簡單,接口沒有構造函數。

不過不用擔心,Hilt 對接口的依賴注入提供了相當完善的支持,所以你很快就能掌握這項技能。

我們繼續通過具體的示例來學習。

任何一輛卡車都需要有引擎纔可以正常行駛,那麼這裏我定義一個 Engine 接口,如下所示:

interface Engine {
    fun start()
    fun shutdown()
}

非常簡單,接口中有兩個待實現方法,分別用於啓用引擎和關閉引擎。

既然有接口,那就還要有實現類纔行。這裏我再定義一個 GasEngine 類,並實現 Engine 接口,代碼如下所示:

class GasEngine() : Engine {
    override fun start() {
        println("Gas engine start.")
    }

    override fun shutdown() {
        println("Gas engine shutdown.")
    }
}

可以看到,我們在 GasEngine 中實現了啓動引擎和關閉引擎的功能。

另外,現在新能源汽車非常火,特斯拉已經快要遍地都是了。所以汽車引擎除了傳統的燃油引擎之外,現在還有了電動引擎。於是這裏我們再定義一個 ElectricEngine 類,並實現 Engine 接口,代碼如下所示:

class ElectricEngine() : Engine {
    override fun start() {
        println("Electric engine start.")
    }

    override fun shutdown() {
        println("Electric engine shutdown.")
    }
}

類似地,ElectricEngine 中也實現了啓動引擎和關閉引擎的功能。

剛纔已經說了,任何一輛卡車都需要有引擎纔可以正常行駛,也就是說,卡車是依賴於引擎的。現在我想要通過依賴注入的方式,將引擎注入到卡車當中,那麼需要怎麼寫呢?

根據剛纔已學到的知識,最直觀的寫法就是這樣:

class Truck @Inject constructor(val driver: Driver) {

    @Inject
    lateinit var engine: Engine
    ...

}

我們在 Truck 中聲明一個 engine 字段,這就說明 Truck 是依賴於 Engine 的了。然後在 engine 字段的上方使用 @Inject 註解對該字段進行注入。或者你也可以將 engine 字段聲明到構造函數當中,這樣就不需要加入 @Inject 註解了,效果是一樣的。

假如 Engine 字段是一個普通的類,使用這種寫法當然是沒問題的。但問題是 Engine 是一個接口,Hilt 肯定是無法知道要如何創建這個接口的實例,因此這樣寫一定會報錯。

下面我們就來看看該如何一步步解決這個問題。

首先,剛纔編寫的 GasEngine 和 ElectricEngine 這兩個實現類,它們是可以依賴注入的,因爲它們都有構造函數。

因此分別修改 GasEngine 和 ElectricEngine 中的代碼,如下所示:

class GasEngine @Inject constructor() : Engine {
    ...
}

class ElectricEngine @Inject constructor() : Engine {
    ...
}

這又是我們剛纔學過的技術了,在這兩個類的構造函數上分別聲明 @Inject 註解。

接下來我們需要新建一個抽象類,類名叫什麼都可以,但是最好要和業務邏輯有相關性,因此我建議起名 EngineModule.kt,如下所示:

@Module
@InstallIn(ActivityComponent::class)
abstract class EngineModule {

}

這裏注意,我們需要在 EngineModule 的上方聲明一個 @Module 註解,表示這一個用於提供依賴注入實例的模塊。

如果你之前學習過 Dagger2,那麼對於這部分理解起來一定會相當輕鬆,這完全就是和 Dagger2 是一模一樣的嘛。

而如果你之前沒有學習過 Dagger2,也沒有關係,跟着接下來的步驟一步步實現,你自然就能明白它的作用了。

另外可能你會注意到,除了 @Module 註解之外,這裏還聲明瞭一個 @InstallIn 註解,這個就是 Dagger2 中沒有的東西了。關於 @InstallIn 註解的作用,待會我會使用一塊單獨的主題進行講解,暫時你只要知道必須這麼寫就可以了。

定義好了 EngineModule 之後,接下來我們需要在這個模塊當中提供 Engine 接口所需要的實例。怎麼提供呢?非常簡單,代碼如下所示:

@Module
@InstallIn(ActivityComponent::class)
abstract class EngineModule {

    @Binds
    abstract fun bindEngine(gasEngine: GasEngine): Engine

}

這裏有幾個關鍵的點我逐個說明一下。

首先我們要定義一個抽象函數,爲什麼是抽象函數呢?因爲我們並不需實現具體的函數體。

其次,這個抽象函數的函數名叫什麼都無所謂,你也不會調用它,不過起個好點的名字可以有助於你的閱讀和理解。

第三,抽象函數的返回值必須是 Engine,表示用於給 Engine 類型的接口提供實例。那麼提供什麼實例給它呢?抽象函數接收了什麼參數,就提供什麼實例給它。由於我們的卡車還比較傳統,使用的仍然是燃油引擎,所以 bindEngine() 函數接收了 GasEngine 參數,也就是說,會將 GasEngine 的實例提供給 Engine 接口。

最後,在抽象函數上方加上 @Bind 註解,這樣 Hilt 才能識別它。

經過一系列的代碼編寫之後,我們再回到 Truck 類當中。你會發現,這個時候我們再向 engine 字段去進行依賴注入就變得有道理了,因爲藉助剛纔定義的 EngineModule,很明顯將會注入一個 GasEngine 的實例到 engine 字段當中。

實際是不是這樣呢?我們來操作一下就知道了,修改 Truck 類中的代碼,如下所示:

class Truck @Inject constructor(val driver: Driver) {

    @Inject
    lateinit var engine: Engine

    fun deliver() {
        engine.start()
        println("Truck is delivering cargo. Driven by $driver")
        engine.shutdown()
    }

}

我們在開始送貨之前先啓動車輛引擎,然後在送貨完成之後完畢車輛引擎,非常合理的邏輯。

現在重新運行一下程序,控制檯打印信息如圖所示:

正如我們所預期的那樣,在送貨的前後分別打印了燃油引擎啓動和燃油引擎關閉的日誌,說明 Hilt 確實向 engine 字段注入了一個 GasEngine 的實例。

這樣也就解決了給接口進行依賴注入的問題。

友情提醒,別忘了剛纔我們定義的 ElectricEngine 還沒用上呢。

現在卡車配送公司通過送貨賺到了很多錢,解決了溫飽問題,就該考慮環保問題了。用燃油引擎來送貨實在是不夠環保,爲了拯救地球,我們決定對卡車進行升級改造。

但是目前電動車還不夠成熟,存在續航里程短,充電時間長等問題。怎麼辦呢?於是我們準備採取一個折中的方案,暫時使用混動引擎來進行過渡。

也就是說,一輛卡車中將會同時包含燃油引擎和電動引擎。

那麼問題來了,我們通過 EngineModule 中的 bindEngine() 函數爲 Engine 接口提供實例,這個實例要麼是 GasEngine,要麼是 ElectricEngine,怎麼能同時爲一個接口提供兩種不同的實例呢?

可能你會想到,那我定義兩個不同的函數,分別接收 GasEngine 和 ElectricEngine 參數不就行了,代碼如下所示:

@Module
@InstallIn(ActivityComponent::class)
abstract class EngineModule {

    @Binds
    abstract fun bindGasEngine(gasEngine: GasEngine): Engine

    @Binds
    abstract fun bindElectricEngine(electricEngine: ElectricEngine): Engine

}

這種寫法看上去好像挺有道理,但是如果你編譯一下就會發現報錯了:

注意紅框中的文字即可,這個錯誤在提醒我們,Engine 被綁定了多次。

其實想想也有道理,我們在 EngineModule 中提供了兩個不同的函數,它們的返回值都是 Engine。那麼當在 Truck 中給 engine 字段進行依賴注入時,到底是使用 bindGasEngine() 函數提供的實例呢?還是使用 bindElectricEngine() 函數提供的實例呢?Hilt 也搞不清楚了。

因此這個問題需要藉助額外的技術手段才能解決:Qualifier 註解。

Qualifier 註解的作用就是專門用於解決我們目前碰到的問題,給相同類型的類或接口注入不同的實例。

這裏我們分別定義兩個註解,如下所示:

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class BindGasEngine

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class BindElectricEngine

一個註解叫 BindGasEngine,一個註解叫 BindElectricEngine,這樣兩個註解的作用就明顯區分開了。

另外,註解的上方必須使用 @Qualifier 進行聲明,這個是毫無疑問的。至於另外一個 @Retention,是用於聲明註解的作用範圍,選擇 AnnotationRetention.BINARY 表示該註解在編譯之後會得到保留,但是無法通過反射去訪問這個註解。這應該是最合理的一個註解作用範圍。

定義好了上述兩個註解之後,我們再回到 EngineModule 當中。現在就可以將剛纔定義的兩個註解分別添加到 bindGasEngine() 和 bindElectricEngine() 函數的上方,如下所示:

@Module
@InstallIn(ActivityComponent::class)
abstract class EngineModule {

    @BindGasEngine
    @Binds
    abstract fun bindGasEngine(gasEngine: GasEngine): Engine

    @BindElectricEngine
    @Binds
    abstract fun bindElectricEngine(electricEngine: ElectricEngine): Engine

}

如此一來,我們就將兩個爲 Engine 接口提供實例的函數進行了分類,一個分到了 @BindGasEngine 註解上,一個分到了 @BindElectricEngine 註解上。

不過現在還沒結束,因爲增加了 Qualifier 註解之後,所有爲 Engine 類型進行依賴注入的地方也需要去聲明註解,明確指定自己希望注入哪種類型的實例。

因此我們還需要修改 Truck 類中的代碼,如下所示:

class Truck @Inject constructor(val driver: Driver) {

    @BindGasEngine
    @Inject
    lateinit var gasEngine: Engine

    @BindElectricEngine
    @Inject
    lateinit var electricEngine: Engine

    fun deliver() {
        gasEngine.start()
        electricEngine.start()
        println("Truck is delivering cargo. Driven by $driver")
        gasEngine.shutdown()
        electricEngine.shutdown()
    }

}

這段代碼現在看起來是不是很容易理解了呢?

我們定義了 gasEngine 和 electricEngine 這兩個字段,它們的類型都是 Engine。但是在 gasEngine 的上方,使用了 @BindGasEngine 註解,這樣 Hilt 就會給它注入 GasEngine 的實例。在 electricEngine 的上方,使用了 @BindElectricEngine 註解,這樣 Hilt 就會給它注入 ElectricEngine 的實例。

最後在 deliver() 當中,我們先啓動燃油引擎,再啓動電動引擎,送貨結束後,先關閉燃油引擎,再關閉電動引擎。

最終的結果會是什麼樣呢?運行一下看看吧,如下圖所示。

非常棒,一切正如我們所預期地那樣運行了。

這樣也就解決了給相同類型注入不同實例的問題。

卡車這個例子暫時先告一段落,接下來我們看一些更加實際的例子。

剛纔有說過,如果我們想要在 MainActivity 中使用 OkHttp 發起網絡請求,通常會創建一個 OkHttpClient 的實例。不過原則上 OkHttpClient 的實例又不應該由 Activity 去創建,那麼很明顯,這個時候使用依賴注入是一個非常不錯的解決方案。即,讓 MainActivity 去依賴 OkHttpClient 即可。

但是這又會引出一個新的問題,OkHttpClient 這個類是由 OkHttp 庫提供的啊,我們並沒有這個類的編寫權限,因此自然也不可能在 OkHttpClient 的構造函數中加上 @Inject 註解,那麼要如何對它進行依賴注入呢?

這個時候又要藉助 @Module 註解了,它的解決方案有點類似於剛纔給接口類型提供依賴注入,但是並不完全一樣。

首先定義一個叫 NetworkModule 的類,代碼如下所示:

@Module
@InstallIn(ActivityComponent::class)
class NetworkModule {

}

它的初始聲明和剛纔的 EngineModule 非常相似,只不過這裏沒有將它聲明成抽象類,因爲我們不會在這裏定義抽象函數。

很明顯,在 NetworkModule 當中,我們希望給 OkHttpClient 類型提供實例,因此可以編寫如下代碼:

@Module
@InstallIn(ActivityComponent::class)
class NetworkModule {

    @Provides
    fun provideOkHttpClient(): OkHttpClient {
        return OkHttpClient.Builder()
            .connectTimeout(20, TimeUnit.SECONDS)
            .readTimeout(20, TimeUnit.SECONDS)
            .writeTimeout(20, TimeUnit.SECONDS)
            .build()
    }

}

同樣,provideOkHttpClient() 這個函數名是隨便定義的,Hilt 不做任何要求,但是返回值必須是 OkHttpClient,因爲我們就是要給 OkHttpClient 類型提供實例嘛。

注意,不同的地方在於,這次我們寫的不是抽象函數了,而是一個常規的函數。在這個函數中,按正常的寫法去創建 OkHttpClient 的實例,並進行返回即可。

最後,記得要在 provideOkHttpClient() 函數的上方加上 @Provides 註解,這樣 Hilt 才能識別它。

好了,現在如果你想要在 MainActivity 中去依賴注入 OkHttpClient,只需要這樣寫即可:

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

    @Inject
    lateinit var okHttpClient: OkHttpClient
    ...

}

然後你可以在 MainActivity 的任何地方去使用 okHttpClient 對象,代碼一定會正常運行的。

這樣我們就解決了給第三方庫的類進行依賴注入的問題,不過這個問題其實還可以再進一步拓展一下。

現在直接使用 OkHttp 的人已經越來越少了,更多的開發者選擇使用 Retrofit 來作爲他們的網絡請求解決方案,而 Retrofit 實際上也是基於 OkHttp 的。

爲了方便開發者的使用,我們希望在 NetworkModule 中給 Retrofit 類型提供實例,而在創建 Retrofit 實例的時候,我們又可以選擇讓其依賴 OkHttpClient,具體要怎麼寫呢?特別簡單:

@Module
@InstallIn(ActivityComponent::class)
class NetworkModule {

    ...

    @Provides
    fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
        return Retrofit.Builder()
            .addConverterFactory(GsonConverterFactory.create())
            .baseUrl("http://example.com/")
            .client(okHttpClient)
            .build()
    }

}

這裏定義了一個 provideRetrofit() 函數,然後在函數中按常規的方式去創建 Retrofit 的實例,並將其返回即可。

但是我們注意到,provideRetrofit() 函數還接收了一個 OkHttpClient 參數,並且我們在創建 Retrofit 實例的時候還依賴了這個參數。那麼你可能會問了,我們要如何向 provideRetrofit() 函數去傳遞 OkHttpClient 這個參數呢?

答案是,完全不需要傳遞,因爲這個過程是由 Hilt 自動完成的。我們所需要做的,就是保證 Hilt 能知道如何得到一個 OkHttpClient 的實例,而這個工作我們早在前面一步就已經完成了。

所以,假如現在你在 MainActivity 中去編寫這樣的代碼:

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

    @Inject
    lateinit var retrofit: Retrofit
    ...

}

絕對是沒有問題的。

剛纔我們在學習給接口和第三方類進行依賴注入時,跳過了 @InstallIn 這個註解,現在是時候該回頭看一下了。

其實這個註解的名字起得還是相當準確的,InstallIn,就是安裝到的意思。那麼 @InstallIn(ActivityComponent::class),就是把這個模塊安裝到 Activity 組件當中。

既然是安裝到了 Activity 組件當中,那麼自然在 Activity 中是可以使用由這個模塊提供的所有依賴注入實例。另外,Activity 中包含的 Fragment 和 View 也可以使用,但是除了 Activity、Fragment、View 之外的其他地方就無法使用了。

比如說,我們在 Service 中使用 @Inject 來對 Retrofit 類型的字段進行依賴注入,就一定會報錯。

不過不用慌,這些都是有辦法解決的。

Hilt 一共內置了 7 種組件類型,分別用於注入到不同的場景,如下表所示。

這張表中,每個組件的作用範圍都不相同。其中,ApplicationComponent 提供的依賴注入實例可以在全項目中使用。因此,如果我們希望剛纔在 NetworkModule 中提供的 Retrofit 實例也能在 Service 中進行依賴注入,只需要這樣修改就可以了:

@Module
@InstallIn(ApplicationComponent::class)
class NetworkModule {
    ...
}

另外和 Hilt 內置組件相關的,還有一個叫組件作用域的概念,我們也要學習一下它的作用。

或許 Hilt 的這個行爲和你預想的並不一致,但是這確實就是事實:Hilt 會爲每次的依賴注入行爲都創建不同的實例。

這種默認行爲在很多時候確實是非常不合理的,比如我們提供的 Retrofit 和 OkHttpClient 的實例,理論上它們全局只需要一份就可以了,每次都創建不同的實例明顯是一種不必要的浪費。

而更改這種默認行爲其實也很簡單,藉助 @Singleton 註解即可,如下所示:

@Module
@InstallIn(ApplicationComponent::class)
class NetworkModule {

    @Singleton
    @Provides
    fun provideOkHttpClient(): OkHttpClient {
        return OkHttpClient.Builder()
            .connectTimeout(20, TimeUnit.SECONDS)
            .readTimeout(20, TimeUnit.SECONDS)
            .writeTimeout(20, TimeUnit.SECONDS)
            .build()
    }

    @Singleton
    @Provides
    fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
        return Retrofit.Builder()
            .addConverterFactory(GsonConverterFactory.create())
            .baseUrl("http://example.com")
            .client(okHttpClient)
            .build()
    }

}

這樣就可以保證 OkHttpClient 和 Retrofit 在全局都只會存在一份實例了。

Hilt 一共提供了 7 種組件作用域註解,和剛纔的 7 個內置組件分別是一一對應的,如下表所示。

也就是說,如果想要在全程序範圍內共用某個對象的實例,那麼就使用 @Singleton。如果想要在某個 Activity,以及它內部包含的 Fragment 和 View 中共用某個對象的實例,那麼就使用 @ActivityScoped。以此類推。

另外,我們不必非得在某個 Module 中使用作用域註解,也可以直接將它聲明到任何可注入類的上方。比如我們對 Driver 類進行如下聲明:

@Singleton
class Driver @Inject constructor() {
}

這就表示,Driver 在整個項目的全局範圍內都會共享同一個實例,並且全局都可以對 Driver 類進行依賴注入。

而如果我們將註解改成 @ActivityScoped,那麼就表示 Driver 在同一個 Activity 內部將會共享同一個實例,並且 Activity、Fragment、View 都可以對 Driver 類進行依賴注入。

你可能會好奇,這個包含關係是如何確定的,爲什麼聲明成 @ActivityScoped 的類在 Fragment 和 View 中也可以進行依賴注入?

關於包含關係的定義,我們來看下面這張圖就一目瞭然了:

簡單來講,就是對某個類聲明瞭某種作用域註解之後,這個註解的箭頭所能指到的地方,都可以對該類進行依賴注入,同時在該範圍內共享同一個實例。

比如 @Singleton 註解的箭頭可以指向所有地方。而 @ServiceScoped 註解的箭頭無處可指,所以只能限定在 Service 自身當中使用。@ActivityScoped 註解的箭頭可以指向 Fragment、View 當中。

這樣你應該就將 Hilt 的內置組件以及組件作用域的相關知識都掌握牢了。

Android 開發相比於傳統的 Java 開發有其特有的特殊性,比如說 Android 中有個 Context 的概念。

剛入門 Android 開發的新手可能總會疑惑 Context 到底是什麼,而做過多年 Android 開發的人估計根本就不關心這個問題了,我天天都在用,甚至到處都在用它,對 Context 是什麼已經麻木了。

確實,Android 開發中有太多的地方要依賴於 Context,動不動調用的什麼接口就會要求你傳入 Context 參數。

那麼,如果有個我們想要依賴注入的類,它又是依賴於 Context 的,這個情況要如何解決呢?

舉個例子,現在 Driver 類的構造函數接收一個 Context 參數,如下所示:

@Singleton
class Driver @Inject constructor(val context: Context) {
}

現在你編譯一下項目一定會報錯,原因也很簡單,Driver 類無法被依賴注入了,因爲 Hilt 不知道要如何提供 Context 這個參數。

感覺似曾相識是不是?好像我們讓 Truck 類去依賴 Driver 類的時候也遇到了這個問題,當時的解決方案是在 Driver 的構造函數上聲明 @Inject 註解,讓其也可以被依賴注入就可以了。

但是很明顯,這裏我們不能用同樣的方法解決問題,因爲我們根本就沒有 Context 類的編寫權限,所以肯定無法在其構造函數上聲明 @Inject 註解。

那麼你可能又會想到了,沒有 Context 類的編寫權限,那麼我們再使用剛纔學到的 @Module 的方式,以第三方類的形式給 Context 提供依賴注入不就行了?

這種方案乍看之下好像確實可以,但是當你實際去編寫的時候又會發現問題了,比如說:

@Module
@InstallIn(ApplicationComponent::class)
class ContextModule {

    @Provides
    fun provideContext(): Context {
        ???
    }

}

這裏我定義好了一個 ContextModule,定義好了一個 provideContext() 函數,它的返回值也確實是 Context,但是我接下來不知道該怎麼寫了,因爲我不能 new 一個 Context 的實例去返回啊。

沒錯,像 Context 這樣的系統組件,它的實例都是由 Android 系統去創建的,我們不可以隨便去 new 它的實例,所以自然也就不能用前面所學的方案去解決。

那麼要如何解決呢?非常簡單,Android 提供了一些預置 Qualifier,專門就是用於給我們提供 Context 類型的依賴注入實例的。

比如剛纔的 Truck 類,其實只需要在 Context 參數前加上一個 @ApplicationContext 註解,代碼就能編譯通過了,如下所示:

@Singleton
class Driver @Inject constructor(@ApplicationContext val context: Context) {
}

這種寫法 Hilt 會自動提供一個 Application 類型的 Context 給到 Truck 類當中,然後 Truck 類就可以使用這個 Context 去編寫具體的業務邏輯了。

但是如果你說,我需要的並不是 Application 類型的 Context,而是 Activity 類型的 Context。也沒有問題,Hilt 還預置了另外一種 Qualifier,我們使用 @ActivityContext 即可:

@Singleton
class Driver @Inject constructor(@ActivityContext val context: Context) {
}

不過這個時候如果你編譯一下項目,會發現報錯了。原因也很好理解,現在我們的 Driver 是 Singleton 的,也就是全局都可以使用,但是卻依賴了一個 Activity 類型的 Context,這很明顯是不可能的。

至於解決方案嘛,相信學了上一塊主題的你一定已經知道了,我們將 Driver 上方的註解改成 @ActivityScoped、@FragmentScoped、@ViewScoped,或者直接刪掉都可以,這樣再次編譯就不會報錯了。

關於預置 Qualifier 其實還有一個隱藏的小技巧,就是對於 Application 和 Activity 這兩個類型,Hilt 也是給它們預置好了注入功能。也就是說,如果你的某個類依賴於 Application 或者 Activity,不需要想辦法爲這兩個類提供依賴注入的實例,Hilt 自動就能識別它們。如下所示:

class Driver @Inject constructor(val application: Application) {
}

class Driver @Inject constructor(val activity: Activity) {
}

這種寫法編譯將可以直接通過,無需添加任何註解聲明。

注意必須是 Application 和 Activity 這兩個類型,即使是聲明它們的子類型,編譯都無法通過。

那麼你可能會說,我的項目會在自定義的 MyApplication 中提供一些全局通用的函數,導致很多地方都是要依賴於我自己編寫的 MyApplication 的,而 MyApplication 又不能被 Hilt 識別,這種情況要怎麼辦呢?

這裏我教大家一個小竅門,因爲 Application 全局只會存在一份實例,因此 Hilt 注入的 Application 實例其實就是你自定義的 MyApplication 實例,所以想辦法做一下向下類型轉換就可以了。

比如說這裏我定義了一個 ApplicationModule,代碼如下所示:

@Module
@InstallIn(ApplicationComponent::class)
class ApplicationModule {

    @Provides
    fun provideMyApplication(application: Application): MyApplication {
        return application as MyApplication
    }

}

可以看到,provideMyApplication() 函數中接收一個 Application 參數,這個參數 Hilt 是自動識別的,然後我們將其向下轉型成 MyApplication 即可。

接下來你在 Truck 類中就可以去這樣聲明依賴了:

class Driver @Inject constructor(val application: MyApplication) {
}

完美解決。

到目前爲止,你已經將 Hilt 中幾乎所有的重要知識點都學習完了。

做事情講究有始有終,讓我們回到開始時候的一個話題:在 MVVM 架構中,倉庫層的實例到底應該由誰來創建?

這個問題現在你有更好的答案了嗎?

我在學完 Hilt 之後,這個問題就已經釋懷了。很明顯,根據 MVVM 的架構示意圖,ViewModel 層只是依賴於倉庫層,它並不關心倉庫的實例是從哪兒來的,因此由 Hilt 去管理倉庫層的實例創建再合適不過了。

至於具體該如何實現,我總結下來大概有兩種方式,這裏分別跟大家演示一下。

注意,以下代碼只是做了 MVVM 架構中與依賴注入相關部分的演示,如果你還沒有了解過 MVVM 架構,或者沒有了解過 Jetpack 組件,可能會看不懂下面的代碼。這部分朋友建議先去參考 《第一行代碼 Android 第 3 版》的第 13 和第 15 章。

第一種方式就是純粹利用我們前面所學過的知識自己手寫。

比如說我們有一個 Repository 類用於表示倉庫層:

class Repository @Inject constructor() {
    ...
}

由於 Repository 要依賴注入到 ViewModel 當中,所以我們需要給 Repository 的構造函數加上 @Inject 註解。

然後有一個 MyViewModel 繼承自 ViewModel,用於表示 ViewModel 層:

@ActivityRetainedScoped
class MyViewModel @Inject constructor(val repository: Repository) : ViewModel() {
    ...
}

這裏注意以下三點。

第一,MyViewModel 的頭部要爲其聲明 @ActivityRetainedScoped 註解,參照剛纔組件作用域那張表,我們知道這個註解就是專門爲 ViewModel 提供的,並且它的生命週期也和 ViewModel 一致。

第二,MyViewModel 的構造函數中要聲明 @Inject 註解,因爲我們在 Activity 中也要使用依賴注入的方式獲得 MyViewModel 的實例。

第三,MyViewModel 的構造函數中要加上 Repository 參數,表示 MyViewModel 是依賴於 Repository 的。

接下來就很簡單了,我們在 MainActivity 中通過依賴注入的方式得到 MyViewModel 的實例,然後像往常一樣的方式去使用它就可以了:

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

    @Inject
    lateinit var viewModel: MyViewModel
    ...

}

這種方式雖然可以正常工作,但有個缺點是,我們改變了獲取 ViewModel 實例的常規方式。本來我只是想對 Repository 進行依賴注入的,現在連 MyViewModel 也要跟着一起依賴注入了。

爲此,對於 ViewModel 這種常用 Jetpack 組件,Hilt 專門爲其提供了一種獨立的依賴注入方式,也就是我們接下來要介紹的第二種方式了。

這種方式我們需要在 app/build.gradle 文件中添加兩個額外的依賴:

dependencies {
    ...
    implementation 'androidx.hilt:hilt-lifecycle-viewmodel:1.0.0-alpha02'
    kapt 'androidx.hilt:hilt-compiler:1.0.0-alpha02'
}

然後修改 MyViewModel 中的代碼,如下所示:

class MyViewModel @ViewModelInject constructor(val repository: Repository) : ViewModel() {
    ...
}

注意這裏的變化,首先 @ActivityRetainedScoped 這個註解不見了,因爲我們不再需要它了。其次,@Inject 註解變成了 @ViewModelInject 註解,從名字上就可以看出,這個註解是專門給 ViewModel 使用的。

現在回到 MainActivity 當中,你就不再需要使用依賴注入的方式去獲取 MyViewModel 的實例了,而是完全按照常規的寫法去獲取即可:

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

    val viewModel: MyViewModel by lazy { ViewModelProvider(this).get(MyViewModel::class.java) }
    ...

}

看上去和我們平時使用 ViewModel 時的寫法完全無二,這都是由 Hilt 在背後幫我們施了神奇的魔法。

需要注意的是,這種寫法下,雖然我們在 MainActivity 裏沒有使用依賴注入功能,但是 @AndroidEntryPoint 這個註解仍然是不能少的。不然的話,在編譯時期 Hilt 確實檢測不出來語法上的異常,一旦到了運行時期,Hilt 找不到入口點就無法執行依賴注入了。

在最開始學習 Hilt 的時候,我就提到了,Hilt 一共支持 6 個入口點,分別是:

  • Application
  • Activity
  • Fragment
  • View
  • Service
  • BroadcastReceiver

之所以做這樣的設定,是因爲我們的程序基本都是由這些入口點出發的。

比如一個 Android 程序肯定不可能憑空從 Truck 類開始執行代碼,而一定要從上述的某個入口點開始執行,然後才能輾轉執行到 Truck 類中的代碼。

但是不知道你有沒有發現,Hilt 支持的入口點中少了一個關鍵的 Android 組件:ContentProvider。

我們都知道,ContentProvider 是四大組件之一,並且它也是可以稱之爲一個入口點的,因爲代碼可以從這裏開始直接運行,而並不需要經過其他類的調用才能到達它。

那麼爲什麼 Hilt 支持的入口點中不包括 ContentProvider 呢?這個問題我也很疑惑,所以在上次的上海 GDG 圓桌會議上,我將這個問題直接提給了 Yigit Boyar,畢竟他在 Google 是專門負責 Jetpack 項目的。

當然我也算得到了一個比較滿意的回答,主要原因就是 ContentProvider 的生命週期問題。如果你比較瞭解 ContentProvider 的話,應該知道它的生命週期是比較特殊的,它在 Application 的 onCreate() 方法之前就能得到執行,因此很多人會利用這個特性去進行提前初始化,詳見 Jetpack 新成員,App Startup 一篇就懂 這篇文章。

而 Hilt 的工作原理是從 Application 的 onCreate() 方法中開始的,也就是說在這個方法執行之前,Hilt 的所有功能都還無法正常工作。

也正是因爲這個原因,Hilt 纔沒有將 ContentProvider 納入到支持的入口點當中。

不過,即使 ContentProvider 並不是入口點,我們仍然還有其他辦法在其內部使用依賴注入功能,只是要稍微麻煩一點。

首先可以在 ContentProvider 中自定義一個自己的入口點,並在其中定義好要依賴注入的類型,如下所示:

class MyContentProvider : ContentProvider() {

    @EntryPoint
    @InstallIn(ApplicationComponent::class)
    interface MyEntryPoint {
        fun getRetrofit(): Retrofit
    }
    ...

}

可以看到,這裏我們定義了一個 MyEntryPoint 接口,然後在其上方使用 @EntryPoint 來聲明這是一個自定義入口點,並用 @InstallIn 來聲明其作用範圍。

接着我們在 MyEntryPoint 中定義了一個 getRetrofit() 函數,並且函數的返回類型就是 Retrofit。

而 Retrofit 是我們已支持依賴注入的類型,這個功能早在 NetworkModule 當中就已經完成了。

現在,如果我們想要在 MyContentProvider 的某個函數中獲取 Retrofit 的實例(事實上,ContentProvider 中不太可能會用到網絡功能,這裏只是舉例),只需要這樣寫就可以了:

class MyContentProvider : ContentProvider() {

    ...
    override fun query(...): Cursor {
        context?.let {
            val appContext = it.applicationContext
            val entryPoint = EntryPointAccessors.fromApplication(appContext, MyEntryPoint::class.java)
            val retrofit = entryPoint.getRetrofit()
        }
        ...
    }

}

藉助 EntryPointAccessors 類,我們調用其 fromApplication() 函數來獲得自定義入口點的實例,然後再調用入口點中定義的 getRetrofit() 函數就能得到 Retrofit 的實例了。

不過我認爲,自定義入口點這個功能在實際開發當中並不常用,這裏只是考慮知識完整性的原因,所以將這塊內容也加入了進來。

到這裏,這篇文章總算是結束了。

不愧稱它是一篇我自己都怕的文章,這篇文章大概花了我半個月左右的時間,可能是我寫過的最長的一篇文章。

由於 Hilt 涉及的知識點繁多,即使它將 Dagger2 的用法進行了大幅的簡化,但如果你之前對於依賴注入完全沒有了解,直接上手 Hilt 相信還是會有不少的困難。

我在本文當中儘可能地將 “什麼是依賴注入,爲什麼要使用依賴注入,如何使用依賴注入” 這幾個問題描述清楚了,但介於依賴注入這個話題本身複雜度的客觀原因,我也不知道本文的難易程度到底在什麼等級。希望閱讀過的讀者朋友們都能達到掌握 Hilt,並用好 Hilt 的水平吧。

另外,由於 Hilt 和 Dagger2 的關係過於緊密,我們在本文中所學的知識,有些是 Hilt 提供的,有些是 Dagger2 本身就自帶。但是我對此在文中並沒有進行嚴格的區分,統一都是以 Hilt 的視角去講的。所以,熟悉 Dagger2 的朋友請不要覺得文中的說法不夠嚴謹,因爲太過嚴謹的話可能會增加沒有學過 Dagger2 這部分讀者朋友的理解成本。

最後,我將本文中用到的一些代碼示例,寫成了一個 Demo 程序上傳到了 GitHub 上,有需要的朋友直接去下載源碼即可。

github.com/guolindev/H…

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章