主页 > 开发文档 > 如何打造一个 Android 编译时注解框架

如何打造一个 Android 编译时注解框架

  • 前言

  • 预览

  • permissions4m-annotation

  • permissions4m-processor

    • AnnotationProcessor

    • ProxyInfo

    • AnnotationProcessor 编码

    • ProxyInfo 编码

  • permissions4m-api

  • 后话

<h2 id="perform">前言</h2>
近期笔者开源了一个 Android 编译时注解框架库——Permissions4M,一款处理 Android 6.0 运行时权限的库。此库基于鸿洋前辈的 MPermissions 二次开发,目前 Android 中主流的几款编译时处理框架有大名鼎鼎的 Dagger2 和 Butterknife,希望在阅读完笔者的这篇博客和笔者的框架后,能够帮助各位读者更深一步的帮助各位读者了解 Android 编译时注解处理技术,所以希望读者在阅读笔者的这篇博客前,请先对注解有些了解,对 Android 6.0 运行时权限有一定了解,更好地是对笔者的库试一试,便于理解笔者后面所述内容。推荐阅读以下内容:

  • Android注解快速入门和实用解析

  • Android 如何编写基于编译时注解的项目

<h2 id="preview">预览</h2>
为了便于各位读者理解 Permissions4M,笔者特地画下了以下这幅图:

  • 编译前:

这里写图片描述

编译前,你的程序中(为了更方便的理解,后面笔者就不使用程序两个字,直接使用“Activity”来实例化程序这两个字)除了自己写的代码之外,涉及到注解库的内容可以被分成两个部分,一个部分是注解,另一个部分应该是你所调用的 API,例如在 Butterknife 中,你可能会用到注解 @BindView,而使用到 API 应该是 ButterKnife.bind( this ) ;可能各位读者在写的时候不怎么会去细分模块,但是如果是设计一个注解库的话,应该是将这两块分开,一个是为了方面模块化开发,另一个方面是后面所要开发的注解器的模块库要基于注解库。

  • 编译中:

这里写图片描述

在编译的这个过程中,我们的注解处理器就发挥作用了,它会对 Activity 扫描,然后进行信息提取与处理,最后拼接出来一个代理类,同样的,我们的注解处理器应该也是一个单独区分于注解模块和 API 模块的一个新的模块,所以来说一共需要三个模块来进行开发,分别是 API 模块,用来暴露给用户进行使用;注解模块,虽然也是暴露给用户使用,但是应该区分于 API 模块,各司其职;注解处理器模块,对用户进行屏蔽,模块作用仅为在编译器为使用了注解的 Activity 生成代理类。

  • 编译后:

这里写图片描述

一直未说到代理类和 API 模块的作用,实际上 API 模块就是对生成的代理类进行操作,以此来到达我们所想要的目的,例如 ButterKnife.bind( this ) ; ,通俗点说实际上就是在相应的代理类中调用了 findViewById() 方法,那么该代理类是如何将findViewById() 和 @BindView 一一对应起来的呢?不好意思笔者不知道,因为笔者没看过 Butterknife 的源码。但是笔者可以告诉你 Permissions4M 是如何将权限回调函数与 @PermissionsGranted@PermissionsDenied@PermissionsRationale 一一对应起来的。

<h2 id="annotation">permissions4m-annotation</h2>
在 Android Studio 中点击菜单左上角 File -> New -> Module,选择 java module,为什么选择 java module?前期提到,这个库中仅涉及到所使用到的注解,所以 java 类型的库已经可以满足我们的所有需求,并不需要使用 android library,否则涉及到多余的资源文件反而使得我们的库大小会更大,permissions4m-annotation module 截图如下:

这里写图片描述

前四个注解可以归为一类,分别是自定义权限二次申请回调、权限拒绝时回调、权限通过时回调和权限二次申请回调。这里拿出 PermissionsGranted 为各位读者解答下:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.CLASS)
public @interface PermissionsGranted {
    int[] value();
}

虽然前期笔者提到希望各位读者具有一点注解基础,但是笔者还是在这里啰嗦一下,我们需要使用 @Retention(RetentionPolicy.CLASS) 注解和 @Target(ElementType.METHOD) 标记我们的注解接口,前者是将接口相关信息只保留到 .class 时期,后者是表明接口修饰的对象是方法。而接口中 int[] value() 是什么意思呢?它的意思就是如果我们这样使用 @PermissionsGranted 注解是不可以的 ——

@PermissionsGranted()
public void grant() {
}

而应该像如下方法使用:

@PermissionsGranted({1, 2})
public void grant(int code) {
}

