新增存放區和手動 DI

1. 事前準備

說明

在先前的程式碼研究室中,您已瞭解如何透過 ViewModel 使用 API 服務從網路擷取火星相片的網址,以便從 Web 服務取得資料。雖然這種方法有效且實作簡單,但並不能隨著應用程式業務拓展而擴充,需要採用多個資料來源。為解決這個問題,Android 架構最佳做法建議將 UI 層和資料層分開。

在這個程式碼研究室中,您會將 Mars Photos 應用程式重構為獨立的 UI 層和資料層。您將瞭解如何實作存放區模式,並使用依附元件插入功能。使用依附元件插入功能可建立更有彈性的程式設計結構,協助進行開發和測試。

必要條件

課程內容

  • 存放區模式
  • 插入依附元件

建構項目

  • 修改 Mars Photos 應用程式,將應用程式分為 UI 層和資料層。
  • 在劃分資料層時,您會實作存放區模式。
  • 使用依附元件插入功能建立鬆耦合的程式碼集。

軟硬體需求

  • 電腦需搭載新版網路瀏覽器,例如最新版 Chrome

取得範例程式碼

如要開始使用,請先下載範例程式碼:

或者,您也可以複製 GitHub 存放區的程式碼:

$ git clone https://github.com/google-developer-training/basic-android-kotlin-compose-training-mars-photos.git
$ cd basic-android-kotlin-compose-training-mars-photos
$ git checkout repo-starter

您可以瀏覽 Mars Photos GitHub 存放區中的程式碼。

2. 區隔 UI 層和資料層

為什麼要使用不同的層?

將程式碼分成不同的層後,應用程式不僅會更具擴充性,還可以穩定而又輕鬆地進行測試。若多個層都有明確定義的界線,則還能方便多個開發人員開發同一款應用程式,而不會互相干擾。

Android 的建議應用程式架構指出應用程式至少應具備 UI 層和資料層。

在本程式碼研究室中,您會專注於資料層並做出變更,以便應用程式遵循建議的最佳做法。

什麼是資料層?

資料層負責處理應用程式的商業邏輯,並為應用程式取得及儲存資料。資料層會使用單向資料流模式向 UI 層公開資料。資料來源可能包括網路要求、本機資料庫或裝置上的檔案等。

一個應用程式甚至可能有多個資料來源。應用程式開啟時,系統會從裝置上的本機資料庫擷取資料 (也就是第一個來源)。應用程式執行期間,系統會向第二個來源發出網路要求,以擷取較新的資料。

將資料放在與 UI 程式碼不同的層中,即可對程式碼的一部分進行變更,而不會影響程式碼的其他部分。這種方法屬於關注點分離的設計原則。某部分程式碼專注於自己的關注點,並從其他程式碼封裝內部工作。封裝是一種隱藏形式,可隱藏程式碼在程式碼其他部分的內部運作方式。當某部分程式碼需要與另一部分程式碼互動時,就會透過介面執行封裝作業。

UI 層的關注點在於顯示所提供的資料。由於資料是資料層的關注點,UI 不再擷取資料。

資料層由一個或多個存放區組成。存放區本身包含零或多個資料來源。

dbf927072d3070f0.png

最佳做法是要求應用程式對所用的每種資料來源類型,都配有一個存放區。

在本程式碼研究室中,該應用程式具有一個資料來源,因此在重構程式碼後,會有一個存放區。就該應用程式而言,從網際網路擷取資料的存放區會完成資料來源的職責。方法是向 API 發出網路要求。如果資料來源程式設計作業較複雜或新增其他資料來源,資料來源職責就會封裝於不同的資料來源類別中,而存放區應負責管理所有資料來源。

什麼是存放區?

一般而言,存放區類別如下:

  • 向應用程式的其餘部分公開資料。
  • 集中對資料做出變更。
  • 解決多個資料來源之間的衝突。
  • 抽象化應用程式其餘部分的資料來源。
  • 包含商業邏輯。

Mars Photos 應用程式只有一個資料來源,就是網路 API 呼叫。該應用程式只是擷取資料,因此沒有任何商業邏輯。可透過存放區類別向應用程式公開資料,而該類別會抽離資料來源。

ff7a7cd039402747.png

3. 建立資料層

首先,您需要建立存放區類別。Android 開發人員指南指出,存放區類別是以其所負責的資料命名。存放區命名慣例資料類型 + 存放區。在應用程式中則為 MarsPhotosRepository

