24年最新 idea 插件开发教程,面试刷题插件技术实现!

536 阅读7分钟

大家好,我是松柏。前几天我们公司发布了面试刷题 JetBrains IDE 插件,其目的是帮助大家方便、隐蔽的摸鱼刷题,大家可以先安装体验一下。

为了帮助大家扩展技术的广度,今天给大家分享下这个插件的技术实现!

完整开源代码:github.com/yuyuanweb/m…

首先贴一下官方文档:plugins.jetbrains.com/docs/intell…

虽然这个文档教学属性比较弱,更多是一些概念、规范,但是给了一些示例代码仓库,还是很有用的。

技术选型

我们先看下使用到的技术栈:

  • Gradle IntelliJ Plugin 插件开发的核心 SDK
  • lombok 简化开发的工具包
  • hutool-core hutool 工具包的核心模块
  • retrofit2 网络请求工具包,OkHttp的加强版,也可以使用其他的包

除了插件的 SDK ,没有用到什么特殊的东西。这个 SDK 里的很多内容都继承自 Swing ,所以如果有过相关开发经验的话会更加容易上手,当然了,没有的话影响也不大。

这里值得一提的是,官方还提供了一个新的 SDK : IntelliJ Platform Gradle Plugin,不过它不兼容较旧版本的 IDE ,个人不推荐大家用。

实现

定义入口

确定了需求和使用的技术之后,就可以开始编码了。

先来明确两个概念:

plugin.xml 插件的配置文件,定义插件的元数据和配置,比如插件的入口、一些生命周期的监听器。

tool window 主 IDE 窗口内的一个窗格,比如 ProjectCommit 都是一个个的 tool window

首先,我们给插件定义一个入口然后注册到plugin.xml中,这样就能在左侧看到我们的图标:

@Slf4j
public class MyToolWindowFactory implements ToolWindowFactory {

    private static final Logger logger = Logger.getInstance(MyToolWindowFactory.class);

    public MyToolWindowFactory() {}

    @Override
    public void createToolWindowContent(@NotNull Project project, ToolWindow toolWindow) {}
}
<extensions defaultExtensionNs="com.intellij">
  <toolWindow canCloseContents="true"
    icon="/icons/favicon.svg"
    factoryClass="com.github.yuyuanweb.mianshiyaplugin.toolWindow.MyToolWindowFactory" id="">
  </toolWindow>
</extensions>

并且支持挪到其他我们想要的位置,比如底部:

顶部导航栏

接下来是顶部的导航栏:

在插件开发中,有各种各样的 manager,它们用于处理特定功能或资源的管理。比如导航栏这里我们就用到了 ActionManager,我们会定义一系列导航行为,并注册到 ActionManager上:

OpenUrlAction webAction = new OpenUrlAction(WEB_ZH, CommonConstant.WEB_HOST, IconConstant.WEB);
actionGroup.add(webAction);
actionManager.registerAction(WEB, webAction);
ActionToolbar actionToolbar = actionManager.createActionToolbar(ACTION_BAR, actionGroup, true);
mainPanel.add(actionToolbar.getComponent(), BorderLayout.NORTH);

不同的导航行为需要对AnAction定义不同的实现,比如上述 OpenUrlAction 的作用是用默认浏览器打开一个页面,其定义如下:

public class OpenUrlAction extends AnAction implements DumbAware {
    private final String url;
    
    // 构造函数
    public OpenUrlAction(String text, String url, Icon icon) {
        // Action 名称
        super(text, text, icon);
        this.url = url;
    }
    @Override
    public void actionPerformed(@NotNull AnActionEvent e) {
        // 使用默认浏览器打开指定网址
        BrowserUtil.browse(url);
    }
}

登录功能

接下来是登录功能,在面试刷题网页端已经提供了扫码登录,所以没必要在插件端再搞一套登录逻辑,不仅麻烦,还有可能负优化用户体验。借用网页端的扫码登录,我们只需要打开一个内嵌浏览器弹窗然后监听登录态并记录就可以了:

getJBCefClient().addLoadHandler(cefLoadHandler = new CefLoadHandlerAdapter() {
    @Override
    public void onLoadingStateChange(CefBrowser browser, boolean isLoading, boolean canGoBack, boolean canGoForward) {
        cefCookieManager.visitAllCookies(new CefCookieVisitor() {
            @Override
            public boolean visit(CefCookie cefCookie, int count, int total, BoolRef boolRef) {
                if ("SESSION".equals(cefCookie.name)) {
                    // 记录 session
                }
                return true;
            }
        });
    }
}, getCefBrowser());
loadURL();

这里需要提一下,打开内嵌浏览器需要 JCEF 的支持,JetBrains系列较低的版本(比如 2021.3 以下)或者某些 IDE (比如 <font style="color:rgba(0, 0, 0, 0.85);">Android Studio</font>)是不支持这个功能的,也就是说,这些 IDE 将无法正常使用我们的插件。

题库、题目列表

接下来是插件的核心之一:题库、题目列表的展示。这里以比较简单的题库列表为例给大家讲解。

这块分为三个部分,上面的题库分类,下面的题库列表,以及底部的分页条,其中的数据都来自接口,所以我们先来看下如何从接口获取数据。在本插件中,要用到的接口比较多并且经常会复用一些接口,所以我们选择的网络请求包是 retrofit2 ,方便统一定义、管理这些接口。

首先看下请求工具类的定义:

public class ApiConfig {
    public static MianShiYaApi mianShiYaApi;
    static {
        // 自定义 Gson 实例
        Gson gson = new GsonBuilder()
                .registerTypeAdapter(Date.class, new DateTypeAdapter())
                .create();
        OkHttpClient client = new OkHttpClient.Builder()
                .addInterceptor(new HeaderInterceptor())
                .addInterceptor(new LogInterceptor())
                .addInterceptor(new ResponseInterceptor())
                .build();
        String mianShiYaBaseUrl = CommonConstant.API;
        Retrofit retrofit = new Retrofit.Builder()
                .baseUrl(mianShiYaBaseUrl)
                .addConverterFactory(GsonConverterFactory.create(gson))
                .client(client)
                .build();

        mianShiYaApi = retrofit.create(MianShiYaApi.class);
    }
}

可以看到,我们在初始化时添加了一些拦截器,用来添加请求头、输出一些日志等。

然后看下题库分类和题库列表两个接口的定义:

/**
 * 获取题库列表
 */
@POST("questionBankCategory/list_questionBank")
Call<BaseResponse<Page<QuestionBank>>> getQuestionBankList(
        @Body QuestionBankCategoryBankQueryRequest queryRequest
);

/**
 * 获取题库分类列表
 */
@POST("questionBankCategory/list")
Call<BaseResponse<List<QuestionBankCategory>>> listQuestionBankCategory(
        @Body PageRequest pageRequest
);

之后,我们就能在代码中使用 MianShiYaApi.getQuestionBankList() 获取数据了。

接下来看题库分类的展示,其实我们要做的就是把 listQuestionBankCategory 接口返回的数据展示出来,同时用插件的方式给每个分类绑定对应的事件,部分代码如下:

JBPanel<?> labelPanel = new JBPanel<>(new WrapLayout(FlowLayout.LEFT, 5, 5));
ApplicationManager.getApplication().executeOnPooledThread(() -> {
    List<QuestionBankCategory> tagList = ApiConfig.mianShiYaApi.listQuestionBankCategory(new PageRequest()).execute().body().getData();
    ApplicationManager.getApplication().invokeLater(() -> {
        for (QuestionBankCategory tag : tagList) {
            JBLabel label = new JBLabel(tag.getName());
            label.addMouseListener(new MouseAdapter() {
                // 点击事件
                @Override
                public void mouseClicked(MouseEvent e) {
                    // 获取数据
                    searchAndLoadData(queryRequest);                    
                }
            });
            labelPanel.add(label);
        }
    });
});

题目列表的展示与之类似,不过是数据的容器从 JBPanel 换成了 MTabModel

ApplicationManager.getApplication().executeOnPooledThread(() -> {
    Page<QuestionBank> data = this.fetchDataFromApi(queryRequest);
    // 创建表格数据模型
    ApplicationManager.getApplication().invokeLater(() -> {
        tableModel = new MTabModel();
        // 将数据添加到表格模型
        for (QuestionBank row : data.getRecords()) {
            tableModel.addRow(new Object[]{row.getId().toString(), row.getTitle(), row.getTagList()});
        }
        JBTable table = PanelUtil.createTablePanel(tableModel, (tempTable, mouseEvent) -> {
           // 双击某一行数据执行
        });
    });
});

可能有小伙伴好奇,executeOnPooledThreadinvokeLater 这些东西是干嘛的,我们慢慢道来。

IDEA 整个应用的 UI 渲染都是由主线程也叫事件调度线程(Event Dispatch Thread, EDT)来处理的,一旦这个线程卡住,整个 IDEA 就会卡住,一定要注意,不是当前的插件,而是整个 IDEA 都会卡住!一些严重的异常或耗时操作甚至会导致 IDEA 卡死、卡退,这个体验是极端糟糕的。

executeOnPooledThread 的作用就是把操作放到线程池中去执行,防止卡住主线程。

invokeLater 的作用是把涉及到 UI 更新的操作放到主线程中,那为什么非要把 UI 更新操作放到主线程呢?

答案很简单,如果不放到主线程,轻则 UI 状态与实际数据状态不一致,造成用户体验不佳;重则干扰主线程的状态,导致 IDEA 卡死!

题目详情

为了保证阅读体验,在题目详情页中我们使用了内嵌网页的形式展示内容。

JetBrains 插件中,我们需要在编辑区打开自定义内容时,一般是以下步骤:

  • 实现 FileEditorProvider 接口:创建一个类,继承 FileEditorProvider,定义如何创建和管理自定义编辑器。
  • 实现 FileEditor 接口:在自定义编辑器类中实现 FileEditor 接口,以定义编辑器的行为和 UI 组件。
  • 注册编辑器提供者:在插件的 plugin.xml 文件中注册自定义的 FileEditorProvider,使其能够被 IntelliJ IDEA 识别和使用。
  • 处理文件类型:如果需要,定义一个自定义的文件类型,以便在打开特定文件时使用自定义编辑器。

我们这里的核心诉求是 打开一个内嵌的浏览器 ,在浏览器里展示对应的内容,由于打开的内容是需要校验登录态的,所以在打开浏览器时需要带上对应 cookie

打开内嵌浏览器的简化代码如下:

public BrowserFileEditor(@NotNull Project project, @NotNull VirtualFile file) {
    this.jbCefBrowser = new JBCefBrowser();
    this.panel = new JPanel(new BorderLayout());

    // 浏览器
    jbCefBrowser.getJBCefClient().addLoadHandler(new CefLoadHandlerAdapter() {
        @Override
        public void onLoadingStateChange(CefBrowser browser, boolean isLoading, boolean canGoBack, boolean canGoForward) {
            cookieManager.setCookie(CommonConstant.WEB_HOST, jbCefCookie, false);
            jbCefBrowser.setJBCefCookieManager(cookieManager);
        }
    }, jbCefBrowser.getCefBrowser());
    jbCefBrowser.loadURL(url);
}

这里说一下在开发时踩的一个大坑:setJBCefCookieManager执行之前一定一定一定要确保JCEF已经初始化完成,不要直接执行,否则会导致 IDE 卡退!一般的做法是放到 onLoadingStateChange 等钩子里,这样就能正常打开一个内嵌网页:

以上就是面试刷题插件的大概实现了,我们在实际开发中肯定远比这些复杂,篇幅限制,很多细节就不在这里展开了,大家可以前往 Github 页面搜索“mianshiya-plugin”查看源码细节。

有帮助的话欢迎点个赞,祝大家有所收获,拜拜👋🏻!