
最近在做 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.errorArkTS 对跨语言传对象有限制最简单的方式就是传 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.json5license 字段缺失时走兜底扫描到模块目录后光有 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 Recordstring, 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每次返回一页数据最外层再带一个totalDart 侧根据 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 编译器对MapString, 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 构建策略。把这些问题写下来既是给自己复盘也是给后来者留一条更顺的路。如果你也在做类似适配不妨从最小链路开始一路把坑踩完再考虑完整功能。