最近Jetpack Compose发布了Beta版本,抽时间了解了一下Compose带来的改变和其中的一些原理。本文不会讲解具体API,只是比较随意的分享自己的一些疑问以及在探寻答案过程中的一些收获。
为什么要有Compose?
Android已经十年多了,传统的Android UI ToolKit有很多历史遗留问题,而有些官方也很难修改。比如View.java有三万多行代码,比如Combo box竟然叫Spinner,再比如Button继承自Textview。同时官方的一些widget修复依赖系统升级,到达用户周期过长。
通过在Jetpack中添加Compose,脱离了Android系统,代码修复可以更快地到达用户。
而对国内开发者来说,更统一的代码,意味着没有厂商定制。这几天有位朋友和我抱怨『哪个大佬有时间重写个editText吗,厂商/系统的一堆问题』,我想他可能要梦想成真了。
同时,Compose通过引入声明式编程,依赖Kotlin特性,可以让代码编写更快更简单。
想象写一个搜索通讯录的界面,传统的Android开发写这个界面需要多少代码?activity一个xml,item一个xml,封装一个recyclerview,再写一个Adapter,写了这么多,可能还费力不讨好,xml转成view的过程中,IO和反射影响了性能,界面再复杂一些,走异步layout还是x2c?而在compose中,可能只需要下面这段简短的代码,并且没有xml的性能问题。
如上图,在Compose中,不断的方法调方法,就完成了UI的组装。
什么是声明式编程?
提到Compose,就不得不了解什么是声明式编程。
我们来看一下维基百科的解释,声明式编程是一种编程范式,表达逻辑但不描述具体控制流程。就是告诉计算机我想要什么,而不是告诉它怎么做。那对应到Android就是我想要一个什么样的UI,而不是这个UI应该如何改变,当然UI的自动改变需要框架的支持。
声明式编程在React、Flutter等框架中已经有广泛的应用,声明状态,状态变化,UI自动重绘。
有意思的是,Compose的发起人Jim Sproch之前是React的核心开发人员,他在Slack上聊到VDOM的一些问题,比如vdom分配内存空间在复杂项目中成为性能瓶颈,compose采用调用composable方法的方式,减少内存分配。相比vdom,compose把node隐藏在背后以防滥用,同时可以更方便的使用if/for控制流程。
@Composable是什么原理?
上面这个简单的例子,当点击button,button中的文字自动加1,remember用来记录最新的count值。
@Composable是个注解,而要实现自动更新UI,肯定是修改了Class文件,让我们看看class文件变成了什么样?Kotlin编译后的class在build/tmp/kotlin-classes目录中,但在Android Studio中是无法看到class反编译后的内容,可以用Jadx。然后Text()这些Composable方法编译成class后也有改动,为了方便阅读,最好是编译好APK后,再用Jadx阅读反编译源码。
上面就是编译后的CountInner方法,可以看到,方法参数都被改变了,方法块中添加了很多start/end,调用Text()的Lambda变成了ComposableLamda,改动还是比较多的。
这些改动是怎么实现的呢?如果我没记错的话,Kotlin的协程也做了有些改变方法参数的操作,两个是不是差不多的实现?但协程是kotlin特性,应用层动态修改class文件,难道是在Gradle Transform里用ASM去操纵class的?
一番搜索,发现Compose应用了Kotlin compiler的新特性,通过IR extension,可以在中间代码生成期间修改逻辑。IR又是什么?intermediate representation的缩写,翻译为中间语言。Kotlin为了Compose开放了扩展能力,并且统一了JVM/JS/Native的IR流水线,为跨平台提供支持。可以理解为Kotlin对协程做的那些事情,通过使用IR extension,你在应用层也可以去做了。
Talk is cheap, I will show you the code.
Compose Compiler的源码。ComposePlugin.kt中注册了ComposeIrGenerationextension。ComposeIrGenerationExtension中又有ComposableFunctionBodyTransformer实现上面描述的方法中添加start/end,ComposerLambdaMemoization实现上面描述的改变成ComposerLambda。具体逻辑可以看源码,注释描述的比较清楚。
重组是怎么实现的?
看Compose的文档,一直有重组(Recomposition)这个词,就是状态变化的时候,自动更新UI。那重组是怎么实现的呢?
每次调用count.getValue()的时候,最终会回调到Composer,Composer中维持着一个Map,这时就把state和当前的scope进行了关联,scope可以理解为一段可以重组的范围。那当前的scope哪里来呢?还记得编译的class里多了很多start和end吗,在调用start方法的时候,会生成一个scope,放在栈顶。所以调用count.getValue()的时候,直接拿栈顶scope就可以了。当调用end的时候,会调用updateScope更新scope的block属性,而这个block是一个lambda,执行这个lambda会调用对应的composable方法重绘,这样state和block就关联起来了,后面state变化的时候,拿到block执行就可以了。在这个例子中,count state对应的block是一个调用Button方法的lambda。
再来看下更新state的流程。每次调用count.SetValue()的时候,最终会调到Composer中的recordModificationsOf方法,然后从上段说的Map中获取state对应的scope, 并把它添加到invalidations中,通过编舞者监听,下次vsync时,会调用invalidations中lambda的invoke方法,从而更新UI。
请注意,『在调用start方法的时候,会生成一个scope』,但其实只有第一次添加的时候生成就够了,后面更新UI的时候直接用旧的就可以了,太多类似的东西需要存储,Compose中有一个非常重要的数据结构叫插槽表SlotTable,刚说的这个scope复用以及例子中的remember都是利用了SlotTable,具体可以看深入详解 Jetpack Compose | 实现原理。
Text对应的是TextView吗?
Text对应的是TextView吗?不是的。
debug看了一下,所有的composable UI最后被包在一个AndroidComposeView中,放在ContentView下面,所以最上层的东西是没有变化的。传统的Android UI中的view树,变成了node树,view的那些功能被node替代了。
和旧有体系兼容,可以直接把AndroidComposeView添加在xml中,这样就可以混用了。
自定义Layout怎么写?
简单的看了一下,measure/layout走的是measurePolicy,在一个方法中去写measure和layout。measure中有个Constraints最大最小限制,类似MeasureSpec那一套,match_parent变成了Modifier.fillMaxWidth(),这个Modifier会在measure之前修改Constraints,measure的时候会把修改后的Constraints传递进去。
draw是通过Modifier实现的,还是走canvas那一套。
Touch事件怎么处理?
自以为对Android的touch事件还算比较了解,之前在看Android源码的时候也发现了一些有意思的地方,比如down事件在native底层处理,不是作为message在java层looper处理,所以setMessageLogging的方式检测不到down里的耗时。那编舞者不是分发Input/animation/layout的callback吗?那个主要是用来处理move事件。你以为move事件里只有一个坐标点吗,看看MotionEvent.getHistorySize方法吧,那这个size和屏幕采样率以及触控采样率又是什么关系呢?
言归正传,看到Compose的出现,肯定也好奇对Touch事件处理方式的改变。
dispatchTouchEvent/onInterceptTouchEvent/onTouchEvent这些方法不见了,拦截事件需要实现PointerInputFilter,主要逻辑写在onPointerEvent方法,这个方法甚至连boolean都没返回,那怎么判断是否消费了呢?传递进来包装好的event中有个是否消费的属性,每个filter自己判断是否有未消费的事件,去修改已经消费。感觉这一块还有优化空间,好像没有消费之前的事件,后续事件还会回调到。
现在定义了Initial、Main、Final三个阶段,在你关心的阶段中去处理,前两个阶段和以前差不多,Final阶段类似用来处理之前的cancel事件。
结尾
--
Compose还在持续优化中,比如composable函数最近要支持并发执行了。
两年磨一剑,谷歌推广Compose的决心是毋庸置疑的。Compose为了方便开发者,也是考虑到了很多现实的东西,比如像kotlin支持和java互调一样,支持Compose和传统UI互调。虽然投入巨大,的确更快更简单,但在社区中的普及还有待时间验证,毕竟Jetpack中的库很多大家都还没有用过,而Compose的征程也注定要比Kotlin艰难。
时间有限,本文只能纸上谈兵、管中窥豹、抛砖引玉了,如有谬误,还望不吝赐教。