Android Studio IDE 插件开发

作者:字节跳动终端技术——周宸韬

概述

这篇文章旨在向读者介绍IntelliJ IDE插件的开发流程以及常用的一些通用功能,任何基于IntelliJ开发的IDE都可以通过该方式制作插件,例如Android Studio(AS),本篇也将基于Android Studio进行展开介绍,读者将从0到1学习到 Android Studio插件开发。

背景介绍

什么是IDE插件、IDE插件能做什么?

IDE插件是将一些功能集成到了IDE界面当中,当我们使用IDE进行开发工作时能很方便的通过UI界面使用这些功能,例如大家熟悉的project工程目录,Gradle工具栏,IDE底部的Run、Terminal、Build界面等,都是通过IDE插件来实现的,可以说大部分需要通过命令行执行、或用户手动的一些操作都可以通过插件实现,并以UI的形式呈现。

如下图:左图为Android Studio IDE界面右侧Gradle工具栏,包含了很多Gradle任务,点击UI的效果等同于用户在命令行中输入Gradle命令。右图为IDE顶部菜单栏版本控制部分,其中对于版本的提交、拉取等按钮等价于命令行输入对应指令。

为什么要做Android Studio IDE插件?

笔者作为中台部门开发者,经常涉及到一些通用的功能的开发,并以工具或组件等形式交由外部使用。所以如何降低用户学习成本、提高工作效率是我的目标,而这些优化方向都离不开巧妙的使用工具。例如本次要介绍的IDE插件的开发背景就是以此为目标:将原本需要使用命令行完成的工作、或者学习成本较高的操作通过UI进行包装,并且依附在原生的AS界面中,通过UI的交互大幅降低用户学习成本,同时提升使用体验。

举例对比一下,下面两幅图片是某个工程自动搭建功能的截图,左右两图分别为使用命令行和使用AS插件的体验对比,可以看到左侧在使用CLI命令行进行工程搭建时界面信息不够简洁明了,且用户交互体验较差,用户必须在使用前阅读文档,并且没有容错机制,输错就得从头开始。而相同的功能使用右侧AS插件的体验则好很多,不仅各条信息清楚明了,还能拓展更多细节功能,如动态检验用户输入,输入无误才可进行下一步等等,用户完全可以在零知识背景的情况下使用该插件并轻松完成所有功能操作,而且接近原生的界面更美观。

如何开发一个IDE插件?

准备工作

在开发第一个插件前,我们要下载正确的开发工具,在JetBrain官网中下载IntelliJ IDEA下载链接

这里使用的开发工具是IntelliJ IDEA而不是Android Studio,因为AS是基于IntelliJ为模版开发的,IDE插件必须通过IntelliJ开发、发布,再安装到Android Studio中才能使用。

我们需要确认我们使用的Android Studio是基于哪个IntelliJ版本。这很重要,和你当前使用的Android Studio版本相同能让你在调试时很方便,而新版的IntelliJ包含的features在你使用的AS上可能并没有,导致插件无法安装,或提示兼容性报错。(图中的报错也会出现在未开启向高版本兼容时发生,开发时按需开启) 。

下载时请跟随这个步骤:

  1. 打开你的Android Studio,查看版本号(The Build Number),这就是我们需要的IntelliJ版本号。
  2. 下载页面,点击Other versions,找到对应的IntelliJ版本,下载安装即可。

创建新的工程 + 配置

这部分也可参照官网:www.jetbrains.org/intellij/sd…

  1. 创建新工程包含两个向导页面,【选择工程模版框架 + 填写插件工程信息】,按图中配置即可。
  1. 完成两步向导程序后自动创建工程,我们需要先了解两个核心文件:【build.gradle + plugin.xml】,并做一些前置的配置工作。

build.gradle

因为build.gradle和Android工程中的构建文件非常类似,这里只解释Android中没有的配置。

  • version:intellij闭包创建时只带一个属性 version,该属性代表用来构建这个插件的IntelliJ 平台IDE的版本,如果我们在开发时调用【runIde】这个Gradle task,一个基于这个版本的IntelliJ IDE实例就会被创建。
  • localPath:因为我们希望在AS的环境下测试我们的插件,所以我们需要将AS作为我们插件的一个依赖,增加一个属性叫localPath指定本机Android Studio应用程序Contents的安装目录,一个基于这个版本的Android Studio实例就会被创建(注意localPath不能和version属性同时使用,因为我们本地的AS路径中已经有了版本信息)。
  • plugins:添加开发需要的依赖插件。可以在这里添加很多我们想用的插件,比如我们想在插件中执行git命令,我们可以添加 ’git4idea‘ plugin。
intellij {
    version '2020.1.4'
    localPath '/Applications/Android Studio.app/Contents'
    plugins = ['Kotlin','android','git4idea']
}

plugin.xml

在resource文件夹下可以找到plugin.xml文件,这个文件中可以配置我们插件的各项属性,核心功能是注册我们插件包含的components和service(功能类实现后还需要在这里进行注册才能使用,类似在AndroidManifest.xml中声明Activity和Service)。

  • 声明我们的插件需要并且和AS相兼容:增加android和android studio modules作为依赖。
<idea-plugin>
    ...
    <depends>com.intellij.modules.platform</depends>
    <depends>org.jetbrains.android</depends>
    <depends>com.intellij.modules.androidstudio</depends>

    <extensions defaultExtensionNs="com.intellij">
        <!-- Add your extensions here -->
    </extensions>

    <actions>
        <!-- Add your actions here -->
    </actions>

</idea-plugin>

运行插件

配置完成后我们可以尝试运行插件工程,具体位置在Gradle工具栏 项目名称/Tasks/intelliJ/runIde路径。运行runIde任务,因为我们配置了Android Studio为启动路径,所以一个Android Studio模拟IDE会打开,所有内容都和我们本地的Android Studio没有差别。

IDE插件常用功能介绍

创建一个Action

什么是Action?

Actions官方介绍: The system of actions allows plugins to add their own items to IDEA menus and toolbars. An action is a class, derived from the AnAction.

Actions是用户调用插件功能最常见的方式,如下图的工具目录是开发者经常用到的,里面所有的可选项都是一个Action,可以进一步展开的则是Action Group。

如何创建一个Action?

两个步骤:

  • 【code implementation - 实现Action的具体代码逻辑】:决定了这个action在哪个context下有效,并且在UI中被选择后的功能(继承父类AnAction并重写actionPerformed()方法,用于Action被执行后的回调)。
  • 【registered - 在配置文件中注册】:决定了这个action在IDE界面的哪个位置出现(创建新的group或存放进现有的ActionGroup,以及在group中的位置)。

两个条件达成,action就可以从IntelliJ Platform中获得用户执行动作后的回调,例子: ‘HelloWorld’。

Code implementation

class HelloWorldAction : AnAction() {

    override fun actionPerformed(event: AnActionEvent) {
        //这里创建了一个消息提示弹窗,在IDE中展示“Hello World”
        val notificationGroup = NotificationGroup(
            displayId = "myActionId",
            displayType = NotificationDisplayType.BALLOON
        )

        val notification = notificationGroup.createNotification(
            title = "chentao Demo",
            content = "Hello World",
            type = NotificationType.INFORMATION
        ).notify(event.project) //从方法的Event对象中获取到当前IDE正在展示的project,在该project中展示弹窗
    }
}

Registering a Custom Action

<actions>
    <!-- Add your actions here -->
    <!-- 创建了一个ActionGroup -->
    <group id = "ChentaoDemo.TopMenu"
           text="ChentaoDemo Plugin"
           description="Demo Plugin in top menu">
           <!-- 注册HelloWorld Action -->
        <action class="com.chentao.demo.actions.HelloWorldAction"
                id="DemoAction"
                text="Hello World Action"
                description="This is a test action">
                <!-- 设置 HelloWorld Action 的键盘快捷键-->
            <keyboard-shortcut first-keystroke="control alt p" keymap="$default"/>
            <!-- 将HelloWorld Action添加到剪切拷贝组中 -->
            <add-to-group group-id="CutCopyPasteGroup" anchor="last"/>          
        </action>
        <!-- 将这个Group添加到主菜单 -->
        <add-to-group group-id="MainMenu" anchor="last"/>
    </group>
</actions>

运行插件 - 结果展示

实现了以上两步后,运行runIde Task,顶部的主菜单栏末尾出现了我们添加的ActionGroup,展开可看见HelloWorldAction,点击Action,右下角弹出“Hello World”提示信息。我们不仅可以创建Group来放置Action,还可以将Action添加进IDE已有的Group当中,如下左图中,我们将HelloWorld Action添加进了IDE的CutCopyPasteGroup,和复制粘贴等Action放在了一起。

plugin.xml文件中actions的group可以更为复杂,group可以相互包含,并形成工具栏或菜单(如下图),有兴趣的同学可以拉取Demo(文章末尾)体验一下。

向导程序Wizard

Wizard意为向导程序,就是指引使用者完成某个功能的程序,通常为单个或多个指引界面组成。例如下面两幅图为Android Studio中经典的创建新工程窗口,就包含两个页面的向导程序。下面将介绍如何制作出和图中主题完全相同的向导程序。

向导程序的基础类属于android.jar,由以下几个核心类构成:

1. ModelWizard

向导程序的“主类”,一个ModelWizard包含了一个ModelWizardStep的队列(Step队列是一个有序的队列,并且每个Step都包含了它的下一个Step的引用),当一个ModelWizard结束时,它会遍历所有steps,访问step对应的WizardModel,并调用WizardModel#handleFinished()方法。

2. ModelWizardStep

一个Step就是Wizard向导程序中的一个单独页面,它负责创建一个UI界面呈现给用户,确定页面上的信息是否有效,并且将用户数据保存在对应的WizardModel对象中。

3. SkippableWizardStep

可以设置可见性的Step,可以通过前一个Step来控制跟在其后的Step可见性,例如一个Step提供了一些选项给用户,并根据用户的选择来决定之后哪些Steps可以被展示。

4. WizardModel

Model就是数据的集合,这些数据由wizard中的每个step进行填充。多个step可以共享同一个model,核心的方法是handleFinished(),当用户在向导程序中点击了“Finish”按钮时,wizard结束,这个方法将会被调用进行最终的逻辑处理。

Wizard向导程序工作流程图

创建一个Android Studio样式的向导程序

同样在android.jar库中,和wizard同级的名叫ui的包中提供了一个很方便的类,帮助使用者创建AS样式的ModelWizard,只需将ModelWizard对象作为参数放入StudioWizardDialogBuilder的构造器中即可。使用AS样式包装我们的插件UI能让用户使用时更有原生的感觉,体验更好。

class CreateNewProjectAction : AnAction() {

    override fun actionPerformed(e: AnActionEvent) {
        StudioWizardDialogBuilder(
            ModelWizard.Builder().addStep(NewProjectStep()).build(),
            "Create New MARS Project"
        ).build().show()
    }
}

class ProjectWizardModel : WizardModel() {
    //记录一些希望保存的字段
  	//...
    override fun handleFinished() {
        //处理最后的逻辑
    }
}

class NewProjectStep : ModelWizardStep<ProjectWizardModel?>(ProjectWizardModel(), "Create MARS Project") {

    init {
      //创建Step页面的UI
    }
    //链接下一个Step
    override fun createDependentSteps(): MutableCollection<out ModelWizardStep<*>> {
        return arrayListOf(SelectBaselineStep(model))
    }
}

Tool Windows

Tool Windows是IDE的子窗口。这些窗口通常都在IDE主窗口的“边框”上拥有属于自己的一个tool window button,点击后将在IDE主窗口的左、右、下侧激活panel来展示信息。如下图左一Gradle工具栏。创建Tool Window需要提供一个JPanel,并通过ToolWindowFactory来实现。

ToolWindowFactory

Performs lazy initialization of a tool window registered in {@code plugin.xml}.

使用者必须创建ToolWindowFactory的实现类,并实现createToolWindowContent()方法,在该方法中初始化tool Window的UI,并添加到Android Studio中。ToolWindowFactory提供了懒加载机制,这样实现的好处是未使用的工具窗口不会增加启动时间或导致内存使用方面的任何开销:如果用户没有与该工具窗口交互,相关代码就不会被加载和执行。

public class MyToolWindowFactory implements ToolWindowFactory {

    @Override
    public void createToolWindowContent(@NotNull Project project, @NotNull ToolWindow toolWindow) {
        // 初始化自定义组件对象
        MyToolWindow myToolWindow = new MyToolWindow(project, toolWindow);

        // 组件添加到AS中
        ContentFactory contentFactory = ContentFactory.SERVICE.getInstance();
        Content content = contentFactory.createContent(myToolWindow.getContent(), "", false);
        toolWindow.getContentManager().addContent(content);
    }
}

在插件中使用Tool Windows有两种形式:

  • declarative setup:可以理解为静态,在plugin.xml文件中注册,始终可见用户随时都可以使用。
  • Programmatic Setup:通过API接口动态注入,可以在一些操作前后出现和隐藏。

Declarative Setup

<extensions defaultExtensionNs="com.intellij">
    <!-- Add your extensions here -->
    <toolWindow id="MyToolWindow" secondary="true" anchor="right" factoryClass="com.volcengine.plugin.toolwindow.MyToolWindowFactory"/>
</extensions>

Programmatic Setup

updateBaselineBtn.addActionListener(e -> {
    BaselineWindow baselineWindow = new BaselineWindow(versionsJson, project, toolWindow);
    ContentFactory contentFactory = ContentFactory.SERVICE.getInstance();
    Content content = contentFactory.createContent(baselineWindow.getContent(), "", false);
    toolWindow.getContentManager().addContent(content);
    toolWindow.getContentManager().setSelectedContent(content);
});

UI创建工具

Wizard向导程序和Tool Window工具栏都需要UI作为面板内容的填充,根本上来说,需要的只是一个内容丰富的JPanel作为Content。AS 插件中的UI大量的使用了Java Swing组件,所以对Swing比较熟悉的同学上手会很快,这里介绍几种在AS插件中生成UI的技巧。

GUI Form

New --> Swing UI Designer --> GUI Form 填写信息后就会生成对可视化的.form文件以及绑定的java类,在对应的java文件中增加一个getRootPanel方法获取root panel就可以将构建好的Panel给到向导程序或工具栏中使用。

Eclipse - WindowBuilder

上面提到的GUI Form有一个缺点,只能使用Java,并且.fome文件和.java文件强绑定,我们也无法单独使用这个生成的java文件,并且当我们想编写纯Kotlin代码时,GUI Form就显得不好用了。

Eclipse是多数同学刚接触Java时使用的经典IDE,其中有一个WindowBuilder插件同样可以可视化创建GUI界面,但是相比于GUI Form,WindowBuilder生成的是单独的.java文件,用户在GUI可视化界面操作的每个步骤都会生成对应的源码,我们可以直接copy这些代码到AS插件当中,并使用“convert java code to Kotlin”功能,将这些代码一键转为Kotlin代码,非常方便(更重要的是,WindowBuilder的使用体验个人觉得更好)。

Kotlin UI DSL

IntelliJ 插件官方提供的一些基于Kotlin的领域特定语言,可以在Kotlin代码中写UI,优点是代码优美,缺点是累,具体可参考官网的指引plugins.jetbrains.com/docs/intell…

数据持久化

有时我们希望能保存用户在插件中的操作或一些配置,避免重复的工作以及必要数据的读取,或避免用户重复多次输入。IntelliJ Platform提供了一些方便的API来做数据持久化。

Plugin Service

这是IntelliJ插件开发中的基础能力,分为三种不同的类型,当我们想要在IDE插件的不同生命周期进行一些状态和逻辑上的处理,就可以使用这三种服务,例如:持久化状态、订阅事件、Application启动/关闭时、Project被打开/关闭时。

Service 接口类型作用描述
Application LevelIDEA启动时会初始化,IDEA生命周期中仅存在一个实例
Project LevelIDEA 会为每一个 Project 实例创建一个 Project 级别的实例
Module LevelIDEA 会为每一个 Project 的加载过的Module实例Module级别的实例,在多模块项目中容易导致内存泄露

这块代码是模拟在IDE启动时,自动检验当前是否存在新版本的功能,若有新版本则进行更新操作,就是使用了持久化存储来实现的。

@State(name = "DemoConfiguration", storages = [
    Storage(value = "demoConfiguration.xml")
])

class DemoComoponent:ApplicationComponent, PersistentStateComponent<DemoComoponent>, Serializable {
    var version = 1
    var localVersion = 0;

    private fun isANerVersion() = localVersion < version

    private fun updateVersion(){
        localVersion = version
    }

    override fun initComponent() {
        if(isANerVersion()){
            updateVersion()
        }
    }

    override fun getState(): DemoComoponent? = this
    override fun loadState(state: DemoComoponent) {
        XmlSerializerUtil.copyBean(state, this)
    }
}

持久化存储的两种方式

1. PropertiesComponent

这是一个简单的Key-Value数据结构,可以当作Map使用,用于保存application 和 project 级别的数据。

//获取 application 级别的 PropertiesComponent
PropertiesComponent propertiesComponent = PropertiesComponent.getInstance();

//获取 project 级别的 PropertiesComponent,指定相应的 project
PropertiesComponent propertiesComponent = PropertiesComponent.getInstance(Project);

// set & get
propertiesComponent.setValue(name, value)
propertiesComponent.getValue(name)

2. PersistentStateComponent

复杂类型的数据结构使用PersistentStateComponent,可以指定持久化的存储位置。

public interface PersistentStateComponent<T> {
  @Nullable
  T getState();
  void loadState(T state);
}
  1. 创建一个PersistentStateComponent的实现类,T表示需要持久化的数据结构类型,可以是任意类,甚至是实现类本身,然后重写getState和loadState方法。
  2. 若要指定存储的位置,需要在显现类上增加*@State*注解。
  3. 若不希望其中的某个字段被持久化,可以在该字段上增加*@Transient* 注解。
@State(
    name = "ChentaoPlugin" ,
    storages = [Storage("chentao-plugin.xml")]
)

class AarCheckBoxSettings :PersistentStateComponent<HashMap<String, AarCheckBoxState>> {
    var checkBoxStateList = HashMap<String, AarCheckBoxState>()

    override fun getState(): HashMap<String, AarCheckBoxState>? {
        return checkBoxStateList
    }
    override fun loadState(stateList: HashMap<String, AarCheckBoxState>) {
        checkBoxStateList = stateList
    }

    //将持久化组件声明为Serveice的获取方式是通过ServiceManager
    companion object{
        @JvmStatic
        fun getInstance(): PersistentStateComponent<HashMap<String, AarCheckBoxState>>{
            return ServiceManager.getService(AarCheckBoxSettings::class.java)
        }
    }
}
data class AarCheckBoxState(val componentId:String, val isSelected:Boolean)
注册持久化组件

PersistentStateComponent的实现类需要在plugin.xml中注册为 Service后使用。

<extensions defaultExtensionNs="com.intellij">
    <applicationService serviceImplementation="com.volcengine.plugin.actions.AarCheckBoxSettings"/>
</extensions>

插件打包与安装

  • 打包:在Gradle工具栏中运行assemble任务,即可在/build/distribution/{插件名称}-{插件版本}.zip路径下找到打包好的插件zip包。
  • 本地安装:还没将插件发布到插件市场前我们可以选择安装本地插件,打开AS菜单栏/Android Studio/Preference/Plugins/Install Plugin from Disk... 安装后即可使用。
  • 发布插件市场:

总结

回顾开发过程:IDE插件的核心步骤:安装正确版本IntelliJ --> 配置工程 --> 创建Action --> 将复杂流程注入Wizard向导程序或ToolWindow工具栏(同时创建UI) --> 使用数据持久化保存必要数据 --> 打包&安装&发布。

笔者觉得IDE插件开发的难点主要是摸索的过程,IDE插件较冷门,网上介绍的文章很少,官网介绍了一些功能和组件后也没有详细的API指引,令人有点无从下手。最终,通过反编译查看一些官方插件(Firebase、Flutter等)的源码,以及收集Google、Youtube和各大博客的信息,终于将AS插件的一期雏形打造完毕,也将学到的一些常用的通用能力在本文中进行整理,希望能帮到之后想要接触AS插件开发的同学。

Demo

github.com/ChentaoZhou…

关于字节终端技术团队

字节跳动终端技术团队(Client Infrastructure)是大前端基础技术的全球化研发团队(分别在北京、上海、杭州、深圳、广州、新加坡和美国山景城设有研发团队),负责整个字节跳动的大前端基础设施建设,提升公司全产品线的性能、稳定性和工程效率;支持的产品包括但不限于抖音、今日头条、西瓜视频、飞书、懂车帝等,在移动端、Web、Desktop等各终端都有深入研究。

就是现在!客户端/前端/服务端/端智能算法/测试开发 面向全球范围招聘!一起来用技术改变世界,感兴趣请联系 [chenxuwei.cxw@bytedance.com](mailto:chenxuwei.cxw@bytedance.com),邮件主题 简历-姓名-求职意向-期望城市-电话

火山引擎应用开发套件MARS是字节跳动终端技术团队过去九年在抖音、今日头条、西瓜视频、飞书、懂车帝等 App 的研发实践成果,面向移动研发、前端开发、QA、 运维、产品经理、项目经理以及运营角色,提供一站式整体研发解决方案,助力企业研发模式升级,降低企业研发综合成本。