資料層

雖然 UI 層包含 UI 相關狀態和 UI 邏輯,但資料層包含「應用程式資料」和「商業邏輯」。商業邏輯可為應用程式提供價值,也就是由實際業務規則組成,可決定建立、儲存及變更應用程式資料的方式。

將這種分離因素放在資料層中,就能在多個螢幕上使用、應用程式各部分共用資訊,以及在 UI 之外重製商業邏輯,以便進行單元測試。如要進一步瞭解資料層的優點,請參閱 架構總覽頁面

資料層架構

資料層是由存放區組成,每個存放區都可包含零到多個資料來源。您應針對應用程式中處理的各種資料類型建立存放區類別。舉例來說,您可以為電影相關資料建立 MoviesRepository 類別,或是為款項相關資料建立 PaymentsRepository 類別。

在一般架構中,資料層存放區提供了資料給應用程式其餘部分,及取決於資料來源。
圖 1. 使用者介面圖層在應用程式架構中的角色。

存放區類別用於下列工作:

  • 將資料公開給應用程式的其餘部分
  • 集中對資料的變更。
  • 解決多個資料來源之間的衝突。
  • 從應用程式的其餘部分摘要資料來源。
  • 包含商業邏輯。

每個資料來源類別都應只負責處理一個資料來源 (可以是檔案、網路來源或本機資料庫)。資料來源類別是應用程式與系統作業之間的橋樑。

階層中的其他階層一律不得直接存取資料來源;指向資料層的進入點一律是存放區類別。狀態持有者類別 (請參閱 UI 層指南) 或用途類別 (請參閱網域層指南) 不應將資料來源做為直接依附元件。使用存放區類別做為進入點,可讓架構的不同層獨立調度資源。

這個圖層暴露的資料不可變更,因此不會遭到其他類別竄改,這可能導致其值不一致。多個會話串也能以安全的方式處理不可變更的資料。詳情請參閱 討論串專區

遵循依附元件插入最佳做法後,存放區會將資料來源做為其建構函式中的依附元件:

class ExampleRepository(
    private val exampleRemoteDataSource: ExampleRemoteDataSource, // network
    private val exampleLocalDataSource: ExampleLocalDataSource // database
) { /* ... */ }

公開 API

資料層中的類別通常會提供函式,以便執行一次性建立、讀取、更新和刪除 (CRUD) 呼叫,或是通知資料隨著時間改變。資料層應該能在下列情況中揭露下列資訊:

  • 單次操作: 資料層應公開 Kotlin 中的函式;針對 Java 程式設計語言,資料層應顯示函式,以提供回呼來通知作業結果,或是 RxJava SingleMaybeCompletable 類型。
  • 如要接收資料隨時間變化的資料通知: 資料層應在 Kotlin 中公開 流程;如果是 Java 程式設計語言,資料層應顯示會發出新資料的回呼,或是 RxJava ObservableFlowable 類型。
class ExampleRepository(
    private val exampleRemoteDataSource: ExampleRemoteDataSource, // network
    private val exampleLocalDataSource: ExampleLocalDataSource // database
) {

    val data: Flow<Example> = ...

    suspend fun modifyData(example: Example) { ... }
}

本指南中的命名慣例

在本指南中,存放區類別會以其負責任的資料命名。慣例如下:

資料類型 + 存放區

例如 NewsRepositoryMoviesRepositoryPaymentsRepository

資料來源類別會按照所屬資料與其來源命名。慣例如下:

資料類型 + 來源類型 + DataSource

資料類型可能經過變更,因此請使用 RemoteLocal 來建立更通用。例如 NewsRemoteDataSourceNewsLocalDataSource。如要更具體地說明來源為何,請使用來源類型。例如 NewsNetworkDataSourceNewsDiskDataSource

請勿根據實作詳細資料 (例如 UserSharedPreferencesDataSource) 為資料來源命名,因為使用該資料來源的存放區不應知道資料的儲存方式。遵循這項規則時,您可以變更資料來源的實作方式 (例如從 SharedPreferences 遷移至 DataStore),而不影響受影響呼叫該來源的圖層。

多級別存放區

