前言
笔者最近有时间翻阅官方github的工程代码,发现目前官方项目几乎所有的业务代码都采用 Compose 编写。笔者在跨端方面写过flutter,当时觉得flutter一统多端,指日可待。后来又到了一个写原生的团队,涉及到了Compose。这时候才发现Compose和KMP也已经发展到了一个非常完善的地步。
不过官方推荐和实际业内使用之间一直存在gap,比如Compose,比如Hilt,比如catalog等等。毕竟业务重构和新技术使用有一定的学习和开发成本,并且实际产出也很难说服业务方。
相比于flutter已经有了非常完善的生态社区,Compose生态目前还不完善。不过有google和JetBrains两位坐镇,Compose算得上未来可期。尤其是Compose跨端调用原生的方式,相比于flutter通过channel进行接口调用,简直是黑科技一样的存在。
未来在跨端这方面到底谁一统江湖,还很难说,因为我一直认为,真正决定跨端的关键因素是苹果会不会搞事。但是即便如此,Compose是有一定的学习必要的。这些都是我在看代码和文档时记录的笔记。
优势
Compose语法和Flutter语法极像,本身都是通过声明式UI的方式进行页面开发。
相比于用惯了传统XML定义UI的开发路径,第一次使用这种声明方式语法会有点痛苦,尤其是对于代码UI层层嵌套的开发形式,很容易出现代码错乱的情况,比如少写个括号什么的,一个个对齐耗费心力。其次相比于XML所见即所得的开发模式,声明式UI对于UI展示过于抽象,虽然Compose提供了预览的能力,但是大多数UI都要绑定mock数据,这使得预览能力更偏向于是规范化测试集合的能力。
如果是之前已经具备了flutter开发能力的开发者来说,Compose的开发上手会相当快,并且Compose使用了jetpack flow的能力,对于用惯了流的开发者来说,无疑是一件好事。
在之前我曾做过启动优化和页面秒开方面的工作。在这里,影响页面运行效率最大的点就是XML解析的工作,首页复杂的XML解析和Layout.inflate中大量的反射和锁,导致XML中view的层级越复杂,那么耗时越长,并且页面卡顿几率也越高。因此我对flutter创建UI的方案来解决XML解析耗时的方案有极大的兴趣(当然,flutter本身在性能方面还有其他问题)。不过在Android页面中混入flutter组件本身具备一定的桥接成本,相比之下,Compose天生Android友好,可以比较完善地解决这个问题。
和flutter相似,Compose通过代码声明式构建UI。代码在编译期被转化为了优化后的字节码,绕过了XML文件的IO读取和解析反射的性能耗时。Compose使用一次测量机制,通过父布局和子布局的信息交换,确保每一个控件只进行一次尺寸测量,将测量复杂度降到了O(N),这一点和Flutter很像,也是通过限定父子布局的约束信息,将测量复杂度降到O(N)。
相比之下,原生Android的View的渲染,会因为measure中父布局的不同以及子View的区别,出现多次渲染的情况,导致测量指数呈指数增长,影响布局性能。因此,传统的XML中过深的View嵌套被认为是低质的代码,而Compose往往是深不见底的嵌套层。
UI界面
Composable
Compose中的重组方法一直给人很大的迷惑性。事实上,一开始,我一直以为它和flutter的widget是一样的功能,用于返回一个View。
@Composable
fun Title(post: Post) {
Text(
text = post.title,
style = MaterialTheme.typography.titleMedium,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
上面就是渲染一个text控件。但是事实上也并非如此,比如:
@Composable
private fun getHomeScreenType(isExpandedScreen: Boolean, uiState: HomeUiState): HomeScreenType = when (isExpandedScreen) {
false -> {
when (uiState) {
is HomeUiState.HasPosts -> {
if (uiState.isArticleOpen) {
ArticleDetails
} else {
Feed
}
}
is HomeUiState.NoPosts -> Feed
}
}
true -> FeedWithArticleDetails
}
上述getHomeScreenType方法用于返回首页type。 官方对@Composable的解释是:
Composable functions are the fundamental building blocks of an application built with Compose.
Composable can be applied to a function or lambda to indicate that the function/lambda can be used as part of a composition to describe a transformation from application data into a tree or hierarchy.
Compsable方法是Compose项目的基础组成部分,用于描述从应用数据到(UI)树或层级结构的转换。这里可以看到Compsable 不等于 UI创建。官方定义Composable 函数的核心作用是描述应用状态到 UI 的映射,而不是UI的创建,这是因为UI创建本身具备渲染,状态和数据这几块。
归结起来,Composable方法可能是:
- (1)纯逻辑处理:
@Composable
fun validInput(
username: String,
password: String,
onValid: () -> Unit,
onInvalid: (String) -> Unit
) {
if (username.isEmpty()) {
onInvalid("Username cannot be empty")
} else if (password.length < 8) {
onInvalid("Password must be at least 8 characters")
} else {
onValid()
}
}
这里的例子其实没必要是一个Composable函数。但是在很多情况下,我们的方法会应用到Composable方法或者Composable提供的状态,这时候就必须添加上@Composable注解
- (2)状态或者数据处理:
@Composable
fun fetchUserData(userId: String, onResult: (User) -> Unit) {
val scope = rememberCoroutineScope()
// 启动协程执行网络请求(副作用)
LaunchedEffect(userId) {
val user = fetchUserFromNetwork(userId) // 模拟网络请求
onResult(user)
}
}
上面方法执行了从网络获取数据的操作,并进行了回调。方法本身和普通方法没有任何区别。之所以需要添加@Composable注释,是因为其使用了rememberCoroutineScope和LaunchedEffect ,它们必须在Composable方法中使用。
- (3) UI展示
@Composable
fun ConditionalContent(showContent: Boolean) {
if (showContent) {
Text("Content is visible")
}
}
在我看来,标注了@Composable的意义在于明确自己是Compose体系内的方法,从而可以调用其他@Composable注解的方法,以及限定自身也只能被其他注解过@Composable的方法调用,这样的一个好处在于代码分离,以及逻辑统一。
布局
和Flutter一致,Compose在构建UI时,也存在组合,布局和渲染三个阶段。组合负责构建UI层级树,布局负责measure和layout,渲染负责draw。由此可见,组合和重组不一样,在一个Compose页面从0到1展示,构建一整棵完整的Compose树的完整过程中,Compose的组合只会有一次,而重组涉及到状态的改变和UI的变化,则会执行很多次。(受原生开发的影响,我一直认为布局阶段是整个UI构建中最复杂的,以及性能耗费最大的点。对应到原生中就是measure和layout)
目前Compose的View并不完善,但是好在它给出了自定义View的功能,使得我们完全可以做出复杂的UI样式。
我们完全可以介入Measure和Place这两个步骤,从而创建出自定义View。
data class BaselineHeightModifier(val heightFromBaseline: Dp) : LayoutModifier {
override fun MeasureScope.measure(measurable: Measurable, constraints: Constraints): MeasureResult {
val textPlaceable = measurable.measure(constraints)
val firstBaseline = textPlaceable[FirstBaseline]
val lastBaseline = textPlaceable[LastBaseline]
val height = heightFromBaseline.roundToPx() + lastBaseline - firstBaseline
return layout(constraints.maxWidth, height) {
val topY = heightFromBaseline.roundToPx() - firstBaseline
textPlaceable.place(0, topY)
}
}
}
上面代码自定义了一个modifer能力,通过调整文本的基线位置,确保了所有文字底部对齐。
LayoutModifier是一个修饰符,只能作用于单个子元素,而 Layout 组件可以直接管理多个子元素的布局。
@Composable
fun VerticalGrid(modifier: Modifier = Modifier, columns: Int = 2, content: @Composable () -> Unit) {
Layout(
content = content,
modifier = modifier,
) { measurables, constraints ->
val itemWidth = constraints.maxWidth / columns
val itemConstraints = constraints.copy(
minWidth = itemWidth,
maxWidth = itemWidth,
)
val placeables = measurables.map { it.measure(itemConstraints) }
val columnHeights = Array(columns) { 0 }
placeables.forEachIndexed { index, placeable ->
val column = index % columns
columnHeights[column] += placeable.height
}
val height = (columnHeights.maxOrNull() ?: constraints.minHeight)
.coerceAtMost(constraints.maxHeight)
layout(
width = constraints.maxWidth,
height = height,
) {
val columnY = Array(columns) { 0 }
placeables.forEachIndexed { index, placeable ->
val column = index % columns
placeable.placeRelative(
x = column * itemWidth,
y = columnY[column],
)
columnY[column] += placeable.height
}
}
}
}
VerticalGrid实现了垂直网格布局效果,它将子元素按列排列,类似瀑布流。Constraints是由父容器传递给子元素,存有父view对子view的约束数据,Measurables则是子View的集合,用于测量子view的尺寸。在将网格布局中每一个item的宽高计算出来后,通过placeable.placeRelative确定好了item在页面上的布局。简单来说,layout的用法和Android自定义的measure+layout一样。
因此,虽然组合只会测量view一次,但是我们在使用时,很难确保重组的次数,如果layout中有大量复杂的计算和对象创建,也会有UI卡顿的情况,这个和原生开发的逻辑也是一样的。
重组
Compose UI的变化,依靠重组。组合是构建整棵Compose树,属于高成本的操作行为,但是重组是局部级别的。正如原生代码中,在渲染中只会重绘设置了dirty的UI区域,flutter中也只对局部renderObject进行重绘,Compose的重组也达到了局部渲染最优化的目的。
优秀的代码风格可以使得UI渲染的成本达到最低。而良好的state管理和状态提升,可以专门写一篇文章来唠唠,在官方文档中也对此有很详细的例子来描述。
在一开始,我潜意识地认为重组的最小颗粒是函数,这归功于@Composable注解的函数,天然具备完整的执行周期。所以,我会想尽可能地让函数功能内聚,让方法变小,当然这一切在执行上没问题,但是实际上,重组的最小颗粒是可组合函数内部的执行节点,而非整个函数。这个和state的使用也息息相关。
@Composable
fun Greeting(name: String) {
Text("Hello ")
Text(name)
Text("Maple")
}
name变化时,只有Text(name) 会重组。 要注意,这里只重组了Text(name),并不是说Greeting函数没执行。这有巨大的区别。
@Composable
fun Greeting() {
var state by remember { mutableStateOf(0) }
Log.i("TAG", "日志1:重组开始") // 每次重组都执行(无状态依赖)
Text("固定文本") // 不依赖state,UI 不更新但代码会执行
if (state > 0) {
Button(onClick = { state++ }) {
Text(state.toString()) //// 依赖state,state变化时UI会更新
}
}
Log.i("TAG", "日志2:重组结束") // 每次重组都执行(无状态依赖)
}
当Greeting重组时,所有的Log都会执行,但是固定文本的text不会重组,依赖state的Text会重组。换而言之,在重组方法中,状态变化了的方法都会重组,同时,非Compose方法也会执行。所以在Compose方法中执行任何耗时的逻辑操作都是不合理的,即使放在remember当中。例如网络请求,或者IO数据存储等,最好的方式还是放在ViewModel当中。
总结
Compose原生支持kotlin,无论KMP的未来如何,对于Compose取代XML,我认为是必然的结果。之前在看KMP时,一直好奇Compose是怎么和IOS进行交互的,结果看完才发现是通过Kotlin的c-interop 能力,使得KMP能够直接调用OC的方法,这种黑科技简直震惊我一百年。我一直认为对于任何跨端语言,多端之间的交互是最核心的,flutter最令人诟病的就是channel以及原生view和flutter view互相嵌套的问题,如果KMP能解决这个问题,那就非常值得期待了。