前言
人闲下来就会对各种各样的东西感到好奇,好奇的东西多了就发现自己是真的菜。
今天这篇文章写出来的原因,源自一次非常非常“诡异的”ide的语法错误提示。
文章是由android的知识引入,但真正想聊的东西是编译原理。所以:才有了标题《奇怪的知识点》。因此各位看官没必要太纠结自己没有学过android或者java,不影响阅读~
复现一次语法错误的代码:
正文
一、android知识部分
ide提示的也很明白:res的id不能在library级别的module中的switch语法中应用。原因是res的id不是常量。
注意:同样的代码在application级别的module中是没有语法问题的。所以对于res的id来说,application中是常量,library中不是常量。如果有同学看过r的内容,就会发现的确如此:
这个是application中的r文件:
这个是library中的r文件:
这个显现引申出一个android打包的知识点:aapt[1]过程中的资源合并[2]。
一句话描述这个知识点:不同module之间的重复的资源会按优先级的进行合并覆盖。这个流程引发的问题,很多老司机都遇到过,资源被覆盖了,我们引用的资源永远会被指向唯一的res。这肯定是不符合预期的。
因此诸如给资源名加前缀的方案便应运而生。
为什么不是final
这里咱们聊一个问题:常量有什么特别之处?下面的代码,编译之后就是能看到常量的特别之处:
class testfinal {staticfinalintsint = 1;void testfinal(){inttemp= sint;system.out.println(temp);}}
编译后的代码会是这样:
publicvoid testfinal(){system.out.println(1);}
会发现编译器的优化,会把常量直接内联到代码引用之处。那么咱们想想:如果library里的res也是常量会出现什么问题?
常量被内联,一旦发生项目中资源重复,打包过程中就出现覆盖,那么内联的常量已经不能映射到真正的资源上了,毕竟资源已经被覆盖。
也就是会出现:资源找不到的crash
不是final引发的问题
library中的r引用不是常量,就意味着这种用法是不能工作的:
可以看到,注解也是要常量的,所以这个问题对我们日常影响还是挺大的...等等!butterknife就是注解的这种用法,为什么没有问题??
深入了解过butterknife的同学应该知道,butterknife针对这种情况进行了特殊处理:
butterknife的方案
butterknife为了不让注解处出现语法错误,自己创造了一个叫做r2的类。这个类其实就是原样copy了r,唯一不同就是r2都是常量。
的确这样不会有语法错误,但是咱们刚才也分析了:常量内联,资源覆盖。所以一旦满足case,那就是crash。所以butterknife又是如何规避这个问题的呢?
看过butterknife中findviewbyid()源码的同学应该都知道,此处butterknife的实现大概是这样:
publictestactivity_viewbinding(t target,viewsource) {this.target = target;target.parentlayout = utils.findrequiredviewastype(source, r.id.test,"field 'parentlayout'", viewgroup.class);
我们能够看到,butterknife最终打进包里的代码,并没有发生常量内联!所以它是怎么做的呢?
看到这里的同学,不妨停下来想想,如果是你会怎么解决这个问题?这里我说说我能想到的方案:
asm阶段,把内联的代码,再给它改写成r的正常引用。问题就来了:asm的输入是class,这个时机我没办法再拿到r的正常引用了。
那如果继续提前这个干预的过程,放到apt阶段呢?试了一下,也没有搞定。apt阶段拿到的注解value也已经是被内联的常量了...
这就有点奇怪了,butterknife是如何做到通过内联的常量和r引用的映射呢?翻看了butterknife的源码,发现butterknife是在apt阶段执行的,关键类在butterknifeprocessor[3]。
butterknife通过jctree这个api拿到了r的引用,然后把内联的代码又改回了r的引用。具体的api实现咱们就不看了,有兴趣的同学可以自行github。
咱们接下来聊一聊这个jctree是干啥的?
二、编译原理
我们都知道:日常我们写下的代码,最终想要运行在目标机器上都需要编译成目标机器能够识别的机器码。而做这些工作的我们称之为编译器。一般编译器就是干了如下的事情:
图片来自《编译原理》第二版
在各种源码编译的实现中,基本都不约而同地抽象出一个概念:抽象语法树(ast),以求在整个编译实现过程更加的方便。
一句话解释抽象语法树:源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。
咱们粗略了解了编译器的的实现流程,那么编译器又是怎么实现的呢?当然是用代码实现的咯,而且它们的实现往往离我们很近...以我们java编译器为例。
入坑java时,我们应该都试过javac。而这个命令的实现在哪?就在jdk里的tools.jar中的com.sun.tools.javac.main包下。核心逻辑在于com.sun.tools.javac.main.javacompiler。
这里边就实现了如何分析我们的源码,如何转化成class。也就上那个图中编译器该干的事。
那么jctree在整个编译过程中充当什么角色呢?一句话:jctree是对源码的一种api级别的描述。或者说jctree是java编译流程中语法树的实现。
也就是说通过jctree相关api,我们可以访问到源码结构。说起来似乎很抽象,我们debug个一段代码就能get到它存在的意义了:
fun main() {val context = context()val scanner = rscanner()val javacompiler = javacompiler.instance(context)val testjavacodefile = file("/users/x/xx/xxx/testautocode.java")toolprovider.getsystemjavacompiler().getstandardfilemanager(diagnosticcollector(),null,null).getjavafileobjectsfromfiles(listof(testjavacodefile)).foreach {javacompiler.parse(it).defs.foreach {scanner.scan(it)}}}class rscanner : treescanner() {override fun visitmethoddef(tree: jctree.jcmethoddecl?) {super.visitmethoddef(tree)}}
基于这一套api我们是能够获取到源码的任何信息的。而且这段demo代码,只需要导入tools.jar就可以快速运行,成本非常的低。
三、用代码run代码
上述我们通过javacompiler的实例,对java源码进行了动态的编译,拿到的结果就是这个java源码的class文件。有了class文件,我们就可以通过classloader去加载这个class。
有了上边的基础,实现源码已经不重要,这里贴一个链接大家自取吧:how do you dynamically compile and load external java classes?[4]
尾声
我个人没有正经的学过编译原理,所以了解这部分内容时,觉得还是挺神奇的。也希望这篇文章能对同样没有学过编译原理的同学带来一些思考和启发~
references
[1] aapt: https://developer.android.com/studio/command-line/aapt2?hl=zh_cn
[2] 资源合并: https://developer.android.com/studio/write/add-resources?hl=zh-cn#resource_merging
[3] butterknifeprocessor: https://github.com/jakewharton/butterknife/blob/fcdebedf3276096db2f51bf6372b849b5a9c75ed/butterknife-compiler/src/main/java/butterknife/compiler/butterknifeprocessor.java#l1470
[4] how do you dynamically compile and load external java classes?: https://stackoverflow.com/questions/21544446/how-do-you-dynamically-compile-and-load-external-java-classes