建立存放區

  1. 在「com.example.marsphotos」上按一下滑鼠右鍵,然後依序選取「New」>「Package」
  2. 在對話方塊中輸入 data
  3. data 套件上按一下滑鼠右鍵,然後依序選取「New」>「Kotlin Class/File」
  4. 在對話方塊中選取「Interface」,然後輸入 MarsPhotosRepository 做為介面名稱。
  5. MarsPhotosRepository 介面當中,新增名為 getMarsPhotos() 的抽象函式,以傳回 MarsPhoto 物件清單。系統會從協同程式中進行呼叫,因此請使用 suspend 進行宣告。
import com.example.marsphotos.model.MarsPhoto

interface MarsPhotosRepository {
    suspend fun getMarsPhotos(): List<MarsPhoto>
}
  1. 在介面宣告下方,建立名為 NetworkMarsPhotosRepository 的類別,以實作 MarsPhotosRepository 介面。
  2. 在類別宣告中新增介面 MarsPhotosRepository

因為未覆寫介面的抽象方法,所以系統會顯示錯誤訊息。下一步將解決這個錯誤。

Android Studio 螢幕截圖,顯示 MarsPhotosRepository 介面和 NetworkMarsPhotosRepository 類別

  1. NetworkMarsPhotosRepository 類別中,覆寫抽象函式 getMarsPhotos()。這個函式會透過呼叫 MarsApi.retrofitService.getPhotos() 傳回資料。
import com.example.marsphotos.network.MarsApi

class NetworkMarsPhotosRepository() : MarsPhotosRepository {
   override suspend fun getMarsPhotos(): List<MarsPhoto> {
       return MarsApi.retrofitService.getPhotos()
   }
}

接著,您需要按照 Android 最佳做法建議來更新 ViewModel 程式碼,以使用存放區取得資料。

  1. 開啟 ui/screens/MarsViewModel.kt 檔案。
  2. 向下捲動至 getMarsPhotos() 方法。
  3. 將「val listResult = MarsApi.retrofitService.getPhotos()」這一行替換成以下程式碼:
import com.example.marsphotos.data.NetworkMarsPhotosRepository

val marsPhotosRepository = NetworkMarsPhotosRepository()
val listResult = marsPhotosRepository.getMarsPhotos()

5313985852c151aa.png

  1. 執行應用程式。請注意,顯示的結果會與先前的結果相同。

資料會從存放區提供,而不是由 ViewModel 直接發出針對資料的網路要求。ViewModel 不再直接參照 MarsApi 程式碼。流程圖,顯示之前如何直接從 Viewmodel 存取資料層,現在則有火星相片存放區

這種做法可讓程式碼擷取與 ViewModel 進行鬆耦合的資料。實現鬆耦合後,只要存放區具有名為 getMarsPhotos() 的函式,就可對 ViewModel 或存放區做出變更,同時也不會對另一方造成負面影響。

現在能夠在存放區內變更實作項目,而不會影響呼叫端。如果是大型應用程式,這項變更可以支援多個呼叫端。

4. 插入依附元件

類別往往需要其他類別的物件才能發揮作用。類別需要其他類別時,必要類別稱為依附元件

在以下範例中,Car 物件依附於 Engine 物件。

類別可透過兩種方式取得這些必要物件。其中一個方法是讓類別對必要物件本身執行個體化。

interface Engine {
    fun start()
}

class GasEngine : Engine {
    override fun start() {
        println("GasEngine started!")
    }
}

class Car {

    private val engine = GasEngine()

    fun start() {
        engine.start()
    }
}

fun main() {
    val car = Car()
    car.start()
}

另一個方法是將必要物件做為引數傳入。

interface Engine {
    fun start()
}

class GasEngine : Engine {
    override fun start() {
        println("GasEngine started!")
    }
}

class Car(private val engine: Engine) {
    fun start() {
        engine.start()
    }
}

fun main() {
    val engine = GasEngine()
    val car = Car(engine)
    car.start()
}

類別對必要物件執行個體化非常簡單,但由於此類別及必要物件屬於緊耦合,使用此方法會讓程式碼缺乏彈性,且較難測試。

呼叫類別需要呼叫物件的建構函式,也就是實作詳細資料。如果建構函式有所變更,則呼叫程式碼也需要變更。

為了讓程式碼更有彈性,方便調整,類別不得對自己的相依物件執行個體化。相依物件必須在類別外執行個體化,然後傳入。由於類別無法再以硬式編碼的方式寫入特定物件,使用這個方法會建立更有彈性的程式碼。必要物件的實作方式可能會變更,無需修改呼叫程式碼。

延續上例,如果需要 ElectricEngine,可以建立並傳遞至 Car 類別。Car 類別不需要以任何方式進行修改。

