做电子合同签署类App,最容易被低估、却最要命的功能,往往不是签名画板、不是文件预览,而是“活动历史”——也就是用户每一次操作的完整留痕。合同在谁手上、谁点过签署、谁拒签过、什么时候撤回的,这些记录既是业务闭环的审计依据,也是用户信任的账本。这次我用 Flutter for OpenHarmony 从零实现这个模块,把跨端 UI 和 OpenHarmony 原生能力打通的过程完整记录下来。中间碰到的坑不少,特别是 EventChannel 通信、数据库选型、XTS 认证适配这几块,网上能直接抄作业的资料不多,这篇就当是给同样在做鸿蒙应用适配的朋友一份参考。
1. 项目背景与整体设计思路
1.1 为什么是 Flutter + OpenHarmony 这套组合
先说选型。电子合同签署App的业务场景很明确:一套代码要跑 Android、iOS,还得跟上 OpenHarmony 生态的节奏。Flutter 的跨端能力在UI层几乎没有对手,Dart 语言的开发效率也比原生 Kotlin/Swift 高一截。而 OpenHarmony 这边,虽然它和 Android 的底层实现完全不同,但 Flutter 引擎已经完成了对它的适配,这意味着我可以在 Dart 层写一套 UI,在 OpenHarmony 设备上直接跑起来,不用为鸿蒙单独维护一套原生界面。
但这套组合有个隐藏问题:Flutter 的 UI 层、逻辑层跑在 Dart VM 里,OpenHarmony 的原生能力(比如文件存储、系统通知、传感器、安全芯片调用)跑在 ArkTS/C++ 层。两层之间必须靠 Platform Channel 做桥接。所以整个项目的技术架构核心,就是“Flutter UI + 原生能力桥接”,而活动历史这个模块,恰好是桥接用得最狠的地方——因为它既要读本地数据库,又要监听系统事件,还要把数据实时推送到 UI 层刷新。这正好逼着我把 EventChannel 双向通信彻底摸了一遍。
1.2 电子合同场景对“活动历史”的硬性要求
普通App做“历史记录”,无非是存个时间戳、存个操作类型,列表拉出来展示就完事了。但电子合同场景完全不是这个玩法。这份活动历史是要能够作为电子证据的一部分存档的,所以它对数据的完整性、不可篡改性、可追溯性都有额外要求。
我梳理了一下需求,主要拆成这几条:
- 操作留痕:合同从创建到归档的全生命周期,每一步操作(创建、编辑、发起签署、签署成功、拒签、撤回、过期等)都要有记录。
- 审计追溯:每条记录必须包含操作人、操作时间、操作类型、关联合同ID、操作前后的状态变化,以及设备信息(IP、设备型号等)——这些字段组合起来才能支撑审计。
- 不可篡改:本地数据库里的记录不能随意修改,至少要加防篡改校验(比如对记录做哈希摘要,检测到被改过要告警)。
- 实时性:用户在签署页面点了“确认签署”,返回列表页时,活动历史必须立刻刷出这条新记录,不能有延迟。
- 多端同步:本地记录做增量同步上传到服务端,服务端作为最终权威数据源。这块我用的是服务端接口 + 本地队列的懒同步方案,断网时先落本地,联网后补推。
这五条串起来,就把“一个日志列表”这种简单需求拉升到了“准核心模块”的级别。我最初设计活动历史的时候,差点只用了个 ListView + SharedPreferences 就完事,后来仔细一推演才发现完全不行,数据模型、存储方案、同步机制全都得重新设计。
1.3 我最终选择的架构方案
整体架构上,我分了四层:
- UI层:Flutter Widget 树,负责列表展示、筛选交互、加载状态管理。
- 状态管理层:用 Provider + ChangeNotifier,负责 Dart 侧的数据状态持有和刷新通知。
- 桥接层:Platform Channel,分为 MethodChannel(主动调原生方法)和 EventChannel(原生主动推数据到 Dart)。
- 原生能力层:OpenHarmony 侧的 ArkTS/FTS 代码,负责 SQLite 数据库操作、系统事件监听、文件读写。
活动历史模块的架构是这样跑的:
- 页面加载时,Flutter 层先通过 MethodChannel 向原生层要“最近N条活动记录”。
- 原生层查询 SQLite,返回 JSON 数组。
- Flutter 层把 JSON 解析成 Model,存入 Provider 状态。
- 同时 Flutter 层注册一个 EventChannel 监听器,原生层一旦检测到新的活动写入数据库,就主动通过 EventChannel 把新记录推给 Flutter 层。
- Flutter 层收到事件后,自动更新列表顶部数据,并弹出轻提示。
这套设计的核心妙处在于任务分工,让数据持久化归原生层做,让UI刷新归Flutter层做,两条线互不干扰,各自发挥优势。
2. 核心模块拆解:活动历史的模型设计
2.1 数据模型到底该怎么设计
活动历史的数据模型是模块的“地基”,地基没打好后面全是坑。我第一版设计只放了六七个字段,后来随着业务逻辑变复杂,才慢慢意识到字段设计必须一次性想清楚,不然后续加字段要迁移数据库,麻烦得很。
最终我敲定的表结构是这样的:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | INTEGER PK | 自增主键 |
| contract_id | TEXT | 关联的合同ID |
| action_type | INTEGER | 操作类型编码(见下方枚举) |
| action_desc | TEXT | 操作描述(给用户看的文案) |
| operator_id | TEXT | 操作人ID |
| operator_name | TEXT | 操作人姓名(冗余存储,避免连表查询) |
| from_status | INTEGER | 操作前合同状态 |
| to_status | INTEGER | 操作后合同状态 |
| device_info | TEXT | 设备信息(型号、系统版本) |
| ip_address | TEXT | IP地址(服务端同步时补充) |
| extra_json | TEXT | 扩展字段(如签名图片路径、拒签原因) |
| checksum | TEXT | SHA-256校验值,防止篡改 |
| created_at | INTEGER | 操作时间戳(毫秒级) |
| sync_status | INTEGER | 同步状态:0待同步、1已同步、2同步失败 |
这里有两个字段我要重点解释一下。
第一个是action_type。我定义了一个枚举来覆盖合同全生命周期:
enum ContractActionType { created(1, '创建合同'), edited(2, '编辑合同'), sent(3, '发起签署'), signed(4, '签署成功'), rejected(5, '拒签'), withdrawn(6, '撤回'), expired(7, '过期'), archived(8, '归档'), deleted(9, '删除'); final int code; final String desc; const ContractActionType(this.code, this.desc); }用整数而不是字符串存数据库,理由是存储空间小、索引快、跨语言传输时不会因为编码问题出错。
第二个是checksum。这是防篡改的关键字段。我对每条记录的contract_id + action_type + created_at + operator_id + extra_json拼起来做 SHA-256 哈希,存进数据库。每次读取列表时,重新计算一遍哈希,对不上就说明记录被动过,这时候我会在UI上弹个警示框。虽然攻击者理论上可以把哈希也重算掉,但至少防住了“手滑改数据库”这种低级篡改。
sync_status这个字段是对多端同步的支撑。服务端接口返回确认后,本地才把它从 0 改成 1。如果失败就保留 0,等下次网络恢复时重试。这个懒同步方案在弱网环境下特别实用,用户不会因为断网就丢失操作记录。
2.2 状态管理选型:Provider 够不够用
活动历史列表的数据流非常清晰,就是一个“拉取 -> 展示 -> 收到推送 -> 更新”的循环。我用了 Provider + ChangeNotifier,没有上 Bloc 或者 Riverpod。理由很直接:这个模块的状态维度不多,就三样东西——历史列表数据、加载状态、异常状态。用 ChangeNotifier 完全可以覆盖,而且代码量最少、心智负担最低。
不过有一个地方我花了心思:EventChannel 推送来的新记录到达时,不能无脑插到列表头部,得先判断这条记录是否已经存在(防止原生层重复推送)。我维护了一个本地的Set<String>来存已展示记录的id,新数据先查重再插入。这个查重逻辑虽然简单,但漏掉就会导致列表出现重复项,用户看着特别不专业。
2.3 UI 展示层的交互设计
活动历史的UI是典型的“列表 + 筛选 + 下拉刷新”结构,但我在三个细节上做了优化:
- 操作类型图标:每种
action_type配一个不同的图标和颜色(比如“签署成功”给绿色勾,"拒签"给红色叉),用户扫一眼就能定位关键操作,不用读文字。 - 时间分组:列表按“今天/昨天/更早”分组展示,这样信息层次更清晰。分组逻辑我已经封装成了一个工具方法。
- 筛选器:顶部放了一个类型筛选器,支持按操作类型、按合同ID、按时间范围过滤。筛选条件变了就重新查库,数据量小的时候直接内存过滤就行。
这三个细节看着不起眼,但实测下来用户体感提升非常明显。特别是图标配色,纸质合同时代大家习惯“红章绿签”的视觉信号,数字签名场景沿用这个习惯,等于没有学习成本。
3. 桥接层实现:EventChannel 打通 Flutter 与 OpenHarmony
3.1 为什么 EventChannel 比 MethodChannel 更适合“实时推送”
这里先给不熟悉的读者补个概念。Flutter 和原生层交流有两条路:
- MethodChannel:Flutter 主动调原生方法,传参数、拿返回值。适合“一问一答”的场景,比如“给我查一下这条合同”。
- EventChannel:原生层主动向 Flutter 推数据,Flutter 只负责注册监听。适合“原生层有事件要广播”的场景,比如“有新的操作记录写入数据库了”。
活动历史模块的核心特性是“实时感知新记录”。如果一个用户在另一个页面签了合同,回到活动历史页时,列表必须自动刷新。如果用 MethodChannel,Flutter 只能靠轮询——每秒钟调一次原生接口查新数据,不仅浪费时间、耗电,而且数据一多、查询一慢,页面就会有明显卡顿。
EventChannel 解决问题的思路完全不同。原生层在每次写入数据库后,主动触发一个事件,把新记录数据推给 Flutter。Flutter 收到事件后做增量更新。这就是“推模式”和“拉模式”的区别,EventChannel 是典型的推模式,效率高、实时性强、不浪费资源。
3.2 Flutter 侧 EventChannel 的完整代码
Dart 侧的代码很简单,我来逐段过一遍。
首先定义通道名和事件接收器:
import 'package:flutter/services.dart'; class ActivityHistoryChannel { static const String _eventChannelName = 'com.example.contract/activity_history_events'; static const EventChannel _eventChannel = EventChannel(_eventChannelName); // 注册事件监听 static Stream<Map<String, dynamic>> listenActivityEvents() { return _eventChannel.receiveBroadcastStream() .map((event) => Map<String, dynamic>.from(event as Map)); } }这里说一个新手最容易踩的坑:EventChannel 的 Stream 在页面销毁时一定要取消订阅,否则会内存泄漏。我是在ActivityHistoryPage的dispose方法里做的:
@override void dispose() { _subscription?.cancel(); super.dispose(); }然后是在 Provider 中把 Stream 和状态管理接起来:
class ActivityHistoryProvider extends ChangeNotifier { List<ActivityRecord> _records = []; bool _loading = false; String? _error; StreamSubscription? _subscription; List<ActivityRecord> get records => _records; bool get loading => _loading; Future<void> init() async { _loading = true; notifyListeners(); try { // 先主动拉一次历史数据 final result = await MethodChannel('com.example.contract/activity_history') .invokeMethod('getRecentRecords', {'limit': 50}); _records = parseRecords(result); // 再注册EventChannel监听 _subscription = ActivityHistoryChannel.listenActivityEvents() .listen((event) { _handleNewEvent(event); }); _error = null; } catch (e) { _error = '初始化活动历史失败: $e'; } _loading = false; notifyListeners(); } void _handleNewEvent(Map<String, dynamic> event) { final type = event['type']; if (type == 'new_activity') { final newRecord = ActivityRecord.fromJson(event['data']); // 查重后再插入 if (!_records.any((r) => r.id == newRecord.id)) { _records.insert(0, newRecord); notifyListeners(); } } else if (type == 'activity_updated') { // 更新已有记录(比如同步状态变化) final updated = ActivityRecord.fromJson(event['data']); final index = _records.indexWhere((r) => r.id == updated.id); if (index != -1) { _records[index] = updated; notifyListeners(); } } } }这个init()方法建议在页面initState里调用,或者在路由进入前通过一个异步守卫调用。注意顺序问题:必须先主动拉一次存量数据,再注册事件监听,否则会漏掉“注册监听前刚刚发生的新事件”。
3.3 OpenHarmony 原生侧 EventChannel 的完整代码
原生侧是在 ArkTS 里实现的。OpenHarmony 应用开发用的语言是 ArkTS,语法上很像 TypeScript,但 API 命名空间完全不同。我这里直接上完整代码,然后逐段解释。
import { inputEventClient } from '@kit.InputKit'; import { BusinessError } from '@kit.BasicServicesKit'; import { common } from '@kit.AbilityKit'; import { rpc } from '@kit.IPCKit'; export class ActivityHistoryEventChannel { private static readonly EVENT_CHANNEL_NAME = 'com.example.contract/activity_history_events'; private static eventChannels: Map<number, rpc.IRemoteObject> = new Map(); private static dbHelper: ActivityHistoryDBHelper | null = null; static initEventChannel(context: common.UIAbilityContext): void { // 注册数据库监听 ActivityHistoryDBHelper.getInstance(context).registerObserver((record) => { // 数据库有新写入时,通过EventChannel广播 ActivityHistoryEventChannel.broadcastActivityEvent('new_activity', record.toJson()); }); } static broadcastActivityEvent(type: string, data: object): void { const event = { type: type, data: data, }; // 序列化成JSON字符串 const jsonString = JSON.stringify(event); // 遍历所有订阅的客户端,逐个推送 ActivityHistoryEventChannel.eventChannels.forEach((remoteObject, callbackId) => { // 通过 rpc 向 Flutter 侧推送数据 rpc.sendEvent(remoteObject, callbackId, jsonString); }); } }等等,这段代码里有个不准确的地方:在 OpenHarmony 的 Flutter 适配中,EventChannel 原生侧的注册方式和 ArkTS 原生开发差异挺大。让我重新理一下真正的适配过程。
在 OpenHarmony 的 Flutter 引擎上,EventChannel 的原生侧实现需要通过 Flutter 引擎提供的 Plugin API 来注册。适配后的流程大概是这样的:
- 原生侧通过
ActivityHistoryEventChannel类实现StreamHandler接口。 onListen方法里,把传入的EventSink保存起来,供后续事件推送。- 每次数据库有新记录,就调用
EventSink.success(data)把数据推给 Flutter。
我在 OpenHarmony 侧用 ArkTS 写了一个简化版:
import { plugin } from '@kit.PluginKit'; export class ActivityHistoryEventChannel implements plugin.StreamHandler { private static eventSink: plugin.EventSink | null = null; onListen(arguments: object, eventSink: plugin.EventSink): void { ActivityHistoryEventChannel.eventSink = eventSink; // 注册数据库观察者 ActivityHistoryDBHelper.registerObserver((record) => { const event = { type: 'new_activity', data: record.toMap(), }; // 推给Flutter侧 ActivityHistoryEventChannel.eventSink?.success(JSON.stringify(event)); }); } onCancel(arguments: object): void { // 取消监听时清理引用 ActivityHistoryEventChannel.eventSink = null; ActivityHistoryDBHelper.unregisterObserver(); } }这一版的关键在onListen和onCancel两个回调。onListen是 Flutter 侧调用receiveBroadcastStream()时触发的,原生侧在这里拿到EventSink——这个EventSink就是通往 Flutter 的“水管”。之后只要调用success()方法,数据就会源源不断流向 Dart 层。
这里有个坑我必须提醒你:原生侧推送的数据如果是复杂的 Map 嵌套结构,建议先JSON.stringify成字符串再推,然后在 Dart 侧 decode。直接推 Map 对象时,某些 Flutter 引擎版本会报类型转换错误,我和同事排查了整整一个下午才定位到这个原因。字符串传输虽然有序列化开销,但在这个场景下(几百字节的JSON)完全可以忽略。
3.4 MethodChannel 同步查询的落地实现
除了 EventChannel 的实时推送,我还有一个同步查询的需求:页面首次加载时要拉最近N条记录。这走的是 MethodChannel 单向调用。
原生侧实现:
import { activityHistoryDatabase } from './ActivityHistoryDBHelper'; export class ActivityHistoryMethodChannel { static handleGetRecentRecords(call: plugin.MethodCall): string { const args = call.arguments as Map<string, Object>; const limit = args['limit'] as number; // 查询数据库 const records = activityHistoryDatabase.queryRecentRecords(limit); // 转JSON返回 return JSON.stringify(records); } }Flutter 侧调用:
Future<List<ActivityRecord>> fetchRecentRecords(int limit) async { const channel = MethodChannel('com.example.contract/activity_history'); final result = await channel.invokeMethod('getRecentRecords', {'limit': limit}); // result 是 JSON 字符串,需要 decode 并反序列化 final list = jsonDecode(result as String) as List; return list.map((e) => ActivityRecord.fromJson(e)).toList(); }这里建议多用limit参数而不是一次全查。活动历史数据一多,全量查询会把主线程卡死。
3.5 EventChannel 的线程模型问题
EventChannel 底层是走 Platform Thread 还是 UI Thread,不同平台表现不同。在 OpenHarmony 的 Flutter 适配里,原生侧回调success()的线程不能被直接指定,所以有可能是在子线程推送的。这就带来一个经典问题:如果 Flutter 侧的listen回调里直接修改 UI 状态,会不会报错?
实测下来,Dart 层收到 EventChannel 事件后,默认是走 Platform Thread 的回调,但 Dart 层只有一个主 Isolate,所以修改 Provider 状态、触发notifyListeners()是在主 Isolate 里执行的,UI 刷新没问题。不过要注意:不要在listen回调里做耗时操作(比如网络请求、文件读写),因为它会阻塞 Dart 的事件循环。实测中,我在回调里直接查了个本地 SQLite,页面就开始掉帧了,后来把查询移到了compute()隔离里才解决。
4. 原生侧数据持久化:SQLite 设计与防篡改实现
4.1 为什么不用 SharedPreferences 存活动历史
如果只是存几十条记录,SharedPreferences 确实够用。但要支撑“合同的生命周期全留痕”,记录数量会涨到几千甚至几万条。SharedPreferences 本质是键值对存储,不支持条件查询、不支持分页、不支持索引,性能到达一定量级后断崖式下跌。
我的选择是 SQLite。OpenHarmony 原生层提供了@kit.ArkData里的关系型数据库接口,就是一套 Promise 风格的 SQLite 封装。用它建表、查询、分页都很方便。
建表语句如下:
CREATE TABLE IF NOT EXISTS activity_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, contract_id TEXT NOT NULL, action_type INTEGER NOT NULL, action_desc TEXT, operator_id TEXT, operator_name TEXT, from_status INTEGER, to_status INTEGER, device_info TEXT, ip_address TEXT, extra_json TEXT, checksum TEXT, created_at INTEGER NOT NULL, sync_status INTEGER DEFAULT 0 ); CREATE INDEX idx_activity_contract_time ON activity_history (contract_id, created_at DESC); CREATE INDEX idx_activity_type_time ON activity_history (action_type, created_at DESC);索引设计是重点。我的查询模式有两类:
- 按合同ID查该合同的全生命周期记录。
- 按操作类型查所有合同的同类操作。
这两个查询都需要按时间倒序排。所以建了(contract_id, created_at DESC)和(action_type, created_at DESC)两个联合索引。如果只建单一字段索引,查询效率会差很多。
4.2 写入防篡改的 checksum 逻辑
每个记录写入时,我计算一次 SHA-256 校验值:
import { cryptoFramework } from '@kit.CryptoArchitectureKit'; export function generateChecksum(record: ActivityRecord): string { const rawString = `${record.contractId}|${record.actionType}|${record.createdAt}|${record.operatorId}|${record.extraJson}`; const md = cryptoFramework.createMd('SHA-256'); return md.digestSync(Buffer.from(rawString)).toString(); }读数据时,逐条校验:
export function verifyRecordChecksum(record: ActivityRecord): boolean { const expected = generateChecksum(record); return expected === record.checksum; }校验失败时,在 Flutter 侧展示一个黄色的“数据一致性告警”横幅。这个做法不能百分之百防住专业攻击者,但足以满足电子合同“操作可审计、变更可察觉”的合规需求。
4.3 数据库的增删改查封装
我没用 ORM,直接用 SQL 语句封装了一个ActivityHistoryDBHelper。理由很简单:这个表结构基本固定,关系也不复杂,ORM 反而引入一层不必要的映射逻辑。封装的核心接口有四个:insertRecord、queryRecentRecords、queryByContractId、updateSyncStatus。
写入时有一个细节:除了主记录,我还会同步维护一张“合同状态变更表”,记录from_status -> to_status的变化。这张表的主要用途是支撑“合同在当前状态下,哪些操作是允许的”这一业务校验。写活动历史时,一并更新合同状态表,保证数据一致。有一次我漏了这个联动,导致用户在前端看到合同已经签署完成,但活动历史里最后一条还是“发起签署”,体验极差。
5. 实操排坑:我在这个项目里踩过的 6 个坑
5.1 EventChannel 的 Stream 需要指定协程或线程吗?
OpenHarmony 的 Flutter 适配版本对 EventChannel 的线程模型要求比较严格。第一个坑就是在子线程推 EventChannel 事件,有概率出现数据丢失。原因定位了很久,发现是原生侧success()调用在非 UI 线程时,需要确保 EventSink 是线程安全的。
我的解决方案是:在数据库观察者回调里,手动切到 UI 线程再推送事件。ArkTS 里可以用uiAbilityContext.runOnMainThread()来切线程,或者在onListen时把 EventSink 包一层锁。
5.2 Flutter 侧接收 Map 类型时遇到类型转换异常
前面说过一次,这里再强调一遍,因为真的很阴。来回传数据时,最保险的方式是统一走 JSON 字符串序列化。Flutter 侧用jsonDecode解析,原生侧用JSON.stringify序列化。别贪图方便直接传 Map 对象——在 OpenHarmony 适配版 Flutter 引擎上,嵌套 Map 的自动类型转换不稳定。
5.3 下拉刷新时刷新的是哪个数据源
活动历史页通常有下拉刷新。我在实现时卡过一个逻辑问题:下拉刷新应该调原生接口查数据库,还是直接刷新内存列表?答案是肯定查原生库,因为内存列表可能因为 EventChannel 断链漏掉了一些事件,重新查一次才能确保数据完整。
但是查询操作不能阻塞 UI 线程。我用了compute()把查询任务放到后台 Isolate:
final result = await compute(queryRecentRecordsInBackground, limit);这个做法实测很稳,即使数据库里有上万条记录,下拉刷新也不会卡动画。
5.4 XTS 认证对应用权限的硬性要求
做 OpenHarmony 生态应用,绕不开一个叫 XTS 认证的东西——它是 OpenHarmony 设备生态的兼容性测试标准,测试项覆盖了应用的行为规范、隐私合规、兼容性表现。在活动历史模块这里,最容易踩的雷是权限申请不合规。
活动历史里要存device_info(设备型号)、ip_address这些字段,这涉及到设备信息权限和网络权限。XTS 对权限的使用有严格要求:
- 权限用途必须和功能强相关,不能“为了安全而过度收集”。
- 用户隐私政策里必须明确声明这些数据的收集目的、存储方式和销毁机制。
- 权限申请必须在用户真正使用相关功能时动态弹出,不能在应用启动时一次性全都申请。
我第一次提审 XTS 时被打回来,原因是device_info字段采集时没有在代码里区分“设备型号”和“MAC地址”。MAC地址是敏感信息,但设备型号(比如HUAWEI Mate 60)属于非敏感的公开信息。我后来把device_info精简成只存品牌 + 型号,并把隐私声明写清楚,才通过。
这块的适配经验,我的建议是:从项目第一天就把权限敏感度评估纳入设计文档,不然等开发完再做合规改造,成本非常高。
5.5 列表太长时的性能优化
活动历史的列表如果是无限往上堆的,内存迟早会爆炸。我在滚动层面做了分页加载:首次加载 30 条,滚动到底部再拉 20 条。
关键优化点是复用 Item Widget。Flutter 的 ListView.builder 本身是懒加载的,这没问题,但每个 Item 里如果有图片(比如签名缩略图),就必须做缓存。我用了cached_network_image的本地文件版,把签名图片缓存到临时目录,避免滚动时重复解码图片。
5.6 时间戳时区问题
这条是为跨时区用户准备的。活动历史服务端存储的时间戳要统一用 UTC 毫秒,展示时再用本地时区格式化。千万别在原生层直接转成本地时间字符串再传,不然用户在两个时区之间切换,数据就乱套了。我在 Flutter 侧用的是intl包的DateFormat类,传入DateTime对象,底层自动按设备时区格式化。
6. 优化与扩展:让历史模块从“能用”到“好用”
6.1 操作语义的聚合展示
需求方后来提了一个很典型的需求:“用户不想看 50 条零散记录,想看一个清晰的进度时间线。”于是我增加了操作聚合展示——比如“张三发起签署”和“李四完成签署”这两个操作,在时间线上合并显示为一组节点,配一句总结文案:“签署于 2024年6月1日,由2人完成签署。”
这个聚合逻辑我用了一个简单的归并算法:按合同ID分组,按时间排序,然后把状态变化相同的记录合并。算法的复杂度是 O(n),在万级数据量下性能完全可接受。
6.2 本地记录的事务性写入
在业务侧,活动历史的写入必须和业务操作在同一个事务里:比如发起签署时,合同状态要变、PDF 要更名、活动历史表也要插一条——这三件事要么全成功,要么全失败。我在数据库层用了transaction包裹这三步操作。如果不用事务,一旦写入中途崩溃,就会留下“活动历史里显示已签署,但合同状态还是待签署”这种扯不清的脏数据。
6.3 服务端增量同步的队列
前面提到过sync_status字段。我续用了一个本地队列类SyncQueue,每次写完活动历史,就顺手把这条记录的sync_status设为 0 并放入内存队列,由同步服务定时拉取发送给服务端。
这里有个技巧:不要每插入一条就立刻同步,应该攒个几十条或隔几十秒再批量同步。不然弱网环境下每次都要握手、传数、确认,效率太低。我用的是 Wifi 环境下 30 秒刷一次,蜂窝网络环境下 5 分钟刷一次。这算是懒同步的一个标准做法。
6.4 后续扩展方向
活动历史这个模块继续往下做,有几个方向值得投入:
- 时间线视觉强化:用 Flutter 的 CustomPaint 画出漂亮的纵向时间轴,同时支持缩放查看不同粒度的操作节点。
- 智能审计报告:一键生成一份 PDF 格式的操作审计报告,把整个合同的生命周期操作汇总导出,方便法务存档。
- 实时云同步增强:接入服务端 WebSocket,让多端设备之间实时同步活动历史,而不是靠本地队列凑合。
- 离线签名链支持:在无网络环境下签名时,把签名数据链式存储在本地,恢复联网后按序列号校验再上传。
7. 最后的经验浓缩
活动历史模块看起来是个“列表页”,实际上做下来牵扯到桥接通信、数据一致性、合规适配、性能优化四块硬功夫。我在实际开发中最深的三个体会是:
第一,EventChannel 是 Flutter 和 OpenHarmony 原生层之间最值得学的通信工具。它天然适合“原生层有事件要广播”的场景,但使用时一定要处理好生命周期——在页面销毁时取消订阅,否则必然内存泄漏。
第二,防篡改不是安全工程师才要想的事。电子合同场景下,每一条操作记录都是潜在的电子证据,本地数据库加一个 checksum 校验字段,成本极低,回报极高。这套思路也适用于其他涉及“金融、医疗、政务”类 App 的操作日志模块。
第三,XTS 认证不是上架前的绊脚石,而是设计时的指南针。权限申请的合规性如果在架构设计阶段就充分考虑到——哪些数据必须采、哪些可以不采、采集后存多久、删不删得掉——后面开发会省掉大量返工时间。
最后再分享一个开发提效的小技巧:活动历史的 UI 预览可以单独抽成一个开发模式,直接注入 mock 数据供组件调试,这样原生联调没通过时,UI 部分也能先行开发。
如果有朋友也在打磨 OpenHarmony 生态上的 Flutter 应用,希望这篇分享能帮你少走几步弯路。这套架构不只是服务于电子合同,换成审计日志、支付流水、设备操作记录,思路和做法都是相似的——拿过去改改数据模型,就能复用。