Compose学习和使用(一)

144 阅读10分钟

Compose学习和使用(二):状态和Effect

前言

笔者最近有时间翻阅官方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能解决这个问题,那就非常值得期待了。