interface Engine {
    fun start()
}

class ElectricEngine : Engine {
    override fun start() {
        println("ElectricEngine started!")
    }
}

class Car(private val engine: Engine) {
    fun start() {
        engine.start()
    }
}

fun main() {
    val engine = ElectricEngine()
    val car = Car(engine)
    car.start()
}

傳入必要物件稱為「依附元件插入」(DI),也稱為控制反轉

DI 是指在執行階段提供依附元件,而不是以硬式編碼的方式寫入呼叫類別。

實作依附元件插入:

  • 有助於重複使用程式碼。程式碼不依附於特定物件,因此更具彈性。
  • 簡化重構程序。程式碼為鬆耦合,因此重構某部分程式碼並不會影響另一部分的程式碼。
  • 有助於測試。測試物件可在測試期間傳入。

例如,測試網路呼叫程式碼時,便可瞭解到 DI 如何有助於測試。在本測試中,您其實正在嘗試測試網路呼叫是否已發起,以及資料是否已傳回。若在測試期間每次發出網路要求時都必須付費,則您可能會因昂貴的費用而決定略過測試該程式碼。現在,想想是否能偽造網路要求以進行測試。這又會給您增加多少快樂 (以及財富) 呢?為了進行測試,您可以將測試物件傳遞至存放區,該存放區會在呼叫時傳回假資料,而不會實際執行真實的網路呼叫。1ea410d6670b7670.png

我們希望將 ViewModel 設為可測試,但目前取決於進行實際網路呼叫的存放區。在使用真實的生產存放區進行測試時,系統會發出許多網路呼叫。如要解決這個問題,必須採用一種方法來決定和傳遞用於動態生產及測試的存放區執行個體,而非使用 ViewModel 建立存放區。

藉由實作向 MarsViewModel 提供存放區的應用程式容器,即可完成此程序。

容器是指包含應用程式所需依附元件的物件。這些依附元件用於整個應用程式,因此必須位於所有活動均可使用的通用位置。您可以建立「應用程式」類別的子類別,並儲存對容器的引用。

建立應用程式容器

  1. data 套件上按一下滑鼠右鍵,然後依序選取「New」>「Kotlin Class/File」
  2. 在對話方塊中選取「Interface」,然後輸入 AppContainer 做為介面名稱。
  3. AppContainer 介面中,新增名為 marsPhotosRepositoryMarsPhotosRepository 類型抽象屬性。7ed26c6dcf607a55.png
  4. 在介面定義下方,建立名為 DefaultAppContainer 的類別來實作介面 AppContainer
  5. network/MarsApiService.kt 中,將變數 BASE_URLretrofitretrofitService 的程式碼移至 DefaultAppContainer 類別,以便這些變數均位於維護依附元件的容器內。
import retrofit2.Retrofit
import com.example.marsphotos.network.MarsApiService
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType

class DefaultAppContainer : AppContainer {

    private const val BASE_URL =
        "https://android-kotlin-fun-mars-server.appspot.com"

    private val retrofit: Retrofit = Retrofit.Builder()
        .addConverterFactory(Json.asConverterFactory("application/json".toMediaType()))
        .baseUrl(BASE_URL)
        .build()

    private val retrofitService: MarsApiService by lazy {
        retrofit.create(MarsApiService::class.java)
    }

}
  1. 若是變數 BASE_URL,請移除 const 關鍵字。現在 BASE_URL 不再是頂層變數,而是 DefaultAppContainer 類別的屬性,因此必須移除 const,並重構為駝峰式大小寫的 baseUrl
  2. 若是變數 retrofitService,請新增 private 瀏覽權限修飾符。之所以要新增 private 修飾符,是因為變數 retrofitService 只能透過屬性 marsPhotosRepository 在類別中使用,不必在類別外開放存取。
  3. DefaultAppContainer 類別會實作 AppContainer 介面,因此我們需要覆寫 marsPhotosRepository 屬性。在 retrofitService 變數之後,新增下列程式碼:
override val marsPhotosRepository: MarsPhotosRepository by lazy {
    NetworkMarsPhotosRepository(retrofitService)
}

完成的 DefaultAppContainer 類別應如下所示:

class DefaultAppContainer : AppContainer {

    private val baseUrl =
        "https://android-kotlin-fun-mars-server.appspot.com"

    /**
     * Use the Retrofit builder to build a retrofit object using a kotlinx.serialization converter
     */
    private val retrofit = Retrofit.Builder()
        .addConverterFactory(Json.asConverterFactory("application/json".toMediaType()))
        .baseUrl(baseUrl)
        .build()

