【开源软件移植】EasyTier 适配鸿蒙 PC:上架后的网络链路、桌面体验与持续迭代
【开源软件移植】EasyTier 适配鸿蒙 PC:上架后的 网络链路、桌面体验与持续迭代
欢迎加入开源鸿蒙PC社区: https://harmonypc.csdn.net/
欢迎在PC社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper
鸿蒙 PC 应用仓库: https://atomgit.com/OpenHarmonyPCDeveloper/ohos_easytier
Rust 内核适配仓库: https://atomgit.com/FrankHan2004/EasyTier
本文仓库: https://atomgit.com/FrankHan2004/EasyTierBlog-PublishEasyTierBlog-Publish

写在前面
EasyTier 鸿蒙版已经上架,但上架不意味着移植工作结束了。开发时能连上自己的设备,和交给用户日常使用,中间还有不少事情要补。比如连接为什么没有按预期建立,运行状态长时间不更新该怎么判断,配置项越来越多以后,编辑页为什么开始卡顿。
这些问题不一定涉及新的组网协议,却会直接决定一个应用是否好用。后续迭代里,不少工作就集中在这些部分。
本文以开源版 EasyTier 在鸿蒙 PC/2in1 桌面设备上的适配为主,应用界面由 ArkTS 与 ArkUI 实现,组网能力复用 Rust 内核。先讲怎么用它加入一个网络,再展开问题定位、网络链路、配置页性能,以及上架和更新时容易弄错的地方。涉及的部分改动属于上架后的开发分支迭代,不代表当前商店正式版已经包含全部内容;性能数据也会单独说明测量条件。
阅读导航与界面截图说明
文章篇幅不短,可以按需要跳着读:
- 只想用它加入一个网络:看“先上手”和紧接着的“从四个页面看日常使用”。
- 想知道迁移做到了哪里:看 T0 / T1 / T2 三张表。
- 想弄清网络 链路和状态为什么要分层:看第二、三章。
- 在意配置页卡顿怎么查:看第四章。
- 要自己上架或维护更新:看第五章。
文中四张界面截图来自我自己的设备,分别是首页、编辑页、状态页,以及设置里的“关于与帮助”。它们是 3000×2000 的原始截图,没有裁剪或拼接,设备名显示为 MatePad SE,属于鸿蒙大屏设备上的窗口化界面。使用这些图之前,先说明几个边界:
- 截图里的版本是 v2.6.4(App 2.6.4.61),本文分析的是后续开发分支的源码,两者的文案和布局可能有差别,实际界面以你安装的版本为准。
- 每张图只是某一次截屏的瞬间画面。它们能说明界面结构和入口,不能证明连接长期稳定、吞吐表现,或者业务一定可以访问,也不是本文重新做的一轮真机验证。
- 不根据截图推断系统版本,也不把它当成设备支持范围的依据。
- 图中的网络名称、对端地址、实例标识只是我自己的示例,网络密码已经是掩码显示;请不要照抄这些值,更不要把真实密钥贴到公开渠道。
PC 适配也不只是把手机页面放大。工程声明了 2in1 设备以及浮窗、分屏和全屏模式,窗口尺寸变化会同步到布局状态;配置页按窗口宽度在单栏、双栏和三栏之间切换。因此,窄窗口需要保留清晰的操作入口,宽窗口则要控制同时创建的配置组件数量,后面的加载优化就是围绕这个问题展开的。
先上手:让鸿蒙 PC 加入一个 EasyTier 网络
以鸿蒙 PC 连接另一台已经运行 EasyTier 的电脑为例。先安装 EasyTier 鸿蒙版,并准备一份你有权使用的网络配置。这里讲的是本机组网,不需要把云同步和跨设备控制也一并配置好。
- 从首页右上角的加号进入“新建实例”。 如果已经有标准 TOML 配置或普通 EasyTier 分享链接,先复制完整内容,再选择“从剪切板导入”。共享的配置文件也可以通过系统分享交给 EasyTier,文件内容仍进入同一套导入流程。
- 没有现成配置,就手动填写。 输入一个便于辨认的实例名称,点“完成”进入编辑页。实例名称是本地管理用的名字,不是组网身份;两端要使用相同的网络名称和密钥,并配置可访问的对端或共享节点地址。第一次使用可以先保留自动分配虚拟 IP 的设置,不急着改高级路由。
- 回到首页,选中这份配置,再点底部的启停按钮。 首次使用按系统提示确认 网络 授权。若授权或启动没有完成,先查看提示和日志,不要把按钮已经切换当作连接成功。
- 进入状态页,看虚拟 IP 和节点信息。 等待分配的地址、正在确认的运行状态,都不是“已经连通”。先确认本机状态,再看 Peer 是否建立连接;目标服务是否可访问,还要另做验证。
- 用对端的虚拟 IP 验证实际业务。 比如访问对端已经启动的 Web 服务,或连接允许虚拟网卡流量的远程桌面服务。这里用的是 EasyTier 虚拟网段里的地址,不是对端公网地址;如果节点已连接但服务打不开,还需要检查监听地址、端口和对端防火墙。
- 用完后停止实例,需要复用就保留或分享配置。 首页启停按钮和状态页的“停止运行”都提供停止入口。编辑页可分享链接或 TOML 文件,也能生成二维码。配置与链接可能包含网络密钥,不要把真实配置随手贴到公开讨论区。
还有一个入口差别:“扫码直达”等仅启动链接,在当前没有实例运行时会走一次性启动路径,不等于已经把配置导入保存。需要长期保留一份配置时,使用普通分享链接或 TOML 文件更合适。
这个流程里,最容易省掉的是第四、第五步:应用接受了启动请求,和两台设备上的服务已经能互相访问,是两件事。后面不少状态与诊断改动,就是为了让这段过程更容易看明白。操作入口可对应 HomePage、NewConfig 和 StatusPage;自行编译安装的步骤另见 中文编译与运行说明。
从四个页面看日常使用
上面六个步骤分散在不同页面里。把四个最常用的页面按使用顺序走一遍,能看出每一页只负责什么,也能看出为什么前面一直强调“状态”和“连通”要分开。
首页:选配置,开关实例,找日志

截图 1:首页。窗口标题下显示 EasyTier v2.6.4,右上角是新建实例的加号和设置入口。
首页只放三类内容:配置卡、日志入口和底部的启停按钮。配置卡右上角的数字是配置数量,图中为 1 项;每一项带有名称、实例标识、收藏星标和编辑图标,点进编辑就是下一张图。旁边的卡片显示“日志总数”22,这是 Logs 组件里的统计入口,目的是让用户在遇到问题时一眼能找到日志,而不是先去翻设置。
这一页刻意没有放节点列表、流量曲线之类的细节。首页要在窄窗口、宽窗口和浮窗之间反复变形,放得越多,布局越难稳定;详细状态交给状态页,首页只负责“选哪份配置、要不要启动”。底部蓝色按钮是启停入口,截图里显示的是暂停样式图标,这只是按钮此刻的外观,不能用它推出隧道已经能访问业务。判断连通仍要回到状态页,再用对端虚拟 IP 访问实际服务。
编辑页:基础设置在左,功能开关在右

截图 2:编辑页。左侧是基础设置,右侧是一组功能开关卡片;图中地址、域名和开关组合只是示例。
这是整个应用字段最多的一页,也是第四章性能问题的来源。左侧“基础设置”按使用频率排列:虚拟 IPv4 地址和 DHCP 开关(图中 DHCP 处于关闭,手填 10.126.126.5 / 24)、主机名(旁边有“使用设备名”按钮,可以直接取设备名,图中为 MatePad SE)、网络名称、网络密码(默认掩码显示,右侧可切换可见性),以及“对等节点地址”列表。第一次使用时,通常只需要核对网络名称、密码和对端地址这三项,其余保持默认即可。
右侧是由布尔字段生成的功能卡片,每张卡片有分类标签、标题和图标。图中只有“开启延迟优先模式”和“启用多线程”是蓝色高亮,其余为灰色,包括使用用户态协议栈、KCP / QUIC 代理与输入、禁用 P2P、无 TUN 模式、启用出口节点、系统转发、禁用加密、禁用 UDP 打洞、启用魔法 DNS、启用私有模式等。把这些开关做成统一的卡片,是为了让一类字段共用一套组件;代价就是数量多,宽屏下同时创建的节点也多,所以才有 4.1 的共享加载队列和 4.3 里针对 BoolField 的 Native 文本子树。
两点提醒。第一,界面上出现某个开关,不等于它在鸿蒙平台一定可用,前面 T1 一节已经讲过 faketcp、ICMP ping 代理这类依赖高权限网络操作的路径,同样的判断适用于这里的其他选项。第二,“禁用加密”“系统转发”“出口节点”这类选项会改变安全或路由行为,除非清楚后果,不要照着截图打开。
状态页:先看本机,再看对端

截图 3:状态页。这是一次截屏的瞬时数据,不代表持续表现。
状态页自上而下的顺序,大致对应第三章讲的分层:
- 运行健康提示。 顶部显示“主进程与内核响应正常”,文案来自
EasyTierUtil里对采样时间和内核心跳的判断;如果采样中断或内核响应超时,这里会改成“状态不可确认”,而不是继续显示上一次的正常结果。 - 本机信息。 设备名 MatePad SE 和虚拟 IP 10.126.126.5,右侧有一个绿色圆点;紧接着是 NAT 信息,图中为 UDP 端口受限型、TCP 全锥型,这对判断打洞是否容易成功有参考价值。
- 流量。 上传、下载都是 0.0 B/s,只能说明截图那一刻没有明显的业务流量,不能说明链路异常,也不能说明吞吐如何。
- Peer 列表。 每个节点一张卡片,包含节点名称、P2P / TCP 标签、延迟(图中分别是 62、58、72 ms)、丢包率(Loss 0.0%)和各自的流量。其中一个节点显示 245.5 B/s,其余为 0.0 B/s,更说明这是瞬时值。
顶部右侧的暂停图标是“停止运行”的入口。排查问题时,建议按这个顺序读:先看健康提示是否可信,再看本机地址,然后看 Peer 是否建立,最后才轮到目标服务本身。即使 Peer 延迟、丢包率看起来都很好,也只说明节点之间的链路情况,不能替代对端防火墙和监听端口的检查。
设置里的“关于与帮助”:记录你到底在用哪一份包

截图 4:设置中的“关于与帮助”弹层,版本信息为截图时所装构建。
这个弹层看起来最不起眼,却是排查和发布验收里最常用的一页。“版本”分组里有三行:APP 版本 2.6.4.61(26422503)、编译时间 2026-08-13T11:05:23.158Z,以及内核版本 2.6.4-main-61-2930-1-g8c15941c。第五章反复强调“应用新了,装进去的内核不一定新”,这一页就是把应用版本、构建时间和内核版本放在一起,让用户和维护者能对上同一份包。
“帮助”分组提供教程和更新日志入口,更新日志的展示规则见 5.5 对 CHANGELOG_REVISION 的说明。“购买服务”里有申请退款入口,下方说明写的是:退款由华为应用内支付处理,申请提交后以华为审核结果为准。这里只是说明入口和处理方,应用本身不做额外承诺。再往下是仓库入口,弹层里被截断,没有展示在图中。
把四个页面串成一次自检
下面是一套建议的检查顺序,供使用者排查和发布者验收参考。它是经验性的建议,不是本文已经完成的一轮测试记录。
- 先在“设置 → 关于与帮助”记录 APP 版本、编译时间和内核版本;如果在做发布验收,对照 CI 记录里的 App 版本和 Core 版本是否一致。
- 回到首页,确认选中的是预期的那份配置,顺手记下日志总数,复现问题前后对比。
- 进入编辑页,核对网络名称、密码、对端地址和虚拟 IP / DHCP 设置;不确定的高级开关先恢复默认,一次只改一项。
- 启动实例,在状态页依次看健康提示、虚拟 IP、NAT 和 Peer;看到“状态不可确认”时,先别急着判断网络,要先弄清是采样中断还是内核没有响应。
- 用对端的虚拟 IP 访问一项实际服务。不通的话,再检查对端监听、端口和防火墙。
- 要提交问题时,附上第 1 步记录的版本信息,再按 2.3 末尾的方式分享日志,并先检查材料里有没有网络密钥。
先把迁移进度说清楚:T0 / T1 / T2
只说“已经上架”,很难看出到底迁移了哪些能力。我把目前的工作按 T0 基础可用、T1 日常核心功能、T2 PC 平台增强分开看:前一阶段解决能不能跑,后一阶段解决能不能在桌面上用得顺手。
下面依据的是应用仓库当前源码和随工程分发的 HAR。“已实现”表示有实际调用链,不是只有一个按钮,也不意味着每个系统版本都验证过,更不代表这些开发分支改动已经全部进入商店正式版。
T0:从 Rust 库到能进入组网流程的应用
| 能力 | 当前进展 | 移植时实际处理的内容 |
|---|---|---|
| 内核接入与应用构建 | 已实现 | arm64 HAR 包含 Native 库、ArkTS 入口和类型声明,应用通过 NAPI 调用;当前工程面向 HarmonyOS 6.1.0(API 23) |
| 配置创建与导入 | 已实现 | 创建配置后写入内核配置仓库;标准 TOML 和 EasyTier 分享链接可经剪贴板、共享文件等入口导入,二维码承载的是分享链接 |
| 启动前校验 | 已实现 | 启动入口先调用 parseNetworkConfig,配置同步成功后再进入运行宿主的启动流程,不把界面填完等同于配置有效 |
| 系统 网络 与 TUN | 已实现 | 系统授权后准备 网络 连接,收到内核的 TUN 请求后创建接口并交付 fd;TUN 创建、挂载和失败清理都有单独路径 |
| 节点与运行信息 | 已实现 | 内核返回虚拟 IP、Peer、NAT、路由和 TUN 状态,页面负责展示,不在 ArkTS 中重写组网协议 |
T0 最重要的是把“应用启动”“实例启动”“TUN 挂载”“节点互通”分开。前面的界面截图能说明应用界面和窗口化形态,不能替后面几件事作证。源码中的主要入口是 EasyTierUtil 和 EntryVpnConnectionService。
T1:配置能维护,运行出了问题也能查
| 能力 | 当前进展 | 已实现的内容与限制 |
|---|---|---|
| 多配置管理 | 已实现 | 创建、复制、收藏、重命名和删除均调用内核配置接口;多份配置可保存,不等于每个实例都能各建一套系统 TUN |
| 配置导出与分享 | 已实现 | 内核导出 TOML 后生成 .toml 文件交给系统分享,也可生成分享链接和二维码;不是把界面内部 JSON 当作通用配置文件 |
| 运行状态与日志 | 已实现 | 使用内核快照、Peer 事件、流量统计和运行采样更新界面,并提供日志文件与崩溃日志分享;过期状态不继续冒充当前正常状态 |
| 切网恢复与启停编排 | 已实现 | 观察物理默认网络变化,对运行实例按原配置软重启;启停通过队列协调,切网回调不直接重建 网络/TUN |
| 后台运行与通知 | 按条件提供 | 有后台任务、运行通知和不同运行宿主路径,但系统不提供长时任务能力时并不会因此获得同等保持效果,不能承诺永久常驻 |
运行模式的边界也得写清楚:独立 网络扩展进程维护一个当前实例,另一个实例同时启动会被拒绝;主进程的 网络 服务维护当前系统 TUN,也不能据多配置列表推导出任意并发多 TUN。至于 faketcp、ICMP ping 代理等依赖高权限网络操作的路径,不能仅凭配置里有字段,就把它们算作鸿蒙 PC 已支持的能力。
这部分可以从 DataUtil、ShareUtil 和 NetworkChangeWatchdog 看到配置与运行侧的实际衔接。
T2:把原生应用做成适合 PC 使用的样子
| 能力 | 当前进展 | PC 侧实际适配与前提 |
|---|---|---|
| 窗口与多栏布局 | 已实现 | 支持浮窗、分屏和全屏;窗口宽度换算成 vp 后,以 720 / 1080 vp 为断点切换配置页的一、二、三栏 |
| 首页与大屏加载 | 已实现 | 首页卡片可以移动、调整尺寸和隐藏,位置与尺寸写入 Preferences;配置页共享逐帧加载队列,BoolField 使用小范围 Native 文本子树 |
| PC 状态栏入口 | 按条件提供 | 根据设备类型及平板浮窗状态尝试注册状态栏项,注册成功后才允许关闭窗口退到后台;桌面 Form 卡片是另一套能力,不能据声明就认定每种 PC 桌面都能添加 |
| 云空间配置同步 | 已实现,依赖云服务 | 使用配置库快照、FileSync 和 CloudFileCache 完成上传、缓存和导入;需要华为账号、可用网络及云空间服务就绪,不是局域网同步 |
| 跨设备控制 | 已实现,需授权与权益 | 通过鸿蒙软总线协同会话查看状态、管理配置、一次性下发配置并启停实例;面向一定范围内同账号的鸿蒙设备,需要分布式数据权限与 remote_control 购买权益 |
| Web 控制台与高权限系统控制 | 未作为当前客户端能力接入 | HAR 中存在配置服务器接口,不等于 ArkTS 已接入上游 Web 控制台;近场协同也不能替代任意互联网远程管理 |
这里最容易被“跨端”两个字带偏。云同步、近场控制和 PC 状态栏解决的是不同问题,不能合成一句“全平台都支持”。对应实现可以看 CloudConfigSyncService、跨设备控制入口 和 StatusBarIntegrationService。
目前基础调用链、日常配置管理和 PC 布局都有实现,但不适合按表格里的条目数算一个“迁移完成率”。更有意义的是继续检查授权失败、切网、连续启停和窗口变化这些具体场景。下面就挑其中几处上架后改动较多的地方展开。
一、HAR 跑通以后,要继续处理什么
EasyTier 鸿蒙版采用 ArkTS 应用与 Rust 内核配合的方式:鸿蒙侧调用系统能力创建 TUN,再把文件描述符交给 Rust。组网协议、节点连接和数据转发由 EasyTier 内核处理,Rust 能力通过 HAR 集成到应用工程。
这套结构里,各部分的职责需要分清楚:
- ArkTS 页面负责配置、操作入口和状态展示。
- ArkTS 的系统衔接层负责 网络 授权、TUN 创建和资源释放。
- Rust 内核负责网络实例、组网通信及运行信息的生成。
这里有个容易混淆的地方:界面需要的状态信息,与真正的网络数据包,不是一条处理链路。
本地内核 socket 传递节点变化、流量统计和 TUN 请求;业务数据包并不是先变成 JSON 送到页面,再由 ArkTS 转发。
把这两件事分开以后,页面就不需要为了显示几个状态,介入内核的数据转发。接下来要解决的,是状态和实际运行情况能不能对得上。
这里的分层是职责划分,不意味着每层都必须在独立进程中。下文以本轮技术迭代所分析的 Entry 主进程运行路径为例;独立 网络 扩展进程属于另一条运行路径,不能仅凭工程中存在 VpnExtensionAbility 就推断所有构建都把内核放在扩展进程。
二、网络 建立了,EasyTier 自己的连接该怎么走
2.1 隧道流量不能再被自己的隧道接管
网络 类应用有一个比较特殊的问题:它既要接收系统交给 TUN 的数据,又要通过真实网络连接其他节点。
这两类流量的用途不同:
需要进入虚拟网络的流量
→ TUN → EasyTier 内核 → 组网隧道
EasyTier 用于连接节点的底层 socket
→ 真实网络 → 对端节点
如果第二条路径也被 网络路由重新送进 TUN,就可能产生回流。表面上看,应用已经启动,网络 也已经建立,但节点连接仍然不正常。
HarmonyOS 提供了 VpnConnection.protect(socketFd):将指定 socket 排除在 网络转发之外,使它通过物理网络收发数据。这一轮开发分支把绕过 网络的范围收细到具体 socket,而不是对整个进程统一放行。
这样做的好处是,不必因为几个传输 socket 需要走真实网络,就把整个进程的网络访问全部排除出去。
2.2 关键不只是调用 protect,还要等它完成
protect 是异步接口。如果 Rust 把 fd 通知给 ArkTS 后就立即开始连接,系统保护操作可能还没有完成。
所以这里需要一条有结果返回的交接链路:

