Flutter Home Widget on iOS:背景刷新四天半沒有完成一次
Dart 裡宣告的一小時,iOS 從沒收到;手機鎖著時讀不到 token,背景刷新拿不到資料;修好之後,它在我的手機上四天半沒有完成一次。最後我讓小元件自己去算淨資產。
Flutter Home Widget 陷阱系列・下篇(共兩篇)|上篇:Flutter Home Widget:測試全綠、agent 也說沒問題,桌面上的數字還是錯的

前言:先讓它跑起來,再讓它更新#
上篇講的是桌面上的數字對不對。這篇是另一半:它會不會更新,以及在 iOS 上,是誰決定它什麼時候更新。
沒讀過上篇也沒關係,先講好幾件事:
- 小元件跑在另一個行程,沒有 Dart VM,讀不到 App 的資料庫。App 和它之間只有一塊共享儲存(iOS 是 App Group),App 把算好的字串寫進去,原生端只負責排版
- 背景刷新:App 自己的背景程式,iOS 上是
BGAppRefreshTask,Android 上是 WorkManager - reload:向 WidgetKit 要求一份新的 timeline,請小元件重新讀資料
- 重畫:系統把 timeline 上排好的某一筆 entry 畫到桌面上,不需要任何人醒來
所以小元件上的數字,只會在「App 寫入新資料,而且系統願意重畫」的時候改變。這篇的每一個坑,都卡在最後那半句。
發現這些坑的時候,專案已經有 1300 多個測試全綠,四個 review agent 都看過。它們大多是在 iPhone 實機上、從裝置的 log 裡挖出來的,測試和 review 都碰不到。
這篇能幫你避開什麼
- 小元件在 iPhone 上從來沒有啟動過,而模擬器上一切正常
- 燒光 iOS 的更新額度,小元件完全停止更新;而最可靠的 timeline 被排到十五小時之後
- 你在 Dart 宣告的一小時排程,iOS 從頭到尾沒收到過
- 手機鎖著時讀不到 keychain,背景刷新從來沒有在它該工作的時候工作過;讀不到的設定退回預設值,被悄悄寫進歷史紀錄
- 使用者按了登出,舊的 token 還留在 keychain 裡,下次冷啟動又被自動登入回去
- 背景刷新修到能跑通之後,在我的手機上四天半沒有完成一次,而系統 log 說不出是哪一條規則擋下它
- 一筆持股拿不到價格,小元件為了「不算錯」乾脆整個不算,整份淨資產停在舊數字

1. 那些讓小元件根本沒在跑的錯#
上篇那兩個陷阱有一個共同前提:小元件在跑,只是顯示的東西不對。講背景更新之前,先講真機給我的幾件事,它們讓這個功能根本沒有在執行。
它從加進去的那天起,在 iOS 上從來沒有運作過。
widget extension 只 import Foundation、SwiftUI、WidgetKit。但它的 build configuration 從專案的 base configuration 繼承了 Pods 的 OTHER_LDFLAGS,於是編出來的 appex(包在 App bundle 裡、由系統獨立啟動的執行檔)連結了 57 個 plugin framework。那些 framework 放在 Runner.app/Frameworks,而 extension 的 rpath 沒有包含那個路徑。dyld 每次都在啟動前就殺掉它:
Library not loaded: @rpath/<Plugin>.framework/<Plugin>
Referenced from: Runner.app/PlugIns/<Widget>.appex/<Widget>
WidgetKit 接著回報 descriptors for 0 extensions,小元件從來沒有出現在系統的小工具選單裡。模擬器建置成功、appex 有被嵌進去、App Group 也解析得出來,全部都對。
App 本身每次冷啟動幾秒後就死。 AppDelegate 從來沒有呼叫 workmanager 的原生註冊,Dart 送出的請求打到一個沒有註冊 handler 的 BGTaskScheduler,丟出未捕捉的 NSInternalInconsistencyException。
還有兩個連 crash report 都不會留下的。
寫進一個沒被授權的 App Group 不會丟例外。 identifier 差一個字母,或只在 extension 那邊勾了 capability、忘了 App 這邊也要勾,UserDefaults 安安靜靜寫進一個不存在的容器,小元件讀到空的。後者特別容易犯:在 Apple Developer 後台建 extension 的 App ID 時會很自然地勾上 App Groups,既有的那個 App ID 卻要另外回頭去補。
改動 capability 會讓既有的 provisioning profile 失效,但 Apple 不會更新它的 entitlement。 重新下載拿到的還是舊的那份,必須重新產生:在 developer portal 打開 profile 按 Edit 再存一次,或刪掉讓 Xcode 自動簽章重建。漏掉的症狀是:編得過、上得了架、審核也過,只有使用者桌面上的小元件是空的。