在某些更複雜的業務需求中,存放區可能需要依賴其他存放區。這可能是因為資料來源涉及多個資料來源的匯總,或是必須負責封裝於其他存放區類別。

舉例來說,負責處理使用者驗證資料的存放區 (UserRepository) 可能仰賴其他存放區 (例如 LoginRepositoryRegistrationRepository) 來滿足其需求。

在這個範例中,UserRepository 取決於另外兩個存放區類別:LoginRepository,取決於其他登入資料來源;以及 Registration Repositories,取決於其他註冊資料來源。
圖 2. 依賴其他存放區的存放區依附元件圖表。

可靠資料來源

每個存放區都必須定義單一的資料來源。可靠資料來源一律會包含一致、正確且最新的資料。事實上,從存放區公開的資料應一律來自可靠資料來源。

可靠來源可能是資料來源 (例如資料庫),甚至是存放區可能包含的記憶體內快取。存放區會結合不同的資料來源,並解決資料來源之間的任何潛在衝突,以便定期或因使用者輸入事件而更新單一資料來源。

應用程式中的不同存放區可能有不同的資料來源。舉例來說,LoginRepository 類別可能會將快取當做可靠資料來源,PaymentsRepository 類別可能會使用網路資料來源。

如要提供離線優先支援,本機資料來源 (例如資料庫) 是建議的可靠資料來源

執行緒

呼叫資料來源和存放區應設定為「對主執行緒無威脅」,也就是可從主要執行緒呼叫。這些類別在執行時間較長的封鎖作業時,負責將邏輯的執行作業移至適當執行緒。例如,資料來源應從檔案讀取,或讓存放區針對大型清單執行高昂篩選。

請注意,大多數資料來源已經提供主要安全 API,例如 RoomRetrofit 提供的停權方法呼叫。您的存放區可使用這些 API。

如要進一步瞭解執行緒,請參閱 背景處理指南。如果是 Kotlin 使用者,建議您選擇使用 協同程式。如需 Java 程式設計語言的建議選項,請參閱 在背景執行緒執行 Android 工作

Lifecycle

資料層中的類別執行個體會保存在記憶體中,前提是可透過垃圾收集根目錄存取這些要求 (通常是從應用程式中的其他物件進行參照)。

如果類別包含記憶體內的資料 (例如快取),建議您在特定時間範圍內重複使用該類別的相同的執行個體。這也稱為類別執行個體的「生命週期」

如果整個類別對整個應用程式來說至關重要,您可以將該類別的執行個體「限定」Application 類別。因此執行個體會遵循應用程式的生命週期。或者,如果您需要在應用程式中的特定流程 (例如註冊或登入流程) 重複使用相同的執行個體,則必須將執行個體限制為擁有生命週期的類別。舉例來說,您可以將含有記憶體內資料的 RegistrationRepository 範圍限制為 RegistrationActivity 或註冊流程的 導覽圖表

每個執行個體的生命週期是決定在應用程式中提供依附元件的關鍵因素。建議您遵循依附元件插入最佳做法管理依附元件,並將範圍限制為依附元件容器。如要進一步瞭解如何在 Android 中設定範圍,請參閱在 Android 和 Hilt 中設定範圍這篇網誌文章。

代表商業模式

您想要從資料層公開的資料模型,可能是您從不同資料來源取得的資訊子集。在理想情況下,不同的資料來源 (網路和本機) 應只會傳回您應用程式需要的資訊;但通常不會發生這種情況。

舉例來說,假設新聞 API 伺服器不只傳回報導資訊,還會傳回記錄、使用者留言和部分中繼資料:

data class ArticleApiModel(
    val id: Long,
    val title: String,
    val content: String,
    val publicationDate: Date,
    val modifications: Array<ArticleApiModel>,
    val comments: Array<CommentApiModel>,
    val lastModificationDate: Date,
    val authorId: Long,
    val authorName: String,
    val authorDateOfBirth: Date,
    val readTimeMin: Int
)

應用程式不需要內含該文章的過多資訊,因為系統只會顯示報導內容,以及作者的基本資訊。建議您區隔模型類別,讓存放區僅顯示階層中其他層所需的資料。例如,以下說明如何從網路修剪 ArticleApiModel 以向網域和 UI 層公開 Article 模型類別:

