Android Repository Pattern 設計模式

在現代 Android 開發中,Repository Pattern (儲存庫模式) 是連間 UI 與資料源(網路 API、本地資料庫、快取等)的唯一守門員。它的核心目標是將「資料獲取邏輯」從 ViewModel 中解耦出來,提供一個簡潔且一致的介面。

核心原則:單一事實來源 (SSOT)

Single Source of Truth (SSOT) 是 Repo 模式最重要的靈魂。在離線優先的架構中,本地資料庫 (Room) 通常被設定為 SSOT。

  • UI 層 (ViewModel) 只觀察資料庫。
  • Repository 負責去網路拉取資料,並在拉到後「寫入」資料庫。
  • UI 會因為資料庫的更新而自動被驅動,從而達到網路與本地的無縫同步。

資料映射 (Data Mapping)

在實務中,我們通常會有三種不同的資料模型:

  1. Network DTO: 網路傳回的 JSON 實體(由 Retrofit 解析)。
  2. Database Entity: 存入本地的資料模型(由 Room 管理)。
  3. Domain/UI Model: ViewModel 或 UI 真正使用的純粹模型。

Repository 的重要職責之一就是處理這些模型之間的轉換:

class NewsRepository @Inject constructor(
    private val apiService: NewsApiService,
    private val newsDao: NewsDao
) {
    // 透過 Flow 提供資料庫中的資料 (SSOT)
    val newsList: Flow<List<News>> = newsDao.getAllNews().map { entities ->
        entities.map { it.asDomainModel() } // 轉換為 UI 使用的模型
    }

    suspend fun refreshNews() {
        // 從網路抓取內容 (DTO)
        val response = apiService.getLatestNews()
        
        // 將 DTO 轉為 Entity 並寫入資料庫
        newsDao.insertAll(response.results.map { it.asEntity() })
    }
}

離線優先快取策略 (Offline-first Strategy)

這款策略能確保即使在斷網時,使用者仍能看到之前的資料,且在拉到新資料時 UI 會自動平滑更新。

  1. Read: UI 觀察 Repo 回傳的 Flow
  2. Write: Repo 的 refresh 方法會去打 API 拿資料。
  3. Sync: 拿完後直接執行 dao.insert()
  4. Auto-Update: 因為 dao.getAllNews() 回傳的是 Flow,Room 會感知資料變動並自動發送新清單。

Hilt 依賴注入與抽象化

為了讓程式碼更具備可測試性,我們通常會定義介面 (Interface),並透過 Hilt 來綁定實作。

// 定義介面
interface NewsRepository {
    fun getNewsStream(): Flow<List<News>>
    suspend fun refresh()
}

// Hilt 模組綁定
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
    @Binds
    @Singleton
    abstract fun bindNewsRepository(
        impl: NewsRepositoryImpl
    ): NewsRepository
}

如何測試 Repository

Repository 的測試應聚焦於資料流的轉換與異常處理。由於我們通常會注入 DAO 和 Service,我們可以使用 Fake (偽造物件) 或 Mock 來驗證。

class NewsRepositoryTest {
    @Test
    fun refresh_success_writesToDatabase() = runTest {
        val fakeApi = FakeApiService()
        val fakeDao = FakeNewsDao()
        val repo = NewsRepositoryImpl(fakeApi, fakeDao)

        repo.refresh()

        // 驗證 API 拿到的資料是否真的寫入了 DAO
        val news = fakeDao.getAllNews().first()
        assertEquals(fakeApi.latestResults.size, news.size)
    }
}

最佳實踐建議

  1. 不要在 Repo 中處理業務邏輯:如果需要對資料進行複雜的商業過濾(例如根據用戶權限過濾文章),請將其放在 UseCase 中。
  2. 處理 Loading 與 Error 狀態:建議封裝一個 Result<T> 類別,讓 ViewModel 能夠清晰地捕捉網路異常或空資料。
  3. 分頁處理 (Paging):當資料量極大時,應整合 Paging 3 函式庫,Repo 負責提供 Pager 物件。
  4. 多數據源協調:如果你的資料跨越了多個微服務或資料庫,Repo 是整合這一切邏輯的最佳場所。
Repository 模式的本質是隔離。只要 UI 層不依賴於底層的網路函式庫或資料庫實作,你的架構就具備了極佳的靈活性與可維護性。