
本篇文章写于2020年10月09日,从公众号|掘金|CSDN手工同步过来(博客搬家),本篇为原创文章。
由原来的四篇文章合并为一篇:
- 《编写一个IDEA插件之:开发环境准备那些坑》
- 《编写一个IDEA插件之:使用PSI分析Java代码》
- 《编写一个IDEA插件之:自动生成Java代码》
- 《编写一个IDEA插件之:事件监听》
不得不说,IDEA确实强大,不知不觉已经成为我们Java、Android程序员的首选IDE工具,不知不觉中,我们已经淡忘了Eclipse。
IDEA有着极强的扩展功能,它提供插件扩展支持,让开发者能够参与到IDEA生态建设中,为更多开发者提供便利、提高开发效率。
我们常用的插件有Lombok、Mybatis插件,这些插件都大大提高了我们的开发效率。
即便IDEA功能已经很强大,并且也已有很多的插件,但也不可能面面俱到,有时候我们需要自给自足。
如何准备插件开发环境
在开发插件之前,你可能需要先搭建环境。
你可能需要下载一个社区版本的IDEA和源码,社区版IDEA可以到官网下载,而源码可从gitee克隆(从github克隆太慢)。
从gitee克隆:
git clone --depth 1 https://gitee.com/mirrors/intellij-community.git
因为源码项目太大,clone到一半时可能会出错,需要使用--depth 1指定克隆深度,1表示只克隆最近一次commit。

虽然--depth 1只会把默认分支clone下来,其他远程分支并不在本地,但我们不需要用到其它的分支,如果有需要,可以先git fetch指定分支再切换到指定分支。
$ git remote set-branches origin 'remote_branch_name'
$ git fetch --depth 1 origin remote_branch_name
$ git checkout remote_branch_name
安装社区版后,我们在社区版的IDEA开发插件。
需要先搭建环境,安装插件DevKit,以及创建IntelliJ Platform SDK。下图为官方文档中《搭建开发环境》部分的截图。

在新建插件项目时,选择IntelliJ Platform Plugin,注意不要勾选Groovy。勾选Groovy表示你需要添加一些Groovy的链接库和框架,此时底部的Use library就不能为空。

关于环境的准备网上有不少文章,推荐阅读:
- 《从零开始编写
IntelliJ IDEA插件》:https://juejin.im/post/6844904058625474573 - 官方教程(可使用谷歌浏览器的翻译插件翻译成中文阅读):https://jetbrains.org/intellij/sdk/docs/basics/getting_started.html
使用PSI分析Java代码
PSI是Program Structure Interface的缩写,即程序结构接口。
如果我们想要分析源代码文件的内容就离不开PSI。
我们知道,JVM在加载类之前,首先需要读取Class文件,并将Class文件解析成一个结构体对象,对应的是Class文件结构。与JVM解析Class文件不同的是,IDEA解析的是Java源代码,但IDEA也是将Java文件解析为一个结构体对象。
请记住一句话,对于任何拥有固定结构的文件或者代码,都可以使用访问者模式。
不仅Java文件,任何代码文件都会有一定的结构,否则编译器也不能识别,也是因为如此,IDEA实现的PSI与Java字节码操作工具ASM有非常多的相似之处,除了都是将文件解析成结构外,也都支持使用访问者模式编辑文件,一个大的结构下面包含许多小的结构,小的结构也支持使用访问者模式编辑。
因为很相似,所以我们可以用学习使用ASM工具分析、创建、或改写Class文件的思维去学习PSI。
由于不同的编程语言编写的代码文件有不同的结构,IDEA将文件结构抽象为接口,叫程序结构接口文件(PSI File),不同类型的文件解析后生成不同的PsiFile接口的实现类实例,这也是IDEA能够扩展支持多语言的基础。
PsiFile接口
一个文件就是一个PsiFile,也是一个文件的结构树的根节点,PsiFile是一个接口,如果文件是一个.java文件,那么解析生成的PsiFile就是PsiJavaFile对象,如果是一个Xml文件,则解析后生成的是XmlFile对象。
PsiElement接口
Class文件结构包含字段表、属性表、方法表等,每个字段、方法也都有属性表,但在PSI中,总体上只有PsiFile和PsiElement。
Element即元素,一个PsiFile(本身也是PsiElement)由许多的PsiElement构成,每个PsiElement也可以由许多的PsiElement构成。
PsiElement用于描述源代码的内部结构,不同的结构对应不同的实现类。
对应Java文件的PsiElement种类有:PsiClass、PsiField、PsiMethod、PsiCodeBlock、PsiStatement、PsiMethodCallExpression等等。其中,PsiField、PsiMethod都是PsiClass的子元素,PsiCodeBlock是PsiMethod的子元素,PsiMethodCallExpression是PsiCodeBlock的子元素,正是这种关系构造成了一棵树。
解析一个Java文件有上百种类型的PsiElement,对于一个新手,我们如何才能快速的认识对应Java代码文件中的每行代码都会解析生成呢?好在IDEA提供了PSI视图查看器。
如果你正在编写插件,那么IDEA会自动在“工具”菜单中显示“查看PSI结构”的选项,否则,我们需要修改IDEA的配置文件才能在“工具”菜单中看到这个选项。