data class Article(
    val id: Long,
    val title: String,
    val content: String,
    val publicationDate: Date,
    val authorName: String,
    val readTimeMin: Int
)

將模型類別區分為有下列好處:

  • 這項功能可將資料縮減為僅有需要,藉此節省應用程式記憶體。
  • 該函式會根據應用程式使用的資料類型,自動調整外部資料類型。舉例來說,您的應用程式可能會使用不同的資料類型來代表日期。
  • 這項功能可以更有效地區隔問題。舉例來說,假設某個模型類別是預先定義,則大型團隊的成員可針對該網路和網路中的 UI 層執行個別作業。

您可以擴充這個做法,並在應用程式架構的其他部分 (例如資料來源類別和 ViewModel) 定義不同的模型類別。但是,您必須定義應正確記錄及測試的額外類別和邏輯。如果資料來源收到的資料與應用程式的其餘部分不符,我們會建議您建立新模型。

資料作業類型

資料層會根據其重要性來處理各種作業類型:以使用者介面為準、以應用程式為核心和業務導向的營運方式。

以使用者介面為基礎的作業

使用者介面導向的作業只有在使用者使用特定畫面時才相關,而且當使用者離開該畫面時就會取消。以下示例顯示從資料庫取得的一些資料。

使用者介面導向作業通常會由 UI 層觸發,並會遵循呼叫者的生命週期 (例如 ViewModel 的生命週期)。如需 UI 導向作業的範例,請參閱 發出網路要求 一節。

應用程式導向作業

只要應用程式開啟,就會執行應用程式導向作業。如果應用程式遭到關閉或程序被終止,系統就會取消這些作業。範例會快取網路要求的結果,以備之後即可使用。詳情請參閱 實作記憶體內資料快取 一節。

這些作業通常會遵循 Application 類別或資料層的生命週期。如需範例,請參閱 將作業執行時間超過畫面時間 一節。

企業營運

無法取消業務導向作業。程序死亡後仍應有效。例如使用者想要在個人資料中張貼的相片,完成上傳作業。

針對業務導向作業,建議您使用 WorkManager。詳情請參閱 使用 WorkManager 排定工作 一節。

公開錯誤

如果與存放區和資料來源的來源互動,即可在失敗時傳回或擲回例外狀況。針對處理常式和流程,您應使用 Kotlin 的 內建錯誤處理機制。如要解決暫停函式所觸發的錯誤,請視情況使用 try/catch 區塊;在流程中,請使用 catch 運算子。使用這個方法時,UI 圖層在呼叫資料層時應處理例外狀況。

資料層可以瞭解及處理不同類型的錯誤,並使用自訂例外狀況 (例如 UserNotAuthenticatedException) 顯示這些錯誤。

如要進一步瞭解協同程式中的錯誤,請參閱協同程式中的例外狀況這篇網誌文章。

一般工作

以下各節說明如何使用 Android 應用程式常見的架構工作,以及如何建構資料層。這些範例以指南中提及的一般新聞應用程式為基礎。

發出網路要求

發出網路要求是 Android 應用程式可能會執行的常見工作之一。新聞應用程式必須向使用者顯示從網路擷取的最新消息。因此,應用程式需要資料來源類別來管理網路作業:NewsRemoteDataSource。為了將資訊公開給應用程式其餘部分,系統會建立新的存放區 NewsRepository 來處理新聞資料中的作業。

其要求是當使用者開啟螢幕時,一律必須更新最新消息。因此,這是 以使用者介面為基礎的作業

建立資料來源

資料來源必須揭露會傳回最新新聞的函式:ArticleHeadline 執行個體清單。資料來源必須提供主要的方法,以便取得網路的最新消息。因此,您必須在 CoroutineDispatcherExecutor 上執行依附元件,才能執行工作。

發出網路要求是由新的 fetchLatestNews() 方法所處理的一次性呼叫:

