資料圖層

使用者介面層包含使用者介面相關狀態和使用者介面邏輯,而資料層包含「應用程式資料」和「商業邏輯」。商業邏輯可為應用程式帶來價值,這是由實際業務規則組成,可決定建立、儲存及變更應用程式資料的方式。

透過這種「關注點分離」原則,資料層可在多個畫面中使用,在應用程式的不同部分之間分享資訊,並在使用者介面以外重現商業邏輯,以利單元測試。如要進一步瞭解資料層的優點,請參閱架構總覽頁面

資料層架構

資料層是由「存放區」組成,每個存放區都可包含零到多個「資料來源」。您應針對應用程式中處理的各種資料類型建立存放區類別。舉例來說,您可以為電影相關資料建立 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) { ... }
}

本指南中的命名慣例

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

「<資料類型>」+「Repository」

例如 NewsRepositoryMoviesRepositoryPaymentsRepository

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

「<資料類型>」+「<來源類型>」+「DataSource」

由於實作方式可能變更,資料類型可以使用「Remote」或「Local」這類較為籠統的名稱,例如 NewsRemoteDataSourceNewsLocalDataSource。如果來源很重要,來源類型就可以具體一些,例如 NewsNetworkDataSourceNewsDiskDataSource

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

多級別存放區

如果是有更複雜的業務需求,存放區可能需要依附於其他存放區。這可能是因為資料來源涉及多個資料來源的匯總,或是由於必須在另一個存放區類別中封裝所致。

舉例來說,處理使用者驗證資料的存放區 (UserRepository) 可能依附於其他存放區 (例如 LoginRepositoryRegistrationRepository) 以執行其要求。

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

可靠資料來源

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

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

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

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

執行緒

呼叫資料來源和存放區應設定為「main-safe」,也就是可從主要執行緒呼叫。這些類別在執行時間較長的封鎖作業時,負責將邏輯的執行作業移至適當執行緒。舉例來說,只有設為 main-safe 的資料來源,才能經由檔案讀取;也只有設為 main-safe 的存放區,才能對大型清單執行耗費資源的篩選作業。

請注意,大多數資料來源已提供 main-safe API,比如 RoomRetrofitKtor 提供的暫停方法呼叫。只要有這類 API,您的存放區就能加以利用。

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

生命週期

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

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

如果類別的責任對整個應用程式而言至關重要,您可以將該類別的執行個體「範圍」限制為 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,以向網域和使用者介面層公開 Article 模型類別:

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

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

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

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

資料作業類型

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

使用者介面導向作業

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

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

應用程式導向作業

只要應用程式開啟,就會執行應用程式導向作業。如果應用程式關閉或程序終止,系統就會取消這些作業。其中一個範例是快取網路要求的結果,方便在之後需要時使用。如要瞭解更多相關資訊,請參閱「實作記憶體內資料快取」。

這些作業通常會遵循 Application 的類別或資料層的生命週期。如需範例,請參閱「讓作業時間大於畫面時間」。

業務導向作業

業務導向作業無法取消,而且在程序終止後仍應持續。例如完成上傳使用者要在其個人資料中張貼的相片。

如為業務導向作業,建議使用 WorkManager。如要瞭解更多相關資訊,請參閱「使用 WorkManager 安排工作」。

公開錯誤

與存放區和資料來源互動可能會成功,或在失敗時擲回例外狀況。如為協同程式和流程,建議使用 Kotlin 的內建錯誤處理機制。如為可由暫停函式所觸發的錯誤,請視情況使用 try/catch 區塊;如果是在流程中,請使用 catch 運算子。使用這種方法時,使用者介面層在呼叫資料層時就會處理例外狀況。

資料層可以瞭解及處理不同類型的錯誤,並使用自訂例外狀況 (例如 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()
}

如要瞭解直接從使用者介面層使用存放區類別的方式,請參閱「使用者介面層」指南。

實作記憶體內資料快取

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

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

快取

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

快取網路要求的結果

為了方便起見,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 做為資料來源

每個資料來源都應只有一個來源處理特定類型的資料,因此 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 類別:RefreshLatestNewsWorker。此類別使用 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 呼叫,然後驗證這些要求。

範例

以下 Google 範例示範如何使用資料層。歡迎查看這些範例,瞭解實務做法: