☰
Flutter鸿蒙化:license_checker插件适配与踩坑实践
2026/10/6 4:05:00 网站建设 项目流程

最近在做 Flutter 工程的鸿蒙化迁移时,我卡在了一个最不起眼但又绕不开的环节:开源协议审计。flutter_ohos 把 Dart 生态里绝大多数兼容性问题都解决了,但依赖扫描这类需要原生能力的插件就没那么幸运了。license_checker 是 Flutter 社区里最常用的许可证检查三方库,它能在 App 里自动生成一份开源协议声明页,帮开发者完成合规兜底。这篇博文就记录我把 license_checker 适配到鸿蒙的完整过程,包括原理拆解、ArkTS 插件落地,以及我在真实工程里踩过的那些坑。

如果你现在正打算把 Flutter 工程迁到鸿蒙上,或者负责 App 上架前的开源合规审查,这篇文章适合你。我会先讲清楚 license_checker 内部的工作链路,再逐个击破鸿蒙侧的适配点,最后给出可复现的验证方法和长期维护建议。

1. license_checker 的职责边界:它究竟扫了什么、生成了什么

1.1 一个经常被忽略的合规基础设施

很多 Flutter 开发者把 license_checker 当成一个“生成设置页的 UI 库”,这其实是误解。它真正的价值在于把“扫描依赖许可证”和“呈现声明”这两件事串起来,做成一条自动化链路。没有它,你只能在发版前手动翻 node_modules、pubspec.lock、Podfile.lock,逐个确认依赖的许可证类型,再手工拼一个 HTML 或 Markdown 声明文件。项目小的时候还能忍,依赖超过三十个之后,手动维护基本不可持续。

合规这件事不只是法务部门的需求。鸿蒙应用市场、各大安卓商店在提交审核时,对开源协议声明都有明确要求。你用了 Apache-2.0、MIT、GPL 这类协议的三方库,就必须在应用内提供对应的版权声明和许可文本。这不是“建议”,是硬性门槛。license_checker 解决的就是这个问题的自动化:让用户打开 App 的一个页面,就能看到所有依赖的许可证清单,同时让开发者不再手工维护。

1.2 工作链路拆解:Dart 层和原生层各管什么

license_checker 的整体结构并不复杂,核心链路可以拆成四步:

  • Dart 侧调用插件方法,发起许可证收集请求;
  • 原生侧(Android/iOS)扫描当前工程依赖的许可证信息并序列化返回;
  • Dart 侧收到数据后,通过LicenseRegistry注册;
  • UI 层读取注册数据,渲染LicensePage或LicenseDetailsPage。

换句话说,Dart 层负责的是“展示逻辑”,真正的脏活累活——遍历依赖目录、解析许可证文件、提取库名与版本——全在原生侧。

拿 Android 来说,它扫描的是 assets 下或依赖元数据中的 LICENSE 文件,把文本内容和许可证标识提取出来。iOS 侧则是遍历 CocoaPods 生成的 Pods 目录,读取每个组件的 license 文件。所以当你决定把 license_checker 迁到鸿蒙,本质上要重写的不是 Dart 代码,而是原生插件里“扫描”这一整块。这一步搞不定,UI 层再好看也只是空壳。

2. 鸿蒙化之前必须先搞清楚的三处兼容性断层

2.1 HarmonyOS NEXT 下的兼容性真相

很多刚从 Android 转过来的开发者会有一个错觉:把 license_checker 的 Android 实现编译成 AAR,再塞进鸿蒙工程里就能跑。这个思路在早期鸿蒙版本还能勉强成立,因为那时兼容 Android APK。但 HarmonyOS NEXT 已经彻底不兼容 Android 应用,Flutter 在鸿蒙上的运行依赖的是flutter_ohos引擎,它有自己的插件注册机制。

这意味着,原来 license_checker 插件里写在 Android 原生层的扫描逻辑,在鸿蒙上根本没有机会执行。你需要重新实现一个 ArkTS 插件,通过 MethodChannel 暴露给 Dart 调用。这个过程比想象中麻烦的地方在于:鸿蒙的依赖管理方式(ohpm + oh-package.json5)和 Android 的 Gradle 依赖体系完全不同,扫描目标目录、解析入口、数据格式都得重新设计。

2.2 原生层的三处重写点:路径扫描、依赖解析、数据回传

