简介:面向基于 JetBrains Runtime 17.0.9 与 IntelliJ IDEA 2023+(兼容 2024)的插件开发者,《Intellij idea PlugIn插件开发手册(下)》是一份聚焦语言类插件开发的 PDF 教程。手册由上册、下册及附录构成完整体系,本册对应第三部分,重点讲解 PSI 程序结构接口、PSIFile/FileViewProvider/PSIElement 的使用、自上而下与自下而上的 PSI 浏览方式,以及 References 搜索与多解析结果处理等知识。若读者目标是编写代码自动完成、代码依赖管理、代码检查等付费级插件,可先掌握上册基础,再深入本册;若只想开发 UI 类效率插件,则可结合上册与附录按需查阅。资源包共 1 个 PDF 文件,压缩后约 9.73MB,目录从 PSI 结构到 References 解析逐层展开,便于按需定位。目前已有 218 人学习浏览,内容基于官方指导与个人实践经验整理,适合具备一定 IntelliJ IDEA 插件基础的开发者系统进阶,也可作为 JetBrains 平台语言类插件开发的知识清单与排错参考。
1. 插件开发的下半程:从 Demo 到能交付的插件
intellij idea 插件开发这件事,网上不缺入门教程,点个 Action、注册到 plugin.xml、跑一个 Hello World,半天就能摸到门。但做到能交付给团队用的插件,你会发现坑全在另一边:事件监听在哪个线程回调、PSI 能不能直接改、工具窗口为什么偶尔白屏、设置项存了为什么重启就丢。这些是上册不太会展开的细节,却恰恰是 ide 插件开发里决定生死的部分。这篇拆解的《Intellij idea PlugIn插件开发手册(下)》,核心就是把这些进阶链路串起来:事件与异步边界、PSI 操作、避坑记录、工具窗口与持久化,最后落到我平时调试插件的一套习惯。适合已经写过至少一个 Action、想把自己插件做扎实的开发者。
2. 事件与异步边界:监听、后台任务和线程模型的三个关键点
插件要做到「像 IDE 原生功能」而不是「一个能跑的按钮」,第一关就是事件与线程。消息总线解决组件之间怎么通信,线程模型决定代码在哪条线程上跑,两套体系经常要在一个功能里同时面对。
2.1 事件总线:从 MessageBus 到 topic 订阅
MessageBus 是平台内部各组件通信的骨架,Action、编辑器、项目打开关闭这些动作,都通过它广播。插件的正确姿势是定义自己的 Topic,而不是去监听一大堆系统事件。
// 1. 定义自己的监听接口和 Topic public interface MySettingsListener { void onFilterChanged(String filterId); } public final class MyTopics { public static final Topic<MySettingsListener> FILTER = Topic.create( "com.example.myplugin.settings.filter", MySettingsListener.class ); private MyTopics() {} }接口方法的参数尽量少,回调本身要轻。Topic.create 的第一个参数是全局唯一字符串,建议用包名做前缀,避免和其他插件冲突;第二个参数是要广播的 listener 接口。这段代码定义了一个项目事件类型,后面所有设置变更的通知都走这一个入口。
订阅时最关键的是拿到 Connection 并绑好生命周期:
// 2. 在项目级 Disposable 上订阅,项目关掉自动解绑 project.getMessageBus() .connect(project) .subscribe(MyTopics.FILTER, new MySettingsListener() { @Override public void onFilterChanged(String filterId) { // 注意:这里仍在发送线程上,别做重活 ApplicationManager.getApplication() .invokeLater(() -> myPanel.refresh(filterId)); } });发布端用project.getMessageBus().syncPublisher(MyTopics.FILTER).onFilterChanged(id)触发。这里有一个常见误用:直接在回调里刷新 UI 或碰 PSI。回调线程跟着发布方走,如果你在后台任务里发布,回调也在后台线程;强制在回调里操作 UI 就会偶发报错。我一般只在回调里做两件事:收集状态,然后invokeLater切回 EDT。连接对象通过connect(project)绑到了项目生命周期,项目关闭时订阅自动解绑,不需要手动清理。
另一个值得注意的点是应用级和项目级的区分。全局性质的设置变更用ApplicationManager.getApplication().getMessageBus(),项目相关的用project.getMessageBus()。选错了范围,轻则产生游离监听,重则项目关闭后回调还在跑,排查起来非常费时间。
2.2 后台任务与线程边界:ReadAction、WriteAction 和模态状态
IntelliJ 平台的核心线程规则只有一句话:读 PSI 可以并发,写 PSI 必须独占,UI 操作必须在 EDT。三个操作分别对应 ReadAction、WriteAction、EDT,混用是插件崩溃的主要来源。
// 现代写法:用协程做后台解析,再切回 EDT 刷新面板 val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) scope.launch { val result = ReadAction.compute<String?> { // 在 read action 里做耗时但只读的 PSI 遍历 myFile.text.lines().firstOrNull { it.contains("TODO") } } withContext(Dispatchers.EDT) { myList.setItems(listOfNotNull(result)) } }ReadAction.compute 的 lambda 内部不能有任何修改 PSI 或文档的动作,否则会抛异常。EDT 的切换用 Dispatchers.EDT,比老的 invokeLater 回调嵌套好维护。注意 scope 要跟随插件组件生命周期,组件 dispose 时scope.cancel(),否则后台协程可能引用已关闭的 Project,造成内存泄漏。
真正需要修改文件时,必须走 WriteCommandAction,因为只有它会在编辑器里留下 undo 记录,这是用户能接受插件改代码的前提:
WriteCommandAction.runWriteCommandAction(project) { // 在这里执行 document 替换、PsiElement 增删等修改 // lambda 内部不要再启动协程或其他写操作 }提示:WriteCommandAction 的 lambda 里不要再嵌套另一个 write action,同一线程里重复获取写锁会直接抛出 IllegalStateException,而且 undo 记录会变得混乱。
2.3 一个完整示例:文件保存前收集信息并刷新面板
把三块拼起来看会更清楚。假设插件面板要展示当前打开文件里的 TODO 列表,文件保存时需要刷新数据。
// 订阅文档保存事件,这是系统已提供的 Topic project.getMessageBus().connect(project) .subscribe(FileDocumentManagerListener.TOPIC, new FileDocumentManagerListener() { @Override public void beforeDocumentSaving(@NotNull Document document) { VirtualFile file = FileDocumentManager.getInstance().getFile(document); if (file == null || !isSupported(file)) return; // 保存入口处拿到文件引用,实际解析放到后台线程 ApplicationManager.getApplication().executeOnPooledThread(() -> { PsiFile psi = PsiManager.getInstance(project).findFile(file); if (psi == null) return; String[] todos = collectTodos(psi); ApplicationManager.getApplication().invokeLater(() -> myPanel.update(todos)); }); } });这里的流程是:保存事件在 EDT 触发 → 丢到池化线程做 PSI 解析 → 再切回 EDT 更新 UI。看似绕,实际是平台推荐的路径。不要试图省掉中间的线程切换,否则在低配置机器上很容易复现 UI 卡顿或Already disposed异常。线程规则速查表我贴在工位旁边,每次不确定时先看一眼:
| 操作 | 线程 | 手段 |
|---|---|---|
| 读 PSI / 遍历语法树 | 任意,建议后台 | ReadAction.compute |
| 改 PSI / 文档 | 必须独占 | WriteCommandAction |
| UI 控件操作 | EDT | Dispatchers.EDT / invokeLater |
| 纯后台计算(不碰 PSI) | 任意池化线程 | executeOnPooledThread |
这张表是排查线程问题的第一参照物。插件开发里最烧时间的错误类型,九成都能在上面找到答案。
3. 动 PSI 与虚拟文件:代码分析与快速修复的落地细节
上一章讲的是骨架,事件怎么来、代码在哪跑;这一章要碰真正的肉:PSI。PSI 是 IntelliJ 对源码建立的结构化模型,相当于把文本解析成可编程的树。任何「读懂代码」的插件,本质上都在和 PSI 打交道。
3.1 从 VirtualFile 到 PsiFile:解析边界与缓存
VirtualFile 是文件系统的抽象,PSI 是文件内容的结构化表示。两者通过 PsiManager 桥接。
// 常见做法:把 VirtualFile 转成 PsiFile VirtualFile vFile = e.getData(CommonDataKeys.VIRTUAL_FILE); if (vFile == null) return; PsiFile psiFile = PsiManager.getInstance(project).findFile(vFile); if (psiFile == null) return;这里有个重要边界:PSI 只在项目加载的文件上有效。如果 vFile 不属于当前项目,或者文件刚创建还没来得及索引,findFile 可能返回 null。不要假设它一定非空,所有拿到 PsiFile 的地方都要判空。缓存方面,PSI 树本身有内部缓存,代价是它依赖文件内容版本:如果你持有某个 PsiElement 太久,文件一变,它就可能失效。经验是:进入后台任务时重新解析,不要把上一步的 element 一路传进去。
3.2 遍历语法树与解析符号:别手写递归,平台早替你想好了
分析代码时,最常见的需求是「找到这个类里所有特定类型的方法调用」。手写递归遍历 AST 也能实现,但平台提供了一堆现成工具,用熟之后代码量能砍掉一半。
// 收集某个 PsiElement 子树下所有特定类型的节点 List<PsiMethodCallExpression> calls = PsiTreeUtil.collectElementsOfType(psiClass, PsiMethodCallExpression.class) .stream() .filter(call -> call.getMethodExpression().getReferenceName() != null) .toList();PsiTreeUtil.collectElementsOfType 是按类型做广度收集,返回顺序不保证稳定。如果需要精确顺序或带上下文判断,可以用访问者模式:
psiClass.accept(new PsiRecursiveElementVisitor() { @Override public void visitMethodCallExpression(PsiMethodCallExpression expr) { PsiMethod method = expr.resolveMethod(); if (method != null && "execute".equals(method.getName())) { // 记录调用点信息,比如文件路径和行号 } super.visitMethodCallExpression(expr); } });这里很容易踩的一个坑是resolveMethod()返回 null。常见原因有三个:方法声明在外部依赖里但 jar 没被索引;调用发生在注释或字符串文本里但被访问者遍历到;还有泛型擦除导致解析失败。遇到 null 不要慌,先看调用点上下文,再决定是提示用户还是跳过。
3.3 修改 PSI 的正规路径:从 WriteCommandAction 到文档提交
当插件要提供「一键重命名变量」「插入一段模板代码」这类功能时就涉及修改。修改 PSI 有两条路径:直接改 PsiElement,或改 Document 文本。前者适合结构化增删,后者适合文本替换。两条路都必须包在 WriteCommandAction 里。
WriteCommandAction.runWriteCommandAction(project) { // 路径一:创建新 PsiElement 再插入 val factory = PsiElementFactory.getInstance(project) val newMethod = factory.createMethodFromText( "public void reload() { load(); }", targetClass ) targetClass.add(newMethod) // 路径二:直接替换文档文本 val document = FileDocumentManager.getInstance().getDocument(vFile) document?.replaceString(start, end, newText) }路径一走完,PSI 树会立刻更新;路径二改了 document 后,PSI 和 document 会暂时不同步,必须做一次提交把磁盘、Document、PSI 三者对齐:
PsiDocumentManager.getInstance(project).commitDocument(document)不 commit 的后果很隐蔽:第一次调用可能正常,第二次读到的是旧 PSI,表现为「我明明改了文本,查询结果却没变」。而且这个 bug 几乎不会在调试时暴露,因为你总会打开编辑器看一眼,编辑器一激活,平台自动做了一次隐含提交。血泪经验:凡是改动后马上要查 PSI 的逻辑,都强制 commit。
改完 PSI 后通常还要做一次格式化,否则插件生成的代码缩进和括号风格跟项目风格不一致,用户第一眼就会觉得不对劲:
CodeStyleManager.getInstance(project).reformat(newMethod)这一步直接决定插件输出的代码观感。跑完写入后顺手 reformat,能少收很多「生成的代码格式乱」之类的反馈。
4. 插件开发避坑手册:五条反复踩过的记录
这一章我把手册里反复出现的报错和挫败感整理成条目。每条都有现象、原因、解决,按实际排查顺序排列。
4.1 先说排查顺序:日志、最小复现、再看异常栈
插件报错和普通应用不一样:IDE 会吞掉很多异常,界面上只闪一下。所以最开始要固定一套排查顺序。第一件事打开 idea.log,路径是 Help > Show Log in Explorer;第二件事看异常栈最上面是不是你自己的类,确认问题归属;第三件事把操作缩小到最小复现——新建一个空项目,只保留必要插件,能让它稳定复现,再回来找原因。这套顺序能解决至少六成的问题,剩下的才需要断点和更深入的线程分析。
4.2 坑一:后台线程拿着 PsiElement 到处跑
现象:插件偶尔抛PsiInvalidElementAccessException,有时干脆静默返回 null,难以稳定复现。原因:把某个 PsiElement 从后台线程传到另一个后台线程,文件被编辑后,原 element 已不属于当前 PSI 树,访问自然失效。解决:不要在任务间传 element,传 VirtualFile 和 offset,回到目标线程后用 PsiManager.findFile 重新解析。这条规则我在代码评审里提得最多,几乎每个新人都犯过。
4.3 坑二:WriteAction 嵌套引发的修改被拒
现象:明明写了WriteCommandAction.runWriteCommandAction,还是报修改失败,而且只在某些菜单路径下必现。原因:多数情况是内层 lambda 里又发起了一个新的异步写入,或者修改代码没有真正放进外层 lambda;IntelliJ 的写锁是单线程独占,重入会直接抛异常。解决:检查修改代码是否真的在 runWriteCommandAction 的 lambda 里;如果外层已经处于写锁,就不要再包一层,直接把修改逻辑写成普通代码块。另外一个排查技巧:报错堆栈里如果出现assertWriteAccessAllowed,基本就是写锁上下文问题。
4.4 坑三:工具窗口里 resolve 返回 null
现象:工具窗口面板打开时调用PsiReference.resolve()返回 null;同样的代码放到 Action 里执行就正常。原因:工具窗口初始化时文件可能还没完成索引,或当前没有活动 Editor,上下文拿不到对应模块。解决:在面板里做解析时,一定要先PsiManager.findFile拿 PsiFile,再基于 PsiFile 解析;索引未完成时用 DumbService 等待:
DumbService.getInstance(project).runWhenSmart(() -> { // 索引就绪后再执行解析逻辑 PsiReference ref = psiElement.getReference(); if (ref != null) resolve(); });DumbService 是处理「索引未就绪」的标准姿势,工具窗口、后台任务里凡是涉及解析的地方都应该考虑加一层。
4.5 坑四:PropertiesComponent 存了列表取回来是空的
现象:设置窗口里保存了几条 List,重启后列表为空,其他简单字符串却正常。原因:PropertiesComponent 本质是 Properties 键值存储,塞对象进去会走 toString,取出来只是一段字符串,平台不会帮你反序列化。解决:简单标量用它没问题;复杂结构改用 PersistentStateComponent,或者自己把 List 序列化成 JSON 字符串再存。我一般会在设置对象里放一个 JSON 工具方法,把数据和存储格式解耦,迁移也不影响旧版本用户。
4.6 坑五:升级 IDE 后自定义 Action 消失
现象:用户反馈升级后插件在菜单里没了,但插件管理页显示已启用。原因:since-build与until-build范围写得太窄,新 IDE 大版本号直接超出上限;或者依赖了已移除的 internal API。解决:发布时把 until-build 留长或直接去掉,让平台自己判断兼容性;代码里尽量用公开 API,别依赖 internal 类。不确定某个类是否属于 internal,看依赖里有没有com.intellij.internal包,或者类上有没有@Internal注解。这个坑最坑的地方在于你自己的开发环境永远测不出来,只能靠发布前检查。
5. 工具窗口、设置持久化与分发配置:收尾的三件套
前三章把插件最难的部分讲完了,最后一关是把这些能力装进 IDE 界面,并且让它在别人机器上能用。工具窗口、设置存储、构建配置,这三样决定插件「看起来像不像一个正式插件」。
5.1 ToolWindow 注册与面板刷新策略
工具窗口不等于一个普通 JPanel,它要跟着 Project 生命周期走。注册在 plugin.xml 里做:
<extensions defaultExtensionNs="com.intellij"> <toolWindow id="MyAssistant" anchor="right" icon="/icons/logo.svg" factoryClass="com.example.myplugin.MyToolWindowFactory"/> </extensions>对应的工厂类:
public class MyToolWindowFactory implements ToolWindowFactory { @Override public void createToolWindowContent(@NotNull Project project, @NotNull ToolWindow toolWindow) { MyMainPanel panel = new MyMainPanel(project); Content content = ContentFactory.getInstance() .createContent(panel, "", false); toolWindow.getContentManager().addContent(content); panel.startListening(project); } }anchor 指的是停靠方位,可选值有 left、right、bottom。createContent 的第三个参数 false 表示不包滚动面板:如果面板内部本身有 JScrollPane,这里就该 false;如果面板内容简单需要自动滚动,设 true 更省事。面板刷新时不要重复 addContent,应该保留 content 引用,在需要时替换 content 里的 JComponent,或者用 toolWindow.show() 主动唤起。
5.2 PersistentStateComponent:比 PropertiesComponent 靠谱的结构化存储
凡是设置项超过三个字段,我就默认用 PersistentStateComponent 而不是 PropertiesComponent。前者的存储是 XML,结构清晰,支持嵌套对象。
@State(name = "MyPluginSettings", storages = [Storage("myplugin-settings.xml")]) @Service(Service.Level.PROJECT) class MyPluginSettings : PersistentStateComponent<MyPluginSettings> { var serverUrl: String = "" var filter: MutableList<String> = mutableListOf() var lastRunAt: Long = 0L override fun getState(): MyPluginSettings = this override fun loadState(state: MyPluginSettings) { serverUrl = state.serverUrl filter = state.filter.toMutableList() lastRunAt = state.lastRunAt } }getState 返回自身即可,loadState 负责从磁盘恢复。平台在 IDE 退出时自动调用 getState,序列化到项目目录下的 myplugin-settings.xml。注意 Service.Level.PROJECT 表示每个项目一份,如果是全局设置改成 Service.Level.APP。loadState 里手动复制字段而不是直接引用 state,是为了避免下次 getState 时把同一个对象原样写回,造成脏数据。
5.3 构建配置与分发:别把测试版当发布版
打包这步,新版 IntelliJ Platform Gradle Plugin 推荐在 build.gradle.kts 里用声明式配置,而不是老式 apply 写法——apply 方式在 Kotlin DSL 下容易触发 Gradle 配置冲突,报错就是「should be applied via the plugins block」那一类。
plugins { id("org.jetbrains.kotlin.jvm") version "${kotlinVersion}" id("org.jetbrains.intellij.platform") version "${platformVersion}" } repositories { mavenCentral() intellijPlatform { defaultRepositories() } } dependencies { intellijPlatform { localPath(uri("D:/idea/dev/")) // 本地 IDEA 安装目录,调试用 // 发布前切到固定版本再做一次 clean build // version("2024.1") } }localPath 指向本机 IDE 目录,适合日常调试;发布前切到 version 做一次 clean build。打包产物在 build/distributions,zip 可以直接分发或上传插件市场。这里有一个容易忽略的点:如果你引用了第三方 jar,Gradle 会把它打进 lib 目录,分发包体积变大,还容易因为版本冲突导致用户加载时看到 NoClassDefFoundError。不需要嵌入的依赖记得用 compileOnly,打包前检查一下 build/distributions 里的 lib 目录。
发布前还要检查 plugin.xml 里的<depends>是否声明了需要的平台模块。只依赖com.intellij.modules.platform却用到 Java PSI,在开发机上可能没事,在用户机器上会晚一步报错,而且错误信息很不直观。
6. 调试插件的老手习惯:日志分级、断点策略与最后一块压舱石
调试 IDE 插件和调试普通应用不同,你不能随便打断点,一断就卡住整个 IDE 的 EDT。我的顺序是:日志在前,断点在后,最小复现兜底。
插件开发时用 Logger 分好级别,这是第一道防线。Action、监听器、工具窗口三个关键入口各打一条 info,方法内部用 debug 打参数,循环体里用 trace 打过程。将来用户报问题时,先让他打开 Help > Show Log,把 idea.log 里和你的插件包名相关的行发过来。大部分问题一眼就能定位是空指针还是线程异常:
private static final Logger LOG = Logger.getInstance(MyAction.class); public void actionPerformed(AnActionEvent e) { LOG.info("action triggered from: " + e.getPlace()); try { // 业务逻辑 LOG.debug("parsed file: " + file.getName()); } catch (Exception ex) { LOG.error("failed to process file", ex); } }断点策略上,我只在三个地方打断点:事件回调入口、WriteCommandAction lambda 里的第一行、从后台线程切回 EDT 之后的 UI 刷新代码。这三个点能覆盖绝大多数问题的定位。其他地方用日志配合,避免断点卡住 EDT 导致 IDE 假死。如果一定要在 UI 线程深挖,开一个独立的调试用 IDEA 实例跑插件,别在你正在开发的那个 IDE 上调试,边开发边调试会互相干扰。
每个功能合入前,我强制走一遍最小复现三件套:清空日志后在干净项目里操作一次;连续操作十次以上,观察是否有偶发线程异常;改一次代码存储,重启 IDE 验证设置能恢复。从那以后我每次动 PSI 之前都会强制检查自己是否在 WriteCommandAction 里,这个习惯帮我挡掉了不少半夜的翻车。希望帮到你。
本文还有配套的精品资源,点击获取