@PermissionsGranted(1)
public void granted() {
}

所以说的简单点就是需要限制接口必须要传入一个整型数组才可以,那么为什么我们需要传入一个整型数组呢?使用过 Permissions4M 的小伙伴就知道,@PermissionsGranted 注解是支持任意个权限申请回调的,那么如何区分是哪个权限申请回调的呢?就是 @PermissionsGranted 注解修饰的方法的参数是一个整型值,根据该参数来进行判断,而该参数的可能值就是数组中的值,可能这样说还是不能很清晰,如下:

@PermissionsGranted({1, 2})
public void grant(int code) {
    switch (code) {
        case 1:
            break;
        case 2:
            break;
        case 3:
            // nerver
            break;
        default:
            break;
    }
}

如果各位读者在此还是一头雾水,不要紧,笔者在后期还会提到这块内容。 permissions4m-annotation 具体源码查看:jokermonn/permissions4m/permissions4m-annotation。各位读者发现笔者虽然 BB 了这么多,但是 permissions4m-annotation module 的源码却如此简单~

<h2 id="processor">permissions4m-processor</h2>
毫不避讳的说,这是三个模块中最难且最难理解的模块,但是不要慌,笔者会一一为大家解答,首先仍然是在 Android Studio 中点击 File -> New -> Module,新建一个 java module,为什么是一个 java module?其一是因为如 permissions4m-annotation 中提到的一般,使用 java 库已经可以满足我们的设计,其二是因为 Android 中对 javax.annotation 类进行了删减,所以 Android 库对自定义处理器支持很不好,所以综上两点我们会选择使用 java module。module 建好后,需要打开 module 下的 build.gradle 添加两行依赖,第一行是 compile project(':permissions4m-annotation') 表示 permissions4m-annotation 库依赖,第二行是 compile 'com.google.auto.service:auto-service:1.0-rc3',该库可以更好地辅助我们完成自定义注解处理器的设计。permissions4m-processor module 截图如下:

这里写图片描述

源码见:jokermonn/permissions4m/permissions4m-processor

<h3 id="annotationProcessor">AnnotationProcessor</h3>

AnnotationProcessor 继承自 AbstractProcessor,实际上它需要完成两件事,一是获取我们所需要的信息,二是命令 ProxyInfo 去生成相应的代理类的代码,首先固定套路如下:

1.构造函数

private Elements mUtils;
private Filer mFiler;

@Override
public synchronized void init(ProcessingEnvironment processingEnv) {
    super.init(processingEnv);
    mUtils = processingEnv.getElementUtils();
    mFiler = processingEnv.getFiler();
}

mUtils 可以帮助我们获取到使用注解的类的信息、类的包、方法信息等等,所以说 mUtils 是一个对于我们来说十分强大且在获取信息这个过程中必不可少的工具类。mFiler 是生成 java 文件的类,也就是我们后期需要用来生成代理类的工具类。

2.覆写方法

@Override
public Set<String> getSupportedAnnotationTypes() {
    Set<String> set = new HashSet<>(5);
    set.add(PermissionsGranted.class.getCanonicalName());
    set.add(PermissionsDenied.class.getCanonicalName());
    set.add(PermissionsRationale.class.getCanonicalName());
    set.add(PermissionsCustomRationale.class.getCanonicalName());
    set.add(PermissionsRequestSync.class.getCanonicalName());

    return set;
}

@Override
public SourceVersion getSupportedSourceVersion() {
    return SourceVersion.latestSupported();
}

getSupportedAnnotationTypes() 中将你所需要处理的注解的 getCanonicalName() 组成 Set 并返回即可,而 getSupportedSourceVersion() 中你只需要返回 SourceVersion.latestSupported(); 即可。

3.类头注解

@AutoService(Processor.class)
public class AnnotationProcessor extends AbstractProcessor {
}

固定套路,使用 @AutoService(Processor.class) 注解当前类。

4.覆写 process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) 方法
这是注解器的核心方法,就是通过该方法,我们提取到关于注解使用地方的信息,例如类名,类的包名,方法名,注解传入的参数等等。具体内容会在后面提及到。

<h3 id="proxyInfo">ProxyInfo</h3>

前期提到,ProxyInfo 类就是提供代理类的代码的,那么我们首先要想到,写一个代理类的话我们需要什么。例如我们在 MainActivity 中使用了我们的注解,那么我们生成的 MainActivity 代理类需要 MainActivity 的包名、注解当中传入的数组参数、注解所修饰的方法的方法名等等信息。说到这里就差不多告一段落了,我们就开始正式的写代码的过程了。