我梳理下来,license_checker 鸿蒙化必须处理三个层面,缺一不可。

第一是路径扫描。Android 插件遍历的是 assets 或插件目录里的 LICENSE 文件,鸿蒙侧则要面对 oh_modules 这个结构。听起来都是“扫目录”,但鸿蒙工程里每个模块有自己的oh_modules,且不同的构建形态(HAP、HSP、HAR)导致的目录层级还不一样,后面我会细说。

第二是依赖解析。Android 可以通过 Gradle 的 dependencies 信息直接拿到库坐标,鸿蒙这边最接近的信息来源是oh-package.json5文件。麻烦的是 JSON5 格式不是严格 JSON,直接用JSON.parse解析会崩,需要先做预处理或者引入 JSON5 解析器。

第三是数据回传。原生侧收集到的是结构化的许可证列表,包含库名、版本、许可证类型、许可证原文。这个数据要通过 MethodChannel 返回给 Dart。数据量大的时候还需要考虑一次性传输的体积问题,以及 ArkTS 类型系统对 JSON 序列化的限制。

3. ArkTS 插件层落地:MethodChannel、目录扫描与 JSON5 兜底

3.1 从 Plugin 注册到 MethodChannel,先跑通最小链路

我建议不要一上来就写完整扫描逻辑,先把插件骨架搭好,保证 Dart 能调到 ArkTS 方法,再填充细节。鸿蒙的 Flutter 插件本质是一个 ArkTS 模块,实现 FlutterPlugin 接口,在 onAttach 里往 BinaryMessenger 上注册 MethodChannel。

下面这个示例是我在一个 API 12 的工程里调通的,import 路径在不同版本的 flutter_ohos 上会略有差异,以你工程里的实际 SDK 为准:

// src/main/ets/plugin/LicenseCheckerPlugin.ets import { MethodChannel, FlutterPlugin, MethodCall, MethodResult } from 'flutter-ohos/plugin'; export class LicenseCheckerPlugin implements FlutterPlugin { onAttach(binding: FlutterPluginBinding): void { const channel = new MethodChannel(binding.getBinaryMessenger(), 'com.example/license_checker'); channel.setMethodCallHandler((call: MethodCall, result: MethodResult) => { if (call.method === 'collectLicenses') { try { const licenses = LicenseCollector.collect(); result.success(licenses); } catch (e) { result.error('collect_failed', (e as Error).message, null); } } else { result.notImplemented(); } }); } onDetach(): void { // 这里记得释放资源 } }

这个骨架里有三个容易被忽略的点。第一,result回调必须在同一个调用周期内同步或异步触发一次,别漏掉,否则 Dart 侧的 Future 会一直挂着。第二,异常信息先转成字符串再传给result.error,ArkTS 对跨语言传对象有限制,最简单的方式就是传 string。第三,插件的注册入口在鸿蒙工程的模块初始化处,别漏了把 Plugin 实例传给 Flutter 引擎。

跑通最小链路后,我建议先写一个写死的测试方法collectLicenses,在 Dart 侧调用并打印返回值。这一步验证的是 MethodChannel 双向通信正常,后续再替换成真正的扫描逻辑时,可以把问题隔离在原生侧,而不是通信层。

3.2 真正的重头戏:递归扫描 oh_modules 的许可证文件

链路通了之后,开始写扫描逻辑。鸿蒙的依赖目录结构大致是:

module/ ├── oh_modules/ │ ├── @ohos/axios/ │ │ ├── oh-package.json5 │ │ ├── LICENSE │ │ └── ... │ ├── @kit/abc/ │ └── ... ├── oh-package.json5 └── src/

依赖库名称带@作用域前缀时,目录层级会深一层,递归扫描时别漏。我实现的收集逻辑核心是一个递归函数:

