最近做的一个项目,是把一套电子合同签署App适配到 OpenHarmony 平台上,技术栈选的 Flutter。整个项目里最核心、也最容易翻车的就是合同详情页——表面上它只是“展示一份合同 + 一个签署按钮”,实际上要同时处理多类型文件渲染、多签署方状态机、手写签名点位计算、原生能力桥接,任何一个环节没考虑清楚,用户真机点两下就会崩给你看。这篇文章就是围绕这一页做的完整复盘,从数据模型怎么设计、PDF 预览用什么方案,到 EventChannel 怎么对接 OpenHarmony 原生、再到 Impeller 渲染引擎和页面状态恢复上踩过的坑,一次性讲清楚。如果你正准备用 Flutter 给 OpenHarmony 做应用,或者只是好奇跨端适配会遇到什么,这篇应该能帮你少走不少弯路。
1. 为什么选 Flutter 而不是 ArkUI 原生:一次在 OpenHarmony 上的务实选型
1.1 两种技术路线的现实对比
OpenHarmony 应用开发目前主流有两条路:一条是直接用 ArkUI / ArkTS 写原生应用,另一条就是 Flutter for OpenHarmony 这类跨端方案。我们团队之所以选 Flutter,不是因为 ArkUI 不好,而是项目本身的约束在那摆着:存量 App 的 Android / iOS 版本都是 Flutter 写的,业务逻辑全在 Dart 层,几十个页面已经迭代了两三年,如果为了适配 OpenHarmony 重新用 ArkTS 写一遍,相当于双倍维护成本,交付周期根本排不过来。
当然,选 Flutter 要付出的代价也明确:OpenHarmony 生态的 Flutter 适配还在快速演进期,部分插件、渲染行为、系统能力通道都需要自己调。做选型不能只看发布会上的“一次编写处处运行”,要落实到你的具体业务里。
| 对比维度 | ArkUI 原生 | Flutter for OpenHarmony |
|---|---|---|
| 建设周期 | 全部重写,周期长 | 复用 Dart 业务层,周期可控 |
| 渲染一致性 | 与系统控件一致 | 自绘渲染,跨端体验统一 |
| 原生能力接入 | 直接调用系统 API | 需要桥接层(MethodChannel / EventChannel 等) |
| 团队门槛 | 需要 ArkTS 技术栈 | 现有 Flutter 团队可平滑切换 |
| 性能表现 | 原生最优 | 接近原生,复杂页面需优化 |
| 应用上架验证 | 也要做兼容性测试 | 同样需要过 XTS 认证等环节 |
表格里的 XTS 认证值得单独说一句。OpenHarmony 生态的应用上架前,通常要过兼容性测试,覆盖权限声明、接口使用、稳定性等多个维度。我们适配过程中遇到过因为权限白名单没声明完整,导致在 XTS 测试里“静默失败”的情况,表现出来就是某个原生能力在调用时没有报错,但就是不返回数据。这类问题非常隐蔽,所以从第一天起,就必须把“合规性检查”当作开发流程的一部分,而不是上架前的最后一步。
1.2 Flutter 在 OpenHarmony 上的运行路径
Flutter for OpenHarmony 的基本运行思路是:Dart 层代码跑在 Flutter Engine 上,UI 通过自研的渲染引擎(目前主推 Impeller,也可以回退到 Skia)绘制到 OpenHarmony 的 Surface 上,与原生能力交互则通过 Flutter 插件体系完成。这条路径跟 Android 上的 Flutter 架构高度相似,所以 Flutter 老手基本能很快上手,但差异集中在“系统能力接口”这一层。
比如 OpenHarmony 的权限模型、生物识别接口、文件沙盒路径,跟 Android 是两套东西。你写 flutter 插件的时候,Android 侧用的是 Activity 和 Fragment 那套生命周期,OpenHarmony 侧则要用自己的 Ability 生命周期,PlatformView 的嵌入方式也有差异。后面我会重点展开这块。
一个实操结论:在做 Flutter for OpenHarmony 项目前,先用官方模板工程跑通一个最小 Demo,再逐步把业务的骨架代码迁过来,不要一上来就搬全量代码库。否则很容易出现“跑起来了但不知道哪个环节没有真正适配”的情况,排查成本极高。
2. 合同详情页的数据模型:从后端 JSON 到状态机的映射
2.1 合同详情不是一个静态页面
很多人会把合同详情页想成“一张 PDF 图片 + 底部按钮”,但在电子合同业务里,这个页面承载的信息密度远比想象中大:合同正文文件、参与签署的各方、各自的签署状态、签署顺序、每一步的时间戳、催签记录、拒签原因,甚至还有合同模板的法律条款版本。这些信息要在一屏之内组织出清晰的层级,背后需要的是一个经过深思熟虑的领域模型。
我习惯先把详情页拆成四个区域:顶部操作区(签署、拒签、催签按钮)、中间文档区(合同正文渲染)、下方状态区(签署进度时间轴)、底部记录区(签署日志、查看记录)。这四个区域的数据来源各不相同,有的是登录用户视角计算出来的,有的是全量签署方视角,有的则完全来自后端审计日志。因此,页面 Controller 层必须先定义清楚“当前用户能看到什么”,再决定按钮显隐。
2.2 状态机的现实复杂度
电子合同的签署状态不是简单的“待签署 / 已签署”。真实业务里至少包括这些状态:草稿、待签署、签署中、已完成、已过期、已撤销、已拒绝。而且在多签署方场景下,还有一个“签署顺序”的概念:A 签完才会轮到 B,B 签完触发合同生效,进入已完成状态。
enum ContractStatus { draft, pendingSign, signing, completed, expired, revoked, rejected, }看起来只是一个枚举,但实际问题在于“状态之间的迁移条件”。比如合同处于 pendingSign 状态,当前用户到底能不能看到“签署”按钮,取决于:他在签署顺序中的位置是否轮到了、合同有没有被更高优先级的一方锁定、合同是否已过期、文件是否已经准备完毕。这些条件组合起来,就是一个小小的状态机:
class ContractStateModel { ContractStatus status; List<ContractSigner> signers; String currentSignerId; bool get canCurrentUserSign { final me = signers.firstWhere((s) => s.userId == currentSignerId); if (me.signStatus != SignStatus.waiting) return false; return status == ContractStatus.pendingSign || status == ContractStatus.signing; } }这里没有用复杂的状态管理框架,因为详情页的状态流转本身收敛、可枚举,用值对象 + 派生 getter 反而最容易理解和调试。
2.3 状态驱动 UI:别让 Widget 直接改状态
Flutter 新手容易犯的一个毛病,是把页面状态直接存在一个 StatefulWidget 的局部变量里,所有按钮点击都在 build 里去改这个变量。这在简单页面上问题不大,但在合同详情这种“跳转后还要保留数据”的场景,就会出现热搜上反复有人问的那个问题——Flutter Navigator 切换页面后,会丢失状态吗。
答案是:取决于状态放在哪。如果你把状态放在 State 的字段里,页面压栈、弹栈、或者底层栈回收时,State 可能被重建,字段就没了。正确做法是把状态提升到页面 Controller 层,用 ChangeNotifier / ValueNotifier 管理,Widget 只负责监听和展示:
class ContractDetailController extends ChangeNotifier { ContractInfo _contract; SignPosition _signPosition; Uint8List _signatureImage; void updateSignPosition(SignPosition pos) { _signPosition = pos; notifyListeners(); } }页面从相机或相册返回时,Controller 还活着,签名数据就不会丢。
还有一个 Dart 层事件循环的细节值得提:Future 的 then 回调是放入微任务队列吗。是的,Dart 的 then 回调默认被调度进微任务队列,会在当前事件循环的同步代码执行完之后立即执行,不排队等下一帧事件。这在合同详情里反而容易引出 bug——比如你在 Future 回调里先读了后端返回的合同状态,然后又在一个原生 EventChannel 的异步事件里更新了同一个状态对象,两个回调的先后顺序可能会跟你的预期不一致。所以在写状态更新逻辑时,不要依赖回调时序,尽量把“更新状态”收敛到 Controller 的唯一入口方法,避免在多个异步回调里交错修改。
3. 多类型合同渲染:PDF 预览方案的选择与落地
3.1 合同文件最常见的四种形式
合同详情页遇到的文件类型,比预想中复杂。我们线上大概有四种:PDF 正式合同、Word 转换出的高清图片页、HTML 格式的在线条款、纯图片(比如盖完章的扫描件)。每种类型的渲染策略不同,不能“一招吃遍天下”。
在这四种类型里,PDF 占比最高,也是技术选型的胜负手。大部分电子合同平台的做法是,后端在上传时就做一次预处理,把 PDF 按页渲染成高清图片,前端直接加载图片列表。这样做的优点是前端实现简单、兼容性最好、跨端表现稳定;缺点是文件体积大、首次加载慢、缩放体验不如矢量 PDF。另一种做法是前端用 PDF 渲染插件,直接在 Flutter 里解析 PDF 并绘制,但这在 OpenHarmony 上会遇到兼容性问题。
3.2 Flutter 侧 PDF 渲染方案实测结论
我自己在 OpenHarmony 真机上测过几条路线,结论供参考:
- 纯 Flutter PDF 插件(类似 pdfx):普通小体积 PDF 能跑,页面绘制也流畅,但遇到带加密、带复杂字体、超过上百页的合同文件时,会出现缺字、页码错位,甚至直接渲染空白页。原因是 Flutter 侧 PDF 解析引擎对某些字体子集和 CID 字体的支持不完整,这个问题在 Android 上也有,只是 OpenHarmony 上更容易触发。
- PlatformView 嵌入原生 PDF 控件:能解决大部分渲染兼容性问题,缩放和翻页也顺滑,但代价是要同时维护 Flutter 侧和 OpenHarmony 原生侧的代码,还要处理 PlatformView 与 Flutter UI 的图层深度问题。我们的真机测试里,PlatformView 在页面快速滑动返回时偶发黑屏,需要手动管理原生视图的回收时机。
- 后端转图片 + 前端 PageView:目前对我们来说最稳。后端把 PDF 每页转成 WebP 或 JPEG,前端用 PageView 按页加载,配好缓存、缩放、骨架屏,体验基本可以打平原生,开放兼容性上也不会被某个 PDF 解析库卡脖子。
| 方案 | 兼容性 | 开发成本 | 性能 | 大文件体验 | OpenHarmony 适配 |
|---|---|---|---|---|---|
| 纯 Flutter PDF 插件 | 中等 | 低 | 中等 | 差 | 有已知问题 |
| PlatformView 原生控件 | 高 | 高 | 高 | 好 | 需要处理图层回收 |
| 后端转图片 + PageView | 最高 | 中 | 中高 | 好 | 最省心 |
我的建议是:如果合同类型以标准 PDF 为主、页数不多,可以直接上 Flutter 插件;如果面向的是严肃签约场景,文件可能又大又复杂,那么优先考虑后端转图片方案,把兼容性的雷排除在客户端之外。
3.3 分页加载与缓存策略
无论选哪种渲染方案,”不能一次性把整份合同塞进内存“都是基本原则。我们项目里的合同,最多的有三百多页扫描件,单张页面图片在 2 倍屏下分辨率可达 2000×2800,一页就是几 MB,全部加载出来,真机内存直接爆掉。
实践下来的分页策略是这样:后端在返回详情时附带一个 pageCount 字段,前端只渲染当前页、上一页、下一页三张图,超出范围就释放。缓存用 LRU,限定图片总内存占用不超过 200MB。页面滑动时,用图片预加载代替全量加载,滑动到第 N 页,后台只去请求 N+1 和 N+2 页。这样在低端 OpenHarmony 设备上,连续翻几十页也不会有明显卡顿和内存抖动。
另外,图片解码参数要显式控制:不要解码原始尺寸,而是按当前显示区域的目标宽高进行 downSample。否则一张 4000×3000 的扫描件解码成 RGBA 位图大概是 48MB,几张同时驻留就把内存打满了。
3.4 HTML 条款页的渲染隔离
还有一类合同是 HTML 形式,我们用的是 WebView 渲染。这里有一个安全细节:合同 HTML 里不能允许加载远程外链。道理很简单,合同文本一旦能加载远程脚本或远程图片,就可能被注入跟踪代码或者被篡改展示内容。所以我们把 HTML 本身下载到本地沙盒,WebView 只加载本地资源,并且把 JavaScript 权限关掉,只保留基础的点击翻页能力。这个逻辑在 Flutter 里通过 PlatformView 拉起 OpenHarmony 原生 Web 组件实现,期间要注意跨域的 COSP 配置,保证合同内容不会被外部网络请求带走。
4. 手写签名与签署位置放置:交互细节决定合同合法性
4.1 签名面板:不只是画出笔画
手写签名是电子合同签署最严肃的交互之一。用户用手指在签名面板上写完名字,我们看到的是一串触摸事件流:down、move、up。开发签名控件时,最容易做错的是把轨迹点直接连成线段——这样写出的字棱角分明,而且笔画断裂感明显,非常影响法律证据链上的可信度。
更合理的做法是:在 move 事件里不做绘制,而是在 up 事件后,对轨迹点集做一次平滑插值,用 Catmull-Rom 样条把原始点补成密集曲线,然后再重新绘制。实测下来,即使触摸屏每秒只上报 60 个点,平滑后的笔迹也接近真实书写形状。
List<Offset> smoothPoints(List<Offset> raw, {int samplesPerSegment = 8}) { final result = <Offset>[]; for (int i = 0; i < raw.length - 1; i++) { final p0 = raw[i - 1] ?? raw[i]; final p1 = raw[i]; final p2 = raw[i + 1]; final p3 = raw[i + 2] ?? raw[i + 1]; for (int t = 0; t < samplesPerSegment; t++) { final u = t / samplesPerSegment; result.add(catmullRomPoint(p0, p1, p2, p3, u)); } } return result; }另外,OpenHarmony 真机的手写笔输入事件可以拿到压感和倾斜角。我把压感数据归一化后映射到笔画宽度,写出来的效果明显更自然。这块依赖 OpenHarmony 的输入事件 HDI 接口,需要在 Flutter 插件层桥接一次原生触摸事件才拿得到。
4.2 签名生成时的一个坑:不要直接截屏
签名面板的透明背景看着简单,实现上有个容易踩的坑:如果你用 RepaintBoundary + toImage 去截屏,得到的是一张带着面板背景色的图片,放在合同上会盖住文字,完全不能用。正确做法是把轨迹点画到一个离屏 Canvas 上,导出为带 alpha 通道的 PNG:
final recorder = ui.PictureRecorder(); final canvas = Canvas(recorder); final paint = Paint() ..color = Colors.black ..strokeWidth = 3 ..strokeCap = StrokeCap.round; canvas.drawPath(signaturePath, paint); final picture = recorder.endRecording(); final image = picture.toImage(width, height); final byteData = await image.toByteData(format: ui.ImageByteFormat.png);离屏绘制的另一个好处是不受 widget 布局层的影响,签名位置、大小、缩放都可以独立控制,还不会在真机上出现闪烁。
4.3 签署位置的坐标系换算
合同签署不是“把签名图拖到页面上”这么简单。合同里的每个签署域都有固定的业务含义:谁在这个位置签、签表单里哪一项、签的是公司章还是个人章。所以我们不仅要在视觉上拖动签名块,还要把签名块在页面视图上的位置,换算成合同页面坐标系里的真实坐标,然后回传给后端存证。
以 A4 合同页为例,PDF 页面的逻辑坐标是 595×842 pt。前端在 PageView 里看到的合同图片,是按屏幕宽度渲染出来的,实际显示宽高比会跟 PDF 逻辑坐标不同。换算公式其实不复杂:
Offset viewToPdfOffset(Offset viewOffset, Size viewSize, Size pdfSize) { return Offset( viewOffset.dx / viewSize.width * pdfSize.width, viewOffset.dy / viewSize.height * pdfSize.height, ); }但真正的坑在“缩放”。用户双指放大合同页面后,同一个签名块在 view 坐标系里的位置变了,如果后端只记录最后一次的换算坐标,用户撤销一次缩放再拖动,位置可能就错位了。我的处理方式是:签名块始终以 PDF 坐标系存储,拖动手势拿到的位移也先换算到 PDF 坐标系再应用,这样无论用户怎么缩放,签名块相对合同页面内容的位置都是稳定的。
4.4 签名数据回传与防篡改
签名完成后,签名的图片要回传给后端,但只回传图片是不够的。我们还会同时回传:签名轨迹的原始点集、签署时间戳、签名在 PDF 中的坐标、设备标识符,然后后端对所有字段计算一个 hash 摘要。一旦这些数据在传输或存储过程中被改动,hash 对不上,这份签名就失去法律效力。轨迹点集一定要保留,因为它可以在纠纷场景下做笔迹回放,这是只存一张位图做不到的。
5. EventChannel 桥接 OpenHarmony 原生:下载、指纹与打印的落地姿势
5.1 桥接层设计:一次性调用与持续事件分开
Flutter 与原生通信主要就是 MethodChannel 和 EventChannel 这两条路。很多初学者不理解为什么需要两种通道,直到你真正做业务才发现:一次性调用和持续事件流的处理模型完全不同。
在合同详情页里,我规划了三类原生能力:
| 能力 | 使用通道 | 说明 |
|---|---|---|
| 触发下载、获取设备信息、验证权限 | MethodChannel | 发一个请求,等一个结果 |
| 下载进度、打印任务进度、生物识别状态 | EventChannel | 原生向 Flutter 持续推送事件 |
| 嵌入原生 PDF / 相机 / 系统日期选择 | PlatformView 或原生页面跳转 | 复杂的原生交互界面 |
实际开发里的一个常见反模式,是用 MethodChannel 循环轮询下载进度,比如每 500ms 调一次“查下载进度”。这么做不是不能用,而是在高频率调用时开销大、时序不稳定——原生侧回调的顺序跟 Flutter 侧的接收顺序可能不一致,进度条会来回跳。正确做法是原生侧在进度变化时主动通过 EventChannel 推给 Flutter,Flutter 侧用 StreamBuilder 或 listen 接收,更新进度条。
5.2 文件下载:从 FTP 到沙盒路径的链路
合同附件经常放在公司内部的 FTP 或自建文件仓库,原生侧发起下载,Flutter 侧拿到的不是网络流,而是原生沙盒里的文件路径。这里有一个非常容易踩的坑:文件路径在不同平台上的语义不同。OpenHarmony 的沙盒目录,在 Flutter 侧如果用 Dart 的 File 直接访问,要确保路径前缀正确,否则会报文件不存在。
我们最终的设计是:原生侧下载完成后,把沙盒文件的绝对路径通过 MethodChannel 返回,Flutter 侧拿到路径后再用 dart:io 的 File 去读取。原生下载时使用断点续传,EventChannel 持续上报“已下载字节数 / 总字节数”,Flutter 侧只管进度展示。另外,下载完成后一定要原生侧主动关闭文件流,不然会发现文件句柄泄漏,表现就是日志里出现类似 “could not close i” 的 IO 错误。
5.3 指纹鉴权与系统打印
电子合同中“签署”这个动作通常要二次确认,很多场景会用指纹或面容验证。这块在 OpenHarmony 上要走原生生物识别接口,Flutter 侧先用 MethodChannel 发起验证:
final verified = await methodChannel.invokeMethod<bool>('biometricVerify'); if (verified == true) { controller.confirmSign(); }这里的一个细节是:生物识别弹窗是系统级的原生 UI,它不在 Flutter 的 layer tree 里。如果 Flutter 页面上有 PlatformView,原生弹窗出现时,要处理好两层系统的焦点和生命周期,否则可能出现在原生弹窗关闭后,PlatformView 的渲染画面异常。解决办法是,在调用原生能力前,把当前页面里的 PlatformView 暂停拦截触摸事件,等验证完成再恢复。
打印能力也类似:用户要把签好的合同直接打到办公打印机上,原生侧通过系统打印任务实现,任务状态通过 EventChannel 回传,Flutter 侧显示“打印中 / 已完成 / 失败”。整个桥接层的职责边界很清楚,原生只管系统能力,Flutter 只管业务体验,两者之间只通过序列化好的字符串和数值通信。
6. 适配过程踩过的三个坑:从现象到修复链路
6.1 Impeller 渲染引擎在 OpenHarmony GPU 上的 shader 问题
项目上线前做真机兼容性测试,反馈最频繁的问题集中在渲染侧:签名笔画偶尔出现黑色矩形块,合同图片区域在快速翻页时会闪屏,时间轴的滚动动画在某些机型上看有明显的撕裂感。这些现象指向同一个怀疑对象——渲染引擎。
Flutter 3.44 之后,OpenHarmony 侧默认使用 Impeller 渲染引擎。Impeller 的指导思想是预编译 shader,避免运行时 shader 编译导致的卡顿,但它在某些 OpenHarmony 设备的 GPU 驱动上,对特定的绘制指令组合兼容性一般。实测下来,出现黑块的场景几乎都涉及“带有 mask filter 的路径绘制”,也就是签名里的半透明笔画边缘投影。
修复思路分两步。第一步是代码层面规避:签名面板的 shader 效果尽量简化,不用复杂的 maskFilter,改用 Canvas 自带的抗锯齿和透明混合,视觉差异很小。第二步是运行时降级:在构建配置里保留回退开关,如果目标设备的 GPU 驱动对 Impeller 不友好,就临时切回 Skia 渲染。测试验证用长时间压测,在几台中低端机型上连续执行签名、翻页、缩放操作,黑块不再出现。
6.2 Navigator 切换页面后的状态恢复:一场真实事故
那次事故的现象是:用户进入合同详情,挪好签名位置,然后点了页面里的“拍照上传身份证”,拍照返回后,签名位置错乱,甚至直接消失。因为拍照跳转走的是系统原生页面,压栈和恢复的时序跟 Flutter Navigator 不完全一致。
当时的根因正如前面说的,签名位置数据被临时存在了 StatefulWidget 的字段里,原生页面返回时,Flutter 侧页面经历了 deactivate 和重新 build,字段被重置。修复方案是把这个状态提升到 ContractDetailController 层,并在恢复时做一次幂等的重算。每次拍照返回后,页面重新从 Controller 读取签名坐标,而不是依赖 build 过程中的局部变量。
这件事也解释了“Flutter navigator 切换页面后会丢失状态吗”到底在什么条件下成立:如果状态对象没有生命周期高于页面的容器持有,丢失是必然的。特别是涉及原生页面跳转时,Flutter 页面的状态保留机制没有想象中那么可靠,必须主动把关键数据提到高层。
6.3 构建与插件注册的工程规范
另外要说的两个编译期问题。一个是在 OpenHarmony 工程里配置 Flutter 插件时,早期文档推荐的“手写 apply 脚本”方式,会触发类似 Android 构建里 “you are applying flutter's main gradle plugin imperatively using the apply script” 的警告。这其实就是插件注册方式不规范,应该使用官方工具链统一管理,避免在构建脚本里手工 apply 导致依赖顺序混乱。
另一个是打包时偶发的资源流相关断言错误。日志会提示某个文件流没有正常关闭,通常是资源目录里存在重复或损坏的资源文件。处理方式比较朴素:清理构建缓存、检查资源文件完整性、确认没有并发任务同时写同一个产物目录。OpenHarmony 的构建系统还在完善中,这类问题遇到先沿“缓存 → 资源 → 并发”的顺序排查,不要盲目重装依赖。
7. 性能优化的几个关键动作:让详情页在低端设备上不卡
7.1 预取与骨架屏
合同详情页的体感速度,很大程度取决于列表页到详情页的过渡设计。我们从列表页接口里直接捎带了详情页的“轻量摘要”,包括合同标题、签署状态、签署人头像列表。用户点进详情页时,第一帧先用这些数据渲染一个骨架布局,同时后台并发请求完整详情和文件信息。这样即使完整数据要 1 秒才回来,页面也不是白屏,体验差距非常明显。
7.2 图片与 PDF 页的内存控制
在转图片方案里,合同页图片的内存管理直接决定页面稳定性。我们给图片组件统一加了显式的解码尺寸:
Image.network( pageUrl, cacheWidth: (screenWidth * devicePixelRatio * 0.9).round(), cacheHeight: null, );cacheWidth 设为目标显示宽度的 0.9 倍,既不会让图片模糊,又能把解码后内存压到最低。配合 LRU 缓存,页面连续翻几十页都没有发生内存溢出。
7.3 降低首帧主线程负载
合同详情页里有两个高频重绘区域:签名画布和时间轴动画。我在签名画布外层包了 RepaintBoundary,签名过程只在画布内部重绘,不会牵扯整个页面;时间轴的进度动画同样隔离到独立 layer。状态更新时,只 notify 那些真正变化的监听者,避免整页 rebuild。
实测数据上,中端机型在进入详情页、首屏渲染完成后,滑动和翻页能稳定在 55~60 帧;只有对 300 页大合同做双指缩放时,偶尔会掉到 40 帧左右,但不再有卡死或黑屏。这个表现在真机测试里是可以接受的。
最后一个阶段的心得也顺便分享:跨端适配真正难的不是把界面画出来,而是让业务语义、原生能力和渲染引擎的脾气对齐。OpenHarmony 生态还在快速迭代,上线的第一个版本要把日志和性能监控提前打好,后续踩坑才有据可查。合同详情这块做完之后,我接下来准备继续做 CA 证书链展示和骑缝章生成,到时候再单独写一篇复盘。