一,写在前面的话
1,什么是Jetpack Compose?
Jetpack Compose是一个现代工具包,旨在简化UI开发。它结合了响应式编程模型和Kotlin编程语言的简洁性和易用性。同时,它完全是声明性的,意味着您可以通过调用一系列将数据转换为UI层次结构的方法来描述UI。当数据更改时,框架会自动调用这些功能,从而为您更新视图层次结构。
www.bilibili.com/video/BV1Ey...
先体验一下
2,为什么要有Compose?
1)Android的UI由XML和代码一起定义,XML和代码之间存在很强的耦合,容易出现修改不一致导致运行错误。Compose的UI完全由代码完成,提升了内聚和封装性;
2)Compose通过引入声明式编程,UI自动更新,只需要专注于数据的更新逻辑,可以让代码编写更快更简单。
二,怎样使用Jetpack Compose?
1,一个最简单的例子
这样,一个简单的Text就完成了。
为了提升区分度,Compose的方法名需要大写开头,并添加 @Composable 注解,有这个注解的方法才会被认为是Compose的方法。
一个Compose方法,必须要在其他的Compose方法内才可以被调用。
因为使用代码去编写布局,不再依托xml文件,ui预览的方式也有了一定的变化。每个Compose方法都可以单独进行预览,只需要在方法上添加 @Preview 注解就可以,一个文件中可以有多个 @Preview 注解,系统会为每个带有 @Preview 的Compose方法生成各自的预览图。
需要注意的是,添加 @Preview 必须保证该Compose方法没有未定的参数,也就说,方法要么无参,要么所有参数都有默认值。
2,一个稍微复杂的例子
使用Compose如何去实现一个列表的功能呢?
暂时无法在文档外展示此内容
如下图所示,只需要一个LazyColumn或者LazyRow,然后在里面完成list与item的绑定就OK了。
如果使用RecyclerView实现一个列表应该怎么做呢?首先要在容器Activity或者Fragment的xml中放一个RecyclerView,然后在Activity中通过findViewById找到他,再写一个ViewHolder,以及他的xml,然后又是一系列的findViewById。再写一个Adapter,实现各种方法完成与ViewHolder的绑定。还要在Adapter中添加一些数据绑定的方法,去更新adapter中的数据。 而如果使用Compose,代码量就会减少很多了。
这就是声明性编程范式带来的直观改变。声明性界面模型大大简化了与构建和更新界面关联的工程设计。我们后面会详细描述下这个概念,以及Compose是如何work的。
三,Compose的一些特点
1,声明式
命令式编程需要告诉系统每一步要做什么,具体该怎么做,通过这样的一种流程实现我们想要的功能。通过调用 View 的某些 set 方法来更新 UI 的状态,这是一个手动更新 UI 的过程,这个过程的维护性通常会随着 UI 复杂度的增加而增加,非常容易出错。
声明式编程则是隐藏了怎么做这个流程,你只需要操作一些基本元素,通过组合方式,把你想要的效果搭建出来就好了。使用声明式 UI 编写界面可以极大的减少代码量,同时也提高了容错率,整个 UI 构建逻辑也变得更加清晰、易读。
举个最简单的栗子🌰
假设有一个带有未读消息图标的电子邮件应用。如果没有消息,应用会绘制一个空信封;如果有一些消息,我们会在信封中绘制一些纸张;而如果有 100 条消息,我们就把图标绘制成好像在着火的样子......
使用命令式接口,我们可能会写出一个下面这样的更新数量的函数:
在这段代码中,我们接收新的数量,并且必须搞清楚如何更新当前的 UI 来反映对应的状态。
作为替代,使用声明式接口编写这一逻辑则会看起来像下面这样:
再来一个例子
暂时无法在文档外展示此内容
在 Compose 中,UI 的刷新是通过重新渲染生成整个屏幕实现的,这是所有声明式 UI 刷新页面的工作原理。但是 Compose 会根据数据的变化,仅针对需要改变的元素做必要的修改,通过改变数据而重新生成组合 UI 这一过程称为重组 (recompose) 。当数据更新了,虽然是从整个屏幕开始重新渲染,但与数据修改无关的元素,会保持之前生成的实例,因为 @Composable 方法渲染是非常快的、幂等且无副作用。
非常重要的一点就是幂等,幂等的意思是指可以使用相同参数重复执行,并能获得相同结果的函数。这些函数不会影响系统状态,也不用担心重复执行会对系统造成改变。说白了就是与数据修改无关的 @Composable 元素在屏幕重新渲染的过程中不会被重组,仅调用可能已更改的函数或 lambda,而跳过其余函数或 lambda。通过跳过所有未更改参数的函数或 lambda,Compose 可以高效地重组。
2,重组(recompose)
- 可组合函数(带有@Compose注解的方法)可以按任何顺序执行。
- 可组合函数可以并行执行。
- 重组会跳过尽可能多的可组合函数和 lambda。
- 重组是乐观的操作,可能会被取消。
- 可组合函数可能会像动画的每一帧一样非常频繁地运行。
1)可组合函数可能按任何顺序执行
Compose 可以选择识别出某些界面元素的优先级高于其他界面元素,因而首先绘制这些元素。
如果某个可组合函数包含对其他可组合函数的调用,这些函数可以按任何顺序运行。
scss
@Composable
fun ButtonRow() {
MyFancyNavigation {
StartScreen()
MiddleScreen()
EndScreen()
}
}
对 StartScreen
、MiddleScreen
和 EndScreen
的调用可以按任何顺序进行。这意味着,举例来说,您不能让 StartScreen()
设置某个全局变量(附带效应)并让 MiddleScreen()
利用这项更改。相反,其中每个函数都需要保持独立。
2)可组合函数可以并行运行
Compose 可以通过并行运行可组合函数来优化重组。这样一来,Compose 就可以利用多个核心,并以较低的优先级运行可组合函数(不在屏幕上)。
这种优化意味着,可组合函数可能会在后台线程池中执行。如果某个可组合函数对 ViewModel
调用一个函数,则 Compose 可能会同时从多个线程调用该函数。
调用某个可组合函数时,调用可能发生在与调用方不同的线程上。这意味着,应避免使用修改可组合 lambda 中的变量的代码,既因为此类代码并非线程安全代码,又因为它是可组合 lambda 不允许的附带效应。如下错误的例子:
kotlin
@Composable
fun ListWithBug(myList: List<String>) {
var items = 0 // 不应使用该变量
Row(horizontalArrangement = Arrangement.SpaceBetween) {
Column {
for(item in myList) {
Text("Item: $item")
items++
}
}
Text("Count: $items")
}
}
在本例中,每次重组时,都会修改 items
。这可以是动画的每一帧,或是在列表更新时。但不管怎样,界面都会显示错误的项数。因此,Compose 不支持这样的写入操作;通过禁止此类写入操作,我们允许框架更改线程以执行可组合 lambda。
3)重组会跳过尽可能多的内容
如果界面的某些部分无效,Compose 会尽力只重组需要更新的部分。这意味着,它可以跳过某些内容以重新运行单个按钮的可组合项,而不执行界面树中在其上面或下面的任何可组合项。
4)重组是乐观的操作
只要 Compose 认为某个可组合项的参数可能已更改,就会开始重组。重组是乐观的操作,也就是说,Compose 预计会在参数再次更改之前完成重组。如果某个参数在重组完成之前发生更改, Compose 可能会取消重组,并使用新参数重新开始。
取消重组后,Compose 会从重组中舍弃界面树。如有任何附带效应依赖于显示的界面,则即使取消了组成操作,也会应用该附带效应。这可能会导致应用状态不一致。
5)可组合函数可能会非常频繁地运行
在某些情况下,可能会针对界面动画的每一帧运行一个可组合函数。如果该函数执行成本高昂的操作(例如从设备存储空间读取数据),可能会导致界面卡顿。
3,重组的流程
就这样一小点带有 @Composable 注解的代码,反编译后看到的代码就是下面这个样子
核心代码在Composer.kt
在Composer中,持有了一个SlotTabel,存储了所有的数据。
间隙缓冲区是一个集合,它在内存中使用扁平数组 (flat array) 实现。这一扁平数组比它代表的数据集合要大,而那些没有使用的空间就被称为间隙。
一个正在执行的 Composable 的层级结构可以使用这个数据结构,而且我们可以在其中插入一些东西。
让我们假设已经完成了层级结构的执行。在某个时候,我们会重新组合一些东西。所以我们将游标重置回数组的顶部并再次遍历执行。在我们执行时,可以选择仅仅查看数据并且什么都不做,或是更新数据的值。
我们也许会决定改变 UI 的结构,并且希望进行一次插入操作。在这个时候,我们会把间隙移动至当前位置。
现在,我们可以进行插入操作了。
在了解此数据结构时,很重要的一点是除了移动间隙,它的所有其他操作包括获取 (get)、移动 (move) 、插入 (insert) 、删除 (delete) 都是常数时间操作。移动间隙的时间复杂度为 O(n)。我们选择这一数据结构是因为 UI 的结构通常不会频繁地改变。当我们处理动态 UI 时,它们的值虽然发生了改变,却通常不会频繁地改变结构。当它们确实需要改变结构时,则很可能需要做出大块的改动,此时进行 O(n) 的间隙移动操作便是一个很合理的权衡。
让我们来看一个计数器示例:
kotlin
@Composable
fun Counter() {
var count by remember { mutableStateOf(0) }
Button(
text="Count: $count",
onPress={ count += 1 }
)
}
这是我们编写的代码,不过我们要看的是编译器做了什么。
当编译器看到 Composable 注解时,它会在函数体中插入额外的参数和调用。
首先,编译器会添加一个 Composer.start 方法的调用,并向其传递一个编译时生成的整数 key。
编译器也会将 Composer 对象传递到函数体里的所有 composable 调用中。
kotlin
fun Counter($Composer: Composer) {
$Composer.start(123)//随机整数
var count by remember($Composer) { mutableStateOf(0) }
Button(
$Composer,
text="Count: $count",
onPress={ count += 1 },
)
$Composer.end()
}
当此 Composer 执行时,它会进行以下操作:
- Composer.start 被调用并存储了一个存有随机数的组对象 (group object)
- remember 插入了这个组对象 (group object)
- mutableStateOf 的值被返回,而 state 实例会被存储起来
- Button 基于它的每个参数存储了一个分组
最后,当我们到达 Composer.end 时:
数据结构现在已经持有了来自组合的所有对象,整个树的节点也已经按照深度优先遍历的执行顺序排列。
现在,所有这些组对象已经占据了很多的空间,它们为什么要占据这些空间呢?这些组对象是用来管理动态 UI 可能发生的移动和插入的。编译器知道哪些代码会改变 UI 的结构,所以它可以有条件地插入这些分组。大部分情况下,编译器不需要它们,所以它不会向插槽表 (slot table) 中插入过多的分组。为了说明一这点,请您查看以下条件逻辑:
kotlin
@Composable fun App() {
val result = getData()
if (result == null) {
Loading(...)
} else {
Header(result)
Body(result)
}
}
在这个 Composable 函数中,getData 函数返回了一些结果并在某个情况下绘制了一个 Loading composable 函数;而在另一个情况下,它绘制了 Header 和 Body 函数。编译器会在 if 语句的每个分支间插入分隔关键字。
scss
fun App($Composer: Composer) {
val result = getData()
if (result == null) {
$Composer.start(123)
Loading(...)
$Composer.end()
} else {
$Composer.start(456)
Header(result)
Body(result)
$Composer.end()
}
}
让我们假设这段代码第一次执行的结果是 null。这会使一个分组插入空隙并运行载入界面。
函数第二次执行时,让我们假设它的结果不再是 null,这样一来第二个分支就会执行。
对 Composer.start 的调用有一个 key 为 456 的分组。编译器会看到插槽表中 key 为 123 分组与之并不匹配,所以此时它知道 UI 的结构发生了改变。
于是编译器将缝隙移动至当前游标位置并使其在以前 UI 的位置进行扩展,从而有效地消除了旧的 UI。
此时,代码已经会像一般的情况一样执行,而且新的 UI ------ header 和 body ------ 也已被插入其中。
在这种情况下,if 语句的开销为插槽表中的单个条目。通过插入单个组,我们可以在 UI 中任意实现控制流,同时启用编译器对 UI 的管理,使其可以在处理 UI 时利用这种类缓存的数据结构。
四,和原生View的互操作性
1,现有代码中嵌套Compose
Compose是一种ui框架,渲染机制与现有的View体系有很大的差异,就目前而言,不可能像java转kotlin那样润物细无声的完成转换,所以即便工程中引入了Compose,也不太可能把现有的代码整体替换为Compose的写法。
使用Compose时,在Activity中使用的是setContent这样一个方法,而没有我们常见的setContentView,此外,默认的Activity的父类也从AppCompatActivity变为了AppCompatActivity的爷爷ComponentActivity。在setContent中,传入了我们所写的Compose方法。
(AppCompatActivity- > FragmentActivity -> ComponentActivity -> Activity)
setContent实际为Compose为ComponentActivity提供了一个扩展方法,具体实现无非是把setContentView进行了一层包装,在调用setContentView前,调用了ComposeView的setContent方法。
但是有些情况下,我们需要在同一个页面混合使用Compose和Android原生View。Compose还是提供了一些尽量兼容的一些方案,比如说我们想在一个现有的view中使用Compose,只需要在对应的Xml文件中添加一个ComposeView,然后像普通的view一样,在代码中通过findViewById获取到,然后就可以开始尽情的setContent了。
ComposeView继承自View,同时也作为Compose在Android上的宿主,负责Android和Compose在UI上的链接。
2,Compose中嵌套现有代码
当前Compose的生态还不是特别的完善,很多功能还没有做好兼容,或者一些我们常用的框架,还没有产出Compose版本,比如常用的Fresco等。或者要使用一些自定义的View,如VerticalViewPager等。
为了解决这个问题,Compose同样提供了另外一种反向的兼容功能,就是在Compose中兼容使用现有的view。
五,跨平台
Compose除了可以应用于Android外,还可以用于桌面,如下图所示,同样的一份代码,编译完成后就可以在mac上运行了。
无法复制加载中的内容
六,引入Compose后的要求和问题
1,包体积会有3M多的提升
普通的apk
Compose的apk包
2, 版本要求
1)在项目中使用的是 Kotlin 1.4.21 或更高版本
2)minSdkVersion 大于21
参考链接
developer.android.google.cn/jetpack/Com...
mp.weixin.qq.com/s/t-RY6UZIr...
mp.weixin.qq.com/s/ssZMERV4P...
mp.weixin.qq.com/s/pRurO-7up...
mp.weixin.qq.com/s/0mAbKEuBH...