资讯动态

IntelliJ IDEA插件开发:从Action到PSI的工程实践与避坑指南

发布时间:2026/10/8 9:02:36 来源:尧图企业网站定制
简介这份Intellij Platform插件开发手册上册是一份面向IntelliJ IDEA插件开发者的系统指导基于JetBrains Runtime 17.0.9与IDEA 2023兼容2024版本编写主要服务希望开发框架集成、代码统计、效率工具等UI类插件的初学者和进阶者。手册为单个PDF文档大小15.82MB正文聚焦插件开发基础与图形化插件开发两大部分目录清晰、层级分明。内容依托官方指引、作者个人实践及网络资料整理而成系统覆盖开发环境要求、插件类型与开发流程、参考网站等基础模块并逐步演示了从工程创建、配置到测试的完整流程适合按图索骥。上册还特意区分不同插件类型的学习路径若目标是UI类插件可重点掌握第一部分基础与第二部分图形化开发若未来计划转向语言类高级插件也能通过第一部分打好根基再进入下册学习。目前已有383人学习浏览是一份值得收藏的入门与进阶参考。1. 这本手册(上)真正想讲的能力让 IDE 在正确时机回调你的代码做 IDEA 插件和做普通后端服务的思路完全不一样。普通服务是你控制入口插件则是把你的代码塞进别人的事件循环和扩展点里用户点一下菜单、打开一个文件、保存一次文档IDE 才会回调你的代码。这也是《Intellij Platform PlugIn插件开发手册(上)》这类入门手册最值钱的部分——它把一个插件工程里最难讲清的骨架Gradle 工程、plugin.xml、Action/Extension/Listener一次性交代完让没有接触过平台开发的人不至于连工程都建不起来。这个方向真正适合的人不是想设计炫酷界面的而是手里积压了大量重复操作的一线工程师批量改代码、批量补注释、把团队规范变成菜单按钮。这篇笔记把这套东西的落地路径重新走一遍从空工程到能点、能挂、能改代码再把最容易翻车的几个位置标出来。2. 把工程模板跑起来从 New Project 到第一个 Hello Action2.1 开发 IntelliJ 插件第一步让 Gradle 骨架先跑通新建插件工程最简单的方式是直接用 IntelliJ IDEA 的 New Project 向导。新版向导里选 IntelliJ Platform Plugin语言选 Java 或 Kotlin它会生成一个完整的 Gradle 工程而不是只给几个源文件。这里有个角色要分清你当前打开的这个 IDEA 是“开发工具”你代码里依赖的intellij坐标指向的是“被开发的平台”。插件编译时不会把整个 IDE 打包进来它只声明了在哪个 IDE 版本上开发以及用什么版本去启动沙箱。plugins { java id(org.jetbrains.intellij) } group com.example version 1.0.0 repositories { mavenCentral() } intellij { // 这个版本号决定编译 API 和沙箱 IDE 版本以你本机向导生成值为准 version.set(2024.1) type.set(IC) plugins.set(listOf(com.intellij.java)) } tasks { patchPluginXml { sinceBuild.set(231) untilBuild.set(241.*) } }这段配置里version.set(2024.1)是最关键的参数它决定了编译时依赖的平台 API 版本也决定了 Run Plugin 时启动哪个版本的沙箱 IDE。type.set(IC)表示社区版如果需要 Java 语言相关的 PSI 能力plugins.set(listOf(com.intellij.java))不能省。patchPluginXml里的sinceBuild和untilBuild是插件兼容版本区间自己调试可以放宽但发布到插件市场时必须收敛到实际验证过的版本范围。第一次同步 Gradle 会拉取平台依赖耗时几分钟是正常的。如果提示找不到org.jetbrains.intellij插件多半是 Gradle 版本太老或网络源不通优先检查gradle-wrapper.properties里的 Gradle 版本是否和当前向导生成的一致而不是急着手动换源。工程跑通的标准不是“能编译”而是 Run 配置里出现Plugin条目。2.2 写第一个 Action继承 AnAction 再注册进 plugin.xmlGradle 骨架没问题后插件的最小功能单元是 Action也就是菜单项。一个 Action 由两部分组成一个继承AnAction的 Java 类以及 plugin.xml 里的一条注册记录。新手最常犯的错是把这两步当成可选项只写了类忘了注册结果跑到沙箱里菜单什么都没有。package com.example; import com.intellij.openapi.actionSystem.AnAction; import com.intellij.openapi.actionSystem.AnActionEvent; import com.intellij.openapi.ui.Messages; import org.jetbrains.annotations.NotNull; public class HelloAction extends AnAction { Override public void actionPerformed(NotNull AnActionEvent e) { Messages.showInfoMessage(插件能跑了, Hello Plugin); } }idea-plugin idcom.example.hello/id nameHello Plugin/name vendorexample/vendor actions action idcom.example.HelloAction classcom.example.HelloAction textHello Plugin description弹出提示 add-to-group group-idToolsMenu anchorlast/ /action /actions /idea-pluginclass属性填的是全限定名这里最容易写错少写一个包名编译期不报错运行到点击菜单时才炸出 ClassNotFound。id是插件内部的唯一标识规范做法是带包名前缀如果随手写个hello以后和其他插件撞了 id菜单会异常消失。anchorlast表示把按钮加到 Tools 菜单底部调试阶段放在那里最显眼不会和 IDE 原生菜单混在一起找不到。update方法暂时不用管平台默认会调用基类实现按钮恒为可用状态。2.3 第一次运行Run Plugin 背后发生了什么写好代码后右上角选择PluginRun 配置点击运行。这个动作会依次执行prepareSandbox、buildPlugin等 Gradle 任务把插件产物复制到build/idea-sandbox目录再启动一个全新的 IDE 实例。那个新实例里就能看到 Hello Plugin。这里有一个容易误会的点沙箱 IDE 是干净的不带本机的个性化设置和已装插件所以不要用“我本机设置里调整过”来掩盖问题。如果运行后没看到菜单项先到沙箱窗口的 Settings | Plugins 里确认你的插件是否在列表里。不在就说明产物没被装进去删掉build/idea-sandbox重新运行通常能解决。此时先不要怀疑逻辑平台插件的调试路径里七成问题出在构建或注册环节。3. 把插件挂进 IDEActions、Extensions、Listeners 三种接线方式怎么选3.1 Actions 不止是菜单项ActionGroup 把散装按钮变成下拉分组做 idea 插件开发第一个绕不开的概念就是 Action。单个 Action 只能算一个按钮实际插件通常需要一组功能比如“代码生成”下挂四个不同模板。这时要用 ActionGroup。继承ActionGroup在getChildren里返回子 Action 即可。package com.example; import com.intellij.openapi.actionSystem.ActionGroup; import com.intellij.openapi.actionSystem.AnAction; import com.intellij.openapi.actionSystem.AnActionEvent; import org.jetbrains.annotations.NotNull; public class GenerateGroup extends ActionGroup { Override NotNull public AnAction[] getChildren(AnActionEvent e) { return new AnAction[]{ new HelloAction(), new AnotherAction() }; } }action idcom.example.GenerateGroup classcom.example.GenerateGroup textGenerate Group popuptrue add-to-group group-idEditorPopupMenu anchorfirst/ /actionpopuptrue决定这个分组在菜单里以弹出子菜单形式出现。如果子项固定更省事的写法是把子 Action 直接声明在 XML 里而不是代码里返回但代码方式在需要根据当前上下文动态隐藏某些子项时更灵活。Action 体系的重点不是“实现一个按钮”而是理解ActionManager它负责按 id 查找、注册和缓存动作实例你可以在任意位置用ActionManager.getInstance().getAction(com.example.HelloAction)拿到同一个实例。同一 Action 在不同菜单里显示的是同一对象所以不要在 Action 里存用户相关的临时状态。3.2 Extensions 才是真正的“扩展点”以 ToolWindow 为例Actions 面向用户点击Extensions 面向 IDE 本身。IDE 会在特定时机扫描 plugin.xml 里声明的扩展并实例化对应类比如侧边工具栏、设置面板、文件编辑器下方的提示条。这个体系叫扩展点每个扩展点有固定的 schema插件只需要在extensions块里按约定填属性。extensions defaultExtensionNscom.intellij toolWindow idSampleTool anchorright factoryClasscom.example.ToolWindowFactoryImpl /toolWindow /extensionspackage com.example; import com.intellij.openapi.project.Project; import com.intellij.openapi.wm.ToolWindow; import com.intellij.openapi.wm.ToolWindowFactory; import com.intellij.ui.content.Content; import com.intellij.ui.content.ContentManager; import org.jetbrains.annotations.NotNull; import javax.swing.JLabel; public class ToolWindowFactoryImpl implements ToolWindowFactory { Override public void createToolWindowContent(NotNull Project project, NotNull ToolWindow toolWindow) { ContentManager manager toolWindow.getContentManager(); Content content manager.getFactory() .createContent(new JLabel(这里放你的插件面板), Main, false); manager.addContent(content); } }扩展点编程和 Action 最大的区别是生命周期扩展类由平台实例化你不控制构造时机不要在createToolWindowContent里做耗时初始化否则会拖慢 IDE 启动。所有要在 UI 上展示的内容都要在回调里搭好anchorright决定停靠方位常见还有left、bottomid必须全局唯一否则插件加载阶段就直接起冲突。这个模式的难点在于查扩展点定义每个扩展点接口不同、必填属性也不同最常见的做法是在idea.wiki或已有开源插件里搜同一个epName照着写不会错太远。3.3 Listeners 接住事件不是等用户点而是等 IDE 通知插件要响应“用户没点你的菜单”的瞬间比如项目打开、文件保存、光标移动靠的是 Listener。Listener 分 application 级和 project 级两类选择原则很简单关注整个 IDE 生命周期用 application只关心当前项目的状态用 project。注册方式看起来和扩展很接近但语义完全不同。package com.example; import com.intellij.openapi.diagnostic.Logger; import com.intellij.openapi.project.Project; import com.intellij.openapi.project.ProjectManagerListener; import org.jetbrains.annotations.NotNull; public class ProjectWatcher implements ProjectManagerListener { private static final Logger LOG Logger.getInstance(ProjectWatcher.class); Override public void projectOpened(NotNull Project project) { LOG.info(project opened: project.getName()); } }applicationListeners listener classcom.example.ProjectWatcher topiccom.intellij.openapi.project.ProjectManagerListener/ /applicationListenerstopic是接口全限定名class是实现类。如果 listener 只对某个项目感兴趣需要自己在回调里判断Project是否匹配平台不会帮你过滤。Listener 回调跑在 UI 线程上里面绝对不能做网络请求、大文件扫描这类耗时操作否则整个 IDE 菜单都会卡住需要重活时丢给ApplicationManager.getApplication().executeOnPooledThread()拿到结果再切回 UI 线程更新界面。4. 插件开发避坑五个比功能更早出现的“玄学”报错这些坑集中出现在“第一个能跑的插件”到“第一个能用的插件”之间。它们不会暴露在编译期只能在运行时通过日志和沙箱行为去判断每一条拆开看都很简单但合在一起足够让人怀疑人生。4.1 报了 ClassNotFound先查三处而不是重编译现象点击插件菜单IDE 弹java.lang.ClassNotFoundException: com.example.HelloAction或插件在 Plugins 列表里直接显示“不可加载”。原因绝大多数情况不是缺 jar而是 plugin.xml 里class属性和实际包名不一致。类写在com.example.actions包里XML 里却填com.example.HelloAction编译不报错运行时才炸。另一种可能是类放进了默认包Gradle 没有把它编进产物。解决先在 Project 面板确认类的全限定名再对比 plugin.xml。接着右键 plugin.xml选Verify Plugin XMLDevKit 会直接高亮无法解析的 class。把两条路径统一后重新./gradlew buildPlugin再到沙箱里看插件详情错误信息会变成更具体的问题。这个校验动作建议每次改完 plugin.xml 都做一次成本几乎为零。4.2 功能在沙箱里正常打包给别人就 NoClassDefFound现象自己 Run Plugin 一切正常把 build/libs 下的 zip 装到另一台机器或分发到团队内网后插件一加载就 NoClassDefFound。原因在 build.gradle.kts 里把第三方库加成了implementationGradle 把依赖的类直接打进插件 jar而 IDE 本身可能已经带同名的类运行时两者冲突或者依赖类根本没进制品。沙箱能跑是因为开发环境的 classpath 里恰好有这些类。解决凡是 IntelliJ 平台已经提供的包一律用compileOnly不要打成implementation。真正需要捆绑的第三方库用 shadow/embed 方式打进子目录并在打包后检查META-INF里有没有和 IDE 冲突的包名。判断标准很简单插件 zip 的体积如果超过预期很多多半是把 IDE 自带的库又捆了一遍。4.3 JAVA_HOME 和 JBR 版本错位沙箱起不来现象Run Plugin 执行到prepareSandbox后Gradle 报错或沙箱进程秒退日志里有UnsupportedClassVersionError。更隐蔽的是本地 JDK 是 11但平台版本需要 17编译能过运行时类加载直接挂。原因IntelliJ Platform 新版本编译产物要求更高版本的字节码编译器和运行沙箱的 JBR 版本如果与 JAVA_HOME 不一致就会出现这类错位。解决在gradle.properties里显式设置org.gradle.java.home到本机确认的 JDK 17/21 路径同时让 Gradle JVM 和 Project SDK 指向同一个版本。改完 JAVA_HOME 记得执行./gradlew --stop重启守护进程否则 Gradle 还在用旧 JVM改了等于没改。4.4 沙箱启动后弹出“must install the j2se plugin”现象启动沙箱 IDE界面提示类似you must install the j2se plugin version ...插件里和 Java 相关的功能全部不可用。原因插件依赖com.intellij.java这个模块但沙箱的 IDE 实例没有把它带进来。常见于intellij { plugins }里没声明或者沙箱的模块缓存还停留在旧状态。这条提示很误导人它不是让你去手动装插件而是工程声明的依赖模块没生效。解决回到 build.gradle.kts确认plugins.set(listOf(com.intellij.java))然后删掉build/idea-sandbox重新运行。我曾经在这条提示上卡了一个下午最后只是清了一次沙箱。4.5 插件代码里调用 System.exit会把整个 IDE 一起带走现象插件调试时想快速退出写了System.exit(0)。结果沙箱 IDE 整个消失连日志都没留下再次运行还提示端口占用。原因插件跑在 IDE 进程内没有独立进程边界。退出调用不是结束插件而是结束整个宿主。解决用异常、用LOG.error、用Messages.showErrorDialog让用户知道出了问题而不是结束进程。真遇到 JVM 崩溃才需要看 hs_err 日志。这条是血泪经验从命令行工具转过来的人基本都会踩一次。5. 让插件开始改工程代码Project、PSI 与 VFS 的配合5.1 从 AnActionEvent 里拿上下文Project、Editor、PsiFile 一条链插件要干活第一步是从AnActionEvent里拿到当前状态。最常见的组合是项目对象、编辑器对象、当前文件、光标位置。这些数据不能靠静态全局变量拿平台提供了CommonDataKeys作为统一入口。Project project e.getProject(); Editor editor e.getData(CommonDataKeys.EDITOR); if (project null || editor null) { return; } Document document editor.getDocument(); PsiFile psiFile PsiDocumentManager.getInstance(project).getPsiFile(document);CommonDataKeys.PROJECT和e.getProject()通常等价但EDITOR、PSI_FILE、VIRTUAL_FILE必须用getData查询因为这些依赖焦点位置。IDE 允许同时打开多个项目和多个窗口不存在“当前只有一个编辑器”这种假设。PsiDocumentManager的getPsiFile是把 Document 转成 PSI 文件的桥梁这一步拿不到时先检查传入的 Document 是否来自当前项目。拿到PsiFile后建议先判空再往下走。很多教程省略了这个判空导致用户右键普通文件时插件直接抛 NPE。判空不只是防御也是给用户反馈如果这不是你能处理的文件类型直接 return什么也不做。5.2 用 PSI 读代码结构不要用正则去解析 Java 文件写插件时最好抵抗住“用正则解析源码”的诱惑。正则匹配注释里的类名、字符串里的方法名都会翻车。PSI 把源码解析成结构化对象IDE 自身所有代码分析都建立在这个模型上。ReadAction.run(() - { if (psiFile instanceof PsiJavaFile javaFile) { for (PsiClass cls : javaFile.getClasses()) { for (PsiMethod method : cls.getMethods()) { if (method.hasModifierProperty(PsiModifier.PUBLIC)) { LOG.info(method.getName()); } } } } });所有 PSI 读取操作必须包在ReadAction.run里这是平台线程模型的基本规则。PsiJavaFile.getClasses()返回文件里的顶级类getMethods()返回类的全部方法hasModifierProperty(PsiModifier.PUBLIC)判断修饰符。这个例子输出一个类里所有 public 方法名就是做代码生成、模板补齐类工具的基础骨架。如果 PSI 遍历的成本明显偏高比如扫描大文件里所有引用考虑用PsiTreeUtil的搜索方法而不是自写递归。遍历时要缩小范围能定位到方法级就不要遍历整个文件。5.3 通过 VFS 落盘写文件之前先拿到 Document改代码和读代码是两套规则。读要ReadAction写必须放在WriteCommandAction里而且不能直接用java.io.File绕过虚拟文件系统否则 IDE 的缓存会失去同步。WriteCommandAction.runWriteCommandAction(project, () - { Document doc psiFile.getViewProvider().getDocument(); if (doc null) { return; } doc.insertString(0, // generated by plugin\n); });WriteCommandAction.runWriteCommandAction会在内部申请写锁并注册 undo 栈用户在 IDE 里可以撤销插件刚才的修改。doc.insertString(0, ...)是把新内容插到文件开头第二个参数是插入位置。写入后 IDE 会异步刷新 PSI 和 VFS不需要手动调用保存。这里最常见的错误是在循环里多次调用writeCommandAction每次提交一个命令性能差且 undo 历史碎片化。正确做法是把整批修改放进同一个runWriteCommandAction里一次性提交。如果要在写之前读取当前内容做判断也要把读逻辑放进同一个命令内避免插入位置计算的竞态。6. 快速自验不重启 IDE 也能确认插件逻辑没坏6.1 用日志和 plugin.xml 校验代替反复弹窗插件功能跑通后别再用弹窗验证代码走到了哪一步。弹窗会阻塞 UI 线程验证一次要手动点掉效率太低。在关键路径上用Logger.getInstance(某类.class).info(...)运行后在沙箱里通过 Help | Show Log 查看 idea.log日志文件路径在启动日志里也能找到。每次改完 plugin.xml 先右键执行 Verify Plugin XML再跑./gradlew buildPlugin编译期能拦住的问题不要让运行时背锅。6.2 把核心逻辑抽成纯 JavaJUnit 就能测如果大量逻辑写在 Action 类里测试就一定要启动沙箱成本很高。我一般把核心逻辑抽成无平台依赖的纯 Java 方法Action 只负责从上下文拿数据、调用核心方法、把结果展示出来。这样核心逻辑能用普通 JUnit 测不用启动 IDE。public class CodeGeneratorTest { Test void buildMethodSignature() { String result CodeGenerator.buildSignature(hello, 2); assertEquals(hello(String, int), result); } }这个测试跑在普通 JVM 上不到一秒钟。Action 变成一层薄壳后平台相关的 bug 只剩数据获取和线程切换两处定位范围小很多。我现在的习惯是凡是写完一段超过十行的处理逻辑先写一个这样的纯函数测试再往 Action 里塞。以前总是先跑插件再验证改一次参数就要重启一次沙箱一个下午只调完两个按钮把测试补上之后速度明显不一样。希望这些来自实践的选择和踩坑记录能帮你把 Intellij Platform 插件开发这条路走得更稳一点。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