
简介面向 IntelliJ IDEA 的插件开发人员这份 PDF 手册是围绕 IntelliJ Platform 插件开发的系统指导内容依据动手实践与官方文档整理适合从零开始的初学者也适合想实现语言级插件的进阶开发者。手册由上下两册加附录组成共四个部分先讲插件架构、插件体系、生命周期、事件监听与 API 用法再深入 Action System、Tool Window、Popup Menu 等图形化界面开发随后转向自定义语言解析器、语法高亮、代码补全、代码分析等高级主题附录则集中给出 Gradle 配置、SDK 链接、参考书目、示例代码和社区资源。资源为单个 PDF大小约 3.99MB携带方便已有 876 人学习下载。相比零散教程这份手册把学习路径和常见问题一并讲清并明确基础图形插件与语言类插件各自的选读章节能帮助 Java 工程师减少试错快速搭建自己的 IntelliJ IDEA 扩展附录中的参考资源也能继续支撑后续深入实践。1. Intellij platform plugin开发指导手册在讲什么为什么你需要自己写Idea插件每天打开IntelliJ IDEA写代码、看报错、跑测试、对着diff发呆总有那么几个瞬间觉得某个重复动作要是能一键自动化就好了。Intellij platform plugin开发指导手册讲的就是把这些“要是能”变成真实插件。它面向java集成开发环境Intellij idea底层是JetBrains开放的Intellij platform SDK写插件的人用Java和Kotlin操作SDK把菜单、快捷键、右键菜单、Tool Window、编辑器里的高亮和代码生成全部变成自己可控的行为。适合读这本手册的人不是准备去做商业IDE的开发者而是每天被CRUD和脚手架折磨的Java后端、负责内部工具链的工程师以及想给团队批量提效的架构师。你不需要对IDE底层有很深了解但需要对Java和Gradle不陌生剩下的交给SDK和反复调试。2. 搭建Intellij idea插件工程SDK选型与Gradle骨架写插件的第一步不是写代码是先把工程骨架搭对。骨架搭错了后面所有东西都会在运行期以“玄学”的方式翻车。我建议直接用Gradle而不是传统DevKit的XML式工程前者在多人协作和CI打包上明显省心后者适合快速验证原型。下面按Gradle方案讲。2.1 用Gradle模板初始化Intellij idea插件项目在IDEA里新建项目时选择Intellij Platform Plugin模板IDEA会默认生成一个Gradle工程自带build.gradle.kts、src/main/java和src/main/resources/META-INF/plugin.xml。如果用的是Intellij idea社区版同样可以创建插件项目社区版对插件开发没有功能裁剪这算是社区版最值得利用的一个能力。需要注意工程里的gradle wrapper和JDK版本JetBrains的Gradle插件对新JDK支持会晚半拍建议先固定在JDK 17或者JDK 21这类LTS上。下面是一个最小可用的build.gradle.ktsplugins { id(java) id(org.jetbrains.intellij) version 1.17.4 } group com.example version 1.0.0 repositories { mavenCentral() } dependencies { implementation(com.google.guava:guava:33.0.0-jre) } intellij { // 指定插件构建依赖的IDEA版本建议用与目标用户一致的版本 version.set(2023.2.6) type.set(IC) // IC IntelliJ IDEA Community IU Ultimate plugins.set(listOf(com.intellij.java)) // Java插件依赖 } tasks { patchPluginXml { sinceBuild.set(232) untilBuild.set(233.*) } }这段配置说明了三点。type字段决定你是基于社区版还是旗舰版做构建plugins声明你依赖的其他插件模块比如com.intellij.java能让你的插件读取到Java语言的PSI结构patchPluginXml里的sinceBuild和untilBuild是兼容性边界写得太宽会让IDEA以为你的插件兼容所有旧版本实际跑起来直接报“Plugin Error”。如果你是给团队内部用untilBuild甚至可以不加因为内网分发可控。提示org.jetbrains.intellij插件版本和IDEA版本没有严格绑定但旧版Gradle插件解析新版IDEA SDK偶尔会失败升级SDK版本时顺手把Intellij Gradle插件也升一下能省掉不少奇怪的下载报错。2.2 配置plugin.xml身份信息、扩展挂载点与Action注册plugin.xml是整个插件的“身份证加户口本”。身份信息是id、name、version声明扩展用的是extensions注册动作历史写法用actions。IDEA在启动时按它加载插件不识别的字段只给警告不报错所以很多插件加载失败的诡异问题根源都是这里写漏了属性或者写错了挂载点。idea-plugin idcom.example.my-plugin/id nameMy Java Helper/name version1.0.0/version vendor emaildevexample.com urlhttps://example.comExample Dev/vendor description![CDATA[ 一个给Java开发提效的Idea插件示例包含菜单动作和工具窗口。 ]]/description dependscom.intellij.modules.platform/depends dependscom.intellij.modules.java/depends extensions defaultExtensionNscom.intellij notificationGroup idMyPluginNotification displayTypeBALLOON toolWindowIdMyToolWindow/ toolWindow idMyToolWindow anchorright icon/icons/my-tool.svg factoryClasscom.example.MyToolWindowFactory/ /extensions actions action idcom.example.GenerateBoilerplateAction classcom.example.GenerateBoilerplateAction textGenerate Boilerplate description批量生成Java样板代码 add-to-group group-idEditorPopupMenu anchorfirst/ keyboard-shortcut keymap$default first-keystrokecontrol alt G/ /action /actions /idea-plugin这里要特别留意depends。只写com.intellij.modules.platform时你的插件只拿到了最基础的平台API用PsiJavaFile相关类会直接NoClassDefFoundError加上com.intellij.modules.java后才真正有Java语言支持。这也是新手最常见的“手册里明明教了运行就崩”的头号原因。另外actions里的keyboard-shortcut要写明keymap$default否则快捷键不会注册到默认键盘映射方案里用户自定义键位后你的快捷键就丢了。2.3 用runIde启动开发实例热重载与断点调试工程建好后用./gradlew runIde启动一个安装了当前插件的IDEA实例。这个开发实例跑起来很费内存建议至少在gradle.properties里配org.gradle.jvmargs-Xmx2g不然编译时Gradle先被OOM然后你会以为是插件写错了。# 启动开发实例过程中会下载对应版本的IDEA首次较慢 ./gradlew runIde # 如果只想验证编译不启动IDE ./gradlew compileJava # 打包插件为zip用于分发到别的机器 ./gradlew buildPluginrunIde调试时有个习惯值得早点养成别在开发实例里手工点出操作再去看日志直接在IDEA的Run配置里把调试端口开出来然后attach到开发实例。这样插件里任何异常都会在断点处停下你可以在actionPerformed入口处打第一个断点确认整个链路是否真的被触发。另外Intellij平台从2020.1开始支持动态插件改动actionPerformed这类业务代码后可以直接Build Reload Changed Classes热重载不用重启整个IDE这几乎是写插件时最值钱的一条快捷键。热重载也有边界plugin.xml的修改以及SDK版本升级都需要完全重启开发实例才生效。我见过有人热重载后菜单多了两份就是改plugin.xml里的Action注册没重启导致的遇到这类界面重复的怪问题先重启再排查代码。3. 做一个能跑的插件Action、通知与Tool Window串成完整链路工程骨架立起来后下一步是让插件在IDE里产生看得见的动作。三个最常见的入口是菜单动作、通知与工具窗口。这一节串一条完整链路用户从右键菜单点一个动作动作弹通知通知点击后打开工具窗口。3.1 实现AnAction从右键菜单到快捷键的注册细节Action是Intellij idea插件里最常用的交互单位。实现类继承AnAction重写actionPerformed然后用plugin.xml里的action把它挂到现成的菜单组。IDEA的菜单组本身是一棵组合树EditorPopupMenu是编辑器右键菜单MainToolBar是顶部工具栏ProjectViewPopupMenu是工程树右键菜单挂错组最常见的结果是菜单里找不到入口不是报错。public class GenerateBoilerplateAction extends AnAction { Override public void actionPerformed(NotNull AnActionEvent e) { Project project e.getProject(); if (project null) return; // 拿当前编辑的文件和选中的文本 Editor editor e.getRequiredData(CommonDataKeys.EDITOR); String selected editor.getSelectionModel().getSelectedText(); if (selected null || selected.isBlank()) { Messages.showInfoMessage(project, 请先在编辑器里选中一段代码, Generate); return; } // 后续可以把选中文本交给模板引擎处理 NotificationGroupManager.getInstance() .getNotificationGroup(MyPluginNotification) .createNotification(已收到选中文本 selected, NotificationType.INFORMATION) .notify(project); } }这段代码的核心是AnActionEvent这个信息总线getProject()能拿到当前工程CommonDataKeys.EDITOR能拿到当前编辑器实例进而拿选中文本。注意getRequiredData在拿不到数据时会抛异常所以使用时必须提前判空getProject()。update方法也值得重视。如果不重写它插件在任何上下文里都会显示菜单项如果重写了就要在里面根据当前上下文动态控制visible和enabled。比如只希望Java文件里显示这个ActionOverride public void update(NotNull AnActionEvent e) { VirtualFile file e.getData(CommonDataKeys.VIRTUAL_FILE); e.getPresentation().setEnabledAndVisible( file ! null java.equalsIgnoreCase(file.getExtension())); }通知的NotificationGroup必须在plugin.xml里注册过否则运行时找不到通知组ID直接静默不显示。这一条几乎不报错很多人会在“通知不弹”的坑里绕很久。3.2 用NotificationGroup把结果通知给用户通知不只是弹个气泡Notification可以挂setListener用户点击时打开某个面板或执行某个动作。这一节我们把上一步的通知改成可点击点击后打开一个工具窗口。Notification notification NotificationGroupManager.getInstance() .getNotificationGroup(MyPluginNotification) .createNotification(处理完成点击查看结果, NotificationType.INFORMATION); notification.setListener((n, event) - { if (event NotificationListener.NotificationEventType.CLICK) { ToolWindowManager.getInstance(project) .getToolWindow(MyToolWindow) .show(); } }); notification.notify(project);这里setListener里的事件参数能区分CLICK和EXPIRE等不同场景一般判断是否NotificationEventType.CLICK。ToolWindowManager.getInstance(project).getToolWindow(MyToolWindow)依赖于plugin.xml里注册过的toolWindow idMyToolWindow两者ID必须一字不差这一步拼写错了就是点击没反应日志也不会给你任何提示。3.3 挂一个Tool Window面板创建与生命周期工具窗口是IDE右侧常驻面板。实现ToolWindowFactory接口在createToolWindowContent里往Content里塞一个JPanel所有IDE的UI控件、Swing组件都能放进去。public class MyToolWindowFactory implements ToolWindowFactory { Override public void createToolWindowContent(NotNull Project project, NotNull ToolWindow toolWindow) { JPanel panel new JPanel(new BorderLayout()); JTextArea area new JTextArea(); area.setEditable(false); JButton refresh new JButton(刷新); refresh.addActionListener(e - { // 耗时操作不能在EDT上跑放到线程池 ApplicationManager.getApplication().executeOnPooledThread(() - { String content collectProjectInfo(project); // 回到EDT更新UI ApplicationManager.getApplication().invokeLater(() - area.setText(content)); }); }); panel.add(new JLabel(Project Info), BorderLayout.NORTH); panel.add(new JScrollPane(area), BorderLayout.CENTER); panel.add(refresh, BorderLayout.SOUTH); toolWindow.getContentManager().addContent( toolWindow.getContentManager().getFactory().createContent(panel, Info, false) ); } private String collectProjectInfo(Project project) { // 统计当前工程下Java文件数时需要遍历模块这里省略细节 return placeholder; } }工具窗口的坑主要在生命周期。IDE关闭工程时不会自动通知你的面板如果你在里面挂了线程或者缓存了Project对象轻则泄漏重则IDE关闭时崩溃。常见的做法是让工厂实现Disposable或者在createToolWindowContent里调用Disposer.register(toolWindow.getDisposable(), ...)把清理逻辑挂到IDE给的disposable树上。另一个容易忽略的点是executeOnPooledThread和invokeLater这对组合耗时计算放后台线程UI刷新必须回到EDT线程否则Swing会抛线程检查异常。4. 访问Java源码与代码生成PSI和Document的读写细节菜单和窗口只是门面。插件要真正介入编码就必须理解Intellij idea里两个层次的数据模型Document是纯文本缓冲PSI是结构化的语法树。把所有源码当成字符串处理的插件在真实工程里几乎都会因为注释、字符串常量、泛型折叠之类的问题翻车。4.1 用PSI读Java类从VirtualFile到PsiClass的完整链PSI的全称是Program Structure Interface是Intellij platform把源码解析成树状结构后暴露给插件开发者的一套只读接口。PsiFile对应一个文件PsiClass对应一个类PsiMethod对应一个方法节点间用父子关系串起来。要拿到当前编辑的Java文件的PSI不能用Editor的文本而要先用FileDocumentManager从VirtualFile取Document再通过PsiDocumentManager把Document转成PSI。public static PsiFile getPsiFile(Project project, VirtualFile file) { Document document FileDocumentManager.getInstance().getDocument(file); if (document null) return null; return PsiDocumentManager.getInstance(project).getPsiFile(document); } public static ListString listMethodNames(PsiClass psiClass) { ListString names new ArrayList(); for (PsiMethod method : psiClass.getMethods()) { // 跳过构造器和静态初始化块 if (method.isConstructor()) continue; names.add(method.getName()); } return names; }这里的关键点是PsiDocumentManager.getPsiFile(document)与直接从PsiManager.getInstance(project).findFile(file)的区别。前者能拿到当前编辑器里未保存的最新PSI后者拿的是磁盘上的稳定版两者不一致时用后者的结果做代码生成会覆盖掉你还没CtrlS的修改。我一般取PSI永远用文件管理器这条链确保与用户看到的内容一致。PSI遍历还有一个用学费换来的教训不要自己去递归所有子节点要用PsiTreeUtil.findChildrenOfType和PsiUtil这类工具原因很简单PSI树里包含大量空白节点、注释节点和词法元素手工递归十有八九会把注释里的类名也扫进来。另外现在市面上不少基于Intellij idea接入ai的编码插件本质就是走这条链路监听Document变化、把PSI结构或选中代码喂给模型、再把模型返回的补全结果通过WriteAction写回编辑器。理解了PSI和Document的分工你就能看懂这类插件的核心逻辑不至于被“AI魔法”糊弄过去。4.2 在WriteAction里改代码写操作的三条铁律任何修改Document或PSI的操作都必须包在WriteAction里否则运行期抛ReadAccess异常。三条铁律值得记在便签上一是所有写操作必须持写锁二是不要在EDT线程做耗时写操作三是批量修改必须用CommandProcessor包裹否则用户按一次撤销只能撤销一步。public static void replaceMethodBody(PsiMethod method, String newBody) { PsiElementFactory factory JavaPsiFacade.getElementFactory(method.getProject()); // 先构造新代码块注意这里的代码字符串是Java代码而不是模板 PsiCodeBlock newBlock factory.createCodeBlockFromText({\n newBody \n}, null); PsiCodeBlock oldBlock method.getBody(); if (oldBlock null) return; // 写操作统一走WriteAction WriteAction.run(() - { oldBlock.replace(newBlock); }); }PsiElementFactory.createCodeBlockFromText是生成代码最常用的入口它把字符串按Java语法解析成PSI结构解析失败会抛IncorrectOperationException。字符串里的代码必须是完整可编译的Java语句不能是模板占位符。WriteAction.run是Intellij平台提供的便捷方法它内部会申请写锁再执行千万不要自己调CommandProcessor外面又包WriteAction重复加锁会死锁。如果你要在一个循环里批量改几十个方法正确做法是WriteAction.run(() - { CommandProcessor.getInstance().executeCommand(project, () - { for (PsiMethod m : methods) { replaceMethodBody(m, newBody); } }, Batch Replace Method Body, null ); });这样用户按一次撤销能退回到批量修改前的状态。不包CommandProcessor的话几十个方法就得按几十次撤销用户体验非常差。4.3 用FileTemplate生成新类创建Java项目文件的最佳姿势新建Java文件时不要自己拼字符串再用WriteAction写文件正确姿势是FileTemplateManager配合JavaDirectoryService。这样可以继承IDEA自带的File Header模板、版权头以及对新文件自动做代码格式化。public static PsiFile createJavaClass(Project project, String packageName, String className) throws Exception { Properties props new Properties(); props.setProperty(PACKAGE, packageName); props.setProperty(CLASS_NAME, className); // 模板名来自 File Templates 里的 Java Class FileTemplate template FileTemplateManager.getInstance(project).getTemplate(Class.java); String text template.getText(props); PsiDirectory dir JavaDirectoryService.getInstance() .getPackageDirectory(project, packageName); if (dir null) return null; PsiFile file JavaDirectoryService.getInstance() .createClass(dir, className, text); return file; }FileTemplateManager.getTemplate(Class.java)拿到的是IDEA内置模板的只读实例getText(props)返回填充好变量后的字符串。JavaDirectoryService.createClass会处理好包路径与文件名的对应关系比你手写VfsUtil创建文件要稳得多。注意createClass内部已经做了写操作调用它时不要再包一个WriteAction否则会得到双重写锁的警告这个问题在日志里表现为“Write access is allowed from inside write-action only”的堆栈反复横跳。如果你想把插件做成“一键生成Controller、Service、Mapper”这类脚手架工具最理想的方案就是读一个模板目录逐个调用createJavaClass或createClass生成文件然后针对生成的PSI再补注解和继承关系。这比直接在文件系统里写字符串再让IDEA重新解析要可靠得多因为IDEA会即时感知文件变化并建立PSI后续操作可以无延迟跟进。5. Intellij平台插件开发避坑指南5个必踩的坑与排查路径写插件和写普通Java应用的差别在报错方式上体现得最明显。IDE加载插件失败时经常不打红叉只在日志里留一段模糊警告新手很容易对着没错的代码反复编译最后发现问题是配置。下面这几条是我在Intellij platform plugin开发实践中最常见的翻车现场。5.1 插件加载失败但IDE不报错看idea.log的三个位置现象开发实例启动后插件列表里有你的插件但自定义的Action在菜单里就是找不到。原因plugin.xml的某个action引用了不存在的类或者depends写了一个当前IDEA版本里没有的模块导致插件被IDE标记为disabled但没弹错误框。IDEA对这类问题大部分时候只把警告写进日志不弹窗。解决直接打开Help Show Log in Finder/Explorer找到idea.log搜索Plugin或插件ID。出现Plugin com.example.my-plugin failed to load: class com.example.GenerateBoilerplateAction not found时别怀疑Gradle缓存先检查类全限定名与src/main/java目录结构是否一致。我遇到过class写对但Plugin.xml里的类名少了个包前缀的情况症状完全一样。5.2 升级Intellij idea版本后API消失internal API的代价现象同一段代码在IDEA 2023.2正常在某新版Intellij idea社区版内部版本上直接编译不过或者运行期NoSuchMethodError。原因插件开发里有一类com.intellij.openapi.*包下带Internal注解的类JetBrains明确不保证它们的签名稳定。新版本里删掉某个方法很常见你在旧版本里用Ctrl右键进去看到的源码在新版本可能已经改名为另一个。解决升级前先在Help About里看目标版本号去插件兼容性报告里搜该方法名或者直接在目标版本的IDEA里建一个临时插件工程引用同一份代码编译。不要在生产插件里依赖package-private或者internal包路径。我一般给团队定一条规矩只用官方SDK文档列出的public接口内部包一律在代码里注释标红代码审查时看到com.intellij.internal开头的import直接打回。5.3 工具窗口关闭后Project还活着Disposer泄漏现象关闭工程后IDEA偶尔卡一下多次开关工程后开发实例内存飙升GC不掉。后面甚至出现DisposalException: Already disposed。原因工具窗口的JPanel里挂了线程、定时器或事件监听器但没注册到Disposer。IDE的Project对象关闭时不会强制清理Swing组件里自己启动的线程于是这些线程一直持有Project引用形成了一个极大的泄漏点。解决在createToolWindowContent里埋销毁钩子用Disposer.register(toolWindow.getDisposable(), () - { // 停线程、清监听、清缓存 })并让面板实现Disposable接口把所有子组件统一交给它。代码行数不多但能避免掉九成工具窗口相关崩溃。值得注意的还有project.getMessageBus().connect()连接消息总线后必须手动disconnect()或注册到Disposable上否则Project关闭后监听器还在收事件。5.4 Action ID冲突谁的右键菜单被覆盖了现象装了另一个同类型插件后你的Action在有的机器上显示有的机器上不显示或者两个插件各自菜单都出现但点击后跑的是对方的逻辑。原因IDEA里Action的id是全插件共享的命名空间不同的插件如果写了相同id后加载的会覆盖先加载的。官方插件里不少Action ID是固定的例如EditorPopupMenu里有一长串官方ID你注册进这些名称空间没问题但如果你给自己的Action自定义id时图省事写成了com.example.Generate这种太短的名字和其他插件撞车的概率很高。解决Action的id一律用域名反写加项目名加动作名的完整形式例如com.example.mytool.action.GenerateBoilerplateAction。即使这样多人协作时仍建议在项目文档里记录已注册的Action ID清单合并前先查一下。插件市场提交时平台也会对ID做重复检查但不是所有来源的插件都经过市场内网分发时撞ID只能靠自觉。5.5 runIde调试与真实环境行为不一致现象开发实例里一切正常打包到正式Intellij idea上却出现com.intellij.diagnostic.PluginException或者面板布局不对。原因三类常见区别。一是开发实例不会模拟正式插件市场对since-build的校验二是开发实例的plugin.xml经过Gradle patch也许拼接了开发环境信息三是开发实例默认会加载你本地安装的其他插件正式环境未必有行为差异被掩盖了。解决先跑一遍./gradlew clean buildPlugin然后开一个干净的IDEA版本选择Install Plugin from Disk安装刚才的zip独立验证。这一步能过滤掉九成“在开发实例能跑、交付用户就崩”的问题。打包时还要注意patchPluginXml的sinceBuild要低于等于你的目标最低IDEA版本否则老版本用户根本装不上。如果插件依赖了JDK新特性记得在build.gradle.kts里把java的sourceCompatibility和targetCompatibility指到与目标IDE一致的版本。6. 发布前验证与测试用UsefulTestCase给插件上保险写到此你应该有一个能跑、能打包、能安装的Intellij idea插件了。最后一步是用测试把风险锁住。Intellij platform SDK提供了一套叫UsefulTestCase的框架它会在测试里启动一个轻量IDE上下文让你在JUnit里直接调用AnAction.actionPerformed和PSI读写。public class GenerateBoilerplateActionTest extends UsefulTestCase { public void testMethodListing() { // 在测试框架里拿一个临时Project Project project getProject(); PsiFile file createFile(sample.java, class A { void f() {} }); PsiClass clazz JavaPsiFacade.getInstance(project).findClass(A, GlobalSearchScope.allScope(project)); assertNotNull(clazz); assertEquals(1, listMethodNames(clazz).size()); } }测试跑在同一个插件的classpath里createFile会在IDE的内存文件系统里建文件不会弄脏真实代码。建议在gradle test的CI配置里把它挂上这样每次改动插件后都会跑一遍核心逻辑回归。要注意UsefulTestCase里读PSI本身算读操作测试线程里不需要额外申请读锁但如果你在测试里调replaceMethodBody这种写方法仍然需要WriteAction.run。最后养成一个习惯把目标用户按IDEA版本拆成矩阵至少在两个大版本上各验证一次比如2023.x和2026.x每个版本跑一遍“创建工程、注册动作、打开工具窗口、打包安装”四条路径。我自己的插件维护里吃过一次亏以为untilBuild写宽点无害结果一个只在旧版本出现的API在升级后触发栈溢出用户那边直接崩溃事后我把发布策略改成了“只声明已验证的版本区间”运维负担反而小了。希望帮到你。本文还有配套的精品资源点击获取