笔者作为一个日常Jetpack Compose开发者,对Compose的了解也在逐渐加深中,最近回顾当初学习和实践的过程中,犯了不少过错和踩了许多坑,本篇文章作为小总结分享给咱们,一起文章会持续更新,也欢迎评论区或许私信给笔者投稿,谈谈你运用Compose过程中踩过的那些坑。

一、ViewModel传递到子可组合项

Jetpack Compose的状况办理是极其重要的一环,当一个可组合项的状况较少时,咱们需求运用状况目标来封装状况,而屏幕级的状况目标咱们最常用的便是ViewModel

关于状况办理能够参考下面这篇开发者文档:

状况容器和界面状况 | Android 开发者 | Android Developers (google.cn)

持续回到论题,运用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的状况和办法的能力,看起来十分完美,代码跑起来也没问题。

可是,这样的办法是过错的,一起也会带来内存走漏的隐患。

刚学Jetpack Compose?最好不要踩这些新手容易碰到的坑

基于官方文档,笔者总结出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,会呈现无法预览的问题,如下:

刚学Jetpack Compose?最好不要踩这些新手容易碰到的坑

刚学Jetpack Compose?最好不要踩这些新手容易碰到的坑

呈现这个问题的原因是预览体系无法正确实例化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的重组概念以及如何运用几种官方的顺便效应Api解决顺便效应的问题,读者能够自行阅览。

妈!Jetpack Compose太难学了,别怕,这里帮你理清几个概念 – ()

四、预览时不遵从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%从头构建了整个列表,类似RecyclerViewnotifyDataSetChanged()

what?哪怕你只是添加了一条数据,或许修正了某一条数据的某一个小参数,都会导致整个列表从头构建。这是无法承受的,特别是列表项特别多元素时。

因而,要完结高效的重组,列表必须定位出当时列表和旧列表的改变,判定出这种改变必须了解每一个项的以下两点内容:

  1. 我是谁
  2. 我有什么内容

第一点用于让列表了解,每一个项的绝无仅有的标志是什么,这让列表能够知道项的方位联系是否产生了改变,项是否是新增的或许现已被移除了。

第二点用于让列表了解,每一个项自身的元素是否产生了改变。

第二点,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网络结构的项目,每一个接口都有一个对应的实体类)。

因而,咱们应该防止把可组合项和某个事务绑定起来,在规划可组合项的状况目标时,不应该考虑只和某个事务的目标绑定(除非你十分明确该可组合项只用于某个特定的事务),脱离事务去规划状况目标即可。当某个事务想运用该可组合项时,例如可组合项要显现接口回来的列表,咱们应该将该接口的实体类映射成可组合项的状况类,再传入可组合项,防止事务和某个可组合项产生耦合