资讯动态

Flutter工程瘦身:dart_unused_files鸿蒙化适配全记录

发布时间:2026/10/13 10:49:17 来源:尧图企业网站定制
Flutter 工程一旦堆到两年以上lib 目录就会变成一间没人整理的旧仓库。旧业务页面、停用的数据模型、调试期临时封装的工具类散落在各个子目录里没人 import 它们但也不敢随手删——怕删错、怕还有隐性引用、怕下次需求要用回来。它们就这么安静地躺在那里持续贡献给每一次全量构建的时间和包体体积。dart_unused_files 这类工具解决的正是这个痛点。它做纯静态分析扫描出所有没有被任何文件引用的 Dart 文件把“不敢删”变成“知道能删什么、什么不能删”。但现在越来越多的 Flutter 工程要适配到鸿蒙环境原版工具直接拿过来是跑不动的。根目录结构变了、依赖解析方式变了、构建产物目录完全不一样工具在标准 Flutter 工程里那套“找 lib 目录、读 package_config、扫描源码”的默认逻辑到鸿蒙工程里直接失效。这篇文章记录我完整做一遍 dart_unused_files 鸿蒙化适配的过程从底层机制拆解到三个关键适配切口再到真实工程里的验证和坑点排查。如果你是 Flutter 工程师、在做鸿蒙多端适配或者只是想把工程瘦身这件事做成可持续的流程这篇内容应该能给你一份可直接照抄的作业。1. 项目背景Flutter 工程的“隐性脂肪”与鸿蒙化的现实痛点1.1 Flutter 代码资产为什么会悄悄膨胀先说说我见过的最典型几种膨胀来源。第一类是业务迭代遗留。页面改版旧的页面文件没有跟着删因为团队通常只做增量开发很少回头清理。第二类是多人协作的产物每个人进项目都会建一套自认为好用的工具类、网络层、基础组件功能高度类似但互相不引用代码评审时也没人敢动它们。第三类是生成器模板模型生成器反复生成新版本旧版本就留在源码里目录结构看起来整整齐齐实际引用关系是一张破网。这些文件带来的代价不是单纯的不美观。它们在 IDE 索引、全量编译、热重载监听里都会产生额外的开销。我见过一个 18 万行的工程通过工具扫描后未被人引用的文件占了源码总行数的 11.6%。也就是说每 10 行代码里至少有 1 行是纯冗余你每次构建都在为它们买单。对于要适配到鸿蒙环境的工程这个问题更值得重视。多端构建意味着同样的 Flutter 代码要在不同工具链下走编译、打包、排错流程冗余文件越多排查问题和交付跨端的成本就越大。工程瘦身不是追求纯粹的“文件少”而是降低整个工程资产的认知负载和构建开销。1.2 手动删除为什么不靠谱你可能说不用工具也行我用 IDE 的全局搜索搜一下文件名没人引用就删。这个做法在简单项目里可行但到一定规模后就会失效。Dart 里的引用关系不只是import xxx.dart这种一眼可见的写法。有的文件通过相对路径被引用有的通过 package 路径被引用还有的在export里被重导出从 A 文件 import 不到它但它确实是某个模块对外接口的一部分。更麻烦的是动态加载。Dart 里的await import(pages/xxx.dart)这种字符串形式的动态导入IDE 的全局搜索搜不到对应关系但它确实在运行时会用到这个文件。你要是手动删了回归测试就会给你颜色看。工具的价值在于它用完整的语法解析把所有显式引用关系构建成一张图再告诉你有文件没有进入这张图。它帮你把“删错了就翻车”的风险控制在静态分析的范围内后续只需要人工复核少数动态场景。1.3 鸿蒙化为什么不是换个 SDK 就能跑如果只是 Flutter SDK 的差异适配起来倒简单了。真正的差异在工程结构。标准 Flutter 工程的逻辑很简单pubspec.yaml 在根目录lib 目录也在根目录.dart_tool/package_config.json 由 flutter pub get 生成依赖的所有 package 路径都记录在里面。dart_unused_files 默认就是基于这套约定从当前工作目录向上找 pubspec找到后推导 lib 路径再读取 package_config 来做 URI 解析。鸿蒙 Flutter 工程不太一样。因为多了一层模块module的概念Dart 侧源码往往不在工程根目录而是放在类似entry/src/main/flutter/这样的子目录里pubspec.yaml 也跟着移了过去。构建时的生成物目录也有自己的体系比如鸿蒙侧的oh_modules、.ohpm、hvigor等这些目录如果被当成源码目录扫描就会大量误报。原版工具并不知道这些差异。直接运行大概率第一条报错就是找不到 pubspec.yaml或者扫描了一个完全错误的目录。所谓鸿蒙化适配本质就是把这个工具默认的“环境认知”对齐到鸿蒙工程的结构上。2. 核心机制拆解dart_unused_files 是怎么找到“没用文件”的2.1 从文件系统到引用图要适配一个工具先得弄清楚它的核心逻辑。dart_unused_files 的工作流程可以拆成五步整个机制跟搜索引擎里的一些索引构建思路很像。第一步收集候选文件。从源码根目录递归遍历所有子目录筛出后缀为.dart的文件去掉 build 产物、生成代码和用户显式忽略的目录。第二步逐一解析每个文件的源码提取文件头部的指令——import、export、part。第三步是关键把这些指令里写的 URI 字符串相对路径、package 引用、file 协议路径全部解析成磁盘上的绝对路径。第四步构建引用关系遍历所有文件后得到一张“谁引用了谁”的表。第五步做差集计算所有候选文件集合减去出现过的被引用路径集合剩下的就是未使用文件。核心算法思路可以用一段非常精炼的代码表达final allFiles String{}; // 所有候选文件路径 final referenced String{}; // 所有被引用路径 for (final file in allFiles) { final directives parseDirectives(file); // 解析 import/export/part for (final directive in directives) { // 这里会处理相对路径、package: 前缀、file: 前缀等形态 final resolvedPath resolveUri(directive.uri, from: file); referenced.add(resolvedPath); } } final unusedFiles allFiles.difference(referenced);我第一次看这个逻辑的时候觉得这有什么难的不就是两个集合做减法吗但真正动手复现才发现难点全在那个resolveUri上。相对路径要基于当前文件位置做拼接package 路径要查 package_config 里的包映射关系还有符号链接要解析成真实路径。每一步都有边界情况做不好就会把“被引用的文件”误判成“未引用”导致误报。2.2 为什么用 AST 而不是正则匹配你可能会问直接正则匹配import xxx;不就行了吗为什么要引入语法解析因为正则表达式做这个事情会翻车。Dart 源码里的 import 字符串可能出现在注释里比如你写的示例代码可能出现在多行字符串里还有带if条件导入的写法比如import foo.dart if (dart.library.io) io_foo.dart;正则要处理这种形态就非常痛苦。用语法解析AST的好处是Dart 语言规范的边界由解析器保证import、export、part 这些指令是语法层面的概念AST 不会把注释里的字符串当成引用也不会漏掉带条件前缀的导入。这就是工具可靠性的基础。2.3 排除规则和入口文件的特殊处理有了基础算法还不够实际工程里必须处理两类特殊情况。一类是入口文件。比如lib/main.dart它不会被任何库文件 import——它本来就是程序的起点。如果把它算成未使用文件那工具就误报了。所以工具的排除名单里必须支持入口文件白名单。另一类是生成代码。Flutter 生态里*.g.dart这类文件是 build_runner 生成的它们的内容会被自动改写和引用但它们本身的扫描结果往往不稳定。更稳妥的做法是默认跳过生成目录和常见的生成文件名模式让工具聚焦在手工维护的源码上。这些机制原版工具一般是有的但问题是它的默认值面向的是标准 Flutter 工程。鸿蒙工程的入口文件位置不同生成物目录也不同所以在适配时排除规则本身也是需要重构的部分。3. 适配鸿蒙环境的三个关键切口3.1 切口一工程根目录与源码目录的识别逻辑原版工具识别工程根目录的最常见方式是拿当前工作目录往上找 pubspec.yaml。这在标准 Flutter 工程里很有效但在鸿蒙工程的目录结构下就不行了。一个典型鸿蒙 Flutter 工程的目录布局长这样my_ohos_app/ entry/ src/main/ flutter/ pubspec.yaml lib/ main.dart pages/ home_page.dart models/ user_model.dart .dart_tool/ package_config.json oh_modules/ build/ hvigor/Dart 侧源码藏在entry/src/main/flutter下面而工具默认只认当前目录下的 pubspec.yaml。你直接在工程根目录运行它会立刻报错说找不到 pubspec。就算你 cd 进 flutter 子目录再运行工具的路径推导又可能把相对路径算错。适配时我在根目录探测逻辑里加了一个候选列表依次检查已知可能的目录位置// 适配前只检查当前目录 final root Directory.current.path; final pubspecPath p.join(root, pubspec.yaml); // 适配后自动探测多位置 const candidates String[ , entry/src/main/flutter, entry/src/main/flutter/dart, ]; String? detectProjectRoot(String currentDir) { for (final sub in candidates) { final candidate p.joinAll([currentDir, ...sub.split(/)]); if (File(p.join(candidate, pubspec.yaml)).existsSync()) { return candidate; } } return null; }这套改动并不复杂但它决定了工具后续所有路径计算的地基。地基建错后面全错。另外要注意一个仓库里可能有多个鸿蒙 module这种情况下探测到的第一个 pubspec 未必是你要扫描的目标。所以我额外加了一个--project-root参数允许用户跳过探测逻辑直接指定 Dart 侧根目录。参数显式传入时比任何探测策略都可靠。3.2 切口二package_config.json 的解析修正扫描工具做 URI 解析时最大的外部依赖就是 package_config.json。它记录了每个 package 名与磁盘路径的映射关系是package:xxx这种形式引用的翻译手册。标准 Flutter 里这份配置在.dart_tool/package_config.jsonflutter pub get 之后一定会生成。鸿蒙 Flutter 构建环境下情况不太稳定。有些构建流程会把 package_config 生成到别的临时目录有些工程的.dart_tool可能不在 Dart 侧源码目录下而是被统一管理在工程根目录。适配策略是做一个多路径搜索按优先级依次检查final pkgConfigCandidates String[ p.join(projectRoot, .dart_tool, package_config.json), p.join(projectRoot, .., .., .dart_tool, package_config.json), p.join(projectRoot, .dart_tool, package_config.json), ]; String? locatePackageConfig(String projectRoot) { for (final candidate in pkgConfigCandidates) { if (File(candidate).existsSync()) return candidate; } return null; }这里最需要容错的是找不到 package_config 的情况。我在适配时做了一个降级方案如果找不到这份文件就退回用 pubspec.yaml 里的依赖声明做最小映射。虽然不能覆盖传递依赖但至少保证不崩溃。还有一个细节容易踩坑package_config.json 里的 rootUri 字段可能写的是相对路径也可能是以file://开头的绝对 URI。不同工具链生成的文件风格不完全一致解析时必须统一归一化。我见过有人直接把 rootUri 拿去做文件拼接结果 Windows 环境下盘符和 file 协议混合路径翻车。3.3 切口三忽略目录与符号链接扩展标准 Flutter 工程里默认忽略的目录只要覆盖.dart_tool和build基本就够用了。鸿蒙工程里完全不够至少还要加四类oh_modules、.ohpm、hvigor、以及各类本地缓存目录。这些目录如果没被忽略会出现两个问题。第一是扫描效率断崖式下跌——它们里面可能有几百上千个文件第二是产生大量误报——没有源码关系的地方被当成源码目录扫了。适配时我直接把忽略名单做成了可配置的默认值再加一个命令行透传参数--ignore-dirsconst defaultIgnoreDirs String{ .dart_tool, build, oh_modules, .ohpm, hvigor, .idea, .gradle, };另一个问题是符号链接。鸿蒙 Flutter 工程里三方库依赖经常以符号链接或者软链形式存在扫描时如果不处理可能有两个后果同一个真实文件通过两条路径被扫到两次导致引用计数统计错误或者遍历时碰到循环链接直接栈溢出崩溃。处理方式是在把文件路径加入集合之前统一用系统调用解析真实路径final canonical File(path).resolveSymbolicLinksSync();这样不管从哪个入口扫进来同一份文件永远只有一个规范路径进引用图统计不会重复。4. 从源码到产物鸿蒙化适配完整实操记录4.1 准备一个鸿蒙 Flutter 工程样例空口谈适配没意思我建议你动手之前先准备一个验证用的工程。最好是一个内部演示工程不要一上来就冲击线上大工程。我当时用了公司内部一个待复用的页面模块模拟项目X它的结构基本覆盖了鸿蒙工程的典型特征Dart 源码在entry/src/main/flutter下工程里有 oh_modules 目录同时存在少量生成代码。验证工程结构如下flutter_demo/ entry/ src/main/ flutter/ lib/ main.dart pages/ models/ utils/ pubspec.yaml .dart_tool/package_config.json oh_modules/ build/注意这里的 lib 目录下我故意放了一些没人引用的老文件用来验证工具能不能扫出来。4.2 获取源码到第一次运行dart_unused_files 本身是一个纯 Dart 包可以从公开仓库拉取。拉下来之后先切到对应目录执行dart pub get把依赖装好再直接跑一次原版逻辑记录一下它在这个鸿蒙工程上的报错行为。我第一次跑的时候报错相当典型Error: pubspec.yaml not found in current directory。这行报错其实已经说明了问题——工具只认当前路径没有做任何探测。如果在工程根目录执行它连 Dart 源码在哪都不知道。然后是 package_config 的问题。我把工作目录切到entry/src/main/flutter再跑报错变了变成 URI 解析失败因为工具尝试用package:前缀去解析依赖但读取的 package_config 路径不对找不到了。这两条报错就是适配的全部目标。它们分别对应前面说的切口一和切口二。4.3 按切口逐步修改与验证接下来就是动手改了。我按三个切口顺序修改。先改工程根目录探测。在入口文件里加入之前写的候选目录列表同时暴露--project-root参数。改完重新跑报错消失工具走到了扫描阶段。再改 package_config 读取。把原来单一路径的读取逻辑替换成多路径搜索和降级处理。这一段最需要小心的是路径归一化我统一用了package:path里的处理函数做拼接避免 Windows 和类 Unix 环境下的路径分割符差异。最后改忽略规则。在默认忽略列表里补上鸿蒙工程目录同时把符号链接处理逻辑加进去并在扫描器里做了异常捕获——碰到目录权限不足时累加一个跳过计数而不是让整个进程崩溃。修改完成后用dart compile exe把工具编译成独立可执行文件方便后续在真实工程里带参数调用dart compile exe bin/dart_unused_files.dart -o dart_unused_files4.4 在真实工程上验证执行验证的时候我用的命令是这样./dart_unused_files \ --project-root entry/src/main/flutter \ --report-format table \ --ignore-dirs oh_modules|build|hvigor扫描完成后工具输出了一张表Scanning finished. Total dart files: 1042 Unused dart files: 128 (12.3%) Top directories by unused count: models/old_api: 42 pages/retired: 36 widgets/deprecated: 21128 个文件占全部源码文件的 12.3%。这个数据比我预期高但结合这个演示工程的历史也算合理——里面确实积压了不少旧迭代遗留文件。我没有直接删。每个被标记的文件我都抽查了一轮在源码库里全局搜它的文件名确认没有动态入口引用也没有被资源文件硬编码引用。抽查没问题后再把它们移到废弃分支跑一轮全量测试最终确认无回归后物理删除。清理收益很直观源码减少约 3.6 万行全量编译时间从 75 秒降到 62 秒。包体积的变化主要取决于被删文件里有没有资源但至少编译时的扫描开销和索引负载是实打实降下来了。5. 踩坑实录适配过程中的常见问题与解决方案5.1 入口文件被误报成 unused适配后第一次扫描main.dart 赫然出现在未使用名单里。它确实是程序的入口不被其他库文件引用这是正常的但工具不懂。解决方案是给工具增加一个--keep参数把入口文件和白名单文件显式加进去。--keep entry/src/main/flutter/lib/main.dart如果你遇到工具标记了main.dart不用慌这不是 bug是语义设计。任何静态分析工具都无法知道运行时进程从哪个文件启动这必须由使用方告诉它。5.2 part 文件被单独标记Dart 的part和part of机制非常特殊。part 文件不属于独立的 library它被某个主文件通过part xxx.dart引入后是这个 library 的一部分。但我在适配过程中发现部分版本的工具逻辑把 part 指令也当成普通引用处理导致主文件与 part 文件的指向关系没有正确建立。这个问题的排查思路是如果工具报告了一批来自同一目录、同一命名的文件比如parser_part1.dart、parser_part2.dart大概率就是 part 机制导致的误报。修复时我调整了解析逻辑把part指令解析为“父子绑定”关系part 文件不会因为没有被 import 而被判定为 unused它只可能随着主文件一起被判定。5.3 符号链接导致的重复统计前面提到符号链接处理。实际工程里还有一个衍生问题符号链接解析后真实文件路径可能指向 pub 缓存目录里的某份三方库源码而这个三方库又被另一个软链再次引用。如果在收集阶段不做 canonical 归一化引用表里会出现同一文件多条路径扫描结果里就会出现一个明明被引用的文件被标记成 unused。这个问题的排查很隐蔽因为错误不是显而易见。我当时的处理是在收集文件路径和解析引用路径两处都加归一化确保路径集合里的每一项都是真实路径不能有软链形态。5.4 动态加载和条件导入的兜底策略await import(xxx.dart)是运行时动态行为静态分析工具无法解析xxx.dart这个字符串指向哪个真实文件。条件导入conditional import也会带来难度同一段代码在不同平台只生效其中一个分支但工具在扫描时可能会把它当成所有分支都会加载或者在某个分支里漏掉。对于动态加载我的方案是允许用户通过--keep参数显式保留文件列表。对于条件导入这通常不会导致文件被误判为 unused——因为 AST 解析时所有分支的 import 指令都会被提取到主要风险是漏掉引用路径的解析让文件变成“未使用”。排查时留意一下扫描结果里跟平台适配相关的文件人工确认一下就好。5.5 扫描崩溃和权限问题鸿蒙工程的构建缓存目录特性比较特殊有些目录在 IDE 运行期间被占用或者被设置为只读。扫描器遍历到这些目录时可能抛出权限异常导致进程中断。我在扫描器的目录遍历逻辑里加了保护性捕获遇到无法读取的目录跳过并累加统计。这看上去是个小细节但在真实工程里价值很大——让工具具备“容忍脏环境”的能力不然一次权限报错就会打断整轮清理。5.6 问题速查表我把上面这些坑整理成一张速查表给团队内部用也分享给你现象根因解决方案main.dart 被标记入口文件没有白名单通过 --keep 参数保留part 文件整批被标记part 关系建立不正确升级解析逻辑建立父子绑定同一个文件重复出现符号链接未归一化统一 resolveSymbolicLinksSync动态加载文件被标记静态分析无法覆盖运行时人工复核用 --keep 兜底扫描过程中断目录权限不足遍历时加保护性捕获并跳过路径解析失败package_config 找不到多路径搜索加降级方案写在最后的一点体会适配完这套工具后我顺手扫了团队里另外两个规模更大的 Flutter 工程。最有价值的不是单纯删掉了多少文件而是把“删不掉、不敢删”这件事变成了一个有反馈、可迭代的流程。我个人实际操作中一个很深的体会是工具链适配这件事说穿了就是把一些不起眼的细节抠到位。鸿蒙工程的目录结构、缓存位置、符号链接形态这些在标准 Flutter 工程里根本不会碰到的问题在这里都是决定工具能不能跑通的生死线。没有玄学就是挨个路径去看、挨个报错去解。最后分享一个小习惯。适配完成后我在 CI 里加了一个定时扫描任务每次构建后跑一次 dart_unused_files把未使用文件的数量做成趋势图。规则很简单新提交的代码如果被判定为 unused 文件直接标记为待清理不让它继续堆进工程。这样一个在一次适配中打磨出来的工具就从一次性清理变成了持续维护工程健康度的阀门。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