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;每次 rebuild 都遍历当前 active CookingSession 并直接调 startOrUpdate,再对当前已非 active 且本轮 key 不在 active 集合里的 session 调 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 触发较晚、进程可能先被杀掉,因此这不是绝对保证。另外,main() 还注册了 MethodChannel('culinatech.app/lifecycle');原生侧触发 onDestroy(以及预留的 onTaskRemoved)时,也会调用同一个 _AppExit.run(...) 走 Live Activity 清理。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。
startOrUpdate({session, currentTempF, ambientTempF, statusLabel}) 自带懒初始化逻辑:如果服务尚未 _initialized,它会先 await init(),然后才继续创建或更新 Live Activity;只有当 init() 抛错并将 _initFailed 置为 true 时,本次调用才会中止。相对地,end() 与 endAll() 不会补做初始化,若服务尚未初始化会直接返回。
| 方法 | 用途 |
|---|---|
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 重生仍指向同一物理探针槽位。
另外,service 对“当前有哪些 activity 正在被追踪”的依据只有进程内的 _activeKeys 集合;这份集合没有持久化,也没有在启动时从 iOS 侧回填。因此 end() 与 endAll() 只能清理本进程已经加入 _activeKeys 的槽位,应用重启后的追踪状态不会自动恢复。
补充:单个 end() 会在调用 iOS 的 endActivity 前先从 _activeKeys 移除该槽位。因此即使 endActivity 抛错,该槽位也不再被本进程追踪;后续同一 end() 调用会直接 no-op,除非之后再次通过 startOrUpdate() 加回该槽位。
补充:结束清理这一步并没有保存“上一轮 active 集合”来做严格 diff。当前实现是遍历本轮 sessions 中 isActive == false 且 deviceId 非空的条目;如果同一 (deviceId, probeNumber) 已出现在本轮 activeKeys 中,则视为该槽位已有新的 active 会话,不会结束。provider 注释也明确说明:由于 service 的内部追踪状态是私有的,所以这里只能通过遍历已完成会话来做近似清理。同样地,如果某个 session 不是“变成 inactive 后仍留在列表里”,而是直接从当前 sessions 列表消失,这段 sweep 也拿不到它,因此不会触发 end()。
补充:provider 在启动/结束两段扫描里都会先过滤 session.deviceId.isEmpty 的会话;这类 session 不会参与 Live Activity 的 startOrUpdate,也不会进入 end 清理分支,因此不会生成空 deviceId 的 activity key。
补充:当当前 (deviceId, ProbeNumber) 查不到 Probe,或 Probe 已断开时,provider 并不会跳过该 session;它仍会继续调 startOrUpdate,同时 statusLabel 置为 'Probe disconnected'。其中 currentTempF / ambientTempF 只有在 probe 本身为空或对应温度字段为空时才会回退为 0.0;如果 Probe 对象仍存在且带有最近一次温度值,代码会继续把这些温度值连同断连状态一起 upsert。也就是说,断连态卡片不是停止同步,而是继续以上述断连 payload 做 upsert。
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,不影响其他功能。
补充:成功路径里的 LIVE_ACTIVITY_START 只会在某个 (deviceId, ProbeNumber) 槽位第一次加入 _activeKeys 时记录一次;同一槽位后续的成功 startOrUpdate() 不会再额外写 success 日志。因此排查“卡片有更新但日志里没有新的 START”时,要按“这是预期行为”理解;更新阶段若出错,代码只会落 LIVE_ACTIVITY_UPSERT_FAIL。
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 系列真机回归测一次。