跳转至

VFS、索引、Gists 与性能实战

VFS 与索引 介绍了基础概念。本章关注实战:什么时候刷新 VFS、怎么监听文件变化、什么时候用 File-Based Index、Stub Index 或 Gist,以及如何避免索引和 Dumb Mode 问题拖垮用户体验。

VFS 快照不是磁盘本身

VFS 维护的是应用级快照,不是项目级快照。只有被访问过的文件或目录才会进入快照;目录内容也只有在读取子项后才会被加载。这意味着:

  • UI 看到的是 VFS 快照,不一定立刻等于磁盘状态。
  • 外部工具修改文件后,需要刷新才会被 IDE 看到。
  • 已经加载过的文件会留在快照中,除非磁盘删除且父目录刷新。
  • ignored/excluded 文件不会被 VFS 自动跳过;上层代码必须自己过滤。
  • 同一个磁盘文件在 IDE 生命周期中可能对应多个 VirtualFile 实例,但它们相等、hashCode 相同并共享 user data。

刷新策略

优先异步刷新:

virtualFile.refresh(false, true) {
    // runs after refresh
}

需要批量刷新时使用 RefreshQueue.createSession()。同步刷新会阻塞调用线程;如果后台线程持有 read lock 时发起同步刷新,可能死锁。

实践规则:

  • 外部进程生成文件后,对输出目录发起异步刷新。
  • 不要在 Read Action 内调用同步刷新。
  • LocalFileSystem.refreshAndFindFileByPath() 找不到文件时可能触发局部刷新,也要遵守锁规则。
  • 文件内容变化但时间戳不变,IDE 可能无法检测到。
  • native file watcher 存在时,刷新只处理报告过变化的路径;没有 watcher 时会遍历刷新范围。

调试 watcher 覆盖范围可用内部动作:

Tools | Internal Actions | VFS | Show Watched VFS Roots

VFS 事件

VFS 事件在 EDT 中、write action 下发送。最常用的是 BulkFileListener

project.messageBus.connect(disposable).subscribe(
    VirtualFileManager.VFS_CHANGES,
    object : BulkFileListener {
        override fun after(events: List<VFileEvent>) {
            val index = ProjectFileIndex.getInstance(project)
            val relevant = events.filter { event ->
                event.file?.let(index::isInContent) == true
            }
            // handle relevant events
        }
    }
)

如果需要非阻塞预处理,考虑 AsyncFileListener

事件注意点:

  • VFS listener 是应用级,会收到所有打开项目的事件。
  • 必须用 ProjectFileIndex、路径、文件类型等过滤。
  • refresh 触发的事件发生时,磁盘变化已经发生。
  • before deletion 中,磁盘文件可能已经删除,但 VFS 快照仍能读到最后内容。
  • 未加载进快照的文件变化不会产生事件。

File-Based Index、Stub Index、Gist 怎么选

需求 推荐
按文件内容或轻量元数据查找文件 File-Based Index
查找声明、函数、类、可见符号等 PSI 元素 Stub Index
只对少量文件按需计算并缓存结果 Gist
只需要当前文件临时结果 普通缓存或 CachedValue

File-Based Index 查询结果是文件集合。Stub Index 查询结果是 PSI 元素。自定义语言如果需要跨文件 resolve、补全、导航,通常应投资 Stub Tree 和 Stub Index。

Gists

Gist 适合满足这些条件的场景:

  • 不需要全项目聚合。
  • 不想在 indexing 阶段预计算全量数据。
  • 数据能在请求时从单个文件快速计算。
  • 结果可以持久缓存到磁盘。

两类常用 Gist:

  • VirtualFileGist:基于文件内容或元数据计算。
  • PsiFileGist:基于 PSI 文件计算。

示例场景:

  • 图片尺寸、位深等 UI 展示信息。
  • Java 文件中某些简单属性。
  • 单文件配置摘要。

不要把 Gist 当成通用数据库。需要跨文件聚合、反向查找或按 key 检索时,仍然用 Index。

Dumb Mode 实战

索引期间,任何访问索引的功能都可能抛 IndexNotReadyException。处理方式:

DumbService.getInstance(project).runWhenSmart {
    // index-dependent work
}

可在 Dumb Mode 运行的扩展实现 DumbAware。Action 要继承 DumbAwareAction,不要只覆写 isDumbAware()

2025.1+ Plugin DevKit 提供 Can be DumbAware 检查,帮助识别可以安全标记的实现。

测试:

Tools | Internal Actions | Enter/Exit Dumb Mode

索引性能

索引代码的成本会乘以项目文件数量,必须保守:

  • 能用 Lexer 信息就不要构建 AST。
  • 必须看结构时,优先 Light AST。
  • PsiDependentFileContent.getLighterAST() 获取轻量树。
  • 遍历 Light AST 时只访问必要节点。
  • Stub Index 实现 LightStubBuilder
  • lazy-parseable 元素如果通常不含 stub,可实现 StubBuilder.skipChildProcessingWhenBuildingStubs()
  • XML 索引可评估 NanoXmlUtil

开发实例会在 logs 目录生成 indexing performance metrics,2021.1+ 还有 HTML 格式。性能问题不要凭感觉猜,先看指标。

Shared Indexes

大项目可以考虑 shared project indexes。它能减少用户本地首次索引成本,但会带来生成、存储、版本匹配和 CI 维护成本。适合:

  • 企业内大型单仓。
  • IDE 和项目版本较稳定。
  • 有 CI 资源生成预构建索引。

检查清单

  • 外部文件生成后有异步刷新。
  • VFS 事件按项目、内容根和文件类型过滤。
  • 不在 read lock 内同步刷新。
  • 不全项目遍历 PSI 替代索引。
  • Dumb Mode 下不访问索引,或显式延迟到 Smart Mode。
  • 索引构建不加载完整 AST,除非确实必要。
  • 单文件懒计算用 Gist,而不是滥建全项目索引。
  • 大项目用指标验证索引耗时。

参考来源