图 1:Socket 保护的异步确认流程。
当前开发代码通过 nextSocketProtectionRequest 获取请求,通过 completeSocketProtection 回传结果。需要 TUN 的实例会在启动内核前准备保护服务;protect 与 网络 创建、销毁等操作也通过队列协调,避免相互交错。
失败路径同样重要。protect 失败不能只打一条日志,然后假装网络准备已经完成。对于仍在等待结果的请求,当前实现会把真实保护失败反馈给内核,触发实例停止和 TUN 清理,失败原因写入日志。
但 completeSocketProtection 返回 false 是另一回事:它表示这次确认没有被接收,可能是请求者已经取消了操作,不等于系统的 protect 调用失败。当前代码会继续保护其余 socket,不能因为一个已取消请求的回执就把其他实例一起停掉。
对应的 Rust 保护实现还保留了一份 socket fd 副本,直到请求完成。
原因是保护操作需要跨过 ArkTS 的异步等待:如果只记一个整数,原 socket 提前关闭后,这个 fd 数值有可能被别的资源复用。保留副本,是为了避免后续保护操作指向错误的 socket。
这部分改动在界面上几乎看不出来,但排查连接问题时很有用。至少能分清楚:是节点本身连不上,还是连接之前的系统网络准备就没有完成。
2.3 一个被取消的请求,为什么会让保护服务退出
这里有一处很具体的 bug 修复,可以说明定位时为什么不能只看最后的“连接失败”。仓库的 50c853e 修复了保护请求被取消后,保护服务仍被当作故障退出的问题。
先从调用链缩小范围。 旧实现里,completeSocketProtection 返回 false,且服务仍处于当前活动代次时,会记录 socket protection acknowledgement rejected 并抛出异常。异常再进入保护循环的失败处理,最终触发 Entry 侧停止实例和销毁 网络 连接。这样,一个已经不再等待结果的请求,就会影响仍然需要保护服务的网络操作。
再区分两个结果。 VpnConnection.protect() 有没有成功,是系统网络操作的结果;completeSocketProtection() 有没有接收回执,是请求交接的结果。请求者取消后,后者可能返回 false,不能反推前者也失败。排查这类问题时,需要把日志中的 requestId、purpose 与当时的停止、切网等操作放到同一条时间线上,而不是看见一个 false 就归类为 网络故障。
修复的是判断,不是把错误藏起来。 这处提交把未被接收的回执作为请求者已释放的情况记录,并继续处理剩余 socket;仍有请求者等待时,真实的 protect 失败依然进入停机与资源清理路径。修复前后的差异见 50c853e,当前实现见 NativeSocketProtectionService。
这类修复的回归不能只检查“不会报错”:应同时覆盖“取消请求后仍能处理后续请求”和“真正保护失败仍会停止并清理”,再检查连续启停时旧请求有没有影响新的运行代次。前者避免误停,后者避免把本来需要处理的网络故障也放过去。
用户侧提供材料也可以更具体一些:在“设置 → 关于与帮助”记录 APP 版本、内核版本和编译时间;在“设置 → 日志与调试”启用所需日志,“记录内核日志”提示重启后生效时先重启,再复现最短操作序列。随后通过“分享日志”选择对应启动的日志,或使用“分享运行诊断”。附上发生时间、设备和系统版本、运行模式、当时的网络以及预期与实际结果,通常比一张“连接不上”的截图更容易定位。公开提交前要先检查材料里的网络密钥、配置链接等敏感内容。
三、把“命令发出去了”和“已经运行了”分开
一次启动操作会经过页面、系统网络准备和 Rust 内核。入口返回“接受了启动请求”,并不代表后面的步骤已经完成。
应用当前区分乐观运行态和已确认运行态。
启动返回后,如果内核快照还没到,界面可以先保留一个过渡状态,但不会凭空填出已连接的 Peer 或已经挂载的 TUN。这个乐观状态有十秒宽限。后续快照到来时,确认运行的实例会替换为真实状态,仍未确认且已过宽限期的状态才会被清理。
运行健康检查还会看采样时间和内核响应。信息长时间没有更新时,显示的是“状态不可确认”,而不是一直沿用上一次的正常结果。网络工具里,一个不确定但诚实的提示,往往比始终显示绿色更有用。
不过,即使实例已被确认运行,也不能直接解释成“目标设备已经可达”。
实例启动、TUN 就绪、Peer 建连和业务可访问,仍然是不同层次的状态。连接有没有真正达到用户预期,还要结合节点和隧道信息判断。
这也是后续状态展示需要继续做细的地方。错误提示如果只剩一句“连接失败”,用户很难知道该检查配置、网络,还是系统授权。
四、配置页的卡顿,不能都算到 Rust 头上
相比网络链路,配置页性能是另一类问题。
EasyTier 的配置项不少。随着字段、分组和高级选项逐渐补齐,尤其在大屏多栏布局下,进入编辑页时需要创建和测量的 UI 节点也越来越多。即使配置数据已经准备好,把整页组件集中放在短时间内构建,仍然会卡。
因此这一轮把两个问题分开处理:页面一次放进来多少内容,以及单个高频组件的构建成本。
4.1 分批加载,要控制整页的量
最直接的办法是渐进加载:先显示当前可见分区,剩余内容逐步补上。
但如果每个组件都各自决定“我每帧只加载一点”,几个分区、列表和递归分组叠加起来,整页仍然可能在同一帧做太多工作。
现在这些加载任务共用一个页面级 FIFO 队列。分区挂载、列表行和递归 group 都进入同一个调度器,每个 VSync 最多放行一个工作项。这里的工作项是有界的一批内容,不是任意大小的整棵组件树。
同时,列表使用 LazyForEach,离屏内容不急着入队,能确定高度的行尽量减少反复测量。先限制同一帧新增的内容,再单独看 BoolField 的构建成本。

