Android Studio 中有很多可以提升我们工作效率的插件,比如 AndroidButterKnife Zelezny、GsonFormat、AndroidCode Generator 等等,好的插件可以通过 UI 交互降低用户学习成本、提升使用体验。那如何自己开发一款 AS 插件呢?本篇文章会通过两个实例介绍一些 AS 插件开发入门级别的知识,希望能有所帮助。
开发 AS 插件要用的 IDE 是 IntelliJ IDEA(或者在 AS 中直接装一个插件也可以),最好是点击下载页面左侧的 Other Versions 选项进入历史版本下载页面找到与你使用的 AS 兼容的 IDEA 版本。比如说你使用的 AS 版本信息如下:
红框标记的数字表示 Major version,193 对应的 Major version 就是 2019.3,所以在下载页面找到该版本下载即可:
尽量不要选择相对于你所使用的 AS 新太多的 IDEA 版本,因为新版 IDEA 的 features 在老的 AS 上可能没有,进而导致我们写的插件无法安装或者兼容性报错。
配置插件项目的过程也不复杂,点击菜单上的 File -> New -> Project 进入创建项目页面:
选择 IntelliJ Platform Plugin,点击 Next 后输入项目名称和路径即可。进入项目后的第一件事就是在 resources 目录下的 plugin.xml 配置插件的 id 和名称:
<idea-plugin>
<id>com.plugin.generatorid>
<name>JavaBeanGeneratorname>
...
<actions>
actions>
idea-plugin>
此外我们看到 actions 标签目前是空的,它是存放 action 的集合,而 action 用来描述插件的动作,具体说就是插件新建的菜单选项出现在哪个组里的什么位置、选择该选项后由哪个类执行具体操作、快捷键信息等等,后续结合实例会看的更清楚一些。
到这里准备工作就算就绪了,下面来介绍两个插件开发的简单实例,这两个例子会阐述两个问题:
废话不多说,直接开整。
第一个实例我们来看如何根据给定的字段生成一个 JavaBean 文件。比如说给你如下字段:
// 定义要生成的 JavaBean 包含的字段
private String fieldStr = "name String\n" +
"age int\n" + "id Integer\n";
// 字段的访问限定
private String accessController = "public";
那么在 AS 的项目文件区右键 New 时就会出现一个生成 JavaBean 的选项,点击后就会在当前目录下生成一个 JavaBean 文件:
下面来看实现过程。
在 src 目录右击:
点击 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 后可能会报空指针异常。
在正式开始 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
}
主要做了如下几件事:
需要注意的是,Java 的模板文件后缀名是 .java.ft
,不能随意改动。
在 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 中,详细代码就不贴了,有需要的可以参考文章末尾的源码链接。
到这里,插件功能基本完成。
按照下图路径编译插件:
编译成功后会在如下位置生成插件的 jar 包:
打开 AS,在设置插件页面选择本地安装插件:
选取上面的 jar 包,然后重启 AS 让插件生效,选取一个文件目录,右击:
选择 GenerateJavaBeanByString 这一项,会在选取目录下生成一个 User.java 文件:
第二个例子是仿照 AndroidButterKnife Zelezny 插件,在已经存在的文件中插入绑定视图的语句,基本功能演示如下:
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 获取到布局文件名。获取它的方式有三种:
其中第二种方式截取文件名的方法如下:
/**
* 检查光标所在的那一行是否有 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
对话框通过 JFrame 和 JPanel 实现,与插件的主线内容关系不大,就不说详细了,如有需要来查看源码链接。
到这里两个例子就介绍完了,本文是属于 AS 插件开发的入门级文章,如果想深入研究,可以参考字节跳动终端技术团队的文章Android Studio IDE 插件开发,本文中的部分内容也参考自这里。