1. 项目背景与核心痛点
1.1 license_checker 在开源合规审计里的价值
先把结论放在前面:License 审计这事,在鸿蒙生态里比在安卓时代更麻烦,也更躲不掉。
一个 Flutter 应用跑起来之后,背后到底依赖了多少个开源项目?如果你从来没系统性查过,多半会被数字吓一跳。我手上这个不算大的应用,只算 pub.dev 上的直接依赖就有 60 多个,再算上它们的传递依赖、原生带进来的开源 C/C++ 库,轻轻松松破百。过去很多团队对这件事的态度是“只要能编译过就不管”,但当你把应用提交到应用市场、尤其是有政企采购或出海需求的时候,开源协议审计几乎是一个绕不开的硬指标,审核方问你要 license 清单的频率远比你想象中高。
license_checker 在 Flutter 生态里干的事情很聚焦:读取项目锁文件和包配置文件,把每个依赖的名字、版本、许可证类型、版权声明汇总出来,再生成一份结构化的开源声明。它解决的不只是“合规检查”这个看似法务的问题,更重要的是避免了人工维护声明文件的灾难——你没法保证每次升级依赖都记得去更新一条记录,但机器可以。我在项目里遇到过最典型的一次事故是:把某个图表库从 1.0 升到 2.0,顺手把依赖里的一个底层压缩库从 MIT 协议换成了 LGPL 协议,声明文件没同步更新,结果审核环节被卡了两天,最后只能人工去翻 changelog 补材料。如果当时就把 license_checker 作为构建环节的一部分,这种问题根本不会发生。
1.2 原库在普通平台上的运行机制
要把一个库挪到鸿蒙上,第一步不是找鸿蒙的 API 文档,而是先吃透它原来是怎么写的。license_checker 的核心逻辑并不复杂,大致分三步。
第一步是建立依赖清单。它通过解析pubspec.lock和package_config.json,拿到当前项目实际使用的所有包。注意,pubspec.lock锁定的是精确版本,package_config.json则记录了每个包在磁盘上的真实映射路径,这两份文件共同构成依赖发现的基础。单独读 lock 文件能知道版本号,但拿不到包的具体位置,所以在很多实现里两者都要读。
第二步是识别许可证。每个 Flutter 包安装时通常会带LICENSE或LICENSE.txt文件,库会读取这些文本,再和内置的许可证模板做比较。比较并不是简单的字符串相等,而是要做大小写归一化、空白归一化,否则版权声明里的格式化差异会导致识别失败。内置模板基本覆盖 MIT、Apache-2.0、BSD-3-Clause 这些主流协议,但遇到一些冷门协议或者自定义许可证时,就很容易走到“无法识别”的分支。
第三步是报告生成和展示。库提供两套出口:一套是命令行工具,可以把结果导出成 Markdown、HTML 或 JSON 文件;另一套是应用内展示页面,用户可以在“关于”里查看完整的开源声明。对于大多数团队,前者是给审计和交付用的,后者是给终端用户看的。
这套机制在 Android、iOS、macOS 等常规平台都很成熟,开发者只需要在pubspec.yaml里加上依赖,调用一个 API 就能拿到列表。但问题是,它隐含预设了一个条件:dart:io可以随意读取当前工作目录或项目根目录的文件,资源加载也可以依赖rootBundle。这个假设在一套新操作系统上往往不成立,尤其是鸿蒙的沙箱体系下。
1.3 鸿蒙平台上暴露出的三类主要问题
我们的应用要上架鸿蒙(HarmonyOS NEXT),把原有 Flutter 工程迁移到鸿蒙专有引擎上编译。跑起来之后,license_checker 直接原地爆炸,问题集中在三方面。
第一是文件路径失效。原库在解析依赖时用了大量绝对路径拼接,比如File(rootPath + '/pubspec.lock'),但鸿蒙应用沙箱里的运行目录和开发机不一致,导致pubspec.lock根本找不到。更麻烦的是,哪怕找到了pubspec.lock,里面的包路径仍然是开发机上的绝对路径,直接拿过去解析就会读到不存在的目录。这个问题看日志时最迷惑,因为异常信息只是一个普通的FileSystemException,完全不提是什么原因导致的。
第二是平台通道缺失。原库的导出功能在移动端通常会借助系统的文件分享面板,这部分底层走的是 Flutter 的 MethodChannel 调用原生代码。鸿蒙侧如果没有插件提供对应的平台实现,一调用就会收到MissingPluginException。这类异常不像空指针那么好排查,因为它在 Dart 层往往表现为某个 Future 永远无法完成,或者直接抛错后才浮现出真面目。
第三是资源打包路径不统一。在 Android 上,Flutter 资源被打进 APK 的 assets 目录,rootBundle能稳定读取;鸿蒙上资源归档到 HAP 包内,路径前缀和读取方式与 Android 存在差异。license_checker 某些版本会内置一些辅助数据文件,加载行为不完全一致,需要我们做兼容处理。
这三个问题不是孤立的,改一个地方往往会牵出另外两个。所以动手之前,我先把整个适配方案理了一遍,而不是急着改代码。
2. 鸿蒙适配的整体规划与技术选型
2.1 适配目标与改造边界
拿到一个三方库要跨平台移植,第一件事不是改代码,而是划定改造边界。我们的目标是:在鸿蒙平台上,license_checker 达到和 Android 相同的可用性。具体拆成四个小目标:
- 能扫描出鸿蒙工程中 Flutter 依赖的完整清单;
- 能识别出每个依赖对应的开源许可证;
- 能生成 Markdown 和 JSON 两种格式的声明文件;
- 能提供应用内展示和导出能力。
其中前三个是必须的,第四个可以根据鸿蒙生态成熟度打折。我们的原则很简单:能用纯 Dart 解决的就用纯 Dart,解决不了的才去桥接 ArkTS 原生层。这个原则听起来很理所当然,但实际操作中很多人会走反:一开始就试图去鸿蒙工程里实现各种原生插件,结果越做越复杂。实际上 license_checker 的核心能力百分之九十都可以在 Dart 生态内完成,真正需要碰原生层的只有文件分享这类交互功能,而那部分可以降级处理。
2.2 平台识别与代码隔离方案
license_checker 原本只识别 Android、iOS、macOS 等常规平台,整个代码里没有保留鸿蒙的分支,也不能直接加一个Platform.isOhos,因为鸿蒙适配版 Flutter 引擎没有暴露这个常量。我们采用编译期标记的方式:
const bool isOhos = bool.fromEnvironment('OHOS', defaultValue: false);然后把所有平台相关逻辑收拢到一个独立文件platform_adapter.dart里,内部根据isOhos选择不同的实现。这样能确保原有平台不受影响,也方便后续抽成独立插件。
选择这条路而不是大量加if (Platform.isLinux)这类运行时判断,是考虑到两点。一是鸿蒙的 Flutter 适配版还没有完全对齐上游,运行时嗅探系统特性容易拿到错误信息,而且误判概率比预想的高;二是编译期变量在发布时就被编译器树摇掉,不会产生任何运行时开销,也不会在日志里暴露平台差异。
2.3 依赖管理策略:fork 一份私有维护分支
改造后的代码不能直接发布到 pub.dev 上作为正式版本,因为它依赖了鸿蒙专有的编译参数,放在公开源上会让其他平台用户莫名其妙。我们的做法是 fork 一份仓库,在独立分支上维护鸿蒙适配,然后通过 Git 依赖串进工程:
dependencies: license_checker: git: url: https://gitee.com/your-team/license_checker.git ref: ohos-support使用 Git 依赖唯一要注意的是版本锁定。Git 依赖不会自动遵守语义化版本,同一个 ref 如果被强制推送更新,团队里其他人拉到的代码可能就不一样了。我们的做法是固定 commit 而不是分支名,改代码时主动更新 commit 并同步到工程配置里。这样虽然多一步操作,但稳定性大幅提升。
3. 核心改造流程与实操要点
3.1 第一步:重构依赖发现模块
原库的入口是LicenseChecker类,构造时会接收一个路径参数。改造前它默认读取调用方传入的绝对路径;鸿蒙下我们不能信任这个路径,改为优先从包的package_config.json中提取rootUri来定位工程根目录。
这里有一个很关键的细节:package_config.json里的 rootUri 是相对路径,需要以自己的所在目录为基准再做一次拼接。原库解析时直接用 Dart 的Uri解析,鸿蒙适配版引擎对相对 URI 的处理与桌面平台有细微差异,所以我们干脆改成了手动路径拼接,避免在不同实现上踩坑。
代码层面,我重写了_resolveProjectRoot:
Future<String> _resolveProjectRoot() async { if (isOhos) { final config = await _findPackageConfig(); if (config != null) { final uri = config.uri.resolve('.').toFilePath(); if (_dirExists(uri)) return uri; } // 兜底:从当前可执行文件目录往上找 pubspec.yaml return _searchUpwardForPubspec(Directory.current.path); } // 原有逻辑保持不变 return _legacyProjectRoot(); }这段代码里最值得注意的就是_searchUpwardForPubspec这个兜底函数。鸿蒙的沙箱目录层级很深,直接使用Directory.current往往指向应用的可执行目录而不是工程目录,所以需要向上搜索pubspec.yaml,找到第一个包含该文件的目录就停下来。这个逻辑让同一个工具既能服务开发期扫描,也能服务运行期展示,两种场景下都稳定。
3.2 第二步:许可证识别的兼容处理
许可证识别部分总体稳定,但我们做了三处增强,全部是为鸿蒙场景准备的。
第一处是增加了缺失 LICENSE 文件的兜底。部分鸿蒙适配的 Flutter 插件打包后,LICENSE 文件没有被打进 asset,导致读取为空。我们增加了远程仓库兜底:根据包名和版本从 pub.dev 的 API 拉取 metadata,匹配许可证字段。如果 pub.dev 也没有记录,就标记为“待人工确认”,而不是直接跳过。这样虽然不能自动完成全部审计,但至少不会让问题隐藏在流程之外。
第二处是统一文本归一化规则。原库计算的相似度阈值是 0.8,在鸿蒙侧遇到部分中文本地化修改过的许可证文本时,误判率明显上升。我调整了算法:先去 BOM 和所有不可见字符,再做小写化和关键词权重比对。MIT 和 BSD-3-Clause 这类结构相似的协议,重点区分版权声明占位符,降低了“识别成了但实际不对”的概率。
第三处是缓存位置的调整。识别结果需要缓存起来,避免每次启动都重新读取和比对几十个包的 LICENSE 文件。原库缓存到临时目录,但鸿蒙沙箱对临时目录有清理策略,应用随时可能被系统回收缓存文件,所以我们把缓存放到了应用支持目录。实测下来,配合path_provider的鸿蒙适配版,二次扫描的速度基本在 200ms 以内,体感和 Android 没有差别。
3.3 第三步:声明文件生成的模板与输出
声明文件是审计工作的最终产物,格式直接影响后续的法务和审核效率。原库的 Markdown 模板相对简单,我们基于鸿蒙上架要求做了增强,每个依赖输出为一个表格行:
| 依赖名 | 版本 | 许可证 | 版权信息 | 来源地址 |
|---|---|---|---|---|
| flutter_secure_storage | 9.2.2 | MIT | Copyright (c) 2019 German Saprykin | https://github.com/mogol/flutter_secure_storage |
生成逻辑依然是纯 Dart 实现,模板单独放在assets/license_template.tmpl文件里,用字符串替换的方式填充数据,不依赖任何模板引擎。这样做的好处是减少依赖数量,避免把更多第三方库带进鸿蒙适配范围;坏处是模板语法非常原始,一旦要加复杂逻辑就得在代码里拼字符串。目前对我们来说足够用。
JSON 输出也保留了,这是给程序用的。CI 脚本在比对声明文件时,只需要解析 JSON 判断依赖集合和许可证类型是否发生变化即可,不需要解析 Markdown 表格。
3.4 第四步:应用内展示与导出交互
应用内展示这部分,原库提供showLicensePage风格的页面,本质上就是根据解析结果渲染一个 ListView。鸿蒙适配版 Flutter 对基础控件渲染的支持足够,这部分改动很小,只需要在页面初始化时传入我们改造过的数据源即可。
真正的坑在导出按钮。Android 上的分享面板依赖原生实现,鸿蒙上没有现成的插件可以直接调。我们最终采用了降级方案:点击导出时,把声明文件保存到应用文档目录,同时弹出一个对话框告诉用户文件的绝对路径,并额外提供“复制到剪贴板”的按钮。这个方案不惊艳,但完全规避了平台通道带来的不确定性,用户也能通过系统的文件管理访问文档目录。
如果你非要保留原生的导出面板体验,也不是不能做,需要在鸿蒙工程里自己注册一个 MethodChannel,接收 Dart 侧传过来的文件路径,然后调用系统的文件分享能力。这个工作量不小,而且需要同时维护 Dart 和 ArkTS 两边的代码,收益却有限,我不建议一开始就投入进去。
3.5 上架前最后一步:把声明文件放进 HAP
鸿蒙的 HAP 打包和 Android 的 APK 有区别,静态声明文件不应该只放在 Flutter asset 里,因为有的审核流程会要求它出现在工程原始资源中。我们最终把生成的声明文件放进ohos/app/src/main/resources/rawfile目录,这样它会被完整打进 HAP,业务代码可以随时通过资源管理器读取。
这一步如果漏了,最直接的表现就是审核人员反馈说“找不到开源声明文件”,而你本地明明已经生成过了。由于打包产物在真机上的路径不容易直观查看,这个问题排查起来特别费劲,建议在 CI 流程里加一个专门的归档步骤,把声明文件和 HAP 放在一起交付,避免靠人肉确认。
4. 常见问题与排查技巧实录
4.1 MissingPluginException:平台通道没有一个实现
这个异常在鸿蒙移植初期出现的频率最高。很多人一看到异常就到处搜鸿蒙的 MethodChannel 文档,其实最有效的步骤是先判断这个调用到底是不是必需的。我们的经验是:能绕开的先用纯 Dart 绕开。比如之前提到的分享导出功能,改成本地保存加剪贴板,三行代码解决问题,比去实现一个新的 ArkTS 方法快得多。
如果确实需要调用原生能力,比如读取系统信息,那就要检查插件的ohos目录下是否真的有原生代码。常见的坑是:插件在 pub.dev 上声称支持鸿蒙,实际上只是把 Android 代码原样放在仓库里,没有对应的鸿蒙实现。这种情况下,再怎么配置依赖都无济于事,只能换方案或者自己补。
4.2 Directory listing failed:沙箱目录读取权限不足
我们在真机上遇到过一种情况:Directory.current明明存在,但调用list()时返回权限错误。原因是鸿蒙应用默认沙箱的可读范围是受限的,不是所有目录都能随便列目录、读文件。解决办法是不要依赖当前目录,而是通过getApplicationSupportDirectory()这类能拿到真实沙箱路径的 API。
另一个相关细节是:如果应用确实需要读取公共存储目录下的文件,必须在module.json5里声明对应权限,并且运行时动态申请用户授权。license_checker 本身不需要在运行时读取公共目录,所以我们在适配中直接不碰这些权限,减少审核风险。
4.3 pubspec.lock 与 package_config.json 不一致
多平台开发时,一个工程可能同时维护 Android 和鸿蒙两套构建配置。我们出现过pubspec.lock里记录的版本和package_config.json指向的包不一致,导致许可证识别结果混乱。排查下来发现是团队有人直接改了 pubspec.yaml 但没有重新执行flutter pub get,锁文件暂时是旧内容。
这类问题靠代码很难兜住,最终我们在 CI 里加了一步校验:每次扫描前先对比两份文件的依赖集合,如果差异超过一定阈值就直接报错,提醒开发者执行flutter pub get。这个校验逻辑写起来很简单,但真的能省下不少看日志的时间。鸿蒙适配版 Flutter 引擎对锁文件的洁癖程度比官方版更高,一旦不一致,报错信息还经常是无关紧要的警告,容易误导排查方向。
4.4 中文内容乱码与模板转义
另一个看起来不严重但实际很折腾的问题是乱码。鸿蒙的编译工具链在部分环境下默认的字符集不是 UTF-8,导致生成的声明文件里中文字段变成一串问号。排查到最后发现不是代码的问题,而是 CI 的工作流里没有设置环境变量。
解决办法很朴素:在生成文件的写入代码里强制指定utf8编码,同时确保传入的字符串本身是标准 Dart String,不要夹杂字节流。下面这个写法是安全的:
final content = _renderLicenseMarkdown(); await File(path).writeAsString(content, encoding: utf8);如果是在 Windows 上,要额外注意终端脚本的编码设置,否则同样的代码在不同机器上行为完全不同。我们用 GitHub Actions 跑 Linux 环境没有遇到问题,但本地 Windows 开发者复现乱码时,最终定位到 PowerShell 的默认编码不是 UTF-8。
5. 自动化集成与后续扩展
5.1 把审计流程挂进 CI
手工跑一次 license_checker 只能算临时检查,要让合规审计持续生效,必须把它挂进 CI。我们用的流程是:每次提交 Pull Request 时,在工作流里跑一次命令行模式,生成最新的声明文件,然后和仓库里的旧文件比对,重点关注三个维度:
- 依赖集合是否变化(新增、删除、升级);
- 新增依赖是否已经通过许可证识别;
- 是否存在许可证状态为“待人工确认”的依赖。
一旦有未确认项,CI 会直接卡住合并按钮,并把报告发到团队沟通群里。刚开始大家觉得这有点严格,但第一个月就拦下了三个许可证变更,体验过之后都认可了这套机制。
下面是 CI 步骤里最核心的命令:
dart run license_checker export --format markdown --output THIRD_PARTY_LICENSES.md dart run license_checker check --strict第一条命令负责生成,第二条负责校验。在--strict模式下,只要有任何依赖未识别或识别不确定,进程就返回非零退出码,流水线自然中断。需要注意,命令行模式对鸿蒙工程也有效,因为命令行的运行环境和真机沙箱不同,目录可访问性反而更好。
5.2 与鸿蒙应用市场审核流程衔接
声明文件生成好之后,具体放在哪里、怎么写,不同审核渠道的要求有细微差别。以我们对接的情况来说,普遍要求是在应用内提供一个“开源软件声明”的入口,用户点击后能看到完整的协议列表。
为了稳妥,我们把声明文件同时做成三个副本:一个放进rawfile随 HAP 打包,一个挂在应用内的“关于页面”里渲染,还有一个放到项目文档目录用于审计时交付。前两个是市场审核实际会检查的,第三个是给法务和交付团队留存的。整个过程在 CI 里一次性完成,不需要人工介入。
这里强烈建议不要在 UI 层面用 WebView 渲染声明文件,鸿蒙适配版的 WebView 和 Android 有差异,部分 API 行为不同,直接用原生的 ListView 或 Markdown 渲染组件更可靠。既减少依赖,又降低审核风险。
5.3 下一步可扩展的两个方向
完成基础适配之后,还有两个可以继续深挖的方向。
第一个是接入更标准化的报告格式。目前我们用的是自研的 Markdown 和 JSON,后续可以考虑输出 SPDX 文档或 CycloneDX SBOM 格式,这样审计结果能直接被专门的合规工具读取,跨平台复用价值更高。尤其是面对政企客户时,他们往往有自己的软件物料清单收集系统,一份标准格式的报告能省去大量的表格转换工作。
第二个是针对鸿蒙原生依赖做更深层扫描。Flutter 插件经常会带入 C/C++ 底层库,这些库的许可证信息不在pubspec.lock里,需要借助鸿蒙构建系统的产物去分析。这项工作比 Flutter 层适配更繁琐,但也是政企项目中审查方最在意的一块。我们的计划是在现有命令行工具里增加一个--scan-ohos-binary参数,专门扫描.so文件的元信息和许可证头,目前还在验证阶段。
这两个方向我们只完成了第一个的调研。如果你也在做同类工作,建议先解决“能用”,再追求“好用”,不要一上来就铺开大改,毕竟 license_checker 的核心价值在于稳定和省心,而不是功能炫技。
我在这次适配中还有一个小技巧值得分享:调试阶段把-DOHOS=true写进 VS Code 的 launch.json,这样本地调试和真机调试走同一套鸿蒙逻辑,不会出现“开发机正常、真机异常”的割裂。整个改造过程用了不到两周,真正耗时的地方不在代码量,而是在识别那些“原本以为理所当然”的路径和权限假设。库本身不复杂,复杂的是它所立足的平台生态变了。