拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

codemagic_app_preview鸿蒙适配实战:平台通道与CI/CD集成

codemagic_app_preview鸿蒙适配实战:平台通道与CI/CD集成

1. 为什么要给 codemagic_app_preview 做鸿蒙适配

先交代下背景。我所在的小组一直用 Flutter 做跨端业务,CI/CD 走的是 Codemagic,产物自动化预览用的是 codemagic_app_preview 这个三方库。它的作用很直接:每次构建跑完后,自动生成 App 内各页面的截屏/录屏预览,附带设备型号、系统版本、构建号、git commit 等信息,团队成员不用装包、不用自己跑模拟器,打开链接就能看这次改版到底变成什么样了。

这个库在工作流里的地位很微妙,平时没人夸它,但一旦坏掉,UI 还原度、样式回归、状态变更确认全都卡壳。所以当初团队决定探索 OpenHarmony 平台支持时,我第一个想到的不是业务代码怎么跑,而是这套预览链路能不能跟着一起迁过去。如果 CI/CD 构建跑完,预览生成不了,整个审查流程就断了一截。

真正的难点在于,codemagic_app_preview 的设计初衷是纯 Android/iOS 双端,它内部通过平台通道拉起原生截图能力,再结合 Codemagic 的环境变量和 API 完成上传。OpenHarmony 上 Flutter 的运行时、插件通道、文件存储路径、权限模型都跟 Android 有差异,直接拿来用必然跑不通。而且三方库官方大概率不会主动做鸿蒙适配,至少短期没有计划。这个活只能自己干。

这篇文章我会按我实际操作的顺序来写:先讲清楚 codemagic_app_preview 的工作原理,再拆 OpenHarmony 与 Android 的工程差异,然后给出可落地的适配方案和完整 CI/CD 集成步骤,最后把适配过程中踩过的坑和排查思路一并列出来。内容偏向实战,适合已经跑通 Flutter 鸿蒙构建、正准备把工程化能力补上的团队参考。

2. 先拆库:codemagic_app_preview 到底在做什么

动手改代码之前,必须先搞清楚这个库的运行机制。我建议读源码的时候不要一头扎进方法实现里,先看它的 pubspec 声明和示例代码,理解它在编译期和运行期各干了哪些事。

2.1 编译期的元数据注入

codemagic_app_preview 在编译阶段做了一件事:把当前构建的元数据写入到工程里,这些元数据包括构建号、git commit hash、分支名、Codemagic 构建页面链接等。它依赖 Codemagic 提供的一组环境变量,比如CM_BUILD_ID、CM_COMMIT、CM_BRANCH、CM_BUILD_URL。如果你在本地直接跑,这些环境变量不存在,库会自动退回到测试模式,不会真的执行截图上传逻辑。

这块逻辑相对独立,鸿蒙适配时不需要动。因为 OpenHarmony 的 Flutter 工程同样走 Dart 层编译,环境变量是平台无关的,只要在 CI 环境里把这些变量带进来,元数据注入照常生效。

2.2 运行期的自动预览执行

运行期逻辑是重头戏。当 App 以 preview 模式启动时——一般通过--dart-define=CODEMAGIC_PREVIEW=true之类的参数触发——库里会创建一条独立的预览执行链:

  • 检测当前运行环境是否满足截图条件
  • 逐个打开配置好的页面或路由
  • 通过平台通道调用原生截图/录屏能力
  • 将生成的图片/视频暂存到临时目录
  • 通过 HTTP 上传到 Codemagic 的预览 API

这里最关键的就是平台通道调用。在 Android 上它调用的是MediaProjection或PixelCopy;在 iOS 上走的是UIWindow的截图 API。OpenHarmony 上这些 API 一个都不存在,所以适配工作的核心就是重写这条原生链路。

2.3 为什么说它的架构其实不难适配

说实话,这个库的代码结构算清晰的。它把"策展逻辑"放在 Dart 层,"资产采集"放在原生层,"交付上传"放在 Dart 层。这种分层方式对鸿蒙适配非常友好:Dart 层几乎所有代码可以原样保留,真正需要动的只有原生端的那层 Channel Handler。