// src/main/ets/plugin/LicenseCollector.ets import { fileIo as fs } from '@kit.CoreFileKit'; interface LicenseInfo { name: string; version: string; license: string; licenseText: string; } export class LicenseCollector { static collect(rootDir: string): LicenseInfo[] { const result: LicenseInfo[] = []; LicenseCollector.scanDirectory(rootDir, result); return result; } private static scanDirectory(dir: string, result: LicenseInfo[]): void { let entries: string[] = []; try { entries = fs.listFileSync(dir); } catch (e) { return; // 目录不存在或权限不足时直接跳过 } for (const entry of entries) { const fullPath = `${dir}/${entry}`; let stat; try { stat = fs.statSync(fullPath); } catch (e) { continue; } if (stat.isDirectory()) { const subEntries = fs.listFileSync(fullPath); if (subEntries.includes('oh-package.json5')) { // 这是一个依赖模块,解析它的元数据,再尝试读取 LICENSE LicenseCollector.parsePackage(fullPath, result); } else { // 继续向下递归 LicenseCollector.scanDirectory(fullPath, result); } } else { const upperName = entry.toUpperCase(); if (upperName === 'LICENSE' || upperName.startsWith('LICENSE.')) { const dirName = dir.substring(dir.lastIndexOf('/') + 1); result.push({ name: dirName, version: '', license: '', licenseText: LicenseCollector.readTextFile(fullPath), }); } } } } private static readTextFile(path: string): string { try { const file = fs.openSync(path, fs.OpenMode.READ_ONLY); const stat = fs.statSync(path); const arrayBuffer = new ArrayBuffer(stat.size); fs.readSync(file.fd, arrayBuffer); fs.closeSync(file); return String.fromCharCode(...new Uint8Array(arrayBuffer)); } catch (e) { return ''; } } }

这段代码在功能上没问题,但有几个细节值得说。第一,fs.openSync的路径如果带中文字符或特殊符号,在不同 API 版本上表现不一样,建议统一用/拼接。第二,readTextFile用String.fromCharCode(...new Uint8Array(arrayBuffer))在文件特别大时会爆栈,因为展开运算符会把每一个字节作为参数传入。一百万字节的文件就会生成一百万个参数,直接 RangeError。我后来改成分段读取:

