Flutter Home Widget:測試全綠、agent 也說沒問題,桌面上的數字還是錯的
一個沒量過的 +0.00%、一個被截成另一個數字的淨資產。這些錯都不會讓建置失敗,只會靜靜地顯示錯的東西。
Flutter Home Widget 陷阱系列・上篇(共兩篇)|下篇:Flutter Home Widget on iOS:背景刷新四天半沒有完成一次
前言:測試全綠,桌面上的數字還是錯的#
Moniit 是我自己的個人資產管理 App(Flutter,iOS / Android)。它的桌面小元件:我決定規格、讀 review、在真機上驗收,agent 寫實作和測試。8 月底,整個 App 有 1300 多個測試全綠,四個 review agent 各從一個角度看過一輪。
然後我發現,它在 iPhone 上一次都沒有啟動過(下篇第 1 節)。
這不能怪 agent。這兩篇裡大部分的坑,寫法都是「對的」:「拿最新一筆跟前一筆比」在 App 裡一直是對的,縮字縮到下限就截斷是 SwiftUI 的預設行為。照官方文件寫、交給 agent 寫,寫出來的就是這些程式碼。它們會通過編譯、通過 lint、通過測試,然後在某一類使用者的桌面上,安靜地顯示錯的東西。
官方文件會教你怎麼把小元件做出來。這兩篇寫的是做出來之後,它還會錯在哪裡:
- 上篇(這篇):數字顯示得對不對。架構怎麼切、兩個會讓數字說謊的陷阱、Android 這邊的重點
- 下篇:數字會不會更新。iOS 的背景刷新、keychain、WidgetKit 的額度,以及資料只缺一部分時該怎麼顯示
大部分的坑附一條可以直接抄走的規則,下篇最後有一份照順序做的實作步驟。文中的圖都是實測截圖,或從實測資料算出來的。
把數字放到桌面,等於簽了一張你沒讀過的合約#
做這個功能的動機很單純。使用者一天會想確認好幾次「我今天賺了多少」,但唯一的路徑是:解鎖手機、打開 App、等首頁那張卡片載入、再點一下切換成日增值。四個動作,為了看一個數字。
桌面小元件正是為這種「瞄一眼」的場景存在的。裝上去,數字就在那裡。
我原本以為這是個小功能。裝個套件、寫個 SwiftUI view、把數字塞進去,一個下午的事。
實際上 Flutter 那一半確實不難。難的是我沒想過的另一半:那個小方塊不在你的 App 裡面。它跑在另一個行程,讀不到你的資料庫、叫不到你的 state、拿不到你的翻譯檔,而且一天只被作業系統叫醒幾十次。你能給它的只有一包字串。
而你放上去的是使用者的身家。
App 裡的數字不用為新鮮度負責,因為使用者是「打開」App 才看到它,那個動作本身就帶著「我現在去查」的意思。桌面上的數字沒有這個動作。
這篇能幫你避開什麼
- 小元件在使用者沒開 App 的日子裡,顯示一個從來沒有被測量過的
+0.00% - 淨資產 11 位數時,九位數的今日增減被截成
+123,456,7…,讀起來是另一個數字 - 使用者在小工具選單裡看到的預覽,是一張空白的白卡片

1. 小元件不是你的 App,它是貼在冰箱門上的便條紙#
你在寫 Flutter 的時候,畫面上的每個東西都活在同一個世界裡:widget 讀 provider,provider 讀 repository,repository 讀資料庫。要更新畫面就改 state,framework 幫你重畫。
桌面小元件比較像你貼在冰箱門上的便條紙:上面寫什麼,就是你上次寫上去的樣子,你人不在家它也不會更新。
技術上的限制是這樣的:
- iOS 的 WidgetKit extension 是獨立的行程,記憶體額度遠比 App 小(常見說法是 30 MB 上下,Apple 沒有公布數字),而且沒有 Dart VM。你的 Flutter engine 在那裡不存在。
- Android 的 App Widget 走
RemoteViews,畫面實際上是由 launcher 的行程渲染的。你交出去的是一份「怎麼排版」的指令,launcher 拿著它去畫。 - 兩邊都打不開 Drift、叫不到 Riverpod provider、讀不到
.arb翻譯檔。你 App 裡那一套網路層也不在那裡:Dio、攔截器、token 刷新,一個都沒有。
一開始唯一的通道是一塊共享儲存。iOS 走 App Group 的 UserDefaults,Android 走 SharedPreferences。你能塞進去的就是字串。
iOS 後來多了四條,都是為了讓小元件自己估值(下篇第 5 節)。現在的全貌是這樣:

架構因此是被限制決定的,不是我挑的:App 算好 → 寫進共享儲存 → 通知系統重畫。
(home_widget(0.9.3)套件有另一條路:renderFlutterWidget,把 Flutter 畫面渲染成圖片貼上去。我沒用,理由在下一節的結尾。)
2. 所以架構只有一條規則:原生端刻意寫笨#
既然通道只能傳字串,接下來的問題就是:哪些事情在 Dart 做,哪些在 Swift / Kotlin 做?
我的答案是一條規則,只有一句話:
原生端的程式碼是刻意寫笨的。
每一個數字的格式化、每一個字的翻譯、每一個狀態的判斷,全部在 Dart 算完,然後寫成一包「string → string」的 JSON 丟過去。Swift 和 Kotlin 只做一件事:把這些已經是最終樣貌的字串排到版面上。

所以 payload 長這樣(節錄):
{
"state": "ready", // ready / empty / signedOut
"trend": "up", // up / down / flat(語意,不是顏色)
"netWorthText": "1,240,500", // 已格式化,不帶幣別符號,見第 5 節
"netWorthShortText": "124.1萬", // 放不下時的短格式(Android 用)
"dailyRateText": "+1.24%", // 已加正負號
"dailyValueText": "+15,200",
"labelNetWorth": "淨資產", // 已翻譯,widget 不碰 .arb
"updatedAtText": "更新於 14:32",
"updatedAtEpochMs": "1756450320000", // 給原生做 stale 判斷用
"dailyFigureDateIso": "2026-08-29", // 這組日增數字在講哪一天
"localeName": "zh_TW" // App 的語言,不一定是手機的語言
}
第一版的原生端少到有點反直覺:Swift 加 Kotlin 不到八百行,全部是排版。
這樣切的理由只有一個:flutter test 碰不到 Swift。
任何寫進原生端的邏輯,都是自動化測試永遠看不到的邏輯,而且它一定會跟 Dart 那邊的雙胞胎慢慢長歪。你在 Dart 改了負數的顯示規則,Swift 那邊不會知道。半年後兩邊不一致,你不會有任何紅燈。
所以規則反過來寫:要新增任何判斷,先問它能不能塞回產生 payload 的那個函式。 那是一個純函式:輸入是兩筆 snapshot 加上使用者偏好,輸出是一包字串,沒有資料庫、沒有網路、沒有 Riverpod。
順帶回答第 1 節留下的問題:為什麼不用 renderFlutterWidget 把畫面渲染成圖片?因為圖片是死的。一張 PNG 沒辦法在午夜自己把「昨天的日增率」收起來,也沒辦法依寬度決定要不要換成短格式。走那條路,widget 就永遠只能顯示 App 上次醒著時的樣子,連下一節那幾件事都做不到。
這條規則我後來破了一次,在下篇第 5 節。破的方式是這條規則的延伸:Swift 那份估值程式碼,跟 Dart 對同一份 fixture。
3. 例外:時鐘和寬度只能在畫面上讀#
上面那條規則有例外。第一版只有兩個,現在有四個,而且每多一個我都先問過:它能不能塞回 Dart?
前三個需要的是**「畫面被畫出來的那一刻」的時鐘**,不是「App 寫入資料的那一刻」的時鐘:
dailyFigureDateIso是不是已經落後裝置的今天了?(過了午夜要把日增數字收起來)updatedAtEpochMs是不是超過 24 小時了?(要顯示「資料可能過舊」)- payload 是不是今天發布的?(新用戶第一天的提示只在當天成立,見第 4 節)
這幾件事 Dart 沒辦法在寫入的當下決定,因為寫入的當下它們都還是「否」。是時間往前走,答案才變成「是」,而那時候你的 App 早就不在了。
第四個需要的是畫面本身的寬度:這個數字放不放得下? 寬度取決於裝置、小元件的尺寸、使用者拉了幾格、字級設定,只有排版的那一刻知道(第 5 節)。