    private val retrofitService: MarsApiService by lazy {
        retrofit.create(MarsApiService::class.java)
    }

    override val marsPhotosRepository: MarsPhotosRepository by lazy {
        NetworkMarsPhotosRepository(retrofitService)
    }
}
  1. 開啟 data/MarsPhotosRepository.kt 檔案。我們目前已將 retrofitService 傳遞至 NetworkMarsPhotosRepository,且需要修改 NetworkMarsPhotosRepository 類別。
  2. NetworkMarsPhotosRepository 類別宣告中加入建構函式參數 marsApiService,如以下程式碼所示。
import com.example.marsphotos.network.MarsApiService

class NetworkMarsPhotosRepository(
    private val marsApiService: MarsApiService
) : MarsPhotosRepository {
  1. NetworkMarsPhotosRepository 類別的 getMarsPhotos() 函式中,變更回傳敘述,從 marsApiService 擷取資料。
override suspend fun getMarsPhotos(): List<MarsPhoto> = marsApiService.getPhotos()
}
  1. MarsPhotosRepository.kt 檔案移除以下匯入作業。
// Remove
import com.example.marsphotos.network.MarsApi

我們已從 network/MarsApiService.kt 檔案中,將所有程式碼移出物件。現在可以刪除剩下不再需要的物件宣告。

  1. 刪除下列程式碼:
object MarsApi {

}

5. 將應用程式容器附加至應用程式

本節中的步驟會將應用程式物件連結至應用程式容器,如下圖所示。

92e7d7b79c4134f0.png

  1. com.example.marsphotos 上按一下滑鼠右鍵,然後依序選取「New」>「Kotlin Class/File」
  2. 在對話方塊中輸入 MarsPhotosApplication。此類別繼承自應用程式物件,因此必須將該類別新增至類別宣告。
import android.app.Application

class MarsPhotosApplication : Application() {
}
  1. MarsPhotosApplication 類別中,宣告類型為 AppContainercontainer 變數,以儲存 DefaultAppContainer 物件。該變數會在呼叫 onCreate() 時進行初始化,因此必須使用 lateinit 修飾符標示該變數。
import com.example.marsphotos.data.AppContainer
import com.example.marsphotos.data.DefaultAppContainer

lateinit var container: AppContainer
override fun onCreate() {
    super.onCreate()
    container = DefaultAppContainer()
}
  1. 完整的 MarsPhotosApplication.kt 檔案應類似於以下程式碼:
package com.example.marsphotos

import android.app.Application
import com.example.marsphotos.data.AppContainer
import com.example.marsphotos.data.DefaultAppContainer

class MarsPhotosApplication : Application() {
    lateinit var container: AppContainer
    override fun onCreate() {
        super.onCreate()
        container = DefaultAppContainer()
    }
}
  1. 必須更新 Android 資訊清單,以便應用程式使用您剛定義的應用程式類別。開啟 manifests/AndroidManifest.xml 檔案。

759144e4e0634ed8.png

  1. application 區段中新增 android:name 屬性,並將值設為應用程式類別名稱 ".MarsPhotosApplication"
<application
   android:name=".MarsPhotosApplication"
   android:allowBackup="true"
...
</application>

6. 將存放區新增至 ViewModel

完成這些步驟後,ViewModel 可以呼叫存放區物件來擷取 Mars 資料。

7425864315cb5e6f.png

  1. 開啟 ui/screens/MarsViewModel.kt 檔案。
  2. MarsViewModel 的類別宣告中,新增類型為 MarsPhotosRepository 的私人建構函式參數 marsPhotosRepository。應用程式現已使用依附元件插入,因此建構函式參數的值取自應用程式容器。
import com.example.marsphotos.data.MarsPhotosRepository

class MarsViewModel(private val marsPhotosRepository: MarsPhotosRepository) : ViewModel(){
  1. getMarsPhotos() 函式中,建構函式呼叫中現已填入 marsPhotosRepository,因此移除以下這行程式碼。
val marsPhotosRepository = NetworkMarsPhotosRepository()
  1. 由於 Android 架構不允許在建立時於建構函式中向 ViewModel 傳遞值,我們實作 ViewModelProvider.Factory 物件以規避此限制。

工廠模式是一種用來建立物件的建立模式。MarsViewModel.Factory 物件會使用應用程式容器擷取 marsPhotosRepository,然後在建立 ViewModel 物件時將這個存放區傳遞至 ViewModel

