昨天決定了 Widget 要顯示哪些資料,但資料真的更新之後,又遇到另一個問題:
使用者剛完成散步,今天的距離明明已經增加了,為什麼桌面上的 Widget 有時候還是舊的?
因為「資料更新」和「Widget 更新」其實不是同一件事情。
今天就來把這中間的流程接起來。
前面已經有 WidgetSnapshot,所以當 local_walk_history 裡的資料發生變化時,下一步就是重新產生 Snapshot,再交給 Native。
一開始最直覺的做法,可能是在不同地方各自處理:
完成散步
↓
更新 Snapshot
↓
reload Widget
登入時也做一次:
登入成功
↓
更新 Snapshot
↓
reload Widget
App 回到前景又做一次,同步歷史紀錄也做一次。
這樣很快就會出現一個問題:到底哪些地方需要負責 Widget 更新?
如果每個地方都自己處理,除了容易漏掉,也很容易同一時間重複更新。
所以最後把一般的 Widget 更新統一收斂到 refreshWidgetSnapshot()
它只負責用目前最新的資料重新產生 Snapshot,然後交給 Native。
實際流程是:
local_walk_history
↓
refreshWidgetSnapshot()
↓
createWidgetSnapshot()
↓
updateWidgetSnapshot()
↓
Native
這裡 RN 不需要再另外處理 iOS 或 Android 的更新方式。
寫入 Snapshot 之後,Native Module 會自己處理後面的 Widget 更新。
iOS 使用 WidgetKit。
Native Module 收到新的 Snapshot 後,會先寫進 App Group 的 UserDefaults:
App Group
└── widget_snapshot_v1
寫入成功後,再通知目前的兩個 Widget:
WidgetCenter.shared.reloadTimelines(ofKind: "TraceWalkTodayWidget")
WidgetCenter.shared.reloadTimelines(ofKind: "TraceWalkWeeklyWidget")
這裡沒有使用 WidgetCenter.shared.reloadAllTimelines(),因為目前只有兩個 Widget,直接指定 kind 就可以。
不過這裡有一件事情要特別注意:reloadTimelines() 不代表 Widget 一定會立刻更新。
它比較像是通知 WidgetKit 這份資料已經變了,可以重新產生 Timeline。
至於什麼時候真的重新讀取資料,還是由 iOS 決定。
所以測試時可能會看到:
完成散步
↓
Snapshot 已經更新
↓
reloadTimelines()
↓
Widget 過幾秒才變
這是正常的。
如果把 Widget 當成一般 App UI,看到沒有立即變化就開始懷疑 Snapshot 沒寫成功,很容易把問題查錯地方。
另外 iOS 對 Widget 的更新頻率本身也有系統限制。
所以 reloadTimelines() 不是可以無限制呼叫的 API。短時間內一直要求更新,系統可能會延後處理。
除了主動 reload 之外,目前 Timeline 也設定了 30 分鐘後重新產生:
Timeline(
entries: [entry],
policy: .after(Date().addingTimeInterval(30 * 60))
)
這次重新產生 Timeline 時,只會重新讀取 App Group 裡現有的 Snapshot,不會重新打後端。
主要是讓 Widget 在使用者沒有重新開啟 App 的情況下,也能重新判斷資料是否已經過期。
Android 使用 Jetpack Glance。
Native Module 會把 Snapshot 寫進 SharedPreferences:
tracewalk_widget
└── widget_snapshot_v1
寫入完成後,再呼叫兩個 Widget:
TodayWalkWidget().updateAll(context)
WeeklyProgressWidget().updateAll(context)
讓 Glance 重新建立 Widget。
這裡有一個比較細的地方:SharedPreferences 寫入使用 commit(),而不是 apply()。
因為寫入之後馬上就會執行 updateAll()。
如果使用 apply(),資料寫入是非同步的,就可能變成:
寫入 Snapshot
↓
updateAll()
↓
Widget 開始讀取
↓
新的資料還沒寫完
所以這裡需要先確定 Snapshot 已經寫入,再讓 Widget 重新讀。
Android 沒有 iOS Timeline 那套更新機制,但也不代表可以一直呼叫 updateAll()。
例如散步過程中的 GPS 每幾秒就可能更新一次。
如果每個 GPS 點都刷新 Widget,實際上沒有什麼意義。
因為昨天已經決定,Widget 不顯示散步中的即時資料,只使用正式完成的紀錄。
所以定位更新不會觸發 Widget 更新。
現在 Widget 有了統一入口,下一個問題就是哪些事件真的需要呼叫它?
目前實際會觸發 refreshWidgetSnapshot() 的地方有:
共同點其實很簡單:這些事件都有可能讓 Widget 要顯示的正式統計發生變化。
以完成散步為例:
完成散步
↓
取得後端正式統計
↓
更新 local_walk_history
↓
refreshWidgetSnapshot()
↓
Native 更新 Widget
這裡特別重要的是順序。
要先更新 local_walk_history,再產生 Snapshot。
如果反過來:
完成散步
↓
refreshWidgetSnapshot()
↓
local_walk_history 才更新
那產生出來的 Snapshot 還是舊資料。
所以 Widget 的更新點不是單純看「某個操作有沒有完成」,而是看正式資料有沒有真的更新完成。
前面把 Widget 的更新都集中到 refreshWidgetSnapshot() 之後,還有一個問題:不同地方的更新可能會在很短的時間內同時發生。
例如 App 回到前景時,會先同步歷史資料:
App 回到前景
↓
開始同步
↓
同步完成
↓
refreshWidgetSnapshot()
但同步的過程本身也可能更新 local_walk_history,進而觸發另一個 Widget 更新。
最後就可能變成:
refreshWidgetSnapshot()
refreshWidgetSnapshot()
如果每次呼叫都從頭執行,就會重複:
讀取 local_walk_history
↓
重新計算 Snapshot
↓
寫入 Native
↓
通知 Widget
這些其實沒有必要在同一時間做兩次。
所以 refreshWidgetSnapshot() 裡面另外用一個 refreshPromise,讓還沒完成的更新可以被後面的呼叫共用:
let refreshPromise: Promise<void> | null = null;
export function refreshWidgetSnapshot(): Promise<void> {
if (refreshPromise) return refreshPromise;
refreshPromise = performRefresh()
.catch(() => {
// Widget freshness must never make login, history, or walk completion fail.
})
.finally(() => {
refreshPromise = null;
});
return refreshPromise;
}
第一次呼叫時,會正常執行 performRefresh()。
如果這時候又有其他地方呼叫 refreshWidgetSnapshot(),因為 refreshPromise 還存在,就直接回傳同一個 Promise,不會再開第二次更新。
可以想成:
第一次呼叫
↓
開始更新
│
├── 第二次呼叫 → 等待同一次更新
│
└── 第三次呼叫 → 等待同一次更新
↓
更新完成
↓
refreshPromise = null
這裡不是 debounce,也不是把後面的更新全部忽略,它只處理同一時間已經在進行中的更新。
例如第一次更新已經完成:
A 開始
↓
A 完成
↓
local_walk_history 更新
↓
B 開始
這時候 B 還是會重新產生 Snapshot。
因為 B 發生時資料已經和 A 執行時不同,如果把 B 直接忽略掉,Widget 最後反而可能停在舊資料。
所以這個機制主要解決的是:
同一時間有好幾個地方要求刷新時,不要讓它們同時做同一件事情。
而不是限制 Widget 一段時間只能更新一次。
另外一個問題是:
如果 Widget 更新失敗怎麼辦?
例如:
這些錯誤都不應該讓主要功能一起失敗。
所以 refreshWidgetSnapshot() 會把錯誤吃掉:
refreshPromise = performRefresh()
.catch(() => {
// Widget freshness must never make login, history, or walk completion fail.
})
.finally(() => {
refreshPromise = null;
});
因此完成散步可以保持:
await saveCompletedWalk();
await refreshWidgetSnapshot();
即使第二步失敗,第一步保存的散步紀錄也不會被影響。
這裡 Widget 的定位就是 Best Effort:有更新最好,但不能為了 Widget 影響 App 本身的主要流程。
下一次有新的更新事件時,再重新產生一次 Snapshot 就可以。
一般更新和登出看起來很像,但實際上不能共用同一個流程。
一般更新是:
有 user
↓
產生他的 Snapshot
登出則是:
user 要離開了
↓
不能再產生他的 Snapshot
↓
直接清除
所以 logout() 不會呼叫 refreshWidgetSnapshot() 而是直接 await clearWidgetSnapshot()
Native 端收到後會:
刪除共享儲存裡的 Snapshot
↓
通知 Widget 更新
這樣才不會在登出的過程中,又用還存在於 App Store 裡的 user 重新產生一次 Snapshot。
最後 Widget 就會回到未登入或空狀態,而不是繼續顯示上一位使用者的散步資料。
做到這裡 Widget 的更新就不只是資料變了 -> reload Widget
而是有一套比較完整的流程:
正式資料更新
↓
refreshWidgetSnapshot()
↓
Coordinator 避免重疊更新
↓
重新產生 Snapshot
↓
Native 寫入共享儲存
↓
Native 通知平台更新 Widget
↓
Widget 重新讀取
同時又把幾個例外分開處理:
一般資料更新 → refreshWidgetSnapshot()
重疊的更新 → 共用同一個 Promise
Widget 更新失敗 → 不影響主要流程
登出 → clearWidgetSnapshot()
GPS 更新 → 不刷新 Widget
這樣之後,其他功能如果需要讓 Widget 更新,也不需要知道 iOS 的 reloadTimelines() 或 Android 的 updateAll()。
只要在「正式資料真的發生變化」的地方呼叫 refreshWidgetSnapshot() 就可以。
到這裡 Widget 從資料準備、顯示內容,到資料更新的整個流程就接起來啦,明天開始又是新的主題。