☰
Flutter与OpenHarmony跨端桥接:电子合同活动历史模块实战
2026/10/3 3:35:35 网站建设 项目流程

做电子合同签署类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 数据库操作、系统事件监听、文件读写。

活动历史模块的架构是这样跑的:

  1. 页面加载时,Flutter 层先通过 MethodChannel 向原生层要“最近N条活动记录”。
  2. 原生层查询 SQLite,返回 JSON 数组。
  3. Flutter 层把 JSON 解析成 Model,存入 Provider 状态。
  4. 同时 Flutter 层注册一个 EventChannel 监听器,原生层一旦检测到新的活动写入数据库,就主动通过 EventChannel 把新记录推给 Flutter 层。
  5. Flutter 层收到事件后,自动更新列表顶部数据,并弹出轻提示。

这套设计的核心妙处在于任务分工,让数据持久化归原生层做,让UI刷新归Flutter层做,两条线互不干扰,各自发挥优势。

2. 核心模块拆解:活动历史的模型设计

2.1 数据模型到底该怎么设计

活动历史的数据模型是模块的“地基”,地基没打好后面全是坑。我第一版设计只放了六七个字段,后来随着业务逻辑变复杂,才慢慢意识到字段设计必须一次性想清楚,不然后续加字段要迁移数据库,麻烦得很。

最终我敲定的表结构是这样的:

字段名类型说明
idINTEGER PK自增主键
contract_idTEXT关联的合同ID
action_typeINTEGER操作类型编码(见下方枚举)
action_descTEXT操作描述(给用户看的文案)
operator_idTEXT操作人ID
operator_nameTEXT操作人姓名(冗余存储,避免连表查询)
from_statusINTEGER操作前合同状态
to_statusINTEGER操作后合同状态
device_infoTEXT设备信息(型号、系统版本)
ip_addressTEXTIP地址(服务端同步时补充)
extra_jsonTEXT扩展字段(如签名图片路径、拒签原因)
checksumTEXTSHA-256校验值,防止篡改
created_atINTEGER操作时间戳(毫秒级)
sync_statusINTEGER同步状态: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 来注册。适配后的流程大概是这样的:

  1. 原生侧通过ActivityHistoryEventChannel类实现StreamHandler接口。
  2. onListen方法里,把传入的EventSink保存起来,供后续事件推送。
  3. 每次数据库有新记录,就调用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 应用,希望这篇分享能帮你少走几步弯路。这套架构不只是服务于电子合同,换成审计日志、支付流水、设备操作记录,思路和做法都是相似的——拿过去改改数据模型,就能复用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询