Android AOP 编译时注解开发

前言

公司项目要开发自己的路由模块,在研究了已有的开源库和进行模拟之后,选择使用编译时注解来开发,好处主要是对已有的代码入侵程度最小也更灵活.之前一直都用别人现有的注解类的库直接开发,虽然知道一些原理,但是毕竟没有实践经验,理解并不深.结合这次的机会,对开发流程做一个记录.
像我这么懒的人,写文章其实也有一点无奈.之前看过一些趣文,也看过斯坦福的 Android 开发公开课,米国中情局的内部黑客培训教程,从最开始的环境搭建开始讲,中间会细致到使用软件中的某个步骤的按钮在哪里都会标出来,清清楚楚,斯坦福的教授讲解时用的 eclipse 开发,跳转代码的快捷键是什么都会跟同学说,还会说忘记快捷键应该怎么操作.然后呢,就不得不吐槽一下网络上搜到的技术文的文风,经常是一个开发流程可能分为 ABC 三个步骤,博文直接从 B 开始讲了,B 怎么来的,原理是什么,也没有说明,整个文章看下来中间也有代码,也有截图,但是就是感觉无从下手,没办法得到博主的结果,感觉很无厘头.看了还不如不看,本来是带着一个 怎么弄 的问题去搜文章,看完了脑子里全变成了 为什么.

理论

主要是参考了 codeKK -- 公共技术点之 Java 注解 Annotation 这篇文章,对不太了解的关键词进行了搜索查阅,同时结合 butterknifeEventBus 两个开源库的源码帮助自己更快的理解上手.
关于理论这方面每个人的理解能力不一样,我通常是在脑海里给自己做类比或者建模,自己的讲解可能会让人更困扰,就不在这里多说了,推荐的文章说的很详细,我也未必讲解的比别人更清楚.
需要说明的是不管是 butterknife 使用的编译时注解,还是 EventBus 使用的运行时注解都会用到反射技术,所以注解的应用还是看开发项目时的取舍.

分析

编译时注解,主要的开发工作是对 自定义的注解 进行 编译时 处理,这么说明可能有点绕口,下面会用一个完整的示例来解释这句话,现在需要知道三点:

  1. 定义 注解
  2. 处理 注解
  3. 操作 处理结果

工具

AndroidStudio 最新稳定版本(3.0.1)即可

开发

  1. 新建一个 Android 项目,按照提示新建一个空 activity,然后一路点击 next 即可
    image.png
  1. 在工具栏,选择 file -> new -> new Module... 在弹出框中选择 Java Library ,新建一个用来编写注解的java库工程 -- router-annotations

    2.png

  2. 新建一个注解,其中 @Target 表示该注解可以用在哪些元素上,这里标记的是 Type 也就是 (稍后会看到使用), @Retention 注解保留策略,编译时注解使用 RetentionPolicy.CLASS ,给这个注解定义了一个默认值为 heheda 的参数成员 name()

    3.png

  3. 同步骤2,新建一个进行注解处理的java库工程 -- router-compiler

    4.png

  4. 这时候可能就会开始有为什么了

    1. 为什么要建两个库工程
    2. 这两个工程有什么区别

    问题1:我们先来看看 butterknife 的使用说明, 依赖声明中添加了两个声明

    compile 'com.jakewharton:butterknife:8.8.1' // api库
    annotationProcessor 'com.jakewharton:butterknife-compiler:8.8.1' //字面意思是注解处理器
    

    如果我们不引用这个注解处理器的库, 只引用 api 这个库,构建之后,在项目的依赖目录下和引用注解处理器库是没有任何区别的,并没有增加任何信息, 编码的时候也可以各种正常使用,比如调用api库里面的类和注解进行编码, 但是项目运行之后,使用了 butterknife 注解的变量就会报空指针异常,这说明了两个问题:首先注解处理器库不管里面有什么不会打到包里面去,因为看不到任何类;其次,它是用来处理注解的,什么时候处理,绕来绕去其实就是说 注解处理器在项目编译时处理了注解 ,然后它就不再参与打包流程.废话说的有点多有点绕,可以自行搜索 annotationProcessor 加强个人的理解.
    那么问题2:步骤2和步骤4建的项目有什么区别,首先既然都是 java library 那最后能提供的都是jar,问题缩小到jar之间有什么区别,我就不贴图了,直接按照我的理解说,jar包除了提供编译好的类(在包目录下)之外,还有一个 META-INFO 的目录,有一个 MANIFEST.MF 文件(其实是可有可无的), 但是 能声明为 annotationProcessor 的jar在该目录下会多出一个 services 的目录,里面有一个固定为 javax.annotation.processing.Processor 的文件,文件中会一行行列出声明为注解处理器的类的路径,方便编译器加载类.(在这说一下,有些文章说 "继承自AbstractProcessor(下面会说到)的类会自动参与编译时注解处理" 我觉得有点问题,如果所有继承自AbstractProcessor的类会自动参与注解处理,难道编译器会遍历一个个的子类加到编译器中?) 我没有继续做深入的理论研究,现在我们只需要知道这么多,上面的话引出另一个问题,如何才能生成上面说了那么多废话的目录,文件,以及文件内容.很显然如果是固定套路就应该是一个自动化的流程,google为我们提供了相关库,把下面的依赖信息添加到 router-compiler 的依赖声明中

    compile project(':router-annotations')
    compile 'com.google.auto.service:auto-service:1.0-rc2' //提供 @AutoService() 注解
    

    第一个依赖是让 router-compiler 可以调用到刚刚在 router-annotations 中生成的注解,第二个依赖就是用来解决上面所说的问题的, google开发的这个库可以在编译jar包的时候把上面所说的信息自动生成(也是用的注解机制)

  5. 然后就是对刚刚定义好的 @Test 注解进行处理的编码工作,定义一个 AbstractProcessor 的子类, 加上 @AutoService(Processor.class) 该注解来自上面说到的google提供的包

@AutoService(Processor.class)
public class TestProcessor extends AbstractProcessor {
}
  1. 重写相关的方法,首先是java版本,其次是要告诉编译器 -- 本处理器能处理哪些注解,当前只有一个注解,所以我们返回刚才声明的 @Test
    /**
     *
     * @return 声明java版本
     */
    @Override
    public SourceVersion getSupportedSourceVersion() {
        return SourceVersion.RELEASE_7;
    }

    /**
     *
     * @return 支持处理的注解集合
     */
    @Override
    public Set<String> getSupportedAnnotationTypes() {
        Set<String> annotations = new LinkedHashSet<>();
        annotations.add(Test.class.getCanonicalName());
        return annotations;
    }
  1. 重写 process 函数,开始我们的处理逻辑.返回值 true 表示该注解已经被处理完了,不需要继续传递给其他注解处理器( AbstractProcessor 的子类), false 相反,会继续传递
@Override
    public boolean process(Set<? extends TypeElement> set, RoundEnvironment roundEnvironment) {
        return false;
    }
  1. @Test 的作用域声明为 Type 所以我们处理 CLASS 种类的元素
    @Override
    public boolean process(Set<? extends TypeElement> set, RoundEnvironment roundEnvironment) {
        if (set.isEmpty()) {
            return false;
        }
        Set<? extends Element> elements = roundEnvironment.getElementsAnnotatedWith(Test.class);
        try {
            processTestAnnotationClass(elements);
        } catch (IOException e) {
            e.printStackTrace();
        }
        return true;
    }

    private void processTestAnnotationClass(Set<? extends Element> elements) throws IOException {
        for (Element classElement : elements) {
            switch (classElement.getKind()) {
            case CLASS:
                //todo 处理逻辑
                generateClassAnnotation(classElement);
                break;
            }
        }
    }
  1. 这里我的处理的逻辑是在 指定的包 下面生成一个 指定名称 的java类,并且重写 toString() 函数,返回一些信息. 关于动态编写代码,这里使用的是 com.squareup:javapoet:1.9.0 库 (相关用法可以查阅对应的文档, 刚接触不是很了解, 使用该库的相关api,可以免去自己编写 import 的语句,还是很方便的),同时我希望返回的信息是有结构的,所以这里也引用了 Android 里面使用的 apache 的 json 库,
compile 'com.squareup:javapoet:1.9.0'
compile 'org.json:json:20160810'

添加到 router-compiler 的依赖声明中.这里的返回信息是被注解的类的路径和声明 @Test 注解时添加的 name() 成员信息 (其实查阅相关api可以直接用其他方式直接返回 xxxActivity.class,这里只是为了演示获取 被注解的类的相关信息 的用法)

    private void generateClassAnnotation(Element classElement) throws IOException {
        /*   获取到注解对象,可以读取定义的信息  */
        Test test = classElement.getAnnotation(Test.class);

        /* 填充要记录的信息 */
        Map<String, Object> params = Maps.newHashMap();
        //获取被注解的 class 的完整路径
        params.put("p", ClassName.get((TypeElement) classElement).reflectionName());
        //获取标记注解时输入的信息
        params.put("n", test.name());

        //以下类信息来自 com.squareup:javapoet:1.9.0 主要用来动态生成代码,推荐使用
        //方法生成器
        MethodSpec.Builder toStringBuilder = MethodSpec.methodBuilder("toString")
                .addAnnotation(Override.class)
                .addModifiers(javax.lang.model.element.Modifier.PUBLIC)
                .returns(String.class)
                .addStatement("return $S", new JSONObject(params).toString());

        //类生成器
        TypeSpec typeSpec = TypeSpec.classBuilder(classElement.getSimpleName().toString())
                .addJavadoc("DO NOT EDIT THIS FILE!")
                .addModifiers(javax.lang.model.element.Modifier.PUBLIC)
                .addMethod(toStringBuilder.build())
                .build();
    }
  1. 要生成的类和重写函数都有了,这个时候就要开始生成java文件了,重写 AbstractProcessorinit 函数,从编译环境参数中获取 Filer 对象,该对象可以在编译时写文件,将类和函数组装在一起,使用 JavaFile 就可以写文件了, 第一个参数是 10 里面说到的 指定的包 路径声明,第二个参数是java类的信息(包含类定义和重写的 toString() 函数),最后通过 Filer 输出文件
    /**
     * 编译时可以做写操作的封装类
     */
    private Filer mFiler;       // File util, write class file into disk.

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

    private void generateClassAnnotation(Element classElement) throws IOException {
        /*   获取到注解对象,可以读取定义的信息  */
        Test test = classElement.getAnnotation(Test.class);

        /* 填充要记录的信息 */
        Map<String, Object> params = Maps.newHashMap();
        //获取被注解的 class 的完整路径
        params.put("p", ClassName.get((TypeElement) classElement).reflectionName());
        //获取标记注解时输出的信息
        params.put("n", test.name());

        //以下类信息来自 com.squareup:javapoet:1.9.0 主要用来动态生成代码,推荐使用
        //方法生成器
        MethodSpec.Builder toStringBuilder = MethodSpec.methodBuilder("toString")
                .addAnnotation(Override.class)
                .addModifiers(javax.lang.model.element.Modifier.PUBLIC)
                .returns(String.class)
                .addStatement("return $S", new JSONObject(params).toString());

        //类生成器
        TypeSpec typeSpec = TypeSpec.classBuilder(classElement.getSimpleName().toString())
                .addJavadoc("DO NOT EDIT THIS FILE!")
                .addModifiers(javax.lang.model.element.Modifier.PUBLIC)
                .addMethod(toStringBuilder.build())
                .build();
        //往 com.ll.support.router.table 包下面通过 mFiler 写 java 文件
        JavaFile.builder("com.ll.support.router.table", typeSpec).build().writeTo(mFiler);
    }
  1. 验证结果.添加相关的依赖到 Android app 工程,注解处理器需要对应的声明 annotationProcessor project(':router-compiler') ,但是这里的工程用到了kotlin(包括纯kotlin和java混合kotlin),所以这里是使用的是 kapt .注解处理器里面 jdk 声明的是 7, 所以标记开发环境是 java7, 然后进行 gradle 同步,依赖构建完成之后,在 app 工程中的 MainActivity 类声明我们的注解 @Test ,最后进行 build -> reBuild 或者直接 run
    apply plugin: 'com.android.application'
    apply plugin: 'kotlin-android'
    apply plugin: 'kotlin-android-extensions'

    android {
        compileSdkVersion 26
        defaultConfig {
            applicationId "com.ll.router"
            minSdkVersion 14
            targetSdkVersion 26
            versionCode 1
            versionName "1.0"
            testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
        }
        buildTypes {
            release {
                minifyEnabled false
                proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
            }
        }
        compileOptions {
            sourceCompatibility JavaVersion.VERSION_1_7
            targetCompatibility JavaVersion.VERSION_1_7
        }
    }

    dependencies {
        testImplementation 'junit:junit:4.12'
        androidTestImplementation 'com.android.support.test:runner:1.0.1'
        androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.1'
        implementation fileTree(dir: 'libs', include: ['*.jar'])
        implementation"org.jetbrains.kotlin:kotlin-stdlib-jre7:$kotlin_version"
        implementation 'com.android.support:appcompat-v7:26.1.0'
        implementation 'com.android.support.constraint:constraint-layout:1.0.2'

        kapt project(":router-compiler")
        compile project(":router-annotations")
    }
12.png
  1. 成功之后打开 app\build\generated\source\kapt\debug\com\ll\support\router\table 目录(如果使用的 annotationProcessor 声明,那 kapt替换成apt目录),就可以看到我们动态生成的java类了,同时查看我们的 apk 文件,也可以在 10 里面的 指定的包 下面找到我们的类.
    image.png
13-1.png

这里面没有对分析的第三步进行讲解 -- 操作处理结果.这个地方每个人的需求不同,就不再展开了,类似于 butterknife 自己开发相关 api 处理库就可以了.

开发流程就是上面所说的,掌握了这些操作之后,剩下的就是各种脑洞了.最后说一下在实践中会遇到的非理论性问题:经常是刚刚创建了 module 但是 Android studio 无法识别,我的解决办法是,在 settings.gradle 文件中删掉 module 声明,然后同步,再加上 module 声明再同步,或者干脆退出重新打开 studio,多试几次之后才会好,具体原因还不知道,世界上总有那么多未解之谜,只要中间不迷惑走到头就行了.另外这一招也可以用在重命名项目上.

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

推荐阅读更多精彩内容