class NewsRemoteDataSource(
  private val newsApi: NewsApi,
  private val ioDispatcher: CoroutineDispatcher
) {
    /**
     * Fetches the latest news from the network and returns the result.
     * This executes on an IO-optimized thread pool, the function is main-safe.
     */
    suspend fun fetchLatestNews(): List<ArticleHeadline> =
        // Move the execution to an IO-optimized thread since the ApiService
        // doesn't support coroutines and makes synchronous requests.
        withContext(ioDispatcher) {
            newsApi.fetchLatestNews()
        }
    }
}

// Makes news-related network synchronous requests.
interface NewsApi {
    fun fetchLatestNews(): List<ArticleHeadline>
}

NewsApi 介面會隱藏網路 API 用戶端的實作方式;無論介面是由 Retrofit 還是 HttpURLConnection 備份,都不會改變。透過介面,您可以在應用程式中切換 API 實作。

建立存放區

由於這項工作的存放區類別不需要其他邏輯,因此 NewsRepository 可以當做網路資料來源的 Proxy。新增額外抽象層的優點,請參閱 記憶體內快取一節。

// NewsRepository is consumed from other layers of the hierarchy.
class NewsRepository(
    private val newsRemoteDataSource: NewsRemoteDataSource
) {
    suspend fun fetchLatestNews(): List<ArticleHeadline> =
        newsRemoteDataSource.fetchLatestNews()
}

如要瞭解如何直接從 UI 層使用存放區類別,請參閱 使用者介面圖層 指南。

實作記憶體內資料快取

假設「新聞」應用程式加入了新的規定:使用者開啟螢幕時,如果先前提出要求,則必須向使用者顯示快取新聞。否則,應用程式應發出網路要求以擷取最新消息。

根據這些新規定,應用程式必須在使用者開啟應用程式的情況下,將記憶體的最新資訊保留在記憶體中。因此,這是 以應用程式為主的作業

快取

您可以新增記憶體內資料快取,藉此保留使用者在您應用程式中留存的資料。快取的目的是將一些資訊儲存在記憶體中,在這個情況下即為使用者在應用程式中使用的時間。快取的實作方式可能會有所不同。從簡單的可變動變數到較複雜的類別,可避免多個執行緒讀取/寫入作業。視用途而定,您可以在存放區或資料來源類別中實作快取。

快取網路要求的結果

為了方便起見,NewsRepository 使用可變動變數快取最新的新聞。如要保護不同執行緒的讀寫作業,系統會使用 Mutex。如要進一步瞭解共用的可變動狀態和並行,請參閱 Kotlin 說明文件

下列實作會將最新的新聞資訊快取至存放區中受 Mutex 寫入保護的存放區中的變數。如果網路要求的結果成功,系統會將資料指派給 latestNews 變數。

class NewsRepository(
  private val newsRemoteDataSource: NewsRemoteDataSource
) {
    // Mutex to make writes to cached values thread-safe.
    private val latestNewsMutex = Mutex()

    // Cache of the latest news got from the network.
    private var latestNews: List<ArticleHeadline> = emptyList()

    suspend fun getLatestNews(refresh: Boolean = false): List<ArticleHeadline> {
        if (refresh || latestNews.isEmpty()) {
            val networkResult = newsRemoteDataSource.fetchLatestNews()
            // Thread-safe write to latestNews
            latestNewsMutex.withLock {
                this.latestNews = networkResult
            }
        }

        return latestNewsMutex.withLock { this.latestNews }
    }
}

讓操作畫面比螢幕更長

如果使用者在網路要求執行期間離開畫面,系統會取消動作,而不會快取結果。NewsRepository 請勿使用呼叫端的 CoroutineScope 執行這個邏輯。反之,NewsRepository 應使用其生命週期中附加的 CoroutineScope擷取新聞快報必須是以應用程式為主的作業。

為了遵循依附元件插入最佳做法,NewsRepository 應在其建構函式中接收一個範圍參數,而不是自行建立 CoroutineScope。由於存放區應該在背景執行緒中執行大部分工作,因此您必須使用 Dispatchers.Default 或自己的執行緒集區來設定 CoroutineScope

class NewsRepository(
    ...,
    // This could be CoroutineScope(SupervisorJob() + Dispatchers.Default).
    private val externalScope: CoroutineScope
) { ... }