iOS 這邊有一個機制剛好可以處理它,但要先講清楚 WidgetKit 是怎麼運作的,因為它跟「App 想畫就畫」完全不同。
你交給系統的不是畫面,是一份預排。 那份預排叫 timeline,裡面是一串 entry,每一筆 entry 就是一組「幾點、用什麼內容畫」。你把 timeline 交出去之後就可以下班了;時間到了,系統自己拿出對應那一筆去畫,不需要(也叫不動)你的 App。
所以第一版讓 timeline 排兩筆 entry,而不是一筆:一筆是現在,一筆排在下一個當地午夜。同一包 payload,用一個已經走過午夜的時鐘重新評估一次。這樣「午夜收起昨天的數字」就變成決定性的,不用祈禱 WidgetKit 剛好在那個時間叫醒我。現在的排法是每 15 分鐘一筆,排到一小時後或當地午夜(取較早的那個),所以午夜那筆一樣一定在裡面。
圖裡 00:00 那一列值得單獨講:淨資產照常顯示,收起來的只有日增數字。 這不是隨手決定的。淨資產這個數字只宣稱「我是某個時間點的值」,它配著時間戳一起出現,沒有過期問題;日增率則暗含了一個對「今天」的宣稱,一過午夜那個宣稱就是假的。
同一包資料,兩個欄位的保存期限不一樣。這是我做完這個功能才想清楚的事。
判斷「過午夜了沒」,原生端要拿裝置的今天去跟 payload 裡的日期比。這裡有一個我自己看不出來的錯,是 review agent 平行 review 時抓到的:iOS 原本用 Calendar.current 產生今天的日期字串,而它讀的是「設定 → 一般 → 語言與地區 → 曆法」。App 本身沒有曆法功能,但 iPhone 允許使用者把曆法改成佛曆或和曆;在佛曆下今年是 2569,跟 Dart 寫進 payload 的 2026-… 永遠不相等,日增數字就永遠不顯示。修法是比對時把曆法固定成西曆(Calendar(identifier: .gregorian)),時區照樣跟著使用者。Android 用 Locale.US 格式化日期,本來就沒事。這一條是從程式碼讀出來的,我沒有在真機上切換曆法重現過。
顏色也是同樣的道理,但方向相反:它必須交給原生。系統的深淺色模式可能在 App 沒執行的時候被切換,所以任何烘焙進 payload 的顏色,到了渲染的時候都可能已經是錯的。payload 只帶 trend: "up" 這種語意,兩邊各自透過自己的主題資源去解析:Android 是 values/ 跟 values-night/ 兩份顏色資源,iOS 是 asset catalog 裡的 color set。
這一點 Apple 的文件講得比我更直接:系統外觀變化和 locale 變化,不要從你的 App 請求 timeline reload,系統會自己更新 widget。 交給原生資源不只是比較乾淨,是你根本不該為了換個顏色去花那個額度。
4. 陷阱一:一個沒有被量過的「今天」#
前面都是設計。接下來是這個功能教我東西的部分。
程式碼寫完、測試全綠之後,我問了一個問題:「使用者今天一次都沒打開 App,這個 widget 會顯示什麼?」
當時的邏輯是「拿最新的 snapshot,跟今天之前的最後一筆比」。聽起來沒問題。
但在一個沒開過 App 的日子裡,「最新的 snapshot」和「今天之前的最後一筆」是同一筆。
於是 widget 算出 +0.00%,然後蓋上今天的日期。
翻譯成人話,它在說:「我今天量過了,你的資產沒有變動。」
而事實是:今天根本沒有量過。

