前阵子我们把一个基于 Flutter 的内部工具应用迁到鸿蒙设备上其他页面都没事偏偏资源扫描模块出了问题。assets_scanner 在 Android、macOS 上跑了快一年一上鸿蒙环境就行为怪异目录列表扫不全、路径返回空、偶尔还直接抛异常。排查到后面才意识到这不是库的扫描算法有 Bug而是它对文件系统长什么样的假设在鸿蒙运行时下不成立了。这篇文章就把我们做鸿蒙化适配的完整过程拆开来讲assets_scanner 依赖了哪些原生能力、适配层怎么设计、四个关键改造点、我们踩过的五个坑以及怎么用回归验证让自己确信这事真改完了。适合正打算把 Flutter 三方库迁移到鸿蒙生态的开发者参考也适合做资源扫描类跨平台改造的团队借鉴。1. 一个 File.list 引发的适配事故鸿蒙运行时到底改了什么1.1 assets_scanner 的正常环境假设assets_scanner 这类资源扫描库表面上干的事情很简单给一个根目录路径递归遍历目录树把文件路径、大小、扩展名、内容哈希取出来生成资源索引。但它之所以快、之所以稳是因为它隐式依赖了所在平台的三个特性。第一文件系统路径规则稳定。POSIX 路径和 Windows 路径各自统一库内部可以放心地做字符串拼接、目录分隔符处理、相对路径归一化。第二应用对文件系统的可见范围符合预期。在 Android 上拿到存储权限后可以扫外部存储在 macOS 上放开沙箱也能访问大部分目录库只需要一个根路径就能放心深入。第三目录遍历和文件状态读取的开销可控正常的Directory.list调用不会因为文件系统挂载点特殊就返回诡异结果也不会被平台层的权限机制静默拦截。这三个假设放在 Android 和桌面环境上很稳定但到了鸿蒙运行时情况全变了。应用默认只能稳定访问自己沙箱内的文件外部目录要走文件授权选择器让用户挨个勾选同时鸿蒙的 Flutter 引擎对底层文件系统的路径语义做了自己的归一化处理。你在 Dart 侧传一个/data/app/xxx路径底层可能根本不认识或者返回一个看起来合法的空目录。这个合法但为空最坑人。我们第一轮排查时发现 assets_scanner 的遍历函数正常返回了但list结果永远是空数组于是拼命查权限、查目录是否存在完全没想到是路径语义压根没对上。后来在适配层里加了一行路径打印才发现传入的根路径在被底层处理时已经悄悄变了。1.2 鸿蒙生态对三方库的兼容粒度鸿蒙对 Flutter 三方库的兼容不是只要写过 Dart 就能跑这么简单而是分层的。纯 Dart 逻辑、只依赖dart:core和dart:async的库通常没问题一旦用了dart:io、dart:ffi、MethodChannel或Platform接口就得逐个过。assets_scanner 恰好把这几样沾了个遍dart:io里的File、Directory、FileSystemEntity是它的主食MethodChannel在部分版本里用来查系统信息还有一处用Platform.resolvedExecutable去推导 assets 根目录。这里给一个我自己的分层判断表方便你评估手头的库dart:core/dart:async/ 纯算法层风险极低基本原样运行。dart:ioFile、Directory、Socket 等中高风险重点检查路径语义、符号链接、权限行为。dart:ffi高风险需要确认是否依赖特定 ABI 或原生库特性。MethodChannel/EventChannel中高风险鸿蒙侧要用自己的原生实现响应通道注册方式与 Android 有所不同。assets_scanner 属于dart:io中高风险 Channel 低风险的组合所以它出问题的方式不是编译不过而是运行时行为诡异。这也是三方库鸿蒙化适配最典型的形态——不是把源码翻译一遍而是补上环境契约这一层。2. 先把库拆成两层资产索引与文件访问别混在一起2.1 解耦思路让索引逻辑不再触碰文件系统动刀之前我们先把 assets_scanner 的代码按职责重新画了一遍边界。扫描类工具看起来是一个整体但内部至少有两条完全不同的逻辑链一是怎么找到文件、怎么读出元数据二是拿到这些数据之后怎么组织索引、怎么去重、怎么校验资源引用。原来的代码把这两件事揉在了一起遍历到一半就开始拼索引结构中间还夹着一堆if (Platform.isAndroid)之类的分支。这种写法在单一平台上没问题一旦要同时支持 Android、Windows、macOS、鸿蒙就会变成一张补丁摞补丁的网。我们的做法是先解耦成两层AssetIndexer纯 Dart 层只处理已经采集到的资源条目负责索引结构、统计、去重、引用校验。它不认识任何路径语义也不知道文件系统长什么样。FileSystemAdapter平台适配接口负责目录遍历、文件读取、属性查询、路径归一化。每个平台只实现一个薄薄的 adapter索引逻辑完全不需要动。接口可以抽象成下面这个样子abstract class FileSystemAdapter { FutureListFsEntry listDir(String path); FutureFsStats stat(String path); FutureListint readChunk(String path, int offset, int length); String normalizePath(String path); String get rootPath; }FsEntry、FsStats是跨平台的自定义数据类型里面只放字段不带任何平台实例。这样一来鸿蒙化适配就变成了一件很具体的事为鸿蒙运行时写一个HarmonyFileSystemAdapter而不是去改索引算法。我们后来发现凡是直接改 assets_scanner 核心代码的尝试最后都会在新的边界条件下再次崩掉。2.2 统一资源路径抽象虚拟资产路径与物理路径映射解耦之后还有一个隐含问题必须解决——路径表示。同一个资源目录在 Android 上可能是/storage/emulated/0/project/assets/在 macOS 上是/Users/xxx/project/assets/到了鸿蒙沙箱里又变成了一长串带应用 ID 的路径。如果索引表里存的是物理路径那么同一份资源索引在三个平台上就会得到三份不同的结果增量缓存也无法复用。我们引入了一个虚拟资产路径的概念。上层 API 统一只认/assets/xxx这种路径adapter 负责把虚拟路径映射为当前平台的物理路径。资源索引表里所有条目都存虚拟路径只有真正要读文件的那一刻才交给 adapter 去解析。映射规则看起来简单但有三个细节很容易漏。一是根目录的识别虚拟路径的根从哪里来我们规定rootPath由 adapter 在初始化时探测并固定索引器每次做路径拼接都基于它不自己猜。二是结尾斜杠的处理/assets/和/assets在不同平台下拼接结果可能不同adapter 里要统一规范化后再拼。三是 URI 编解码文件路径里如果带中文或空格Dart 的File.uri会把它们百分号编码索引表里存编码前的明文路径还是编码后的 URI必须全库统一。我们选择统一存编码前的明文路径只在 adapter 真正访问文件系统时临时编码。这套虚拟路径抽象后来成了所有跨平台功能的地基。增量扫描、资源引用校验、构建集成全都只认虚拟路径平台差异被彻底关在 adapter 这一层里。3. 从文件读取到路径归一化改造 assets_scanner 的四个关键点3.1 文件遍历接口用探测加降级替代直接递归原库的核心遍历逻辑是Directory.list(recursive: true)一把梭。在 Android 上这条路通但鸿蒙运行时下部分目录的list行为不稳定有的返回空有的抛FileSystemException有的扫到一半直接卡住。我们没有在鸿蒙上硬修Directory.list而是把遍历改成三层降级策略。第一层先尝试系统自带的递归列表接口看能不能拿到非空结果第二层如果返回空或异常切成逐层手动遍历也就是先列当前目录再对每个子目录递归调用listDir第三层如果还是失败就按入口目录探测表逐个候选根路径试找到能读出内容的路径才继续。判断依据很简单你没法在纯 Dart 层预知底层文件系统语义对不对只能让 adapter 自己试探然后按结果降级。这个思路在鸿蒙上尤其重要因为不同设备、不同系统版本对文件系统的处理可能都不一样硬编码一种行为早晚要踩雷。3.2 沙箱与权限归一化库内不弹权限框assets_scanner 原来在 Android 上会隐式假设调用方已经申请了存储权限甚至内部有一段引导用户授权的外部存储逻辑。这在 Android 上勉强合理但鸿蒙的权限模型不太一样应用默认只能稳定访问自己的沙箱外部目录需要用户通过文件选择器逐次授权。我们统一把权限模型改成了外部传入已授权根目录集合。意思是assets_scanner 自己不再申请任何权限也不弹任何授权框而是由宿主应用在合适时机获取授权然后把根路径列表通过 API 传进来。库内部只处理一件事在这些根路径下做扫描。这样改有几个好处第一权限获取逻辑回归宿主应用每个平台的 UI 流程差异不会污染扫描核心第二assets_scanner 不再因为缺权限而抛奇怪的异常拿不到根目录时就返回一个明确的状态码第三鸿蒙化适配时我们只需要让 adapter 正确理解已授权目录的路径形式不需要去动系统权限 API省掉了非常多麻烦。3.3 元数据提取与内容指纹不要完全信任 mtime资源扫描要提取的关键元数据无非是大小、修改时间、扩展名、内容指纹。在鸿蒙环境里前几个基本正常但File.length和stat在大文件上偶尔会返回不准确结果尤其是那些由系统文件管理服务提供路径的目录。我们干脆做了一个规则所有索引条目都以内容指纹 大小作为核心字段mtime 只作为辅助参考。内容指纹分两档。默认用 CRC32 快速指纹适合绝大多数资源需要强校验的场景比如发布前资源完整性检查再切换成 SHA-1。指纹计算过程中大文件不能一次性读进内存我们按 64KB 分块流式读取逐块累加哈希。这个细节在鸿蒙设备上尤其重要——某些目录挂载点的 IO 性能比 Android 低一次性读一个几十 MB 的文件很容易触发内存抖动。为什么不能完全信任 mtime因为很多资源是被打包工具、同步工具、版本控制工具写入的它们会保留甚至改写时间戳。我们用内容哈希兜底至少保证文件真的变了这件事能被索引层感知到。3.4 增量扫描与缓存失效缓存文件只存虚拟路径全量扫描在资产目录大的时候非常难受几千个文件扫一次要好几分钟。所以我们给 assets_scanner 加了增量扫描第一次全量扫描后把索引结果落盘之后每次启动先读缓存只对变更目录做增量更新。缓存失效策略是这里最容易做错的地方。我们最初把缓存 key 设计成物理根路径 适配器版本号结果跨平台一跑就出问题物理路径完全对不上。后来改成三层检查虚拟根路径指纹、文件系统类型标识、adapter 版本号三者任何一个变化都强制重建缓存。缓存文件本身只存虚拟路径、相对路径、内容哈希、大小、扩展名不存物理绝对路径。加载缓存时当前 adapter 会基于自己的rootPath重新拼接物理路径。这个设计从源头解决了跨平台缓存污染问题也让我们在鸿蒙上的缓存复用率达到了预期。另一个细节是缓存文件要写到应用缓存目录不能随便放沙箱外部鸿蒙下尤其如此。4. 我们踩过的五个坑以及完整的排查链路4.1 中文资源名乱码漏扫索引里少了一堆文件第一次在鸿蒙设备上跑出结果后我们拿索引条目数和 Android 的基线对比发现少了大约三百条。起初以为是权限问题后来发现少掉的文件名几乎都带中文。排查链路是这样的先在适配层打印FileSystemEntity.uri发现中文被百分号编码了看起来正常再直接打印entry.path的原始字节才发现底层返回的路径在字节解码时先被解码成了非法 UTF-8 序列Dart 侧拿到 String 后长度都对不上导致一批文件被当成脏数据丢弃。修复方式是在 adapter 层统一做字节级路径解码优先按 UTF-8 解析遇到非法序列再用系统 locale 或 GBK 作为 fallback解码失败的文件单独记入异常列表而不是直接丢掉。这个坑给我的教训是路径问题不能只看 String 层很多隐蔽的漏扫都是字节编码造成的。4.2 符号链接把扫描器拖入死循环同样的路径反复出现第二个坑更隐蔽。某次扫描直接卡死进程 CPU 飙高日志里同一个路径出现了上百次。加了递归栈深度日志之后才看清楚是符号链接循环鸿蒙沙箱里有预置目录指向了父目录Directory.list(recursive: true)顺着链接走下去永远出不来。修复策略是默认不跟随符号链接。适配层的listDir在遇到FileSystemEntityType.link时先readlink读取目标如果目标已经出现在当前遍历祖先链里就直接跳过同时给递归深度设了一个上限超过就记录一条警告并停止该分支。后来我们检查了 Android 和桌面平台发现原库在那些环境里同样存在这个隐患只是一直没有触发。适配鸿蒙反而帮我们把存量隐患挖了出来。4.3 上万文件扫描时的内存抖动一次性 List 撑爆堆当资源量到一万多个文件时鸿蒙设备上的应用开始频繁卡顿Dart VM 的内存曲线像过山车。定位后发现原库会把所有FsEntry一次性塞进一个 List再统一排序、建索引高峰期光对象实例就有好几万。我们改成流式遍历适配层用async*生成器逐条产出条目索引层边收边处理每批最多积累 128 条就 yield 一次让出事件循环。排序也从全量内存排序改成分批归并。改造后内存峰值降了一半以上扫描一个大目录再也不会把应用卡到让人想砸手机。4.4 缓存文件跨平台串味安卓生成的缓存在鸿蒙上能读但数据错我们当时觉得缓存方案已经够稳妥了直到把 Android 上生成的索引缓存丢到鸿蒙设备里测试发现能正常加载但条目里的资源路径全是错的。排查链路是先看缓存文件结构发现里面存了物理绝对路径和旧 adapter 版本号再看加载逻辑发现加载时只校验了文件存在没校验路径形式是否符合当前平台。修复就是前面说的那套方案缓存只存虚拟路径和相对路径加载时由当前 adapter 重新映射。从这件事之后我们给所有平台敏感数据定了条铁律——落地文件里永远不写物理路径。4.5 并发遍历一句 too many open files 直接崩溃为了提高扫描速度我们加过一段并发列表逻辑默认 8 个并发同时打开文件读哈希。鸿蒙设备上跑到一半直接抛too many open files。原因很直白文件句柄是有限资源并发数开太大加上某些文件的读取没有及时关闭句柄很快就触碰系统上限。修复分两步。第一步全局限制并发数默认降到 4并且用信号量控制任何时刻最多同时打开这么多文件。第二步所有文件读取改成with作用域或try/finally保证句柄必然释放。更关键的是我们给适配层加了运行时检测一旦捕获到句柄耗尽类异常就自动降级为串行遍历不再继续压榨并发。实测下来串行虽然慢一点但至少扫描能跑完不会因为句柄问题半途而废。5. 让适配结果可验证资源发现率、耗时与构建集成5.1 回归对照怎么才算适配好了适配改完不是说跑通一次就结束得有一套可量化的回归标准。我们当时设计了五个对照维度每个维度都有明确的操作方法资源发现率准备一个固定资产集包含中文名、超长文件名、空文件、嵌套子目录等边界样本分别用鸿蒙和基线环境跑扫描对比两边生成的索引条目集合是否完全一致。不一致的条目就是漏扫或错扫的直接证据。扫描耗时清空缓存后在同一台鸿蒙开发设备上连续跑三次取中位数和适配前对比。内存峰值通过 Dart VM 的内存打点观察 GC 前后的堆占用重点看大目录扫描阶段有没有异常尖峰。缓存命中率连续两次扫描同一目录统计第二次有多少条目直接命中缓存不重新读文件。错误计数每次扫描记录异常条目的数量和类型比如解码失败、符号链接跳过、读取超时全部归零才算通过。我们内部给这五个维度做了一个简单的验收门槛资源发现率必须 100% 与基线一致错误计数必须为 0内存峰值不能高于适配前 20%耗时不能慢于适配前 50%。达不到就继续调直到满足为止。5.2 把资源扫描塞进构建流程自动化才有人愿意用适配完成只是第一步资源扫描这种东西如果只能手动跑过两周就没人用了。所以我们把 assets_scanner 整合进了构建流程做成一个可执行的自检命令dart run assets_scanner \ --rootassets \ --outputbuild/resource_index.json \ --fail-on-missing规则就三条。第一每次构建前先做一次增量扫描把资源索引和上次的对比输出新增、删除、变更列表第二代码里被引用的资源 key 必须能在索引里找到找不到就直接构建失败避免资源引用悬空第三每天跑一次全量扫描检查资产目录里有没有意外丢进来的大文件或者敏感文件。这套自动化跑了两周帮我们发现了好几个问题有人把设计稿 PNG 直接丢进了 assets 目录、有资源文件被同事误删但没人注意、还有一次是文件名编码问题在 CI 上原形毕露。机器扫描可比人工 review 靠谱多了。6. 补一个调试鸿蒙侧扫描的小手法最后说一个不完全属于适配、但调试非常好用的小技巧。鸿蒙环境下Dart 层打印的异常信息有时掩盖了底层文件系统的真实错误尤其是权限和路径映射失败的时候往往只抛一个笼统的FileSystemException。我的做法是在 adapter 里加一个慢日志任何单个目录的遍历耗时超过 500ms就把当前路径、条目数、耗时整体 dump 出来扫描结束后集中输出一份慢目录排行榜。这个设计一开始只是临时排查用后来直接保留成了正式功能。两个最难定位的坑——符号链接循环和句柄耗尽——都是靠这份慢日志先看到异常趋势才顺藤摸瓜找到根因的。如果你也在做类似的跨平台扫描类工具建议一开始就把这层观测能力加上别等出了问题再补。
阅读完成 · 觉得有帮助?