由於 NewsRepository 已準備好使用外部 CoroutineScope 執行以應用程式為主的作業,因此必須對資料來源執行呼叫,並使用該範圍啟動的新處理常式儲存結果:

class NewsRepository(
    private val newsRemoteDataSource: NewsRemoteDataSource,
    private val externalScope: CoroutineScope
) {
    /* ... */

    suspend fun getLatestNews(refresh: Boolean = false): List<ArticleHeadline> {
        return if (refresh) {
            externalScope.async {
                newsRemoteDataSource.fetchLatestNews().also { networkResult ->
                    // Thread-safe write to latestNews.
                    latestNewsMutex.withLock {
                        latestNews = networkResult
                    }
                }
            }.await()
        } else {
            return latestNewsMutex.withLock { this.latestNews }
        }
    }
}

async 是用來啟動外部範圍的協同程式。在新協同程式上呼叫 await 時,系統會暫停網路,直到網路要求恢復且結果儲存在快取中為止。如果該時間點仍未顯示在畫面上,系統會顯示最新消息;如果使用者離開畫面,await 就會取消,但 async 中的邏輯仍會繼續執行。

如要進一步瞭解 CoroutineScope 的模式,請參閱 這篇網誌文章

從磁碟儲存及擷取資料

假設您想要儲存書籤和使用者偏好設定等資料。即便使用者未連線至網路,這個類型的資料仍可供處理,而且仍可存取。

如果您處理的資料必須處理到程序死亡情況,則必須採用下列其中一種方法將資料儲存在磁碟中:

  • 對於需要接受查詢、需要參照完整性或是需要部分更新的「大型資料集」,請將資料儲存在「Room 資料庫」中。在新聞應用程式範例中,新聞報導或作者可儲存至資料庫。
  • 對於只需要擷取及設定 (而非查詢或部分更新) 的「小型資料集」,請使用「DataStore」。在新聞應用程式範例中,使用者偏好的日期格式或其他顯示偏好設定可以儲存在 DataStore 中。
  • 對於 JSON 物件等「資料區塊」,請使用「檔案」

可靠資料來源 一節所述,每個資料來源都只能使用一個來源,而且會與特定資料類型 (例如 NewsAuthorsNewsAndAuthorsUserPreferences),使用資料來源的類別不應瞭解資料的儲存方式,例如資料庫或檔案。

作為資料來源的聊天室

每個資料來源都應只有一個來源處理特定類型的資料,因此 Room 資料來源會收到資料存取物件 (DAO) 或資料庫本身做為參數。舉例來說,NewsLocalDataSource 可能會將 NewsDao 的例項做為參數,AuthorsLocalDataSource 則可能會是 AuthorsDao 的執行個體。

在某些情況下,如果不需要額外的邏輯,則可將 DAO 直接插入存放區,因為 DAO 是介面,可讓您輕鬆地替換測試。

如要進一步瞭解如何使用 Room API,請參閱 Room 指南

DataStore 做為資料來源

DataStore 非常適合儲存鍵/值組合,例如使用者設定。例如時間格式、通知偏好設定,以及是否在使用者閱讀後顯示或隱藏新聞項目。DataStore 也可以儲存包含 通訊協定緩衝區 的輸入物件。

和其他物件一樣,DataStore 支援的資料來源應包含與特定類型或應用程式特定部分相對應的資料。這一點對於 DataStore 來說更是如此,因為 DataStore 讀取作業會在每次更新值時做為公開流程發出。因此,建議您將相關偏好設定儲存在同一個 DataStore 中。

例如,您可以建立 NotificationsDataStore 只處理通知相關偏好設定,而 NewsPreferencesDataStore 則專門處理與新聞畫面相關的偏好設定。如此一來,您就可以更妥善地確定更新範圍,因為 newsScreenPreferencesDataStore.data 流程只會在畫面與該畫面相關的偏好設定變更時觸發。這表示物件的生命週期也較短,因為物件只能在顯示新聞畫面時顯示。

如要進一步瞭解如何使用 DataStore API,請參閱 DataStore 指南

做為資料來源的檔案

處理 JSON 物件或點陣圖等大型物件時,您必須處理 File 物件並處理切換執行緒。