這一節的錯都沒有錯誤訊息,驗收沒辦法靠「有沒有紅字」,只能在真機上逐條確認,第 8 節每一步都附了驗法。
2. 陷阱三:新鮮度押在 reload 上,而不是 timeline#
iOS 對小元件的更新有兩道閘門,兩道都不是你的:iOS 決定背景刷新跑不跑,WidgetKit 決定你送的 reload 認不認。
關鍵在前景和背景。App 在前景時送的 reload 不扣額度,這是 Apple 文件明講的,我在裝置上看到的都是幾乎馬上更新。從背景要求的,從一個每日額度裡扣,系統 log 會明講:
externalRequest(reason: WidgetCenterServer)-immediate-budgeted-1
超過額度之後不是延後,是切斷。chronod(系統端排程小元件更新的程序)開始寫 Ignoring low-priority task,然後小元件完全停止更新。我有一版「每次背景刷新都送 reload」,在裝置上就撞到了這裡。
額度多少,Apple 在 Keeping a widget up to date 裡給了參考值:
- 使用者常看的 widget,一天大約 40 到 70 次,約每 15 到 60 分鐘一次
- 額度是每個 widget 實例各自一份,不是每個 App 一份
- 24 小時的窗口會依使用習慣調整,不保證在午夜歸零

看到額度,很自然會寫一個守衛:畫面不會變就不送 reload。要注意兩點:比對的基準要讀共享儲存裡實際存著的那份,因為背景每次喚醒都是新的 isolate,記在記憶體裡的活不過一次喚醒;而且省 reload 可以,省寫入不行,小元件判斷自己過不過期,讀的正是 payload 裡的時間戳記。
但守衛寫得再對,也只是在省一個不該當主力的東西。
真正該排的是 timeline#
當時的 timeline 是兩筆 entry,一筆現在、一筆下一個當地午夜,policy 設成 .after(midnight)。早上九點打開來看,這等於告訴 WidgetKit:十五個小時之後再來找我。 把排程函式單獨編起來、餵固定時間去量,數字是 worst redraw gap: 900 min。
中間唯一能改變畫面的,就只剩每一次都要花額度的 reload 請求。新鮮度押在要花額度的請求上,而我自己能排時刻表的 timeline,被我排到了十五小時之後。
所以現在改成:
static let freshnessInterval: TimeInterval = 60 * 60 // 每小時要一次新資料
static let redrawInterval: TimeInterval = 15 * 60 // 窗口內每 15 分鐘重畫
「每小時要一次」有效,是因為 getTimeline 在小元件自己的行程裡跑,會重新讀一次 App Group。 上一次背景刷新寫進去的東西,不管它的 reload 有沒有送達,都會在下一次 timeline 被要求時被撿起來。一天 24 次,對上 40 到 70 的額度還有很大餘裕。窗口在當地午夜截斷,因為日增數字不能活過它描述的那一天。

規則:timeline 是唯一由你自己排時刻表的機制,reload 請求不是。 把新鮮度建在 timeline 上,把額度留給「真的有事發生」的時候。你以為在省的資源,很可能根本不是瓶頸。
3. 陷阱四:你宣告的一小時,iOS 從來沒收到#
Dart 這邊寫得很清楚:
const refreshInterval = Duration(hours: 1);
Android 的 WorkManager 會照這個值排。iOS 從頭到尾沒看過它。
workmanager_apple(0.9.10)只把 initialDelay 映射到 BGAppRefreshTaskRequest.earliestBeginDate,frequency 直接忽略。這個註冊沒有傳 initialDelay,預設是零。裝置 log,送出時間 22:21:40(當地):
submitTaskRequest: <BGAppRefreshTaskRequest: <task identifier>,
earliestBeginDate: 2026-08-28 14:21:40 +0000>
earliestBeginDate 就是送出的那一瞬間,實測兩次執行相隔八分鐘。而且每次 App 啟動都會重新註冊,把節奏重設回零。

