Android Studio 插件开发入门

Android Studio 中有很多可以提升我们工作效率的插件,比如 AndroidButterKnife Zelezny、GsonFormat、AndroidCode Generator 等等,好的插件可以通过 UI 交互降低用户学习成本、提升使用体验。那如何自己开发一款 AS 插件呢?本篇文章会通过两个实例介绍一些 AS 插件开发入门级别的知识,希望能有所帮助。

一、准备工作

1.1 下载开发工具

开发 AS 插件要用的 IDE 是 IntelliJ IDEA(或者在 AS 中直接装一个插件也可以),最好是点击下载页面左侧的 Other Versions 选项进入历史版本下载页面找到与你使用的 AS 兼容的 IDEA 版本。比如说你使用的 AS 版本信息如下:

Android Studio 插件开发入门_第1张图片
红框标记的数字表示 Major version,193 对应的 Major version 就是 2019.3,所以在下载页面找到该版本下载即可:

Android Studio 插件开发入门_第2张图片
尽量不要选择相对于你所使用的 AS 新太多的 IDEA 版本,因为新版 IDEA 的 features 在老的 AS 上可能没有,进而导致我们写的插件无法安装或者兼容性报错。

1.2 配置项目

配置插件项目的过程也不复杂,点击菜单上的 File -> New -> Project 进入创建项目页面:

Android Studio 插件开发入门_第3张图片
选择 IntelliJ Platform Plugin,点击 Next 后输入项目名称和路径即可。进入项目后的第一件事就是在 resources 目录下的 plugin.xml 配置插件的 id 和名称:

<idea-plugin>
    
    <id>com.plugin.generatorid>
    
    <name>JavaBeanGeneratorname>
    ...
    
    <actions>
        
    actions>
idea-plugin>

此外我们看到 actions 标签目前是空的,它是存放 action 的集合,而 action 用来描述插件的动作,具体说就是插件新建的菜单选项出现在哪个组里的什么位置、选择该选项后由哪个类执行具体操作、快捷键信息等等,后续结合实例会看的更清楚一些。

到这里准备工作就算就绪了,下面来介绍两个插件开发的简单实例,这两个例子会阐述两个问题:

  1. 如何生成全新文件
  2. 如何在已有文件中加入代码

废话不多说,直接开整。

二、生成一个 JavaBean 文件

第一个实例我们来看如何根据给定的字段生成一个 JavaBean 文件。比如说给你如下字段:

 	// 定义要生成的 JavaBean 包含的字段
    private String fieldStr = "name String\n" +
            "age int\n" + "id Integer\n";

    // 字段的访问限定
    private String accessController = "public";

那么在 AS 的项目文件区右键 New 时就会出现一个生成 JavaBean 的选项,点击后就会在当前目录下生成一个 JavaBean 文件:

Android Studio 插件开发入门_第4张图片

下面来看实现过程。

2.1 创建 Action

在 src 目录右击:

Android Studio 插件开发入门_第5张图片
选择 Action 后对其进行配置:

Android Studio 插件开发入门_第6张图片

点击 OK 后这些配置信息会被自动创建到 plugin.xml 中:

  <actions>
    
    <action id="GenerateJavaBeanAction_01" class="action.JavaBeanGenerator" text="GenerateJavaBeanByString"
            description="Generate a JavaBean file by string.">
      <add-to-group group-id="NewGroup" anchor="first"/>
      <keyboard-shortcut keymap="$default" first-keystroke="shift alt A" second-keystroke="WINDOWS"/>
    action>
  actions>

同时还会自动生成 JavaBeanGenerator 这个 Action 子类,触发该 Action 后的响应动作都写在 actionPerformed() 中:

public class JavaBeanGenerator extends AnAction {

    @Override
    public void actionPerformed(AnActionEvent e) {
        // TODO: insert action logic here
    }
}

这里注意一个细节,就是要把 JavaBeanGenerator 文件放在一个包下面(包名没有硬性命名规则),而不是放在 src 的根目录,否则插件安装到 AS 后可能会报空指针异常。

2.2 编写模板文件

在正式开始 JavaBeanGenerator 的代码编写工作之前,还有一项准备工作,就是要写一个模板文件,最终要生成的 JavaBean 文件就是从以模板文件为模板生成的,内容如下:

#if (${PACKAGE_NAME} != "")
package ${PACKAGE_NAME};
#end
#if (${INTERFACES} != "")
import java.io.Serializable;
#end

public class ${NAME} ${INTERFACES} {
#if (${INTERFACES} != "")
private static final long serialVersionUID = 1L;
#end

}

主要做了如下几件事:

  • 先判断包名,即 PACKAGE_NAME 是否为空,如不为空则用 package 关键字声明包名
  • 再判断接口,即 INTERFACES 是否为空,由于后续我们在填写这个模板时会给 INTERFACES 传 “implements Serializable”,所以当 INTERFACES 不为空时需要 import 导入 Serializable 的包名
  • 根据文件名 NAME 和接口 INTERFACES 生成类体,如果 INTERFACES 不为空就定义一个常量 serialVersionUID。

需要注意的是,Java 的模板文件后缀名是 .java.ft,不能随意改动。

2.3 实现 JavaBeanGenerator

在 actionPerformed() 中进行生成 JavaBean 文件的工作:

public class JavaBeanGenerator extends AnAction {

    // 定义要生成的 JavaBean 包含的字段
    private String fieldStr = "name String\n" +
            "age int\n" + "id Integer\n";

    // 字段的访问限定
    private String accessController = "public";

    @Override
    public void actionPerformed(AnActionEvent e) {
        // 生成一个名字为 User 的 JavaBean 文件
        generateFile(e, "User", fieldStr);
    }

    /**
     * 根据 fieldStr 字符串中定义的字段,生成名字为 fileName 的 JavaBean 文件
     */
    private void generateFile(AnActionEvent actionEvent, String fileName, String fieldStr) {
        // 获取当前工程对象
        Project project = actionEvent.getProject();
        // 得到目录服务
        JavaDirectoryService directoryService = JavaDirectoryService.getInstance();
        // 得到当前菜单选项的相对路径,会在该路径下生成 JavaBean 文件
        IdeView ideView = actionEvent.getRequiredData(LangDataKeys.IDE_VIEW);
        PsiDirectory directory = ideView.getOrChooseDirectory();

        // 将模板文件中需要填写的参数放入 Map 中
        Map<String, String> map = new HashedMap();
        map.put("NAME", fileName);
        map.put("INTERFACES", "implements Serializable");
        map.put("PACKAGE_NAME", CommonUtils.getPackageName(project));

        // 开始生成文件,createClass() 的第三个参数必须和模板文件的文件名保持一致,不用写扩展名
        // Psi:Program Structure Interface,即程序结构接口
        PsiClass psiClass = directoryService.createClass(directory, fileName, "GenerateFileByString", false, map);
        WriteCommandAction.runWriteCommandAction(project,
                new Runnable() {
                    @Override
                    public void run() {
                        // 加入字段
                        generateModelField(project, psiClass, fieldStr);
                        // 加入 getter&setter 方法
                        generateModelMethod(project, psiClass, fieldElements);
                    }
                });
    }
}

generateModelField() 就是根据 fieldStr 字符串中给出的字段名和类型,生成对应的字符串,比如:public String name;,然后以此生成一个 PsiField 并添加到 PsiClass 中:

	/**
     * 生成字段的声明语句
     */
    private void generateModelField(Project project, PsiClass psiClass, String fieldStr) {
        if (psiClass == null) {
            return;
        }

        PsiElementFactory factory = JavaPsiFacade.getInstance(project).getElementFactory();
        // fieldStr 是以 \n 分隔每个字段的,这里区分开,得到每个字段的字符串
        String[] lineString = fieldStr.split("\n");

        if (lineString.length < 0) {
            return;
        }

        fieldElements = new ArrayList<>();
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < lineString.length; i++) {
            String[] temp = lineString[i].split(" ");
            String fieldName = temp[0];
            String fieldType = temp[1];
            // 记录下所有字段的类型和名字,下一步生成方法时要用
            FieldElement fieldElement = new FieldElement(fieldType, fieldName);
            fieldElements.add(fieldElement);
            // 拼接出 public fieldType fieldName;
            sb.append(accessController + " " + fieldType + " " + fieldName + ";");
            PsiField psiField = factory.createFieldFromText(sb.toString(), psiClass);
            psiClass.add(psiField);
            sb.delete(0, sb.length());
        }
    }

而生成 getter 和 setter 的 generateModelMethod() 也是类似的,用字符串拼接出方法体并以此生成 PsiMethod,也是添加到 PsiClass 中,详细代码就不贴了,有需要的可以参考文章末尾的源码链接。

到这里,插件功能基本完成。

2.4 效果测试

按照下图路径编译插件:

Android Studio 插件开发入门_第7张图片

编译成功后会在如下位置生成插件的 jar 包:

Android Studio 插件开发入门_第8张图片

打开 AS,在设置插件页面选择本地安装插件:

Android Studio 插件开发入门_第9张图片

选取上面的 jar 包,然后重启 AS 让插件生效,选取一个文件目录,右击:

Android Studio 插件开发入门_第10张图片

选择 GenerateJavaBeanByString 这一项,会在选取目录下生成一个 User.java 文件:

Android Studio 插件开发入门_第11张图片

三、仿 AndroidButterKnife Zelezny

第二个例子是仿照 AndroidButterKnife Zelezny 插件,在已经存在的文件中插入绑定视图的语句,基本功能演示如下:

Android Studio 插件开发入门_第12张图片

Action 配置在 GenerateGroup 和 CodeMenu 的首位显示:

  <actions>
    <action id="ButterKnifePlugin_202208" class="action.ButterKnifePlugin" text="ButterKnifePlugin"
            description="ButterKnife plugin">
      <add-to-group group-id="GenerateGroup" anchor="first"/>
      <add-to-group group-id="CodeMenu" anchor="first"/>
      <keyboard-shortcut keymap="$default" first-keystroke="shift ctrl alt X"/>
    action>
  actions>

Action 的具体实现:

    private FindViewByIdDialog dialog;
    private String xmlFileName;

    @Override
    public void actionPerformed(AnActionEvent e) {
        Project project = e.getProject();
        // 得到 AS 的代码编辑区对象
        Editor editor = e.getData(PlatformDataKeys.EDITOR);
        if (editor == null) {
            return;
        }

        // 获取用户选择的字符(用户应该选择 xml 文件名)
        SelectionModel model = editor.getSelectionModel();
        xmlFileName = model.getSelectedText();

        // 如果用户没有选择任何内容,则先检查光标所在的那一行代码是否有布局文件名字
        if (TextUtils.isEmpty(xmlFileName)) {
            // 获取光标所在位置那一行中的布局文件名
            xmlFileName = getCurrentLayout(editor);
            if (TextUtils.isEmpty(xmlFileName)) {
                // 如果还没有就弹对话框让用户自己输入
                xmlFileName = Messages.showInputDialog(project, "输入layout名称", "未输入", Messages.getInformationIcon());
                if (TextUtils.isEmpty(xmlFileName)) {
                    Utils.showPopupBalloon(editor, "用户没有输入layout", 5);
                    return;
                }
            }
        }

        // 找到 xmlFileName 对应的 xml 文件,获取所有 id 并保存到 elements 集合中
        PsiFile[] psiFiles = FilenameIndex.getFilesByName(project, xmlFileName + ".xml", GlobalSearchScope.allScope(project));
        if (psiFiles.length == 0) {
            Utils.showPopupBalloon(editor, "未找到选中的布局文件" + xmlFileName, 5);
            return;
        }

        XmlFile xmlFile = (XmlFile) psiFiles[0];
        List<Element> elements = new ArrayList<>();
        Utils.getIDsFromLayout(xmlFile, elements);

        // 生成选择对话框,并根据用户的选择生成代码
        if (elements.size() != 0) {
            // 获取 editor 所在文件的 PsiFile 对象
            PsiFile psiFile = PsiUtilBase.getPsiFileInEditor(editor, project);
            PsiClass psiClass = Utils.getTargetClass(editor, psiFile);
            // 生成 UI
            dialog = new FindViewByIdDialog(editor, project, psiFile, psiClass, elements, xmlFileName);
            dialog.showDialog();
        }
    }
}

主要思路就是通过 Project 获取到 IDE 的代码编辑区,进而获取鼠标选中的代码内容,这样我们就可以拿到代码的内容,进而通过 R.layout.xxx 获取到布局文件名。获取它的方式有三种:

  1. 鼠标选取 layout 名称(即 R.layout. 后面的部分)
  2. 将光标停留在 R.layout.xxx 代码那一行
  3. 前两种都没有选中 layout 文件的情况下,弹出对话框输入 layout 文件名

其中第二种方式截取文件名的方法如下:

	/**
     * 检查光标所在的那一行是否有 layout 文件名,有则返回
     */
    private String getCurrentLayout(Editor editor) {
        Document document = editor.getDocument();
        // 获取光标对象并得到其位置(相对于左上角编辑区起始点的偏移量)
        CaretModel caretModel = editor.getCaretModel();
        int caretOffset = caretModel.getOffset();
        // 得到一行开始和结束的位置
        int lineNumber = document.getLineNumber(caretOffset);
        int lineStartOffset = document.getLineStartOffset(lineNumber);
        int lineEndOffset = document.getLineEndOffset(lineNumber);
        // 得到一行的所有字符串
        String lineContent = document.getText(new TextRange(lineStartOffset, lineEndOffset));
        String layoutMatcher = "R.layout.";

        System.out.println("lineContent:" + lineContent + ",contain:" + lineContent.contains(layoutMatcher));
        if (!TextUtils.isEmpty(lineContent) && lineContent.contains(layoutMatcher)) {
            // 获取 layout 文件名的字符串
            int startPosition = lineContent.indexOf(layoutMatcher) + layoutMatcher.length();
            int endPosition = lineContent.indexOf(")", startPosition);
            return lineContent.substring(startPosition, endPosition);
        }
        return null;
    }

拿到布局文件名之后可以拿到其文件对象 PsiFile,由于布局文件其实是一个 xml 文件,所以使用解析 xml 文件的方式解析 PsiFile:

	/**
     * 解析 psiFile 中添加了 id 的组件,将该组件信息封装到 Element 元素中,并存入
     * elements 集合
     */
    public static List<Element> getIDsFromLayout(PsiFile psiFile, List<Element> elements) {
        // 遍历一个文件的所有元素
        analyzeFromXml(psiFile, elements);
        return elements;
    }

    private static void analyzeFromXml(PsiFile psiFile, List<Element> elements) {
        psiFile.accept(new XmlRecursiveElementVisitor() {
            @Override
            public void visitElement(PsiElement element) {
                super.visitElement(element);
                if (element instanceof XmlTag) {
                    XmlTag xmlTag = (XmlTag) element;
                    String name = xmlTag.getName();
                    // 如果 xml 中有 include 标签,那就要递归去找 include 布局中的标签
                    if ("include".equalsIgnoreCase(name)) {
                        XmlAttribute layout = xmlTag.getAttribute("layout");
                        Project project = psiFile.getProject();
                        String layoutName = getLayoutName(layout.getValue()) + ".xml";
                        PsiFile[] psiFiles = FilenameIndex.getFilesByName(project, layoutName, GlobalSearchScope.allScope(project));
                        if (psiFiles.length > 0) {
                            // 开始递归
                            analyzeFromXml(psiFiles[0], elements);
                            return;
                        }
                    }

                    // 没有 include 的一般情况,去找 android:id 属性
                    XmlAttribute id = xmlTag.getAttribute("android:id", null);
                    if (id == null) return;

                    String value = id.getValue();
                    if (value == null) return;

                    XmlAttribute aClass = xmlTag.getAttribute("class");
                    if (aClass != null) {
                        name = aClass.getValue();
                    }

                    Element newElement = new Element(name, value, xmlTag);
                    elements.add(newElement);
                }
            }
        });
    }

解析 xml 时将使用了 android:id 属性的组件的信息封装到 Element 元素中,并添加到 List 集合中,最后根据这些 Element 中保存的组件 id 和标签上的类名等信息生成对话框(即上述代码中的 FindViewByIdDialog),通过选取的方式决定是否为某个组件生成绑定字段和点击方法。

对话框通过 JFrame 和 JPanel 实现,与插件的主线内容关系不大,就不说详细了,如有需要来查看源码链接。

到这里两个例子就介绍完了,本文是属于 AS 插件开发的入门级文章,如果想深入研究,可以参考字节跳动终端技术团队的文章Android Studio IDE 插件开发,本文中的部分内容也参考自这里。

你可能感兴趣的:(#,开源框架,Android,android,studio,android,intellij-idea)