-
Notifications
You must be signed in to change notification settings - Fork 7
使用 Drift stream 實現 reactive 資料觀察 #220
Copy link
Copy link
Description
動機
目前 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 對StreamProvider和FutureProvider完全相同,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,不需要改動。
計畫
getActiveRegistration()——最簡單的案例,純 view query,無 TTL。作為 pattern 的 proof-of-concept。getUser()和getSemesters()——引入 watch/refresh 拆分與 TTL 邏輯。getCourseTable()和getSemesterRecords()——複雜 aggregation,需要.distinct()避免過多 rebuild。getAvatar()——File I/O 不是 DB 可觀察的;評估 stream 版本是否有價值,或Future+ invalidation 更簡單。- 移除手動
ref.invalidate()呼叫——stream 自動處理傳播。
Reactions are currently unavailable