修法是兩邊都明確給一小時:Dart 註冊時傳 initialDelay,AppDelegate 的 WorkmanagerPlugin.registerPeriodicTask 也帶上 earliestBeginInSeconds: 3600,因為 handler 之後重送下一張請求時用的是原生這邊的值。副作用是每次冷啟動都會送出一個新的「一小時後再說」,越常開 App 的人,背景刷新被往後踢得越遠;好在這群人本來就靠前景發布拿到新數字。
還有幾件 iOS 特有的事:
- 使用者手動滑掉 App,這個功能就停了。 iOS 不會為了
BGAppRefreshTask重新啟動被強制關閉的 App - 這條鏈是自己接自己的。 下一次的請求是在這次 handler 裡送出的,這次沒跑到,就只能等使用者再打開 App
時間到了,iOS 不會等你的 finally。 一次 BGAppRefreshTask 最多三十秒。我的背景刷新先抓價格(預算二十秒),再在 finally 裡重新發布小元件。但 workmanager_apple 的逾期處理只寫一行 log,不取消工作,也不呼叫 setTaskCompleted,於是 iOS 直接把行程收掉。行程被殺的時候,finally 不會執行:它保證的是例外,不是死亡。
裝置上的症狀:快照寫進去了,桌面上的 payload 卻停在七個半小時前。旁邊三個全綠的測試,refresh 都是立刻回傳的假函式,假的東西替正式程式碼做完了它在裝置上做不到的事。
修法是便宜又一定要做的事放前面:每次喚醒先發布一次,再去刷新,刷新完再發布一次。
規則:跨平台排程套件宣告的週期,在 iOS 上很可能只是一個 Dart 常數。 以送出去的請求裡有什麼為準,而那只能從裝置 log 讀出來(換版本要重新確認)。一定要做的事排在最前面,不要放進
finally指望它收尾。
4. 陷阱五:鎖著的手機不會把 token 給你,而且不會說#
這是整個功能裡代價最高的一個,因為它讓背景刷新從來沒有做到它存在的唯一理由:在手機躺在口袋裡的時候,把數字更新好。
flutter_secure_storage(9.2.4)的 IOSOptions 預設是 KeychainAccessibility.unlocked。手機鎖著的時候,每一次讀取都被拒絕,而那正好是背景刷新執行的時候。
拒絕是隱形的:plugin 第一次沒拿到值時會用 synchronizable query 重試,那個重試對「鎖著所以不給」的項目回 errSecItemNotFound,而它被映射成 success-with-nil。Dart 收到一個純粹的 null,沒有例外、沒有錯誤碼、沒有 log。token 從頭到尾都好好地待在 keychain 裡。

