最近在搞一个移动数据使用监管助手App,技术栈是Flutter for OpenHarmony,核心模块之一就是套餐历史的记录与展示。家人用合约机,每月30GB通用流量加一堆定向流量,可运营商App只能查当月,上个月到底用了多少、套餐什么时候改的、超没超套,完全没有历史概念。与其等短信提醒,不如自己做一版监管工具。这篇文章把"套餐历史"这个模块从数据模型、本地数据库、用量采集到页面UI、后端同步的完整实现过程写出来,重点是每一步为什么这么选,以及我在OpenHarmony真实设备上遇到的那些文档里不会写的坑。想用Flutter做OpenHarmony应用,或者打算做流量监管类工具的朋友,可以直接抄作业。
1. Flutter适配OpenHarmony到什么程度了:项目能不能启动,先看这几点
1.1 从社区适配到系统能力:我需要关心什么
Flutter for OpenHarmony这个组合,最近两年已经不像早期那么"玩具"了。社区基于Flutter官方引擎做了OpenHarmony的适配分支,Dart代码层面基本不需要改,但有几个前提条件必须先确认。
第一,OpenHarmony的Flutter适配目前主要面向API 9及以上的系统版本。如果设备系统版本太低,引擎跑不起来,很多新特性也没有。我做适配时用的是一个API 10的开发板固件,整体表现稳定很多。
第二,渲染后端。OpenHarmony的Flutter适配没有直接用Skia,而是接入了系统的渲染能力,这意味着某些自定义绘制比如Shader、部分Canvas特效,和标准Flutter行为会有差异。对于套餐历史页面里的图表绘制,我建议先用Flutter自带的CustomPaint和基础图形API,别一上来就上重型的渲染方案。
第三,插件生态。pub.dev上大量插件在OpenHarmony上是不能直接用的,尤其是依赖Android系统服务的。说句实在话,OpenHarmony上Flutter插件基本是"够用但挑着用"的状态。我的项目引用的插件很少,除了UI相关的,比如fl_chart、intl、dio、hive_flutter这类纯Dart实现的,剩下凡是要碰系统能力的,全部自己封装MethodChannel走原生侧。
1.2 监管助手App的模块地图:套餐历史在哪个位置
整个App拆成四个模块:首页仪表盘、实时用量监控、套餐历史、设置与同步。套餐历史不是独立孤岛,它和另外三个模块都有数据往来。
首页仪表盘显示"当前套餐总用量/已用量/剩余天数"。实时用量监控每隔固定时间采集一次流量数据,写入用量明细表。套餐历史则读取这些明细表,按套餐ID聚合出每个月、每个历史套餐的使用情况。设置与同步负责把本地记录推到后端,方便多设备查看。
模块之间的关系用一句话概括:实时监控是数据的生产者,套餐历史是数据的消费者,同步模块负责把数据搬运出去。这样的分层让我在做套餐历史时,只需要关注两个问题:数据从哪来、数据长什么样。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 套餐历史的数据模型:把流量套餐实体建模一次想清楚
2.1 套餐记录PlanRecord的字段取舍
套餐历史的核心是一张套餐记录表。这个表直接决定了后续所有页面和统计能不能做出来。我设计实体时没有一开始就堆字段,而是从业务问题反推:我要回答用户哪几个问题?
- 这个套餐叫什么名字、属于哪家运营商?
- 套餐从哪天开始、哪天结束?
- 总共多少流量、这个月已经用了多少?
- 套餐里包含哪些类型(通用、定向、夜间)?
- 这个套餐现在的状态是什么?
对应的实体字段如下:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | String | 主键,UUID,客户端生成 |
| planName | String | 套餐名称,如"畅享30GB版" |
| carrier | String | 运营商,如中国移动 |
| startDate | DateTime | 生效日期 |
| endDate | DateTime? | 失效日期,null表示当前生效 |
| totalBytes | int | 套餐总流量,单位字节 |
| usedBytes | int | 当前已用流量,单位字节 |
| currentCycleStart | DateTime | 当前计费周期开始日 |
| currentCycleEnd | DateTime | 当前计费周期结束日 |
| price | double | 套餐月费 |
| status | String | active / expired / archived |
| createdAt / updatedAt | DateTime | 记录创建和最后更新时间 |
流量统一用字节存储,不在数据库层做单位转换。这一点特别重要,我见过不少App把单位存成"GB数值",界面好看了,但统计聚合时小数点误差、四舍五入问题全出来了。要做展示层再除以1024或者1000,取决于运营商计费习惯,一般流量是1024进制,但部分定向流量按1000算,需要再单独处理。
2.2 为什么需要"套餐变更流水"而不是只存一张最新表
只存一张最新套餐表是最容易犯的错误。用户换套餐、续约、加购流量包,都会产生历史变更。如果只更新当前记录,一个月后谁也说不清上个月用的流量到底属于哪个套餐。
我额外做了一张套餐变更流水表PlanChangeLog,字段包括:变更ID、套餐ID、变更类型(create/manual_reset/downgrade/upgrade/cancel)、变更前数值摘要、变更后数值摘要、变更原因、操作时间。
举一个真实例子:用户在月中发现流量不够,临时加了10GB加油包。这个加油包可能是独立于主套餐的,也可能并进主套餐抵扣。监管App要做的是把这次加包的记录和数据同步写入流水表,套餐历史页面上就会出现一条"2025-06-15 新增加油包10GB"的记录。这样页面能画出时间线,用户一看就明白流量为什么突然多了。
2.3 用量日表与月表:统计时少算的账
套餐历史页面要展示两个维度的数据:按日明细和按月汇总。如果只存月汇总,日明细画不出来;如果只存日明细,每次查月汇总都要全表SUM,数据量大时很慢。
我的方案是双表:usage_daily存每日用量快照,usage_monthly存每月汇总。每日快照由采集端在当天结束时自动写入,包含:日期、套餐ID、通用流量用量、定向流量用量、总用量、当日是否产生超额扣费。月度汇总由日表汇聚生成,每次汇聚后记录上次汇聚的截止日期,避免重复计算。
这样设计的好处是页面查询非常轻:历史时间线读日表,月度卡片读月表。坏处是写库逻辑多了一层,采集端要同时维护两张表。这个代价值得。
3. 本地数据库落地:用Hive做套餐历史存储的完整过程
3.1 为什么没选sqflite和drift
OpenHarmony上可用的Flutter数据库方案,我认真对比过三条路线:sqflite、drift和hive。
sqflite在OpenHarmony上有个很尴尬的问题——它依赖原生的SQLite插件实现。OpenHarmony的Flutter适配版里原生的SQLite通道未必通畅,我试过在API 10的设备上跑,插桩后直接找不到原生的数据库实例,排查成本很高。
drift确实强大,类型安全、迁移机制完善,但它底层同样依赖sqlite3的Native实现。OpenHarmony上的sqlite3库需要自己编译并导出符号,动手能力强的可以上,但我当时评估了一下,开发周期不允许为这个折腾。
最后选了hive_hive_flutter。原因是它是纯Dart实现的KV数据库,不依赖Android/iOS的原生能力,天然适合OpenHarmony这种需要"绕开原生差异"的环境。配合hive_ce(社区维护版)在OpenHarmony上跑得很稳。缺点是没有SQL语法,复杂查询需要自己在内存里做过滤。对于套餐历史这个场景,数据量级是几千到几万条记录,Hive完全扛得住。
3.2 Hive初始化与自定义TypeAdapter
Hive存储套餐记录有个关键点:自定义对象的存取需要注册TypeAdapter。PlanRecord不是基础类型,不能直接放进Box里。
我的做法是:
dart复制// 初始化
import 'package:hive_flutter/hive_flutter.dart';
await Hive.initFlutter();
Hive.registerAdapter(PlanRecordAdapter());
Hive.registerAdapter(PlanChangeLogAdapter());
await Hive.openBox<PlanRecord>('plan_records');
await Hive.openBox<PlanChangeLog>('plan_changelog');
await Hive.openBox<DailyUsage>('usage_daily');
await Hive.openBox<MonthlyUsage>('usage_monthly');
自定义Adapter示例:
dart复制class PlanRecordAdapter extends TypeAdapter<PlanRecord> {
@override
final int typeId = 0;
@override
PlanRecord read(BinaryReader reader) {
return PlanRecord(
id: reader.readString(),
planName: reader.readString(),
carrier: reader.readString(),
startDate: DateTime.fromMillisecondsSinceEpoch(reader.readInt()),
endDate: reader.readBool()
? DateTime.fromMillisecondsSinceEpoch(reader.readInt())
: null,
totalBytes: reader.readInt(),
usedBytes: reader.readInt(),
price: reader.readDouble(),
status: reader.readString(),
);
}
@override
void write(BinaryWriter writer, PlanRecord obj) {
writer.writeString(obj.id);
writer.writeString(obj.planName);
writer.writeString(obj.carrier);
writer.writeInt(obj.startDate.millisecondsSinceEpoch);
writer.writeBool(obj.endDate != null);
if (obj.endDate != null) {
writer.writeInt(obj.endDate!.millisecondsSinceEpoch);
}
writer.writeInt(obj.totalBytes);
writer.writeInt(obj.usedBytes);
writer.writeDouble(obj.price);
writer.writeString(obj.status);
}
}
这里有个实战提醒:DateTime不能直接存,必须先转成millisecondsSinceEpoch,读取时再还原。否则Adapter会在类型转换时报错,而且这种错只在运行时出现,写代码时不注意很难发现。
3.3 套餐历史的CRUD与时间分页查询
Hive的CRUD很直接:
dart复制final box = Hive.box<PlanRecord>('plan_records');
// 新增套餐
await box.put(newPlan.id, newPlan);
// 更新套餐状态
final updated = oldPlan.copyWith(status: 'expired', updatedAt: DateTime.now());
await box.put(updated.id, updated);
// 删除套餐(一般只归档,不真删)
await box.put(archivedPlan.id, archivedPlan.copyWith(status: 'archived'));
套餐历史页面需要按时间倒序分页展示。Hive没有SQL的ORDER BY和LIMIT,我的做法是:维护一个按startDate排序的Key列表作为索引,存成一个独立的Box项。新增或修改套餐时重建索引,查询时直接切片:
dart复制List<PlanRecord> fetchPlanPage({required int page, required int pageSize}) {
final indexBox = Hive.box<List>('plan_index');
final keys = indexBox.get('byStartDateDesc') ?? [];
final start = page * pageSize;
final end = (start + pageSize).clamp(0, keys.length);
return keys.sublist(start, end)
.map((key) => Hive.box<PlanRecord>('plan_records').get(key))
.whereType<PlanRecord>()
.toList();
}
这个索引模式比每次全量排序快得多,尤其当历史套餐积累到几百条时,差别非常明显。
3.4 轻量统计:合并同一套餐的用量
套餐历史页面经常要展示"这个套餐总共用了多少、超额多少"。如果只看currentCycle内的usedBytes,会漏掉上个月套餐未清零的结余。
我在写统计函数时做了这样一个规则:查询某套餐的所有月度汇总,如果连续两个月的 summary 存在且没有套餐变更记录,就把流量合并为"连续使用总量"。这是最贴近运营商计费习惯的做法。实际代码里就是一个循环累加:
dart复制double getTotalUsedForPlan(String planId) {
final monthlyBox = Hive.box<MonthlyUsage>('usage_monthly');
final allMonthly = monthlyBox.values.where((m) => m.planId == planId).toList();
return allMonthly.fold<int>(0, (sum, m) => sum + m.totalBytes).toDouble();
}
4. 移动数据用量采集:从系统拿真实流量的路径与兜底
4.1 OpenHarmony侧的数据访问:Platform Channel通向哪里
套餐历史的数据来源是实时的移动数据用量。这部分绕不开系统能力。OpenHarmony上Flutter要拿系统数据,走的还是MethodChannel,但通道的另一头不是Android的Java层,而是OpenHarmony的NAPI或者ArkTS接口。
在OpenHarmony里,telephony子系统暴露了数据业务相关的能力,比如获取数据业务状态、获取当前数据SIM卡等。理论上通过这些接口能拿到部分流量信息,但如果你期望的是像Android的NetworkStatsManager那样能查每个应用、每个时间段的精确流量,现阶段OpenHarmony还不太给力,接口覆盖不完整,不同厂商的设备行为也不一致。
所以我的方案是:数据访问层做抽象,定义统一的获取用量接口,底层根据设备能力决定走哪条路。
4.2 数据源抽象:真实设备用系统API,RK3568开发板用模拟源
RK3568开发板是我在开发阶段的主要运行设备。但这里有个极容易踩的坑:很多RK3568开发板根本没有接调制解调器,没有SIM卡槽,系统里根本不存在真实的移动数据流量。如果你写死在真机上采集,开发板上一跑就崩或者永远返回0。
我做了两层隔离:
dart复制abstract class UsageDataSource {
Future<int> getMobileBytesUsed();
Future<int> getMobileBytesTotal();
Future<bool> isMobileDataEnabled();
}
class SystemUsageDataSource implements UsageDataSource {
@override
Future<int> getMobileBytesUsed() async {
// 通过MethodChannel调用OpenHarmony telephony data接口
final result = await _channel.invokeMethod('getMobileBytesUsed');
return result as int;
}
}
class SimulatedUsageDataSource implements UsageDataSource {
@override
Future<int> getMobileBytesUsed() async {
// 固定基数 + 随机增量,模拟真实流量增长
return _base + Random().nextInt(1024 * 1024);
}
}
通过一个工厂根据设备能力返回对应实现:
dart复制UsageDataSource createDataSource() {
if (Platform.isOpenHarmony) {
final hasModem = await _channel.invokeMethod('hasTelephonyModem');
return hasModem ? SystemUsageDataSource() : SimulatedUsageDataSource();
}
return SimulatedUsageDataSource();
}
这样我在开发板上调试页面UI、测试套餐历史逻辑完全没有障碍,切到真机只需要替换数据源,不用动任何页面代码。
4.3 定时采集与补录:防止漏数据
套餐历史依赖连续的数据,漏一天,历史画出来就有缺口。Android上可以用WorkManager做后台定时任务,OpenHarmony上这套机制不通用。我退而求其次,用了前台Service加定时器的方案:App在前台时每15分钟采集一次,每次采集除了记录当前值,还和前一次值做差,把差值写入当日汇总。
为了防止App被杀导致漏采,我加了一个补录逻辑:每次启动App时,先检查usage_daily里昨天、前天的记录是否存在。缺失的话,从系统接口拉取最近48小时的历史总量差值补录过去,并在记录里打一个recovered: true标记,避免用户误以为是实时采集的精确数据。
5. 套餐历史页面UI:时间线列表和用量可视化
5.1 历史时间线卡片:套餐切换一目了然
套餐历史页面的核心是一个时间线列表。每个套餐一条时间线节点,节点上放一个卡片,包含套餐名称、运营商、生效起止时间、总流量、已用流量、状态标签。状态标签用颜色区分:蓝色active、灰色expired、橙色archived。
时间线用CustomScrollView加SliverList实现。左侧是一根竖线,用CustomPainter画,节点处画圆点。卡片内容用Card组件包装,Padding控制在16,间距12,保证在OpenHarmony平板和大屏设备上的可读性。
这里我踩过一个UI层的坑:ListView.builder在OpenHarmony上滚动性能不如CustomScrollView平滑,尤其是卡片里嵌了图表组件时,快速滚动会出现掉帧。后来把所有历史列表改成CustomScrollView + SliverList,同时给卡片里的图表开启了repaintBoundary,滚动流畅了很多。
5.2 用量对比与剩余天数的直观表达
单看数字,用户很难判断这个套餐用得"快还是慢"。我需要把用量和剩余天数做对比。
画了一个简单的仪表盘环图:环的总长度表示套餐计费周期,已用流量占环的比例,配合剩余天数在中心显示。计算公式是:
dart复制double usageRatio = usedBytes / totalBytes;
int remainingDays = currentCycleEnd.difference(DateTime.now()).inDays + 1;
另外做了一个"预估超额日"的小算法:取过去7天的日均流量,预估按这个速度还有几天用完。如果预估用完日早于周期结束日,就在卡片上标红提示:
dart复制int estimatedExhaustDay =
DateTime.now().add(Duration(days: (totalBytes - usedBytes) ~/ avgDailyBytes));
这个预估功能在真机上测下来准确度还不错,因为移动数据使用习惯相对稳定,7天滑动平均已经能反映趋势。
6. 本地与后端同步:离线优先的增量同步方案
6.1 同步协议:updatedAt+dirty标记
套餐历史不只活在本地。用户可能在另一台设备上看同一个账号的数据,所以需要把本地记录同步到后端。我设计的是离线优先模式:本地永远是第一数据源,所有写操作先落Hive,同时打一个dirty标记,再异步同步到服务端。
每张表都带updatedAt字段,同步请求带上lastSyncTime,服务端返回该时间之后变更的记录。增量同步的核心代码:
dart复制Future<void> syncPlanRecords() async {
final pending = Hive.box<PlanRecord>('plan_records')
.values
.where((r) => r.dirty)
.toList();
final response = await _api.syncPlans(
records: pending,
lastSyncTime: _lastSyncTime,
);
// 服务器回写确认后清除dirty标记
for (final rec in response.confirmedIds) {
final local = Hive.box<PlanRecord>('plan_records').get(rec);
if (local != null) {
Hive.box<PlanRecord>('plan_records')
.put(rec.id, local.copyWith(dirty: false));
}
}
}
要注意的是:Hive条目更新时要带dirty字段,Adapter里要把它序列化进去,否则重启后dirty标记丢失,同步就会漏数据。
6.2 冲突处理:到底以谁为准
离线场景下,同一台设备在不同时间、两台设备同一时间修改同一条套餐记录,都会产生冲突。我的规则很简单:updatedAt较新的一方胜出,但服务器时间戳优先于客户端时间戳。因为客户端本地时钟可能不准,运营商大促时用户手动改手机时间这种极端情况确实出现过。
具体流程:客户端上传冲突记录时,服务端比较两端updatedAt,若服务端更新则以服务端为准,把服务端记录下发,客户端本地覆盖;若客户端更新则接受客户端记录并回写确认。这个策略极大简化了逻辑,对套餐历史这种低并发、低频修改的业务完全够用。
6.3 同步时机设计:WiFi下批量补传
流量监管App本身对流量敏感,不能在移动数据下频繁传数据。我的同步触发时机做了四级策略:
- 启动App时立即同步一次,只拉增量;
- 每30分钟在有更高优先级任务完成后尝试同步;
- 检测到WiFi连接变化时(连接成功/断开)触发一次同步;
- 手动下拉刷新强制全量同步。
为了避免弱网环境下的超时堆积,所有同步请求设了10秒超时,失败后进入重试队列,重试次数上限5次,超过5次等下一次同步时机再处理。这个容错机制上线后,几乎没有再出现"数据怎么少了一条"的反馈。
7. 实战中踩过的坑和我的处理方式
7.1 RK3568设备树选择与固件适配
开发阶段我在RK3568开发板上跑OpenHarmony,第一步就被设备树卡住。RK3568是一个很通用的芯片,各家开发板布局、外设、屏幕都不一样,网上能搜到的固件和镜像五花八门,"openharmony的rk3568有许多设备树到底咋选"这个问题我实实在在花了两天才确认。
我的经验是:不要直接下别人打包好的通用固件,先确认自己板子的SoC型号、DDR容量、存储介质(eMMC还是SD卡)、屏幕分辨率,再去找对应的设备树配置文件。一个简单的判断方法:刷完固件后执行hdc shell cat /proc/device-tree/model,看系统识别到的板子型号和实际硬件是否一致。如果不一致,WiFi、触摸屏、背光这些外设大概率有问题。
7.2 Flutter版本与依赖库的匹配问题
OpenHarmony的Flutter适配分支和官方Flutter版本存在一个"时间差"。我刚开始直接按官方最新版创建项目,结果依赖怎么都拉不下来,报错信息还是一堆英文堆栈。后来发现是OpenHarmony适配分支要求特定版本的Flutter SDK,不能跟着官方主分支走。
解决办法是严格按照OpenHarmony社区适配文档里指定的Flutter版本安装SDK,然后用fvm管理多版本。依赖库方面也要注意:优先选纯Dart实现的库,凡是依赖Android Gradle插件的库,在OpenHarmony工程里经常报"Applying Flutter's main Gradle plugin imperatively"这类错误,这种只能换库或者自己手写实现。
7.3 后台定时任务与保活的现实约束
OpenHarmony的后台约束和Android越来越高,想靠纯后台定时器长期保活采集数据是不现实的。系统会在资源紧张时杀掉后台进程。
我的处理是双保险:第一,把采集任务做成前台服务,通知栏常驻一个"流量监测中"的通知,这样进程优先级维持在中高;第二,在App内置一个"自检补录"逻辑,每次用户打开App发现数据有缺口就自动补采。对于监管助手这个场景,用户本来就是偶尔打开看一眼,主动补录比后台保活更可靠。
这套组合下来,套餐历史模块从数据采集、存储、查询到展示、同步,已经完整跑通。最直观的收获是:在OpenHarmony上做Flutter应用,很多通用经验要打折,不能照搬Android/iOS的思路。数据源要抽象、插件要精选、方案要兜底。如果你也在折腾类似的跨端监管工具,先把数据模型定稳,把本地存储和同步的边界划清楚,页面上那些事反而不难。
