在鸿蒙化的Flutter工程里最闹心的往往不是UI跑不起来而是自动化测试跑完拿不到一份像样的报告。前阵子帮一个跨端测试平台做底层对接流水线要在鸿蒙真机上跑完一批Flutter用例最后统一汇总成JUnit XML喂给Jenkins做质量趋势分析。一开始以为只是改改路径、换换命令的事结果从测试结果采集到报告落盘、再到CI解析每一个环节都藏着坑。折腾完这轮适配我决定把整套方案沉淀成一篇实战指南核心就是围绕junitreport_maintained这个库做鸿蒙化适配让它在端侧把测试结果直接输出成标准JUnit XML再通过脚本回收并接入Jenkins。这篇文章适合正在做鸿蒙Flutter自动化测试基建设计的同学也适合那些还没开始鸿蒙化、但想提前评估三方库迁移成本的测试开发工程师。1. 先看清问题为什么鸿蒙化之后测试报告会“断链”1.1 Flutter测试报告在传统Android上的链路在Android端Flutter自动化测试的报告链路其实是比较成熟的。用flutter test跑单元测试或者用flutter drive跑集成测试测试结果会先以JSON事件流的形式输出每个测试用例的启动、结束、结果、耗时都以结构化数据记录。然后用junitreport这类工具把JSON事件流转换成JUnit XML最后Jenkins用JUnit插件的junit步骤读取XML生成通过率、失败趋势、耗时波动这些图表。这条链路中junitreport或者说它的维护分支junitreport_maintained起的就是一个“转换器”的作用把Dart测试框架产生的原始事件翻译成Jenkins能理解的标准格式。这个转换不依赖Android原生API是一个纯Dart库理论上只要Dart运行时还在它就能工作。1.2 鸿蒙化之后链路哪些环节失效鸿蒙化之后失效的不是“转换器”而是“怎么把转换器跑起来”和“产物怎么收回来”这两件事。先看测试执行本身。在鸿蒙设备上跑Flutter测试目前主流的方式还是通过Flutter的鸿蒙适配版SDK在设备端启动应用并执行用例。但问题在于很多CI流水线还是沿用Android时代的习惯在宿主机跑flutter test指望它直接输出XML文件或者用adb把设备上的文件拉回来。鸿蒙生态里根本没有adb取而代之的是hdc设备文件系统的沙箱规则也和Android完全不同路径不对、权限不够报告就卡死在“生成了但拿不到”这一步。再看库本身的运行环境。junitreport_maintained虽然纯Dart但它涉及到dart:io的文件读写、路径判断还有一些对运行环境的隐式假设。鸿蒙的Flutter SDK在底层API实现上做了不少裁剪和替换直接拿Android的用法套上去会出现一些莫名其妙的运行时异常。1.3 junitreport_maintained 为什么值得作为适配基座这么多转换工具为什么我选junitreport_maintained第一个原因是它解决了原开源项目长期不维护的问题。老牌junitreport已经很久没有大版本更新对新的Dart SDK版本和测试事件格式兼容性越来越差而junitreport_maintained一直在跟进至少我实测下来它能把新版本flutter test产生的JSON事件正确解析。第二个原因是它同时提供了命令行和API两种使用方式命令行适合在宿主机快速转换API适合集成到端侧Runner里做定制化生成。这一点对鸿蒙化适配非常关键因为我们可能要在一个Dart isolate里接收测试事件、当场生成XML、直接写入沙箱目录。所以整体的适配思路就很清晰了保留这个库作为格式转换核心针对鸿蒙的运行环境和CI集成方式做一层封装而不是从零去写一套XML生成逻辑。2. 适配前准备环境与依赖改造2.1 鸿蒙Flutter开发环境怎么搭在开始代码改造前先把环境打通。这里有一个容易踩的误区很多人以为装了OpenHarmony SDK就能直接跑Flutter测试实际上还需要一个专门适配鸿蒙的Flutter SDK分支同时用DevEco Studio作为原生工程管理工具。我当时的做法是准备了两套环境宿主机上安装基础Flutter SDK用于代码编辑、静态分析和部分依赖拉取鸿蒙工程目录里使用适配分支的Flutter SDK用于真正编译和运行到鸿蒙设备DevEco Studio负责生成鸿蒙原生工程壳包括entry模块、权限声明和应用签名。这里最关键的是不要混用两套SDK的pub cache。鸿蒙适配分支可能对某些包的SDK约束有调整如果缓存混了会出现“本地编译能过一上鸿蒙设备就报找不到类”的奇怪问题。2.2 在三方依赖里把包引入工程依赖引入这块我推荐用path依赖而不是直接引用pub仓库的版本。原因很简单鸿蒙化适配过程中我们很可能要临时给库打补丁或者通过wrapper做平台隔离。用path依赖可以随时修改本地源码改完立即生效。在pubspec.yaml里这样配置dependencies: flutter: sdk: flutter junitreport_maintained: path: ./third_party/junitreport_maintained另外如果你的工程里有多个模块都要跑测试报告生成建议把junitreport_maintained单独抽成一个内部公共库用Workspace的方式统一管理避免每个模块各fork一份代码。后续鸿蒙适配版本升级、bug修复只要改一处其他模块跟着受益。引入之后第一步不是写代码而是跑一遍依赖分析flutter pub deps --stylecompact重点看junitreport_maintained的传递依赖里有没有明显依赖dart:io特殊API的包。如果某个依赖来自flutter_test内部框架那在鸿蒙的Flutter适配版里可能没被mock掉测试时会报错。2.3 条件编译与平台分支处理虽然junitreport_maintained是纯Dart库但它在代码里可能通过Platform.isAndroid或者Platform.operatingSystem来做一些路径判断。鸿蒙在Flutter适配版里通常不会把Platform.isAndroid返回true也不会新增一个专门的isHarmonyOS这就会导致库走错分支。我的做法是不直接改库源码而是在我们的逻辑里做一层隔离。把这层隔离封装成一个公共接口TestReportSink再针对不同平台写实现。端侧生成XML时所有涉及路径和文件操作的地方都通过这个封装接口走库本身只管把事件流转换成XML字符串。abstract class TestReportSink { String get baseOutputDir; Futurevoid writeReport(String xmlContent, String fileName); }这一步看着简单实际上省了后面很多麻烦。鸿蒙的沙箱路径和Android不一样如果不能通过一个接口统一管理到了CI环境调试时你会疯掉。3. 端侧JUnit XML生成实现从结果事件到落盘文件3.1 采集设备端测试执行事件这一步是整个“端侧生成”的关键。所谓端侧生成不是指在宿主机上用命令行转一下而是测试用例在鸿蒙设备上跑完后XML文件直接在设备内生成。这么做的好处是不依赖宿主机环境尤其是当宿主机可能是macOS可能没有对应的Dart运行时版本时端侧生成会让报告链路更稳定。具体实现思路是这样的在鸿蒙Flutter测试工程里注册一个测试事件接收器。当每个用例执行结束时我们把testStart、testDone、suiteStart这类事件统一收集到一个缓冲数组里然后在所有用例跑完后把整个数组转成符合dart test --reporter json格式的JSON字符串交给junitreport_maintained的API处理。采集代码大致长这样final events MapString, dynamic[]; void onTestCaseComplete(TestCompletionInfo info) { events.add({ type: testStart, test: { id: info.id, name: info.name, suiteID: info.suiteId, metadata: null, }, time: info.startTime, }); events.add({ type: testDone, testID: info.id, result: info.result, skipped: false, hidden: false, time: info.endTime, }); }注意这里的时间字段。dart test事件里用的是毫秒级时间戳而JUnit XML的标准字段time是用秒表示的这个转换逻辑junitreport_maintained会处理但你自己采集的时候千万不要提前除以1000否则库内部再除一次所有用例耗时都会变成很小的数字。3.2 调用库API完成XML序列化事件采集完成之后XML序列化只需要一次调用。junitreport_maintained对外暴露了一个简洁的入口函数传入JSON字符串返回JUnit XML字符串import package:junitreport_maintained/junitreport_maintained.dart as junit; final jsonString jsonEncode(events); final xmlString junit.junitReportFromJson(jsonString: jsonString);这里有个细节不同版本的库函数名可能略有差别。有的版本是junitReportFromJson有的版本是junitReportFromResults。如果你引入的版本里没找到对应函数点进源码看一眼公开的接口定义两分钟就搞定。我当时就是因为这个函数名绕了一下真的没必要硬记。生成的XML字符串是一个完整的testsuites根节点内部包含多个testsuite和testcase结构符合JUnit schema。要注意的是如果测试发生崩溃导致用例事件缺失库不会报错而是把没有testDone的用例归类为失败。这个行为对我们做异常场景分析其实是有帮助的。3.3 鸿蒙沙箱下的文件落盘策略XML字符串拿到之后要解决的就是写到哪儿。鸿蒙应用沙箱和Android不太一样你不能想当然地往什么/sdcard目录写。实测下来比较稳妥的是写到应用自己的沙箱目录下然后通过hdc工具拉取。在鸿蒙Flutter运行环境里dart:io的Directory可用但获取当前目录的语义和Android有差异。我倾向于在设备端用一个固定的相对路径挂到应用沙箱下比如这样final outputDir Directory(${Directory.systemTemp.path}/test-results); if (!outputDir.existsSync()) { outputDir.createSync(recursive: true); } final reportFile File(${outputDir.path}/junit.xml); reportFile.writeAsStringSync(xmlString);注意Directory.systemTemp在鸿蒙上对应的目录是什么取决于Flutter适配版SDK怎么实现建议你在测试代码里先打一句日志看一下真实路径。我在适配时发现直接在CI脚本里硬编码/data/storage/el2/base这种路径很危险因为不同的应用包名、不同的签名方式路径前缀都可能变。所以更稳妥的做法是在应用内打印出完整路径同时在CI脚本里用正则或通配符去匹配而不是写死某个绝对路径。3.4 手工校验XML结构的小工具端侧生成的XML不一定每次都对。我的习惯是生成后做一次快速校验尤其是看根节点属性里的tests、failures数量和UI上的用例总数是否一致。写一个简单的解析脚本成本很低但能帮你早发现问题。import dart:io; import package:xml/xml.dart; void main() { final doc XmlDocument.parse(File(junit.xml).readAsStringSync()); final root doc.rootElement; final tests root.getAttribute(tests); final failures root.getAttribute(failures); final errors root.getAttribute(errors); print(tests$tests failures$failures errors$errors); }这个脚本不是必须的但是在你刚开始集成、还没接Jenkins阶段手工看一眼能建立信心。一旦Jenkins开始抓数据了再有问题就是另一个维度的排查后面我们会单独讲。4. Jenkins/CI集成与自动化汇总4.1 hdc 文件回收与流水线脚本端侧XML落盘之后CI侧要做的第一件事就是把它从设备上拉回来。对应的命令是hdc file recv。我封装成了一个shell函数放在流水线里function pull_test_report() { local package_name$1 local pull_path$2 local remote_path remote_path$(hdc shell ls -d /data/storage/el2/base/*/cache/test-results 2/dev/null | head -n 1) if [ -n $remote_path ]; then mkdir -p $pull_path hdc file recv $remote_path/junit.xml $pull_path/junit.xml else echo test-results directory not found exit 1 fi }这里需要注意权限问题。hdc连接设备后默认可能有权限限制hdc shell里能查到路径不代表hdc file recv能拉。如果拉取时报Permission denied先执行hdc shell cat /data/storage/el2/base/*/cache/test-results/junit.xml看能不能读出来。如果读不出来多半是应用沙箱权限没开要去DevEco Studio的应用配置里加存储权限或者把报告写到shell可读的公共区。我建议在应用里把报告文件权限改成644确保至少当前用户可读Process.run(chmod, [644, reportFile.path]);虽然Dart代码里写Process.run(chmod)看着不太优雅但在鸿蒙设备调试环境下确实能省很多事。如果不想依赖chmod也可以把报告写到getExternalStorageDirectory这类公共目录但路径获取在鸿蒙上可能返回null需要做判空处理。4.2 Jenkins JUnit插件接入与趋势图报告拉回来之后Jenkins的配置就简单了。在流水线里使用标准步骤stage(Parse Test Report) { steps { junit testResults: reports/*.xml, allowEmptyResults: true } }allowEmptyResults: true一定要加。因为端侧测试如果因为设备断连根本没跑完可能没有生成XML这时候如果严格模式会直接把流水线标红而且看不到任何有效日志排查起来很被动。设置允许空结果之后至少能看到“没拿到报告”这个信息而不是一句含混的构建失败。JUnit插件接上之后Jenkins会自动生成case的趋势图、失败用例列表和历史记录。我强烈建议此时把“历史趋势”这个视图重点看一遍因为单次XML只反映一次执行趋势图才能看出测试用例是不是在缓慢腐烂——比如某个用例的耗时一直在上涨早晚会在某次CI中变成超时失败。4.3 多模块XML合并与统计口径如果工程有多个测试模块每个模块在设备上各自生成一份XMLJenkins里就会出现多份报告。这个做法能用但有几个问题一是成功率统计会被割裂二是同一个用例如果分布在多个包里的历史趋势无法统一。更推荐的方案是在CI脚本里完成合并把所有XML合成一个大的testsuites根节点。我写过一个Java脚本版本后来换成用Python处理简单清晰import glob import xml.etree.ElementTree as ET def merge_xml(input_pattern, output_file): testsuites ET.Element(testsuites) for path in glob.glob(input_pattern): tree ET.parse(path) root tree.getroot() for suite in root: testsuites.append(suite) total_tests sum(int(s.get(tests, 0)) for s in testsuites) total_failures sum(int(s.get(failures, 0)) for s in testsuites) total_errors sum(int(s.get(errors, 0)) for s in testsuites) testsuites.set(tests, str(total_tests)) testsuites.set(failures, str(total_failures)) testsuites.set(errors, str(total_errors)) ET.ElementTree(testsuites).write(output_file, encodingutf-8, xml_declarationTrue)这里有一个统计口径要特别说清楚合并时testsuite的name属性尽量不要冲突否则Jenkins里的测试列表会把这几个suite的用例混在一起追溯起来很麻烦。我习惯在生成XML之前给每个模块的testsuite.name加一个模块前缀比如moduleA:LoginTest、moduleB:HomePageTest这样合并之后谁是谁一目了然。4.4 失败重试和稳定性保障鸿蒙真机测试最让人头疼的是稳定性。设备偶发断连、网络抖动、动画没结束导致的断言失败这些都不是代码问题但都会在报告里变成红色用例。完全不重试会让报告失真过度重试又会浪费大量机时。我的折中方案是这样在端侧执行用例前做一次设备连通性检查如果单个case失败且失败原因属于“超时”“连接断开”这类环境问题就自动重跑一次但重跑结果会覆盖同一条case记录这样Jenkins报告里不会出现大量重复case名。重试逻辑写在测试Runner里和业务测试代码完全解耦。Futurebool runWithRetry(Futurevoid Function() testFn, {int retries 1}) async { try { await testFn(); return true; } catch (e) { if (retries 0 isEnvRelatedError(e)) { await Future.delayed(Duration(seconds: 3)); return runWithRetry(testFn, retries: retries - 1); } rethrow; } }另外端侧生成报告的Runner进程记得设置超时保护。我见过不少case是因为应用冷启动太慢Runner还在初始化Jenkins那边已经等不及开始拉文件了。所以要在CI脚本里给hdc file recv设置一个合理的重试和超时比如先sleep 5再尝试3次拉取。5. 实战中的坑与排查记录5.1 最常见的五个报错适配过程中遇到的坑我整理出一张速查表供排查时快速定位现象可能原因解决办法生成的XML里所有用例时间为0事件时间戳传的是秒而不是毫秒库内部又做了一次转换采集时保留毫秒原值不要提前除文件写在设备上但CI拉不到沙箱路径找错或文件权限不足应用内打印真实路径给文件加644权限Jenkins解析XML时报“不可读”XML里混入了控制台日志或非UTF-8字符确保最终写出文件是UTF-8编码不要println到同一文件用例跑完但XML为空testsuites事件采集的数组在异步中丢了数据检查事件缓冲区的作用域确保所有suite结束后再序列化库源码里引用了Android专属路径鸿蒙分支走错入口封装平台隔离层在wrapper里重写路径逻辑每一个坑背后都是真实踩过的尤其是第一个时间单位问题不仔细看库源码很容易被坑。我当时拿着Jenkins报告质问为什么耗时全是0.0查了半天才发现是自己顺手做了秒转换其实库的输入定义要求毫秒。5.2 XML“看起来正确”但Jenkins不认有一种情况特别迷惑XML文件用浏览器打开完全正常testsuite、testcase、failure标签都在但Jenkins就是解析失败或者在测试结果页面取不到用例。后来发现是编码问题。设备端Dart代码输出的字符串如果中间夹着一串不可见的BOM字符或者有非法控制字符Jenkins的XML解析器会直接拒绝。解决方法是写文件时强制使用UTF-8编码并且在生成XML字符串后先做一次trimfinal cleaned xmlString.trim(); reportFile.writeAsStringSync(cleaned, flush: true);另外JUnit XML的failure节点内容如果包含未转义的、、符号也会导致解析失败。库理论上会做转义但当你把多个测试模块的结果手动合并时就保不准了。所以合并脚本里建议对文本节点做一次内容escape宁可多转不要漏转。5.3 端侧时间时区问题JUnit XML里的时间戳信息虽然Jenkins用得不多但一旦用到就是大坑。设备端DateTime.now()返回的时间受系统时区影响如果设备时区不是UTC报告时间会和CI主机不一致趋势图上的时间轴会错乱。建议在端侧采集时间时统一使用DateTime.now().toUtc().millisecondsSinceEpoch这样后续无论CI在哪个时区都能通过换算对齐到同一标准。如果你用junitreport_maintained生成的XML里自带时间戳也要确认库内部用的是UTC还是本地时间。5.4 兼容性排查如何判断一个纯Dart库能不能直接跑最后分享一下判断三方库鸿蒙化适配成本的方法。不是所有纯Dart库都一定能无感运行我自己归纳了一个三步判断法第一步看依赖树。用flutter pub deps列出所有传递依赖凡是依赖flutter_test、flutter_driver这类和平台实现强绑定的包都要打起精神。第二步扫源代码里的平台关键词。包括Platform.isAndroid、Platform.isIOS、dart:io中的Process、InternetAddress、Socket。dart:io的普通文件操作通常没问题但网络socket和进程管理在鸿蒙适配SDK里的支持度不同能不用就不用。第三步做一次最小验证。在鸿蒙设备上写一个5行代码的测试调用库的核心函数生成一个临时XML然后拉回到宿主机手动解析。这一步的成本很低但能提前暴露90%的兼容性问题总比集成到大规模流水线之后再返工强。按照这三步判断junitreport_maintained属于低适配成本库因为它核心依赖就是JSON处理、XML字符串拼接、dart:io的文件写入。真正费时间的不是库本身的代码而是它所在的鸿蒙工程链路——从测试执行、事件采集到报告回收每一环都要有人在真实设备上跑通一遍。我在实际适配中的体会是JUnit XML这个标准本身很老也没有多漂亮但它在CI体系里的市场占有率太高了。与其花时间找一个“更现代”的报告格式不如老老实实把标准格式跑通。鸿蒙化适配绕不开这件事早一天把报告链路打通后面自动化测试的推进都会顺很多。最后再分享一个小建议凡是接CI的报告生成逻辑都尽量做成可离线验证的小工具。这样可以先在本地模拟端侧产出的JSON数据不连接设备也能反复调试XML生成逻辑能大幅缩短每次上真机的等待时间。