
一直想给 Flutter 项目维护一份结构清晰的项目结构 Markdown 文件之前分享过方法一的思路借助现成的 CLI 工具把整个目录树导出成预览文本。当时图省事用久了才发现几个别扭的地方——.dart_tool、build这类噪音目录混在结果里打包好的文档就像一盘散沙想过滤还得靠事后手动删而且格式不可控工具输出什么样的 Markdown 就是什么样想加点自定义说明完全没门。于是我把思路换成了方法二不依赖外部工具直接写一个 Dart 脚本在 Flutter 工程里跑一遍把目录树整理成可定制、带忽略规则、还兼顾可读性的 Markdown 文件。这次分享的内容就是这套方法二的做法包括最基础的遍历实现、参数化改造、以及我在实际运行过程中踩到的几个坑。适合正在维护 Flutter 开源项目、需要写文档或者想把项目结构定期同步到 README 里的开发朋友参考。照着本文的思路改一版基本就能覆盖日常需求。1. 为什么没用现成工具而是自己重写了一套1.1 方法一的做法和用久之后的别扭方法一说白了就是找一个现成的目录树生成器在工程根目录执行一条命令让它把目录递归列出来然后丢进 Markdown 的代码块里。当时这么干确实很快毕竟工具内部已经处理好了排序、缩进、图标这些琐碎事一秒钟就能看到全貌。但真正把这份文档放进 README、并且在后续迭代里持续使用的时候问题就冒出来了。首先是噪音目录太多Flutter 项目默认就有build、.dart_tool、.idea如果还开了平台支持android、ios、linux这些目录也全都会出来。一次导出的目录树可能有一两百行真正写得最多的lib和test反而被淹没在底部。每次更新文档都得手动把噪音剪掉剪完还得检查缩进没有错位极其容易出问题。其次是格式不可控。现成工具给的是固定模板要么是纯文本的树形符号要么是带图标的富文本想在里面加一句“这个目录负责什么业务”需要自己改源码或者写后处理脚本。时间一长文档生成完还要人肉润色失去了自动化更新的意义。1.2 方法二真正想解决的问题方法二的目标很明确自己写生成器把“过滤”和“格式”这两个主动权拿回来。与其在工具输出的结果上做二次拼装不如直接控制在生成那一刻。具体来说我希望这个生成器至少做到三件事。第一默认过滤掉构建目录和 IDE 目录只展示源码相关的内容第二输出格式自己定树形列表和表格都行方便在不同场景下使用第三后续能扩展比如从pubspec.yaml里读取包名、版本号一起写进 Markdown 文件头部。这些需求在现成 CLI 工具里基本都要靠改配置甚至改源码实现而自己写一个 Dart 脚本以后所有逻辑都在眼皮底下想加功能随时加。这里多说一句标题叫“方法二”不是说方法一不好。方法一适合一次性导出、快速预览胜在零成本方法二适合周期性更新、对格式有要求的场景胜在可控。如果你只是临时给同事看下项目结构方法一完全够用如果要长期维护文档我强烈建议花半小时改成方法二这种思路。2. 选 Dart 写脚本而不选 shell考虑的主要是跨平台与可扩展性2.1 跨平台是硬需求shell 脚本撑不住可能有朋友会问生成目录结构这种活用tree命令或者一段 shell 脚本不也能做吗为什么偏偏用 Dart 写我最开始也试过 shell 方案最后放弃的核心原因是Flutter 项目的开发环境不统一。CI 跑在 Linux 容器上同事的本子可能是 Windows我自己的开发机是 macOS。tree命令在 macOS 上默认没有要brew install treeLinux 上要用apt install treeWindows 上压根没有原生tree命令输出那种格式。就算不用tree用find加sed拼字符串三套系统的find参数、路径分割符、文本编码也各有差异调试一轮下来比写代码还费劲。Dart 脚本就没有这个问题。只要装了 Flutter SDKdart命令天然存在于开发环境里而且dart:io对文件系统操作的封装在三套操作系统上行为完全一致。同一份脚本在 macOS 上写好提交到仓库CI 上的 Linux 直接dart run就能跑Windows 开发机上同样可以跑不需要额外安装任何依赖。2.2 方便顺手解析工程里的元信息选 Dart 的第二个理由是这个环境本身就能直接读取 Flutter 工程里的文件不需要调用第三方解析库。比如我想在 Markdown 文档开头加上包名和版本号直接读pubspec.yaml然后正则匹配name:和version:两行就能拿到。想统计每个 Dart 文件的代码行数File.readAsLinesSync().length一行搞定。这些操作如果在 shell 里做语法别扭不说在 Windows 的 Git Bash 和 PowerShell 之间还经常出现诡异差异。换句话说这个脚本不只是“列目录”它完全可以演化成“项目信息报告生成器”。这一点我在后续扩展里会具体讲。2.3 代码本身可以成为团队文档的一部分还有一层考虑可能大家平时不太注意shell 脚本往往写完就扔藏在某个scripts/目录里没人维护。但是用 Dart 写的生成器本质上就是项目源码的一部分可以放在bin/目录下跟着主工程一起走代码评审、走版本管理、走重构。团队其他人接手这个项目之后看到生成器代码就能明白整份文档是怎么来的、规则是怎么定的而不是面对一个“上古时期留下的奇怪脚本”无从下手。这一点对长期项目维护来说价值其实比功能本身更大。3. 最小实现三十行代码生成一棵干净的目录树3.1 核心是递归遍历先写出最简版本我建议不要一上来就考虑各种参数和边界情况先把最核心的 20 行逻辑跑通。这个方法二的雏形非常简单核心就是Directory.listSync加上递归。直接上代码import dart:io; void main() { final root Directory(.); final buffer StringBuffer(); buffer.writeln(# 项目结构); buffer.writeln(); _walk(root, buffer, ); stdout.write(buffer.toString()); } void _walk(Directory dir, StringBuffer buffer, String indent) { final children dir.listSync(followLinks: false); children.sort((a, b) a.name.toLowerCase().compareTo(b.name.toLowerCase())); for (final entity in children) { if (entity is Directory) { buffer.writeln($indent- ${entity.name}/); _walk(entity, buffer, $indent ); } else if (entity is File) { buffer.writeln($indent- ${entity.name}); } } }这段代码里最关键的是if (entity is Directory)与else if (entity is File)的区分。Dart 的FileSystemEntity是父类型实际运行时有可能是Directory、File或者Link这里用类型判断把目录和文件分开处理目录则递归进入、在名字后面加斜杠文件则直接输出。我刻意用了children.sort做了一次按名字排序这一步虽然不影响遍历正确性但直接影响生成文档的可读性。如果不排序最终 Markdown 里目录出现的顺序取决于操作系统的文件系统索引顺序毫无规律找人找文件都很痛苦。排序之后按字母排列至少在大型项目里能快速定位。另一个值得注意的细节是输出到stdout而不是直接写文件。这样脚本既支持重定向dart run generate_structure.dart STRUCTURE.md也方便在终端直接预览灵活性比硬编码写文件高得多。3.2 把目录结构转成 Markdown 列表的缩进思路上面的代码里树形结构的核心实现全靠indent这个字符串参数。每进入一层目录就往缩进里追加两个空格这样所有层级都是“两个空格递增”渲染出来的效果非常整齐。为什么不用制表符或者四个空格因为 Markdown 渲染引擎对制表符的宽度处理不一致有的认为是 4 格有的认为是 8 格两个空格是最稳妥的选择。而且嵌套层级深的话两格缩进能尽量压缩行宽避免文档一行太长阅读体验更好。用短横线-作为列表标记是考虑到它既能被 GitHub 风格的 Markdown 正确渲染成无序列表也能在纯文本环境下保留树形的视觉层次。我见过有人用|--模拟树形字符但在 Markdown 里这样会被渲染成一个段落而不是列表反而丢掉了结构化信息。这里有一个小小的取舍目录名后面加不加/我选择加。虽然 Markdown 渲染时并不会因为多了一个斜杠就自动变成文件夹样式但对纯文本读者来说这传递了一个重要信息——这一项是目录不是文件。3.3 加入过滤规则把噪音目录挡在外面有了基础版本之后马上要加的就是忽略规则。Flutter 项目的噪音目录就那几类构建产物、依赖缓存、IDE 配置、平台原生目录。我在脚本里定义了一个常量集合const defaultIgnore { .dart_tool, build, .git, .idea, .vscode, android, ios, web, macos, windows, linux, };然后在递归时判断if (entity is Directory defaultIgnore.contains(entity.name)) { continue; }continue的语义是跳过当前目录本身、也不再进入该目录递归直接从整体结果里抹掉它。这个处理比“生成之后再过滤”更高效因为压根不会去读取那些目录的子文件。关于android和ios是否要默认过滤建议团队内部商量好。如果文档的阅读对象是 Flutter 业务开发那么原生目录完全可以忽略如果还要兼顾原生层代码的排查可以把它们从忽略列表里去掉。这也是方法二的优势——你可以随手改这个集合让文档只包含你看得见的部分。4. 从脚本进化为工具表格输出与自定义忽略文件4.1 用参数切换 Markdown 的树形和表格两种形态基础版本跑通之后我把脚本往更通用的方向推了一把。最直观的扩展是输出格式除了树形列表我还想生成一张表格把每个文件的相对路径、类型、以及备注都列出来。这种格式特别适合放进技术方案的附件、或者作为代码评审的文档材料。实现上我用了一个简单的布尔参数void main(ListString args) { final useTable args.contains(--table); if (useTable) { _emitTable(Directory(.)); } else { _walk(Directory(.), StringBuffer(), ); } }树形输出走之前的递归逻辑表格输出则改成广度遍历收集所有未被忽略的文件和目录然后拼成 Markdown 表格。表格版本我是这样写的void _emitTable(Directory root) { final rows (String, String)[]; void collect(FileSystemEntity entity) { if (entity is Directory) { if (defaultIgnore.contains(entity.name)) return; rows.add((entity.path, 目录)); collect(Directory(entity.path)); } else if (entity is File) { rows.add((entity.path, 文件)); } } collect(root); stdout.writeln(| 路径 | 类型 |); stdout.writeln(| --- | --- |); for (final row in rows) { final path row.$1.replaceAll(\\, /); stdout.writeln(| $path | ${row.$2} |); } }这段代码里我特意把路径里的\替换成了/否则同一份文档在 Windows 和 Linux 环境下生成的相对路径格式不统一放到 Markdown 里别人一点链接就会因为分隔符问题打不开。这个坑后面专门展开讲。4.2 参照 gitignore 的思路支持自定义忽略文件默认忽略规则再全也架不住每个项目有自己的特殊性。比如有些项目会在assets/目录里放原始设计稿格式是.psd不希望出现在文档里有些项目的tool/目录是内部维护脚本只想展示给核心开发。为了不把脚本改来改去我参照gitignore的思路加了一个自定义忽略文件的支持。在工程根目录放一个.structureignore每一行写一条过滤规则脚本启动时先读取这个文件final customIgnore String{}; final ignoreFile File(.structureignore); if (ignoreFile.existsSync()) { final lines ignoreFile.readAsLinesSync(); for (final line in lines) { final trimmed line.trim(); if (trimmed.isNotEmpty !trimmed.startsWith(#)) { customIgnore.add(trimmed); } } }然后在判断的时候把自定义规则跟默认规则合并final ignoreSet {...defaultIgnore, ...customIgnore};这里我故意只实现“按目录名精确匹配”没有去做通配符和路径模式匹配因为 Flutter 项目的过滤需求绝大多数是目录名级别做复杂了反而增加维护成本。如果你的团队确实需要*.g.dart这种文件级过滤再扩展也不迟思路是一致的。4.3 递归深度限制防止脚本被异常目录卡死还有一个参数我觉得很值得加最大递归深度。正常情况下不需要但是万一有人不小心把lib目录的上一级设置成了符号链接指向自己的父目录深度不限制的话脚本就会无限递归下去直到内存爆掉。实现同样很简单void _walk(Directory dir, StringBuffer buffer, String indent, int maxDepth, [int depth 0]) { if (depth maxDepth) return; // ... _walk(entity, buffer, $indent , maxDepth, depth 1); }默认值给 10 层就足够覆盖绝大多数 Flutter 项目了毕竟lib/pages/home/widgets/detail这种层级也就四五层。加了这层保险之后脚本才敢放心地让团队同事随意执行。5. 实测中踩到的三个坑每一个都有具体表现5.1 符号链接造成的目录循环必须关掉 followLinks第一次把脚本跑到一个带node_modules的混合项目里程序直接抛了FileSystemException。排查下来是某个目录软链接指向了自身父级递归遍历时形成了环路。Directory.listSync默认是跟随符号链接的我一层层读下去越读越深直到系统报错。解决办法是调用listSync时显式传参数dir.listSync(followLinks: false)关掉跟随之后符号链接本身还是会被列出来但遍历不会顺着它钻进目标目录。这里建议对符号链接单独处理如果在遍历时遇到Link类型直接跳过或者标记为“链接”防止误当成普通目录读内容。5.2 Windows 路径分隔符把 Markdown 链接弄坏脚本写完后我在 macOS 上跑得很顺利生成文档里的相对路径全是/。结果一位 Windows 同事跑了一遍生成的 Markdown 里路径全变成了lib\pages\home\home_page.dart。在绝大多数 Markdown 渲染器里lib\pages会被当成普通文本而不是链接分割符导致文件链接全部失效。原因就是File.path在 Windows 上返回原生路径用的是反斜杠。我在最终输出前统一做了一次清洗String _normalizePath(String path) path.replaceAll(\\, /);这个替换不会影响实际文件读取只是改变展示形式。类似的思路也适用于从Directory.current.path或者参数中拼接绝对路径的场景。5.3 中文文件名输出乱码的编码问题项目里有人用中文命名文件名比如用户协议.md。这个文件在 IDE 里完全正常但脚本生成文档后Markdown 里显示成乱码。问题出在 Windows 默认控制台代码页是 GBK而 Dart 输出默认按 UTF-8 编码。把stdout消费端的编码强制切到 UTF-8 之后问题消失。实现方式是在脚本前面加一段import dart:convert; void main() { stdout.addStream(utf8.decoder.bind(stdin).castListint()); // 这行没用别照抄 }上面这行是错误示范我自己最开始绕了弯路。正确做法是直接设置输出流stdout.writeln(utf8.decode(utf8.encode(内容)));严格来说跨平台最稳妥的方式是不要依赖终端的编码输出直接把结果写入文件File(STRUCTURE.md).writeAsStringSync(buffer.toString(), encoding: utf8);这样可以彻底绕开控制台编码问题。脚本最终思路也确实是先写文件再提示用户打开文件查看而不是直接在终端里打印完整内容。5.4 工作目录不等于脚本目录还有一个隐蔽坑脚本里写的Directory(.)指向的是你执行dart run时所在的终端工作目录不是脚本所在的bin/目录。如果从工程根目录执行两者一致没问题但如果你在bin/目录里敲dart run generate_structure.dart它会拿bin/作为根目录生成的文档内容就完全错了。我的建议是脚本里把根目录固定为当前工作目录并且在命令行提示一句“请在工程根目录执行本脚本”。或者更严谨一点可以尝试通过Platform.script反推出脚本位置再向上找pubspec.yaml但这个做起来复杂对一般使用场景来说没太大必要。6. 把生成器接进日常文档维护流程6.1 脚本在工程里的摆放位置与运行方式我将最终的脚本命名为bin/generate_structure.dart根目录下的 README 更新步骤写得很简单在工程根目录执行dart run bin/generate_structure.dart然后用生成好的STRUCTURE.md替换 README 里的示例区块。为什么不直接让脚本改 README因为 README 往往还有人工维护的内容比如蓝绿部署说明、开发环境配置步骤脚本万一写错整个文档就被覆盖了。所以我选择让它生成一个独立文件再通过手动方式或者借助接下来的占位符方案同步进去。6.2 用 README 占位符实现半自动刷新后来我用了一个更省心的方案在 README 里放一个占位符区块脚本生成的内容就填充在这个区块里。!-- STRUCTURE_START -- !-- STRUCTURE_END --生成器的输出只替换这两个注释之间的内容这样 README 的其他部分可以继续人工维护需要更新结构时跑一次脚本即可。实现也不复杂读取 README 全文找到两个锚点的位置中间替换为新生成的目录树内容回写文件。这个方案兼顾了自动化和人工控制的稳定性我强烈推荐对文档整洁度有要求的朋友试试。6.3 后续还能扩展的方向与思路脚本跑顺之后可以加的东西就太多了。我的待办清单里排了三条一是从pubspec.yaml读取包名和版本号在生成文件的头部自动加上“生成时间、针对版本”的元信息让文档和代码版本强绑定。二是统计lib/下.dart文件数量与总行数生成一个轻量级的健康指标配合 CI 在每次提交后刷新。三是把生成结果接入文档站或者截图工具做成项目全景图。我目前只完成了第一项后面两项属于“想到了随时能加”的状态因为方法二最大的好处就是没有外部依赖改代码就是改文档边界完全由自己掌控。这次分享的核心其实不是代码本身而是“自己动手写生成器”的思路。现成工具解决的是 80% 的常见场景剩下的 20% 定制需求恰恰是方法二能发挥价值的地方。如果你在维护 Flutter 项目文档也遇见过目录树噪音多、格式不可控的问题照这个思路写一份脚本半小时换来的是一劳永逸的可控文档。跑通一次之后你会发现文档模板不再需要手工维护结构的变化跑一遍脚本就全在里面了。