private static readTextFile(path: string): string { try { const file = fs.openSync(path, fs.OpenMode.READ_ONLY); const stat = fs.statSync(path); const bufferSize = 64 * 1024; // 64KB 一段 const chunks: string[] = []; let offset = 0; const arrayBuffer = new ArrayBuffer(bufferSize); while (offset < stat.size) { const readLen = fs.readSync(file.fd, arrayBuffer, { offset, length: Math.min(bufferSize, stat.size - offset) }); chunks.push(String.fromCharCode(...new Uint8Array(arrayBuffer, 0, readLen))); offset += readLen; } fs.closeSync(file); return chunks.join(''); } catch (e) { return ''; } }

第三个细节是递归深度。oh_modules 的嵌套层级可能很深,某些包的依赖里还有自己的 oh_modules。如果每一层都递归进去,扫描时间会指数级增长。我的做法是:只要遇到包含oh-package.json5的目录,就当作“一个依赖模块”处理,不再递归它内部的 oh_modules。因为内部依赖的许可证会被它自己的声明覆盖,顶层已经收集过了。

3.3 解析 oh-package.json5,license 字段缺失时走兜底

扫描到模块目录后,光有 LICENSE 文件文本还不够,还需要库名、版本、许可证标识。这些信息最权威的来源是oh-package.json5。它的标准结构长这样:

{ "name": "@ohos/axios", "version": "1.3.4", "description": "A promise-based HTTP client", "main": "index.ts", "license": "Apache-2.0", "dependencies": { "@ohos/crypto": "^1.2.0" } }

问题在于 JSON5 支持注释、尾随逗号、单引号,直接JSON.parse会抛异常。我在工程的 arm64-v8a 真机上第一次跑就遇到这个问题,报错信息指向Unexpected token /。当时没有现成的 JSON5 解析库可用,就写了一个预处理函数:

private static sanitizeJson5(raw: string): string { let text = raw.replace(/\/\/[^\n]*/g, ''); text = text.replace(/\/\*[\s\S]*?\*\//g, ''); text = text.replace(/,\s*([\]}])/g, '$1'); return text; } private static parsePackage(dir: string, result: LicenseInfo[]): void { const pkgPath = `${dir}/oh-package.json5`; try { const raw = LicenseCollector.readTextFile(pkgPath); if (!raw) return; const jsonText = LicenseCollector.sanitizeJson5(raw); const jsonObj = JSON.parse(jsonText) as Record<string, string>; const name = jsonObj['name'] ?? dir.substring(dir.lastIndexOf('/') + 1); const version = jsonObj['version'] ?? ''; let license = jsonObj['license'] ?? ''; // 兜底:license 字段缺失时,尝试取材子目录下的 LICENSE 文件 let licenseText = LicenseCollector.readTextFile(`${dir}/LICENSE`); if (!licenseText) { licenseText = LicenseCollector.readTextFile(`${dir}/LICENSE.txt`); } if (!licenseText && !license) { license = 'Unknown'; } result.push({ name, version, license, licenseText: licenseText || 'No license text provided.', }); } catch (e) { // 解析失败不影响整体,保险起见把目录名作为 name const name = dir.substring(dir.lastIndexOf('/') + 1); result.push({ name, version: '', license: 'Unknown', licenseText: LicenseCollector.readTextFile(`${dir}/LICENSE`), }); } }

注意sanitizeJson5用的正则其实不严谨:如果字符串值里刚好有//或尾随逗号,会被误伤。但在oh-package.json5的实际场景里,字段值绝大多数是短字符串,风险很低。生产环境如果要用,建议引入完整的 JSON5 解析实现。我这样处理的原因很简单:少一个依赖,少一个适配点。

4. 数据采集的两个真坑:oh_modules 的路径真相与 license 字段缺失

4.1 Release 包里根本没有 oh_modules,路径要怎么取

这是我在做真机验证时发现的。Debug 模式下,DevEco Studio 把工程目录同步到设备上,oh_modules是真实存在的,扫描没问题。但我打了一个 Release 包安装到另一台设备上,再打开声明页,数据列表是空的。查了半天才发现,HAP 包内根本没有oh_modules。

这个现象背后的逻辑是:ohpm 依赖里的代码在构建期被编译合并进了 HAR 或 HAP,运行时不再需要原始模块目录。所以“运行时扫描 oh_modules”这条路,在 Release 构建下走不通。

我的解决方案分两层。第一层,保留运行时扫描,但它只服务 Debug 模式,便于开发期预览。第二层,做一个构建期脚本,在打 Release 包前扫描工程根目录的oh_modules,把收集到的许可证数据写成一个assets/license.json,随包发布。运行时插件优先读这个文件,读不到再走目录扫描。

构建期脚本我用的是 Node.js 实现,放在工程根目录的tool/gen_licenses.js里,核心逻辑就是遍历oh_modules目录,读取每个模块的oh-package.json5和LICENSE,生成 JSON。然后在 hvigor 配置里加一个构建钩子,或者直接在 CI 流程里串一行node tool/gen_licenses.js。这个思路同样适用于 iOS 和 Android,一套脚本三端复用。

4.2 license 字段缺失时,如何判断模块的真实许可证

实际扫描了一轮之后我发现,oh-package.json5里license字段缺失的比例比想象中高。很多个人维护的库只放了 LICENSE 文件,没有写元数据字段。这时候不能直接把 license 标记为Unknown就完事,合规审查要求的是“许可证原文可追溯”。

我给兜底逻辑设了优先级,层层递进:

  • 读oh-package.json5的license字段,拿到 SPDX 标识(如 MIT、Apache-2.0);
  • 字段缺失时,读取模块目录下LICENSE、LICENSE.md、COPYING等文本文件,原文保留;
  • 原文也没有时,检查 README 中是否有许可证说明,有则截取相关段落;
  • 全部找不到,才标记为Unknown并给出告警。

后两种方案的文本质量参差不齐,但至少有一个可追溯的入口,比直接标 Unknown 强得多。这个优先级在生成assets/license.json时就已经确定,UI 层只需要展示。

4.3 大结果集的分批回传,避免 MethodChannel 卡死

早期我把所有许可证一次性result.success(licenses)返回,在小工程里没问题。后来接的一个项目依赖数量超过 180 个,其中有两个库的 LICENSE 文件很长(GPL 全文上百万字节),整包 JSON 序列化后接近 3MB。Dart 侧接收用了快两秒,页面出现明显白屏。

方法很简单,做分页。MethodChannel 增加两个参数:pageIndex和pageSize,每次返回一页数据,最外层再带一个total,Dart 侧根据 total 决定是否继续请求下一页。我按每页 50 条拆分,单次传输体积控制在 200KB 以内,耗时降到了 300ms 左右。代价是 Dart 侧要多写几行异步聚合逻辑,但值得。

5. 用真实工程跑通全流程:测试、验证与补丁

5.1 最小验证工程的设计

适配写完,最重要的不是直接塞进大项目,而是先做一个最小验证工程。我建了一个空 Flutter 工程,加入三个依赖:一个只声明了 license 字段的纯净库、一个只放 LICENSE 文件的老派库、一个两者都没有的“问题库”。目标很明确:覆盖正常、兜底、Unknown 三条分支。

跑完之后建议逐项核对四类数据:

  • 库名是否正确解析,特别是带@作用域的包名;
  • version 是否有值,缺失时 UI 层是否能正常展示;
  • license 类型是 SPDX 标识还是原文片段;
  • licenseText 是否完整,特别是长文本有没有截断、乱码。

5.2 验收清单与常见错误

我在调通过程中遇到过三个值得记录的坑,后来写进了团队的验收清单:

第一,读取 LICENSE 文件时如果遇到非 UTF-8 编码,String.fromCharCode会产生乱码。鸿蒙大部分 LICENSE 文件是 UTF-8,但有的老库用的是 GBK。我的处理是每次读取后做一次简单的字符校验,如果出现连续替换符,就把整个文件标记为“编码未知”,至少保证流程不崩。

第二,MethodChannel 传 List 时,如果里面每一项是自定义对象,必须先转成Object[]或Map[],不能直接传对象引用。ArkTS 编译器对Map<String, Object>的限制比 TS 严格,我在第一次编译时就被这种类型错误卡了十几分钟。

第三,扫描过程中注意超时控制。Debug 模式下fs.listFileSync在目录很多时可能耗时过久,建议整个扫描过程放在一个异步 TaskPool 里,避免阻塞 Flutter 渲染线程。我在 Module 的main_pages上遇到过一次 UI 卡死,就是因为在主线程同步扫描了大目录。

6. 适配之后还需要长期盯防的三个风险点

6.1 flutter_ohos 版本升级带来的 API 漂移

flutter_ohos 还在快速迭代中,插件注册接口的 import 路径、MethodChannel 构造函数都有可能在某个版本变化。我一开始以为这套适配写完就能扔一边,结果两个月后升级了一次 Flutter SDK,插件直接编译不过。排查下来是 FlutterPluginBinding 的类型定义变了,原本传BinaryMessenger的地方改成了需要自己取。

这类问题的处理方式没有捷径,只能在新版本发布后抽时间跑一遍最小验证工程,让测试用例先替你把接口问题暴露出来。特别是onAttach和onDetach的生命周期,不同版本对资源释放的约束不一样,别等到线上出问题再查。

6.2 三方库许可证更新:扫描结果要能定期重生成

开源库升级后,许可证可能从 MIT 改成 Apache-2.0,甚至某个库新增了依赖。如果你只在发版前手动跑一次脚本,很容易漏。我的做法是在 CI 流程里加一个定时任务,每周自动执行一次许可证扫描,生成结果后对比上一次的 diff,有变化就发通知。

注意,这里的对比不是文件内容 diff,而是结构化对比:库名、版本、license 标识、licenseText 哈希。文本文件哪怕换行符变了也算变更,但实际合规审查不关心这个,所以我会先对 licenseText 做一次 MD5,只报告哈希变化。

6.3 声明页的 UI 交互:license_checker 的 Dart 层还有多少可复用

最后聊一下 UI 层。license_checker 的 Dart 侧并没有完全失效,LicensePage和LicenseDetailsPage是纯 Dart 实现,只要喂给它的数据结构对得上,就能直接在鸿蒙上跑。我在适配时保留了原始页面的样式,只在加载数据时把数据源从它默认的LicenseRegistry换成自己收集的assets/license.json。

如果你的 App 对声明页有定制需求,我的建议是别动源码,直接在应用层包一层,把默认的单个页面替换成 Tab 结构,按“依赖类型”或“许可证类型”分组。这比改库本身的 UI 逻辑好维护,后续升级 license_checker 时也能平和合并。

在鸿蒙生态里做 Flutter 适配,我最大的感受是:大部分坑都在“原生依赖”这一层。许可证扫描这种听起来简单的功能,实际跑起来牵扯到路径差异、格式解析、长文本传输、Release 构建策略。把这些问题写下来,既是给自己复盘,也是给后来者留一条更顺的路。如果你也在做类似适配,不妨从最小链路开始,一路把坑踩完再考虑完整功能。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询