✅ 已归档:本重构已合并到 main。
lib/core/transport/现在是 main 的一部分,CM4 SET_TARGET 已接线,命令派发统一走BoosterTransport。本页保留作为重构史料 + 下一次类似拆分(例如新增设备家族)的参考范本。
当前 main 的现状描述见 仓库结构 与 BLE 协议。
| 项 | 值 |
|---|---|
| 完成时间 | 2026-05 |
| 分支(已合并) | refactor/transport-split |
| 重构 commits | 12 / 12 ✅ |
| 拆分之后的修复 | 8 (见 §拆分之后的修复) |
| Fork 点 | 9ed3852 |
SET_TARGET cycle(b4d4ef1 → ba53f91 revert)暴露了 CM4 与 legacy 协议在 Dart 层共享 dispatch 点的脆弱性 —— 协议级修复每次都在 BleDeviceService 里加新的 if (deviceId.startsWith('CM4_')) 分支。本重构把家族判别下推到 transport 层,让 feature 代码 transport-agnostic:上层只需 transport.setProbeTarget(...),每个 transport 实现自己的 wire format(legacy 的 0x55XX vs CM4 的 ASCII)。
零行为变化,纯 additive。
9f552a7 — 新建 lib/core/transport/booster_transport.dart:abstract class BoosterTransport + BoosterFamily { legacy, cm4 } enum + String.boosterFamily 分类 extension。零调用点。8d70e0f — 拆出 lib/core/transport/booster_family.dart(避免 core/models/booster.dart 经 BoosterTransport 间接 import services 形成循环依赖);为 Booster 加 family getter;4 处 UI inline startsWith('CM4_') 替换为 booster.family.isCm4。12ca953 — LegacyBoosterTransport 实现 wire-level legacy 命令:setProbeTarget / setUnit / muteAlarm / queryStatus。BleDeviceService 的对应方法 delegate 到 transport;状态记录(lastUserTargetWriteAt、persistence、ack-with-retry)暂留 service 层。c072acf — Cm4BoosterTransport 实现 + CM4 SET_TARGET 首次接线。调用 CM4Command.configureProbe(cm4_protocol.dart:159 已存在的 builder)发送 SET_<probe>=0<unit><temp><meat><doneness>。cooking_page._pushTargetToDevice 不再对 CM4 早返。保守 °F > 99 处理:跳过 + 记 CM4_SET_TARGET_SKIP 日志(Q10 边界)。meatType / doneness 默认 (1, 3)。fef011f — dispatch 点崩塌:3 处 family-branch 调用点(device_providers._broadcastUnit、ble_device_service.muteAllAlarms、cooking_page._pushTargetToDevice)改为单一 transport.x(...) 调用。删除冗余的 setLegacyTarget / setLegacyTempUnit / setCm4Target 方法。务实 scope:wire-format 选择移到 transport 层;timer 所有权暂留 _DeviceState —— 完整 lifecycle 迁移需要 HW gates between commits,对单次 delivery 风险过高。
def2978 — heartbeat bytes / interval / Android MTU 等 wire-format 决定移到 BoosterFamily extension:heartbeatIntervalSeconds、heartbeatBytes、requestsLargeMtu。BleService._sendHeartbeat 不再 inline startsWith('CM4_') 决定字节。469da3e — Audit 发现 DeviceConnectionManager.sendCommand 零生产调用点(命令一直走 BleService.sendCommandTo 直接),plan 假设的 "MQTT failover for command publish" 是 dead code。删除。autoConnect=true 决策保持现状。01bf418 — _legacyTransports + _cm4Transports 合并为单一 Map<String, BoosterTransport> _transports。_transportFor 是唯一访问点;caller 只见 BoosterTransport 抽象类型,不见具体实现。ed6df9e — 移除 LegacyCommand.isLegacyDevice。最后一处 caller(BleDeviceService:1116)改用 deviceId.boosterFamily.isLegacy。3c9441b — 移除 Probe.commandPrefix / commandPrefixLegacy(零调用点 dead code)。probe.dart 的 cm4_protocol.dart import 顺带移除,model ↔ protocol 分层恢复干净。a3304fc — sweep 剩余 inline startsWith('CM4_'):ble_service.dart:1291 改用 family.isLegacy。仅保留正当的 model-level 前缀检查(deviceTypeLabel、getProbeCount 区分 CM1/CM2/CM3/CM4 各自的型号)和 canonical classifier 自身。39acbce — MockBoosterTransport recording mock + 8 个 smoke tests(family 分类、heartbeat 常量、mock 调用记录)。完整 service-level coverage 留作 follow-up。这一节里的 bug 大多数也存在于 main——之所以 branch-only 是因为不想在测试周期里扰动 main。merge 之后已统一进入主线。
824ec3a,2026-05-01)Symptom(HW test 视频,CM1_E4EF): UI 顶部 banner 一直显示 Reconnecting to CM1_E4EF...,cooking 界面也是断连样式,但 BLE 实际是健康的——RSSI -27 ~ -33 dBm,RX 计数稳定增长 292→334,TX 按周期发 55 b1(45s 锁刷新)和 55 ae 00 00(30s settings query)。alarmStateProvider 也因此短路(line 1636 的 isGloballyDisconnected gate),导致 full-screen WarningPage / 铃声不响——只有系统通知栏还在工作。
Root cause: ble_device_service.dart _onUnexpectedDisconnect 在 line 1363 的 _reconnectQueue.add(deviceId) 没有 gate ds != null。_forgetDevice 流程是:先 removeFromReconnectQueue 清队列,然后 disconnectDevice 把设备从 _devices 删掉,BLE 层再断开——这次 BLE callback 进来时 ds = _devices[deviceId] 已是 null,但无条件的 _reconnectQueue.add 把刚被忘掉的设备又塞回了队列。队列从此永远非空,每次成功回连都被 _setState(_reconnectQueue.isEmpty ? connected : reconnecting) 判定为 reconnecting。HW test 视频里有 2 次 FORGET_DEVICE(CM1_E452、CM4_308A),刚好踩中这条路径。
Fix: 把 line 1362 的 queue.add + _startReconnectLoop() gate 在 ds != null && deviceId.isNotEmpty。设备已不被追踪就不该重入队列。
3d7e65f,2026-05-01)Symptom(自审计发现): C5 的 dispatch 崩塌把 cooking_page._pushTargetToDevice 的 family-branch 折成单一 setProbeTarget 调用,但同时丢掉了 OLD 代码里的 early-return 守卫——legacy 设备如果探针地址还没收到 6 字节,整个 dispatch 就跳过;而 NEW 代码里 dispatch 会跑,BleDeviceService.setProbeTarget 内部先记 lastUserTargetWriteAt + 持久化目标温度,然后 LegacyBoosterTransport.setProbeTarget 才检查地址长度并 log SET_TARGET_SKIP 早返。
副作用:TAPD #5 echo-suppression 时间戳被记下来了,但 wire 写其实没发;接下来一个 12B reconciliation 看到时间戳很新就 suppress 了——App 和中继盒静默背离,没有可见的 "目标值跳回旧值" 错误反馈。窗口很窄(探针刚激活、第一个 15B 还没到的 ~3s 内用户调目标温),但比起 OLD 的可见失败,silent divergence 更糟。
Fix: cooking_page 重新引入 early-return,gate 在 booster.family.isLegacy && probeAddress.length != 6 上——CM4 不需要地址,可以放行。同时把 core/models/booster.dart 的 import 加回来(C5 折叠时被认为不必要而删掉,现在又需要 family extension)。
1aaa284,2026-05-01)触发: v5 doc review 把 v4 §15 的 ">101°C 触发 HI 并钳制到 101°C" 改成了双段:
| 范围 | 行为 |
|---|---|
| ≤ 101 °C | 显示真实数值 |
| 102 ~ 120 °C | cooking-page 显示 "HI",但 WarningPage 全屏读数显示真实温度 |
| > 120 °C / out-LUT | "HI" + 钳制到 120 °C |
老规则把 102 ~ 120 °C 的真实数据压平成 101 °C,丢失了 WarningPage 上的精度。
Fix:
AlarmThresholds 新增 internalHardClampC = 120(internalHardClampF = 248.0)internalHiClampC = 101 由"触发 + 钳制"重新定义为"仅触发 HI flag"internalMaxDisplayF 改成对齐 internalHardClampF(WarningPage 上限从 213.8 °F 提到 248 °F)cm4_data_parser line 666 的钳制逻辑:trigger flag 仍在 >101,但只有 >120 才把数值钳到 120;in-LUT 的 102 ~ 120 保留真实数值isInternalTempAboveRange flag 的下游消费者(cooking-page "HI"、internalOverTemp 报警)逻辑不变。
8ea1074,2026-05-01)两个自审计发现的小 polish 项目,一起 commit:
BleDeviceService.disconnectDevice 新增 _transports.remove(deviceId) —— transport 是 _ble + deviceId 的无状态包装,原本不删掉只是无害的 map 增长,但反复 forget / re-pair 会留垃圾。LegacyBoosterTransport / Cm4BoosterTransport 的 stream getter(dataStream / connectionStateStream / rssiStream)从 throw UnimplementedError 改成 const Stream.empty()。当前没有 caller 订阅这些流,但万一未来 refactor 接进去,silent empty stream 比 runtime crash 安全得多。connect() / disconnect() 的 UnimplementedError 保留——那两个本来就是要响的"缺口",要 loud。cfd00bb,2026-05-02)V2454A(1080-wide)上 hardware test 时出现 Flutter 黄黑警告条:"RenderFlex overflowed by 9.4 pixels",发生在中继盒卡片 status 行(status==boosterShuttingDown 时本地化字符串 "Probe docked, shutting down…" 装不下 dot+name+info+battery+signal)。
Fix: status Text widget 包进 Flexible(fit: FlexFit.loose) + maxLines: 1 + TextOverflow.ellipsis,长字符串末尾 ellipsize 而不溢出。同时把级联 if/else-if 折叠成单一三元表达式,结构更清晰。
Pre-existing 隐性 bug —— 在 iPhone(更宽)和短 status 字符串("Reconnecting" / "Booster off")下不会触发;refactor 测试时才显形。
8d62648,2026-05-02)Vivo V2454A profile-mode 测量显示 raster phase 卡在 16-18 ms(120 Hz 屏幕的 8.3 ms 预算超 2 倍)。两个针对性 fix:
A. BG-MON 通知节流(notification_service.dart):
startBackgroundMonitoring 之前每个 booster emit 都触发一次 _notifications.show()(即 ~3-4 Hz × MethodChannel 往返)。即使 onlyAlertOnce: true 抑制了用户可见的 alert,底层调用仍阻塞 UI 线程 ~1-2 ms。改为缓存上次文本,未变则 short-circuit。
B. Android 关闭 BackdropFilter blur(cooking_page.dart):
cooking page 一次合成 6 个 frosted-glass tile + 1 badge × BackdropFilter+ImageFilter.blur。底层 gradient bar 一直在动,RepaintBoundary 也无法 cache,每帧都 re-rasterize。新增 _adaptiveBlur helper:iOS 保留 GPU-加速的 BackdropFilter,Android 改为半透明白填充(alpha 0.18 vs iOS 的 0.10)。视觉差异:Android 上「frosted」变「translucent」——elevated card 的视觉效果保留,没有 blur shader 成本。
1d4e278,2026-05-02)navigateInstant 这个 helper 历史上用 Duration.zero,后来改成 CupertinoPageRoute 是为了恢复 iOS edge-swipe pop gesture。但 CupertinoPageRoute 的 300 ms slide 在 Android 上是 felt-jank 的真凶 ——300 ms 内 source frame + 正在构建的 destination frame 同时合成;在 cooking page 这类 widget tree 复杂的目的页上 compositor 跟不上。
Fix: 按平台分流。iOS 保留 CupertinoPageRoute(slide + edge-swipe),Android 改回 PageRouteBuilder(transitionDuration: Duration.zero)(snap-in,无 transition 阶段可 jank)。V2454A 上确认主观流畅度显著提升。
影响:app 内所有 navigateInstant 调用点 —— dashboard → cooking、cooking → preset / meat / graph、cook log → detail。
14e8d34,2026-05-02)三个 hardware test 暴露的 UX bug 一起修:
A. 信息类报警改为居中 card(alarm_banner.dart 新建 + app.dart):
informational alarm(ambient under-temp、target reached、probe disconnect 等)之前与 safety alarm 一样走 full-screen WarningPage —— 对于「环境温度低于 40°C」这种通知性事件 UI 权重过重。现在 informational 走居中 card:dim backdrop + 居中浮动 card(warning icon + 完整 title + body + Confirm pill),点击 card 或 backdrop 静音并消失。SAFETY alarm(internalOverTemp + ambientOverTemp)仍走全屏红色 WarningPage —— 那两个本来就是 Critical Alerts 候选场景,必须不可错过。WarningPage 自身的 title/body 也分了字号(之前 26pt w800 全部一样大)。
B. Goal Setting 的 meat 网格不再过窄(meat_presets_page.dart):
4 列网格之前 mainAxisSpacing: 4 + childAspectRatio: 1.05,每个 cell 只有 ~70 logical-px 高,装不下 64-px 圆 + 4-px gap + 11-px label,结果 label 被下一行图标盖住。改为 mainAxisSpacing: 14 + childAspectRatio: 0.85,cell 比宽更高,label 完整显示,行间有呼吸空间。
C. 温度图永不空白(cooking_page.dart _TempGraphPainter):
之前三处 early-return(points.isEmpty / range<1 / dur<1)使得只要历史里 < 2 个点(即温度恒定时几乎总是这样,因为 _recordTemperature 只在变化时 append),整个 canvas 就是空白。现在 painter 总是绘制 chrome(坐标轴 + 网格 + 虚线 target),只在 < 2 点时跳过 data line 并居中显示「Waiting for telemetry…」/「Collecting data…」hint。
lib/core/transport/ 描述BoosterFamily extension)