AI 编程时代,为什么 Dart 分析会拖慢大型项目的开发

45 阅读13分钟

写在前面

现在用 AI 写代码,已经不再是一件新鲜事。

我自己的工作方式也发生了变化:先让 AI 理解问题、修改代码,然后立刻让它验证结果;如果验证失败,再把错误交给 AI 继续修。这个循环通常是这样的:

提出需求 → 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,相当于不断重复支付这些成本。

大项目里,真正慢的往往不是检查一个文件

很多人第一次遇到这个问题时,会尝试把命令改成:

dart analyze lib/foo.dart

这确实可以减少输出范围,但它不一定能消除 analyzer 的启动成本。

如果每次执行命令都重新启动 analyzer,那么即使目标只有一个文件,也可能需要经历:

  1. 启动 Dart 进程;
  2. 启动 analysis server;
  3. 读取项目配置;
  4. 扫描和建立分析上下文;
  5. 等待插件初始化;
  6. 最后才检查目标文件。

于是,真正需要优化的并不只是“这一次检查了多少文件”,还包括“分析器是否可以被复用”。

我的第一个结论是:

对高频验证场景来说,持久化 analyzer 往往比单纯缩小检查范围更重要。

我没有修改 Dart analyzer 源码

一开始我也考虑过直接修改 analyzer 或 analysis server。

但这样做有几个现实问题:

  • 需要跟踪 Dart SDK 的版本变化;
  • 需要维护自己的 analyzer 分支;
  • Windows、macOS、Linux 的行为可能不同;
  • 发布和升级成本明显增加;
  • 最终用户需要使用一套特殊构建的 SDK。

这和我想解决的问题不匹配。

我更希望把优化放在 analyzer 外面:让官方 analyzer 继续负责分析,外层负责生命周期、复用、路由和资源管理。

最后采用的是一种 wrapper/daemon 思路:

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 analyzedana,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 任务;
  • 每个任务都有自己的分析请求;
  • 任务之间可能同时运行。

例如:

repo/
├── worktree-feature-a/
├── worktree-feature-b/
└── worktree-feature-c/

如果每个 worktree 各自启动 analyzer,那么内存消耗大致会变成:

一个 analyzer × 三个 worktree

大型 Flutter 项目中,这很容易把机器推入换页,甚至让系统直接失去响应。

同仓库多个 worktree,共用一个 analyzer

dana 提供了一个显式 opt-in 的多根池:

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 MiB1,434.9 MiB节省 1,029.3 MiB,约 41.8%
热态单文件检查 p5032.328 ms32.908 ms增加 0.580 ms
热态单文件检查 p9536.045 ms36.379 ms增加 0.334 ms
热态 40 文件目录检查 p5014.489 ms14.637 ms增加 0.148 ms
热态 40 文件目录检查 p9517.822 ms20.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。

这点很重要。一个看似“友好”的回退行为可能造成更严重的问题:

共享池加入失败
    ↓
又启动一个独立 analyzer
    ↓
内存压力进一步加剧

因此当前策略宁愿返回退出码 75,也不静默扩大内存占用。

3. 空闲 root 回收

池空闲时会定期采样系统内存。连续检测到压力后,只回收符合以下条件的 root:

  • 不是主 root;
  • 没有正在处理请求;
  • 已经空闲超过最小时间;
  • 不是当前请求保护的 root。

正在执行的分析不会被强制打断。

4. macOS 的 warn 与 critical

macOS 的内存压力不是简单的“有内存/没内存”。当前策略把 warn 视作提示,把 critical 视作强压力。

在多根池中,warn 不会直接导致新 root 被拒绝,也不会反复清空已经热身的 root;只有 critical 或物理可用余量确实低于保留值时,才进入多根池的压力处理。

全局单 worker 预算仍保持更保守的 warn 语义,因为它负责控制新的 analyzer worker 启动。

这是一种刻意的取舍:如果只看物理余量,系统可能已经在压缩内存或换页,但程序要等到 critical 才开始回收。它是尽力而为的保护,不是硬内存上限。

为什么没有默认开启多根池

多根池需要维护多个 root 的分析上下文,也会让内存行为更复杂。

所以它目前是显式开启:

set DANA_MULTI_ROOT=1

我不希望用户只升级版本,就突然改变 analyzer 的进程模型。尤其对于不相关的多个项目,自动发现和自动合并可能带来难以预期的资源竞争。

显式 opt-in 还有一个好处:当用户确实在使用多个 worktree 时,可以主动选择共享;当用户希望完全隔离时,也可以保持默认行为。

项目地址与快速开始

这篇文章涉及两个独立的 package,它们解决的是不同层面的问题:

安装 dana

dart pub global activate dart_analysis_daemon

安装后可以直接检查项目:

dana check --root path/to/project --files lib/main.dart

如果同一个仓库有多个 worktree,可以显式开启多根池:

# 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

dart pub global activate dart_analysis_server_proxy

proxy 通过 Dart-Code 的 dart.analyzerPath 接入 IDE,IDE 的 analyzer 暖机后,dana 可以复用这份分析状态。

两个项目的完整参数、环境变量和 IDE 配置见各自 README:

这套方案适合什么场景

我认为它特别适合以下场景:

  1. AI 高频修改、频繁验证的 Dart/Flutter 项目;
  2. 大型仓库中的增量检查;
  3. 同一个仓库的多个 Git worktree;
  4. IDE 已经运行 analyzer、命令行又需要检查的开发流程;
  5. 希望降低 analyzer 重复启动成本,但不想维护自定义 SDK 的团队。

它不适合被理解为:

  • analyzer 的替代品;
  • 完整的内存硬限制器;
  • 把多个 root 的上下文完全合并;
  • 在任意平台都能自动采样和控制内存。

我最后得到的结论

AI 编程把“代码分析”从低频动作变成了高频反馈环节。

如果每一次 AI 修改都重新启动 analyzer,那么大型项目的等待时间和内存成本都会被成倍放大。更合理的方向,是把 analyzer 当成长生命周期服务来管理:

一次启动,持续复用;
多个 worktree,按需共享;
内存不足,明确拒绝或回收;
不修改 analyzer 源码,保持 SDK 可升级。

从目前的实验看,多根池最确定的收益不是“让一次分析变快很多”,而是减少重复 analyzer 带来的内存和启动开销。

对于 AI 编程来说,这已经很有价值:当验证动作变得便宜,AI 的修改—验证—修复循环才不会被基础设施拖住。

结语

我现在更愿意把 analyzer 看成一个应该被复用的服务,而不是每次执行命令时临时启动的工具。

这也是我做 dana 的核心原因:让 AI 可以更频繁地验证代码,让大型 Dart/Flutter 项目的反馈循环保持在可接受范围内,同时把多任务、多 worktree 带来的内存风险控制在一个可解释的范围里。

这条路并不完美。内存采样有滞后,多根 root 也不会神奇地合并成一个上下文,macOS 的压力状态还需要在真实设备上持续观察。但至少,问题已经从“每次都重新启动 analyzer”变成了“如何管理一个长期运行的 analyzer 服务”。

我认为,这是更值得继续优化的方向。