<h3 id="annotationProcessorCode">AnnotationProcessor 编码</h3>

前期提到,核心方法是 process(),而我们整个注解器中真正的逻辑业务代码也仅需要在此方法内写上就可以了,permissions4m-processor 中的 process 方法源码如下:

这里写图片描述

首先,方法在返回 false 的时候处理器将不会做任何事情,直接跳过当前第一次循环,类似于 continue;,而如果希望处理器处理的话,应当返回 true。方法分成两个部分,第一个部分是提取代理类所需要的相关的信息并塞给 ProxyInfo,第二个部分就是使用 mFiler + ProxyInfo 生成我们所需要的代理类。是不是 so easy?好消息是第二部分的东西也是个固定套路,并不需要灵活多变,另一个好消息是第一部分的源码实际上也不是很难。首先是创建一个 HashMap,键值对为 String-ProxyInfo,实际意义是使用到了注解的类的全路径-ProxyInfo,例如 "com.joker.test.MainActivity"- ProxyInfo,"com.joker.test.SecondActivity"- ProxyInfo,前期提到,该方法会被多次循环调用,所以为了避免生成重复的代理类,避免生成类的类名已存在异常。,我们需要在最开始的地方进行 map.clear() 操作。接下来就是相应的判断了,如果不符合条件,我们应当直接返回 false,此处我们就只对 isAnnotatedWithMethod(RoundEnvironment roundEnv, Class<? extends Annotation> clazz) 方法进行分析,简化后源码如下:

private boolean isAnnotatedWithMethod(RoundEnvironment roundEnv, Class<? extends Annotation> clazz) {
    Set<? extends Element> elements = roundEnv.getElementsAnnotatedWith(clazz);
    for (Element element : elements) {
        if (isValid(element)) {
            return false;
        }
        ExecutableElement method = (ExecutableElement) element;
        TypeElement typeElement = (TypeElement) method.getEnclosingElement();
        String typeName = typeElement.getQualifiedName().toString();
        ProxyInfo info = map.get(typeName);
        if (info == null) {
            info = new ProxyInfo(mUtils, typeElement);
            map.put(typeName, info);
        }

        Annotation annotation = method.getAnnotation(clazz);
        String methodName = method.getSimpleName().toString();
        if (annotation instanceof PermissionsGranted) {
            int[] value = ((PermissionsGranted) annotation).value();
            if (value.length > 1) {
                info.grantedMap.put(methodName, value);
            } else {
                info.singleGrantMap.put(value[0], methodName);
            }
        }
    }

    return true;
}

最外围的代码笔者就不做解析了,直接从 ExecutableElement method = (ExecutableElement) element; 开始解析,经过前面一系列的判断,我们已经能确保注解的使用方式符合我们的要求(必须注解的是方法、必须是 public 的且非 abstract 的),所以我们将 element 强制成 ExecutableElement 类型,代表它是一个方法,然后我们通过 getEnclosingElement(); 获取并将其强转成 TypeElement,该函数获取到的就是当前的类类型,TypeElement 有个非常好用的方法叫做 getQualifiedName().toString(),就可以将 TypeElement 的名字打印出来,也就是类名。然后我们根据类名来获取 ProxyInfo,如果我们发现 ProxyInfo 为空,那么我们就 put 一个新的 ProxyInfo 给 map 即可,组成一个新的 ProxyInfo 需要两个参数,一个是 mUtils(理由很简单,我们很有可能需要在 ProxyInfo 中使用它来获取更多的信息),一个是 TypeElement(我们生成的代理类需要和它在同一包下,并且我们也需要这个类的类型,比如说我们的 TypeElement 可能是 Activity 类型的也可能是 Fragment 类型的,而对于不同的类型,我们需要调用的权限申请的 API 是不同的)。说到这里其实已经差不多了,最后我们还需要的一样东西就是注解中的参数,我们肯定是会需要用到它的,而获取的方式也很简单,上面的代码已经很清晰了,笔者就不在此处做扩展了,需要提示的一点是,笔者对于每个注解提供了两个 map,是因为当参数只有一个和当参数有多个的情况下,生成的函数有不同,此处在后面会有相应的扩展。

<h3 id="proxyInfoCode">ProxyInfo 编码</h3>
<h4>思考</h4>
在编写代理类之前,笔者想让各位读者从大局的角度上来考虑下,我们前期说到, API module 实际上是调用代理类的方法来完成的,那么肯定的说代理类必须有固定的方法供 API module 来调用,那么如何才能有固定的方法呢?答案就是让代理类都去实现一个接口,这样 API module 就很好调用了,当然这部分的知识就涉及到 permissions4m-lib 中了。但是一个接口还是很容易理解的,笔者就在这里放出接口的源码了 ——