結果是:背景刷新呼叫 API 時沒帶 Authorization header,一次都沒有在鎖屏時完成過;小元件還把那個 null 讀成「使用者登出了」,把登出畫面推到一個根本沒登出的人面前。
修法一:改 accessibility。 改成 first_unlock_this_device:開機後第一次解鎖之後就讀得到,而且不會跟著備份移到另一台裝置。
但 accessibility 是在寫入的時候固定的。已經躺在 keychain 裡的項目保持舊設定,直到被重寫一次,所以你發版之後,既有使用者身上什麼都不會變。只在登入時才寫入的項目會卡更久。所以要補一段遷移:App 啟動時把舊設定底下的項目用新設定重寫;遇到鎖屏讀不到就安靜放棄,不能擋住啟動。
修法二:null 不等於登出。 讀不到 token 還有很多其他成因。現在一次看不到 token 的喚醒會記成 skipped,畫面留著上一份好的 payload。真的登出時,前景會從真實的 auth state 發布,一樣會立刻反映到小元件。
同一把鎖的第二張臉:讀不到就退回預設值。 存在 keychain 裡的使用者設定也一樣讀不到。基準幣別讀不到時退回預設幣別,用其他幣別的使用者就會在鎖屏時被算出一筆錯的快照,寫進歷史紀錄。這比顯示錯一次更糟,因為那筆快照會變成之後的日增基準,不會自己好。
第三張臉:登出之後,舊的 token 還在。 flutter_secure_storage 9.2.4 的讀取不管 accessibility,但刪除和 readAll 的查詢會帶上它。用新設定去刪舊設定寫入的項目,比對不到任何東西,回報成功,項目原封不動。使用者按登出,下次冷啟動又被自動登入回去。修法是新舊兩種設定各刪一次,再用測試釘住「舊設定寫入、新設定刪除」這條路。
規則:
null是「我不知道」,不是「沒有」,也不是「預設值」。 跨越鎖定狀態、行程邊界或權限邊界的讀取,「讀不到」要跟「確定沒有」分開:前者放棄這一輪,後者才可以改變畫面。會被寫進歷史的值,永遠不准用猜的。改了儲存屬性,刪除也要照舊屬性再刪一次。
5. 陷阱六:背景刷新四天半沒有完成一次#
前面這些坑修完之後,9 月 24 日早上 7 點 38 分,背景刷新在真機上從頭跑到尾一次:抓到價格、寫進快照、重新發布小元件。
9 月 28 日晚上 8 點 57 分,最後一次完成的時間還是 9/24 07:38。四天十三小時,一次都沒有完成,而這幾天我照常開 App。
我從手機收了 24 小時的系統 log(sudo log collect --device-udid <udid> --last 24h),篩出 dasd(iOS 裡決定背景任務什麼時候執行的系統程序)對這個任務的評分紀錄:
- 2751 筆評分,結論全部是不執行,任務一次都沒有開始
- 每一筆的
Application Policy都是 0.00 - 44% 的
Low Power Mode Policy是 0,98% 的Energy Budget Policy是 0
log 證明不了的是:到底是哪一條規則讓任務不跑。 每筆紀錄都有好幾條規則同時是 0,Apple 也沒有公開它們怎麼合併。BGAppRefreshTaskRequest 能設定的只有最早開始的時間,沒有參數能要求它更常跑。
所以我不再猜原因,只看結果:在我的手機上,背景刷新可以好幾天不完成一次,App 這一端沒有辦法改變它。我能自己排時刻表的只剩第 2 節那條 timeline,它跑在小元件自己的行程裡,不需要 App 醒來。只是小元件醒來時,手上沒有新數字可以畫。
所以我讓它自己算。
App 在前景發布時,多寫一份估值輸入(每個錢包的餘額、持股數量與代號,不含 token)。小元件在 timeline 被要求、而且數字已經超過 15 分鐘時,透過 keychain 群組原地讀 App 的 token,打 App 用的同兩支 API 拿價格和匯率,用跟 App 一樣的規則算淨資產,再用跟 Dart NumberFormat 一樣的方式格式化。