配置文件在IDEA安装路径的bin目录下,找到idea.properties文件,如下图所示。

我们需要在idea.properties文件中添加这样一行配置:
idea.is.internal=true
添加配置后重启IDEA就能看到tools菜单下新加了两个选择,如下图所示。

其中View PSI Structure of Current File是将当前查看的文件解析为结构树,选中选项后弹出如下图所示的窗口。

Show PSI structure for:选择PsiFile类型;Show PsiWhiteSpace:去掉勾选后可以隐藏表示连续空格(包括换行符)的元素PsiElement;
当我们选中源码时,IDEA会找到对应的PsiElement标志为选中状态,如上图左侧的PSI Tree窗口所示。
PsiReference
一个PsiReference表示代码中某个PsiElement链接到相应的声明。
简单理解,PsiReference就是我们选中鼠标右键弹出菜单中Go To的Declaration or Usages、或者按住command键+鼠标点击后能够跳转到相应声明的依据。

我们可以通过调用PsiElement#getReference方法获取一个PsiElement的PsiReference,然后调用PsiReference#resolve方法取得该PsiElement链接到(引用)的PsiElement。
例如,获取一个方法调用表达式PsiMethodCallExpression链接到声明的PsiElement可以这样写。

下面是这段代码的一次调试的截图:

如上图所示,此次PsiMethodCallExpression表示的是payConfigApplicationService.createOrUpdate(dto),PsiMethodCallExpression也是一个PsiElement,可以调用getReference获取到该元素的PsiReference实例,最后调用PsiReference实例的resolve方法取得该方法调用表达式元素链接到的声明是一个PsiMethod,表示createOrUpdate方法。
我们还可以继续获取该表达式链接到的PsiMethod所属的类PsiClass。
通过分析一个元素的PsiReference,我们可以判断一行代码是否有调用某个类的方法,如果有,则在代码行号处显示一个图标,点击图标跳转到目标方法等。
总之,要想在自定义插件中分析源代码就不得不了解PSI。
笔者是通过阅读官方文档、通过PSI查看器学习了解PSI、并通过分析MybatisX这个插件的源码,以及自己动手不断试错学习如何编写一个IDEA插件的。
自动生成Java代码
我很喜欢IDEA的一键自动生成代码功能,例如自动生成构造方法、字段的Get/Set方法、ToString方法等等,除此之外,也有一些插件提供自动生成代码的功能,例如我们所熟悉的GsonFormat插件,使用该插件可以为我们快速的解析json字符串生成一个对应的Java类,这在对接一些第三方API时很有帮助。
笔者写过一个运行时根据json自动生成Class的工具包:json-class-generator,与GsonFormat不同的是,该工具使用ASM在运行时解析json结构树生成类的字节码,而GsonFormat生成的是Java源代码。当时写json-class-generator目的是实现一个第三方API自动对接框架,由于该框架涉及到业务,所以没有开源。
虽然json-class-generator与GsonFormat实现的功能不同,但原理相似。
Java源代码编译后生成的Class文件有固定的结构,而在IDEA中,Java源代码也同样有固定的结构:PSI程序结构。与使用ASM操作字节码修改一个Class文件一样,我们也可以通过编辑一个Java源代码的PSI程序结构的元素修改Java代码。
自动生成Java源代码
我们模仿IDEA提供的自动生成代码功能,给右键弹出菜单的Generate...菜单添加一个子菜单:GeneratedInvokePayMethod,在插件使用者点击该菜单时自动生成一串代码,并且生成的代码插入到当前光标所在位置。
首先需要编写一个对应GeneratedInvokePayMethod菜单的Action,并实现actionPerformed方法,代码如下。
public class GeneratedInvokePayMethodAction extends AnAction {
@Override
public void actionPerformed(@NotNull AnActionEvent event) {
}
}
actionPerformed方法在菜单被点击时调用,该方法只有一个参数:
* event:这个参数封装了很多有用的信息,比如我们可以从该参数获取当前文件的PsiFile实例、获取当前光标落在的PsiElement等。
其次,我们需要注册Action,将Action放到右键弹出菜单的GenerateGroup。需要在plugin.xml文件添加如下配置信息:
<actions>
<action id="xxx.action.GeneratedInvokePayMethodAction" class="com.xxx.plugin.action.GeneratedInvokePayMethodAction"
text="GeneratedInvokePayMethod">
<!-- 将action放在哪 -->
<add-to-group group-id="GenerateGroup" anchor="first"/>
</action>
</actions>
效果如下图所示。