你的任务可以理解成:把原来监听 MethodChannel 的两个原生实现(Android 一个、iOS 一个),换成 OpenHarmony 上一个,然后把临时文件目录路径、权限申请方式、截图 API 适配一下。工作量没有想象中那么大,难的地方在于别把链路整体打破。

提示:如果你只想要最小可用版本,可以先不实现录屏,只做截屏,因为 MediaProjection 的录屏在鸿蒙上的权限和后台限制比 Android 更严格,先跑通截屏,后续再补录屏,迭代成本更低。

3. OpenHarmony 上 Flutter 工程的三个致命差异

在没有真正跑起来之前,我一度觉得这种适配就是换几个 API 的事。实际踩下来发现,至少有三个差异会影响整个技术方案,需要提前想清楚。

3.1 引擎与插件通道的差异

OpenHarmony 的 Flutter 引擎目前是社区方案,它跟 Android 的引擎实现是两条线。最直接的体现就是MethodChannel的注册方式:Android 上写在MainActivity.kt里,OpenHarmony 上则要写在 ArkTS 侧的EntryAbility或自定义的FlutterAbility子类里。

也就是说,你没法直接把项目的android目录下的代码搬过来,需要在ohos目录下新建一套 Channel Handler。这个逻辑对做过 Flutter 双端插件开发的同学来说很好理解:本来就有一个抽象 Channel 名称,现在只需要保证平台侧实现换了,Channel 名称和参数协议保持不变,Dart 代码一行都不用改。

// 在 ArkTS 侧注册自定义 Channel let channel = MethodChannel('codemagic_app_preview', StandardMethodCodec.INSTANCE) channel.setMethodCallHandler((call, result) => { if (call.method === 'captureScreenshot') { // 调用鸿蒙截图能力 capturePage(result) } else if (call.method === 'saveToTemporaryFile') { // 写入缓存目录 saveTempFile(call.arguments as string, result) } })

3.2 存储路径与沙箱限制

Android 的临时文件目录,大家都很熟悉,context.cacheDir随便用。OpenHarmony 的沙箱机制更接近 iOS,App 只能在自己分配到的沙箱目录里自由读写。

这个差异在适配时很容易踩坑。因为 codemagic_app_preview 在 Dart 层会通过path_provider获取getTemporaryDirectory(),然后往这个目录里写截图文件。如果你在鸿蒙上没做任何处理,path_provider是可以正常拿到一个沙箱路径的,但如果你的原生截图代码用的是另一个目录——比如appContext.cacheDir的实际值跟 Dart 层拿到的不一致——上传时就会报文件不存在。

解决办法:统一以 Dart 层拿到的路径为准,原生层不要自行拼接。最稳妥的做法是,原生截图完成之后,把二进制字节返回给 Dart 层,由 Dart 层负责写入临时目录。这样路径问题就从根上消失了。

3.3 权限模型差异

Android 的截屏权限在 API 29+ 之后基本靠MediaProjection配合用户弹窗授权,iOS 则直接不能用公共 API 截取非自身 App 的内容。OpenHarmony 的截屏 API 走的是screenshot模块,它同样需要用户授权和系统能力声明。

这里最需要注意的是:OpenHarmony 对截屏时机的管控相对严格,如果页面还没渲染完成就去截,很可能拿到一张黑屏或者白屏。所以适配时建议在截图前做一次主动延迟,或者等首帧回调完成后再触发。

我这里调试时踩过一次黑屏,排了半天不是权限问题,而是截图调用时机太早,最后加了一个帧回调等待,问题就消失了。

4. 鸿蒙适配的完整落地步骤

下面这部分是全文的核心,我把适配过程拆成几个阶段来说明,每个阶段都给出了我实际上用的做法,以及为什么这么做的原因。

4.1 环境准备与基线确认

在做任何代码改动前,先把环境基线锁住。不同版本的 Flutter 鸿蒙 SDK、不同版本的 OpenHarmony API,代码写法会有细微差异,基线不定,后面改代码就是猜谜游戏。

我用的环境参考如下(按我实际操作时的版本,你的环境可能有更新):

