Per spec 2026-04-29(CM1/CM2/CM3 v4 §iOS D — 实时活动):iOS 16.1+ 在锁屏与灵动岛(Dynamic Island)上原生支持 Live Activities,烹饪进行中可以让用户不解锁、不打开 App 就看到当前温度。本章讲 App 侧的 Dart wrapper、Riverpod 同步、iOS Widget Extension 三层是怎么衔接的。
lib/core/services/cooking_live_activity_service.dart(241 行)—— live_activities 包的 Dart 端封装;按 (deviceId, ProbeNumber) 维护活跃槽位集合;activity-id 不是单独存映射,而是每次通过 ${deviceId}_${probe.number} 现算,对外暴露 init / startOrUpdate / end / endAlllib/core/providers/device_providers.dart —— cookingLiveActivitySyncProvider(副作用 Provider,diff 当前活跃 CookingSession 集合并调 startOrUpdate / end)lib/app.dart —— ref.watch(cookingLiveActivitySyncProvider) 让 Riverpod 真正去 evaluate 这个副作用lib/main.dart —— _AppInitializerState.initState 里 instance.init();_AppExit.run() 里 instance.endAll()(500ms 超时),在应用退出清理路径中会 best-effort 调用 endAll();但 detached 触发较晚、进程可能先被杀掉,因此这不是绝对保证ios/CulinaTechLiveActivity/ —— Widget Extension 的 SwiftUI 源(CulinaTechLiveActivityAttributes.swift、CulinaTechCookingActivity.swift、CulinaTechLiveActivityBundle.swift、Info.plist、SETUP.md)ios/Runner/Info.plist —— 声明 NSSupportsLiveActivities + NSSupportsLiveActivitiesFrequentUpdates;后者放开 ~每分钟一次的默认限速,让 15B 探针温度(每 3-6 秒一次)每包都能推到锁屏pubspec.yaml —— live_activities: ^2.4.3kIsWeb 或 !Platform.isIOS 时,startOrUpdate / end / endAll 直接返回;init() 会先把 _initialized = true 标记为已跳过,再返回(cooking_live_activity_service.dart:81-83、:122、:172、:197)。Widget Extension 这一侧用 @available(iOSApplicationExtension 16.1, *) 兜底——iOS 16.0 装得上但 widget bundle 不会注册,最终也是无声 no-op。
| 方法 | 用途 |
|---|---|
init() |
初始化 live_activities 包,传入 App Group ID。幂等;失败则置 _initFailed = true,后续所有调用直接 return(cooking_live_activity_service.dart:79-105) |
startOrUpdate({session, currentTempF, ambientTempF, statusLabel}) |
创建或更新同一 (deviceId, probeNumber) 的 activity;底层用 createOrUpdateActivity 一调两路,removeWhenAppIsKilled: true 让 iOS 在强退时自行清理(:116-164) |
end({deviceId, probeNumber}) |
结束指定槽位的 activity;不存在则 no-op(:168-191) |
endAll() |
清空所有已追踪 activity,失败也会清本地状态(:196-216) |
Activity ID 由 ${deviceId}_${probe.number} 推导(cooking_live_activity_service.dart:62-63),跨 CookingSession.id 重生仍指向同一物理探针槽位。
cookingLiveActivitySyncProvider(device_providers.dart)watch connectedBoostersProvider + cookingSessionsProvider,每次 rebuild:
(deviceId, ProbeNumber) → Probe 字典把当前温度查出来,避免内层 O(N²) 扫描;probe == null || !probe.isConnected → 'Probe disconnected'、currentF >= session.targetTemp → 'Target reached'、否则 'Cooking' 决定,发 fire-and-forget startOrUpdate;end。设计上 mirror alarmStateProvider——两者都是「booster telemetry + session state → 副作用」这个模式。
startOrUpdate 把字段打成一个 Map<String, dynamic>(_payload,cooking_live_activity_service.dart:223-240):deviceId / probeLabel / meatType / doneness / targetTempF / currentTempF / ambientTempF / statusLabel / startTimeIso。所有数值都先 toStringAsFixed(0) 转字符串,避免 JSON 边界上的 double/int 歧义。
包内部把 dict 写到 App Group UserDefaults(key 形如 <activityId>_<field>);Swift 端 LiveActivitiesAppAttributes.prefixedKey(_:) 反向拼 key 把字段读出来再渲染(ios/CulinaTechLiveActivity/CulinaTechLiveActivityAttributes.swift:34-37、CulinaTechCookingActivity.swift)。ContentState 故意留空,数据全走 UserDefaults。
live_activities 包要求 iOS 端有一个 Widget Extension target——这一步不能从 Flutter 侧脚本化,必须用 Xcode 向导(详见 ios/CulinaTechLiveActivity/SETUP.md):File → New → Target → Widget Extension → 命名 CulinaTechLiveActivity → 勾 "Include Live Activity" → Embed in Runner;然后把向导生成的 Swift 文件替换成本仓库的三个;最后在 Runner 与 Widget Extension 两边都加 App Groups 能力,组 ID 必须是 group.tech.culina.meter(必须与 cooking_live_activity_service.dart 的 _appGroupId 完全一致)。组 ID 自 ccac581(TestFlight prep)从 group.shop.culinatech.app 改成 group.tech.culina.meter——旧 ID 被之前的个人开发者账号 7NUAB9ZN63 占用,Xcode 不能在 Foneric 团队下再注册同 ID。
向导未跑完之前,init() 会 catch 住 App-Groups-not-configured 异常,置 _initFailed = true,后续 startOrUpdate / end / endAll 全部静默 no-op,不影响其他功能。
DevLogService.logAppEvent 在以下时机打事件:LIVE_ACTIVITY_INIT / LIVE_ACTIVITY_INIT_FAIL / LIVE_ACTIVITY_START / LIVE_ACTIVITY_UPSERT_FAIL / LIVE_ACTIVITY_END / LIVE_ACTIVITY_END_FAIL / LIVE_ACTIVITY_END_ALL / LIVE_ACTIVITY_END_ALL_FAIL。出现「卡片不显示」时这是第一手排查信号。
kIsWeb || !Platform.isIOS 时立即返回——Android 端始终零开销_appGroupId 与两个 Xcode target 的 App Groups 设置必须三处完全一致;动其中任一项就要同步动另外两处(deviceId, probeNumber) 推导,不用 session.id——多个会话切到同一探针槽位时复用同一卡片,避免重影createOrUpdateActivity 始终带 removeWhenAppIsKilled: true,强退后锁屏不留旧卡iosDetails.interruptionLevel 与本卡片状态都依赖同样的「safety / target-reached」分类Info.plist 声明的位置statusLabel 的语义对得上Mac 侧 Xcode wiring 已在 commit 8a6fb86(build(ios): wire Live Activities Widget Extension target)完成。d1a3ada 当时只交付了 Dart + Swift 源码,Widget Extension target 与 entitlements 必须用 Mac 上 Xcode 完成。
LIVE_ACTIVITY_INIT 事件已确认触发,appGroupId=group.tech.culina.meter 三处一致CulinaTechLiveActivityExtension 已建(Xcode 26 自动加 "Extension" 后缀),bundle ID tech.culina.meter.CulinaTechLiveActivityCulinaTechLiveActivityAttributes.swift、CulinaTechCookingActivity.swift、CulinaTechLiveActivityBundle.swift) 加入 widget extension target,Runner 未勾选App Groups capability 都设 group.tech.culina.meter(ios/Runner/Runner.entitlements、ios/CulinaTechLiveActivityExtension.entitlements)LIVE_ACTIVITY_START / LIVE_ACTIVITY_UPSERT_FAIL / LIVE_ACTIVITY_END 事件链路removeWhenAppIsKilled: true)按 ios/CulinaTechLiveActivity/SETUP.md 走完 Widget Extension 向导后,还有三件事必须人工修正才能 build 通过:
1. 向导覆盖 CulinaTechLiveActivityBundle.swift。 Xcode 在向导跑完时把仓库里现有的 CulinaTechLiveActivityBundle.swift 用 boilerplate 覆盖了(向导生成的版本引用三个 stub widget,缺 iOSApplicationExtension 16.1 可用性门控)。完成 Step 3 删除 stub .swift 之后必须 git restore 恢复仓库版本。
2. Runner build phase 顺序必须改。 默认顺序触发 Cycle inside Runner; building could produce unreliable results build error。修正后顺序(ios/Runner.xcodeproj/project.pbxproj):
[CP] Check Pods Manifest.lockRun Script(Flutter xcode_backend.sh build)Sources / Frameworks / ResourcesEmbed Foundation Extensions(.appex 嵌入)Embed Frameworks(Flutter)[CP] Embed Pods Frameworks[CP] Copy Pods ResourcesThin Binary(必须最后;Flutter 的 binary thinning 操作整个 .app 目录)3. Widget extension target 必须关 script sandboxing。 Xcode 26 的向导把新 target 的 ENABLE_USER_SCRIPT_SANDBOXING 默认设为 YES;Runner 因 Flutter 的 Updating project for Xcode compatibility 流程设为 NO。两边不一致会让 [CP] script 找不到依赖、build 死循环。Widget extension target 三个 build configuration(Debug / Release / Profile)都必须改成 NO。
ios/CulinaTechLiveActivity/Assets.xcassets/ 是向导自动生成、widget extension target 实际未引用的孤儿资源,已在 ios/.gitignore 忽略。
Flutter 3.41.7 在 iPhone 17 Pro Max + iOS 26.3.1 上启动时崩溃于:
EXC_BAD_ACCESS (SIGSEGV) at 0x0
-[VSyncClient initWithTaskRunner:callback:]
← -[FlutterViewController createTouchRateCorrectionVSyncClientIfNeeded]
← -[FlutterViewController viewDidLoad]
Null deref 出现在 ProMotion 120Hz 触摸刷新率初始化路径。该 crash 与 Live Activities 完全无关(衹要打开 ProMotion 就会触发),但因为是 Mac 验证 Live Activities 时第一道拦路虎,记在这里。
Workaround:ios/Runner/Info.plist 中 CADisableMinimumFrameDurationOnPhone = false(强制 60Hz;该键打开时 App 才用 ProMotion)。
TODO:Flutter 修复后改回 <true/> 并在 iPhone 17 系列真机回归测一次。