先说结论:能跑,但不是复制粘贴就能跑。
最近我把一个口腔护理类的轻量级 App 迁移到了 OpenHarmony 生态上,核心功能是“刷牙记录”——配合智能牙刷记录每次刷牙的时长、覆盖区域、力度档位,再生成历史时间线和一周热力图。项目技术栈选了 Flutter,具体用的是 flutter_for_openharmony(下面简称 FFOH)这个社区维护的分支。整条链路走下来,从工程初始化、原生通道补桥、本地存储选型,到蓝牙设备的特征值读取、锁屏场景下计时状态保活,每个环节都有和安卓端完全不一样的“脾气”。
这篇文章适合三类读者:一是想把手头 Flutter 应用平移到 OpenHarmony 上跑起来的开发者,二是正在做健康 / 护理类应用、需要接入蓝牙外设的产品,三是对跨端适配边界感兴趣、想弄明白“一套代码到底能复用多少”的人。我会把项目里能直接照抄的方案和踩过的坑都写下来,包括数据模型设计、原生插件桥接、时间戳校准这几个最容易出问题的地方。
1. 为什么在 OpenHarmony 上搞 Flutter,还要选个“刷牙计数”这种细分工
1.1 一套 Flutter 代码,到底能不能在 OpenHarmony 上原样跑
很多人的第一反应是:Flutter 不是跨端吗?写一遍到处跑,换个系统怎么会这么麻烦。
这句话对了一半。Flutter 的跨端能力体现在 Dart 层和渲染层:你的 Widget 树、布局逻辑、状态管理、纯 Dart 写的业务代码,这些在 FFOH 分支下确实能复用绝大部分。问题出在 Platform Channel 和 原生插件 上。OpenHarmony 不是 Android,没有 Android 那套 PluginRegistry,也没有现成的 shared_preferences、path_provider 这类插件的 OpenHarmony 实现。任何需要调用系统能力的地方——本地存储、蓝牙、震动、通知、传感器——都要在原生侧(OpenHarmony 的 ArkTS/C++ 层)自己写实现,再通过 MethodChannel 暴露给 Dart 调用。
我梳理了一下这个项目里的复用情况:
| 模块 | 能否直接复用 | 说明 |
|---|---|---|
| Widget 与页面路由 | 基本能复用 | 布局、动效、路由逻辑在 Flutter 层,OpenHarmony 侧不需要动 |
| 状态管理(状态机等) | 完全复用 | 纯 Dart 代码,和平台无关 |
| 数据模型与业务计算(评分、聚合) | 完全复用 | 纯 Dart,测试也好写 |
| 本地存储 | 不能直接用 | 需要换方案或用原生 Preferences 桥接 |
| 蓝牙扫码与连接 | 必须自己写桥 | OpenHarmony 侧实现扫描、连接、Notify 订阅 |
| 系统日志 | 部分可用 | 调试期可以在原生侧打日志,线上要统一收集链路 |
所以我的结论是:业务层复用得很爽,系统能力层要重新补课。 而这个“刷牙记录”功能恰好把这两种情况都覆盖了,非常适合作为 FFOH 适配的试金石。
1.2 为什么先用口腔护理这个场景试水
选“刷牙记录”不是随手拍脑袋,是冲着三个硬需求去的:
- 必须对接蓝牙外设。智能牙刷通过 BLE(低功耗蓝牙)把刷牙数据传过来,这逼着我把 FFOH 的原生插件通道完整走一遍,属于“绕不开的深水区”。
- 数据完全本地化。刷牙记录不需要账号体系,不需要服务端同步,用本地数据库就够。这能把后端复杂度全部砍掉,专注验证“设备接入 + 本地数据管理 + UI 自定义”这条主链路。
- UI 有强交互。刷牙计时页需要一个圆环倒计时动画、口腔分区引导图、实时时长数字,这些正好能检验 Flutter 渲染层在 OpenHarmony 上的表现是否流畅。
在这个项目里,我全程用一个测试用的智能牙刷模组“模拟设备 X”做验证(一个自制 BLE 设备,广播刷牙事件和力度传感器值)。不用真机牙刷的原因很简单:开发阶段可控制性太差,调试日志也不好加。如果你手头也只有成品牙刷,建议先加一个“模拟模式”,用定时器代替真实设备事件,否则后面排查问题会非常痛苦。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 项目骨架搭建:Flutter 工程跑通 OpenHarmony 的关键链路
2.1 环境准备与工程初始化
FFOH 不是 Flutter 官方主分支,而是 OpenHarmony 社区维护的分支,它把 Flutter 引擎编译产物接入到 OpenHarmony 的 hvigor 构建体系里。所以整个流程比普通 Flutter 工程多好几步,每一步错一点都可能导致 hvigor build 直接失败。
我当时的环境如下,你可以作为参考基准:
- 操作系统:64 位桌面 Linux,内存至少 16GB(编译 OpenHarmony 的 Flutter 引擎很吃内存,8GB 机器分分钟 OOM)
- 开发工具:DevEco Studio(用来打开 FFOH 工程并构建 HAP 包)
- Flutter SDK:
flutter_for_openharmony分支的 SDK,不是官方主分支 - 目标系统:OpenHarmony API 版本较新的模拟器或真机
工程初始化分两条路:一是直接 clone FFOH 分支源码,用它的示例工程跑起来;二是用 flutter create 生成标准工程,再把工程结构改成 FFOH 能识别的格式。我建议从第一条路开始,先把示例跑通,再往里面加自己的业务代码。
bash复制# 获取 FFOH 分支(以社区仓库为例)
git clone -b ohos https://example.com/flutter_for_openharmony.git flutter_ohos
# 用 FFOH 分支的 flutter 命令创建工程
./flutter_ohos/bin/flutter create brush_app
创建完工程后,你会看到一个 ohos 目录,里面是 OpenHarmony 侧的工程骨架。这个目录就是你的“原生侧”,和安卓工程里的 android 目录一个作用。后续写蓝牙插件、配置权限、加原生依赖都在这里。
2.2 原生通道设计:需要自己补的几块“桥”
跑通默认 Demo 之后,第一件必须做的事就是把工程里所有的“桥”列清楚。FFOH 是把 Flutter 的 dart:ui 和引擎层移植到了 OpenHarmony,但 MethodChannel 对应的原生侧实现需要你自己在 ohos 模块里写。
我这边需要的系统能力有三块:蓝牙、本地偏好存储、系统日志。其中蓝牙是最复杂的,单独开一个原生 Plugin 模块维护,另外两块直接在页面侧用通道调用就行。
以蓝牙扫描为例,Dart 侧是这样的:
dart复制import 'package:flutter/services.dart';
const MethodChannel _bleChannel = MethodChannel('ohos.devices.ble');
Future<void> startScan({Duration timeout = const Duration(seconds: 30)}) async {
try {
await _bleChannel.invokeMethod('startScan', {'timeout': timeout.inSeconds});
} on PlatformException catch (e) {
// 这里会把原生侧抛出的错误码和 message 带出来,方便定位
debugPrint('BLE startScan failed: ${e.code} ${e.message}');
}
}
原生侧(ohos 模块里的 ArkTS 代码)的大体结构如下:
ts复制import { ble } from '@ohos.bluetooth';
import { MethodCall, MethodChannel } from '@ohos.flutter_ohos';
export default class BlePlugin {
onAttach(engine: any) {
this.channel = new MethodChannel(engine, 'ohos.devices.ble');
this.channel.setMethodCallHandler((call: MethodCall): Promise<any> => {
switch (call.method) {
case 'startScan':
return this.handleStartScan(call.arguments as Record<string, number>);
// ... 其他方法分发
}
});
}
private async handleStartScan(args: Record<string, number>) {
const filters: ble.ScanFilter[] = [];
ble.startScan(filters);
// 真正的扫描结果通过广播回调取回
}
}
这里的小坑是:不同 OpenHarmony SDK 版本里蓝牙 API 的包名不太一样,有些版本从 @ohos.bluetooth 换成了 @kit.BluetoothKit 这种 Kit 化写法。写之前一定先查你当前 DevEco Studio 的 API 文档,不要照抄任何博客里的全限定名,包括我这篇示例里的。
2.3 依赖管理:pub.dev 之外的缺失插件怎么办
这一步最熬人。日常 Flutter 开发里顺手就加的插件,在 FFOH 下很多都不能直接用。我把这个项目遇到的插件问题分了三类,你们大概率也会遇到:
| 插件或库 | 状态 | 替代方案 |
|---|---|---|
shared_preferences |
原生插件无 OpenHarmony 实现 | 用 Hive 本地库,或自己在原生侧写偏好存取通道 |
path_provider |
原生插件无 OpenHarmony 实现 | 原生侧返回应用沙箱路径,通过通道传给 Dart |
flutter_blue_plus |
原生依赖全是 Android/iOS 实现 | 自己在 ohos 模块写 BLE 桥接 |
provider / riverpod |
纯 Dart,可直接使用 | 无 |
intl |
纯 Dart,可直接使用 | 无 |
hive |
纯 Dart(本地文件存储) | 无 |
核心原则就一条:优先选纯 Dart 实现的库,尽量少碰带原生平台的插件。尤其是 FFOH 还处在快速演进期,你今天找到的插件实现,可能过两个版本 API 又变了。与其花时间适配第三方插件,不如把系统能力集中封装在自己维护的 NativeBridge 模块里。
3. 刷牙记录的数据层:从本地存储到时间线聚合
3.1 一次刷牙记录,需要记录哪些字段
数据模型是刷牙记录功能的地基。很多人的第一版表结构只存了“时间”和“时长”,结果后面要做评分、做趋势分析时发现字段不够,只能痛苦地加列迁移。
我最终用的字段集合是这样的:
dart复制class BrushRecord {
String id; // 唯一 ID,用时间戳+随机数生成
DateTime startTime; // 刷牙开始时间
DateTime endTime; // 刷牙结束时间
int durationMs; // 实际刷牙时长(毫秒)
int targetDurationMs; // 目标时长,通常是 120000(2 分钟)
int coverageScore; // 口腔覆盖评分 0~100
int mode; // 模式:0 清洁,1 敏感,2 美白
String deviceId; // 牙刷设备标识
String? extraJson; // 扩展字段,力度档位/电量等
}
有两个看似冗余实则有用的字段要解释一下:
- 同时存
startTime和durationMs。一开始我以为有开始时间 + 结束时间就够算了,后来发现设备侧的时钟和手机侧时钟有偏差(下文专门讲),算时长必须优先用设备上报的时长而不是“手机结束时间减去手机开始时间”,因此单独的durationMs字段反而更可靠。 - 存
targetDurationMs。产品后来加了“达成目标天数”的统计,没有这个字段就得从配置里现查,很麻烦。直接在每条记录里冗余一份,查询时零成本。
3.2 本地数据库与状态管理的组合方案
本地存储我在 Hive 和 ObjectBox 之间犹豫过,最后选了 Hive,理由很现实:ObjectBox 有原生依赖,在 FFOH 下编译链路太长了,Hive 则是纯 Dart 实现,只要纯 Dart 库能跑,Hive 就能跑。
Hive 在这个项目里的用法:
dart复制import 'package:hive/hive.dart';
@HiveType(typeId: 0)
class BrushRecord extends HiveObject {
@HiveField(0)
String id;
@HiveField(1)
DateTime startTime;
@HiveField(2)
DateTime endTime;
@HiveField(3)
int durationMs;
// ... 其余字段
}
初始化:
dart复制// 在 main() 里最先执行
final dir = await NativeBridge.getAppDocumentDir(); // 原生侧返回沙箱路径
Hive.init(dir.uriPath);
final box = await Hive.openBox<BrushRecord>('brush_records');
需要注意一点:Hive 的路径必须来自原生侧的沙箱路径,不能自己硬编码一个 /data/xxx 路径,OpenHarmony 的沙箱规则比安卓更严格,错误路径下 Hive 看起来打开了,一写入就抛 FileSystemException。我在这个地方卡了将近半天,最后是打原生日志看到真实沙箱路径才解决。
状态管理我用的是 provider,和 FFOH 没有冲突,纯 Dart 实现。具体拆成了两个模型:
BrushSessionModel:负责一次刷牙会话的实时状态(开始、暂停、结束、当前时长)。BrushHistoryModel:负责从 Hive 读记录、按天聚合、供列表页和热力图使用。
这样拆分的好处是:计时页只用关心 BrushSessionModel,历史页只需要 BrushHistoryModel,两侧互不干扰,也不会出现“计时结束时顺带刷新列表”这种隐式耦合。
3.3 解锁日历热力图:按天聚合的查询逻辑
刷牙记录列表页做起来不难,真正麻烦的是日历热力图——一个月里每天刷了几次、达标没有,要用颜色深浅表示。如果每次打开都全表扫描再按天分组,数据量小的时候没事,攒到几百条记录后就会明显卡顿。
我用的方案是:查询一次全量记录,按“天”分桶,结果做内存缓存。因为单用户一天的刷牙记录量级很小(最多三五条),一个月的记录撑死也就一百多条,这个量级完全不需要上 SQL。
dart复制Map<DateTime, DailyBrushSummary> aggregateByDay(List<BrushRecord> records) {
final grouped = <DateTime, List<BrushRecord>>{};
for (final record in records) {
final day = DateTime(
record.startTime.year,
record.startTime.month,
record.startTime.day,
);
grouped.putIfAbsent(day, () => []).add(record);
}
final summary = <DateTime, DailyBrushSummary>{};
grouped.forEach((day, dailyRecords) {
summary[day] = DailyBrushSummary(
count: dailyRecords.length,
totalDurationMs: dailyRecords.fold(0, (sum, e) => sum + e.durationMs),
achievedTarget: dailyRecords.any((e) => e.durationMs >= e.targetDurationMs),
);
});
return summary;
}
一个容易忽略的细节:时间分组用的是 本地时区的 year/month/day,直接用 UTC 时间做分组会导致早上八点之前的记录被归到前一天。我在测试时故意设置了模拟器时区为 UTC+8 之外的值,结果热力图日期偏移了一天,后来把 DateTime 按本地时区归一才解决。
4. 实时计时页面:状态机、环形动画与双端时间校准
4.1 用状态机管住“取牙刷-刷牙中-暂停-完成”
刷牙计时页是整个 App 交互最复杂的页面。用户从拿起牙刷到刷完,中间可能发生:中途漱口暂停、不小心退出页面、设备断开、超时未操作。如果不用状态机,这一堆状态互相乱跳,写出来的代码就是一团面粉糊。
我定义的状态很简单:
dart复制enum BrushState {
idle, // 未开始
cleaning, // 刷牙中
paused, // 暂停
completed, // 正常完成
cancelled, // 取消/超时未完成
}
状态转换规则放进一个 BrushSessionModel 里集中处理:
dart复制class BrushSessionModel extends ChangeNotifier {
BrushState _state = BrushState.idle;
int _elapsedMs = 0;
void start() {
if (_state != BrushState.idle) return;
_state = BrushState.cleaning;
notifyListeners();
}
void pause() {
if (_state != BrushState.cleaning) return;
_state = BrushState.paused;
notifyListeners();
}
void resume() {
if (_state != BrushState.paused) return;
_state = BrushState.cleaning;
notifyListeners();
}
void complete() {
if (_state != BrushState.cleaning && _state != BrushState.paused) return;
_state = BrushState.completed;
notifyListeners();
}
void cancel() {
_state = BrushState.cancelled;
notifyListeners();
}
}
为什么状态机这么重要?因为在这个页面里,“谁触发了状态变化”很多时候是外部事件而不是用户点击。比如蓝牙设备上报“牙刷离开口腔”,这时候要自动暂停计时;设备 30 秒没有新事件,要自动取消并弹提示。如果没有一个统一的状态入口管住转换,外部事件到处调用 setState,最后必然出现“页面显示正在刷牙、数据却已经完成了”的诡异 bug。
4.2 CustomPaint 绘制进度环与口腔分区引导
计时页的核心视觉是一个圆环进度动画,中间是实时数字“02:31”。用 CustomPaint 做圆环比用第三方圆形进度库更可控,因为只有这个需求时引入一个库反而累赘。
dart复制class ProgressRingPainter extends CustomPainter {
ProgressRingPainter({
required this.progress,
required this.backgroundColor,
required this.foregroundColor,
});
final double progress; // 0.0 ~ 1.0
final Color backgroundColor;
final Color foregroundColor;
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = (size.width - 8) / 2;
final strokeWidth = 12.0;
final backgroundPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..color = backgroundColor;
canvas.drawCircle(center, radius, backgroundPaint);
final foregroundPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.round
..color = foregroundColor
..shader = SweepGradient(
colors: [foregroundColor.withOpacity(0.6), foregroundColor],
startAngle: 0,
endAngle: 3.14159 * 2,
).createShader(Rect.fromCircle(center: center, radius: radius));
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
-3.14159 / 2,
// 起始角度从顶部开始
3.14159 * 2 * progress,
false,
foregroundPaint,
);
}
@override
bool shouldRepaint(covariant ProgressRingPainter oldDelegate) {
return oldDelegate.progress != progress;
}
}
这里有个提升质感的小技巧:给前景弧加 StrokeCap.round,同时渐变从 withOpacity(0.6) 过渡到纯色。这样圆环不仅有两端圆头,还有“颜色从浅到深逐渐变亮”的效果,看起来比单色弧线高级很多,而且代码量只多了三行。
口腔分区引导我做了简化:把口腔按“上排左、上排右、下排左、下排右、前区、后区”画成六块小图形,刷牙时根据当前时长自动提示“请切换到右上区域”。复杂的分区方案(16 分区)在产品早期没有必要,先跑通再细化。
4.3 时间戳校准:为什么不能直接信本机时间
这是整个项目里最值得分享的一个坑:智能牙刷上报的时间戳,和手机本机时间经常不一致。
一开始我在记录里直接用设备的 startTime 存进 Hive,然后在历史页按时间排序。结果发现有几条记录的时间出现了“未来”——设备时间比手机快了十几分钟。后来一查,是牙刷的 RTC 芯片走时不准,又没有联网校时机制。
解决办法是在接入时做一次校准:
- 拿到设备上报的当前时间戳
deviceTimestamp. - 同时记录手机本地时间戳
phoneTimestamp. - 计算出偏移量
offset = deviceTimestamp - phoneTimestamp. - 写入记录前,用
rawDeviceTimestamp - offset得到标准化时间再落库。
dart复制class TimeCalibrator {
TimeCalibrator({
required this.deviceTimestampMs,
required this.phoneTimestampMs,
});
final int deviceTimestampMs;
final int phoneTimestampMs;
int get offsetMs => deviceTimestampMs - phoneTimestampMs;
int normalize(int rawDeviceTimestampMs) {
return rawDeviceTimestampMs - offsetMs;
}
}
校准偏移量不要只算一次,最好在每次连接成功时重新获取并持久化。因为设备 RTC 可能停摆后重新走时,偏移会变化。我在实际测试中还发现,有些设备上报的时间戳单位不是毫秒而是秒,差了一千倍,排序直接错乱。接入时务必打印一次原始值看一眼单位,别想当然。
5. 智能牙刷的蓝牙接入与设备识别
5.1 公共 BLE 服务和厂商私有 SDK 怎么选
蓝牙接入遇到的第一道选择题,就是“走公共 BLE 服务还是厂商私有协议”。
公共 BLE 服务(比如标准健康设备属性)的好处是兼容性好,任何支持标准协议的牙刷都能连;坏处是它只能上报电量、开关机这种基础信息,拿不到“刷牙开始/结束”“力度传感器”“覆盖区域”这些业务数据。
厂商私有协议则相反,功能和数据很全,但往往要依赖厂商 SDK,而厂商 SDK 基本不提供 OpenHarmony 版本。
我的做法是两边都做,但职责分开:
- 标准 BLE 通道:负责设备发现、连接、电量和基础状态。
- 私有特征值通道:负责向牙刷订阅“刷牙事件流”,拿到力度、分区、时长事件。
对于模拟设备 X,我在它的 GATT 服务里自己定义了几个特征值:notify 通道上报事件流,write 通道下发控制指令(比如启动/停止马达)。这样开发和调试完全不依赖友商 SDK,逻辑也完全可控。
5.2 在 OpenHarmony 原生侧实现扫描与特征值读取
这块是 FFOH 项目里工程量最大的一部分。我把它完整放在 ohos 模块里,Dart 侧只暴露几个高层 API:scan()、connect(deviceId)、subscribeEvents()、writeCommand()。
原生侧的核心逻辑分四步:
1. 扫描
ts复制import { ble } from '@ohos.bluetooth';
let scanFilters: ble.ScanFilter[] = [];
ble.startScan(scanFilters);
ble.on('BLEDeviceFind', (device: ble.ScanResult) => {
// 过滤名称,比如 "SimDeviceX"
if (device.deviceName.startsWith('SimDeviceX')) {
dispatchToDart('onDeviceFounded', {
deviceId: device.deviceId,
deviceName: device.deviceName,
rssi: device.rssi,
});
}
});
2. 连接
ts复制ble.createGattClientDevice(deviceId);
gattClient.connect();
3. 订阅特征值通知
ts复制const descriptor: ble.BLEDescriptor = {
serviceUuid: '0000fee0-0000-1000-8000-00805f9b34fb',
characteristicUuid: '0000fee1-0000-1000-8000-00805f9b34fb',
descriptorUuid: '00002902-0000-1000-8000-00805f9b34fb',
};
gattClient.on('BLECharacteristicChange', (value: Uint8Array) => {
// 解析字节流,拆包成事件
parseAndDispatch(value);
});
4. 断开与清理
ts复制gattClient.disconnect();
ble.stopScan();
每一层的错误都要往 Dart 侧抛,并且在 Dart 侧给出用户能看懂的中文提示。灰常重要——如果原生侧静默失败,Dart 侧拿到一个空列表,你根本分不清是“周围没设备”还是“蓝牙权限没开”。
5.3 佩戴/离开检测的简单可靠方案
用户刷牙过程中,牙刷不一定一直握在手里。他会漱口、换区、暂停,这时候计时器不能傻走,必须停下来。检测“牙刷是否在口腔中”最准确的做法是传感器数据,但传感器数据处理复杂,而且不同设备上报频率不同。
我用了组合策略,实测下来够用:
- 事件间隔判断:设备启动后每秒上报一次力度值;如果超过 3 秒没有新事件,判定“可能离开口腔”,进入待确认暂停状态。
- 加速度唤醒:部分设备带加速度计,被拿起时会有一个
motion_wake事件,收到后自动恢复到计时中。 - 超时兜底:待确认暂停状态持续 30 秒无新事件,直接取消本次会话并保存一条“未完成”记录。
注意第三点兜底尤其重要。我一开始只做了前两条,结果用户把牙刷放桌上忘了,计时页一直在“待确认暂停”状态,历史记录里凭空多出一条半小时的超长记录,非常影响热力图的准确性。加上 30 秒超时后,数据干净多了。
6. 真机实测与发布前避坑清单
6.1 分屏、平板尺寸、旋转的适配经验
OpenHarmony 的一个特点是多窗口和多尺寸设备意识比安卓更强。我最初只按手机竖屏设计,结果在平板上打开时,计时页的圆环偏到屏幕一角,底部按钮也挤在中间,非常难看。
我的调整方案:
- 计时页用
LayoutBuilder包一层,默认按短边 360dp 设计,长边缩放到 480dp 上限,不要让圆环无限放大。 - 关键按钮区域用
SafeArea包裹,避免系统导航条遮挡。 - 横屏模式下强制切成“左侧计时圆环 + 右侧状态面板”的双栏布局,不沿用竖屏布局。
dart复制final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape;
if (isLandscape) {
return Row(
children: [
Expanded(child: _buildTimerRing()),
Expanded(child: _buildStatusPanel()),
],
);
} else {
return Column(
children: [
Expanded(flex: 3, child: _buildTimerRing()),
Expanded(flex: 2, child: _buildStatusPanel()),
],
);
}
顺带提醒:用 devicePixelRatio 做像素换算的时候,OpenHarmony 模拟器的值经常会让人困惑(有的给 1.0,有的给 3.25),千万不要在 Dart 侧依赖绝对像素值,统一用逻辑像素,不然在真机上会看到明显缩放问题。
6.2 后台与锁屏场景下计时被杀的应对
刷牙到一半,用户把手机锁屏揣兜里,这是最典型的场景。结果计时计时器一进后台就被挂起,回来一看时间还在原地。
查了一圈,原因是:普通 Timer 在 Flutter 引擎进后台后会被节流甚至暂停,尤其在 OpenHarmony 这类注重省电的系统上更严格。
我的应对分三层:
-
不让计时依赖
Timer.periodic累加。改成每次页面可见时记录“开始时间”,退出时计算差值,恢复后再重新对齐。 -
原生侧保活。在
ohos模块里申请一个短时的前台伴随任务,持有 CPU 锁,让计时器在锁屏后继续运行。设置合理的超时时间(比如 10 分钟),到了就自动释放,不要常驻后台。 -
蓝牙事件驱动兜底。只要设备还在上报事件流,原生侧就不断往 Dart 侧投递事件,哪怕页面在后台,引擎也会因为事件到达而被唤醒。也就是说,真正保活的其实是“数据流”,而不是“计时器”。
dart复制// Dart 侧唤醒处理:收到蓝牙事件时更新当前时长
void _onDeviceEvent(BrushEvent event) {
if (_state == BrushState.cleaning) {
_elapsedMs = _calibrator.normalize(event.timestampMs) - _sessionStartNormalizedMs;
notifyListeners();
}
}
6.3 真机验证清单和性能数据参考
最后是我在发布前整理的真机验证清单,分享出来,你们可以直接拿去改成自己的版本:
| 测试项 | 通过标准 |
|---|---|
| 首次连接配对 | 扫描到设备 → 连接 → 订阅事件成功,全程小于 10 秒 |
| 刷牙计时完整性 | 从开始到结束,记录时长偏差不超过 2 秒 |
| 锁屏长计时 | 锁屏 5 分钟后再解锁,计时误差不超过 1 秒 |
| 蓝牙断开重连 | 距离 20 米断开后回到设备旁,能在 15 秒内自动恢复订阅 |
| 日历热力图准确性 | 连续写入 200 条模拟记录,热力图分布与手动核查一致 |
| 设备时间偏移 | 模拟设备时间偏移 10 分钟,所有记录按校准后时间排序正确 |
| 后台多任务 | 打开拍照、系统设置等应用后再返回,计时页状态不丢失 |
性能方面,我的模拟数据是:持续计时 10 分钟,页面帧率稳定在 55fps 以上;单条蓝牙事件处理耗时小于 5ms;本地 Hive 写入 1000 条记录后,历史页冷启动加载耗时约 120ms。FFOH 分支下的引擎性能已经能满足这类轻交互应用的流畅要求,不需要额外做优化,但复杂动画场景建议实测后再定。
如果让我只留一条经验:在所有设备事件和记录落库的链路上加时间戳日志,并且把日志持久化到本地文件。后期做时间排序、做热力图、排查蓝牙重连问题时,日志会是你唯一能依赖的线索。我在第一版只打了 print 日志,结果日志缓冲在后台崩溃时全丢,事后完全还原不了现场。把日志落盘之后,这些排查难题都变成了“查一下那段时间的日志”就能解决的小事。