  1. getMarsPhotos() 函式下方,輸入隨附物件的程式碼。

隨附物件會利用可供所有人使用之物件的單一執行個體提供幫助,而無須建立成本較高之物件的新執行個體。這是實作詳細資料,區隔後便可進行變更,而不會影響應用程式的程式碼其他部分。

APPLICATION_KEY 屬於 ViewModelProvider.AndroidViewModelFactory.Companion 物件,可尋找應用程式的 MarsPhotosApplication 物件,而該物件具有的 container 屬性可擷取插入依附元件時所使用的存放區。

import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.example.marsphotos.MarsPhotosApplication

companion object {
   val Factory: ViewModelProvider.Factory = viewModelFactory {
       initializer {
           val application = (this[APPLICATION_KEY] as MarsPhotosApplication)
           val marsPhotosRepository = application.container.marsPhotosRepository
           MarsViewModel(marsPhotosRepository = marsPhotosRepository)
       }
   }
}
  1. 開啟 MarsPhotosApp() 函式中的 theme/MarsPhotosApp.kt 檔案,並更新 viewModel() 以使用工廠。
Surface(
            // ...
        ) {
            val marsViewModel: MarsViewModel =
   viewModel(factory = MarsViewModel.Factory)
            // ...
        }

這個 marsViewModel 變數是由對 viewModel() 函式的呼叫填入,該函式是由伴生物件中的 MarsViewModel.Factory 以引數形式傳遞,藉此建立 ViewModel

  1. 執行應用程式,確認應用程式仍可正常運作。

恭喜您重構 Mars Photos 應用程式,讓此應用程式得以使用存放區和依附元件插入功能!實作搭配存放區的資料層後,UI 和資料來源程式碼隨即分開,以符合 Android 最佳做法。

使用依附元件插入功能後,可以更輕鬆地測試 ViewModel。應用程式現在更具彈性、更穩定,且可供擴充。

完成這些改進後,接下來就要瞭解如何測試。測試會確保程式碼的運作符合預期,並盡可能避免您在繼續處理程式碼時發生錯誤。

7. 設定本機測試

在之前的章節中,您已實作一個存放區,以便將與 REST API 服務的直接互動從 ViewModel 中抽離出來。這種做法可讓您測試用途有限的小段程式碼。相較於為具有多項功能的大段程式碼撰寫的測試,功能有限的小段程式碼的測試更容易建構、實作及理解。

您還可以善用介面、繼承和依附元件插入功能來實作存放區。在接下來的幾節中,您將瞭解這些架構最佳做法為何能夠簡化測試。此外,您已使用 Kotlin 協同程式發出網路要求。測試使用協同程式的程式碼時,需要完成額外步驟來考慮非同步執行程式碼。我們稍後會在本程式碼研究室中說明相關步驟。

新增本機測試依附元件

將下列依附元件新增至 app/build.gradle.kts

testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.1")

建立本機測試目錄

  1. 在專案檢視畫面中的「src」目錄上按一下滑鼠右鍵,然後依序選取「New」>「Directory」>「test/java」,即可建立本機測試目錄。
  2. 在名為 com.example.marsphotos 的測試目錄中建立新套件。

8. 為測試建立假資料和依附元件

在本節中,您將瞭解依附元件插入如何協助您編寫本機測試。先前在程式碼研究室中,您建立了依附於 API 服務的存放區。接著,您修改了 ViewModel 以依附於存放區。

每個本機測試只需要測試一個項目。舉例來說,在測試檢視畫面模型的功能時,您不希望測試存放區或 API 服務的功能。同樣地,測試存放區時,您不想測試 API 服務。

使用介面,隨後使用依附元件插入來納入繼承自這些介面的類別,即可使用專為測試用途而建立的假類別,來模擬這些依附元件的功能。插入假類別和資料來源進行測試,不僅可以獨立地進行程式碼測試,還具備可重複性和一致性。

首先,您需要在稍後建立的假類別中使用假資料。

  1. 在測試目錄中,在 com.example.marsphotos 下建立名為 fake 的套件。
  2. fake 目錄中建立名為 FakeDataSource 的新 Kotlin 物件。
  3. 在這個物件中,建立設為 MarsPhoto 物件清單的屬性。清單不必很長,但至少應包含兩個物件。
object FakeDataSource {

   const val idOne = "img1"
   const val idTwo = "img2"
   const val imgOne = "url.1"
   const val imgTwo = "url.2"
   val photosList = listOf(
       MarsPhoto(
           id = idOne,
           imgSrc = imgOne
       ),
       MarsPhoto(
           id = idTwo,
           imgSrc = imgTwo
       )
   )
}

先前在本程式碼研究室中有所提及,存放區依附於 API 服務。如要建立存放區測試,必須有假 API 服務,以便傳回您剛建立的假資料。若將這個假 API 服務傳遞至存放區,在呼叫假 API 服務的方法時,存放區會收到假資料。