這違反了上篇第 2 節那條「原生端刻意寫笨」的規則。我接受它,是因為沒有別的東西能讓一個沒被打開的 App 的小元件往前走。代價用測試付:Swift 那份估值跟 Dart 的刷新管線對同一份 fixture。
結果:同一支四天半沒有完成背景刷新的手機,9/30 那一天小元件自己估值 14 次,成功 12 次。
兩份程式一起猜錯#
這一段最貴的錯在我對 Dio 的想像裡。匯率 API 的查詢字串,App 交給 Dio 組,小元件得自己組。清單參數我寫成 key[]=a&key[]=b,因為「Dio 大概是這樣編碼的」。axios 的預設就是這樣,PHP、Rails 的後端也這樣收。Swift 的測試期望值、本機 mock server,也都照同一個想像寫。
全部全綠。第一次打真的後端:422。Dio 的預設是重複 key:key=a&key=b。三個地方共用同一個錯的假設,沒有任何一個本機檢查有機會紅。同一個作者,不管是人還是 agent,一次寫完實作、測試和 mock,三樣東西就會共用同一個盲點。
![我以為 Dio 會把清單編成 key[]=a&key[]=b,Swift 端、probe 預期值和 mock 都照這個寫,全綠;Dio 實際送的是重複的 key,打到真的後端回 422](https://images.weserv.nl/?url=res.cloudinary.com/dgesxdqgx/image/upload/v1793464520/articles/flutter-home-widget-traps/images/dio-query.png&w=1400&we&output=webp)
現在期望值是 Dio 寫的:一個 Dart 測試用 RequestOptions(...).uri.query 讓 Dio 自己組出查詢字串,再拿去比 Swift 那邊的格式。
規則:兩份實作要對齊同一個格式時,期望值要由真正的那一方產生,不要兩邊各憑記憶寫。 你寫的 mock 只會證明你的假設自洽。
這條路的上限#
- 小元件不能自己換 token。 refresh token 用過一次就失效,小元件一換,App 手上那把就作廢。所以 App 在打開時,只要 token 快到期就先換掉;定期開 App 的人不會被卡住,太久沒開的人還是會停
- 還沒開過新版的使用者不會有效果。 估值輸入是新版 App 在前景才寫的
- 歷史紀錄要另外補。 小元件只負責顯示,不碰 App 的資料庫,所以沒開 App 的日子歷史紀錄是空的。我讓小元件每天記下最後一個值,App 打開時只補進那天完全沒有快照的日子
規則:不要把「沒開 App 也要更新」押在
BGAppRefreshTask上。 它什麼時候跑由系統決定。要可靠的節奏,就讓小元件在自己的 timeline 上拿資料,或者由伺服器推(iOS 26 的WidgetPushHandler,一樣有額度)。
6. 當裝置什麼都不告訴你#
前面那些坑我花了三輪診斷才挖完,每一輪都只能對著原始碼推理,因為 release build 的背景 isolate 不輸出任何 log。「iOS 從來沒有喚醒它」和「它醒了、但提早返回了」,從桌面上看起來一模一樣。三輪都回答不了那個決定下一步的問題:這個任務到底有沒有被觸發過。
第四輪我先去做輸出:每一次喚醒都在 App Group 裡留一個記號,放在 payload 旁邊,不是裡面,因為值得區分的那幾條路徑,正好都是不會發布 payload 的路徑。dev build 的小元件把這個記號畫在畫面上,一支手機自己就能回答問題。
用模擬器把每一種狀態渲染出來。 排程和鎖屏模擬器證明不了,但畫面長什麼樣它證明得了。我用 ImageRenderer 把小元件真正的 SwiftUI view 在每一種狀態下渲染成圖片,一共二十張,一眼就看到兩件事:payload 從沒抵達時畫出一個空白方塊;診斷記號只畫在「資料正常」的畫面裡,偏偏在最該出現的時候不見。診斷輸出要在最壞的狀態下測。
時間要帶日期。 背景刷新可能好幾天不跑,記號只存時分的話,bg 07:38 看起來像今天早上,其實是四天前。

規則:如果一個失敗在裝置上沒有留下任何輸出,先去做輸出,不要先去猜原因。 我用三輪推理換來的東西,第四輪一張截圖就解決了。
7. 誠實不能靠新鮮度,要寫進資料本身#
經過第 2 到第 5 節,這幾層的分工才排對:

我一開始剛好顛倒:timeline 只排到午夜,新鮮度全部押在最下面那兩層。
而不管怎麼排,總有一天使用者會在「上次更新是三天前」的狀態下看到那個小方塊。那一刻救得了你的不是刷新機制,是 payload 自己帶的時間欄位:更新時間,以及「這組數字在講哪一天」。
這是分類問題,不是機制問題。 排刷新處理的是「盡量新」,那是效能;時間欄位處理的是「就算不新也不騙人」,那是正確性。我把它們當成同一件事,所以每次遇到問題就往上加一層,而答案一直在 payload 裡。
只缺一部分的時候#
小元件只要彙整好幾個來源,總有一次會有一個沒回來,例如一筆持股拿不到價格。
整個不算聽起來比較嚴謹,實際上是把「缺一塊」說成「全部不知道」:整份淨資產停在舊數字,使用者只看到「資料可能過舊」,不知道原因。比較好的做法是缺的那一塊沿用上次的值,其他照算,底下標註「部分價格未更新」。
例外是錯了會擴散的輸入。一檔價格舊了,誤差就在那一檔;一個匯率錯了,那個幣別的每一筆都跟著錯,所以匯率缺了還是整個不算。

缺了哪一部分,就說哪一部分。
8. 照這個順序做,每一步怎麼驗#
兩篇的重點收成一份實作順序,每一步附上怎麼確認它真的對了。
- 先把 Xcode 設對,再寫畫面。 extension 的
OTHER_LDFLAGS自己設,不要$(inherited);App Group 在 App 和 extension 兩邊都勾;動過 capability 就重新產生 profile;AppDelegate要呼叫WorkmanagerPlugin.registerPeriodicTask,identifier 列在 Info.plist 的BGTaskSchedulerPermittedIdentifiers。每個 flavor 各自一個 App Group。驗:otool -L看 appex 連結 0 個@rpathframework;小元件出現在小工具選單裡;用正式簽章 archive 一次裝到手機(第 1 節) - Dart 端寫一個純函式產生 payload。 格式化、翻譯、狀態判斷全部在這裡做,整包字串 JSON 寫進共享儲存(上篇第 2 節)
- 原生端只排版,外加兩種判斷。 看畫面當下的時鐘:過了午夜收起日增、超過一天標示可能過舊;看寬度:放不下才換短格式(上篇第 3、5 節)
- timeline 當主力。 每小時要一次新資料,每 15 分鐘一筆 entry,在當地午夜截斷(第 2 節)
- App 在前景時發布。 前景的 reload 不扣額度;資料每次都寫,要省只省 reload(第 2 節)
- 背景刷新當加分,不當主力。 iOS 上兩邊都明確給
initialDelay;一定要做的事排在網路請求之前。**驗:**把手機鎖著放一天不開 App,數一數背景刷新實際跑了幾次(第 3、5 節) - keychain 明確指定
first_unlock_this_device。 背景讀到的null一律當成「不知道」;刪除時照舊設定再刪一次。**驗:**鎖屏狀態下背景刷新真的完成過;登出後冷啟動不會自動登入回去(第 4 節) - 資料不完整就說清楚。 payload 帶著更新時間和「這組數字在講哪一天」;缺了哪一部分就標哪一部分(上篇第 3、4 節,第 7 節)
- 先做好診斷輸出。 dev build 的小元件畫出「這次喚醒走了哪條分支」,帶日期,在沒有 payload 的狀態下也看得到。**驗:**用
ImageRenderer把每一種狀態渲染出來,包括還沒有資料的狀態(第 6 節)
讀裝置 log:Console.app 過濾 chronod、dasd、BGTaskScheduler;要看一整天的 dasd 評分,用 sudo log collect --device-udid 拉下來再 log show。
上架時兩件小事:extension 包在 app bundle 裡出貨,不需要另外的 App Store Connect 記錄;iOS 的「新功能」說明不要提 Android,我們寫了「iPhone 和 Android 都能用」,被以 Guideline 2.3.10 退回一次。
總結#
跟上篇一樣,這篇的坑沒有一個會讓建置失敗。只勾一邊的 App Group 不會丟例外,鎖屏讀不到 keychain 只回一個 null,退回預設值照樣寫得進資料庫,profile 少了 entitlement 照樣上得了架。
省之前先量。 我花力氣去省 reload 額度,真正的問題卻是 timeline 被排到十五小時之後。
最可靠的排程,可能不在你的 App 裡。 我先押 reload,後來押背景刷新,兩次都輸給同一件事:iOS 決定什麼時候讓你醒來,而且不告訴你根據什麼。最後我能自己排時刻表的,是小元件自己的 timeline。
缺了哪一部分,就說哪一部分。 整個停住也是一種不誠實。
測試和 review 能證明程式碼照你的意思寫,證明不了 iOS 會不會叫醒它、鎖著的手機給不給它 token。這些只能拿一支手機,放一天不開 App,再把 log 讀出來。
← 上篇:Flutter Home Widget:測試全綠、agent 也說沒問題,桌面上的數字還是錯的
參考與相關資源#
- Apple — Keeping a widget up to date:額度數字、免扣情況、timeline reload 政策
- Apple — WidgetKit:timeline provider 與 entry 模型
- Apple — Configuring App Groups:App 端與 extension 端都要設定
- Apple — Sharing access to keychain items among a collection of apps:小元件讀 App 的 token 用的 keychain 群組
- Apple — Updating widgets with WidgetKit push notifications:伺服器推播叫 WidgetKit 重新拿 timeline