组件版本/说明
Flutter SDKflutter 3.7.12 或更高版本
OpenHarmony Flutter SDK社区维护的 flutter_ohos 分支
HarmonyOS SDK / DevEco StudioAPI 9 及以上
Codemagic标准 macOS 构建集群
codemagic_app_preview1.x 版本

注意:codemagic_app_preview 这个库本身是一个 Dart 包,对鸿蒙并不感知,你在pubspec.yaml里正常声明就行。它依赖的path_provider、http这些包,鸿蒙平台下都有对应的实现,暂时不需要额外替换。

关键是原生层的适配,这一步需要把android目录下的MainActivity里的 Channel Handler 逻辑,用 ArkTS 重写一遍,放到ohos目录对应的入口类里。

4.2 采集层的鸿蒙原生实现

采集层主要负责两个能力:页面截图和文件保存。我建议分两个 Channel 方法实现,不要混在一起。这样后面如果单独调试生成逻辑,可以只调截图,不碰文件保存。

截图这块,OpenHarmony 提供了@ohos.screenshot模块,可以实现屏捕获。实现思路参考下面的伪代码:

import screenshot from '@ohos.screenshot' async function captureScreenshot(): Promise<ArrayBuffer> { // 申请截屏能力 let options = new screenshot.ScreenshotOptions() options.width = 1080 options.height = 2400 let result = await screenshot.takeScreenshot(options) return result.pixelMap ? await result.pixelMap.getImageData(0, 0, 1080, 2400) : new ArrayBuffer(0) }

实际编码的时候有几个细节要确认:一是ScreenshotOptions的属性命名和 Android 的MediaProjection完全不同,不要照着 Android 的代码硬翻译;二是截图返回的PixelMap需要转成 JPEG/PNG 字节流,Dart 层才方便保存;三是权限声明要在module.json5里加上对应权限条目,否则运行期直接报错。

文件保存这一步,我推荐走 Dart 层。原生截图拿到字节流后,直接把Uint8List返回给 Dart,Dart 再用File.writeAsBytes写入getTemporaryDirectory()拿到的路径。这样可以完全避开沙箱路径不一致的问题。

// 在 Dart 层封装截图逻辑 class PreviewCapture { static const _channel = MethodChannel('codemagic_app_preview'); static Future<File> capture(BuildContext context) async { final bytes = await _channel.invokeMethod<Uint8List>('captureScreenshot'); final dir = await getTemporaryDirectory(); final file = File('${dir.path}/preview_${DateTime.now().millisecondsSinceEpoch}.png'); await file.writeAsBytes(bytes!); return file; } }

4.3 上传逻辑的保持与变更

上传逻辑在 codemagic_app_preview 中已经封装得很好了,核心是通过 Codemagic 的 API 上传截图文件,并关联到对应的构建记录。鸿蒙适配不需要修改上传协议,也不需要改 API 地址。唯一要确认的是上传时机和网络权限。

OpenHarmony 的网络权限需要在module.json5里显式声明ohos.permission.INTERNET。很多第一次接触鸿蒙开发的同事经常漏掉这个,导致 Dart 层 HTTP 请求一直失败,还以为是适配代码写错了。

另外一点,上传的时候建议增加失败重试。OpenHarmony 的 network 栈在某些模拟器或开发板上表现不太稳定,超时概率比 Android 高。原库可能只做了一次重试,你可以根据需要改成三次指数退避。

# module.json5 中声明网络权限(节选) requestPermissions: [ { name: 'ohos.permission.INTERNET' } ]

4.4 触发条件的本地模拟验证

适配完成后,不要直接推到 CI 去验证,先本地跑一遍。codemagic_app_preview 本地跑的时候不会进入正式模式,所以你需要手动模拟环境变量和触发条件。

最简单的方式:写一个内部入口页面,开发模式下点一个按钮,直接调用刚才封装的PreviewCapture.capture(),看截图生成和临时文件写入是否正常。确认这个链路通了,再考虑环境变量映射和 CI 集成。

我本地验证时用的命令大致长这样:

flutter run --dart-define=CODEMAGIC_PREVIEW=true \ --dart-define=CM_BUILD_ID=local-test \ --dart-define=CM_COMMIT=abc123 \ --dart-define=CM_BRANCH=develop \ --dart-define=CM_BUILD_URL=http://local.test/build/1

跑起来之后,用 DevEco Studio 的日志过滤器盯着 Channel 的调用记录,确认captureScreenshot被正确触发,然后看临时目录里是不是真的生成了 PNG 文件。

5. CI/CD 集成:把预览链路接到 Codemagic 全流程

本地验证通过后,剩下的就是把这条链路接到真正的 CI 流程里。这一步比纯代码适配更考验对构建系统的理解,因为你要同时考虑构建触发时机、产物传递、元数据映射等多方面因素。

5.1 构建脚本里的环境变量映射

Codemagic 原生提供了一组环境变量供 codemagic_app_preview 使用。OpenHarmony 构建流程中,这些变量同样存在,但有些变量名可能因为构建机镜像差异而不完整。我建议在构建脚本里做一个显式校验,缺失的先给默认值,避免空指针。

下面是我实际用的脚本片段,放在 Codemagic 的scripts阶段:

#!/bin/bash export CM_BUILD_ID=${CM_BUILD_ID:-unknown} export CM_COMMIT=${CM_COMMIT:-unknown} export CM_BRANCH=${CM_BRANCH:-unknown} export CM_BUILD_URL=${CM_BUILD_URL:-https://codemagic.io/} flutter build hap --release \ --dart-define=CODEMAGIC_PREVIEW=true \ --dart-define=CM_BUILD_ID=$CM_BUILD_ID \ --dart-define=CM_COMMIT=$CM_COMMIT \ --dart-define=CM_BRANCH=$CM_BRANCH \ --dart-define=CM_BUILD_URL=$CM_BUILD_URL

注意编译 target,OpenHarmony 下的产物是.hap包,而不是 Android 的.apk。Codemagic 的构建机默认没有flutter build hap这个 target,需要先确认构建机的 Flutter SDK 是否切换到了 OpenHarmony fork 版本。

5.2 预览产物的留存与生命周期管理

codemagic_app_preview 生成的截图最终会显示在 Codemagic 构建详情页的 Preview 标签里。这个图只反映构建产物实际运行时的 UI 状态,所以它天然带有版本管理价值。

我建议在保存截图时,文件名里带上构建号和 commit hash,比如preview_${CM_BUILD_ID}_${shortCommit}.png。这样即使构建详情页的关联信息丢失,你也能从文件名反推出来源。

另外,OpenHarmony 设备上跑预览截图,需要一台常驻的模拟器或真机。Codemagic 默认的 macOS 集群无法直接跑 HarmonyOS 模拟器,所以有两种路径:

  • 自建 Harbor 或使用华为云构建服务,单独挂载鸿蒙模拟器
  • 在 Codemagic 构建机里用 Docker 驱动 OpenHarmony 容器化的模拟方案

路径的选择取决于团队现有基础设施,我这边选的是第二种,容器方案的好处是构建节点可以复用 Codemagic 的编排逻辑,不是从零搭一套。

5.3 触发策略与失败重试机制

预览截图这个环节对时间敏感。构建完成后的第一张截图最有价值,因为这时候页面处于初始状态,能直接反映冷启动后的 UI。如果产品复杂,建议让预览流程跑的页面控制在三到五个核心页,不要把所有路由都铺进去,不然构建时间会拉长很多。

我之前试过贪多,把几十个页面全配进预览链路,结果单次构建多了快六分钟,团队怨声载道。后来改成只截核心转化路径的页面,时间压缩到一分钟以内,信息量反而更集中。

失败重试也要设计好。OpenHarmony 设备偶尔会出现截图服务无响应的情况,此时直接退出预览模式、让整个构建失败,其实是合理的。因为预览链路失败说明构建产物有问题,宁可失败重跑,也不要让坏产物混进版本管理。

6. 踩坑记录:适配过程中最折磨人的五个问题

适配过程中我遇到的问题远不止上面提到的那些,这一节挑五个最有代表性的记录下来,方便后来者参考。

6.1path_provider在鸿蒙上获取到了空目录

这个是我遇到的第一个问题。path_provider在 Android 上轻松返回 cache 路径,但在 OpenHarmony 上,某些版本拿到的是空字符串,导致所有写入操作失败。

排查链路是这样的:

  1. 先看 Dart 层的getTemporaryDirectory()返回值,发现是null
  2. 用MethodChannel手动调PathProviderPlugin的getStoragePath方法
  3. 发现 ArkTS 侧的原生实现没有正确初始化,导致 channel 返回 null

解决方法有两种,我最后选了第二种:

// 回退方案:手动拼接沙箱路径 final dir = Platform.isOpenHarmony ? Directory('/data/app/el2/100/base/${packageName}/cache') : await getTemporaryDirectory();

这里不推荐手写绝对路径,因为沙箱路径规则可能随系统版本变化。更好的做法是在原生侧通过自己的方法返回真实路径,然后 Dart 层缓存起来。

6.2 截屏黑屏问题

黑屏的原因是 OpenHarmony 的截屏服务需要等待界面帧绘制完成,如果你在onPageShow或路由跳转完成后立即截图,大概率拿到的是一张还没渲染好的图。

我的解决办法是加一个渲染完成的回调延时。不要用简单的Future.delayed,因为低端设备上渲染时间不可控,最好用 Flutter 的WidgetsBinding.instance.endOfFrame。

Future<void> waitForRaster() async { Completer<void> completer = Completer(); WidgetsBinding.instance.addPostFrameCallback((_) { completer.complete(); }); // 再额外等待一帧,确保原生图层更新 await Future.delayed(const Duration(milliseconds: 200)); return completer.future; }

6.3 平台通道注册冲突

如果你的 App 里已经有一个插件注册了同名 Channel,适配时会相互覆盖,导致行为异常。排查方法很简单,打开 DevEco Studio 的 Log,过滤MethodChannel关键词,看是否有两个注册记录指向同一个名称。

解决方式是给 codemagic_app_preview 的鸿蒙适配单独起一个 Channel 名称,比如codemagic_app_preview_ohos,在 Dart 层做一层兼容转发。这样不会干扰其他插件。

6.4 上传超时且无重试

OpenHarmony 设备的网络栈在弱网环境下表现不太稳定。codemagic_app_preview 默认的 HTTP 超时时间是 30 秒,但上传大图时偶尔会超。建议在 CI 环境里手动把上传超时调到 60 秒,同时确认设备可以访问 Codemagic API 域名。

final client = http.Client(); final response = await client .post(uri, body: bytes, headers: headers) .timeout(const Duration(seconds: 60));

6.5 构建机找不到flutter build hap

Codemagic 的镜像默认不带 OpenHarmony 的工具链,需要你手动在构建脚本里拉取并切换到 Flutter 的 OpenHarmony fork 版本。这一步如果漏了,构建会在很早的阶段就挂掉,而且报错信息非常隐晦,类似Target name "hap" not found。

我的建议是把 SDK 切换动作放到单独的脚本步骤里,并输出当前 Flutter 版本和分支,方便定位问题。

cd $FLUTTER_ROOT git clone https://gitee.com/openharmony-sig/flutter_flutter.git -b master . flutter doctor flutter --version

7. 适配之后,版本管理工作流有什么变化

代码适配完成之后,整个 CI/CD 预览链条最直接的变化是:OpenHarmony 构建物也和 Android/iOS 一样,具备可视化的验收抓手了。这对版本管理的影响是深远的,但很多人只看表面,没意识到更深层的价值。

以前hap产物只有安装包大小、崩溃率这类冷数据,UI 层面的改动全靠开发自测 + 截图手工贴到群里。现在每次构建都自带一组结构化预览图,版本评审会议上可以直接对着图过需求,开发、产品、测试三方看到的是同一份产物状态。

另一个变化是构建历史变成了可视化档案。你翻半年前某个版本的预览图,能直接知道当时 Tab 结构长什么样、主题色是什么、首页卡片排布如何。这个东西在快速迭代期价值巨大——它帮你把"当时的 UI 到底什么样"这个问题变成了秒查状态,而不是翻代码历史。

从工程化角度讲,codemagic_app_preview 的鸿蒙适配,真正补齐的是 OpenHarmony 在"交付链路的可视性"这一环上的短板。它不改变产物内容,但它改变了团队感知产物状态的方式。

8. 最佳实践组合与后续扩展方向

适配工作告一段落之后,我复盘了一下整个方案,提炼出几个最佳实践组合,分享给大家。

8.1 最少改动原则

能不改三个平台共用的 Dart 代码,就不要改。我建议把鸿蒙适配集中在原生层和构建脚本层,Dart 层只增加一个平台判断的 fallback。这样后续原库更新时,可以很方便地合并上游改动。

原库如果发布了新版本,我的升级流程一般是:

  1. 先看 changelog,确认 Dart 层是否有破坏性改动
  2. 在分支上合并新版本,跑本地验证
  3. 确认无误后,再把 ohos 原生层的代码同步到新版本对应的构建配置

8.2 配置化驱动预览页面列表

不建议把截图页面列表写死在代码里。更好的方式是通过--dart-define传一个路由名单进来,这样不同团队可以各配各的预览范围,不需要改公共代码。

--dart-define=PREVIEW_ROUTES=home,profile,cart,detail

Dart 层解析这个参数后,动态构造路由列表,逐页跳转截图。这比写死在main.dart里灵活得多,也方便后续接入自动化 UI 巡检。

8.3 持续监控与稳定性看板

预览链路也会挂。我建议把截图成功率作为一个独立指标接入监控体系,CI 构建完成后上报一项数据:preview_success(真/假)。连续几次失败就触发告警,避免预览链路长时间在无声无息中坏掉。

这个动作看起来简单,但价值很高。预览链路挂掉不会影响构建成功,所以在没有监控的情况下极难察觉,等团队发现的时候可能已经错过半个月的视觉回归数据了。

8.4 后续扩展方向

如果你想要更完整的鸿蒙工程化能力,可以考虑在预览链路的基础上做两件事:

一是把截图对比接入自动化视觉回归。既然每次构建都有同一组页面的截图,拿前后两次构建的截图做像素对比,就能实现基本的 UI 回归检测。OpenHarmony 生态里这个能力相对空白,做出来就是亮点。

二是把预览元数据接入版本管理系统的 API。你可以在版本发布时,自动把预览截图链接附到 release note 里,让版本描述从一开始就是带图的,而不是事后补文档。

这两件事都是在现成链路上做的增量优化,投入不大,但对工程管理的帮助非常明显。

9. 写在最后:适配这件事给了我几个启示

从决定适配 codemagic_app_preview 到完全跑通 OpenHarmony 预览链路,整个周期大概花了两周,其中正经写代码的时间只有三四天,剩下的全在排查环境问题和理解系统差异。

我个人最大的体会是:鸿蒙适配的核心困难不在代码量,而在思维转换。Android 上很多"想当然"的 API 和机制,在 OpenHarmony 上都需要重新审视一遍。你用 Android 的惯性去写鸿蒙代码,一定会有各种隐形问题;反过来,把它当成一个全新的平台,用最笨的办法一点点验证,反而顺利得多。

另外一个体会是,这种三方库的适配工作,最怕的不是技术难点,而是不知道边界在哪里。你要很清楚地知道哪些代码能复用、哪些必须重写、哪些可以通过环境配置绕过去。codemagic_app_preview 算是一个架构友好的例子,因为它的 Dart 层和原生层分得很清楚,给了我足够的操作空间。如果你的项目里要适配的库不是这种分层结构,恐怕就得考虑在 Dart 层做整体模拟了。

最后再分享一个小技巧:适配过程中,多做中间态验证。不要等所有代码写完了才跑测试,每完成一个环节——比如截图原生层跑通、文件写入跑通、上传跑通——就单独验证一次。这样即使后面出了问题,排查范围也极其有限。我这次能两周搞定,一半功劳要记在这个习惯上。

返回列表