  1. fake 套件中,建立名為 FakeMarsApiService 的新類別。
  2. 設定繼承自 MarsApiService 介面的 FakeMarsApiService 類別。
class FakeMarsApiService : MarsApiService {
}
  1. 覆寫 getPhotos() 函式。
override suspend fun getPhotos(): List<MarsPhoto> {
}
  1. getPhotos() 方法傳回假相片清單。
override suspend fun getPhotos(): List<MarsPhoto> {
   return FakeDataSource.photosList
}

請注意,如果您還是不清楚本類別的用途,沒關係!我們會在下一節中對這個假類別的用法進行詳細說明。

9. 編寫存放區測試

在本節中,您將測試 NetworkMarsPhotosRepository 類別的 getMarsPhotos() 方法。本節會說明假類別的使用情形,並示範如何測試協同程式。

  1. 在假目錄中,建立名為 NetworkMarsRepositoryTest 的新類別。
  2. 在剛建立的類別中建立名為 networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList() 的新方法,並使用 @Test 加上註解。
@Test
fun networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList(){
}

如要測試存放區,您需要 NetworkMarsPhotosRepository 的執行個體。提醒您,此類別依附於 MarsApiService 介面。您可以在這裡運用上一節中的假 API 服務。

  1. 建立 NetworkMarsPhotosRepository 的執行個體,並將 FakeMarsApiService 做為 marsApiService 參數傳遞。
@Test
fun networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList(){
    val repository = NetworkMarsPhotosRepository(
       marsApiService = FakeMarsApiService()
    )
}

傳遞假的 API 服務後,對存放區中 marsApiService 屬性的任何呼叫都會導致對 FakeMarsApiService 進行呼叫。透過傳遞依附元件的假類別,您可完全控制依附元件傳回的內容。這個方法可確保您要測試的程式碼不依附於未經測試的程式碼,或是可以變更或無法預測問題的 API。在這種情況下,即使您編寫的程式碼沒有任何錯誤,也可能會導致測試失敗。偽造有助於建立更一致的測試環境,減少測試不穩定性,並簡化測試單一功能。

  1. 宣告 getMarsPhotos() 方法傳回的資料等於 FakeDataSource.photosList
@Test
fun networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList(){
    val repository = NetworkMarsPhotosRepository(
       marsApiService = FakeMarsApiService()
    )assertEquals(FakeDataSource.photosList, repository.getMarsPhotos())
}

請注意,在 IDE 中,getMarsPhotos() 方法呼叫會以紅色底線標示。

2bd5f8999e0f3ec2.png

將滑鼠游標懸停在方法上,系統會顯示工具提示,指出「Suspend function ‘getMarsPhotos' should be called only from a coroutine or another suspend function」:

d2d3b6d770677ef6.png

data/MarsPhotosRepository.kt 中查看 NetworkMarsPhotosRepositorygetMarsPhotos() 實作項目時,會發現 getMarsPhotos() 函式是暫停函式。

class NetworkMarsPhotosRepository(
   private val marsApiService: MarsApiService
) : MarsPhotosRepository {
   /** Fetches list of MarsPhoto from marsApi*/
   override suspend fun getMarsPhotos(): List<MarsPhoto> = marsApiService.getPhotos()
}

請記住,您從 MarsViewModel 呼叫這個函式時,是從傳遞至 viewModelScope.launch() 的 lambda 呼叫協同程式,進而呼叫此方法。您也必須在測試中從協同程式中呼叫暫停函式,例如 getMarsPhotos()。但方法有所不同。下一節將討論如何解決這個問題。

測試協同程式

在本節中,您會修改 networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList() 測試,因此測試方法內文會從協同程式中執行。

  1. NetworkMarsRepositoryTest.kt 中的 networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList() 函式修改為運算式。
@Test
fun networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList() =
  1. 將運算式設為等於 runTest() 函式。這個方法需要使用 lambda。
...
import kotlinx.coroutines.test.runTest
...

@Test
fun networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList() =
    runTest {}

協同程式測試程式庫提供 runTest() 函式。這個函式會採用您在 lambda 中傳遞的方法,並從繼承自 CoroutineScopeTestScope 執行該方法。