现在我们继续完成GeneratedInvokePayMethodAction的actionPerformed方法。
由于Intellij Platform不允许插件在主线程中进行实时的文件写入,只能通过异步任务来完成写入,因此,我们需要通过WriteCommandAction.runWriteCommandAction来执行一个后台写入操作,如下代码所示。
public class GeneratedInvokePayMethodAction extends AnAction {
@Override
public void actionPerformed(@NotNull AnActionEvent event) {
// 立即执行一个后台任务
WriteCommandAction.runWriteCommandAction(editor.getProject(), () -> {
// do ...
});
}
}
想要在当前光标所在的位置插入一行代码,那么我们需要做这些事情:
- 1、先判断当前文件是否是一个
Java文件,借助actionPerformed的event参数可取得当前文件的PsiFile实例,判断PsiFile实例的类型是否为PsiJavaFile,如果不是,说明这不是一个Java代码文件,什么也不需要做(或者可以给出对话框提示);
// AnActionEvent event
PsiFile psiFile = event.getData(LangDataKeys.PSI_FILE);
- 2、通过第一步获取的
PsiFile,查找当前光标所在位置的PsiElement实例;
PsiElement element = psiFile.findElementAt(editor.getCaretModel().getOffset());
其中editor.getCaretModel().getOffset()为获取当前光标位置;
另外,可以使用AnActionEvent#getData方法获取当前光标所在的PsiElement,代码如下:
// AnActionEvent event
PsiElement psiElement = event.getData(LangDataKeys.PSI_ELEMENT);
但这种方式不适用于当前场景,如果将光标放在一行代码的;后面,那么该方法就会返回null值。
- 3、根据光标所在的
PsiElement,获取该PsiElement所在方法的PsiCodeBlock(一个方法只有一个PsiCodeBlock);
PsiElement codeBlock = element;
while (!(codeBlock instanceof PsiCodeBlock)) {
codeBlock = codeBlock.getParent();
}
- 4、创建新的
PsiElement,该PsiElement就是需要自动生成的代码;
例如创建一个表达式元素(PsiExpression),可使用PsiElementFactory#createExpressionFromText方法创建,代码如下。
PsiElement newElement = PsiElementFactory.getInstance(element.getProject())
.createExpressionFromText("Invocation<Object> invocation = Invocation.<Object>builder()\\n" +
" .scope(scope)\\n" +
" .service(payType)" +
" .operate(\\"" + method + "\\")\\n" +
" .body(merchantNo)\\n" +
" .build()", element.getContext());
PsiElementFactory使用工厂模式生产PsiElement,提供了大量的API,例如创建字段的createField、创建方法的createMethod、创建类的createClass,创建关键字的createKeyword。
- 5、最后,将新创建的
PsiElement添加到光标所在PsiElement的后面;
// 参数1:新增的PsiElement
// 参数2:位置参照的PsiElement
codeBlock.addAfter(newElement, element);
完整示例代码如下。
public class GeneratedInvokePayMethodAction extends AnAction {
@Override
public void actionPerformed(@NotNull AnActionEvent event) {
WriteCommandAction.runWriteCommandAction(editor.getProject(), () -> {
PsiFile psiFile = event.getData(LangDataKeys.PSI_FILE);
// 查找当前光标停留在的元素
PsiElement element = psiFile.findElementAt(editor.getCaretModel().getOffset());
// 获取当前方法的PsiCodeBlock元素
PsiElement codeBlock = element;
while (!(codeBlock instanceof PsiCodeBlock)) {
codeBlock = codeBlock.getParent();
}
// 使用PsiElementFactory创建表达式元素
PsiElement newElement = PsiElementFactory.getInstance(element.getProject())
.createExpressionFromText("Invocation<Object> invocation = Invocation.<Object>builder()\\n" +
" .scope(scope)\\n" +
" .service(payType)" +
" .operate(\\"" + method + "\\")\\n" +
" .body(merchantNo)\\n" +
" .build()", element.getContext());
// 将新创建的表达式元素插入到光标停留在的元素的后面
codeBlock.addAfter(newElement, element);
});
}
}
实际要实现一个插件可能没有那么简单,例如本篇没有介绍到的UI部分,笔者省略了一些步骤:当点击菜单时,先弹出一个Dialog,提供一些选项,在完成选项点击ok后再生成代码。
编写插件UI其实与开发Android应用编辑UI布局类似,如果你开发过Android应用,那么也就不难理解。
事件监听

事件监听,我们最熟悉不过的就是开发APP时,监听按钮点击事件、列表滑动事件、手指触摸及移动事件、网络状态事件等等。事件监听大多通过观察者模式实现,首先API调用者不需要知道后台是如何检测出网络状态不可用的,而只需要向系统注册一个监听器,当网络状态发生改变时,由系统回调给监听器。
如何监听项目或模块改变事件
首先是项目级别的事件监听。添加一个项目管理事件监听器,我们需要实现ProjectManagerListener接口,该接口有四个方法,其源码如下。
public interface ProjectManagerListener extends EventListener {
default void projectOpened(@NotNull Project project) {
}
default void projectClosed(@NotNull Project project) {
}
default void projectClosing(@NotNull Project project) {
}
default void projectClosingBeforeSave(@NotNull Project project) {
}
}
- projectOpened:该方法在项目打开时被回调;
- projectClosingBeforeSave:在关闭项目时,开始保存项目之前被回调,或者说是在调用
FileDocumentManager#saveAllDocuments方法保存所有文件之前被调用; - projectClosing:在
projectClosingBeforeSave方法之后被回调; - projectClosed:与projectClosing的区别在于,
projectClosed在项目已经关闭时被回调,在ProjectManagerImpl#closeProject方法执行到最后一行代码时被调用。
有了项目管理事件监听器之后,我们如何注册该监听器呢?
有两种方法,一种是代码方式注册,一种是在plugin.xml插件配置文件中注册。
代码方式注册可调用ProjectManager.getInstance().addProjectManagerListener();方法注册,但这种方式注册有一个弊端,就是无法监听到项目打开事件,projectOpened方法不会被调用,应该在我们能够调用该方法注册监听器时,项目实际已经打开了。
所以注册项目管理监听器我们只能通过修改plugin.xml配置文件方式注册,配置代码如下:
<applicationListeners>
<listener class="com.msyc.ycpay.plugin.listener.MyProjectManagerListener"
topic="com.intellij.openapi.project.ProjectManagerListener"/>
</applicationListeners>
- topic:填写事件主题,类似于消息中间件中的Topic,只不过这里填写的是事件监听器的接口类名;
- class:添加接口的实现类名;
当我们给IDEA注册自定义的项目管理事件监听器后,我们就可以通过项目管理事件监听器注册其它的事件监听器,例如注册模块监听事件,这是因为模块的事件触发在项目打开事件触发之后才会触发。因此,在projectOpened方法中可注册任何其它的事件监听器。
注册模块事件监听器代码如下:
project.getMessageBus().connect()
.subscribe(ProjectTopics.MODULES, new ModuleListener(){});
subscribe方法需要两个参数:
* topic:主题,可选值参见ProjectTopics类的源码,有PROJECT_ROOTS和MODULES;
* handler:事件处理器、监听器,当topic为MODULES时,要求传递一个ModuleListener;
ModuleListener接口的定义如下:
public interface ModuleListener extends EventListener {
default void moduleAdded(@NotNull Project project, @NotNull Module module) {
}
default void beforeModuleRemoved(@NotNull Project project, @NotNull Module module) {
}
default void moduleRemoved(@NotNull Project project, @NotNull Module module) {
}
default void modulesRenamed(@NotNull Project project, @NotNull List<Module> modules, @NotNull Function<Module, String> oldNameProvider) {
}
}
- moduleAdded:添加模块完成时被调用;
- beforeModuleRemoved:模块被移除之前被调用;
- moduleRemoved:模块被移除时被调用;
- modulesRenamed:模块修改名字时被调用;
如何监听文件编辑事件
通过前面两篇的学习,我们已经了解什么是PSI,知道一个文件对应一个PsiFile,一个PsiFile本身也是一个PsiElement,由许多的PsiElement构成,每个PsiElement也都可以有子PsiElement。
因此,监听文件改变事件其实就是监听PSI树的结构改变事件,我们需要通过PsiManager注册PsiTreeChangeListener,代码如下。
PsiManager.getInstance(project).addPsiTreeChangeListener(
new PsiTreeChangeListener() {
// .....
}, FILES::clear);
至于注册时机,视情况而定,可以在Service初始化时注册,可以在AnAction触发时注册,也可以在projectOpened事件方法中注册。
PsiTreeChangeListener接口定义的方法较多,可以分为两类事件,一类是before事件、一类是after事件,接口源码如下。
public interface PsiTreeChangeListener extends EventListener {
void beforeChildAddition(@NotNull PsiTreeChangeEvent event);
void beforeChildRemoval(@NotNull PsiTreeChangeEvent event);
void beforeChildReplacement(@NotNull PsiTreeChangeEvent event);
void beforeChildMovement(@NotNull PsiTreeChangeEvent event);
void beforeChildrenChange(@NotNull PsiTreeChangeEvent event);
void beforePropertyChange(@NotNull PsiTreeChangeEvent event);
void childAdded(@NotNull PsiTreeChangeEvent event);
void childRemoved(@NotNull PsiTreeChangeEvent event);
void childReplaced(@NotNull PsiTreeChangeEvent event);
void childrenChanged(@NotNull PsiTreeChangeEvent event);
void childMoved(@NotNull PsiTreeChangeEvent event);
void propertyChanged(@NotNull PsiTreeChangeEvent event);
}
- childrenChanged:子元素内容改变时被调用;
- childReplaced:子元素被替换时被调用,触发
childReplaced事件也会伴随着childrenChanged事件; - childAdded:子元素添加时被调用,触发
childAdded事件时也会伴随着childReplaced、childrenChanged或事件; - childRemoved:子元素移除时被调用,触发
childRemoved事件也会伴随着childReplaced、childrenChanged事件; - propertyChanged:属性改变时被调用,例如修改文件名;
参考
intellij-platform-plugin-template的项目管理监听器注册:https://sourcegraph.com/github.com/JetBrains/intellij-platform-plugin-template@main/-/blob/src/main/resources/META-INF/plugin.xml#L17:55- 接收有关项目结构变更的通知:https://jetbrains.org/intellij/sdk/docs/reference_guide/project_model/project.html?search=projectClosingBeforeSave