如要進一步瞭解如何使用檔案儲存空間,請參閱 儲存空間總覽 頁面。

使用 WorkManager 安排工作

假設新聞應用程式加入了其他新規定:應用程式必須讓使用者只要定期充電並連線至非計量付費網路,就能定期自動擷取最新新聞。該規定將促成一項「業務導向」作業。如此一來,即使裝置在使用者開啟應用程式時沒有連線能力,使用者仍然可以查看近期新聞。

WorkManager 可讓您輕鬆排定非同步和可靠工作的時間,而且可以完成限制管理。建議您使用這個程式庫來處理持續工作。如要執行上述的工作,系統會建立 Worker 類別:FetchLatestNewsWorker。這個類別使用 NewsRepository 做為依附元件,以便擷取最新消息並快取至磁碟。

class RefreshLatestNewsWorker(
    private val newsRepository: NewsRepository,
    context: Context,
    params: WorkerParameters
) : CoroutineWorker(context, params) {

    override suspend fun doWork(): Result = try {
        newsRepository.refreshLatestNews()
        Result.success()
    } catch (error: Throwable) {
        Result.failure()
    }
}

執行這項工作的商業邏輯應納入其所屬類別,並視為個別資料來源。這樣一來,工作管理員只有在負責滿足所有限制時,才會在背景執行緒上執行工作。只要遵循這個模式,您就可以視需求快速更換在其他環境中的實作方式。

在本範例中,您必須從 NewsRepository 呼叫這項新聞相關工作,以使用新的資料來源做為 NewsTasksDataSource,實作方式如下:

private const val REFRESH_RATE_HOURS = 4L
private const val FETCH_LATEST_NEWS_TASK = "FetchLatestNewsTask"
private const val TAG_FETCH_LATEST_NEWS = "FetchLatestNewsTaskTag"

class NewsTasksDataSource(
    private val workManager: WorkManager
) {
    fun fetchNewsPeriodically() {
        val fetchNewsRequest = PeriodicWorkRequestBuilder<RefreshLatestNewsWorker>(
            REFRESH_RATE_HOURS, TimeUnit.HOURS
        ).setConstraints(
            Constraints.Builder()
                .setRequiredNetworkType(NetworkType.TEMPORARILY_UNMETERED)
                .setRequiresCharging(true)
                .build()
        )
            .addTag(TAG_FETCH_LATEST_NEWS)

        workManager.enqueueUniquePeriodicWork(
            FETCH_LATEST_NEWS_TASK,
            ExistingPeriodicWorkPolicy.KEEP,
            fetchNewsRequest.build()
        )
    }

    fun cancelFetchingNewsPeriodically() {
        workManager.cancelAllWorkByTag(TAG_FETCH_LATEST_NEWS)
    }
}

這些類別的名稱會負責處理其所屬資料,例如 NewsTasksDataSourcePaymentsTasksDataSource。與特定資料相關的所有工作都會封裝於同一類別。

如果工作必須在應用程式啟動時觸發,建議您使用從Initializer呼叫程式庫的 應用程式啟動 觸發 WorkManager 請求。

如要進一步瞭解如何使用 WorkManager API,請參閱 WorkManager 指南

測試

依附元件插入 最佳做法可協助測試您的應用程式。如果採用與外部資源通訊的類別,就需要仰賴介面。測試單元時,您可以註冊其依附元件的假版本,使測試具有確定性和可靠性。

單元測試

一般測試指南:測試資料層時適用。進行單元測試時,請視需要使用實際物件,然後偽造任何連線至外部來源的依附元件,例如從檔案讀取或從網路讀取。

整合測試

整合外部來源的整合測試往往較不可靠,因為需要在實際裝置上運作。我們建議在受控管的環境中執行這些測試,讓整合測試更可靠。

對於資料庫,Room 可讓您建立記憶體內資料庫,完全由測試掌控。詳情請參閱 測試資料庫並進行偵錯 頁面。

針對網路,有些熱門程式庫 (例如 WireMockMockWebServer) 可讓您偽造 HTTP 和 HTTPS 呼叫,並驗證這些要求。