上个月接了个有点烫手的任务:把 Flutter 生态里的 emulators 三方库适配到鸿蒙生态,让团队自动化测试平台能像管理 iOS 模拟器和 Android 模拟器一样,顺手把鸿蒙模拟器也管起来。说实话,这个库我用了挺久,但它眼里的世界只有两颗星球——xcrun simctl 管着的 iOS 模拟器,以及 adb/emulator 管着的 Android 模拟器。要把第三颗星球塞进它的轨道,远比想象的麻烦。
这个需求不是凭空来的。公司跑了几万条用例的自动化测试平台,iOS 和 Android 的模拟器全靠 emulators 库管理,一直很稳。但鸿蒙测试设备一接入,库直接不认识了。与其另起炉灶写一套新的管理工具,不如把现有库的能力边界往鸿蒙方向推一把。于是就有了这篇鸿蒙化适配指南,核心目标就一个:让 iOS、Android、鸿蒙三类模拟器在同一套自动化测试体系里,用同一套 API 管起来,同时把生命周期管理和截屏这两块做扎实。
文章适合谁看?如果你在做跨端自动化测试,或者准备把 Flutter 测试基建迁到鸿蒙生态,再或者只是好奇一个三方库是怎么一步步被改造到陌生平台上的,这篇文章都能给你一些能直接落地的思路。
1. 先搞清楚 emulators 库的工作原理
1.1 原库的三层架构
动手改造前,我花了两天时间把 emulators 库的源码从头到尾翻了一遍。这个库的设计其实很简单,主要分三层。
最上层是给 Flutter 业务调用的统一 API,类似listAvailable()、launch(deviceId)、shutdown(deviceId)、takeScreenshot(deviceId)。中间层是设备管理逻辑,负责维护设备列表、状态查询、参数校验这些通用能力。最底层是关键,它抽象了一个命令执行器,把对操作系统的调用封装成run(deviceType, args)这样的统一入口。
具体到不同平台,底层执行器做的事情也不一样。管理 iOS 模拟器时,它实际是在调xcrun simctl list、xcrun simctl boot <udid>、xcrun simctl io <udid> screenshot这套命令。管理 Android 模拟器时,调的是emulator -list-avds、emulator -avd <name>、adb shell screencap这套。也就是说,这个库本质上是一个套着 Flutter 壳的命令行包装器,所有平台差异都被隔离在底层的命令执行器里。
这一点非常重要。因为这意味着鸿蒙化改造的切入点很清晰:只要在底层新增一个针对鸿蒙 hdc 命令的执行器,上层的设备管理逻辑就能最大程度复用。如果原库把命令调用写死在各个业务方法里,没有做这层抽象,改造的工程量会翻好几倍。
1.2 鸿蒙环境带来的三个硬伤
搞清楚架构之后,我列了一个适配前的差异分析,发现鸿蒙环境会给这个库带来三个绕不开的问题。
第一个是命令体系完全不同。Android 用 adb,iOS 用 simctl,鸿蒙用的是 hdc(HarmonyOS Device Connector)。hdc 的操作风格虽然和 adb 有相似之处,但细节差异很大。比如列出设备,adb 是adb devices,hdc 是hdc list targets。截屏,adb 用exec-out screencap -p,hdc 要用两步走,先hdc shell snapshot_display -f <设备端路径>再hdc file recv拉回本地。这些差异意味着不能简单做命令替换,要把整条调用链重新写一遍。
第二个是设备标识体系不同。iOS 模拟器用 UDID,Android 模拟器用 serial 编号,而鸿蒙设备用的是 Device ID,格式和获取方式都不一样。原库内部很多逻辑(比如缓存、状态映射、结果索引)都是基于 UDID 或 serial 构建的,鸿蒙接入后,这些以字符串为主键的地方都要重新梳理。
第三个是模拟器启动的生命周期事件不可见。iOS 和 Android 模拟器在启动后,系统会广播 boot 完成事件,测试框架拿到这个事件就知道设备真正可用了。鸿蒙模拟器在这方面的机制不透明,轮询hdc shell param get sys.boot.completed这类命令成了唯一可靠的手段。
这三个硬伤决定了:鸿蒙化适配不是加一个平台分支那么简单,而是要对原库的设备抽象层做一次结构性扩展。
2. 鸿蒙化适配的路线选择与架构设计
2.1 三条候选路线的深度对比
面对"让 emulators 支持鸿蒙"这个目标,团队内部讨论后筛选出了三条候选路线。
路线 A 是在 Dart 层直接重写一个鸿蒙设备管理器,不依赖原库的任何代码。好处是彻底可控,坏处是要把设备发现、状态机、并发控制这些已有能力全部重造一遍,工作量最少也要四周,还不算踩坑的时间。
路线 B 是深度改造原库,在底层命令执行器层面做抽象扩展,保持上层 API 和业务逻辑不变。好处是复用度极高,团队成员原有的调用习惯完全不用改,坏处是必须对原库内部结构做较大幅度重构,有一定回归风险。
路线 C 是走 Flutter 方法通道,把鸿蒙模拟器管理下沉到鸿蒙原生侧,用 ArkTS 实现后再桥接回来。好处是性能上限更高,但坏处是架构复杂度激增,而且鸿蒙原生侧目前缺乏成熟的模拟器管理 API,很多能力还是得靠执行 hdc 命令实现,桥接的意义不大。
最终选了路线 B。核心考量很简单:emulators 库本身已经具备良好的分层抽象,我只需要在原来的命令执行器层下面增加一个鸿蒙实现,然后把设备发现和状态管理两个模块做小范围调整即可。原库的业务 API 完全不动,自动化测试平台的存量代码一行不用改。
2.2 最终方案的架构分层
适配后的整体架构分五层,从上到下依次是:
- 业务调用层:给测试脚本用的 API,
launch、shutdown、screenshot等,接口签名保持兼容。 - 设备管理层:负责设备索引、状态机维护、操作队列控制,是原库核心逻辑所在。
- 命令抽象层:新增统一的
ICommandExecutor接口,定义run(args)和执行结果对象。 - 平台执行层:
SimctlExecutor、AdbExecutor、新增的HdcExecutor,各自封装平台命令细节。 - 系统交互层:直接调用 xcrun、emulator/adb、hdc 这些命令行工具。
其中命令抽象层是整个改造的关键。原库原本是switch(deviceType)硬路由,我把它改成了执行器注册表模式——每个平台向注册表登记自己的执行器实例,设备管理逻辑不再关心命令差异。举个简单的例子,截屏操作在上层只是一句executor.captureScreenshot(deviceId),内部却会根据平台执行完全不同的命令链。
2.3 抽象接口与具体实现
命令抽象层我设计了四个核心接口方法,基本覆盖了模拟器管理的全部需求:
abstract class ICommandExecutor { Future<CommandResult> listAvailable(); Future<CommandResult> launch(String deviceId, Map<String, String> options); Future<CommandResult> shutdown(String deviceId); Future<CommandResult> captureScreenshot(String deviceId, String savePath); } class CommandResult { final int exitCode; final String stdout; final String stderr; CommandResult(this.exitCode, this.stdout, this.stderr); bool get isSuccess => exitCode == 0; }鸿蒙侧的HdcExecutor是这个接口最典型的实现。比如截屏,需要分两次命令执行,第一步把截图存到手机本地,第二步用 file recv 拉回来,期间还要处理路径里可能出现的空格和中文问题,这在 Windows 开发机上尤其典型。再比如设备上线判断,Android 可以靠adb wait-for-device,hdc 没有完全等价的命令,只能在循环里轮询hdc list targets,直到设备状态变成[Boot]或者[Ready]。
后来我把这些实现细节整理成了一份 HdcExecutor 的开发纪要,回头想,单是命令差异这一个点就值得单独写一篇。鸿蒙的 hdc 命令更新速度不慢,不同 DevEco Studio 版本带的 hdc 参数格式还有细微出入,执行器内部最好做一层容错,而不是假设命令输出格式永远不变。
3. 模拟器生命周期管理:状态机与调度设计
3.1 六态状态机的设计逻辑
模拟器管理最核心的部分是生命周期状态机。原库只区分 running 和 stopped 两种状态,这在 iOS 和 Android 上勉强够用,但鸿蒙模拟器的启动流程明显更重,状态转换的时间窗口更长,两态模型很容易把"启动中"误判为"启动失败",导致测试用例频繁超时。
改造后的状态机采用六态模型:
- unknown:设备未被识别,通常是刚调用
listAvailable之后的初始状态。 - starting:已经下发启动命令,正在等待系统 boot 完成。
- running:设备可正常执行测试操作,是唯一能接受截屏和 shell 命令的状态。
- pausing:收到暂停命令,等待状态确认。
- paused:设备已暂停,模拟器进程仍在,但业务不可用。
- stopped:设备已完全退出。
- error:启动或运行过程中出现不可恢复的异常。
状态机内部维护一张转换表,非法跳转会直接抛异常。比如从 running 直接跳回 starting,这在任何平台上都是不合理的,如果状态机允许这种跳转,一定是上层逻辑出了 bug,不如尽早暴露。
之所以要加 pausing 和 paused 两个状态,是因为自动化测试里经常要模拟应用切后台、系统休眠这类场景,如果连暂停这种操作都不区分,后续的恢复测试就无从谈起。
3.2 冷启动流程与超时控制
生命周期管理里,冷启动是最容易出问题的环节。鸿蒙模拟器冷启动耗时明显高于 Android 模拟器,我们实测的平均冷启动时间在 50 到 90 秒之间,和具体镜像版本关系很大。如果沿用 Android 场景下 30 秒超时的设置,大量用例会直接超时失败。
改造后的启动流程分四步,每步都有独立的超时控制:
- 下发启动命令,等待模拟器进程创建成功(超时 15 秒)。
- 轮询
hdc list targets,确认设备出现在设备列表中(超时 30 秒)。 - 持续检查系统 boot 状态,通过
hdc shell param get sys.boot.completed判断(超时 60 秒)。 - 执行一次最小化 shell 命令做探活,确认设备真正可交互(超时 10 秒)。
四步加起来理论上限在 115 秒,但从实际运行情况看,绝大多数设备能在 80 秒内完成整个启动流程。超时参数我全部收敛到一个配置类里,方便后续针对不同的鸿蒙镜像版本做单独调整。
特别注意:第 2 步和第 3 步不能合并。设备出现在 hdc 列表里不等于系统 boot 完成,这是鸿蒙模拟器和 Android 模拟器一个很明显的差异,如果只做设备列表检查就开始跑测试,大概率会遇到 shell 命令超时。
3.3 多设备并发调度策略
自动化测试平台动辄要同时跑七八台模拟器,原来的处理方式是给每个设备单独开一个控制线程,互不干涉。鸿蒙模拟器接入后,我发现它的 CPU 和内存占用比 Android 模拟器更激进,如果并发数不加控制,宿主开发机的负载会迅速冲高,所有模拟器启动速度一起劣化。
解决方案是在设备管理层引入一个基于信号量的并发闸门。所有启动和截屏操作都走同一个Scheduler,它内部维护maxConcurrent配置,默认设 4。设备请求到来时先申请令牌,拿不到就排队等待;操作完成或超时后释放令牌。同时,每个设备有一个独立的操作队列,保证同一设备的操作严格串行,避免出现"启动还没完成就开始截屏"这种竞态。
这套调度策略上线后,最直观的变化是 8 台模拟器并发冷启动的总耗时,从原来的一窝蜂挤到近 5 分钟,降到稳定的 3 分钟出头。宿主机负载也平稳了不少,不再因为瞬时 CPU 飙升导致其他服务受影响。
4. 截屏策略:从"跑得通"到"跑得快"
4.1 截屏链路拆解与耗时分析
自动化测试离不开截屏。用例断言失败要截图,操作步骤要留痕,崩溃现场要取证。emulators 库原本的截屏逻辑是同步执行:调命令、等返回、拿字节流、直接存文件。这套逻辑在 iOS 和 Android 上跑得还算体面,但在鸿蒙上出了大问题。
前面提到过,鸿蒙截屏必须分两步走,先执行hdc shell snapshot_display -f <远端路径>,再执行hdc file recv把文件拉到本地。两步加一起,单次截屏的端到端耗时平均在 650 毫秒左右,而 Android 的adb exec-out screencap只需要 250 毫秒。如果一条测试用例里有十几次截屏,光是截屏就多出 4 秒多,几千条用例跑下来,累积的时间成本非常可观。
我当时先做了一次详细的耗时拆解,发现 650 毫秒里,snapshot_display本身占 300 毫秒,file recv占 250 毫秒,剩下 100 毫秒是 Flutter 侧命令调度的固定开销。优化只能从后两者入手。
4.2 三个层面的性能优化
第一层优化是减少命令往返。原库每次截屏都会先拼一个临时文件名,截完立刻 recv,然后把远端文件删掉。三句话三个命令,每个命令都要走一遍 hdc 的握手协议。我把"删除远端临时文件"这个操作优化成延迟清理,在同一台设备上的多次截屏共用一个临时文件,截完直接覆盖写,只有最后统一清理一次,省掉的都是纯网络开销。
第二层优化是并行化。平台接入后,多台设备同时跑用例的场景非常常见。原来的调度器对截屏操作按设备队列同步处理,一台设备截图时其他设备必须干等着。改造后,我把截屏从设备操作队列里摘出来,改成并发执行,只受全局信号量控制。实测四台设备同时截屏,总耗时有原来的 2.6 秒压到 1.1 秒左右。
第三层优化是压缩。截图原始 PNG 体积动辄 1.5MB,file recv传输大文件的时间占比很高。我在 Dart 侧对截图数据做了一次 JPEG 压缩再落盘,质量设为 80。这一步把单张截图的平均存储体积降到 180KB 左右,传输耗时直接砍掉七成多。要保留无损细节的场景可以关闭压缩,配置文件里一个开关的事。
4.3 实测数据对比
适配完成之后,我在同一台开发机上跑了一个小规模的基准测试,三种模拟器各截 50 次屏,取 P50 和 P95 耗时:
| 设备平台 | 平均耗时(P50) | 最差耗时(P95) | 说明 |
|---|---|---|---|
| iOS 模拟器 | 220 ms | 380 ms | simctl 截图走的是宿主机文件系统,不走网络 |
| Android 模拟器 | 250 ms | 420 ms | adb exec-out 单条命令完成 |
| 鸿蒙模拟器(优化前) | 650 ms | 950 ms | snapshot_display + file recv 两步链路 |
| 鸿蒙模拟器(优化后) | 430 ms | 580 ms | 并行 + 压缩 + 延迟清理生效 |
鸿蒙侧优化后的 P95 是 580 毫秒,虽然绝对数值仍然高于 iOS 和 Android,但在整套测试平台里已经可以接受。毕竟测试流程中截屏通常和 UI 操作交替发生,只要截屏耗时不超过操作间隔,就不会成为瓶颈。
心得:性能优化一定要先测量再动手。我当时一度怀疑是
file recv传输慢,想改用 hdc 内置的转发通道做流式传输,后来一测才发现真正的耗时大头是命令握手的固定开销,果断把优化重心转到了减少命令往返上。方向错了,工具再好也白搭。
5. 实操记录:一次完整的鸿蒙化改造
5.1 环境准备与依赖调查
动手前我把环境捋了一遍。Flutter 用的是 3.22 分支对应的鸿蒙适配版本,这个版本支持标准 PlatformChannel,但和社区版 Flutter 有一个明显区别:鸿蒙分支的dart:io里Process.run的行为在 Windows 上略有差异,调用外部命令时对环境变量继承的规则更严格。开发机上装的是 DevEco Studio 5.0.3,自带 hdc 工具,版本是 1.2.0。
如果你的环境和我不同,第一件事一定是在终端里跑一遍hdc list targets,确认 hdc 本身能正常发现设备。这一步没通过,后面所有工作都无从谈起。
原库的依赖也要提前排查。emulators 库虽然依赖不多,但有一个间接依赖用了package:process这个包来做进程管理,而package:process在鸿蒙 Flutter SDK 上的兼容性不太好,运行时偶尔会抛ProcessException。我的做法是把进程调用改回标准的dart:io实现,绕开这个兼容性黑洞。
5.2 核心代码改造实录
改造工作主要集中在三个文件。
第一个是新增的hdc_executor.dart,实现ICommandExecutor接口。启动设备的代码是这样的:
class HdcExecutor implements ICommandExecutor { final String hdcPath; HdcExecutor({this.hdcPath = 'hdc'}); @override Future<CommandResult> launch(String deviceId, Map<String, String> options) async { final process = await Process.start(hdcPath, [ 'start', deviceId, '--background', ]); // 等待进程启动成功,不阻塞等待模拟器完整 boot await process.exitCode.timeout(const Duration(seconds: 15)); return CommandResult(0, 'launch ok', ''); } }第二个是device_manager.dart,把原来硬编码的平台类型映射改成注册表模式,新增鸿蒙平台的注册入口:
class DeviceManager { final Map<String, ICommandExecutor> _executors = {}; void registerExecutor(String platform, ICommandExecutor executor) { _executors[platform] = executor; } Future<void> launch(String platform, String deviceId) { final executor = _executors[platform]; if (executor == null) { throw UnsupportedError('Unsupported platform: $platform'); } return executor.launch(deviceId, const {}); } }第三个是emulator_state_machine.dart,引入六态状态机,并把设备状态查询从"一次命令一次结果"改成"带超时的轮询回调",这是鸿蒙设备启动慢的必然要求。
5.3 验证流程与回归测试
代码改造完成只是第一步,验证环节更费精力。我先把 iOS 和 Android 的存量用例完整跑了一遍,确认重构没有引入回归问题。这一步花了三个小时,跑出来的结果和改造前基本一致,心里才稍微踏实一点。
接着做鸿蒙专项验证。我列了一个冒烟用例清单,覆盖设备发现、冷启动、热启动、截屏、暂停、恢复、关闭、异常断开八类场景。真正调试的时候,遇到的问题一个接一个:
- 第一次跑
launch,模拟器在 hdc 里出现了,但状态一直是[N/A],后来发现是 DevEco Studio 的模拟器进程还在初始化,需要等 boot 动画结束才能变成[Ready]。 - 第一次同步截屏,40 台设备同时请求,直接导致 hdc 服务端连接数打满,大批截图失败。后来临时把并发闸门调到 2 才稳住局面,这个问题也直接催生了第 4 章里的并行化改造。
- 第一次做停止操作,发现
hdc shell kill只能关掉应用进程,模拟器本身还在跑。查了文档才知道要用hdc stop配合进程管理才干净。
这些问题的共性是:鸿蒙的命令体系远比想象中复杂,很多能力不在表面文档里,得靠实测才能摸清行为边界。
6. 常见问题排查速查表
6.1 命令与连接类
| 现象 | 可能原因 | 排查与处理 |
|---|---|---|
hdc list targets始终为空 | hdc 服务未启动,或 USB 连接异常 | 先执行hdc kill再hdc start,重新插拔设备 |
设备状态停留在[N/A] | 模拟器进程未完成 boot | 等待 30 秒后再查询,不要频繁轮询 |
定时出现hdc server timeout | 并发连接数过高,超过 hdc 默认上限 | 降低并发截屏数,或在执行器里加入连接复用池 |
| Windows 上执行 hdc 命令报路径错误 | hdc 路径包含空格或中文 | 用短路径别名,或在启动时显式设置 PATH |
提醒:hdc 在 Windows 环境下对路径空格的处理非常敏感,如果 DevEco Studio 装在带空格的目录里,强烈建议把 hdc 工具的路径单独配置,否则排查起来非常痛苦。
6.2 状态机与并发类
| 现象 | 可能原因 | 排查与处理 |
|---|---|---|
| 设备启动后很快回到 stopped | 系统 boot 超时,被状态机判死 | 检查系统镜像负载,延长第 3 步 boot 轮询超时 |
| 同一设备的操作乱序执行 | 设备操作队列未加锁 | 每个设备单独对应一个队列,确保串行 |
| 并发启动 8 台设备导致宿主机卡死 | 未做并发闸门控制 | 全局信号量默认 4,按宿主机器配置调整 |
| 状态从 starting 直接跳到 error | 启动命令退出码非 0 | 查看 stderr 输出,重点确认模拟器镜像是否存在 |
6.3 截屏与资源类
| 现象 | 可能原因 | 排查与处理 |
|---|---|---|
| 截屏返回空字节流 | snapshot_display 执行失败 | 先手动跑一遍命令,确认设备端剩余存储充足 |
| file recv 拉取文件超时 | 截图体积过大 | 开启 JPEG 压缩,或将截图临时目录改为内存盘 |
| 截屏内容一直是上一帧 | 设备处于 pausing 状态 | 截屏前检查状态机,paused/pausing 状态下不允许截屏 |
| 多设备并发截屏丢图 | 临时文件名冲突 | 临时文件名带上设备 ID 前缀,不要用纯时间戳 |
排查这些问题时有一个通用思路:所有执行器都加 verbose 日志开关,把每次执行的完整命令和标准输出记录下来。出问题的时候,先看命令本身对不对,再判断是执行环境的问题还是上层状态机误判。这套思路帮我省掉了大量重复试验的时间。
最后说几句实在话
鸿蒙化适配这件事,技术难点其实并不在某个具体 API 好不好用,而在于如何把一个平台特有的心智模型,无缝嵌入到一个原本为其他平台设计的抽象体系里。emulators 库的架构足够干净,所以改造能够集中在下层,但即便如此,我也在状态机和截屏优化上连续啃了四天。如果你准备在自己团队里做类似的事,我建议先花半天把原库的架构吃透,再动手改,仓促上马大概率会反复返工。
另外一个小技巧:改造期间把 hdc 命令的每一次实际输出都保留存档,会非常有价值。鸿蒙的命令输出格式不像 adb 那么老练,版本之间的差异也比较大,有历史输出做对照,排查诡异问题的时候能少走很多弯路。
这套改造方案后续还能继续扩展,比如把鸿蒙真机也纳入统一管理,或者在截屏链路里直接对接鸿蒙的图像识别能力。自动化测试的基建就是这样,每一个平台适配到位,整个体系的稳定性和效率才能更上一层。