最近在推进电子合同签署App的OpenHarmony适配,项目里最不起眼但让我花心思最多的模块,就是"活动历史"。合同签署这种业务有个很鲜明的特点:每一步操作都可能成为司法举证材料。谁在什么时间点做了什么操作、合同当时处于什么状态、签名关联了哪张数字证书、签署用的设备指纹是什么,全都得可查、可追溯、能展示。而这些诉求最终都落到同一个页面上——合同详情底部的活动时间线。
我在这个项目上用的是Flutter + OpenHarmony的组合。文章里不会重复Flutter基础教程的内容,而是聚焦"活动历史"这一条主线,按我做事的顺序展开:选型逻辑、活动历史的领域模型与数据表设计、用Provider把数据从数据库流到时间线界面、时间线UI的具体落地,以及上真机后踩到的一堆构建与运行时兼容性问题。适合两类人看:一类是准备把Flutter业务往OpenHarmony上搬的团队,另一类是做合同、审批、审计类App、需要实现操作留痕功能的移动端开发者。
1. 为什么选Flutter而不是ArkTS:电子合同App的跨端现实
1.1 团队现状逼着我重新算一笔账
这个电子合同项目启动时,目标平台列表里就有Android、iOS和OpenHarmony三个。OpenHarmony之所以会被列进来,是因为合同签署设备的真实使用场景不只有手机——还有行业平板、银行网点的智能柜台,这些设备不少已经预装OpenHarmony。更关键的是,我们团队本来就有一支成熟的Flutter团队,Android和iOS端的合同签署App已经在线上跑了一年多,从草稿起草、文件上传、手写签名采集到数字证书验签,整套业务逻辑都沉淀在Dart层了。
如果OpenHarmony端重新用ArkTS写一遍,后果基本可以预判:两个技术栈并行维护,业务逻辑双份实现,字段名和状态机稍有偏差就会导致三端行为不一致。电子合同对"一致性"的要求比普通App苛刻得多——同一份合同,用户在Android上看到的签署状态和在OpenHarmony桌面上看到的必须完全一样。所以我的判断很简单:只要Flutter能在OpenHarmony上稳定运行,选型基本没有悬念。
1.2 ArkTS和Flutter之间真正的差异点
很多人问我,OpenHarmony不是主推ArkTS吗?为什么要用Flutter?这个问题要分两层看。OpenHarmony系统本身是用C/C++写内核和底层框架,应用层开发首选ArkTS/ArkUI,这一点没问题,官方主推生态也主要围着ArkTS转。但"首选"不等于"唯一",OpenHarmony从设计上保留了native运行时和多语言应用框架的接入能力,Flutter就是通过OpenHarmony SIG维护的flutter_flutter、flutter_engine仓库完成适配的,Dart代码可以直接跑在OpenHarmony设备上。
ArkTS与Flutter的对比,我整理了一张表,都是项目里实际踩到过的维度:
| 对比维度 | ArkTS/ArkUI | Flutter/Dart |
|---|---|---|
| 框架适配度 | 官方原生支持,新特性第一时间可用 | 依赖SIG适配,版本比上游滞后一到两个大版本 |
| 团队学习成本 | OpenHarmony团队需要掌握ArkTS声明式UI | 复用现有Dart经验,迁移成本低 |
| UI表达力 | ArkUI声明式能力不弱,复杂自绘依赖Canvas | 自带渲染引擎,时间线这类自绘组件表现力强 |
| 平台特性调用 | 直接调用系统接口 | 需要插件桥接,生态比Android/iOS弱不少 |
| 多端一致性 | 只能保证OpenHarmony一端 | Android/iOS/OpenHarmony一套代码 |
1.3 什么情况下我建议你别用Flutter
选型这件事不能只看好处。如果你这个App重度依赖OpenHarmony的系统级能力——比如分布式流转、元服务、跨设备协同,那直接用ArkTS,别折腾Flutter插件桥接,这些能力在Flutter侧的适配成熟度还不够。另外,如果团队里没有现成的Dart工程师,只有ArkTS工程师,我也不会贸然引入Flutter,因为Dart语言、异步模型、Flutter组件体系都是额外的学习负担。
还有一个很实际的经验:Flutter for OpenHarmony不要追新版本。我们当时锁在Flutter 3.22对应的适配分支上,因为SIG的插件适配和引擎发布有滞后,新版本刚出来的时候三方插件大多还没跟上,反而容易在集成阶段卡住。挑一个社区验证过的稳定分支,比你用最新版省心得多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 活动历史的业务模型:先搞清楚"记录什么"再写代码
2.1 合同全生命周期的状态机
活动历史不是流水账,它的核心是合同状态机的每一次变迁记录。先把合同的状态流转定清楚,代码实现里用枚举表达:草稿(DRAFT)、待签署(PENDING)、签署中(SIGNING)、已完成(COMPLETED)、已驳回(REJECTED)、已过期(EXPIRED)、已终止(TERMINATED)。
正常路径是:创建合同进入草稿,发起签署变成待签署,签署人打开进入签署中,双方都签完变成已完成。异常路径有两条:合同发起后被打回,退回草稿;超过约定时效未签署自动过期。终止一般是双方合同履行完毕后归档停用,或者管理员强制关闭。
这里有一个我一开始就踩过的坑:把活动事件简单地等同于"合同状态变化"。实际上很多需要留痕的操作并不会改变合同状态,比如"乙方查看了合同"、"系统给签署人发了提醒消息"、"签署人重新下载了附件"。这些动作对合同效力举证非常关键——对方到底看没看过合同、什么时候看的,经常是纠纷里的争议点。所以活动历史的模型不能只依赖状态机,得独立设计事件类型。
2.2 事件类型枚举与字段设计
我最终设计的ActivityType枚举是这样的:
dart复制enum ActivityType {
created('CREATED', '创建合同'),
sent('SENT', '发起签署'),
viewed('VIEWED', '查看合同'),
signRequested('SIGN_REQUESTED', '请求签署'),
signed('SIGNED', '完成签署'),
rejected('REJECTED', '驳回合同'),
reminded('REMINDED', '签署提醒'),
expired('EXPIRED', '合同过期'),
terminated('TERMINATED', '合同终止');
const ActivityType(this.code, this.label);
final String code;
final String label;
}
每条活动记录的数据结构,除了基本的id、contractId、type、时间戳之外,我额外加了四个字段:operatorId(操作人ID)、operatorName(操作人姓名冗余)、sourceStatus和targetStatus(操作前后的合同状态)、extraData(JSON,存签署证书序列号、设备指纹、IP地址这类扩展信息)。
为什么要把operatorName冗余存下来?活动历史页是高频只读查询,列表一次拉20条,如果每条都join用户表拿姓名,分页查询会慢,而且用户改过昵称后历史记录里的姓名会跟着变——这在举证场景里是致命的,历史记录理应"冻结"当时的事实。电子合同系统里活动历史本质上是一份审计日志,审计日志的第一原则就是不可变性:记录一旦写入,内容就不能因为后续数据变更而改变。
2.3 数据表结构与索引
我用的是关系型数据库,表结构如下:
sql复制CREATE TABLE IF NOT EXISTS contract_activity (
id TEXT PRIMARY KEY,
contract_id TEXT NOT NULL,
activity_type TEXT NOT NULL,
operator_id TEXT,
operator_name TEXT,
source_status TEXT,
target_status TEXT,
extra_data TEXT,
created_at INTEGER NOT NULL
);
CREATE INDEX idx_activity_contract_time
ON contract_activity(contract_id, created_at DESC);
创建时间我用INTEGER存毫秒时间戳而不是TEXT,原因有两点:一是排序比ISO8601字符串快,二是避免时区换算的坑。所有活动时间都以服务端下发的UTC时间为准,客户端只负责按本地时区格式化展示。这个约定必须在最早就定下来,不然你会发现Android端和OpenHarmony端同一批记录差了8个小时——都是因为有的端直接拿本地时间落库了。
索引设计上,最核心的查询是"某个合同下的活动列表,按时间倒序",所以我建了(contract_id, created_at DESC)联合索引。项目早期我犯过一个小失误——只给contract_id建单列索引,结果合同签了几个月、活动记录上万条之后,列表滚动开始明显掉帧,加了这个联合索引才解决问题。
3. Provider状态管理与数据链路:从数据库流到界面的完整路径
3.1 为什么选Provider而不是Riverpod或Bloc
Flutter的状态管理方案五花八门,我在这个项目里选Provider,主要看重三点:第一,它和ChangeNotifier结合得最自然,团队里新同学上手快;第二,Riverpod虽然编译期安全性和可测试性更好,但对一个要交付到OpenHarmony的项目来说,引入太多泛型和Provider族概念,新人写着反而容易出错;第三,项目的状态复杂度没到需要Bloc那种事件驱动架构的程度——活动历史说到底就是一个列表、一个加载状态和一个错误状态,ChangeNotifier完全够用。
组件通信在活动历史页面里其实很典型:页面顶部有一张合同状态卡片,中间是筛选栏,底部是时间线列表。这三个组件如果各自去数据库查数据,会出现数据不同步的问题——比如列表显示合同已驳回,顶部的状态卡片还在显示待签署。我用Provider把"同一份活动数据"提升到页面级共享,三个组件通过Consumer分别订阅,任何一个组件触发刷新,其余组件自动拿到最新数据,这就解决了Flutter组件通信里最常见的"兄弟组件状态不同步"问题。
3.2 Repository层把数据源变化隔离在边界以内
OpenHarmony上的数据库插件适配情况参差不齐,我把数据访问全部封装在Repository层后面,上层只依赖接口,不依赖具体是sqflite还是drift。这样即使今天用sqflite_openharmony,明天因为性能问题换drift,UI层和Provider层一行都不用动。
dart复制abstract class ActivityRepository {
Future<List<ContractActivity>> queryByContract(
String contractId, {
required int limit,
required int offset,
});
Future<void> insert(ContractActivity activity);
Future<int> countByContract(String contractId);
}
Repository的具体实现我用sqflite_openharmony,在OpenHarmony设备上它底层会桥接到系统的关系型数据库能力。insert的时候注意幂等性:同一个活动事件如果因为网络重试被提交两次,不能产生两条记录,我在DAO层加了id冲突处理,写入时使用INSERT OR REPLACE,保证同一个事件id永远只有一条。
3.3 ChangeNotifier + MultiProvider的组织方式
Provider层的核心是一个ChangeNotifier子类,维护活动列表、加载状态、错误信息和分页标记。完整的代码骨架是这样:
dart复制class ActivityHistoryProvider extends ChangeNotifier {
ActivityHistoryProvider(this._repo);
final ActivityRepository _repo;
List<ContractActivity> _items = const [];
ActivityLoadState _state = ActivityLoadState.initial;
String? _errorMessage;
bool _hasMore = true;
static const _pageSize = 20;
List<ContractActivity> get items => _items;
ActivityLoadState get state => _state;
String? get errorMessage => _errorMessage;
bool get hasMore => _hasMore;
Future<void> loadFirstPage(String contractId) async {
_state = ActivityLoadState.loading;
notifyListeners();
try {
final page = await _repo.queryByContract(
contractId, limit: _pageSize, offset: 0);
_items = page;
_hasMore = page.length == _pageSize;
_state = page.isEmpty
? ActivityLoadState.empty
: ActivityLoadState.success;
} catch (e) {
_errorMessage = e.toString();
_state = ActivityLoadState.failure;
}
notifyListeners();
}
Future<void> loadMore(String contractId) async {
if (_state != ActivityLoadState.success || !_hasMore) return;
final offset = _items.length;
final next = await _repo.queryByContract(
contractId, limit: _pageSize, offset: offset);
if (next.isEmpty) {
_hasMore = false;
} else {
_items = [..._items, ...next];
_hasMore = next.length == _pageSize;
}
notifyListeners();
}
}
在页面根部用MultiProvider装配依赖:
dart复制MultiProvider(
providers: [
Provider<ActivityRepository>(
create: (_) => ActivityRepositoryImpl(db),
),
ChangeNotifierProvider<ActivityHistoryProvider>(
create: (ctx) =>
ActivityHistoryProvider(ctx.read<ActivityRepository>()),
),
],
child: const ContractDetailPage(),
)
这个组装顺序很有讲究:ActivityHistoryProvider依赖ActivityRepository,所以Provider得先注册Repository,再用ctx.read取出来传给ChangeNotifierProvider。如果两个Provider的注册顺序写反,create里读不到Repository,运行时会直接抛ProviderNotFoundException。
3.4 分页与加载边界的控制
活动历史会随着合同生命周期越攒越多,一口气load全部记录的做法在真机上必卡。我用了最简单的offset分页,每页20条,滚动到底部时触发loadMore。这里有个细节:loadMore里我先判断_hasMore和当前state,避免滚动监听器在加载过程中被重复触发。另外,分页只对列表数据生效,顶部状态卡片用的还是同一份Provider数据里的最新状态字段,不需要再拉接口。
还有线程模型的问题。数据库操作是异步的,但不要在UI线程里同步等待。我在Repository的所有方法里都用async/await,Provider的notifyListeners只在状态真正改变时调用,避免无意义的重建。在低端OpenHarmony设备上,UI线程一旦被数据库操作卡住,帧率掉到20fps以下,时间线滚动会非常难受,所以数据加载的每一环都保持异步是硬性要求。
4. 活动时间线的UI落地:节点、分组与状态展现
4.1 需求拆解:这个页面到底要画什么
活动历史页的真实需求有三个层次。第一层,用户能按时间倒序看到合同的所有操作记录;第二层,每条记录要一眼识别出是什么类型、谁操作的、什么时间;第三层,关键事件(签署完成、驳回、过期)要能点开查看凭证,比如数字证书序列号、签名指纹。这些需求落到UI上,就是一条典型的垂直时间线:左侧竖线、节点圆点、右侧内容卡片。
节点用什么图标和颜色,我按事件类型做了映射:签署成功是绿色对勾,驳回是红色叉,发起签署是蓝色箭头,查看是灰色眼睛,合同过期是橙色闹钟。颜色和语义一一对应,用户不需要读文字就能抓取到自己关心的重点。这个映射不要散落在Widget的build方法里到处判断,我统一收敛在ActivityType的扩展方法里,UI和业务枚举解耦。
4.2 按日期分组的列表结构
时间线不能平铺,合同签了两三个月后平铺会变成一堆没有逻辑的日志流。我在列表里做了按日期分组:今天、昨天、更早的按日期标题分组展示。分组逻辑放在Provider和UI之间的一层,不在build里做,因为build会被频繁调用,分组计算重复执行很浪费。
dart复制Map<DateTime, List<ContractActivity>> groupByDay(
List<ContractActivity> activities) {
final groups = <DateTime, List<ContractActivity>>{};
for (final activity in activities) {
final day = DateTime(
activity.createdAt.year,
activity.createdAt.month,
activity.createdAt.day,
);
groups.putIfAbsent(day, () => []).add(activity);
}
return groups;
}
列表我用ListView.builder,item的类型有两种:日期标题item和时间线节点item。有人可能想用SliverList做更精细的滚动控制,但在数据量没到几千条之前,ListView.builder加item复用已经足够流畅。竖线我用一条两像素宽的Container放在每条记录的左侧,节点圆点用Stack叠在竖线上,比CustomPaint绘制的开销小,代码也更好维护。
时间展示格式上,同一天的记录只显示HH:mm,跨天分组标题显示完整日期,今天的直接显示"今天"。这类格式化逻辑我放在模型层做缓存,每个item只计算一次,不在build方法里反复toString。
4.3 空态、加载态、错误态一个都不能少
活动历史页面在实际运行中会出现三种非正常状态,开发时很容易漏:空态——合同刚创建还没任何操作;加载态——首屏数据还在查询;错误态——数据库异常或插件桥接失败。我的做法是给Provider设计一个三态枚举,UI根据state切换视图:
- 加载中:时间线骨架屏,每行一个灰色的节点占位,让用户知道页面正在加载;
- 空态:图标加文案"暂无操作记录",不放按钮,因为这个页面是只读审计页;
- 错误态:必须给出重试按钮,而且要显示具体错误信息摘要,方便研发远程排查。
我在OpenHarmony真机上遇到过一种情况:错误态里的错误信息是MissingPluginException,这是因为某个数据库插件在真机上没注册成功,属于典型的环境问题。所以错误态UI里的错误摘要一定要暴露出来,别为了"用户体验"把错误信息吞了,不然线上出问题你连从哪个方向排查都不知道。
4.4 渲染引擎对UI的隐性影响:Impeller与Skia的差异
Flutter渲染引擎有Skia和Impeller两条路线。Android上Impeller已经逐步转正,但Flutter for OpenHarmony适配版本目前主要走Skia路线。我在真机上对比过,Skia渲染时间线这种大量圆角、阴影、半透明叠加的界面,低端设备上会有shader编译卡顿,首次滚动时间线会出现明显掉帧,等shader缓存建好后才流畅。
如果你也遇到第一次进页面卡、后面流畅的情况,可以检查是不是shader编译导致的。解决方案有两种:一是减少界面里动态阴影和圆角裁剪的嵌套层数,时间线节点不要用BoxShadow套BoxShadow;二是如果设备内存够,考虑把时间线页面做成常驻页面,进入合同详情前预加载,把shader预热的时间放在用户无感的后台。在Android侧,如果遇到诡异的文本模糊或圆角锯齿,可以尝试显式启用Impeller对比效果,两个引擎的渲染差异借这个办法很快能定位。
5. 构建与集成:OpenHarmony的插件适配和不兼容排查
5.1 插件依赖清单:能用的和不能用的
OpenHarmony的Flutter插件生态还在成长期,这不是秘密。我项目里的依赖清单和适配状态,大致是下面这张表:
| 插件 | 用途 | OpenHarmony适配状态 |
|---|---|---|
| provider | 状态管理 | 纯Dart,无需适配 |
| intl | 日期格式化 | 纯Dart,无需适配 |
| sqflite_openharmony | 关系数据库 | 社区适配,可用 |
| path_provider_openharmony | 获取文件目录 | 社区适配,可用 |
| shared_preferences_openharmony | 键值存储 | 社区适配,可用 |
| signature | 手写签名采集 | 依赖Canvas自绘,真机验证可用 |
| dio | 网络请求 | 纯Dart,可用 |
选插件的几条经验:第一,纯Dart实现的包基本无脑可用,因为不涉及原生桥接;第二,涉及原生能力的包,优先找OpenHarmony SIG官方维护的三方适配版本;第三,找不到适配版本时,先别急着放弃,看这个包的核心原生能力是否能用platform channel自己桥接。
5.2 Flutter代码接入HAP包的两种方式
OpenHarmony的应用包格式是HAP,Flutter代码最终要打进去才能跑到真机上。我这里有两种路径:一种是在DevEco Studio里建OpenHarmony工程,把Flutter模块作为依赖引入,类似Android里把Flutter作为AAR集成的思路,不过包的形态和构建方式不同;另一种是直接用支持OpenHarmony的Flutter工具链,在项目里启用ohos平台支持后,执行构建命令产出HAP包。两种方式我都试过,DevEco路径对团队里不熟Flutter的工程师更友好,命令行路径更适合在CI里跑。
无论走哪条路,有一个核心检查点:应用包里的二进制不能包含重复的Flutter引擎。我遇到过OpenHarmony端安装后App体积莫名增大、启动变慢的问题,最后发现是Flutter引擎被同时打进了主包和模块包,重复加载了。集成时务必确认HAP里只有一份Flutter引擎产物。
5.3 Gradle插件报错:老工程迁移时的典型坑
如果你是从老的Flutter工程迁移到OpenHarmony构建链,大概率会碰到这样一段报错:you are applying flutter's main gradle plugin imperatively using the apply script which is no longer supported. 这个报错说的是Flutter的主Gradle插件必须用声明式的方式加载,也就是在settings.gradle里用plugins块声明,而不是在build.gradle里用apply script加载。老工程改起来不难:
groovy复制// settings.gradle
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "8.1.0" apply false
}
这里有个细节:Flutter插件loader声明后,各Flutter插件的版本不要自己手写管理,让loader统一控制。如果项目里还引用了旧版AGP,升级AGP版本的同时记得检查gradle wrapper版本,两者不匹配会引发一连串看不懂的构建错误。
6. 真机踩坑记录:从Dart VM初始化错误到滚动卡顿
6.1 dart_vm_initializer.cc(41)的完整排查链路
这个报错我在项目早期遇见过不止一次,完整信息类似:E/flutter (31173): [ERROR:flutter/runtime/dart_vm_initializer.cc(41)] Unhandled Exception。第一次看到时以为是Dart虚拟机崩了,其实它不是崩溃,而是Dart层在应用启动阶段抛了一个未捕获异常,VM初始化器把这些异常打印出来而已。换句话说,真正的问题在我们自己的Dart代码里,报错日志只是冰山一角。
我的排查链路是这样的,你也可以照着走一遍:第一步,先别盯着dart_vm_initializer这行看,往上翻日志,找真正的堆栈,通常就在它上面几行;第二步,看堆栈指向哪里——如果指向main()入口里的某个await,基本是插件注册或数据库初始化失败;第三步,如果是MissingPluginException,去检查pubspec里这个插件的OpenHarmony适配版本是否真正打进HAP了,用包查看工具确认libs目录里有没有对应的so文件;第四步,如果堆栈不明确,在main()第一行就加FlutterError.onError捕获全局异常,把完整堆栈写到本地文件,再重新复现。
这个报错在OpenHarmony上还有一个特殊来源:部分三方插件只编译了Android的arm64-v8a产物,没有编译OpenHarmony要求的so。检查的时候重点看armeabi-v7a、arm64-v8a两种ABI是否都齐了,只带一种ABI的包在高版本设备上可能没问题,但在部分低端方案设备上直接启动崩溃。
6.2 新建项目跑不起来的常见原因
网上很多人问"flutter新建项目后跑不起来",我自己也遇到过。大多数情况出在Flutter工程创建时没有包含ohos平台目录。用官方flutter create创建出来的默认工程只有Android、iOS等平台目录,要跑OpenHarmony必须用支持OpenHarmony的Flutter工具链,并在创建或后续命令里显式声明ohos平台。跑不起来时优先检查这几项:项目里有没有ohos目录、DevEco Studio的SDK路径配没配对、有没有安装对应OpenHarmony版本的SDK、设备有没有开开发者模式并连上调试。
如果以上都对但安装阶段卡住,去DevEco的构建日志里看签名配置。OpenHarmony的HAP包必须签名才能安装,Debug包也要有自动签名配置,很多"跑不起来"其实是最后一步签名配置缺失导致的。
6.3 热重载、中文字体与日期格式化
Flutter在OpenHarmony上的热重载能力不如Android成熟。我在真机上调试时间线布局时,Hot Reload点了没反应或界面错乱的频率还挺高。经验就是:UI调整阶段尽量用Hot Restart,实在不行就冷启动,别在热重载上耗时间。
中文字体是另一个容易翻车的地方。时间线上的操作人姓名、合同状态文案都是中文,如果系统字体路径在OpenHarmony上没有被Flutter引擎正确识别,会出现中文全部渲染成方块字。解决方式是在MaterialApp的theme里显式指定包含中文的字体family,或者把开源中文字体打包进assets,用fontFamilyFallback兜底。代价是包体变大几MB,但换来字体的稳定渲染,对合同App这种正式产品值得。
日期格式化上,我踩过时区的坑:活动记录的时间戳是UTC毫秒数,直接用DateTime.fromMillisecondsSinceEpoch(ts)得到的是本地时区,但如果开发机、真机和服务端的时区设置不一致,三端看到的时间会差出数小时。我的约定是:解析统一用DateTime.fromMillisecondsSinceEpoch(ts, isUtc: true),展示时再转本地时区,并且所有格式化逻辑走intl包的DateFormat并固定locale,禁止到处手写字符串拼接。
6.4 时间线滚动的性能回检
功能跑通后,我用DevEco自带的性能分析工具对时间线页面做了几轮回检。第一轮在200条记录以内都很流畅,超过500条后下拉加载出现轻微掉帧。定位后发现两个问题:一是每个时间线item都重新创建了分组Map,二是item里的日期格式化每次build都在执行。修完之后,我又在ItemWidget上套了const构造和key复用,掉帧基本消失。这条经验可以提前分享:列表性能问题90%来自build方法里的重复计算,而不是Flutter框架本身,先把计算挪出build,再谈其他优化。
7. 提交前的自检清单:从功能验证到XTS兼容性准备
7.1 活动历史功能层面的回归清单
电子合同的每个版本发布,活动历史的回归我都会固定跑一遍下面这份清单:
- 合同从草稿到完成的正常路径,每一步活动是否按时间倒序展示;
- 驳回和过期两条异常路径,状态颜色和图标是否正确切换;
- 同一天多条活动是否归入正确分组,"今天/昨天"边界是否准确;
- 分页加载是否顺畅,快速滚到底部时是否出现重复数据;
- 客户端断网时进入活动历史页,错误态和重试按钮是否正常;
- 关键活动(签署、驳回)的凭证弹层信息是否与服务端一致。
这份清单里最容易漏的是断网场景。活动历史的数据虽然主要来自服务端,但本地数据库有缓存,客户端不能因为断网就白屏,至少要能展示缓存记录并提示"当前数据可能不是最新"。合同签署App在业务场所经常遇到弱网环境,这个体验细节直接影响一线使用者的评价。
7.2 XTS认证前的兼容性自测
如果这个App要上OpenHarmony的正式分发渠道,会有兼容性认证环节,OpenHarmony的XTS认证对应用的行为一致性有要求。我的经验是提前做两件准备:第一,应用内所有系统能力调用(权限申请、文件读写、网络访问)都要有对应的权限声明,并且在不满足权限时优雅降级,不要直接crash;第二,彻底清理日志输出,不要在release包里打印敏感信息。活动历史里会存操作人姓名、签署凭证这些敏感数据,日志一旦泄漏到生产环境,比功能缺陷严重得多。
7.3 留给后来人的两点建议
最后说两个个人体会。第一个,活动历史的表结构和接口字段,在项目第一天就该定成"审计优先"的形态——哪怕当时产品只要求展示,也要把操作人、时间戳、前后状态、扩展信息这些字段都留好,后续要加举证能力的时候才不会需要改表结构。第二个,Flutter for OpenHarmony的排障思路和Android大同小异,但凡是查不到的报错,先怀疑插件适配版本,再怀疑自己的代码逻辑;这个顺序反过来的话,你会绕很多弯路。这套打法帮我在这类项目里省下了大量排查时间,希望对你也有用。