public interface PermissionsProxy<T> {
    void rationale(T object, int code);

    void denied(T object, int code);

    void granted(T object, int code);

    boolean customRationale(T object, int code);

    void startSyncRequestPermissionsMethod(T object);
}

方法的功能顾名思义,第一个方法是二次申请时调用,第二个方法是权限被拒时调用,第三个方法是权限通过时调用,第四个方法是指是否是自定义二次申请对话框,如果是的话,将会转交给 @PermissionsRationale 所修饰的方法,如果不是的话,那么就转交给 @PermissionsCustomRationale 所修饰的方法,而最后一个方法就是进行多权限同步申请时所调用的 API。方法中传入的第一个参数是一个泛型,实际上该泛型只能是 Activity 或者 Fragment 类型,因为在 Android 中,只有 Activity 或者 Fragment 才能实现权限申请。

<h4>编码</h4>
关于思考的部分说到这里,下面就是在 ProxyInfo 中进行实际的编码了,在这里再次回顾一下,我们创建一个代理类需要一些什么东西,我们可以打开一个类,从类的最上面开始看起来:

  • 包名:在 ProxyInfo 中我们通过传入的 mUtils.getPackageOf(typlElement).getQualifiedName().toString(); 即可获取

  • 引用的外部类:我们生成的代理类需要用的外部类只有一个,就是上述中所提到的接口,所以直接 "import " + permissions-lib 的包名

  • 代理类类名:对于 MainActivity 我们希望它的代理类名是 MainActivity$$PermissionsProxy,首先我们使用 `typeElement.getQualifiedName().toString().substring(packageName.length + 1)

                .replace('.', '$');`可以获取到字符串 "MainActivity",然后添加上 "$$PermissionsProxy" 即可,当然,不要忘了该类需要实现 PerimissionsProxy 接口。
    

代码如下:

StringBuilder builder = new StringBuilder();
builder.append("package ").append(packageName).append(";\n\n")
            .append("import com.joker.api.*;\n\n")
            .append("public class ").append(proxyName).append(" implements ").append
            (PERMISSIONS_PROXY).append
            ("<").append(element.getSimpleName()).append("> {\n");

这里需要说明的一点是,并不需要怕多写了一个分号少写了一个花括号什么的,不论多些还是少写了,编译后如果不符合 java 语法都是会报错的,所以大可不必担心。

  • granted/denied
    固定套路首先是要写上一个 @Override 来帮助我们确保后面的方法名不会写错,然后就是添加上方法名和方法参数即可。接下来我们得想起来, ProxyInfo 中有几个 map 是存放了方法名和 resquestCode 的信息的,如下:

这里写图片描述

所以我们需要对 map 进行遍历,每次的 key 就是方法名,而 value 就是对应的请求码。permissions4m 中除了传统的权限申请之外,还有一项是多项权限同步申请,其实做过同步申请的开发人员应该知道,其实就是对上一个权限的授予或被拒时的函数进行监听,并在此添加对下一个权限的申请,所以我们在此处应该还要对存放同步申请权限的 map 进行比对一下,如果 map 中有此次权限申请,那么下一次权限申请的代码应该写在此次权限申请的 granted/denied 方法中。源码如下:

 private void generateDeniedMethod(StringBuilder builder) {
        checkBuilderNonNull(builder);
        builder.append("@Override\n").append("public void denied(").append(element.getSimpleName())
                .append(" object, int code) {\n")
                .append("switch(code) {");
        for (String methodName : deniedMap.keySet()) {
            int[] ints = deniedMap.get(methodName);
            for (int requestCode : ints) {
                builder.append("case ").append(requestCode).append(":\n{");
                builder.append("object.").append(methodName).append("(").append(requestCode).append(");\n");
                // judge whether need write request permission method
                addSyncRequestPermissionMethod(builder, requestCode);
                builder.append("break;}\n");
                if (singleDeniedMap.containsKey(requestCode)) {
                    singleDeniedMap.remove(requestCode);
                }
            }
        }

        for (Integer requestCode : singleDeniedMap.keySet()) {
            builder.append("case ").append(requestCode).append(": {\n")
                    .append("object.").append(singleDeniedMap.get(requestCode)).append("();\nbreak;\n}");
        }

        builder.append("default:\nbreak;\n").append("}\n}\n\n");
    }

还是笔者那句话,这样看起来很晦涩,可以对照已经编译好的 .class 文件进行一一对应,理解起来就十分迅速 ——

