Intellij Idea插件开发--Android文件修改

4,314 阅读9分钟

最近因为工作需要开发了一个Intellij Idea插件,可用于解析Android中的文件结构并进行修改,实现一键引入依赖和初始化,过程各种心酸事,如今记录一下作个备忘,如果能帮助到他人也是一种荣幸。

新建插件工程

1.下载最新的Intellij idea并安装 2. New Project ->选择Intellij Platform Plugin->Project SDK选择 Intellij IDEA Community Edition IC-[版本号]  3. 点击Next并填写Project Name和Project Location 点击完成即可生成最初的插件工程,目录结构如下

enter description here

给插件添加功能

这里介绍两种不同应用时机的插件

  • 一种是继承自 AnAction,这种插件是点击 Intellij IDEA某个按钮后执行插件的功能,
  • 一种是实现ProjectComponent接口,这种插件是在 Intellij IDEA或者Android Studio 打开Project后自动执行插件的功能 两种插件其实是公用,但是在开发过程中,ProjectComponent类插件限制较多,需要特别注意。

AnAction插件

新建方式如下图:

enter description here
然后在打开的对话框中填写插件相关信息以及插件条目出现位置,插件New Action说明如下:

  • id:作为标签的唯一标识。一般以<项目名>.<类名>方式。
  • class:即我们自定义的AnAction类
  • text:显示的文字,如我们自定义的插件放在菜单列表中,这个文字就是对应的菜单项
  • description:对这个AnAction的描述
  • Groups:表示插件入口出现的位置,比如可以让插件出现在Tools菜单上,则可以选择ToolsMenu,如果想让插件出现在编辑框Generate菜单则可以选择GenerateGroup
  • Actions: 已有的Action,即已有的插件功能,选定这里的Action配合Anchor选项,可以指定我们新建的Action出现在已有的Action之前或是之后。
  • Anchor:用来指定动作选项在Groups中的位置,Frist就是最上面、Last是最下面,也可以设在某个选项的上/下方 Keyboard Shortcuts:调用插件Action的快捷键,可以不填,填了要注意热键冲突

填写完必要信息后,可以看到resources/META-INF/plugin.xml文件多了一个Actions节点,新建的Action添加到了Action节点中

public class ExampleAction extends AnAction {

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

新建Action继承AnAction类,插件实现的操作实现actionPerformed方法即可。

以上方式是新建一个Action,实际还可以新建一个ActionGroup,目前好像只支持手动修改resources/META-INF/plugin.xml添加ActionGroup。举个例子:

<actions>
    <group id="com.example.MyGroup" text="MyGroup" popup="true">
        <add-to-group group-id="HelpMenu" anchor="first"/>
        <action  id="MyGroup.FirstAction" class="com.example.plugin.FirstAction" text="FirstAction"/>
        <action id="MyGroup.SecondAction"  class="com.example.plugin.SecondAction" text="SecondAction"/>
    </group>
</actions>

通过以上配置即会在HelpMenu第一位添加一个MyGroup菜单,并且MyGroup菜单会有两个子菜单FirstAction和SecondAction

ProjectComponent插件

这种类型插件需要实现ProjectComponent接口的projectOpened方法和projectClosed方法,主要是在projectOpened方法中即工程打开这个时机执行我们需要的操作,注意,这里执行的操作是在每次工程打开都会执行,因此需要十分慎重,个人建议最好添加条件判断十分每次打开都要执行,避免无谓的重复操作。ps.这里对写操作有很大的限制,需要特别注意。

public class OppoProjectComponent implements ProjectComponent {
    private Project mProject;

    public OppoProjectComponent(Project inProject) {
        mProject = inProject;
    }

    @Override
    public void projectOpened() {
    }

    @Override
    public void projectClosed() {
    }
}

插件开发一些相关概念和API

概念

在开始真正的开发之前,必须先了解Intellij Idea SDK中的常用概念和API. Intellij Idea SDK中有两种File概念,一个是VitrualFile,一种是PSIFile(psi: Program Structure Interface)。 VitrualFile可以近似地认为是Java中的File,传统的文件操作方法VirtualFile都支持。 在说PsiFile是什么之前先说PSI Element是什么,PSI Element是PSI系统下不同类型对象的一个统称,PSI系统中一切皆是PSI Element,包括一个方法,一个左括号,一个空格,一个换行符都是PSI Element。而处于一个文件中所有的PSI Element集合就是PSIFile.

API

插件开发相关的API非常多,大部分都可以在官网文档上找到相关说明。传送门 官方默认对Java文件和XML文件支持比较好,解析和修改这两类文件时可以直接使用自带的API,而如果在Android项目中使用,想要支持Kotlin文件,gradle文件,properties文件或者想使用AndroidManifest.xml文件更方便,必须添加额外的依赖了。 以gradle文件为例。gradle文件内容是用groovy语言编写的,因此想要解析gradle文件需要添加groovy依赖。这里添加有两个步骤。

  1. 打开Project Structure,点击SDKs项,选中Intellij IDEA,然后点击右侧+号,选中[Intellij IDEA安装目录]/plugins/Groovy/lib/Groovy.jar并添加。
    enter description here
    enter description here

经过第一步配置,已经可以使用Groovy相关的插件API了,此时编译打包都不会有问题,然而真正使用的时候会报Groovy相关插件API没找到的问题。因此还需要第二步添加depends

2.在resources/META-INF/plugin.xml添加depends

  <depends>org.intellij.groovy</depends>

通过以上两步才能正常使用Groovy的插件API。

因此如果针对Android Studio开发需要解析Kotlin,gradle,properties和AndroidManifest.xml这些文件,则添加以下依赖

enter description here
enter description here

接下来我们用插件来实现一些小功能。

通过插件生成Java代码

这里其实可以分成两种情况,一种是生成一个完整的类文件,一种是添加部分代码,比如新增某个类并对其调用。

不论是哪种情况,都建议使用模板来生成我们所需的代码。这里我们将模板文件放到resouces目录下,可以通过

xxxx.class.getClassLoader().getResource(templateFileName);

方式获取到文件的流。

生成完整的类文件

以生成自定义Application类为例。这里的自定义的Application类需要先从AndroidManifest.xml解析 是否已有自定义Application类,这里先假设解析出来还没有自定义的Application类。 整个流程可以分为以下几步: 1.从resources中读取模板文件

    /**
     * 读取模板文件中的字符内容
     *
     * @param fileName 模板文件名
     * @return
     */
    private String readTemplateFile(String fileName) {
        InputStream in = null;
        String content = "";
        try {
            in = ApplicationClassOperator.class.getClassLoader().getResource(fileName).openStream();
            content = StreamUtil.inputStream2String(in);
        } catch (IOException e) {
            Loger.error("getResource error");
            e.printStackTrace();
        }
        return content;
    }

2.替换模板中的信息

    /**
     * 替换模板中字符
     *
     * @return
     */
    private String replaceTemplateContent(String source, String packageName) {
        source = source.replace("$packageName", packageName);
        return source;
    }

3.生成java文件

  /**
     * 生成java文件
     *
     * @param content   类中的内容
     * @param classPath 类文件路径
     * @param className 类文件名称
     */
    private void writeToFile(String content, String classPath, String className) {
        try {
            File folder = new File(classPath);
            if (!folder.exists()) {
                folder.mkdirs();
            }

            File file = new File(classPath + "/" + className);
            if (!file.exists()) {
                file.createNewFile();
            }

            FileWriter fw = new FileWriter(file.getAbsoluteFile());
            BufferedWriter bw = new BufferedWriter(fw);
            bw.write(content);
            bw.close();
        } catch (IOException e) {
            e.printStackTrace();
            Loger.error("write to file error! "+e.getMessage());
        }

    }

在原有基础上进行修改,添加部分代码

添加部分代码与添加完整文件其实完全不一样,因为上面的添加并没有用到PSI相关的API. 同样以修改Application为例,这里添加一个方法并在onCreate方法中调用。

  1. 找到自定义的Application类 这里的自定义的Application类需要从AndroidManifest.xml解析获取,AndroidManifest.xml解析暂时先放一放,后文再说。先假设拿到了AndroidManifest.xml中的application中android:name,通过这个android:name即可找到自定义的Application类。
    /**
     * 如果已有自定义application类,检查是否需要添加模板初始化方法
     *
     * @param manifestModel
     * @return 如果application.java已有initTemplate方法返回true + 自定义Application类
     */
    public Pair<Boolean, PsiClass> checkApplication(Project project, ManifestModel manifestModel) {
        PsiClass appClass = null;
        if (manifestModel.applicationName != null) {
            String fullApplicationName = manifestModel.applicationName;
            if (manifestModel.applicationName.startsWith(".")) {
                fullApplicationName = manifestModel.packageName + manifestModel.applicationName;
            }

            appClass = JavaPsiFacade.getInstance(project).findClass(fullApplicationName, GlobalSearchScope.projectScope(project));
            PsiMethod[] psiMethods = appClass.getAllMethods();
            for (PsiMethod method : psiMethods) {
                if (Constants.INIT_METHOD_IN_APP.equals(method.getName())) {
                    return new Pair<>(true, appClass);
                }
            }
        }
        return new Pair<>(false, appClass);
    }
  1. 添加方法
    /**
     * 在自定义Application类中添加initTemplate()方法
     */
    public void addInitTemplateMethod(Project project, PsiClass psiClass) {
        String method = null;
        try {
            InputStream in = getClass().getClassLoader().getResource("/templates/initTemplateInAppMethod.txt").openStream();
            method = StreamUtil.inputStream2String(in);
        } catch (IOException e) {
            e.printStackTrace();
        }
        if (method == null) {
            throw new RuntimeException("initTemplateInAppMethod shuold not be null");
        }
        final String finalMethod = method.replace("\r\n", "\n");
        WriteCommandAction.runWriteCommandAction(project, () -> {
            PsiMethod psiMethod = PsiElementFactory.SERVICE.getInstance(project).createMethodFromText(finalMethod, psiClass);
            psiClass.add(psiMethod);
        });
    }
  1. 找到调用的位置
    /**
     * 在onCreate中找到super.onCreate();所在位置
     *
     * @param psiClass
     * @return
     */
    public static PsiElement findCallPlaceInOnCreate(PsiClass psiClass) {
        PsiMethod[] psiMethods = psiClass.getAllMethods();

        for (PsiMethod psiMethod : psiMethods) {
            if ("onCreate".equals(psiMethod.getName())) {
                PsiCodeBlock psiCodeBlock = psiMethod.getBody();
                if (psiCodeBlock == null) {
                    return null;
                }
                for (PsiElement psiElement : psiCodeBlock.getChildren()) {
                    if ("super.onCreate();".equals(psiElement.getText())) {
                        return psiElement;
                    }
                }
            }
        }
        return null;
    }
  1. 添加调用语句
    /**
     * 在onCreate方法中添加调用initTemplate();语句
     *
     * @param project
     * @param psiClass
     * @param anchor
     */
    public static void addCallInitMethod(Project project, PsiClass psiClass, PsiElement anchor) {
        WriteCommandAction.runWriteCommandAction(project, () -> {
            PsiStatement psiStatement = PsiElementFactory.SERVICE.getInstance(project)
                    .createStatementFromText(Constants.INIT_METHOD_CALL_IN_APP_ONCREATE, psiClass);
            psiClass.addAfter(psiStatement, anchor);
        });
    }

通过插件添加Gradle依赖

要添加gradle依赖自然需要先找到app module的build.gradle文件,然后找到Dependencies节点,并添加需要的依赖。

1.找到app module的build.gradle文件

      /**
     * 获取apply plugin: 'com.android.application'所在的PisFile中的 buildscript 的PsiElement
     *
     * @param project
     * @return
     */
    public static PsiElement getAppBuildScriptFile(Project project) {
        PsiFile[] psiFiles = FilenameIndex.getFilesByName(project, "build.gradle", GlobalSearchScope.projectScope(project));
        for (PsiFile psiFile : psiFiles) {
            PsiElement[] psiElements = psiFile.getChildren();
            for (PsiElement psiElement : psiElements) {
                if ( psiElement instanceof GrMethodCallExpressionImpl && "buildscript".equals(psiElement.getFirstChild().getText())) {
                    return psiElement;
                }
            }
        }
        return null;
    }

2.找到Dependencies节点

  /**
     * 找出buildscript节点下的dependencies节点
     *
     * @param buildscriptElement
     * @return
     */
    public static PsiElement findBuildScriptDependencies(PsiElement buildscriptElement) {
        //buildscriptElement 最后一个child 是 codeBlock
        PsiElement[] psiElements = buildscriptElement.getLastChild().getChildren();
        for (PsiElement psiElement : psiElements) {
            if (psiElement instanceof GrMethodCallExpressionImpl && "dependencies".equals(psiElement.getFirstChild().getText())) {
                return psiElement;
            }
        }
        return null;
    }
  1. 添加依赖
    /**
     * 添加Dependencies,
     *
     * @param project
     * @param dependenciesElement 整个Dependencies父节点
     */
    public static void addDependencies(Project project, PsiElement dependenciesElement, List<String> depends) {
        WriteCommandAction.runWriteCommandAction(project, () -> {
            for (String depend : depends) {
                GrStatement statement = GroovyPsiElementFactory.getInstance(project).createStatementFromText(depend);
                PsiElement dependenciesClosableBlock = dependenciesElement.getLastChild();
                //添加依赖项在 } 前,即在dependencies 末尾添加新的依赖项
                dependenciesClosableBlock.addBefore(statement, dependenciesClosableBlock.getLastChild());
            }
            Loger.info("addDependencies success!");
        });
    }

AndroidManifest.xml解析

    /**
     * 使用Dom方式解析 Manifest.xml
     *
     * @param project
     * @return
     */
    public static ManifestModel resolveManifestModel(Project project) {
        DomManager manager = DomManager.getDomManager(project);
        ManifestModel manifestModel = new ManifestModel();
        PsiFile[] psiFiles = FilenameIndex.getFilesByName(project, "AndroidManifest.xml", GlobalSearchScope.projectScope(project));
        if (psiFiles.length <= 0) {
            Loger.error("this project is not an Android Project!");
        }
        for (PsiFile psiFile : psiFiles) {
            if (!(psiFile instanceof XmlFile)) {
                Loger.error("this file cannot cast to XmlFile,just ignore!");
                continue;
            }
            DomFileElement<Manifest> domFileElement = manager.getFileElement((XmlFile) psiFile, Manifest.class);
            if (domFileElement != null) {
                Manifest manifest = domFileElement.getRootElement();
                if (manifest.getPackage().getXmlAttributeValue() != null) {
                    manifestModel.packageName = manifest.getPackage().getXmlAttributeValue().getValue();
                }
                Application application = manifest.getApplication();

                manifestModel.application = application;
                if (application.exists()) {
                    //application存在则说明这是主app module的AndroidManifest.xml
                    if (application.getName().exists()) {
                        //android:name已经存在,无需重复添加
                        Loger.info("application.getName()==" + application.getName().getRawText());
                        manifestModel.applicationName = application.getName().getRawText();
                    }
                } else {
                    Loger.info("application section not exist,just ignore this xml file!");
                }
            }
        }
        return manifestModel;
    }

如果有需要添加android:name,则可以这样子做:

    /**
     * 在AndroidManifest.xml添加自定义application属性
     * @param project
     * @param application
     */
    public static void addApplicationName(Project project, Application application) {
        WriteCommandAction.runWriteCommandAction(project, () -> {
            application.getXmlTag().setAttribute("android:name", "."+Constants.APPLICATION_CLASS_NAME);
            CommonUtils.refreshProject(project);
            Loger.info("addApplicationName success!!");
        });
    }

一点小技巧

查看文件的PSI结构

如果需要对某个文件进行增删改,首先就需要解析文件的PsiElement结构。而查看结构IntelliJ IDEA本身就支持了。

enter description here
enter description here

plugin工程运行调试可直接运行调试

enter description here
plugin工程可直接运行调试,点击运行后会打开一个沙盒Intellij IDEA编辑器,用这个编辑器打开工程即可运行调试插件。但是这个沙盒Intellij IDEA比较弱,对于额外的API依赖不支持,因此Gradle 插件API运行会失败。如果有更好的办法欢迎告知。

总结

这次插件的开发过程简直是痛并快乐着的一次体验,因为现有大部分关于IDEA插件的开发文章都是比较简单的介绍,特别是针对Android文件(包括gradle文件,properties文件,AndroidManifest.xml文件)的修改更是难找。所以,关于这些文件的修改开发,都是靠类比Java文件结构推理,查看IDEA 插件SDK API以及不断尝试完成的。

参考资料

IntelliJ Platform SDK DevGuide

AndroidStudio插件超详细教程

Android Studio Plugin 插件开发教程