【Android 架構系列 05】離線優先 (Offline-First):當 AI 遇上快取——如何設計 Single Source of Truth 避免資料打架
01. 前言:AI 的線性思維陷阱
這是系列文的第五篇。在前幾篇,我們解決了「單向依賴」的問題,讓 API 資料單向流動到 UI。但現實世界沒那麼美好,因為使用者的手機可能會斷網,或者他們希望打開 App 時能立刻看到上次的內容。
這時候,我們需要 Offline-First (離線優先) 架構。
這對 AI 來說是一個巨大的陷阱。因為 AI 的線性思維非常喜歡寫出「如果有網路就讀 API,沒網路就讀 DB」的邏輯。這聽起來很合理,但這正是導致 App 資料不同步、UI 閃爍的元兇。
這篇將探討如何利用 SSOT (Single Source of Truth, 單一真相來源) 原則,強迫 AI 寫出正確的同步邏輯。
當你要求 AI:「幫我加上 Room 資料庫快取,沒網路時顯示舊資料。」
AI 腦中的預設路徑通常是這樣的:
- 檢查網路。
- 有網路 -> 打 API -> 顯示 API 資料 -> 順便存 DB。
- 沒網路 -> 讀 DB -> 顯示 DB 資料。
這種邏輯被稱為 Dual Sources (雙重來源)。UI 有時候聽 API 的,有時候聽 DB 的。
這會造成什麼問題?
- Race Condition: 使用者打開 App,先顯示了 DB 的舊資料,0.5 秒後 API 回來了,畫面突然跳動刷新 (Flickering)。
- 資料不一致: 如果 API 呼叫成功但存 DB 失敗,UI 顯示了新資料,但下次打開又是舊資料。
在 Offline-First 的架構中,我們必須打破這種線性思維。
02. 核心原則:單一真相來源 (SSOT)
Single Source of Truth 的規則只有一條:
「UI 永遠只觀察資料庫。網路請求只負責更新資料庫。」
- 錯誤流程 (AI 常寫): API -> UI (順便存 DB)
- 正確流程 (SSOT): API -> DB -> UI
這意味著,即使網路請求回來了,我們也不會直接把資料丟給 UI。我們會把資料寫入 Room,然後讓 Room 的 Flow 自動通知 UI 更新。
03. 實戰演練:指揮 AI 實作 SSOT
要讓 AI 寫出這種架構,我們不能只說「加個快取」。我們必須明確指示資料的流向。
步驟 1:定義 DAO (資料庫存取物件)
首先,告訴 AI,DAO 必須回傳 Flow,而不是一次性的 List。這是實現「響應式架構」的關鍵。
Prompt:
"請建立 ArticleDao。 getArticles() 必須回傳 Flow<List<ArticleEntity>>。 insertArticles() 使用 OnConflictStrategy.REPLACE。"
AI 生成的代碼:
@Dao
interface ArticleDao {
// 關鍵:回傳 Flow,這樣當 DB 變動時,UI 會自動收到通知
@Query("SELECT * FROM articles ORDER BY publishedAt DESC")
fun getArticles(): Flow<List<ArticleEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertArticles(articles: List<ArticleEntity>)
@Query("DELETE FROM articles")
suspend fun clearArticles()
}
步驟 2:Repository 實作 (NetworkBoundResource)
這是最難的部分。我們要教 AI 寫一個 getNews() 函式,它要同時做兩件事:
- 立刻回傳 DB 的資料流 (Flow)。
- 在背景觸發 API 更新。
Prompt 策略:
"請實作 NewsRepositoryImpl。 使用 flow builder。 邏輯順序:
- 先 emit 資料庫目前的資料 (透過 map 轉成 Domain Model)。
- 呼叫 API。
- 如果 API 成功,將資料寫入 DB (這會觸發步驟 1 的 Flow 自動更新)。
- 如果 API 失敗,回傳錯誤,但不要中斷 DB 的 Flow。"
AI 生成的 SSOT 代碼:
class NewsRepositoryImpl @Inject constructor(
private val api: NewsApi,
private val dao: ArticleDao
) : NewsRepository {
override fun getNews(): Flow<Result<List<Article>>> = flow {
// 1. 發射 Loading 狀態
emit(Result.loading())
// 2. 訂閱 DB 資料流 (這是唯一的真相來源)
// 這裡使用 collect 監聽 DB 變化,並即時轉換成 Domain Model 發送出去
val dbSource = dao.getArticles().map { entities ->
entities.map { it.toDomain() }
}
// 3. 在背景進行網路同步 (Sync)
try {
// 從 DB 發射當前的資料 (可能是舊的,或者是空的)
// 這裡通常會結合一些邏輯,例如使用 flowOn 或 combine,
// 但為了讓 AI 寫出簡單易懂的邏輯,我們先展示「觸發同步」的概念
val remoteData = api.fetchNews() // 打 API
val entities = remoteData.articles.map { it.toEntity() }
// 關鍵動作:只寫入 DB,不直接 emit 給 UI
// Room 收到寫入後,上面的 dbSource 會自動更新,UI 自然就會拿到新資料
dao.withTransaction {
dao.clearArticles()
dao.insertArticles(entities)
}
emitAll(dbSource.map { Result.success(it) })
} catch (e: Exception) {
// 網路失敗,我們依然可以回傳 DB 的舊資料,並附帶一個錯誤訊息
emitAll(dbSource.map {
Result.success(it) // 還是給舊資料
// 實務上這裡可以用 Result.error(e, it) 帶有資料的錯誤狀態
})
}
}
}
(註:上述代碼是簡化邏輯供 AI 理解。實務上更嚴謹的寫法通常會將 fetch 和 observe 分開,或是使用 Google 官方的 NetworkBoundResource 樣板。)
更好理解的 Prompt:分離「觀察」與「更新」
為了讓 AI 寫出更穩定的代碼,筆者建議將 Repository 拆成兩個方法:
Prompt:
"請將 Repository 拆分為兩個方法:
- getNewsStream(): 只回傳 DB 的 Flow。
- refreshNews(): 只負責打 API 並寫入 DB。 ViewModel 負責同時呼叫這兩個方法。"
AI 生成的代碼 (更推薦):
// 這是最乾淨的 SSOT 實作
class NewsRepositoryImpl @Inject constructor(...) : NewsRepository {
// 唯一的真相來源:DB
override fun getNewsStream(): Flow<List<Article>> {
return dao.getArticles().map { list ->
list.map { it.toDomain() }
}
}
// 動作:更新真相
override suspend fun refreshNews(): Result<Unit> {
return runCatching {
val response = api.fetchNews()
dao.insertArticles(response.articles.map { it.toEntity() })
}
}
}
04. ViewModel 的配合
現在 ViewModel 的工作變得很單純:它在 init 時開始觀察 DB,並同時觸發一次刷新。
class NewsViewModel @Inject constructor(
private val repository: NewsRepository
) : ViewModel() {
// 1. 觀察者:永遠顯示 DB 的內容
val uiState: StateFlow<NewsUiState> = repository.getNewsStream()
.map { NewsUiState.Success(it) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), NewsUiState.Loading)
// 2. 觸發者:要求更新資料
fun refresh() {
viewModelScope.launch {
val result = repository.refreshNews()
if (result.isFailure) {
_oneTimeEvent.emit("網路錯誤,顯示舊資料")
}
}
}
}
05. 結論:別讓 AI 走捷徑
AI 很喜歡走捷徑(直接回傳 API 資料),因為那樣程式碼最少。但在 Offline-First 的世界裡,捷徑就是死路。
透過 SSOT 架構,我們強制 AI 遵守紀律:
- DB 是老大。
- Network 只是搬運工。
- UI 只是顯示器。
這樣做的好處是,無論網路多麼不穩定,你的 App 永遠有資料可以顯示,且絕不會發生「資料倒退嚕」或「閃爍」的靈異現象。
搞定了資料的同步問題,我們還剩下一個潛在的炸彈:例外處理 (Exception Handling)。
AI 很喜歡寫空的 try-catch,或者讓錯誤悄悄被吞掉。我們該如何設計一套全域的錯誤攔截機制?
下集預告:【Part 6】錯誤處理 (Error Handling):別讓 AI 吞掉你的 Exception——建立全域的 ROP 機制

關於作者
Ken Huang
熱衷於嘗試各類新技術的軟體開發者,現階段主力為 Android / iOS 雙平台開發,同時持續深耕前端與後端技術,以成為全端工程師與軟體架構師為目標。
最廣為人知的代表作為 BePTT。開發哲學是「以做身體健康為前提」,致力於在工作與生活平衡的基礎上,打造出擁有絕佳使用體驗的軟體服務。
這裡是用於紀錄與分享開發經驗的空間,希望能透過我的實戰筆記,幫助開發者解決疑難雜症並提升效率。
系列文章目錄
- 【Android 架構系列 03】依賴注入 (DI):讓 Hilt 成為 AI 的「接線生」
- 【Android 架構系列 04】核心防禦:Domain 與 Data Layer 設計——防止 AI 污染業務邏輯
- 【Android 架構系列 05】離線優先 (Offline-First):當 AI 遇上快取——如何設計 Single Source of Truth 避免資料打架 (本文)
- 【Android 架構系列 06】錯誤處理 (Error Handling):別讓 AI 吞掉你的 Exception
- 【Android 架構系列 07】狀態的藝術:Presentation Layer 設計——用 Sealed Interface 終結變數大爆炸