1.1 Jetpack Compose是什么
Jetpack Compose(简称Compose)是Android新一代UI开发框架,致力于帮助开发者用更少的代码和更直观的API完成Native UI开发。相对于传统的UI开发方式,Compose具有以下几个方面的优势:
• 先进的开发范式:Compose采用声明式的开发范式,开发者只需要聚焦在对UI界面的描述上,当需要渲染的数据发生变化时,框架将自动完成UI刷新。
• 直观易用的API:基于Kotlin DSL打造的API紧贴函数式编程思想,相对于传统的视图开发方式,代码效率更高,实现同样的功能只需要以前一半的代码量。
• 良好的兼容性:Compose代码与基于Android View系统的传统代码可以共存,用户可以按照喜欢的节奏将既有代码逐步过渡到Compose。
• 广泛的适用性:Compose最低兼容到API 21,支持市面上绝大多数手机设备的使用;Jetpack以及各种常用三方库也都第一时间与Compose进行了适配。
上述优势也使得Compose一经发布就广受追捧,目前已经有包括Twitter、Airbnb在内的众多应用采用了Compose开发UI, Compose的成熟度和稳定性也得到了市场的进一步验证。图1-1是一些使用了Compose的产品方对它的评价。
1.1.1 谷歌为什么要推出Compose
Andorid系统自诞生至今已发展了十几个年头,这期间智能手机无论是硬件规格还是软件形态都发生了巨大变化,Android应用开发技术也在不断进步:从RecyclerView、ConstraintLayout等各种UI控件的引入,到Architecture Components这样的架构工具,甚至连开发语言也从Java切换到了Kotlin,如图1-2所示。
•图1-1 来自使用Compose的产品方的评价
•图1-2 Android系统发展的历史
虽然技术手段在不断丰富,但是在UI开发方式上并没有根本变化,仍然是那一套继承自View的组件体系。而View.java本身也变得越发臃肿,目前已超过三万行,早已不堪重负。
臃肿的父类视图控件也造成了子类视图功能的不合理。以最常见的Button类为例,为了能让按钮具备显示文字的功能,Button被设计成了继承自TextView的子类:
这样的设计显然是不妥当的,TextView中许多不适于按钮的功能也会被Button一并继承下来,比如用户肯定不需要一个带有粘贴板的Button,而且随着TextView自身的能力迭代,Button有可能引入更多不必要的功能,如图1-3所示。
•图1-3 粘贴板Button
另一方面,像Button这类基础控件只能跟随系统的升级而更新,即使发现了问题也得不到及时修复,长期下来积重难返,破窗效应也越发突出。如今很多新的视图组件都以Jetpack扩展库的形式单独发布,目的也是为了不受系统版本的制约。
补充提示:
来自Android团队的成员对现有视图体系的评价:“现有视图体系的一些API虽然有很多问题,但是我们很难在不破坏功能的情况下收回、修复或扩展这些API”。
类似这样的问题在Android其他传统视图控件中还有很多,究其根源还是在于设计理念的落伍。构筑在基于面向对象思想的设计理念,让各个组件在定义时都偏向于封装私有状态。开发者需要花费大量的精力去确保各组件间状态的一致性,这也是造成命令式UI代码复杂度高的根本原因。因此,谷歌开始考虑寻找一套新的UI开发方式,希望从根本上替换现有的视图体系,彻底根除上述这些问题。
谷歌高级工程师Jim Sproch(见图1-4)基于其在前端开发领域丰富的工作经验,开创性地提出了借助Kotlin Compiler Plugin为Android打造声明式UI框架的想法。在他的推动下,谷歌于2017年启动了Jetpack Compose项目(后文简称Compose项目),随后越来越多的工程师加入其中,Compose项目在谷歌内部越发受到重视。
•图1-4 Compose之父Jim Sproch
补充提示:
Jim Sproch加入Google前曾就职于Facebook React团队,从Compose的设计中能看到很多React(Hook)的影子。但Compose通过Kotlin Compiler Plugin的编译器优化以及基于SlotTable的diff算法在技术创新上又有不少新突破。
在2019年的Google/IO大会上,Jim Sproch团队做了关于Compose的分享,这是Compose的首次公开亮相。同年10月,Compose发布了首个Preview版本,开发者可以在Android Studio 4.0中进行体验。2020年8月Compose进入Alpha阶段,2021年2月发布了首个Beta版本,API趋于稳定。2021年7月其1.0版本终于得以问世。图1-5展示了目前为止Compose的几个重要里程碑。
•图1-5 Compose MileStone
前面提到的Android传统视图体系中的一些问题,也随着Compose的出现得到了有效解决。表1-1展示了Compose与Android View的比较。
表1-1 Compose与Android View的比较
这诸多优点中最大的创新还是对于声明式这一全新开发方式的采用。相对于传统的命令式开发方式,声明式开发大大提高了UI界面的开发效率。前端领域的React、Vue.js等主流开发框架都属于声明式开发框架,所以其先进性早已被广泛验证。
1.1.2 命令式UI与声明式UI
命令式和声明式是两种截然不同的编程范式。命令式用命令的方式告诉计算机如何去做事情(how to do),计算机通过执行命令达到结果,而声明式直接告诉计算机用户想要的结果(what to do),计算机自己去想该怎么做。
Android现有的View视图体系就属于命令式的编程范式,我们使用XML定义的布局是静态的,无法根据响应状态自行更新。开发者需要通过findViewById等获取视图对象,然后通过命令式的代码调用对象方法驱动UI变更,而Compose采用声明式编程范式,开发者只需要根据状态描述UI,当状态变化时,UI会自动更新。
也许有人会说Data Binding不是可以让XML自己“动”起来吗?没有错,Data Binding其实就是Compose诞生之前的一种声明式UI方案,谷歌曾经寄希望于通过它来提升UI编码效率。可见,声明式UI本身并非新鲜概念,而且其优势也早已被官方认可。
补充提示:
官方文档将Data Binding视为一种声明式框架:
"The Data Binding Library is a support library that allows you to bind UI components in your layouts to data sources in your app using a declarative format rather than programmatically."(https://developer.android.com/topic/libraries/data-binding)
Compose作为更新一代的声明式UI框架,其代码完全基于Kotlin DSL实现,相较于Data Binding需要有XML的依赖,Compose只依赖单一语言,避免了因为语言间的交互而带来的性能开销及安全问题。
构成Compose DSL中的基本单元被称为Composable。Composable本质上是一个Kotlin函数,通过Kotlin的尾Lambda语法特性让Composable之间能够嵌套,形成Composable的树形层级,实现不输于XML的结构化表达能力。
以下是一个Composable函数的示例:
例子中,我们将为App添加的@Composable注解标记为一个Composable函数,App接受一个AppData,这是一个不可变数据,因此仅仅基于此数据构建UI,不会对它进行任何修改。App内部进一步调用Header()以及Body()的其他Composable构成整个视图树。在构建UI的过程中,可以使用Kotlin的各种原生语法,例如使用if语句与for循环来控制UI的展示。
接下来通过一个例子进一步体会Compose DSL的声明式语法的优势。场景如下,在一个电子邮件应用中显示未读消息。具体需求如图1-6所示。
•图1-6 未读消息的图标
• 没有消息,需要绘制一个空信封。
• 有少量消息,在信封中绘制一些信纸,并添加消息数。
• 有超过100条消息,添加火焰图标,消息数99+。
按照命令式的实现思路,为信封控件实现更新数量的方法,代码如下:
当这个方法接收了新的数量后,先要通过if语句考虑各种分支逻辑,然后通过对应的方法调用完成UI更新。一旦有分支遗漏就会出错。要注意,这只是updateCount的实现代码,不包括视图的布局,而且真实项目中的逻辑比这个例子往往要复杂得多。
同样的需求,如果基于Composable的DSL编写,则代码会是下面这样:
• 当数量大于0时,Envelope显示信纸。
• 当数量大于99时,插入Fire。
• 当数量大于99时,Badge显示99+。
Envelope、File、Badge等组件基于count这个唯一状态对UI进行渲染,无须再手动更新,也不会出现addXXX、removeXXX这类方法。当count变化时,Compose框架将帮用户实现UI的刷新。
补充提示:
“唯一状态”正是单一数据源思想的体现,这也是声明式UI的重要特点,稍后将详细解释。
1.1.3 Compose API设计原则
由于Compose在编程范式上与传统视图体系有着根本的不同,在开始深入学习Compose之前,有必要对Compose API的设计原则做一个介绍。无论是Compose预置的Composable还是开发者自己定义的Composable,都应该遵守这些原则。
1.一切皆为函数
正如前面介绍的那样,Compose声明式UI的基础是Composable函数。Composable函数通过多级嵌套形成结构化的函数调用链,函数调用链经过运行后生成一颗UI视图树。
视图树一旦生成便不可随意改变。视图的刷新依靠Composable函数的反复执行来实现。当需要显示的数据发生变化时,Compoable基于新参数再次执行,更新底层的视图树,最终完成视图的刷新。整个过程如图1-7所示。
•图1-7 从Composable到视图树
这个通过反复执行更新视图树的过程称为重组,会在后面章节中专门介绍。
Composable函数只能在Composable函数中调用,这与挂起函数只能在协程或其他挂起函数中调用类似,都是在编译期保证的。
在Compose的世界中,一切组件都是函数,由于没有类的概念,因此不会有任何继承的层次结构,所有组件都是顶层函数,可以在DSL中直接调用。视图构建由传统的实例构建过渡到如今的函数构建,开发者需要适应在心智上的转变,这也是我们学习中最需要关注的地方。
最佳实践:
Kotlin编码规范中要求函数的首字母小写,但是Compose推荐Composable使用首字母大写的名词来命名,且不允许有返回值。这样在DSL中书写时可读性更好。有的Composable函数并不代表UI组件,此时可以遵循一般的函数命名规范。
2.组合优于继承
组合优于继承,这是在面向对象设计模式中反复强调的原则。之所以反复强调,就是因为它遵守起来并不容易。因为继承用起来太过方便,大家往往难以从组合的视角思考问题。
Android传统的视图系统中所有组件都直接或间接继承自View类。TextView继承自View,而Button又继承自TextView,处于末端的子类继承了很多无用的功能,导致出现本书开头提到的“带剪贴板的Button”这样的滑稽例子。而反观Compose,Composable作为函数相互没有继承关系,有利于促使开发者使用组合的视角去思考问题,如图1-8所示。
•图1-8“相互继承”的View(左)与“组合使用”的Composable(右)
比如Compose中为一个按钮添加文字的代码是下面这样:
在传统视图体系中,按钮的文字可能是Button类的一个属性。而Compose中需要通过Text这个Composable来组合实现。虽然按钮显示文字是一个常见需求,但是对于一个只需要显示图片的IconButton来说,文字的属性就是多余的。Button真正必要的能力就是接收用户点击而已,Compose通过组合的方式让组件的职责更加单一。
3.单一数据源
单一数据源(Single Source of Truth)是包括Compose在内的声明式UI中的重要原则。回想一下传统视图的EditText,它的文字变化可能来自用户的输入,也可能来自代码某处的setText。
状态变化可能不止一个来源,即所谓的多数据源(Multiple Sources of Truth)。多数据源下的状态变化不容易跟踪,而且状态源过度分散会增加状态同步的工作量。比如EditText由于自己持有mText状态,其他组件需要监听它的状态变化,反之它可能也需要监听其他组件的状态变化。
在Compose中,文本框组件OutlinedTextField的文字状态永远来自其参数value。
当用户输入文字后,onValueChange会接收到响应,但是文本框文字不会自动更新,仍然需要通过唯一来源value的变更来刷新UI。
在上面的代码中,OutlinedTextField响应用户输入后,通过onNameChanged更新外部状态name,当name变化时会驱动HelloContent重新执行,重组中OutlinedTextField也会显示最新的name。
单一数据源决定了Composable数据流的单向流动,数据(name)总是自上而下流动,而事件(onNameChange)总是自下而上传递,如图1-9所示。
•图1-9 Compose数据流和事件流向
1.1.4 Compose与View的关系
我们都知道在传统视图体系中由View与ViewGroup构成视图树,而Compose中也有同样一颗视图树,它由LayoutNode构成,由Composition负责管理,如图1-10所示。
•图1-10 View视图树(左)与Compose视图树(右)
两种树的节点类型不同,但是它们并非全没关系,依然可以共存于一棵树中。就像DOM节点与View也不同,但是可以通过WebView显示在一棵树上,Compose也可以借助这样一个连接点挂载在View树上。使用Android Studio自带的Layout Inspector可以看到这个连接点就是ComposeView,它就是连接View与Compose的桥梁,如图1-11所示。
•图1-11 ComposeView连接View和Compose
ComposeView有一个唯一子节点AndroidComposeView,它既是一个ViewGroup,也是LayoutNode视图树的持有者,它实现了LayoutNode视图结构与View视图结构的连接。既然AndroidComposeView已经承担了两套体系的连接,那为什么还要多一层ComposeView呢?
ComposeView继承自AbstractComposeView,而后者有三个子类,分别对应着Activity窗口、Dialog窗口与PopupWindow窗口。Android平台存在所谓Window的概念,我们在很多场景下会有多窗口需求,例如在页面中弹出一个对话窗。AbstractComposeView的子类负责Android平台各类窗口的适配并生成对应的Composition, ComposeView作为其中一个子类负责Activity窗口的适配。总体来说,ComposeView负责对Android平台的Activity窗口的适配,AndroidComposeView负责连接LayoutNode视图系统与View视图系统。如此的职责划分可以实现上层视图适配与下层窗口适配逻辑的解耦。
补充提示:
Composition是视图树的创建者。从Composable函数到视图树的生成经历这个过程:第一步Composable函数执行后填充SlotTable, SlotTable中记录着Composable执行过程的状态信息;第二步基于SlotTable生成和更新LayoutNode视图树。Composition负责从Composable执行到视图树生成(更新)的整个过程。
ComposeView接入View视图后,内部的UI工作都在Compose侧闭环处理,来自AndroidComposeView的绘制、测量布局与手势事件分发等都下沉到LayoutNode去完成。ComposeView作为View可以挂载到原有View视图树中的任意位置。因此一个传统视图项目可以通过ComposeView阶段性地接入Compose。一个纯Compose页面就是将ComposeView直接挂载到ContentView上面,如图1-12所示。
•图1-12 一个纯Compose页面
1.1.5 不只是Android UI框架
Compose并非一个简单的SDK,它是由一系列库及配套工具组成的完整的UI解决方案,如图1-13所示。
•图1-13 Compose技术栈
在开发阶段,Android Studio为我们提供了代码的实时静态检查,以及对Compose UI的实时预览功能,在编译阶段,Compose Compiler Plugin会对@Composable注解进行预处理,通过插入代码,提升了编码效率。在运行阶段,Compose从上到下分为四层,每一层都可以被单独使用,在不同维度提供能力支持,见表1-2。
表1-2 Compose模块分层
可以只使用Compose的Runtime层构建任何基于数据驱动能力的系统或类库。在这样清晰的分层结构下,我们甚至可以隔离那些平台相关代码,自底向上自己来实现跨平台的UI系统。
补充提示:
Android领域资深专家Jake Wharton认为对Compose各层模块应该分别看待,其核心是底层的Runtime,上层UI只是其应用场景之一:
“What this means is that Compose is, at its core, a general-purpose tool for managing a tree of nodes of any type. Well a ‘tree of nodes' describes just about anything, and as a result Compose can target just about anything。”
Kotlin出品方JetBrains也是Compose项目的主要参与者。依托Kotlin跨平台的特性,JetBrains启动了Compose Multiplatform项目。可以使用Compose开发包括Android、Desktop(Windows, Linux, macOS)甚至Web等不同平台的UI,未来的Compose将极大地拓展Kotlin的应用场景,以及Android开发者的能力边际。