写在前面
现在用 AI 写代码,已经不再是一件新鲜事。
我自己的工作方式也发生了变化:先让 AI 理解问题、修改代码,然后立刻让它验证结果;如果验证失败,再把错误交给 AI 继续修。这个循环通常是这样的:
text
提出需求 → AI 修改代码 → dart analyze → 发现问题 → AI 继续修改
小项目里,这个循环非常舒服。可一旦项目变大,尤其是 Flutter 项目、插件较多的项目,问题就出现了:代码可能只改了一个文件,但每次 dart analyze 都要重新等待一轮分析。
对人来说,这是等待。
对 AI 来说,这是上下文被打断。
而当我同时让 AI 处理两个、三个任务,或者一个仓库拉出多个 Git worktree 时,问题会进一步放大:每个任务都可能启动自己的 analyzer,内存迅速上涨,分析时间也开始影响整个开发节奏。
这篇文章记录我对这个问题的分析,以及最后做出的一个实践方案:dart_analysis_daemon,命令行入口叫 dana。
AI 写代码以后,验证变成了高频操作
传统开发中,我们可能在一段时间内集中修改代码,最后再运行一次完整检查。
AI 辅助开发不是这样。
AI 每完成一小步,都需要尽快知道这一步是否成立:
- 类型是否正确?
- import 是否完整?
- 生成的 API 是否真的存在?
- analyzer plugin 有没有报错?
- 当前修改有没有影响其他文件?
所以,分析器不再只是提交前的工具,而变成了 AI 编程循环中的实时反馈器。
这会带来两个直接后果:
第一,分析请求的次数显著增加。
第二,分析器的启动和初始化成本会被反复暴露出来。
特别是 Dart analyzer 并不是一个完全无状态的命令。它会建立分析上下文、读取 package 配置、解析依赖、加载插件,并持续维护文件和诊断状态。每次都启动一个新的 analyzer,相当于不断重复支付这些成本。
大项目里,真正慢的往往不是检查一个文件
很多人第一次遇到这个问题时,会尝试把命令改成:
bash
dart analyze lib/foo.dart
这确实可以减少输出范围,但它不一定能消除 analyzer 的启动成本。
如果每次执行命令都重新启动 analyzer,那么即使目标只有一个文件,也可能需要经历:
- 启动 Dart 进程;
- 启动 analysis server;
- 读取项目配置;
- 扫描和建立分析上下文;
- 等待插件初始化;
- 最后才检查目标文件。
于是,真正需要优化的并不只是"这一次检查了多少文件",还包括"分析器是否可以被复用"。
我的第一个结论是:
对高频验证场景来说,持久化 analyzer 往往比单纯缩小检查范围更重要。
我没有修改 Dart analyzer 源码
一开始我也考虑过直接修改 analyzer 或 analysis server。
但这样做有几个现实问题:
- 需要跟踪 Dart SDK 的版本变化;
- 需要维护自己的 analyzer 分支;
- Windows、macOS、Linux 的行为可能不同;
- 发布和升级成本明显增加;
- 最终用户需要使用一套特殊构建的 SDK。
这和我想解决的问题不匹配。
我更希望把优化放在 analyzer 外面:让官方 analyzer 继续负责分析,外层负责生命周期、复用、路由和资源管理。
最后采用的是一种 wrapper/daemon 思路:
text
dana check
↓
连接已有的持久化 session
↓
把本次文件或目录检查请求发给 analyzer
↓
返回 JSON 诊断结果
如果 session 不存在,dana 才启动 analyzer;如果 session 还活着,后续请求直接复用它。
这也是 dart_analysis_daemon 这个名字的由来:它不是 analyzer 的替代实现,而是 analyzer 的生命周期管理层。
第一个收益:重复验证不再重复启动 analyzer
持久化 session 解决的是单个项目的重复启动问题。
第一次检查时,dana 启动 analyzer 并建立上下文;后续检查只发送增量请求。这样,AI 每次修改几行代码之后,都可以快速得到结果,而不用重新支付完整启动成本。
它还可以复用已有的 IDE analyzer。
如果 IDE 已经运行了 analyzer,dart_analysis_server_proxy 可以把 IDE 的 analyzer 暴露给 dana。这样命令行检查能够复用 IDE 当前的分析状态,包括已经加载的上下文以及部分未保存内容。
当然,这种复用也有边界:如果需要验证磁盘上的真实内容,仍然应该先保存文件;插件刚启动时,诊断也可能还没有准备好。
先看单 root:持久化 session 才是最大的第一步
在研究多个 worktree 之前,我先验证了最基础的单 root 场景:同一个项目,只让一个 analyzer 长期运行,看看 AI 反复验证时能省下多少时间。
这组数据来自一个真实的 Flutter 单体仓库:
- 15,000+ 个 Dart 文件;
- 约 70 个 package;
- 2 个 analyzer plugin;
- Apple Silicon,24 GB 内存。
对比的是每次都重新执行 dart analyze,以及 analyzer session 已经建立之后的 dana。
| 检查内容 | 每次独立执行 dart analyze |
dana,IDE 已开 |
dana,session 已预热 |
|---|---|---|---|
| 单个文件 | 约 3 分钟 | 不到 1 秒 | 约 0.5 秒 |
| 约 1,200 个文件的目录 | 约 8 秒,且缺少部分 plugin lint | 不到 1 秒 | 约 0.5 秒 |
| 全项目检查 | 约 3.5 分钟 | 约 1 秒 | 约 0.5 秒 |
| 修改公共 API 后的全项目检查 | 约 3.5 分钟 | 约 30 秒 | 约 1 分钟 |
这组结果让我意识到,真正的收益并不是"让 analyzer 解析一个文件的算法突然变快",而是避免每次验证都重新初始化一套分析上下文。
不过这里必须把冷启动和热态请求分开:
- 第一次启动 session,仍然要支付接近
dart analyze的初始化成本; - analyzer plugin 第一次编译和加载,也仍然需要时间;
- 只有 session 建立之后的重复检查,才会进入快速路径;
- 如果公共 API 发生大范围依赖失效,热态检查仍然可能需要几十秒甚至更久。
在另一个 5,000 文件的 resolved-plugin 基准中,独立 dart analyze 约 39.62 秒;session 建立后,dana fast 约 2.47--5.06 秒,下降约 88%--94%。这类数据更接近 AI 高频修改单个文件、不断重复验证的工作流。
所以单 root 的结论是:
先把 analyzer 从"一次性命令"变成"长期运行的服务",通常就能解决大部分重复验证的等待问题。
第二个问题:多个 AI 任务会启动多个 analyzer
单个项目的 session 复用之后,我遇到的下一个问题是并行任务。
现在很多人的工作方式是:
- 一个仓库同时拉出多个 worktree;
- 每个 worktree 对应一个 AI 任务;
- 每个任务都有自己的分析请求;
- 任务之间可能同时运行。
例如:
text
repo/
├── worktree-feature-a/
├── worktree-feature-b/
└── worktree-feature-c/
如果每个 worktree 各自启动 analyzer,那么内存消耗大致会变成:
text
一个 analyzer × 三个 worktree
大型 Flutter 项目中,这很容易把机器推入换页,甚至让系统直接失去响应。
同仓库多个 worktree,共用一个 analyzer
dana 提供了一个显式 opt-in 的多根池:
bash
DANA_MULTI_ROOT=1 dana check --root path/to/worktree-a
DANA_MULTI_ROOT=1 dana check --root path/to/worktree-b
第一个 worktree 启动 analyzer,后续兼容的 worktree 会把自己的 root 加入同一个 analyzer 进程。
这里有一个很容易误解的地方:
共用一个 analyzer 进程,不等于把多个 root 的分析上下文合并成一份。
每个 root 仍然会保留自己的分析状态。多根池主要节省的是:
- analyzer 进程;
- Dart runtime;
- 部分全局缓存;
- 多次启动和初始化的成本。
因此它不是"把三个项目变成一个项目",而是让一个分析 server 管理多个分析 root。
Windows 实测:内存下降,速度基本不变
我做了一组 Windows 对照实验。
内存实验使用两个轻量合成 worktree,每个 root 包含 20,003 个文件;延迟实验使用真实 Dart SDK 的 pkg/analyzer,不加载插件,先热身,再串行请求。
结果如下:
| 测量项 | 两个独立 worker | 共享多根池 | 差异 |
|---|---|---|---|
| 两个 worktree 检查后的 analyzer Job 私有提交 | 2,464.2 MiB | 1,434.9 MiB | 节省 1,029.3 MiB,约 41.8% |
| 热态单文件检查 p50 | 32.328 ms | 32.908 ms | 增加 0.580 ms |
| 热态单文件检查 p95 | 36.045 ms | 36.379 ms | 增加 0.334 ms |
| 热态 40 文件目录检查 p50 | 14.489 ms | 14.637 ms | 增加 0.148 ms |
| 热态 40 文件目录检查 p95 | 17.822 ms | 20.000 ms | 增加 2.178 ms |
这个结果给我的判断是:
在这组实验里,共享多根池明显减少了内存,但没有带来明显的热态请求速度损失。
不过,这组内存测试使用的是轻量合成源码,测量指标是 analyzer Job 的私有提交,不是 Dart heap,也不是完整工作集。因此我不会把 41.8% 直接宣传成大型 Flutter 项目的固定收益。
真实项目的收益取决于:
- 项目文件数量;
- 依赖规模;
- analyzer plugin;
- 每个 root 的分析状态;
- 当前操作系统的内存回收行为。
内存保护:不能只让池无限增长
如果多根池允许无限加入 root,那么它只是把"多个进程打满内存"变成了"一个进程内部保留很多 root"。问题并没有消失。
因此我给多根池增加了几层保护。
1. root 数量上限
通过 DANA_POOL_MAX_ROOTS 限制一个池最多保留的 root 数量,默认值为 3。
当达到上限时,池会优先淘汰最久未使用、且允许淘汰的 root。主 root 和当前请求的 root 会被保护。
2. 内存准入
在 Windows 和 macOS 上,加入新 root 前会检查系统内存。
如果当前内存不足,池会尝试淘汰一个 LRU root;如果没有可淘汰的 root,就返回明确的准入失败,不会偷偷启动第二个独立 analyzer。
这点很重要。一个看似"友好"的回退行为可能造成更严重的问题:
text
共享池加入失败
↓
又启动一个独立 analyzer
↓
内存压力进一步加剧
因此当前策略宁愿返回退出码 75,也不静默扩大内存占用。
3. 空闲 root 回收
池空闲时会定期采样系统内存。连续检测到压力后,只回收符合以下条件的 root:
- 不是主 root;
- 没有正在处理请求;
- 已经空闲超过最小时间;
- 不是当前请求保护的 root。
正在执行的分析不会被强制打断。
4. macOS 的 warn 与 critical
macOS 的内存压力不是简单的"有内存/没内存"。当前策略把 warn 视作提示,把 critical 视作强压力。
在多根池中,warn 不会直接导致新 root 被拒绝,也不会反复清空已经热身的 root;只有 critical 或物理可用余量确实低于保留值时,才进入多根池的压力处理。
全局单 worker 预算仍保持更保守的 warn 语义,因为它负责控制新的 analyzer worker 启动。
这是一种刻意的取舍:如果只看物理余量,系统可能已经在压缩内存或换页,但程序要等到 critical 才开始回收。它是尽力而为的保护,不是硬内存上限。
为什么没有默认开启多根池
多根池需要维护多个 root 的分析上下文,也会让内存行为更复杂。
所以它目前是显式开启:
bash
set DANA_MULTI_ROOT=1
我不希望用户只升级版本,就突然改变 analyzer 的进程模型。尤其对于不相关的多个项目,自动发现和自动合并可能带来难以预期的资源竞争。
显式 opt-in 还有一个好处:当用户确实在使用多个 worktree 时,可以主动选择共享;当用户希望完全隔离时,也可以保持默认行为。
项目地址与快速开始
这篇文章涉及两个独立的 package,它们解决的是不同层面的问题:
dart_analysis_daemon:管理持久化 analyzer session,命令行工具是dana;dart_analysis_server_proxy:复用 IDE 已经运行的 analyzer,避免命令行再启动一份。
安装 dana
bash
dart pub global activate dart_analysis_daemon
安装后可以直接检查项目:
bash
dana check --root path/to/project --files lib/main.dart
如果同一个仓库有多个 worktree,可以显式开启多根池:
bash
# macOS/Linux
DANA_MULTI_ROOT=1 dana check --root path/to/worktree
# Windows PowerShell
$env:DANA_MULTI_ROOT = "1"
dana check --root path/to/worktree
安装 IDE analyzer proxy
bash
dart pub global activate dart_analysis_server_proxy
proxy 通过 Dart-Code 的 dart.analyzerPath 接入 IDE,IDE 的 analyzer 暖机后,dana 可以复用这份分析状态。
两个项目的完整参数、环境变量和 IDE 配置见各自 README:
这套方案适合什么场景
我认为它特别适合以下场景:
- AI 高频修改、频繁验证的 Dart/Flutter 项目;
- 大型仓库中的增量检查;
- 同一个仓库的多个 Git worktree;
- IDE 已经运行 analyzer、命令行又需要检查的开发流程;
- 希望降低 analyzer 重复启动成本,但不想维护自定义 SDK 的团队。
它不适合被理解为:
- analyzer 的替代品;
- 完整的内存硬限制器;
- 把多个 root 的上下文完全合并;
- 在任意平台都能自动采样和控制内存。
我最后得到的结论
AI 编程把"代码分析"从低频动作变成了高频反馈环节。
如果每一次 AI 修改都重新启动 analyzer,那么大型项目的等待时间和内存成本都会被成倍放大。更合理的方向,是把 analyzer 当成长生命周期服务来管理:
text
一次启动,持续复用;
多个 worktree,按需共享;
内存不足,明确拒绝或回收;
不修改 analyzer 源码,保持 SDK 可升级。
从目前的实验看,多根池最确定的收益不是"让一次分析变快很多",而是减少重复 analyzer 带来的内存和启动开销。
对于 AI 编程来说,这已经很有价值:当验证动作变得便宜,AI 的修改---验证---修复循环才不会被基础设施拖住。
结语
我现在更愿意把 analyzer 看成一个应该被复用的服务,而不是每次执行命令时临时启动的工具。
这也是我做 dana 的核心原因:让 AI 可以更频繁地验证代码,让大型 Dart/Flutter 项目的反馈循环保持在可接受范围内,同时把多任务、多 worktree 带来的内存风险控制在一个可解释的范围里。
这条路并不完美。内存采样有滞后,多根 root 也不会神奇地合并成一个上下文,macOS 的压力状态还需要在真实设备上持续观察。但至少,问题已经从"每次都重新启动 analyzer"变成了"如何管理一个长期运行的 analyzer 服务"。
我认为,这是更值得继续优化的方向。