配合分支:day18/start,Repo: 按我
每次測試寫入的照片日記,就需要真的將手機裡頭的資料一清再清。今天針對這個部分做補強,同時引入新的觀念。例如將程式碼「抽象化」,以及把「資料流」弄得整齊。
過去的程式碼一直有個奇怪之處,就是在 View 層(也就是 ComposeView)那邊建立 LocalPhotoStore 存取本機的照片,本身就很奇怪。
所以,總共有兩件事需要處理:
說到抽象化,就必須要題 interface (翻譯應該是:介面)。可以定義一個 interface,裡頭有預先定義好的 Function。外部使用這個 interface 的人,不用管後面到底是怎麼運作的,只需要知道 interface 會給預期的行為就可以了。
比如說這個照片存取器,可以建立一個 interface:
先建立一個 DiaryStore.kt 新檔案。
interface DiaryStore {
fun load(): List<DiaryEntry>?
fun save(entries: List<DiaryEntry>)
}
interface 就是這麼簡單。這算是契約,不實作內容。之前介紹過繼承,實作 interface 寫法很類似。例如,將 LocalDiaryStorage 改為實作這個 DiaryStore。
//👇🏼 加入 DiaryStore 在冒號後面
class LocalDiaryStorage(context: Context) : DiaryStore {
}
接下來就需要依照契約規定實作兩個 Function,以及同意配合兩個 Function 回傳的變數及接受的變數。
因為 LocalDiaryStorage 已有同名的變數,前面加上 override 即可。
// 👇🏼 加入
override fun save(entries: List<DiaryEntry>) {
// ... 省略
}
// 👇🏼 加入
override fun load(): List<DiaryEntry>? {
// ... 省略
}
這樣就完成啦。當然 LocalDiaryStorage 可以有其他自己的實作。只是一旦有 interface 契約,就需要實作已經定義好的內容。
契約、實作者都有以後,存取起來很方便,例如來調一下 ViewModel 存取照片的程式碼。
class PhotoDiaryViewModel(
application: Application,
private val diaryStore: DiaryStore // 👈🏼 直接丟 interface
) : AndroidViewModel(application) {
// ... 省略
}
建立 ViewModel 時,將 DiaryStore 傳進來。任何只要有實作 DiaryStore 都可以塞進去。換句話說不只 LocalDiaryStorage ,任何人只要實作 DiaryStore 都符合條件。
為啥?呼叫 DiaryStore 時不用管後面是誰、裡面怎麼實作,只要知道「叫了這個會給什麼(甚至不回覆)」就好了。
把呼叫的地方改成:
diaryStore.save(entries)
val loadedEntries = diaryStore.load()
像是 load() 已經承諾會回覆 List<DiaryEntry>? 那也不用管真正實作的人,背後怎麼跑的了,反正拿到東西就好。
這種從建構子把內部其他區域需要的 class 放入的行為,就稱為 Dependency Injection (中文是依賴注入),本例從「建構子注入」也稱為 Constructor injection。不只建構子可以注入,其實也可以從 properties,或是做一個 setter function 來進行注入。
例如:
class Ceo(val name: String) {}
class Apple {
var ceo: Ceo? = null
}
// properties injection
class OtherPlace {
fun main() {
val apple = Apple();
apple.ceo = Ceo("John Ternus")
}
}
/* 或是採用 */
class Apple {
private var ceo: Ceo? = null
fun setCeo(newCeo: Ceo) {
ceo = newCeo
}
}
// setter injection
class OtherPlace {
fun main() {
val apple = Apple();
apple.setCeo(Ceo("John Ternus"))
}
}
回到 Android。以前有說 ViewModel 是 Google 提供的工具,無法隨意的透過建構子建構出來。這邊一定要用他們提供的方法來建構。
用 Google 提供的 ViewModel 工廠來建立:

你可能覺得怎麼長得這麼奇怪,但拿人手短只能照規則建立。接著在 MainActivity 裡頭建立 ViewModel,本來是在 ComposeView 裡頭透過 viewModel() 拿:
PhotoDiaryApp(viewModel: PhotoDiaryViewModel = viewModel())
但現在需要 inject 自己的東西進去,只好回到 MainActivity,在 MainActivity 建立 ViewModel。(透過工廠)

在測試裡頭不用走 Google 的工廠,直接用建構子就行了。(畢竟測試只需要 ViewModel,不用和其他 Android 元件互動)。
這裡先將準備階段調整成這樣:
val viewModel = PhotoDiaryViewModel(application(), LocalDiaryStorage(application()))
現階段還是測試到真的寫到系統的 JSON 文件,接著來調整把測試用的 DiaryStorage 改成測試假資料。
配合分支:day18/dependency-injected,Repo: 按我
PhotoDiaryViewModel 支援 DiaryStore interface 這個契約了,就可以直接在測試建立一個假的。

注意到所有的儲存和讀取,都是在這個 FakeDiaryStore 自帶的 List 裡頭完成。
測試依然不用大改,只要改 inject 的東西就行了。
val diaryStore = FakeDiaryStore()
// 👇🏼 塞入這個假資料的 FakeDiaryStore
val viewModel = PhotoDiaryViewModel(application(), diaryStore)
如果想驗證,到底是不是設定這個假的 DiaryStore,也能寫測試驗證(想不到吧)。原來在 ViewModel 執行時,會設定預設的 Sample 日記,但這個假的 FakeDiaryStore 會是空白的。
可以加入以下測試來驗證:

測試通過也代表:的確是使用這個假資料當測試的基底囉。
資料流也更新成:

雖然是 ViewModel 抓著 LocalPhotoStore,但資料的確是由 LocalPhotoStore 給 ViewModel 後,UI 再隨著資料做更新。
讀者可能聽過這個原則,在本例中 PhotoDiaryViewModel 拿著一個 DiaryStore,但卻可以在 Android App 以及 Unit Test 中使用不同的 class 來達成目的。
文中有兩個 DiaryStore 實作者:
一個存取 Android 本機 JSON 檔案讀取寫入,一個則使用記憶體裡頭的 List。兩者都有共同的 Function (因為 interface),呼叫後行為與契約相符。例如該回覆 List<DiaryEntry> 就會回覆,該把日記加到某個儲存空間就真的會加。
但同時他們彼此又各自有自身獨立的實作,像是 LocalDiaryStorage 還能存取 Android 的 Context。雖然如此,兩者又可以彼此替換。
同樣舉本例中 PhotoDiaryViewModel 拿著一個 DiaryStore 為例,如果未來需要修改支援將日記存到 Google 雲端硬碟。則只需要依照 DiaryStore 為契約,再實作一個 GoogleDrivePhotoDiaryStorage 即可。
又或者需要支援外部記憶卡,則依照 DiaryStore 為契約,實作 ExternalStoragePhotoDiaryStorage,反正 ViewModel 用到的也就是 DiaryStore 提供的功能,一行都不用改。
因為這邊 ViewModel 需要的是 DiaryStore interface,它不管實作細節,裡面怎麼運作都不管,反正只要依照 interface 給對應的資料,做對應的行為就好。故 ViewModel 依賴的是 interface 而非某個實作本身。
LocalDiaryStorage 還能存取 Android 的 Context。override:實作 interface 時,既有同名 Function 前面加上 override,表示「我是來完成契約的版本」。DiaryStore 注入 ViewModel,讓它不用管後面是誰。FakeDiaryStore,不用碰到手機真實檔案,測試只換注入的東西、本體不用大改。DiaryStore 這個抽象而非某個實作;未來要支援 Google 雲端或外部記憶卡,只要新增實作者,ViewModel 一行都不用改。就必須要題 interface(X)
就必須要提 interface(O)
原來是人類寫的,力害
欸 我是認真覺得是 題 哎,其實我有用 LLM 掃過,也知道有這個提議,但我認真覺得是 題
題目就寫 手作り 了,有手作溫度吧