作者:晴天小庭
笔者作为一个日常Jetpack Compose开发者,对Compose的理解也在逐渐加深中,最近回顾当初学习和实践的过程中,犯了不少错误和踩了很多坑,本篇文章作为小总结分享给大家,同时文章会持续更新,也欢迎评论区或者私信给笔者投稿,谈谈你使用Compose过程中踩过的那些坑。
一、ViewModel传递到子可组合项
Jetpack Compose的状态管理是极其重要的一环,当一个可组合项的状态较少时,我们需要使用状态对象来封装状态,而屏幕级的状态对象我们最常用的就是ViewModel
。
使用ViewModel
来管理屏幕级状态时的代码大致如下所示:
class MyScreenViewModel(/* ... */) {
val uiState: StateFlow<MyScreenUiState> = /* ... */
fun doSomething() { /* ... */ }
fun doAnotherThing() { /* ... */ }
// ...
}
@Composable
fun MyScreen(
modifier: Modifier = Modifier,
viewModel: MyScreenViewModel = viewModel(),
state: MyScreenState = rememberMyScreenState(
someState = viewModel.uiState.map { it.toSomeState() },
doSomething = viewModel::doSomething
),
// ...
) {
/* ... */
}
可以看到ViewModel
通过参数的方式直接传递到了MyScreen
可组合项中,这样做是没问题的而且非常便利,可组合项可以通过ViewModel
直接获取到所需的状态,同时也可以通过ViewModel
的方法来访问各种逻辑函数。
正因为这样太便利了,很多Compose新手会直接把ViewModel
进一步传递到子可组合项,让子可组合项也能“便利”地访问到状态和逻辑函数,写出这样的代码:
@Composable
fun MyScreen(
modifier: Modifier = Modifier,
viewModel: MyScreenViewModel = viewModel(),
state: MyScreenState = rememberMyScreenState(
someState = viewModel.uiState.map { it.toSomeState() },
doSomething = viewModel::doSomething
),
// ...
) {
/* ... */
SonComposable(viewModel)
}
@Composable
fun SonComposable(
viewModel: MyScreenViewModel = viewModel(),
){
/* ... */
}
🤩哇喔,通过参数将ViewModel
传入了子组合项,让子组合项也拥有访问ViewModel
的状态和方法的能力,看起来非常完美,代码跑起来也没问题。
但是,这样的方式是错误的,同时也会带来内存泄漏的隐患。
基于官方文档,笔者总结出ViewModel
在Compose中的正确方式:
1.ViewModel仅用于最顶层的屏幕级可组合项,即离Activity或者Fragment的setContent{}方法最近的那个可组合项。
2.遵循单一数据源规范,ViewModel将状态传递给子可组合项,子可组合项将事件向上传递给顶层的可组合项,不能将ViewModel直接传递给子可组合项。
注:很久以前官方文档还会提到ViewModel可能会导致子可组合项的内存泄漏,因为ViewModel的生命周期会比子可组合项更长,一些lambda或者匿名方法会导致可组合项被ViewModel持有导致内存泄漏。
我们按照原则(状态下传,事件上传)将代码改造成如下即可:
@Composable
fun MyScreen(
modifier: Modifier = Modifier,
viewModel: MyScreenViewModel = viewModel(),
state: MyScreenState = rememberMyScreenState(
someState = viewModel.uiState.map { it.toSomeState() },
doSomething = viewModel::doSomething
),
// ...
) {
/* ... */
SonComposable(viewModel.content, onContentChange = {
viewModel.onContentChange(it)
})
}
@Composable
fun SonComposable(
content:String,
onContentChange:(String)->Unit={}
){
/* ... */
}
二、不恰当的参数导致@Preview不能预览
也许你的一些可组合项会出现无法预览的问题,导致这个问题的原因有很多,大多数都是一个原因导致的:即预览系统遇到了异常。
- 一个常见的错误就是对使用
ViewModel
的屏幕级可组合项使用@Preview
,会出现无法预览的问题,如下:
出现这个问题的原因是预览系统无法正确实例化ViewModel,因为ViewModel的实例化依赖于运行中的android系统,而预览系统实际上是一个阉割版的android系统,它只有和UI相关的代码。
解决方案:
对屏幕级的可组合项抽离出一个只依赖于状态类的的子可组合项,将@Preview下沉到该子可组合项,屏幕级子可组合项不预览。
@Composable
fun MvRankScreen(
viewModel: MvRankViewModel = viewModel(),
){
MvRankContent(viewModel.rankState)
}
@Composable
private fun MvRankContent(
rankState:RankState
){
/* ... */
}
@Composable
@Preview
private fun PreviewMvRankContent(){
MvRankContent(remember{RankState()})
}
如上所示,将MvRankScreen
的内容抽离出一个MvRankContent
出来,然后MvRankContent
只使用ViewModel
传递下来的状态类,这样只预览MvRankContent
,就可以解决ViewModel
导致无法预览的问题。
- 另外一个常见的错误就是使用了项目中的其他类,该类只能android运行时才能获取,也会导致预览系统的崩溃,例如下面的一个类:
object MyClass{
fun getDesc():String{
return MyApplication.getInstance().getDesc()
}
}
该类的方法会从自定义的Application的实例获取一个字符串参数,而这个自定义的Application在预览系统中是不存在的,在Compose中直接使用此类也会导致预览系统的错误。
解决方法:
和一些View依赖于运行时才能获取的状态导致无法预览的问题类似,Compose也提供了一些方法来区分项目实际运行中和预览中的状态,如下所示:
@Composable
fun MyTest(){
Text(
text=if(LocalInspectionMode.current) "预览中" else MyClass.getDesc()
)
}
我们可以通过LocalInspectionMode.current
来判断当前Compose是否运行于预览系统中,如果处于预览系统,我们使用固定的字符串,防止了直接访问getDesc()导致Compose预览崩溃。
三、没有正确理解重组和处理附带效应
很多刚上手的Compose新手可能会写出这种代码,然后发现Compose没有按照自己预期的方式显示结果,这是没有理解Compose的重组机制导致的,每次重组就是重新执行一遍可组合函数,这会导致函数中的变量被重新声明和创建。
@Composable
fun WrongScreen(){
var num=0
Button(onClick = { num++ }) {
Text("加一")
}
}
四、预览时不遵循Compose的规范
很多Compose新手在写预览代码时,简单的认为预览系统不是正式运行的代码,只是提供界面预览而已,因此不注重附带效应的处理,会写出下面这种代码:
@Composable
@Preview
fun PreviewTest(){
var a=1
Text("$a")
}
这样的代码表面上是不会影响预览的,但是是一种很错误的行为。
首先,在预览中不注重Compose的规范(如果你看不懂上述代码有什么问题可以去看笔者第三节提到的另外一篇文章),只会让你写实际的Compose代码时养成不好的编码习惯,写出错误的代码。
其次,当可组合项很复杂的时候,特别是涉及较多重组的场景下,不正确处理好附带效应的问题,只会得到错误的预览。
因此笔者特别建议不要把预览当成是一种简单的UI预览,而是把预览的代码当成是实际的运行的项目代码来编写,这样项目运行时才可以得到正确的UI。
五、提前读取导致性能下降
很多新手会尝试在较高层的可组合项直接读取一些该组合项用不到的状态,这样的问题是:可被观察的状态变化时,会导致它所在的重组作用域发生重组,而它所在的重组作用域并不直接使用这个状态。我们看一个案例:
@Composable
fun SnackDetail() {
Box(Modifier.fillMaxSize()) { // 重组作用域开始
val scroll = rememberScrollState(0)
// ...
Title(snack, scroll.value)
// ...
} // 重组作用域结束
}
@Composable
private fun Title(snack: Snack, scroll: Int) {
val offset = with(LocalDensity.current) { scroll.toDp() }
Column(
modifier = Modifier
.offset(y = offset)
) {
// ...
}
}
我们逐步分析上面这段代码:
1.scroll.value
所在的重组作用域是SnackDetail
,因为Box是内联函数,编译后实际不是函数。
2.实际使用scroll.value
的是Title
。
3.scroll.value
变化时,发生重组的不仅仅是Title
,还有它的父可组合项SnackDetail
,因为scroll在SnackDetail
中。
因此,scroll
导致了不必要的重组,因为scroll
理应只影响Title
,现在还导致了父可组合项的重组。
解决方法有两种:
1.将scroll
作为参数传入到Title
中,在Title
中调用scroll.value
,使scroll.value的重组作用域变成Title
2.将scroll.value的读取转化为lambda,仅在使用时调用lambda函数,如下所示:
@Composable
fun SnackDetail() {
// ...
Box(Modifier.fillMaxSize()) { // 重组作用域开始
val scroll = rememberScrollState(0)
// ...
Title(snack) { scroll.value }
// ...
} // 重组作用域结束
}
@Composable
private fun Title(snack: Snack, scrollProvider: () -> Int) {
// ...
val offset = with(LocalDensity.current) { scrollProvider().toDp() }
Column(
modifier = Modifier
.offset(y = offset)
) {
// ...
}
}
此外,还有一个巨大的优化点就是,Modifier.offset使用lambda版本
对Title的代码改造成如下:
@Composable
private fun Title(snack: Snack, scrollProvider: () -> Int) {
// ...
Column(
modifier = Modifier
.offset { IntOffset(x = 0, y = scrollProvider()) }
) {
// ...
}
}
这样做有什么意义呢,offset
的非lambda版本会在scroll发生变化的时候导致整个重组作用域发生重组,这就有点不必要了,因为scroll值的变化仅会导致可组合项发生位移,我们并不需要重组,只需要重新绘制或者重新布局就行了。
使用offset的lambda版本就可以实现这种方式,我们看看该方法的部分注释:
This modifier is designed to be used for offsets that change, possibly due to user interactions. It avoids recomposition when the offset is changing, and also adds a graphics layer that prevents unnecessary redrawing of the context when the offset is changing.
翻译:
此Modifier设计用于可能由于用户交互而发生变化的偏移量。它避免了偏移量变化时的重新组合,并且还添加了图形层,以防止偏移量变化时不必要的上下文重绘。
可以看出,lambda版本的offset避免了重组,只会在测量的时候重新修改可组合项的位置关系,这样性能进一步提高了。
总而言之就是,尽可能将读取状态的行为延后。
六、LazyColumn、LazyRow等没有使用key
实际上在绝大部分的声明式UI框架中,懒加载的列表与安卓的传统列表开发不同,在RecyclerView
中,在修改了数据源后,我们需要手动通过Adapter
告知列表,刚才修改了数据源的哪项数据,例如删除了某项,修改了某项,移动了某项,这样RecyclerView
才能正确处理UI和数据源的关系。
但是声明式UI框架中,例如Compose,我们是没有“通知”这个行为的,只需要传递整个列表,LazyColumn等可组合项就自动完成列表构建了,这到底发生了什么?
@Composable
fun MessageList(messages: List<Message>) {
LazyColumn {
items(
items = messages,
) { message ->
MessageRow(message)
}
}
}
遗憾的是,什么都没特别的,LazyColumn只是100%重新构建了整个列表,类似RecyclerView
的notifyDataSetChanged()
。
what?哪怕你只是添加了一条数据,或者修改了某一条数据的某一个小参数,都会导致整个列表重新构建。这是无法接受的,特别是列表项特别多元素时。
因此,要完成高效的重组,列表必须定位出当前列表和旧列表的变化,鉴定出这种变化必须了解每一个项的以下两点内容:
- 我是谁
- 我有什么内容
第一点用于让列表了解,每一个项的独一无二的标志是什么,这让列表可以知道项的位置关系是否发生了变化,项是否是新增的或者已经被移除了。
第二点用于让列表了解,每一个项自身的元素是否发生了变化。
第二点,Compose的延迟列表中是使用对象自身的equals方法来完成的,而对于第一点,则是使用key。
将代码改造成如下:
@Composable
fun MessageList(messages: List<Message>) {
LazyColumn {
items(
items = messages,
key = { message ->
message.id
}
) { message ->
MessageRow(message)
}
}
}
我们多传入一个参数key,即使用message中的id,必须要清楚的是,这个key必须是独一无二的,因为当存在两个相同的key时,列表将无法确定item的唯一性。
这样的好处就是,列表可以清楚感知每一个item的唯一性,当数据源只发生了项的位置的变化,或者部分项被新增或者移除了,列表只需要处理那些发生过变化的项对应的可组合项即可,不需要重组整个列表。这样列表的性能提高了一个数量级。
额外内容:
一个很多人不知道的点是,哪怕不是Lazy系列的可组合项,也可以使用key来提高性能,例如普通的Column可以通过key来提高重组效率!
@Composable
fun NiceColumn(list:List<String>){
Column{
list.forEach {
key(it){
Text(text=it)
}
}
}
}
如果你有一个不断变化的列表,也可以使用key这个可组合函数来完成对项的唯一性声明,当列表变化时,避免其他项被重组。
七、业务对象入侵可组合函数
许多可组合函数的业务就是显示一些后台返回的数据,假设你有一个这样的后台对象:
data class Message(
val content:String,
val id:Int
)
业务需要在一个列表中展示所有的这些对象,因此很多人会尝试写一个这样的可组合项:
@Composable
fun MessageContent(
message:Message
){
Text(message.content)
}
@Composable
fun MessageList(list:List<Message>){
LazyColumn{
items(list){
MessageContent(it)
}
}
}
这样是不存在任何代码上的问题的,但是千万别忘记,业务是会发生变化和重合的。当另外一个业务,或者另外一个接口也使用到这个可组合项的时候呢,就会非常难受,因为该可组合项已经和某个后台对应的实体类发生耦合了(特别是一些使用了Retrofit网络框架的项目,每一个接口都有一个对应的实体类)。
因此,我们应该避免把可组合项和某个业务绑定起来,在设计可组合项的状态对象时,不应该考虑只和某个业务的对象绑定(除非你非常明确该可组合项只用于某个特定的业务),脱离业务去设计状态对象即可。当某个业务想使用该可组合项时,例如可组合项要显示接口返回的列表,我们应该将该接口的实体类映射成可组合项的状态类,再传入可组合项,避免业务和某个可组合项发生耦合。
如果有对Jetpack Compose掌握的不是很好的小伙伴可以去参考《Jetpack Compose 全家桶笔记》:https://qr18.cn/A0gajp