一直想给 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 tree;Linux 上要用apt install tree;Windows 上压根没有原生tree命令输出那种格式。就算不用tree,用find加sed拼字符串,三套系统的find参数、路径分割符、文本编码也各有差异,调试一轮下来比写代码还费劲。
Dart 脚本就没有这个问题。只要装了 Flutter SDK,dart命令天然存在于开发环境里,而且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(List<String> 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).cast<List<int>>()); // 这行没用,别照抄 }上面这行是错误示范,我自己最开始绕了弯路。正确做法是直接设置输出流:
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 项目文档,也遇见过目录树噪音多、格式不可控的问题,照这个思路写一份脚本,半小时换来的是一劳永逸的可控文档。跑通一次之后,你会发现文档模板不再需要手工维护,结构的变化,跑一遍脚本就全在里面了。