图 2:全页共享加载预算。示意图不代表实测帧耗时,性能数据见下文对照表。
4.2 单帧变短了,也可能只是把等待拉长了
这次优化里有一个值得单独记录的弯路。
早期用 postFrameCallback 驱动渐进队列。根据当时的真机 trace,在没有新的 dirty app frame 时,连续批次之间出现过 200~900 ms 的空洞。UI 线程并没有一直忙着计算,但后面的内容就是迟迟没有补出来。
这意味着,只看“最长一帧缩短了”,可能会误判优化已经完成。工作是拆开了,用户等整页内容出现的时间却未必更短。
后续改用 UIContext.createAnimator() 的 onFrame 作为短生命周期的 DisplaySync 驱动。这里借的是逐帧回调,不是给页面额外加动画。
队列连续空闲六帧后释放驱动,避免常驻;短暂保留空帧,是为了跨过父组件更新到子组件继续入队的间隙。
因此验收配置页时,除了首个可见内容、页面切换耗时和 UI 线程长帧,还要看最后一批可见内容是什么时候出现的。不能为了让一个指标好看,把等待悄悄挪到后面。
4.3 C++ 只接管一小块确实有成本的内容
另一个优化点是 BoolField。这个组件数量多,每张卡片里都有标题和说明文字。
这次没有把整个配置页重写成 C++,只是用 ArkUI Native Node 创建 Column + Text + Text 子树,替换卡片里的两段 ArkTS 文本,再通过 NodeContent 挂回原来的组件。
卡片容器、图标、点击动画、配置读写和选中状态仍留在 ArkTS。Native 节点创建失败时,回退到原来的 ArkTS 文本;宽度、主题变化以及节点销毁也需要同步处理。
这仍然是 ArkUI 的渲染体系,不是另写了一套渲染器,也没有把 Layout 搬到后台线程。
应用仓库的 docs/CONFIG_PAGE_PERFORMANCE.md 记录了一组 2800×1840 设备上的签名 Debug HAP 对照,两边均构建 20 个 BoolField,结果如下:
| 指标 | 原 ArkTS 文本 | C++ 文本子树 |
|---|---|---|
| BoolField BuildItem 总时间 | 44.427 ms | 21.798 ms |
| UI frame p95 | 26.553 ms | 17.678 ms |
| UI frame 最大执行时间 | 33.686 ms | 21.664 ms |
第一项下降约 50.9%,但它表示的是这组 BoolField 的构建总时间,不是整个应用“快了一倍”。外层 Swiper、List 和分区布局仍然有自己的成本。
这组对照也不与后续换设备、换字段数量的 DisplaySync 验证混算。保留这些条件,数据才有参考价值。
4.4 卡顿问题具体是怎么查的
网络问题主要靠运行链路和日志,页面卡顿则需要看时间轴。这次性能记录的定位过程也可以复用:
- 先固定对照条件。 记录 HAP 构建模式、设备分辨率、窗口宽度和可见字段数量,采集进入同一配置页的
hitrace,不要把不同设备或不同字段数混成一组 A/B。 - 沿同一次页面切换看几个区间。 将页面导航时刻、
CustomNode:BuildItem [BoolField]、UI 线程的ReceiveVsync区间对齐;Native 路径还可看ET_BOOL_NATIVE_CREATE。这样才知道时间花在建树、布局,还是等下一批内容被放行。 - 区分线程真的忙,与内容迟迟没出现。 前面的长空洞就是后一种情况:UI 线程没有持续执行一个数百毫秒的任务,但队列缺少新的帧回调,尾部内容仍然来得晚。定位到这里,修复方向才从继续压缩组件成本,转向更换 FIFO 的驱动方式,而不是继续把卡顿归到 Rust 计算上。
- 用相同口径检查修复结果。 除了首个可见内容、页面切换 e2e 和最长 UI 执行帧,还要记录最后一个可见内容节点完成的时间。单帧变短、整页却更晚出现,不算把问题修好了。
这些是项目性能记录已有的测量与修正过程,不是本文重新采集的真机结果。它也提醒我,遇到 bug 时最好先保留能解释现象的证据,再选修复手段;换语言、加延时或者多重试几次,都不应该成为默认答案。
五、上架和更新,容易踩哪些坑
生成 package.har 只是内核交付的一步。要让用户拿到可安装的应用,后面还有安装依赖、构建签名包、上传和分发等步骤。
Rust 内核和 ArkTS 应用分别迭代以后,手工更新很容易弄混:应用代码是新的,装进去的 HAR 却还是旧的;或者测试者反馈问题时,双方说的根本不是同一份构建。
目前已经把 Core 更新和应用构建串进 CI。内核更新可以触发应用侧同步 HAR、构建签名 App,再通过 AppGallery Connect 接口处理测试分发。
这里明确分了两条渠道:
- 开发版用于持续验证,由主线更新、Core 同步或手动任务触发。
- 正式发布路径上传并登记应用包,等待 AGC 包处理完成;正式商店版本仍由发布者在 AGC 人工创建和提交。
上传成功、测试版本可用、商店正式发布,是三个不同的结果,不能拿前一个代替后一个。
构建产物也区分 development 和 stable,版本生成会关联所使用的 Core HAR。这样出现问题时,至少能先对齐应用和内核的版本,再继续分析。
更新记录同样区分正式版与开发版。主线里已经实现的功能,可以写进开发版记录,但不能提前放进正式版更新说明。这一点对持续跟随上游的项目尤其重要。
5.1 buildMode 不决定签名,签名也不决定渠道
default / publish 是工程产品,debug / release 是构建模式,development / stable 又是应用分发渠道。这几个名字很像,却不是同一个开关。
当前工程按产品名注入外部签名:default 使用本地调试配置,publish 使用发布配置。不能只加一个 buildMode=debug,就把 product=publish 的包当作调试签名包。本地调试与发布构建分别使用:
# 本地 Debug;需准备自己的外部调试签名
hvigorw assembleApp --mode project -p product=default -p buildMode=debug --no-daemon
# 发布构建;使用自己的发布签名
hvigorw assembleApp --mode project -p product=publish -p buildMode=release --no-daemon
开发测试版和正式版构建都可以使用 publish/release,因此也不能从签名判断渠道。渠道还要看 CI 事件和构建时写入的标记。
设备上已有商店或测试包时,另一个证书签出来的本地 Debug 包不能直接跨证书覆盖。应先记录旧包的版本与签名信息;要保留数据就处理同签名更新或数据迁移,接受清空数据时才卸载再装。卸载会清除本地配置,不能把它变成每次更新的默认步骤。安装后还要核对 HDC 的字面结果、包信息和进程,不能只看命令退出码。具体签名与安装步骤见前面的中文编译说明。
5.2 更新了应用,不代表装进去的内核也更新了
本地依赖引用的是 easytier-ohrs-0.0.1.har。这个固定文件名并不能说明里面是哪一次 Core 构建;替换了源码却没有更新 HAR,得到的仍可能是旧内核。
现有 CI 会读取实际安装的 HAR 版本来生成 App 版本,并校验正式 Release tag 是否与计算出的 App 版本一致。不匹配就停止构建,而不是继续产出一份名字看起来正确的包。排查更新后的问题时,也要同时记录 App 版本、Core 版本和构建时间,需要追溯时再保留 Core 修订与 HAR 校验值;前三项可以直接在“设置 → 关于与帮助”读到(见截图 4)。
另一个容易混淆的地方是本地 Debug 固定保留 0.0.1 / 99999999,CI 发布构建才改写版本。不能把这个调试用版本当作商店升级规则,也不能只根据本地 versionName 判断代码和内核有没有变化。
5.3 上传成功以后,还有包处理、测试分发和正式发布
AGC 的上传结果只说明文件已交到存储侧。脚本还要登记应用包、取得 package ID,并轮询包处理状态;开发渠道随后配置并提交邀请测试,正式构建路径则不创建测试版本,留给发布者在 AGC 中人工创建和提交商店版本。
这里既要检查 HTTP 请求是否成功,也要检查接口的业务返回码。拿到了上传结果,却没等包处理完成,或者拿到了测试 version ID 却没完成配置和提交,都不能算用户已经拿到更新。
当前工作流对这件事也有区分:普通开发构建缺少 AGC 凭据时会跳过上传,仍保留签名构建产物;stable 路径则在上传前检查所需凭据。构建产物可下载、邀请测试可安装、商店版本已发布,验收时要分别确认,不能用一个绿色的构建结果替代全部环节。
5.4 测试分发里,字段长度和时间窗口也会绊人
自动化还得处理一些不起眼的约束。当前发布脚本对上传文件名按 64 字节做收缩;本地构建产物可以保留较完整的来源信息,上传时换成更短的名字,内容仍是同一份签名 App。测试描述则主动截取前 30 个字符,这是脚本选择的保守长度,不是把 30 当作平台最大值。
测试组列表也不是读第一页就够了。脚本逐页获取测试组,检查业务结果和分页信息,没有可用组就拒绝提交,避免把不完整的查询结果当作合法配置。
测试版本的时间窗口同样要看:当前脚本将开始时间设在当前时间后一小时,默认有效期为 14 天。如果提交后立刻发现测试入口还不可用,应先检查时间窗口和组配置,不要直接认定应用包坏了。
频繁同步 Core 时,测试通知也要显式控制。现有工作流对 Core 同步默认传 needNotify=0,手动触发可以单独选择是否请求通知,但这只是请求参数,不是“不打扰测试者”的保证。AGC API CI 文档记载过 AGC 接受 needNotify=0 后仍发送通知的情况;当前发布脚本直接绑定实际测试组并提交,没有实现该文档所述的临时空组提交、随后再绑定真实组的流程。
因此,实际通知结果仍需在测试分发后验证,不能仅凭参数为零或接口返回成功就宣布静默发布已生效。自动更新不只要检查包有没有分发出去,也要检查测试者是否被意外通知;请求值与平台最终产生的副作用,必须分开确认。
5.5 更新说明要跟交付包走,不能跟着主线进度抢跑
主线合入了修复,最多先说明开发构建具备了它。正式发布前还要核对本次包、版本、渠道和更新内容,不能先告诉商店用户“这个问题已经修好了”,却让他们拿到仍不含修复的旧版本。
本地 Debug 固定版本号,还带来一个小问题:只按版本号判断是否展示更新记录,会漏掉同版本的新内容。当前展示标记同时包含版本号、渠道和 CHANGELOG_REVISION,更新记录内容时要递增修订号;这样新记录能显示一次,也不必每次启动都弹一遍。
这些处理可以对应到 ohos 工作流、AGC 发布脚本 和 更新记录维护方案。上架以后,维护的不只是代码本身,也包括用户最终装到了哪一份代码。
六、接下来还准备往哪里做
后续我更想沿着现有问题继续往下做,而不是先把功能列表拉长。
首先是把网络场景的回归验证做得更系统。首次连接只是起点,切换网络、休眠唤醒、连续启停、路由更新和异常退出,都需要分别观察。除了能不能恢复,还要检查有没有遗留实例、旧请求或未释放的资源。
其次是继续改善诊断信息。运行侧已经能返回状态和错误,下一步要让这些信息更容易被用户理解。实例没启动、TUN 没准备好、Peer 没连接和远端服务没响应,不应该最后都变成同一句提示。
再往后是控制配置编辑器跟随上游的维护成本。配置模型和校验继续以 Core 为准,界面负责把字段组织成可操作的入口。高级选项会继续增加,但不能每增加一批字段,就让整个页面重新变重。
七、复盘
回头看,把 Rust 内核封装成 HAR,解决了鸿蒙工程如何复用组网能力的问题。上架后的工作则更多落在那些很难用一张功能截图说明的地方:socket 什么时候可以开始通信,失败后该停止什么,页面每帧究竟放多少内容,以及测试者拿到的是哪一份包。
上架让这些问题变得更具体了。自己的设备上跑通一次,只需要证明方案可行;交给别人使用,还得让问题能够被发现、被解释,并在下一次更新里有依据地修好。
更多推荐


所有评论(0)