yii.
← Writing
Flutter 技術 · · 11 min read · Read in English

Flutter Home Widget on iOS:背景刷新四天半沒有完成一次

Dart 裡宣告的一小時,iOS 從沒收到;手機鎖著時讀不到 token,背景刷新拿不到資料;修好之後,它在我的手機上四天半沒有完成一次。最後我讓小元件自己去算淨資產。

Flutter Home Widget 陷阱系列・下篇(共兩篇)|上篇:Flutter Home Widget:測試全綠、agent 也說沒問題,桌面上的數字還是錯的

iPhone 上背景刷新 4 天半沒有完成一次;iOS 一整天放行背景刷新 0 次;小元件自己算淨資產,一天成功 12 次


前言:先讓它跑起來,再讓它更新#

上篇講的是桌面上的數字對不對。這篇是另一半:它會不會更新,以及在 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 說不出是哪一條規則擋下它
  • 一筆持股拿不到價格,小元件為了「不算錯」乾脆整個不算,整份淨資產停在舊數字

下篇四個陷阱:省 reload、宣告的一小時、鎖著的 keychain、iOS 不放行背景


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 自動簽章重建。漏掉的症狀是:編得過、上得了架、審核也過,只有使用者桌面上的小元件是空的。

extension 繼承 Pods 的 OTHER_LDFLAGS 會讓 appex 一啟動就被殺掉;App Group 只勾一邊會讀到空資料;改了 capability 沒重新產生 profile 會讓上架後的小元件空白;AppDelegate 沒註冊背景任務會讓 App 冷啟動後當掉

這一節的錯都沒有錯誤訊息,驗收沒辦法靠「有沒有紅字」,只能在真機上逐條確認,第 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 小時的窗口會依使用習慣調整,不保證在午夜歸零

App 在前景時要求 reload 不扣額度,裝置上幾乎馬上更新;背景要求的從每日額度扣,常看的小元件一天約 40 到 70 次;超過額度之後請求被忽略,小元件停止更新

看到額度,很自然會寫一個守衛:畫面不會變就不送 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 只排到下一個午夜,早上九點看等於十五小時不回來,最壞 900 分鐘不重畫;新排法:每小時要一次新資料,窗口內每 15 分鐘重畫,午夜截斷在 00:00

規則: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 宣告 frequency 一小時,Android 照這個值排;iOS 上 frequency 被忽略,earliestBeginDate 是送出的那一瞬間,實測兩次執行相隔八分鐘

修法是兩邊都明確給一小時: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 裡。

Dart 看到的:token 是 null、沒有錯誤,於是以為使用者登出了;實際上: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 一樣的方式格式化。

小元件自己估值的四步:透過 keychain 群組原地讀 App 的 token;打 App 用的同兩支 API,總預算 10 秒;用跟 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

現在期望值是 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 看起來像今天早上,其實是四天前。

真機小元件截圖:更新於 19:50 是小元件自己估值的時間;bg 07:38 是背景刷新最後一次,其實是 9/24 07:38;最有用的 live 紀錄被省略號截掉

規則:如果一個失敗在裝置上沒有留下任何輸出,先去做輸出,不要先去猜原因。 我用三輪推理換來的東西,第四輪一張截圖就解決了。


7. 誠實不能靠新鮮度,要寫進資料本身#

經過第 2 到第 5 節,這幾層的分工才排對:

五層更新:timeline 由小元件自己排時刻表,是主力;小元件自己估值跟著 timeline 走;前景發布在 App 打開時立即生效、不扣額度;背景 reload 要花額度、可能被拒;背景刷新由系統決定,iOS 上可能好幾天不跑

我一開始剛好顛倒:timeline 只排到午夜,新鮮度全部押在最下面那兩層。

而不管怎麼排,總有一天使用者會在「上次更新是三天前」的狀態下看到那個小方塊。那一刻救得了你的不是刷新機制,是 payload 自己帶的時間欄位:更新時間,以及「這組數字在講哪一天」。

這是分類問題,不是機制問題。 排刷新處理的是「盡量新」,那是效能;時間欄位處理的是「就算不新也不騙人」,那是正確性。我把它們當成同一件事,所以每次遇到問題就往上加一層,而答案一直在 payload 裡。

只缺一部分的時候#

小元件只要彙整好幾個來源,總有一次會有一個沒回來,例如一筆持股拿不到價格。

整個不算聽起來比較嚴謹,實際上是把「缺一塊」說成「全部不知道」:整份淨資產停在舊數字,使用者只看到「資料可能過舊」,不知道原因。比較好的做法是缺的那一塊沿用上次的值,其他照算,底下標註「部分價格未更新」。

例外是錯了會擴散的輸入。一檔價格舊了,誤差就在那一檔;一個匯率錯了,那個幣別的每一筆都跟著錯,所以匯率缺了還是整個不算。

缺一個價格時的兩種做法:整個不算,畫面停住、只寫可能過舊;沿用上次的餘額,照算並在底下標註部分價格未更新

缺了哪一部分,就說哪一部分。


8. 照這個順序做,每一步怎麼驗#

兩篇的重點收成一份實作順序,每一步附上怎麼確認它真的對了。

  1. 先把 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 個 @rpath framework;小元件出現在小工具選單裡;用正式簽章 archive 一次裝到手機(第 1 節)
  2. Dart 端寫一個純函式產生 payload。 格式化、翻譯、狀態判斷全部在這裡做,整包字串 JSON 寫進共享儲存(上篇第 2 節)
  3. 原生端只排版,外加兩種判斷。 看畫面當下的時鐘:過了午夜收起日增、超過一天標示可能過舊;看寬度:放不下才換短格式(上篇第 3、5 節)
  4. timeline 當主力。 每小時要一次新資料,每 15 分鐘一筆 entry,在當地午夜截斷(第 2 節)
  5. App 在前景時發布。 前景的 reload 不扣額度;資料每次都寫,要省只省 reload(第 2 節)
  6. 背景刷新當加分,不當主力。 iOS 上兩邊都明確給 initialDelay;一定要做的事排在網路請求之前。**驗:**把手機鎖著放一天不開 App,數一數背景刷新實際跑了幾次(第 3、5 節)
  7. keychain 明確指定 first_unlock_this_device。 背景讀到的 null 一律當成「不知道」;刪除時照舊設定再刪一次。**驗:**鎖屏狀態下背景刷新真的完成過;登出後冷啟動不會自動登入回去(第 4 節)
  8. 資料不完整就說清楚。 payload 帶著更新時間和「這組數字在講哪一天」;缺了哪一部分就標哪一部分(上篇第 3、4 節,第 7 節)
  9. 先做好診斷輸出。 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 也說沒問題,桌面上的數字還是錯的


參考與相關資源#