Skip to content

使用 Drift stream 實現 reactive 資料觀察 #220

@rileychh

Description

@rileychh

動機

目前 repository 方法回傳 Future<T>,透過一次性 DB query 取得資料。當使用者清除 cache(#212)時,DB 資料被刪除,但 Riverpod provider 仍持有記憶體中的舊值,需要手動 ref.invalidate() 每個受影響的 provider。

這個做法無法 scale——每新增一個畫面就多了需要追蹤的 provider,容易遺漏導致 UI 顯示過時資料或 crash。

設計

將每個 getX({bool refresh}) 方法拆為兩個:

// 觀察:透過 Drift .watch() 回傳 stream,DB 變動時自動 emit 新值
Stream<List<Semester>> watchSemesters();

// Imperative:從網路 fetch 並寫入 DB(stream 自動 emit 更新)
Future<void> refreshSemesters();

Repository 層

Repository 的 watchX() 方法內部處理 TTL 邏輯——emit cache 資料後,若資料過期則在背景觸發網路 fetch。Stream 在 DB 寫入後自動 emit 更新值,不需要外部介入:

Stream<List<Semester>> watchSemesters() async* {
  await for (final semesters in _selectSemesters().watch()) {
    if (semesters.isEmpty) {
      await _fetchSemestersFromNetwork();
      continue; // stream 會因 DB 變動重新 emit
    }

    yield semesters;

    // 背景檢查是否 stale
    final user = await _database.select(_database.users).getSingleOrNull();
    final age = switch (user?.semestersFetchedAt) {
      final t? => DateTime.now().difference(t),
      null => defaultFetchTtl,
    };
    if (age >= defaultFetchTtl) {
      await _fetchSemestersFromNetwork();
    }
  }
}

Provider 層

Provider 變成單純的 pass-through,不含任何 caching 邏輯:

final semestersProvider = StreamProvider.autoDispose<List<Semester>>((ref) {
  return ref.watch(courseRepositoryProvider).watchSemesters();
});

UI 層

  • .when() API 對 StreamProviderFutureProvider 完全相同,widget 程式碼幾乎不需要改動
  • Stale 資料立即顯示,背景 fetch 完成後 UI 自動更新
  • Pull-to-refresh 呼叫 refreshSemesters(),stream 自動 emit 新資料
RefreshIndicator(
  onRefresh: () => ref.read(courseRepositoryProvider).refreshSemesters(),
  child: ...,
)

清除 cache 的流程

deleteCachedData()
  → 刪除所有 semester rows
  → Drift .watch() 偵測到 table 變動
  → stream emit []
  → repository 偵測到空 list,觸發 refreshSemesters()
  → 網路 fetch 寫入新 rows
  → stream emit [新 semesters]
  → UI 自動 rebuild

不需要 ref.invalidate()。DB 是唯一的 source of truth,stream 自動傳播變動。

範圍

7 個方法橫跨 3 個 repository 需要 stream 版本:

Repository Method 使用 fetchWithTtl 複雜度
AuthRepository getUser() 中——拆分 watch/refresh
AuthRepository getAvatar() 低——涉及 file I/O,可能維持 Future
AuthRepository getActiveRegistration() 低——直接 view .watchSingleOrNull()
CourseRepository getSemesters() 中——拆分 watch/refresh
CourseRepository getCourseTable() 較高——從 view rows 建構複雜 map
CourseRepository getCourse() 中——per-entity TTL
StudentRepository getSemesterRecords() 較高——合併 3 個 query/view

Imperative 方法(login、logout、uploadAvatar 等)維持 Future,不需要改動。

計畫

  1. getActiveRegistration()——最簡單的案例,純 view query,無 TTL。作為 pattern 的 proof-of-concept。
  2. getUser()getSemesters()——引入 watch/refresh 拆分與 TTL 邏輯。
  3. getCourseTable()getSemesterRecords()——複雜 aggregation,需要 .distinct() 避免過多 rebuild。
  4. getAvatar()——File I/O 不是 DB 可觀察的;評估 stream 版本是否有價值,或 Future + invalidation 更簡單。
  5. 移除手動 ref.invalidate() 呼叫——stream 自動處理傳播。

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions