beio Logobeio

【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 腦中的預設路徑通常是這樣的:

  1. 檢查網路。
  2. 有網路 -> 打 API -> 顯示 API 資料 -> 順便存 DB。
  3. 沒網路 -> 讀 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() 函式,它要同時做兩件事:

  1. 立刻回傳 DB 的資料流 (Flow)。
  2. 在背景觸發 API 更新。

Prompt 策略:

"請實作 NewsRepositoryImpl。 使用 flow builder。 邏輯順序:

  1. 先 emit 資料庫目前的資料 (透過 map 轉成 Domain Model)。
  2. 呼叫 API。
  3. 如果 API 成功,將資料寫入 DB (這會觸發步驟 1 的 Flow 自動更新)。
  4. 如果 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 拆分為兩個方法:

  1. getNewsStream(): 只回傳 DB 的 Flow。
  2. 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 遵守紀律:

  1. DB 是老大。
  2. Network 只是搬運工。
  3. UI 只是顯示器。

這樣做的好處是,無論網路多麼不穩定,你的 App 永遠有資料可以顯示,且絕不會發生「資料倒退嚕」或「閃爍」的靈異現象。

搞定了資料的同步問題,我們還剩下一個潛在的炸彈:例外處理 (Exception Handling)

AI 很喜歡寫空的 try-catch,或者讓錯誤悄悄被吞掉。我們該如何設計一套全域的錯誤攔截機制?

下集預告:【Part 6】錯誤處理 (Error Handling):別讓 AI 吞掉你的 Exception——建立全域的 ROP 機制


Ken Huang

關於作者

Ken Huang

熱衷於嘗試各類新技術的軟體開發者,現階段主力為 Android / iOS 雙平台開發,同時持續深耕前端與後端技術,以成為全端工程師與軟體架構師為目標。

最廣為人知的代表作為 BePTT。開發哲學是「以做身體健康為前提」,致力於在工作與生活平衡的基礎上,打造出擁有絕佳使用體驗的軟體服務。

這裡是用於紀錄與分享開發經驗的空間,希望能透過我的實戰筆記,幫助開發者解決疑難雜症並提升效率。

Android APP DevelopmentiOS APP DevelopmentBePTT CreatorFull Stack Learner