對一個財務 App 來說這不是「資料舊了一點」。這是主動誤導。使用者看到 0.00% 會安心,而那份安心是憑空發給使用者的。
更糟的是第二層:那個蓋上去的今天日期,剛好滿足了第 3 節那個原生端的午夜檢查。那道防線反而被拿來對付它自己。午夜檢查本來就是為了在沒有新資料時把數字收起來,結果有人餵了一筆假的「今天量過」戳記給它。
修法是讓 widget 讀跟 App 內卡片同一組資料:一筆「今天的最後一筆」,一筆「今天之前的最後一筆」。今天那筆不存在,日增率就是 null,日期戳記就不蓋。
// 日增數字需要一筆「今天」量到的資料。退回用最新的那筆,
// 等於拿昨天的收盤跟自己比,然後回報一個平盤,宣稱了一次
// 從未發生的測量。
final hasDailyFigures = todaySnapshot != null && baselineSnapshot != null;
// ...
dailyFigureDateIso: hasDailyFigures ? _isoDate(now) : '',
有趣的是,App 裡那張卡片沒有這個問題。它一直都是拿「今天的最後一筆」配「今天之前的最後一筆」,今天沒資料就不顯示。
同一個口徑,在 App 裡是對的,複製到 widget 就變成謊話。因為 widget 是在使用者沒開 App 的那些日子裡被看見的,而那正是 App 內卡片永遠不會遇到的情境。這個 bug 不在程式碼裡,它在「誰會在什麼時候看到它」這件事上。
而這個等待狀態的文案本身也是正確性的一部分。我原本寫的是「明日起顯示日增」,後來改成「今日更新後顯示變動」,理由是數字會在當天第一次更新落地時就回來:下一次背景刷新,或下一次打開 App。剛過午夜看到「明日起」,讀起來像是一個已經放棄的小元件。
這個改法只對了一半。它對「今天還沒量過」是對的,對「從來沒有前一天」是錯的。新用戶的第一天,根本沒有今天之前的紀錄,今天更新幾次都不會出現日增。一個新用戶看到「今日更新後顯示」,會一直回來刷新,等一個今天不會來的數字。
所以現在 payload 帶兩句話:「今日更新後顯示變動」和「明日起顯示日增」。第二句只在完全沒有基準時給,而原生端只在 payload 發布的那一天用它;過了午夜,「明天」已經是今天,就改回第一句。這就是第 3 節那張清單的第三個時鐘。文案講的應該是「什麼時候會回來」,而那個時間點,要看缺的是今天還是昨天。
規則:日增數字必須同時拿到「今天的量測」與「今天之前的基準」,缺一就不給數字,也不蓋今天的日期戳記。 退回用最新那筆當基準,等於宣稱一次從未發生的測量。缺的是哪一個,等待文案就講哪一個。
5. 陷阱二:少一位數的淨資產,比小一號的字糟糕得多#
長數字是每個顯示金額的小元件都會撞到的問題。第一步是拿掉幣別符號:每個數字都掛著符號時,小尺寸上連普通的金額都會被截成 -1.8...。但這只是把問題往後推幾位數,用韓元和日圓去想就知道。
同樣的身家,用 KRW 記比台幣多兩位數左右,用 JPY 多一位。一個資產一千萬美元左右的 KRW 使用者,今天漲 1% 是 +140,000,000。小尺寸半寬的欄位裡,這樣的數字會先縮到 45%,縮到底還放不下,SwiftUI 就用省略號截掉。

+123,456,7… 讀起來不像一個被截斷的數字,像另一個數字。
量了之後才發現,寬度被吃掉了一大塊。iOS 17 之後,WidgetKit 自己會在內容外面留一圈邊界,而我們的 view 又自己加了 12pt padding。在 iPhone 17 模擬器上量,文字離卡片邊緣 30pt(所以 WidgetKit 那圈約 18pt):164pt 寬的卡片,左右加起來丟掉三分之一以上。只在 iOS 17 以前保留那層 padding 之後,小尺寸的內容寬度多出大約兩成。
中尺寸是在高度上出事。每日數字原本是「標籤一行、數字一行」疊兩層,整體比 widget 高出幾個 pt,而 SwiftUI 處理垂直溢出的方式,是把每一個允許縮小的文字一起縮小。於是今日百分比和底下的時間被縮到一半,旁邊的增減卻是正常大小。

中尺寸改成標籤和數字同一行之後,高度省下來,字級反而能加大:標籤用 .subheadline、數字用 .title3(預設字級下是 15pt、20pt)。
放不下的時候說什麼#
長度的規則最後只剩一條:放得下就顯示完整數字,永遠不出現「…」。
iOS 用 ViewThatFits 依序試:完整數字的三個字級,由大到小;三個都放不下,才換成短格式,像是 123.5億、14억、12.35B。iOS 15 沒有 ViewThatFits,維持原本的縮字。

短格式有三個細節,每一個都差點做錯:
- 跟著 App 的語言,不是手機的語言。 使用者可以在 App 裡選日文、手機維持中文,所以 payload 多帶了
localeName。 - 中日韓用各自的萬/億(万、億、만、억),其他語言用 K、M、B,小數點一律是「.」。 西班牙文原本的寫法是
12,35 mil M,那個小數逗號會跟完整數字的千分位逗號混在一起。 - 百分比永遠不縮寫。 四捨五入到整數之後,
-99.99%會變成-100%,看起來像全部虧光。百分比最長也就十一個字元,縮字就好。

Android 難在另一件事:RemoteViews 沒辦法問「這段字放不放得下」。版面是 launcher 在它自己的行程裡畫的,你交出去的只是一份指令。
所以 Android 分兩層做。第一層交給 TextView 的 autoSizeTextType(API 26 以上),淨資產在 26sp 到 18sp 之間自動縮。第二層在 provider 裡:用 Paint.measureText 量完整數字在最小字級時有多寬,跟 launcher 回報的 widget 寬度比,放不下才換成短格式。Android 的短格式由 Dart 算好放進 payload;iOS 由 Swift 自己算,兩份對同一份 fixture。還有一個容易漏的:使用者調整 widget 大小時,系統不會送 update,要自己在 onAppWidgetOptionsChanged 裡重畫。

