iT邦幫忙

2026 iThome 鐵人賽

DAY 25
0
佛心分享-SideProject30

30 天開發一款真正能每天使用的散步 App系列 第 25

Widget(3)——資料變了之後怎麼更新 Widget

  • 分享至 

  • xImage
  •  

昨天決定了 Widget 要顯示哪些資料,但資料真的更新之後,又遇到另一個問題:

使用者剛完成散步,今天的距離明明已經增加了,為什麼桌面上的 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

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:寫完 Snapshot 後重新更新 Glance

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() 的地方有:

  • App 啟動並完成 Session 恢復
  • App 回到前景,且同步成功
  • Google 登入成功
  • 線上取得歷史散步資料並寫入本地
  • 離線 Queue 完成散步同步
  • 刪除散步紀錄

共同點其實很簡單:這些事件都有可能讓 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 更新失敗,不能影響主要流程

另外一個問題是:

如果 Widget 更新失敗怎麼辦?

例如:

  • Native Module 不存在
  • App Group 無法開啟
  • SharedPreferences 更新失敗
  • 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 從資料準備、顯示內容,到資料更新的整個流程就接起來啦,明天開始又是新的主題。


上一篇
Widget(2)——Widget 該顯示什麼資料
系列文
30 天開發一款真正能每天使用的散步 App25
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言