《Jetpack Compose系列学习》-3 Compose编程思想2

642 阅读6分钟

声明式编程

Android视图层次结构是界面控件树,由于应用程序的状态会因用户交互等因素而发生变化,因此界面层次结构需要更新以便显示当面数据。最常见的界面更新方式是通过findViewById等函数遍历控件树,通过调用控件的一些方法来更改控件的一些状态,如:

 button.setText(string)
 imageView.setImageBitmap(bitmap)
 ...

但Compose会自动更新UI层次结构,那么怎么实现的呢?那就是“可组合函数”。

可组合函数

如上次我们说到的Greeting函数就是一个可组合函数,用@Composable修饰,可通过定义一组接收数据并发出界面元素的可组合函数来构建界面。我们来看一个例子:

@Composable
fun Greeting(name: String, isShowName: Boolean) {
    val showName = if (isShowName) "显示名字" else "不显示名字"
    Text(text = "Hello $name! $showName")
}

可以看到,可组合函数中不止可以写页面,也可以将展示页面的逻辑写好。除了接收了name参数,还接收了一个Boolean类型的值,用于判断是否显示名字的逻辑。所以可以看出可组合函数,代码逻辑简洁,不需要再写一遍赋值的操作,减少了代码出错的可能性。 通过预览Preview看到的效果如下:

@Preview(showBackground = true, widthDp = 200, heightDp = 150)
@Composable
fun DefaultPreview() {
    ComposeTheme {
        Greeting("compose开发者", true)
    }
}

image.png

所以如果想修改名字,只需要修改可组合函数的参数即可,这就是可组合函数。

重组

在Compose中,更新某个控件可以使用新数据再次调用可组合函数达到更新效果,这样做会导致函数进行重组。重组就是系统根据需要使用新数据重新绘制的函数来重新组合,Compose并可以智能地仅重组已更改的组件,未更改(不需要更新)的组件不发生重组。那怎么实现这种智能重组呢?当Compose根据新数据进行重组时,它仅调用可能已更改的函数或者lambda,而跳过其余函数或lambda。这种重组是很高效的。

重组是乐观的操作,可能会被取消。不过需要注意,可组合函数可能会像每一帧一样频繁地重新执行,以免视觉上发生“卡顿”,如果执行耗时高成本的操作,尽量在后台协程中执行,并将值结果作为参数传递给可组合函数。

智能重组

我们看看如何构建可组合函数以支持重组,在下面这几种情况下,怎么能做到使组合函数保持快速、幂等且没有附带效应。

1.控件按任何顺序运行

大多数人可能会认为可组合函数会按照其出现的顺序运行,但未必。如果某个可组合函数包括对其他可组合函数的调用,这些函数可以按任何顺序运行。Compose可以选择并识别出某些界面元素的优先级高于其他界面元素,因而优先级高的优先绘制。如下面代码用于在标签页布局中绘制三个tab页:

@Composable
fun Bottom() {
    Navigations {
        firstTabPage()
        secondTabPage()
        thirdTabPage()
    }
}

firstTabPage、secondTabPage和thirdTabPage的调用可以按任何顺序运行,所以,每个可组合函数都需要保持独立,不能依赖于别的可组合函数,不然可能会报错,如secondTabPage一些资源依赖于firstTagPage,但是可能secondTabPage先执行,但是firstTabPage还未执行,依赖的值可能为空,所以会报错。

2.控件并行运行

Compose可以通过并行运行可组合函数来优化重组,这样就可以利用多个核心,并以较低的优先级运行可组合函数了(不在屏幕上)。这种优化意味着可组合函数可能会在后台线程池中执行。如果某个可组合函数对ViewModel调用一个函数,则Compose可能会同时从多个线程调用该函数。所以为了确保应用程序正常运行,所以可组合函数都不应有附带效应,而应通过始终在界面主线程上执行的onClick等回调触发附带效应。

调用某个可组合函数时,调用可能发生在与调用方在不同的线程上,所以应避免使用修改可组合lambda中变量的代码,既因为此类代码并非线程安全的代码,又因为它是可组合lambda不允许的附带效应。我们看下面这个代码,它是一个可组合项,显示一个列表及其项数:

@Composable
fun listComposable(dataList: List<String>) {
    Row(horizontalArrangement: Arrangement.SpaceBetween) {
        Column {
            for(item in dataList) {
                Text("Item: $item")
            }
        }
        Text("Count: ${dataList.size}")
    }
}

上面的代码控件会在后面的文章说。此代码没有附带效应,它会将传入的列表转化为界面,非常适合显示较小的列表,但如果函数写入局部变量,那这并非线程安全的代码:

@Composable
fun listWithBugComposable(dataList: List<String>) {
    var items = 0 // 局部变量
    
    Row(horizontalArrangement: Arrangement.SpaceBetween) {
        Column {
            for(item in dataList) {
                Text("Item: $item")
                items ++ // 注意避免列重组的副作用
            }
        }
        Text("Count: $items")
    }
}

每次重组时会修改items的值,可以是动画的每一帧或列表更新时候发生,但是不管怎么样,界面都会显示错误的项数,因此Compose不支持这样的写入操作,通过禁止此类的写入操作,我们允许框架更改线程以执行可组合lambda。

3.重组会跳过尽可能多的内容

如果界面某些部分无效,Compose会尽力只重组需要更新的部分,可以跳过某些内容以重新运行单个按钮的可组合项,而不执行在界面树上面或下面的任何可组合项。每个可组合函数和lambda都可以自行重组,下面我们看个例子是如何跳过某些元素的:

@Composable
fun NamePicker(
    header: String, 
    names: List<String>, 
    onNameClicked: (String) -> Unit) {
    
        Column {
            Text(header, style = MaterialTheme.typography.h5)
            Divider() // 分割线
            LazyColumn(modifier = Modifier.fillMaxSize()) { // 类似recyclerView,后续会介绍
                items(names) { name ->
                    NamePickerItem(name, onNameClicked)
                }
            }
        }
}

@Composable
private fun NamePickerItem(name: String, onClicked: (String) -> Unit) {
    Text(name, Modifier.clickable(onClick = {onClicked(name)}))
}

这些作用域中的每一个都可能是在重组期间执行的唯一作用域。当header发生更改时,Compose可能会跳至Column lambda,而不执行它的任何父项,还有就是在执行column时,如果names未更改,Compose可能会选择跳过LazyColumn。

同样,执行所有可组合函数或lambda都应该没有附带效应,当需要附带效应时,应通过回调触发。

4.重组是乐观操作

只要Compose认为某个可组合项的参数可能已更改,就会开始重组。重组是乐观的操作,Compose预计会在参数再次更改之前完成重组。如果某个参数在重组完成之前发生更改,Compose可能会取消重组。并使用新参数重新开始。取消重组后,Compose会从重组中舍弃界面树。如有任何附带效应依赖于显示的界面,则即使取消了重组操作,也会应用该附带效应。这可能会导致应用状态的不一致。所以我们应该确保所有可组合函数和lambda都幂等且没有附带效应,以处理乐观的重组。