全部量一遍的結果在這張表。「—」是我沒量的組合,我寧可空著也不用猜的填。

第一眼#
同一輪還修了兩個第一眼的問題。它們不影響數字對不對,影響的是使用者會不會把小元件加上去。
Android 的小工具選單裡,Moniit 的預覽是一張空白的白卡片。previewLayout 直接指向正式的版面,而正式的版面在 provider 填字之前沒有任何字。那是使用者決定要不要加這個小元件的唯一一刻。現在預覽用一份帶範例數字的版面。
另一個是 App 還沒發布過任何資料的時候:新安裝還沒登入,或升級後還沒打開 App 就先加了小元件。畫面上只有一個「淨資產」標題,看起來像壞掉。現在兩邊都多一行「打開 Moniit 以顯示淨資產」。這行字放在 extension 自己的在地化資源裡(iOS 是 extension 的 .lproj,Android 是 res/values*/),因為缺的正是帶著其他標籤的那包 payload。

6. Android 這邊#
Android 的坑比 iOS 少,原因很實際:WorkManager 的週期工作,在模擬器上照宣告的週期跑(實際裝置上仍受 Doze 與待機分組影響);iOS 則是由系統決定什麼時候讓你跑(下篇整篇都在講這件事)。但它有自己的規矩,前面散在各節,這裡收在一起:
- 畫面是 launcher 畫的。
RemoteViews交出去的只是排版指令,你沒辦法問「這段字放不放得下」,所以長數字要靠autoSizeTextType,再自己用Paint.measureText量(陷阱二) - 使用者調整大小時,系統不會送 update,要自己在
onAppWidgetOptionsChanged裡重畫 - 深淺色交給
values/和values-night/,payload 只帶trend這種語意(第 3 節) previewLayout要有範例數字,不然小工具選單裡是一張白卡片
還有一件是刻意不做的:Android 的小元件不自己估值。 iOS 版在 App 不醒來的時候,靠小元件自己抓價格算淨資產(下篇第 5 節),這需要把每一筆持股的數量和代號,放在小元件讀得到的地方。Android 對應的位置是 SharedPreferences,沒有加密。既然 WorkManager 本來就會跑,就沒有理由把持股攤在一個沒加密的檔案裡。
最後要講清楚驗證的範圍:Android 這一半只在模擬器上驗過(Pixel,API 35)。有些 Android 廠牌(例如小米、OPPO、vivo)會更積極地停掉背景工作,我還沒在任何一台實機上測過。下篇那些坑全是靠 iPhone 實機抓到的,Android 這邊目前沒有同等的證據。
總結#
這兩個陷阱沒有一個會讓建置失敗。「拿最新一筆跟前一筆比」在 App 裡一直是對的,縮字縮到下限就截斷是 SwiftUI 的預設行為。它們全部通過編譯、通過 lint、通過一千多個測試,然後在某一類使用者的桌面上顯示錯的東西:沒開 App 的人、資產位數比較多的人。
做完這一輪,我驗收的方式變了:不只看 agent 回報測試通過,而是列出情境,請它逐一回答「這時候小元件上顯示什麼」,例如使用者幾天沒開 App、剛過午夜、資產有 11 位數、剛安裝還沒登入。App 裡的數字是使用者主動打開才看到的;桌面上的數字沒有這個動作,它會在你沒想到的日子、沒想到的寬度被看見。agent 不會自己去想這些情境,測試也不會,因為它們預設畫面是在「App 正在跑」的時候被看到的。情境要由你來列。
這篇處理的是「顯示的東西對不對」。下篇是另一半:這個數字會不會更新。 在 iOS 上,這比我想的難很多:背景刷新在我的手機上四天半沒有完成一次,鎖著的手機讀不到 token 而且不會說,最後我讓小元件自己去算淨資產。
下篇最後有一份照順序做的實作步驟,兩篇的重點都收在裡面。
→ 下篇:Flutter Home Widget on iOS:背景刷新四天半沒有完成一次
參考與相關資源#
home_widget:Flutter 端的共享儲存與 widget 更新橋接- Android — App Widgets overview:
RemoteViews與AppWidgetProvider的限制 - Android — Autosizing TextViews:
RemoteViews裡也能用的自動縮字 - Apple — ViewThatFits:依序試完整數字的幾個字級,放不下才換短格式
- Apple — WidgetKit:timeline provider 與 entry 模型