这里写图片描述

  • rationale/customRationale
    与 granted/denied 相似,我们使用类似的套路完成代码,同时 rationale 方法中并不需要做什么额外的工作。在 customRationale 中,我们对 customRationale 相关的 map 进行遍历,取出相应的 code 之后 return true 即可,表示开发人员将会自定义二次权限申请回调,对于 return false 的函数我们都会走入 rationale 函数中,这里的具体逻辑流程走向,都写在 permissions4m-lib 中了。

这里写图片描述

  • startSyncRequestPermissionsMethod
    多个权限同步申请的函数内容其实很好写,我们只需要提取到同步申请权限 map 的第一个权限的信息,然后调用 permissions4m-lib 向外提供的 API 就可以进行同步申请了,因为在第一个权限申请的授权成功函数和授权失败函数中,会回调下一个权限申请,参考代码如下:

这里写图片描述

这里写图片描述

这里写图片描述

<h2 id="api">permissions4m-api</h2>
这是三个 module 中唯一的 Android module,在 Android Studio 中选择 File|New|Module,然后选择 Android library 进行创建。为什么使用 Android library?因为 permissions4m-api 是面向开发人员的库,并且会涉及到 Activity 和 Fragment 等类,所以必须得使用 Android library。jokermonn/permissions4m/permissions4m-api 结构如下:

这里写图片描述

关于 PermissionsProxy 接口前面已经做了阐述,所以这里介绍一下 Permissions4M 类,Permissions4M 就是暴露于开发人员的接口,对外暴露了如下几个方法接口,首先是多个权限同步申请:

  • syncRequestPermissions(Activity activity)

  • syncRequestPermissions(android.app.Fragment fragment)

  • syncRequestPermissions(android.support.v4.app.Fragment fragment)

同步申请多个权限,源码如下:

这里写图片描述

代码大体是一样的,首先针对当前应用版本进行判断,如果是低于 Android 6.0 的话,那么将不做任何处理,否则的话将会调用 initProxy() 函数,并将传入的类作为参数传给该函数:

这里写图片描述

源码十分简单,先获取传入类的 className,在拼接成代理类的名字,再使用反射来进行代理类的实例化。让我们再看看 syncRequestPermissions() 中的第三步,也就是 syncRequest() 方法 ——

这里写图片描述

一目了然,实际上就是调用了代理类的 startSyncRequestPermissionsMethod()。让我们再理一理流程——确保当前手机版本大于6.0 -> 实例化代理类 -> 调用代理类的多个权限同步申请方法

实际上不仅仅是针对于 syncRequestPermissions() 方法,permissions4m 暴露给开发人员的普通权限申请方法 requestPermission() 也是这么一个套路 ——

这里写图片描述

关键点在于 request 方法——

这里写图片描述

代码虽然看着有点多,但是如果读者有做过 Android 6.0 动态权限适配的经验的话,对于这段代码应该再熟悉不过了,笔者就拿 Activity 来说,首先是调用 ContextCompat.checkSelfPermission((Activity) object, permission) != PackageManager.PERMISSION_GRANTED) 方法来查看该权限是否被授予,如果已经被授予,那么就调用代理类的 granted() 方法。如果没有被授予,那么再通过 ((Activity) object).shouldShowRequestPermissionRationale(permission) 方法判断应用是否应该展示相应的提示信息,如果不展示的话,那么应当直接调用 ActivityCompat.requestPermissions((Activity) object, new String[]{permission}, requestCode); 进行权限申请,否则的话就是应当展示信息。进入展示信息的模块,我们要对开发人员的选择进行判断,开发人员是否自定义了二次权限申请的回调方式,如果没有,那么就进入普通的二次回调方式,显示回调 @PermissionsRationale 注解修饰的方法,再接着就是调用 ActivityCompat.requestPermissions((Activity) object, new String[]{permission}, requestCode); 进行权限申请。

通俗点说,Activity 的权限申请流程了 Permissions4M 来处理,而具体调用什么方法,Permissions4M 就会调用相应的代理类方法来完成。

<h2 id="after">后话</h2>
笔者通篇大话的写了这么多,不知道各位读者理解的如何,如果还有任何疑问,各位可以留言或者提 issues 给 permissions4m。笔者近期也有研究 IDEA 插件开发,希望能早日开发出 Permissions4M 的插件,减少开发人员负担。最后再为自己打个小广告,暑假后就大四了,目前想找份好一些的实习,各位读者若有推荐或可内推,望发送一封邮件至我的邮箱 jokerzoc.cn@gmail.com。诚挚感谢!