  1. 將測試函式的內容移至 lambda 函式。
@Test
fun networkMarsPhotosRepository_getMarsPhotos_verifyPhotoList() =
   runTest {
       val repository = NetworkMarsPhotosRepository(
           marsApiService = FakeMarsApiService()
       )
       assertEquals(FakeDataSource.photosList, repository.getMarsPhotos())
   }

請注意,getMarsPhotos() 下方的紅線現已消失。如果執行這項測試,測試就會通過!

10. 編寫 ViewModel 測試

在本節中,您將從 MarsViewModelgetMarsPhotos() 函式編寫測試。MarsViewModel 依附於 MarsPhotosRepository。因此,如要編寫這項測試,則必須建立假的 MarsPhotosRepository。此外,除了使用 runTest() 方法之外,協同程式還需要考慮一些額外的步驟。

建立假存放區

這個步驟旨在建立一個假類別,該類別不僅繼承自 MarsPhotosRepository 介面,還會覆寫 getMarsPhotos() 函式以傳回假資料。這個方法與假 API 服務採取的做法類似,不同之處在於這個類別擴充 MarsPhotosRepository 介面,而不是 MarsApiService

  1. fake 目錄中建立名為 FakeNetworkMarsPhotosRepository 的新類別。
  2. 使用 MarsPhotosRepository 介面擴充這個類別。
class FakeNetworkMarsPhotosRepository : MarsPhotosRepository{
}
  1. 覆寫 getMarsPhotos() 函式。
class FakeNetworkMarsPhotosRepository : MarsPhotosRepository{
   override suspend fun getMarsPhotos(): List<MarsPhoto> {
   }
}
  1. getMarsPhotos() 函式傳回 FakeDataSource.photosList
class FakeNetworkMarsPhotosRepository : MarsPhotosRepository{
   override suspend fun getMarsPhotos(): List<MarsPhoto> {
       return FakeDataSource.photosList
   }
}

編寫 ViewModel 測試

  1. 建立名為 MarsViewModelTest 的新類別。
  2. 建立名為 marsViewModel_getMarsPhotos_verifyMarsUiStateSuccess() 的函式,並以 @Test 加上註解。
@Test
fun marsViewModel_getMarsPhotos_verifyMarsUiStateSuccess()
  1. 請將這個函式設定為 runTest() 方法的結果的運算式,以確保從協同程式執行測試,就像上一節所述的存放區測試一樣。
@Test
fun marsViewModel_getMarsPhotos_verifyMarsUiStateSuccess() =
    runTest{
    }
  1. runTest() 的 lambda 內文中,建立 MarsViewModel 的執行個體,並將建立的假存放區執行個體傳遞給該執行個體。
@Test
fun marsViewModel_getMarsPhotos_verifyMarsUiStateSuccess() =
    runTest{
        val marsViewModel = MarsViewModel(
            marsPhotosRepository = FakeNetworkMarsPhotosRepository()
         )
    }
  1. 宣告 ViewModel 執行個體的 marsUiState 與成功呼叫 MarsPhotosRepository.getMarsPhotos() 的結果相符。
@Test
fun marsViewModel_getMarsPhotos_verifyMarsUiStateSuccess() =
   runTest {
       val marsViewModel = MarsViewModel(
           marsPhotosRepository = FakeNetworkMarsPhotosRepository()
       )
       assertEquals(
           MarsUiState.Success("Success: ${FakeDataSource.photosList.size} Mars " +
                   "photos retrieved"),
           marsViewModel.marsUiState
       )
   }

如果嘗試依原樣執行這項測試,測試就會失敗。錯誤示例如下:

Exception in thread "Test worker @coroutine#1" java.lang.IllegalStateException: Module with the Main dispatcher had failed to initialize. For tests Dispatchers.setMain from kotlinx-coroutines-test module can be used

提醒您,MarsViewModel 會使用 viewModelScope.launch() 呼叫存放區。該指令會在預設協同程式調度工具 (稱為 Main 調度工具) 下啟動新的協同程式。Main 調度工具會納入 Android UI 執行緒。上述錯誤的原因在於單元測試不支援 Android UI 執行緒。單元測試是在工作站上執行,而非在 Android 裝置或模擬器上執行。如果本機單元測試中的程式碼參照了 Main 調度工具,系統會在執行單元測試時擲回例外狀況 (如上方的錯誤)。如要解決這個問題,您必須在執行單元測試時明確定義預設調度工具。如要瞭解操作方式,請參閱下一節。

建立測試調度工具

Main 調度工具僅適用於 UI 內容,因此必須將該調度工具替換成支援單元測試的調度工具。為此,Kotlin 協同程式程式庫提供了一個協同程式調度工具,稱為 TestDispatcher。對於任何建立新協同程式的單元測試,必須使用 TestDispatcher,而非 Main 調度工具,如同檢視畫面模型中的 getMarsPhotos() 函式一樣。

如要在所有情況下將 Main 調度工具取代為 TestDispatcher,請使用 Dispatchers.setMain() 函式。您可以使用 Dispatchers.resetMain() 函式,將執行緒調度工具重設回 Main 調度工具。為避免重複使用在每項測試中取代 Main 調度工具的程式碼,可以將程式碼擷取至 JUnit 測試規則。TestRule 可讓您控制執行測試的環境。TestRule 可能會新增更多檢查,為測試執行必要的設定或清理作業,或者觀察到測試執行作業以向其他位置回報。可在測試類別之間輕鬆共用。

建立用於編寫 TestRule 的專屬類別,以取代 Main 調度工具。如要實作自訂 TestRule,請完成下列步驟:

  1. 在名為 rules 的測試目錄中建立新套件。
  2. 在規則目錄中建立名為 TestDispatcherRule 的新類別。
  3. 使用 TestWatcher 擴充 TestDispatcherRuleTestWatcher 類別讓您能夠對測試的不同執行階段採取行動。
class TestDispatcherRule(): TestWatcher(){

}
  1. TestDispatcherRule 建立 TestDispatcher 建構函式參數。

此參數可使用不同的調度工具,例如 StandardTestDispatcher。此建構函式參數的預設值必須設為 UnconfinedTestDispatcher 物件的執行個體。UnconfinedTestDispatcher 類別繼承自 TestDispatcher 類別,並指定不得以任何特定順序執行工作。系統會自動處理協同程式,因此這種執行模式適用於簡單的測試。與 UnconfinedTestDispatcher 不同,StandardTestDispatcher 類別可完全控制協同程式執行作業。這種方法更適用於需要手動方法的複雜測試,但對於本程式碼研究室中的測試並非必要條件。

class TestDispatcherRule(
    val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {

}
  1. 這項測試規則的主要目標在於測試開始之前,將 Main 調度工具取代為測試調度工具。TestWatcher 類別的 starting() 函式會在執行特定測試之前執行。覆寫 starting() 函式。
class TestDispatcherRule(
    val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
    override fun starting(description: Description) {

    }
}
  1. 新增對 Dispatchers.setMain() 的呼叫,並做為引數傳入 testDispatcher
class TestDispatcherRule(
    val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
    override fun starting(description: Description) {
        Dispatchers.setMain(testDispatcher)
    }
}
  1. 測試執行作業完成後,可以覆寫 finished() 方法來重設 Main 調度工具。呼叫 Dispatchers.resetMain() 函式。
class TestDispatcherRule(
    val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
    override fun starting(description: Description) {
        Dispatchers.setMain(testDispatcher)
    }

    override fun finished(description: Description) {
        Dispatchers.resetMain()
    }
}

TestDispatcherRule 規則已可重複使用。

  1. 開啟 MarsViewModelTest.kt 檔案。
  2. MarsViewModelTest 類別中,對 TestDispatcherRule 類別執行個體化,並指派給 testDispatcher 唯讀屬性。
class MarsViewModelTest {

    val testDispatcher = TestDispatcherRule()
    ...
}
  1. 如要將這項規則套用到測試,請將 @get:Rule 註解新增至 testDispatcher 屬性。
class MarsViewModelTest {
    @get:Rule
    val testDispatcher = TestDispatcherRule()
    ...
}
  1. 重新執行測試。確認這次測試通過。

11. 取得解決方案程式碼

完成程式碼研究室後,如要下載當中用到的程式碼,您可以使用這些指令:

$ git clone https://github.com/google-developer-training/basic-android-kotlin-compose-training-mars-photos.git
$ cd basic-android-kotlin-compose-training-mars-photos
$ git checkout coil-starter

另外,您也可以下載存放區為 ZIP 檔案,然後解壓縮並在 Android Studio 中開啟。

如要查看本程式碼研究室的解決方案程式碼,請前往 GitHub 查看。

12. 結語

恭喜您完成本程式碼研究室,在重構 Mars Photos 應用程式的過程中,瞭解到如何實作存放區模式和依附元件插入功能!

應用程式的程式碼現在遵循資料層的 Android 最佳做法,因此應用程式會更具彈性、更穩定且易於擴充。

這些變更也有助於應用程式更容易進行測試。程式碼會持續完善,因此這個好處非常重要,同時確保程式碼能正常運作。

記得使用 #AndroidBasics,透過社群媒體分享您的作品!

13. 瞭解詳情

Android 開發人員說明文件:

其他: