动手试试Android Studio插件开发

由于业务关系,经常需要写一些表单页面,基本也就是简单的增删改查然后上传,做过几个页面之后就有点想偷懒了,这么低水平重复性的体力劳动,能不能用什么办法自动生成呢,查阅相关资料,发现android studio插件正好可以满足需求,在Github上搜了一下,找到BorePlugin这个帮助自动生成布局代码的插件挺不错的,在此基础上修改为符合自己需求的插件,整体效果还不错。
发现了android studio插件的魅力,自己也总结一下,也给小伙伴们提供一点参考,今天就以实现自动生成findviewbyid代码插件的方式来个简单的总结。这里就不写行文思路了,一切从0开始,一步一步搭建起这个插件项目吧。效果如下:

效果图

一、搭建环境

由于android studio是基于Intellij IDEA开发的,但Android Studio自身不具备开发插件的功能,所以插件开发需要在IntelliJ IDEA上开发。
好了,说了这么多,开始去官网下载吧,下载地址:https://www.jetbrains.com/idea/
安装运行后我们就可以开始开发了。
创建项目

创建项目

创建成功之后的文件夹是这个样子的:

创建项目

我们重点关注plugin.xml和src,plugin.xml是我们这个插件项目的配置说明,类似于android开发中的AndroidManifest.xml文件,用于配置信息的注册和声明。

<idea-plugin version="2">
  <id>com.your.company.unique.plugin.id</id>
  <name>Plugin display name here</name>
  <version>1.0</version>
  <vendor email="support@yourcompany.com" url="http://www.yourcompany.com">YourCompany</vendor>

  <description><![CDATA[
      Enter short description for your plugin here.<br>
      <em>most HTML tags may be used</em>
    ]]></description>

  <change-notes><![CDATA[
      Add change notes here.<br>
      <em>most HTML tags may be used</em>
    ]]>
  </change-notes>

  <!-- please see http://www.jetbrains.org/intellij/sdk/docs/basics/getting_started/build_number_ranges.html for description -->
  <idea-version since-build="141.0"/>

  <!-- please see http://www.jetbrains.org/intellij/sdk/docs/basics/getting_started/plugin_compatibility.html
       on how to target different products -->
  <!-- uncomment to enable plugin in all products
  <depends>com.intellij.modules.lang</depends>
  -->

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

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

</idea-plugin>

来简单介绍下这个XML配置文件:
id:插件的ID,保证插件的唯一性,如果上传仓库的话。
name:插件名称。
version:版本号。
description:插件的简介。
change-notes:版本更新信息。
extensions:扩展组件注册 。
actions:Action注册,比如在某个菜单下增加一个按钮就要在这注册。

二、开始编码

1、编写菜单选项,用于触发我们的插件。

类似于这样的菜单选项

好了,现在我们要用到很关键的一个类:AnAction,选择new->Action就可以创建:

Action
配置Action

ActionID:代表该Action的唯一的ID
ClassName:类名
Name:插件在菜单上的名称
Description:对这个Action的描述信息
Groups:定义这个菜单选项出现的位置,比如图中设置当点击菜单栏Edit时,第一项会出现GenerateCode的选项,右边的Anchor是选择该选项出现的位置,默认First即最顶部。
之后会出现我们创建的GenerateCodeAction类:

public class GenerateCodeAction extends AnAction {

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

}

plugin.xml中也多了一段代码:

<action id="HelloWorld.TestGenerateCodeAction" class="com.example.helloworld.GenerateCodeAction" text="GenerateCode"
description="generate findviewbyid code ">
<add-to-group group-id="CodeMenu" anchor="first"/>
<keyboard-shortcut keymap="$default" first-keystroke="meta I"/>
</action>

这样,一个菜单选项就完成了,接下来就该实现当用户点击GenerateCode菜单或者按快捷键Command+ M后的功能代码了。

2、实现功能逻辑代码

在实现功能逻辑之前,我们要先理清需求,首先我们是想在选中布局文件的时候,自动解析布局文件并生成findviewbyid代码。那我们主要关注三个点就可以了。

1、如何获取布局文件
2、如何解析布局文件
3、如何根据将代码写入文件

1、如何获取布局文件
为简单起见,我们这里通过让用户自己输入布局文件的方式通过FilenameIndex.getFilesByName方法来查找布局文件。
查找文件我们要用到PsiFile类,官方文档给我们的提供了几种方式:

From an action: 
    e.getData(LangDataKeys.PSI_FILE).
From a VirtualFile: 
    PsiManager.getInstance(project).findFile()
From a Document:     
    PsiDocumentManager.getInstance(project).getPsiFile()
From an element inside the file:
     psiElement.getContainingFile()
To find files with a specific name anywhere in the project, use :
    FilenameIndex.getFilesByName(project, name, scope)

这里使用最后一种方式来获取图片,获取用户选中的布局文件,如果用户没有选中内容,通过在状态栏弹窗提示:

 public static void showNotification(Project project, MessageType type, String text) {
        StatusBar statusBar = WindowManager.getInstance().getStatusBar(project);

        JBPopupFactory.getInstance()
                .createHtmlTextBalloonBuilder(text, type, null)
                .setFadeoutTime(7500)
                .createBalloon()
                .show(RelativePoint.getCenterOf(statusBar.getComponent()), Balloon.Position.atRight);
    }

获取用户选中内容:

@Override
    public void actionPerformed(AnActionEvent e) {

        Project project = e.getProject();
        Editor editor = e.getData(PlatformDataKeys.EDITOR);
        if (null == editor) {
            return;
        }

        SelectionModel model = editor.getSelectionModel();
        //获取选中内容
        final String selectedText = model.getSelectedText();
        if (TextUtils.isEmpty(selectedText)) {
            Utils.showNotification(project,MessageType.ERROR,"请选中生成内容");
            return;
        }
    }
弹窗效果

获取XML文件:

PsiFile[] mPsiFiles = FilenameIndex.getFilesByName(project, selectedText+".xml", GlobalSearchScope.allScope(project));
        if (mPsiFiles.length<=0){
            Utils.showNotification(project,MessageType.INFO,"所输入的布局文件没有找到!");
            return;
        }
        XmlFile xmlFile =  (XmlFile) mPsiFiles[0];

至此,布局文件获取到了,我们开始下一步,解析布局文件啦。
2、如何解析布局文件
关于文件操作,官方文档是这样写的:

Most interesting modification operations are performed on the level of individual PSI elements, not files as a whole.
To iterate over the elements in a file, use
psiFile.accept(new PsiRecursiveElementWalkingVisitor()...);

我们这里通过file.accept(new XmlRecursiveElementVisitor())方法对XML文件进行解析:

public static ArrayList<Element> getIDsFromLayout(final PsiFile file, final ArrayList<Element> elements) {
        file.accept(new XmlRecursiveElementVisitor() {

            @Override
            public void visitElement(final PsiElement element) {
                super.visitElement(element);
                //解析XML标签
                if (element instanceof XmlTag) {
                    XmlTag tag = (XmlTag) element;
                  //解析include标签
                    if (tag.getName().equalsIgnoreCase("include")) {
                        XmlAttribute layout = tag.getAttribute("layout", null);

                        if (layout != null) {
                            Project project = file.getProject();
//                            PsiFile include = findLayoutResource(file, project, getLayoutName(layout.getValue()));
                            PsiFile include = null;
                            PsiFile[] mPsiFiles = FilenameIndex.getFilesByName(project, getLayoutName(layout.getValue())+".xml", GlobalSearchScope.allScope(project));
                            if (mPsiFiles.length>0){
                                include = mPsiFiles[0];
                            }

                            if (include != null) {
                                getIDsFromLayout(include, elements);

                                return;
                            }
                        }
                    }

                    // get element ID
                    XmlAttribute id = tag.getAttribute("android:id", null);
                    if (id == null) {
                        return; // missing android:id attribute
                    }
                    String value = id.getValue();
                    if (value == null) {
                        return; // empty value
                    }

                    // check if there is defined custom class
                    String name = tag.getName();
                    XmlAttribute clazz = tag.getAttribute("class", null);
                    if (clazz != null) {
                        name = clazz.getValue();
                    }

                    try {
                        Element e = new Element(name, value, tag);
                        elements.add(e);
                    } catch (IllegalArgumentException e) {
                        // TODO log
                    }
                }
            }
        });


        return elements;
    }

    public static String getLayoutName(String layout) {
        if (layout == null || !layout.startsWith("@") || !layout.contains("/")) {
            return null; // it's not layout identifier
        }

        String[] parts = layout.split("/");
        if (parts.length != 2) {
            return null; // not enough parts
        }

        return parts[1];
    }

以及实体类Element:

package com.example.helloworld.entity;

import com.intellij.psi.xml.XmlAttribute;
import com.intellij.psi.xml.XmlTag;

import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class Element {

    // constants
    private static final Pattern sIdPattern = Pattern.compile("@\\+?(android:)?id/([^$]+)$", Pattern.CASE_INSENSITIVE);
    private static final Pattern sValidityPattern = Pattern.compile("^([a-zA-Z_\\$][\\w\\$]*)$", Pattern.CASE_INSENSITIVE);
    public String id;
    public boolean isAndroidNS = false;
    public String nameFull; // element mClassName with package
    public String name; // element mClassName
    public int fieldNameType = 1; // 1 aa_bb_cc; 2 aaBbCc 3 mAaBbCc
    public boolean isValid = false;
    public boolean used = true;
    public boolean isClickable = false; // Button, view_having_clickable_attr etc.
    public boolean isItemClickable = false; // ListView, GridView etc.
    public boolean isEditText = false; // EditText
    public XmlTag xml;

    //GET SET mClassName
    public String strGetMethodName;
    public String strSetMethodName;

    /**
     * Constructs new element
     *
     * @param name Class mClassName of the view
     * @param id   Value in android:id attribute
     * @throws IllegalArgumentException When the arguments are invalid
     */
    public Element(String name, String id, XmlTag xml) {
        // id
        final Matcher matcher = sIdPattern.matcher(id);
        if (matcher.find() && matcher.groupCount() > 1) {
            this.id = matcher.group(2);

            String androidNS = matcher.group(1);
            this.isAndroidNS = !(androidNS == null || androidNS.length() == 0);
        }

        if (this.id == null) {
            throw new IllegalArgumentException("Invalid format of view id");
        }

        // mClassName
        String[] packages = name.split("\\.");
        if (packages.length > 1) {
            this.nameFull = name;
            this.name = packages[packages.length - 1];
        } else {
            this.nameFull = null;
            this.name = name;
        }

        this.xml = xml;

        // clickable
        XmlAttribute clickable = xml.getAttribute("android:clickable", null);
        boolean hasClickable = clickable != null &&
                clickable.getValue() != null &&
                clickable.getValue().equals("true");
        String xmlName = xml.getName();
        if (xmlName.contains("RadioButton")) {
            // TODO check
        } else {
            if ((xmlName.contains("ListView") || xmlName.contains("GridView")) && hasClickable) {
                isItemClickable = true;
            } else if (xmlName.contains("Button") || hasClickable) {
                isClickable = true;
            }
        }

        // isEditText
        isEditText = xmlName.contains("EditText");
    }

    /**
     * Create full ID for using in layout XML files
     *
     * @return
     */
    public String getFullID() {
        StringBuilder fullID = new StringBuilder();
        String rPrefix;

        if (isAndroidNS) {
            rPrefix = "android.R.id.";
        } else {
            rPrefix = "R.id.";
        }

        fullID.append(rPrefix);
        fullID.append(id);

        return fullID.toString();
    }

    /**
     * Generate field mClassName if it's not done yet
     *
     * @return
     */
    public String getFieldName() {
        String fieldName = id;
        String[] names = id.split("_");
        if (fieldNameType == 2) {
            // aaBbCc
            StringBuilder sb = new StringBuilder();
            for (int i = 0; i < names.length; i++) {
                if (i == 0) {
                    sb.append(names[i]);
                } else {
                    sb.append(firstToUpperCase(names[i]));
                }
            }
            fieldName = sb.toString();
        } else if (fieldNameType == 3) {
            // mAaBbCc
            StringBuilder sb = new StringBuilder();
            for (int i = 0; i < names.length; i++) {
                if (i == 0) {
                    sb.append("m");
                }
                sb.append(firstToUpperCase(names[i]));
            }
            fieldName = sb.toString();
        }
        return fieldName;
    }

    /**
     * Check validity of field mClassName
     *
     * @return
     */
    public boolean checkValidity() {
        Matcher matcher = sValidityPattern.matcher(getFieldName());
        isValid = matcher.find();

        return isValid;
    }
    public static String firstToUpperCase(String key) {
        return key.substring(0, 1).toUpperCase(Locale.CHINA) + key.substring(1);
    }
}

一些有用的方法
通用方法
FilenameIndex.getFilesByName()通过给定名称(不包含具体路径)搜索对应文件
ReferencesSearch.search()类似于IDE中的Find Usages操作
RefactoringFactory.createRename()重命名
FileContentUtil.reparseFiles()通过VirtualFile重建PSI

Java专用方法
ClassInheritorsSearch.search()搜索一个类的所有子类
JavaPsiFacade.findClass()通过类名查找类
PsiShortNamesCache.getInstance().getClassesByName()通过一个短名称(例如LogUtil)查找类
PsiClass.getSuperClass()查找一个类的直接父类
JavaPsiFacade.getInstance().findPackage()获取Java类所在的Package
OverridingMethodsSearch.search()查找被特定方法重写的方法

3、如何根据将代码写入文件
如Android不允许在UI线程中进行耗时操作一样,Intellij Platform也不允许在主线程中进行实时的文件写入,而需要通过一个异步任务来进行。

  new WriteCommandAction(project) {
            @Override
            protected void run(@NotNull Result result) throws Throwable {
                //writing to file
            } 
       }.execute();

也可以继承自WriteCommandAction.Simple来执行写操作。

 @Override
    public void run() throws Throwable {

        generateFields();
        generateFindViewById();
        // reformat class
        JavaCodeStyleManager styleManager = JavaCodeStyleManager.getInstance(mProject);
        styleManager.optimizeImports(mFile);
        styleManager.shortenClassReferences(mClass);
        new ReformatCodeProcessor(mProject, mClass.getContainingFile(), null, false).runWithoutProgress();
    }

主要使用psiclass.add(JavaPsiFacade.getElementFactory(mProject).createMethodFromText(sbInitView.toString(), psiclass))方法为类创建方法;用mFactory.createFieldFromText方法添加字段;用mClass.findMethodsByName方法查找方法,用onCreate.getBody().addAfter(mFactory.createStatementFromText("initView();", mClass), setContentViewStatement);方法为方法体添加内容。

protected void generateFields() {
        for (Iterator<Element> iterator = mElements.iterator(); iterator.hasNext(); ) {
            Element element = iterator.next();

            if (!element.used) {
                iterator.remove();
                continue;
            }

            // remove duplicate field
            PsiField[] fields = mClass.getFields();
            boolean duplicateField = false;
            for (PsiField field : fields) {
                String name = field.getName();
                if (name != null && name.equals(element.getFieldName())) {
                    duplicateField = true;
                    break;
                }
            }

            if (duplicateField) {
                iterator.remove();
                continue;
            }
            String hint = element.xml.getAttributeValue("android:hint");
            mClass.add(mFactory.createFieldFromText("/** "+hint+" */\nprivate " + element.name + " " + element.getFieldName() + ";", mClass));
        }
    }

    protected void generateFindViewById() {
        PsiClass activityClass = JavaPsiFacade.getInstance(mProject).findClass(
                "android.app.Activity", new EverythingGlobalScope(mProject));
        PsiClass compatActivityClass = JavaPsiFacade.getInstance(mProject).findClass(
                "android.support.v7.app.AppCompatActivity", new EverythingGlobalScope(mProject));

        // Check for Activity class
        if ((activityClass != null && mClass.isInheritor(activityClass, true))
                || (compatActivityClass != null && mClass.isInheritor(compatActivityClass, true))
                || mClass.getName().contains("Activity")) {
            if (mClass.findMethodsByName("onCreate", false).length == 0) {
                // Add an empty stub of onCreate()
                StringBuilder method = new StringBuilder();
                method.append("@Override protected void onCreate(android.os.Bundle savedInstanceState) {\n");
                method.append("super.onCreate(savedInstanceState);\n");
                method.append("\t// TODO: add setContentView(...) and run LayoutCreator again\n");
                method.append("}");

                mClass.add(mFactory.createMethodFromText(method.toString(), mClass));
            } else {
                PsiStatement setContentViewStatement = null;
                boolean hasInitViewStatement = false;

                PsiMethod onCreate = mClass.findMethodsByName("onCreate", false)[0];
                for (PsiStatement statement : onCreate.getBody().getStatements()) {
                    // Search for setContentView()
                    if (statement.getFirstChild() instanceof PsiMethodCallExpression) {
                        PsiReferenceExpression methodExpression = ((PsiMethodCallExpression) statement.getFirstChild()).getMethodExpression();
                        if (methodExpression.getText().equals("setContentView")) {
                            setContentViewStatement = statement;
                        } else if (methodExpression.getText().equals("initView")) {
                            hasInitViewStatement = true;
                        }
                    }
                }

                if(!hasInitViewStatement && setContentViewStatement != null) {
                    // Insert initView() after setContentView()
                    onCreate.getBody().addAfter(mFactory.createStatementFromText("initView();", mClass), setContentViewStatement);
                }
                generatorLayoutCode();
            }
        }
    }
    private void generatorLayoutCode() {
        // generator findViewById code in initView() method
        StringBuilder initView = new StringBuilder();
            initView.append("private void initView() {\n");

        for (Element element : mElements) {
            initView.append(element.getFieldName() + " = (" + element.name + ")findViewById(" + element.getFullID() + ");\n");
        }
        initView.append("}\n");
      mClass.add(mFactory.createMethodFromText(initView.toString(), mClass));

    }

至此,我们之前的目标已经完成了,编码阶段告一段落。

三、使用插件

我们的插件实现完了,填写下plugin.xml文件相关内容,我们就可以导出需要安装的jar文件了:

导出安装jar文件
用于安装的jar

打开android studio,进入setting页面,安装插件:

安装插件

到这里,重启android studio就可以使用我们的插件了。
当然,还可以把我们的插件发布到仓库,支持在plugin中搜索安装,可以参考官方给的文档:
http://www.jetbrains.org/intellij/sdk/docs/basics/getting_started/publishing_plugin.html

我们的插件这样就完成了,本文很多地方实现都参考了BorePlugin的实现,如果对实现细节感兴趣,可以查看这个开源项目的源码,再次也对作者表示感谢。文章简化版本的源码相对简单,方便理解,可以点此下载

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,294评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,493评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,790评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,595评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,718评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,906评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,053评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,797评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,250评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,570评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,711评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,388评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,018评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,796评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,023评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,461评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,595评论 2 350

推荐阅读更多精彩内容