Compose学习笔记-基础篇

Jetpack Compose

基础组件

容器组件

Box 堆叠布局容器

  • 解决场景: 让组件按顺序堆叠摆放。
  • 重要属性:
    • contentAlignment : 子组件的对齐方式。
    • propagateMinConstraints : 是否应该将传入的最小约束传递给内容。
Box(
    modifier = Modifier
        .size(300.dp)
        .background(Color.Gray)
) {
    Box(
        modifier = Modifier
            .align(Alignment.Center)
            .size(200.dp)
            .background(Color.Red.copy(0.4F))
    )

    Box(
        modifier = Modifier
            .align(Alignment.Center)
            .size(100.dp)
            .background(Color.Yellow)
    )
}
Box

Colum 纵向线性布局容器

  • 解决场景: 让组件纵向排列,超出屏幕后可支持滚动。
  • 重要属性:
    • verticalArrangement : 主轴方向上的对齐方式以及子元素间的间距。
    • horizontalAlignment : 交叉轴方向上的对齐方式。
val scrollState = rememberScrollState()  // 获取ScrollState
Column(
    modifier = Modifier.fillMaxSize()  //撑满屏幕
        .verticalScroll(scrollState), //支持垂直滚动
    verticalArrangement = Arrangement.spacedBy(5.dp)  //设置垂直方向每个子元素的间隔
) {
    (1..100).forEach { i ->  //  使用遍历产生100个子元素
        Text(
            text = "我是第${i}个元素",
            fontSize = 20.sp,
            textAlign = TextAlign.Center,
            modifier = Modifier
                .fillMaxWidth()
                .height(80.dp)
                .background(Color.Blue.copy(0.3F))
                .wrapContentSize(),
        )
    }
}
Colum

Row 横向线性布局容器

  • 解决场景: 让组件横向排列,超出屏幕后可支持滚动。
  • 重要属性:
    • horizontalArrangement : 主轴方向上的对齐方式以及子元素间的间距。
    • verticalAlignment : 交叉轴方向上的对齐方式。
val scrollState = rememberScrollState()  // 获取ScrollState
Row(
    modifier = Modifier
        .height(100.dp) //设置高度为100
        .fillMaxWidth()//宽度撑满屏幕
        .horizontalScroll(scrollState), //支持水平滚动
    horizontalArrangement = Arrangement.spacedBy(5.dp)  //设置水平方向每个子元素的间隔
) {
    (1..100).forEach { i ->  //  使用遍历产生100个子元素
        Text(
            text = "第${i}个",
            fontSize = 20.sp,
            textAlign = TextAlign.Center,
            modifier = Modifier
                .width(80.dp)
                .fillMaxHeight()
                .background(Color.Blue.copy(0.3F))
                .wrapContentSize(),
        )
    }
}
Row

基础组件

Text 文本组件

  • 解决场景: 绘制文字

  • 重要属性:

    • textStyle: 修改文字的大小、颜色、粗细等样式
    • overflow: 决定文字溢出时的绘制方式。
  • 简单使用:

    Text("我是一个文字")
    
  • 富文本:

    Text(
        buildAnnotatedString {
            withStyle(
                SpanStyle(
                    fontSize = 15.sp
                )
            ) {
                append("我和我的")
            }
            withStyle(
                SpanStyle(
                    fontSize = 24.sp,
                    fontWeight = FontWeight.Bold
                )
            ) {
                append("祖国")
                //插入行内元素:flag
                appendInlineContent("flag")
            }
            withStyle(
                SpanStyle(
                    fontSize = 15.sp
                )
            ) {
                append(",一刻也不能分隔。")
            }
        },
        inlineContent = mapOf(
            //定义行内元素为一个图片。
            "flag" to InlineTextContent(
                Placeholder(24.sp, 24.sp, PlaceholderVerticalAlign.TextCenter)
            ) {
                Image(
                    painterResource(id = R.drawable.ic_red_flag),
                    contentDescription = "国旗"
                )
            }
        ),
    )
    
    富文本

TextField 文本编辑框组件

  • 解决场景: 绘制文字
  • 重要属性:
    • value : 设置内容
    • onValueChange : 监听内容改变
    • label : 设置标签(material 风格)
    • keyboardOptions : 键盘配置
    • singleLine : 是否单行输入
    • maxLines :最大可显示行数
    • minLines : 最小显示行数
    • placeholder : 占位组件
val text = remember { mutableStateOf("") }
Column(modifier = Modifier.fillMaxSize().padding(top = 20.dp), horizontalAlignment = Alignment.CenterHorizontally) {
    TextField(
        value = text.value,
        onValueChange = {
            text.value = it
        },
        placeholder = {
            Text("请输入内容")
        },
        keyboardOptions = KeyboardOptions(
            keyboardType = KeyboardType.Number
        )
    )
}
TextField

Image 图片组件

  • 解决场景: 显示图片

  • 重要属性:

    • painter : 通常用于设置资源图片,例如:painterResource(R.drawable.ic_lock)

    • imageVector :用于设置矢量图,通常为Material Design图标,例如Icons.Filled.Home

    • contentDescription : 在无障碍模式下对图片进行描述。

    • alignment :可选的对齐参数,用于将Painter、ImageVector、ImageBitmap放置在由宽度和高度定义的给定边界中。

    • alpha :透明度。

    • colorFilter : 修改图片颜色。

    • filterQuality :图像缩放质量的重要参数,它决定了当图像被缩放、旋转或变形时,像素如何被采样和插值计算。可选值和效果如下:

      参数值 描述 适用场景 性能影响
      FilterQuality.None 最近邻插值,无平滑处理 像素艺术、需要锐利边缘 最高性能
      FilterQuality.Low 双线性插值 中等质量,一般UI元素 较好性能
      FilterQuality.Medium 部分抗锯齿处理 平衡质量与性能(Compose默认值) 中等性能
      FilterQuality.High 高质量抗锯齿(类似双三次插值) 高质量图像展示 较高开销
    • bitmap : 用于设置Bitmap位图,示例如下:

      val context = LocalContext.current
      val bitmap = remember {
          BitmapFactory.decodeResource(context.resources, R.drawable.my_bitmap)
              .asImageBitmap() // 转换为Compose的ImageBitmap
      }
      Image(
          bitmap = bitmap,
          contentDescription = "Android位图"
      )
      
Image(
    modifier = Modifier.height(500.dp),
    painter = painterResource(R.drawable.img_bird),  //设置图片资源
    contentDescription = "小鸟.png",  //设置图片描述
    contentScale = ContentScale.Inside  //设置缩放模式
)
Image

Button 按钮组件

  • 解决场景: 为页面添加操作按钮
  • 重要属性:
    • onClick : 监听点击事件。
    • enabled :是否可点击。
    • shape : 定义按钮的形状以及阴影。
    • border : 边框。
    • elevation :控制按钮下方阴影大小,使其看起来具有一定的高度。
    • contentPadding :内边距。
    • colors :设置按钮在不同状态下的颜色。
Button(
    onClick = { /*点击事件*/ },
    shape = RoundedCornerShape(12),
) {
    Text(text = "这是一个按钮")
}
Button

CircularProgressIndicator 圆形进度条

  • 解决场景: 为页面添加圆形进度条组件,用于显示进度或loading样式。
  • 重要属性:
    • strokeWidth : 设置线条粗细。
    • strokeCap :设置线条尾端的样式。
    • progress :当前进度,用0~1表示当前进度,没有设置此参数时为loading样式,设置此参数后为进度条样式。
CircularProgressIndicator(
    modifier = Modifier
        .size(100.dp)
        .drawBehind {
            drawCircle(
              Color(0xFFCCCCCC), 
              radius = (size.minDimension - progressBarWidth) / 2F, 
              style = Stroke(width = progressBarWidth)
            )
        },
    strokeWidth = 10.dp,
    strokeCap = StrokeCap.Round,
    progress = 0.83F
)
CircularProgressIndicator

LinearProgressIndicator 线性进度条

  • 解决场景: 为页面添加水平xianxing进度条组件,用于显示进度或loading样式。
  • 重要属性:
    • strokeCap :设置线条尾端的样式。
    • progress :当前进度,用0~1表示当前进度,没有设置此参数时为loading样式,设置此参数后为进度条样式。
LinearProgressIndicator(
    progress = 0.83F,
    strokeCap = StrokeCap.Round,
    modifier = Modifier.height(20.dp)
)
LinearProgressIndicator

虽然LinearProgressIndicator的名字叫Linear但是他不能调整方向,如果要调整方向比如纵向,则需要手动实现,方法有很多,下面给个栗子:

@Composable
private fun MyComposable() {
    Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        VerticalProgressIndicator(
            progress = 0.82F,
            modifier = Modifier.width(20.dp),
            strokeCap = StrokeCap.Round
        )
    }
}

@Composable
fun VerticalProgressIndicator(
    progress: Float,
    modifier: Modifier = Modifier,
    trackColor: Color = ProgressIndicatorDefaults.linearTrackColor,
    color: Color = ProgressIndicatorDefaults.linearColor,
    strokeCap: StrokeCap = ProgressIndicatorDefaults.LinearStrokeCap,
){
    Box(
        modifier
            .size(4.dp, 240.dp)
            .drawBehind {
                if (strokeCap == StrokeCap.Round) {
                    drawRoundRect(
                      trackColor, 
                      topLeft = Offset.Zero, 
                      cornerRadius = CornerRadius(size.width / 2F)
                    )
                    drawRoundRect(
                      color, 
                      topLeft = Offset(0F, size.height * (1F - progress)), 
                      cornerRadius = CornerRadius(size.width / 2F)
                    )
                } else {
                    drawRect(trackColor, topLeft = Offset.Zero)
                    drawRect(color, topLeft = Offset(0F, size.height * (1F - progress)))
                }
            }
    )
}
VerticalProgressIndicator

CheckBox 复选框

  • 解决场景: 为页面添加水平xianxing进度条组件,用于显示进度或loading样式。
  • 重要属性:
    • checked : 设置当前是否被选中。
    • onCheckedChange :选中状态改变的回调。
    var isChecked by remember {
        mutableStateOf(true)
    }
    Column(
        modifier = Modifier.fillMaxSize().padding(top = 20.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.spacedBy(20.dp)
    ) {
        Row(
            verticalAlignment = Alignment.CenterVertically,
            modifier = Modifier.clickable {
                isChecked = !isChecked
            }
        ) {
            Checkbox(checked = isChecked, onCheckedChange = null)
            Text(text = "已完成")
        }
        Row(
            verticalAlignment = Alignment.CenterVertically,
            modifier = Modifier.clickable {
                isChecked = !isChecked
            }
        ) {
            Checkbox(checked = !isChecked, onCheckedChange = null)
            Text(text = "未完成")
        }
    }
Checkbox

Modifier 修饰符

借助修饰符,您可以修饰或扩充可组合项。您可以使用修饰符来执行以下操作:

  • 更改可组合项的大小、布局、行为和外观
  • 添加信息,如无障碍标签
  • 处理用户输入
  • 添加高级互动,如使元素可点击、可滚动、可拖动或可缩放

多个修饰器的调用是通过链式调用的方式coding的,同一个方法的多次调用并不是后调用的覆盖之前的调用。修饰符函数的顺序非常重要。由于每个函数都会对上一个函数返回的 Modifier 进行更改,因此顺序会影响最终结果。让我们来看看这方面的一个示例:

Box(
    modifier = Modifier
        .size(300.dp)  //设置宽高为300dp
        .background(Color.Green) //设置背景色为绿色
        .padding(30.dp)  //设置30dp的内边距
        .background(Color.Yellow) //设置背景色为黄色
        .padding(50.dp) //在原有padding的基础上在设置50dp的内边距
        .background(Color.Red) //设置背景色为红色
)
Modifier

内边距 padding

Modifier.padding(16.dp)  //设置所有方向上的内边距
Modifier.padding(top: 16.dp)  //单独或分别设置某个方向上的内边距
Modifier.padding(horizontal: 10.dp, vertical: 12.dp)  //设置上下为12dp左右为10dp的内边距

Modifier.statusBarsPadding()  //设置与状态栏等高的内边距(适用于沉浸式页面)
Modifier.navigationBarsPadding()  //设置与导航栏等高的内边距(适用于沉浸式页面)

尺寸 size

Modifier.fillMaxSize()  //撑满最大尺寸,如果父容器是指定大小的,则撑满父容器,否则看父容器的父容器,以此类推。
Modifier.fillMaxWidth()  //撑满最大宽度,如果父容器是指定大小的,则撑满父容器,否则看父容器的父容器,以此类推。
Modifier.fillMaxHeight()  //撑满最大高度,如果父容器是指定大小的,则撑满父容器,否则看父容器的父容器,以此类推。
Modifier.size(100.dp) //设置组件的宽高都为100dp。
Modifier.sizeIn(
  minWidth: 100.dp, //设置最小宽度,等价于:Modifier.widthIn(min= 100.dp)。
  minHeight: 100.dp, //设置最小高度,等价于:Modifier.heightIn(min= 100.dp)。
  maxWidth: 150.dp,  //设置最大宽度,等价于:Modifier.widthIn(max= 150.dp)。
  maxHeight: 150.dp  //设置最大高度,等价于:Modifier.heightIn(max= 150.dp)。
)
Modifier.size(width: 100.dp, height: 40.dp)  //设置组件的宽度为100dp,高度为40dp。
Modifier.width(30.dp)  //设置组件的宽度为30dp。
Modifier.height(30.dp)  //设置组件的高度为30dp。

Modifier.matchParentSize()  //Box子组件特有,只能在Box的子组件上使用。用于仅撑满父容器。

注意: fillMaxSizefillMaxWidthfillMaxHeight都支持一个从0.0到1.0的参数,用于指定百分比,例如:Modifier.fillMaxSize(0.8F)表示撑满到80%。

另外除了通过上面的方式来设置size之外,Modifier还提供了requiredHeightrequiredWidthrequiredSize这三个方法及其重载用于跳出父容器的size约束。

size设置中除了上面的方法外,还有一个通过比例设置的方法:

Box(
    modifier = Modifier
        .background(Color.Green)
        .aspectRatio(1.5F)  //设置Box组件的宽度为高度的1.5倍。
)
aspectRatio

背景 background

仅设置背景色

Row(
    modifier = Modifier.fillMaxSize(),
    horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally),
    verticalAlignment = Alignment.CenterVertically
) {
    Box(
        modifier = Modifier
            .size(100.dp)
            .background(Color.Green)
    )
    Box(
        modifier = Modifier
            .size(100.dp)
            .background(Color.Red)
    )
    Box(
        modifier = Modifier
            .size(100.dp)
            .background(Color.Yellow.copy(0.9F))
            .drawBehind {
                drawCircle(
                   color = Color.Red.copy(0.5F),
                    style = Stroke(width = 20F),
                    radius = size.minDimension.minus(20F) / 2
                )
            }
    )
}
background

设置背景的高级用法

Column(modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(20.dp, Alignment.CenterVertically)) {
    Row(modifier = Modifier, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(20.dp, Alignment.CenterHorizontally)) {
        Box(
            modifier = Modifier
                .size(100.dp)
                .background(
                    color = Color.Green,
                    shape = CircleShape
                )
        )
        Box(
            modifier = Modifier
                .size(100.dp)
                .background(
                    color = Color.Red,
                    shape = RoundedCornerShape(10.dp),
                )
        )
    }
    Row(modifier = Modifier, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(20.dp, Alignment.CenterHorizontally)) {
        Box(
            modifier = Modifier
                .size(100.dp)
                .background(
                    color = Color.Magenta,
                    shape = GenericShape { size, _ ->
                        moveTo(size.width / 2f, 0f)          // 顶点
                        lineTo(size.width, size.height)       // 右下角
                        lineTo(0f, size.height)              // 左下角
                        close()                             // 闭合路径(回到起点)
                    }
                )
        )

        Box(
            modifier = Modifier
                .size(100.dp)
                .background(
                    brush = Brush.radialGradient(
                        listOf(
                            Color.Red,
                            Color.Blue,
                            Color.Yellow,
                            Color.Green,
                            Color.LightGray,
                        )
                    ),
                    shape = CircleShape
                )
        )
    }
}
BackgroundBrush

Row & Colum中的weight修饰

在Android开发中LinearLayout的子组件都可以通过设置权重(weight)来分配大小,而这在Compose中是通过Modifier的weight方法实现。

Column(modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) {
    Box(
        modifier = Modifier
            .background(Color.Red.copy(0.7F))
            .fillMaxWidth(0.6F)
            .weight(1F)
    )
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .weight(0.5F)
    ) {
        Box(modifier = Modifier
            .background(Color.Green)
            .fillMaxHeight()
            .weight(0.5F))
        Box(modifier = Modifier
            .background(Color.Magenta.copy(0.6F))
            .fillMaxHeight()
            .weight(1.5F))
    }
}
weight

添加点击事件 clickable

在Android中如果要监听View的点击事件就需要调用View的setClickListener方法设置监听,而在Compose中则需要通过Modifier的clickable方法实现。

class MainActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
                Box(modifier = Modifier
                    .size(100.dp)
                    .background(Color.Green)
                    .clickable { //设置点击事件监听
                        Toast.makeText(applicationContext, "我被点击了", Toast.LENGTH_SHORT).show()
                    }
                )
            }
        }
    }
}
clickable

设置边框 border

在Compose中如果想要对一个组件设置边框这非常容易:

Box(
    modifier = Modifier
        .size(100.dp)
        .border(
            width = 5.dp,  //设置边框宽度
            color = Color.Green,  //边框颜色
            shape = RoundedCornerShape(12.dp)  //边框形状
        )
)
border

对组件进行形状裁剪 shadow or clip

在Android如果要对一个View进行形状裁剪是比较麻烦的,而在Compose中非常简单,只需要调用Modifier的shadow方法即可。先来看下源码:

@Stable
fun Modifier.shadow(
    elevation: Dp,  //阴影高度
    shape: Shape = RectangleShape,  //形状,默认矩形。
    clip: Boolean = elevation > 0.dp,  // 是否裁剪,默认有阴影时裁剪
    ambientColor: Color = DefaultShadowColor, 
    spotColor: Color = DefaultShadowColor,
)

下面通过一个例子来分别演示一下将一个图片组件分别裁剪成圆角矩形和圆形:

Column(modifier = Modifier
    .fillMaxSize()
    .padding(40.dp),
    verticalArrangement = Arrangement.spacedBy(20.dp, Alignment.CenterVertically),
    horizontalAlignment = Alignment.CenterHorizontally
) {
    Image(
        painter = painterResource(id = R.drawable.img_bird),
        contentDescription = "小鸟",
        modifier = Modifier
            .height(300.dp)
            .shadow(
                elevation = 0.dp,  //设置没有高度
                shape = RoundedCornerShape(12.dp),      //裁剪成圆角矩形
                clip = true  //设置裁剪
            )
    )

    Image(
        painter = painterResource(id = R.drawable.img_bird),
        contentDescription = "小鸟",
        contentScale = ContentScale.Crop,
        modifier = Modifier
            .size(300.dp)
            .shadow(
                elevation = 10.dp,  //设置高度为10
                shape = CircleShape,        //裁剪成圆形
                //clip = true  当高度大于0时默认会开启裁剪
            )
    )
}
shadow

从上面的栗子中不难发现,shadow除了可以裁剪组件形状外最组要的还是为了实现Material Design风格中的卡片效果。如果单纯的仅仅是想要对组件进行形状裁剪,可以优先考虑Modifier.clip(shape: Shape),通过Modifier.clip(shape: Shape)可以完成几乎任何我们想要的形状裁剪。

滚动 scroll

在Android开发中我们如果希望一个组件可以滚动往往需要用ScrollViewHorizontalScrollView包裹一下,而在Compose中则通过Modifier来实现:

val scrollState = rememberScrollState()  //获取滚动状态
Column(
    modifier = Modifier
        .fillMaxSize()
        .verticalScroll(scrollState),  //设置纵向滚动
    verticalArrangement = Arrangement.spacedBy(20.dp),
    horizontalAlignment = Alignment.CenterHorizontally
) {
    Spacer(modifier = Modifier)
    repeat((1..10).count()) {  //重复执行10次,生成10个图片组件
        Image(
            painter = painterResource(id = R.drawable.img_bird),
            contentDescription = "小鸟",
            modifier = Modifier
                .fillMaxWidth()
                .padding(horizontal = 16.dp)
                .clip(RoundedCornerShape(12.dp))
        )
    }
    Spacer(modifier = Modifier)
}
Scroll

重组

当用户与界面交互时,界面会发起 onClick 等事件。这些事件应通知应用逻辑,应用逻辑随后可以改变应用的状态。当状态发生变化时,系统会使用新数据再次调用可组合函数。这会导致重新绘制界面元素,此过程称为“重组”。重组即重新绘制组件

在命令式界面模型中,如需更改某个 view,我们可以在该 view 上调用 setter 以更改其内部状态。在 Compose 中,我们可以使用新数据再次调用可组合函数。这样做会导致函数进行重组 -- 系统会根据需要使用新数据重新绘制Composable组件。Compose 框架可以智能地仅重组已更改的组件。

在使用 Compose 时需要注意的事项:

  • 重组会跳过尽可能多的可组合函数和 lambda。
  • 重组是乐观的操作,可能会被取消。
  • 可组合函数可能会像动画的每一帧一样非常频繁地运行。
  • 可组合函数可以并行执行。
  • 可组合函数可以按任何顺序执行。

特性

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

    如果界面的某些部分无效,Compose 会尽力只重组需要更新的部分。这意味着,它可以跳过某些内容以重新运行单个按钮的可组合项,而不执行界面树中在其上面或下面的任何可组合项。

  • 重组是乐观的操作

    只要 Compose 认为某个可组合项的参数可能已更改,就会开始重组。重组是乐观的操作,也就是说,Compose 预计会在参数再次更改之前完成重组。如果某个参数在重组完成之前发生更改,Compose 可能会取消重组,并使用新参数重新开始。

    取消重组后,Compose 会从重组中舍弃界面树。如果我们有任何附带效应依赖于显示的界面,则即使取消了组合操作,也会应用该附带效应。这可能会导致应用状态不一致。

    确保所有可组合函数和 lambda 都幂等且没有附带效应,以处理乐观的重组。

  • 可组合函数可能会非常频繁地运行

    在某些情况下,可能会针对界面动画的每一帧运行一个可组合函数。如果该函数执行成本高昂的操作(例如从设备存储空间读取数据),可能会导致界面卡顿。

    例如,如果您的 widget 尝试读取设备设置,它可能会在一秒内读取这些设置数百次,这会对应用的性能造成灾难性的影响。

    如果您的可组合函数需要数据,它应为相应的数据定义参数。然后,您可以将成本高昂的工作移至组成操作线程之外的其他线程,并使用 mutableStateOfLiveData 将相应的数据传递给 Compose。

  • 可组合函数可以并行运行

    注意 :可组合函数目前无法并行运行,但我们应该时刻认为每个@Composable函数都运行在不同的线程。未来,Compose 可能会支持多线程。

    Compose 可以通过并行运行可组合函数来优化重组。这样一来,Compose 就可以利用多个核心,并以较低的优先级运行可组合函数(不在屏幕上)。

    这种优化意味着,可组合函数可能会在后台线程池中执行。如果某个可组合函数对 ViewModel 调用一个函数,则 Compose 可能会同时从多个线程调用该函数。

    为了确保应用正常运行,所有可组合函数都不应有附带效应,而应通过始终在界面线程上执行的 onClick 等回调触发附带效应。

    调用某个可组合函数时,调用可能发生在与调用方不同的线程上。这意味着,应避免使用修改可组合 lambda 中的变量的代码,既因为此类代码并非线程安全代码,又因为它是可组合 lambda 不允许的附带效应。

  • 可组合函数可以按任何顺序执行

    注意: Compose 在主线程上运行,但从一开始就以多线程为设计目标,Goolge不保证未来仍会采用单线程模型。因此,我们在编写Compose可组合函数时应当始终认为它可能会在任何线程被调用。

    如果您看一下可组合函数的代码,可能会认为这些代码按其出现的顺序运行。但这并不一定是正确的。如果某个可组合函数包含对其他可组合函数的调用,这些函数可以按任何顺序运行。Compose 可以选择识别出某些界面元素的优先级高于其他界面元素,因而首先绘制这些元素。

类的稳定性

在前面的特性中提到过,Compse会跳过尽可能多的内容,那么它是如何知道是否需要跳过的呢?这就要看可组合函数的参数了。可组合函数的所有参数如果都是稳定的那么这个可组合函数将会在编译器被标记为skipable可跳过的,如果有参数不是稳定的,那么则会被标记为unskippable不可跳过的。有了这两个标记Compose就知道在运行时是否能够对一个可组合函数进行跳过了。

一个可组合函数的所有参数应尽量保持都是稳定的,这样我们才能让智能重组发挥左右,否则通常会有不必要的性能损耗。

如何判断一个类是稳定的呢?

如果要判断一个类是否是稳定的我们只需要看它是否满足以下条件:

  • 类是完全不可变的,如对于类型T的两个实例a和b,他们的equals结果长期不变,例如:全部参数都不可变的data class。
//所有参数都不可变的data class是稳定的。
data class User(
  val id:String,
  val name: String,
  val age: String
)
  • 类是可变的,但全部变化都可跟踪,所有public的属性变化均可被Compose感知,例如下面的示例:
//由于User的所有可变的public成员都是可以被Compose感知变化的`State`类型,所以它也是稳定的
class User {
    var id: String by mutableStateOf("id-007")
    var name: String by mutableStateOf("张三")
    var age: Int by mutableStateOf(23)
}
  • Kotlin中的所有基本类型都是稳定的,包括String类型和lambda函数类型。

  • 所有接口类型均不是稳定的,包括ListMap等。

让一个不稳定的类变得稳定

如果我们能够确定一个不稳定的类或接口,在Compose运行期间的确是稳定的(不会被修改的),那么我们可以通过在类上加一个@Stable注解,让其变得稳定。例如:

@Stable //将User类标记为一个稳定类
class User {
    var id: String
    var name: String
    var age: Int
}

@Stable  //将Animal接口表示为稳定类型
interface Animal

由于Compose默认我们所有不满足条件的类都是不稳定的,所以如果我们希望让一个不稳定的类变成稳定类时就需要@Stable注解,但是这么做我们就要破坏原有的类(也有可能这个类是有三方提供的),那么有没有一种方式让Compose默认我们所有的类都是稳定的呢?答案是:有的:

android { ... }

composeCompiler {
  enableStrongSkippingMode = true
}

通过将enableStrongSkippingMode设置为true的方式可以强制Compose默认认为所有类都是稳定的。

注意:2.0.20之后enableStrongSkippingMode模式是开启的,无论是默认开启还是手动开启enableStrongSkippingMode = true都只仅仅是告诉Compose我们的类是稳定的,并不代表Compose就能追踪我们不稳定类成员的变化;如果在开发中遇到了数据改变了但是Compose和组合函数没有发生重组则首先应该考虑我们的类是否真的是稳定的,然后做出相应的修改。

缺乏唯一标识导致的全量重组

无论是Flutter、ArkUI还是Compose,几乎所有的声明式UI框架都面临着同一个问题,如果在一个列表中我们修改了元素的个数或位置,UI框架是无法知道我们是如何修改的,所以无论是Flutter、ArkUI还是Compose都提供了Key这个概念。
在Compose中我们可以使用key可组合函数为一个可组合函数添加Key:

@Composable
fun ProjectListScreen(projects: List<Project>){
    Column { 
        projects.forEach { project ->
            key(project.id) { 
                ProjectListItem(project)
            }
        }
    }
}

当我们使用key后就可以避免全量重组了。

注意:虽然我们有的时候必须要使用key,不过我们大部分情况下一般不需要过分关心这个key,更不需要为所有的组件都设置key。

生命周期

概览

组合只能通过初始组合生成且只能通过重组进行更新。重组是修改组合的唯一方式。

要点:可组合项的生命周期通过以下事件定义:进入组合,执行 0 次或多次重组,然后退出组合。

组合中可组合项的生命周期

如图所示:进入组合,执行 0 次或多次重组,然后退出组合。

重组通常由对 [State](#状态 State) 对象的更改触发。Compose 会跟踪这些操作,并运行组合中读取该特定 State<T> 的所有可组合项以及这些操作调用的无法跳过的所有可组合项。

注意: 可组合项的生命周期比view、activity 和 fragment 的生命周期更简单。当可组合项需要管理比生命周期更复杂的外部资源或与之互动时,应使用[效应](#副作用 & 效应 Effect)。

如果某一可组合项多次被调用,在组合中将放置多个实例。每次调用在组合中都有自己的生命周期。

@Composable
fun MyComposable() {
    Column {
        Text("Hello")
        Text("World")
    }
}
组合中 MyComposable 的表示

组合中 MyComposable 的表示。如果某一可组合项多次被调用,在组合中将放置多个实例。如果某一元素具有不同颜色,则表明它是一个独立实例。

组合中可组合项的剖析

组合中可组合项的实例由其调用点进行标识。Compose 编译器将每个调用点都视为不同的调用点。从多个调用站点调用可组合项会在组合中创建多个可组合项实例。

关键术语调用点是调用可组合项的源代码位置。这会影响其在组合中的位置,因此会影响界面树。

在重组期间,并不是重组作用域中的所有可组合项都会进行重组,Compose会对两次组合中未更改输入的可组合项跳过重组。请参考官方示例:

@Composable
fun LoginScreen(showError: Boolean) {
    if (showError) {
        LoginError()
    }
    LoginInput() // This call site affects where LoginInput is placed in Composition
}

@Composable
fun LoginInput() { /* ... */ }

@Composable
fun LoginError() { /* ... */ }

在上面的代码段中,LoginScreen 将有条件地调用 LoginError 可组合项,并始终调用 LoginInput 可组合项。每个调用都有唯一的调用点和源位置,编译器将使用它们对调用进行唯一识别。

上图是出现状态更改和重组时,组合中 LoginScreen 的表示。颜色相同,表示尚未重组

即使 LoginInput 从第一次被调用变为第二次被调用,LoginInput 实例仍将在不同重组中保留下来。此外,由于 LoginInput 不包含任何在重组过程中更改过的参数,因此 Compose 将跳过对 LoginInput 的调用。

添加额外信息以促进智能重组

多次调用同一可组合项也会多次将其添加到组合中。如果从同一个调用点多次调用某个可组合项,Compose 就无法唯一标识对该可组合项的每次调用,因此除了调用点之外,还会使用执行顺序来区分实例。这种行为有时是必需的,但在某些情况下会导致发生意外行为。

下面是官方的栗子:

@Composable
fun MoviesScreen(movies: List<Movie>) {
    Column {
        for (movie in movies) {
            // MovieOverview composables are placed in Composition given its
            // index position in the for loop
            MovieOverview(movie)
        }
    }
}

在上面的栗子中,Compose 除了使用调用点之外,还使用执行顺序来区分组合中的实例。如果列表底部新增了一个 movie,Compose 可以重复使用组合中既有的实例,因为这些实例在列表中的位置没有发生变化,因此这些实例的 movie 输入是相同的。

上图是列表底部新增元素时,组合中 MoviesScreen 的表示。

可以重复使用组合中的 MovieOverview 可组合项。MovieOverview 中的相同颜色表示该可组合项尚未重组。

但是,如果因在列表顶部或中间新增内容,移除项目或对项目进行重新排序而导致 movies 列表发生改变,将导致输入参数在列表中的位置已更改的所有 MovieOverview 调用发生重组。例如,如果 MovieOverview 使用附带效应提取影片图像,这一点将非常重要。如果在使用附带效应的过程中发生重组,系统就会取消重组并重新开始。

@Composable
fun MovieOverview(movie: Movie) {
    Column {
        // Side effect explained later in the docs. If MovieOverview
        // recomposes, while fetching the image is in progress,
        // it is cancelled and restarted.
        val image = loadNetworkImage(movie.url)
        MovieHeader(image)

        /* ... */
    }
}

上图是向列表中(顶部)添加新元素后,组合中 MoviesScreen 的表示。MovieOverview 可组合项不能重复使用,并且所有附带效应都将重启。MovieOverview 中不同的颜色表示该可组合项已重组。

理想情况下,我们认为 MovieOverview 实例的身份与传递到该实例的 movie 的身份相关联。如果我们对影片列表进行重新排序,理想情况下,我们会以类似方式在组合树中对实例进行重新排序,而不是将每个 MovieOverview 可组合项与不同影片实例进行重组。您可以使用 Compose 来告诉运行时,您要使用哪些值来标识树的给定部分:key 可组合项。

通过调用带有一个或多个传入值的key可组合项来封装代码块,这些值将被组合以用于在组合中标识该实例。key 的值不必是全局唯一的,只需要在调用点处调用可组合项的作用域内确保其唯一性即可。在此示例中,每个 movie 都需要一个在 movies 的作用域内具有唯一性的 key;movie 也可以与应用中其他位置的其他可组合项共享该 key

@Composable
fun MoviesScreenWithKey(movies: List<Movie>) {
    Column {
        for (movie in movies) {
            key(movie.id) { // 假设movie中有个主键id,那么就可以用这个id作为key。
                MovieOverview(movie)
            }
        }
    }
}

使用上述代码后,即使列表中的元素发生变化,Compose 也能识别 MovieOverview 的各个调用,还可以重复使用这些调用。

上图是向列表中(顶部)添加新元素后,组合中 MoviesScreen 的表示。由于 MovieOverview 可组合项具有唯一键,因此 Compose 会识别未更改的 MovieOverview 实例,并且可重复使用它们;它们的附带效应将继续执行。

要点:当从同一个调用点调用多个可组合项,且这些可组合项包含附带效应或内部状态时,要使用 key 可组合项帮助 Compose 识别组合中的可组合项实例,这非常重要

如果输入未更改,则跳过

在重组期间,如果某些符合条件的可组合函数的输入与上一个组合相比未发生变化,则可以完全跳过其执行。

可组合函数符合跳过条件,除非

  • 函数具有非 Unit 返回值类型
  • 函数带有 @NonRestartableComposable@NonSkippableComposable 注解
  • 必需参数的类型不稳定

上面提到类型不稳定,那么什么是稳定类型呢?某种类型必须符合以下协定,才能被视为稳定类型:

  • 对于相同的两个实例,其 equals 的结果将始终相同。
  • 如果类型的某个公共属性发生变化,组合将收到通知。
  • 所有公共属性类型也都是稳定。

有一些归入此协定的重要通用类型,即使未使用 @Stable 注解来显式标记为稳定的类型,Compose 编译器也会将其视为稳定的类型。

  • 所有基本类型:BooleanIntLongFloatChar 等。
  • 字符串
  • 所有函数类型 (lambda)

所有这些类型都可以遵循稳定协定,因为它们是不可变的。由于不可变类型绝不会发生变化,它们就永远不必通知组合更改方面的信息,因此遵循该协定就容易得多。

注意:所有深层不可变的类型都可以被安全地视为稳定类型。

Compose 的 MutableState 类型是一种稳定但可变的类型。如果 MutableState 中存储了值,状态对象整体会被视为稳定对象,因为 State.value 属性如有任何更改,Compose 就会收到通知。

当作为参数传递到可组合项的所有类型都很稳定时,系统会根据可组合项在界面树中的位置来比较参数值,以确保相等性。如果所有值自上次调用后未发生变化,则会跳过重组。

要点:如果所有输入都稳定并且没有更改,Compose 将跳过重组可组合项。比较使用了 equals 方法。

Compose 仅在可以证明稳定的情况下才会认为类型是稳定的。例如,接口通常被视为不稳定类型,并且具有可变公共属性的类型(实现可能不可变)的类型也被视为不稳定类型。

如果 Compose 无法推断类型是否稳定,但您想强制 Compose 将其视为稳定类型,请使用 @Stable 注解对其进行标记。

//将类型标记为稳定,以便跳过和智能重组。
@Stable
interface UiState<T : Result<T>> {
    val value: T?
    val exception: Throwable?

    val hasError: Boolean
        get() = exception != null
}

在上面的代码段中,由于 UiState 是接口,因此 Compose 通常认为此类型不稳定。通过添加 @Stable 注解,您可以告知 Compose 此类型稳定,让 Compose 优先选择智能重组。这也意味着,如果将该接口用作参数类型,Compose 会将其所有实现视为稳定。

要点:如果 Compose 无法推断某个类型的稳定性,请根据情况为该类型添加 @Stable 注解,让 Compose 优先选择智能重组。

副作用 & 附带效应 Effect

附带效应是指发生在可组合函数作用域之外的应用状态的变化。

可组合项在理想情况下应该是没有附带效应的,但是有时候附带效应又是必须的,例如,触发一次性事件(例如显示信息提示控件),或在满足特定状态条件时进入另一个Screen。这些操作应该在能感知可组合项生命周期的受控环境中调用。

状态和效应用例

关键术语效应是一种可组合函数,该函数不会发出界面,并且在组合完成后不会产生附带效应

由于效应会在 Compose 中带来各种可能性,所以很容易过度使用。所以要确保在其中完成的工作与界面相关,并且不会破坏**单向数据流。

注意:自适应界面本质上是异步的,而 Jetpack Compose 会在 API 级别引入协程而非使用回调来解决此问题。

LaunchedEffect:在某个可组合项的作用域内运行挂起函数

LaunchedEffect在可组合项进入组合的时候会执行一次;LaunchedEffect还提供一个key作为参数,这个key和remember类似,当key变化的时候,LaunchedEffect会重组。

如需在可组合项的生命周期内执行工作并能够调用挂起函数,需使用LaunchedEffect可组合项。当 LaunchedEffect 进入组合时,它会启动一个协程,并将代码块作为参数传递。如果 LaunchedEffect 退出组合,协程将取消。如果使用不同的键重组 LaunchedEffect,系统将取消现有协程,并在新的协程中启动新的挂起函数。

例如,下面的动画会以可配置的延迟脉冲 Alpha 值:

// 允许配置脉冲速率,以便在用户运行时加速
// out of time
var pulseRateMs by remember { mutableStateOf(3000L) }
val alpha = remember { Animatable(1f) }
LaunchedEffect(pulseRateMs) { // 当脉冲速率改变时重新启动效果
    while (isActive) {
        delay(pulseRateMs) // 脉冲每个pulseRateMs提醒用户
        alpha.animateTo(0f)
        alpha.animateTo(1f)
    }
}

在上面的代码中,动画使用挂起函数 delay 等待设定的时间。然后,它会使用 animateTo 将 Alpha 值依次从 0 动画化为 1,然后再从 1 动画化为 0。这将在可组合项的生命周期内重复执行。

rememberCoroutineScope:获取组合感知作用域,以便在可组合项外启动协程

由于 LaunchedEffect 是可组合函数,因此只能在其他可组合函数中使用。为了在可组合项外启动协程,但存在作用域限制,以便协程在退出组合后自动取消,请使用rememberCoroutineScope。 此外,如果您需要手动控制一个或多个协程的生命周期,请使用 rememberCoroutineScope,例如在用户事件发生时取消动画。

rememberCoroutineScope 是一个可组合函数,会返回一个 CoroutineScope,该 CoroutineScope 绑定到调用它的组合点。调用退出组合后,作用域将取消。

根据上面的示例,当用户点按 Button 时,您可以使用以下代码显示 Snackbar

@Composable
fun MoviesScreen(snackbarHostState: SnackbarHostState) {

    // 创建一个CoroutineScope绑定到MoviesScreen的生命周期
    val scope = rememberCoroutineScope()

    Scaffold(
        snackbarHost = {
            SnackbarHost(hostState = snackbarHostState)
        }
    ) { contentPadding ->
        Column(Modifier.padding(contentPadding)) {
            Button(
                onClick = {
                    // 在事件处理程序中创建一个新的协程来显示一个Snackbar。
                    scope.launch {
                        snackbarHostState.showSnackbar("Something happened!")
                    }
                }
            ) {
                Text("Press me")
            }
        }
    }
}

rememberUpdatedState:在效应中引用某个值,该效应在值改变时不应重启

将一个普通的变量转化成一个MutableState,可以用于在避免LaunchedEffect重组的情况下在LaunchedEffect中使用最新的值。

当其中一个键参数发生变化时,LaunchedEffect 会重启。不过,在某些情况下,我们可能希望在效应中捕获某个值,但如果该值发生变化,我们不希望效应重启。为此,需要使用 rememberUpdatedState 来创建对可捕获和更新的该值的引用。这种方法对于包含长期操作的效应十分有用,因为重新创建和重启这些操作可能代价高昂或令人望而却步。

个人理解:其实rememberUpdatedState的作用就是将一个值[包装]成一个引用,在效应中使用时,效应拿到的是引用而不是效应启动时的值,因为效应拿到的是引用所以在使用时获取到的就是使用时的最新的值。

注意:我们并不 一定要 在效应中使用参数时都使用rememberUpdatedState进行[包装]。如果参数本身就是个State,或者我们本来就不希望在使用时获取最新的值而就是要效应启动时的值,这时就无需使用rememberUpdatedState

例如,假设您的应用的 LandingScreen 在一段时间后消失。即使 LandingScreen 已重组,等待一段时间并发出时间已过通知的效应也不应该重启。

@Composable
fun LandingScreen(onTimeout: () -> Unit) {

    //使用rememberUpdatedState保持onTimeout的引用总是最新的
    val currentOnTimeout by rememberUpdatedState(onTimeout)

    // 通过为LaunchedEffect设置key为true(不可变)的方式让LaunchedEffect只执行一次。
    LaunchedEffect(true) {
        delay(SplashWaitTimeMillis)
      
        //因为currentOnTimeout是通过rememberUpdatedState获取的,所以这里永远都是最新的,不会因为重组而出现异常
        currentOnTimeout()  
    }

    /* 其他UI */
}

DisposableEffect:需要清理的效应

如果我们需要在键发生改变或可组合项退出组合后(销毁时)进行某些清理工作则可以使用DisposableEffect。如果DisposableEffect的key发生变化,或当前可组合项即将退出重组,则当前DisposableEffect`会执行一次。

例如,您可能需要使用 LifecycleObserver,根据 Lifecycle 事件发送分析事件。如需在 Compose 中监听这些事件,请根据需要使用 DisposableEffect 注册和取消注册观察器。

SideEffect:将 Compose 状态发布为非 Compose 代码

SideEffect是最基本的副作用函数,仅会在重组成功后执行一次,由于SideEffect总能获取到当前可组合项中的最新状态,因此他常常用于将可组合项内部的状态同步到非可组合项的外部,与非组合项共享状态。

var clickedNum: Int = 0
    private set

@Composable
fun TestSideEffect(modifier: Modifier = Modifier){
    var num by remember {
        mutableIntStateOf(0)
    }
    Column(
        modifier = modifier
            .fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text("Counter:$num", modifier = Modifier.padding(8.dp))
        Button(onClick = { num++ }) {
            Text("点击我+1")
        }

        // 使用 SideEffect 在每次重组后同步外部状态
        SideEffect {
            clickedNum = num
        }
    }
}

produceState:将非 Compose 状态转换为 Compose 状态

produceState是一个可组合函数,它会启动一个协程;它可以在协程中将一个外部数据转成State,与SideEffect正好相反。它可以将外部状态引入到可组合项。下面是一个加载网络图片的栗子:

@Composable
fun loadNetworkImage(
    url: String,
    imageRepository: ImageRepository = ImageRepository()
): State<Result<Image>> {
        //请求加载网络图片
    //如果url或imageRepository改变了协程会取消并重启
    return produceState<Result<Image>>(initialValue = Result.Loading, url, imageRepository) {
        //在协程中可使用挂起函数。
        val image = imageRepository.load(url)
        value = if (image == null) {
            Result.Error
        } else {
            Result.Success(image)
        }
    }
}

derivedStateOf:将一个或多个状态对象转换为其他状态

在 Compose 中,每次观察到的状态对象或可组合输入出现变化时都会发生重组。状态对象或输入的变化频率可能高于界面实际需要的更新频率,从而导致不必要的重组。derivedStateOf可以把多个State转换为一个State。

当可组合项输入的变化频率超过您需要的重组频率时,就应该使用 derivedStateOf函数。这种情况通常是指,某些内容(例如滚动位置)频繁变化,但可组合项只有在超过某个阈值时才需要对其做出响应。derivedStateOf 会创建一个新的 Compose 状态对象,您可以观察到该对象只会按照您的需要进行更新。

注意:derivedStateOf 的成本较高,只应该在结果没有变化时用来避免不必要的重组。

以下代码段展示了适合使用 derivedStateOf 的情形:

@Composable
//当messages参数改变时,MessageList可组合项会重新组合。derivedStateOf不会影响这个重组。
fun MessageList(messages: List<Message>) {
    Box {
        val listState = rememberLazyListState()

        LazyColumn(state = listState) {
            // ...
        }

        //如果第一个可见项超过第一个条目,则显示按钮。使用remeber派生状态来最小化不必要的组合。
        //使用derivedStateOf可以保证再满足条件时只执行一次,否则每次firstVisibleItemIndex改变都会导致重组。
        val showButton by remember {
            derivedStateOf {
                listState.firstVisibleItemIndex > 0
            }
        }

        AnimatedVisibility(visible = showButton) {
            ScrollToTopButton()
        }
    }
}

警告:以下代码段展示的是错误使用 derivedStateOf 的情形。不要在您的项目中使用此代码。

//演示:错误地使用了derivedStateOf。
var firstName by remember { mutableStateOf("") }
var lastName by remember { mutableStateOf("") }

val fullNameBad by remember { derivedStateOf { "$firstName $lastName" } } // 这么写是不对的!!!
val fullNameCorrect = "$firstName $lastName" // 这才是正确的使用方式。

在这个代码段中,fullName 只需要以与 firstNamelastName 一样的频率进行更新。因此,不会发生过多的重组,没有必要使用 derivedStateOf

snapshotFlow:将 Compose 的 State 转换为 Flow

使用snapshotFlowState<T>转换为冷Flow。snapshotFlow会在收集到收到State的值改变后运行自己的代码块,并发送从State中读取值。

下面的示例显示了一项附带效应,是系统在用户滚动经过要分析的列表的首个项目时触发某个业务:

val listState = rememberLazyListState()

LazyColumn(state = listState) {
    // ...
}

LaunchedEffect(listState) {
    snapshotFlow { listState.firstVisibleItemIndex }
        .map { index -> index > 0 }
        .distinctUntilChanged()
        .filter { it == true }
        .collect {
            //do something!
        }
}

在上方代码中,listState.firstVisibleItemIndex 被转换为一个 Flow,从而可以受益于 Flow 运算符的强大功能。

重启效应

Compose 中有一些效应(如 LaunchedEffectproduceStateDisposableEffect)会采用可变数量的参数和键来取消运行效应,并使用新的键启动一个新的效应。

这些 API 的典型形式是:

EffectName(restartIfThisKeyChanges, orThisKey, orThisKey, ...) { block }

由于此行为的细微差别,如果用于重启效应的参数不是适当的参数,可能会出现问题:

  • 如果重启效应次数不够,可能会导致应用出现错误。
  • 如果重启效应次数过多,效率可能不高。

一般来说,效应代码块中使用的可变和不可变变量应作为参数添加到效应可组合项中。除此之外,您还可以添加更多参数,以便强制重启效应。如果更改变量不应导致效应重启,则应将该变量封装在 rememberUpdatedState 中。如果由于变量封装在一个不含键的 remember 中使之没有发生变化,则无需将变量作为键传递给效应。

要点: 应将效应中使用的变量添加为效应可组合项的参数,或使用 rememberUpdatedState

使用常量作为键

在开发中可以使用 true /Unit等常量作为效应键,使其遵循调用点的生命周期。它实际上具有有效的用例,如上面所示的 LaunchedEffect 示例。但在这样做之前,请审慎考虑,并确保我们确实需要这么做。

状态 State

在几乎所有应用程序中的页面内容都是用来展示或响应用户输入的数据的,这些变化的数据将驱动我们的UI如何展现;在Compose中这些变化的数据就被称作为“状态”。

状态&组合

由于 Compose 是声明式工具集,因此更新它的唯一方法是通过新参数调用同一可组合项。这些参数是界面状态的表现形式。每当状态更新时,都会发生重组。因此,TextField 不会像在基于 XML 的命令式视图中那样自动更新。可组合项必须明确获知新状态,才能相应地进行更新。几乎所有的声明式UI框架都是基于对状态变量的监听而驱动UI刷新的。

@Composable
private fun HelloContent() {
    Column(modifier = Modifier.padding(16.dp)) {
        Text(
            text = "Hello!",
            modifier = Modifier.padding(bottom = 8.dp),
            style = MaterialTheme.typography.bodyMedium
        )
        OutlinedTextField(
            value = "",
            onValueChange = { },
            label = { Text("Name") }
        )
    }
}

运行上面的代码并尝试输入文本,就会发现没有任何反应。这是因为,TextField 不会自行更新,但会在其 value 参数更改时更新。这是因 Compose 中组合和重组的工作原理造成的。

关键术语 - 组合:Jetpack Compose 在执行可组合项时构建的界面描述。

初始组合:通过首次运行可组合项创建组合。

重组:在数据发生变化时重新运行可组合项以更新组合。

可组合项中的状态

可组合函数可以使用 remember API 将对象存储在内存中。系统会在初始组合期间将由 remember 计算的值存储在组合中,并在重组期间返回存储的值。remember 既可用于存储可变对象,又可用于存储不可变对象。

注意remember 会将对象存储在组合中,当调用 remember 的可组合项从组合中移除后,它会忘记该对象。

mutableStateOf会创建可观察的MutableState,后者是与 Compose 运行时集成的可观察类型。

interface MutableState<T> : State<T> {
    override var value: T
}

value 所做的任何更改都会安排对读取 value 的所有可组合函数进行重组。

在可组合项中声明 MutableState 对象的方法有三种:

  • val mutableState = remember { mutableStateOf(default) }
  • var value by remember { mutableStateOf(default) }
  • val (value, setValue) = remember { mutableStateOf(default) }

这些声明是等效的,以语法糖的形式针对状态的不同用法提供。您选择的声明应该能够在您编写的可组合项中生成可读性最高的代码。

by 委托语法需要以下导入:

import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue

虽然 remember 可帮助我们在重组后保持状态,但不会帮助我们在配置更改后保持状态,例如横竖屏切换等。为此,您必须使用 rememberSaveablerememberSaveable 会自动保存可保存在 Bundle 中的任何值。对于其他值,您可以将其传入自定义 Saver 对象

注意:在 Compose 中将可变对象(如 ArrayList<T>mutableListOf())用作状态会导致用户在应用中看到不正确或过时的数据。不可观察的可变对象(如 ArrayList 或可变数据类)不能被 Compose 观察,且在发生变化后不会触发重组。建议您使用可观察的数据存储器(如 State<List<T>>)和不可变的 listOf(),而不是使用不可观察的可变对象。

其他受支持的状态类型

Compose 并不要求我们使用 MutableState<T> 存储状态;它支持其他可观察类型。在 Compose 中读取其他可观察类型之前,必须将其转换为 State<T>,以便可组合项可以在状态发生变化后自动重组。

Compose 附带一些可以根据 Android 应用中使用的常见可观察类型创建 State 的函数。在使用这些集成之前,请先添加适当的工件,如下所述:

  • FlowcollectAsStateWithLifecycle()

    collectAsStateWithLifecycle() 会以生命周期感知型方式从 Flow 收集值,以便应用能够节省应用资源。它表示 Compose State 中最新发出的值。请将此 API 作为在 Android 应用中收集数据流的建议方法。

    注意:您可以阅读这篇博文,详细了解如何使用 collectAsStateWithLifecycle() API 在 Android 中安全地收集数据流。build.gradle 文件中需要以下依赖项(应为 2.6.0-beta01 或更高版本):

    dependencies {
          ...
          implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
    }
    
  • FlowcollectAsState()

    collectAsStatecollectAsStateWithLifecycle 类似,因为它也会从 Flow 收集值并将其转换为 Compose State

    请为平台通用代码使用 collectAsState,而不要使用仅适用于 Android 的 collectAsStateWithLifecycle

    collectAsState 可在 compose-runtime 中使用,因此不需要其他依赖项。

  • LiveData: observeAsState()

    observeAsState() 会开始观察此 LiveData,并通过 State 表示其值。

    dependencies {
          ...
          implementation("androidx.compose.runtime:runtime-livedata:1.8.1")
    }
    
  • RxJava3: subscribeAsState()

    subscribeAsState() 是扩展函数,可将 RxJava3 的响应式流(例如 SingleObservableCompletable)转换成 Compose State

    build.gradle 文件中需要以下依赖项

    dependencies {
          ...
          implementation("androidx.compose.runtime:runtime-rxjava3:1.8.1")
    }
    

    要点:Compose 通过读取 State 对象自动重组。如果您在 Compose 中使用 LiveData 等其他可观察类型,则应在读取该类型前,先将其转换为 State。请务必在可组合项中转换类型,并且使用 LiveData<T>.observeAsState() 等可组合扩展函数。

注意:可使用的集成不限于上述几种。您可以为 Jetpack Compose 构建扩展函数,以便其读取其他可观察类型。如果您的应用使用的是自定义可观察类,请使用 produceState API 对其进行转换,以生成 State<T>

如需查看具体操作方法的示例,请参阅内置函数的实现:collectAsStateWithLifecycle。任何允许 Jetpack Compose 订阅每项更改的对象都可以转换为 State<T> 并由可组合项读取。

有状态与无状态

使用 remember 存储对象的可组合项会创建内部状态,使该可组合项有状态。在调用方不需要控制状态,并且不必自行管理状态便可使用状态的情况下,“有状态”会非常有用。但是,具有内部状态的可组合项往往不易重复使用,也更难测试。

无状态可组合项是指不保持任何状态的可组合项。实现无状态的一种简单方法是使用状态提升

状态提升

Compose 中的状态提升,是一种将状态移至可组合项的调用方,使可组合项变成无状态的模式。Jetpack Compose 中的常规状态提升模式是将状态变量替换为两个参数:

  • value: T:要显示的当前值
  • onValueChange: (T) -> Unit:请求更改值的事件,其中 T 是建议的新值

不过,并不局限于 onValueChange。如果更具体的事件适合可组合项,您应使用 lambda 定义这些事件。

以这种方式提升的状态具有一些重要的属性:

  • 单一可信来源:通过移动状态,而不是复制状态,我们可确保只有一个可信来源。这有助于避免 bug。
  • 封装:只有有状态可组合项能够修改其状态。完全是在内部操作。
  • 可共享:可与多个可组合项共享提升的状态。如果您想在另一个可组合项中读取 name,可以通过变量提升来做到这一点。
  • 可拦截:无状态可组合项的调用方可以在更改状态之前决定忽略或修改事件。
  • 解耦:无状态可组合项的状态可以存储在任何位置。例如,现在可以将 name 移入 ViewModel

要点:提升状态时,有三条规则可帮助您弄清楚状态应去向何处:

  1. 状态应至少提升到使用该状态(读取)的所有可组合项的最低共同父项
  2. 状态应至少提升到它可以发生变化(写入)的最高级别
  3. 如果两种状态发生变化以响应相同的事件,它们应一起提升

我们可以将状态提升到高于这些规则要求的级别,但提升状态会使遵循单向数据流变得困难或不可能。

在 Compose 中恢复状态

rememberSaveable API 的行为方式与 remember 类似,因为它采用保存的实例状态机制,可确保在重组后保留状态,以及在重新创建 activity 或进程之后保留状态。例如,当屏幕旋转时,就会发生这种情况。

注意: 如果 activity 被用户完全关闭,rememberSaveable 将不会保留状态。例如,如果用户从最近用过的屏幕向上滑动当前 activity(杀掉进程),系统不会保留状态。

存储状态的方式

添加到 Bundle 的所有数据类型都会自动保存。如果要保存无法添加到 Bundle 的内容,您有以下几种选择。

Parcelize

最简单的解决方案是向对象添加 @Parcelize 注解。对象将变为可打包状态并且可以捆绑。例如,以下代码会创建可打包的 City 数据类型并将其保存到状态。

@Parcelize
data class City(val name: String, val country: String) : Parcelable

@Composable
fun CityScreen() {
    var selectedCity = rememberSaveable {
        mutableStateOf(City("Madrid", "Spain"))
    }
}
MapSaver

如果某种原因导致 @Parcelize 不合适,您可以使用 mapSaver 定义自己的规则,规定如何将对象转换为系统可保存到 Bundle 的一组值。

data class City(val name: String, val country: String)

val CitySaver = run {
    val nameKey = "Name"
    val countryKey = "Country"
    mapSaver(
        save = { mapOf(nameKey to it.name, countryKey to it.country) },
        restore = { City(it[nameKey] as String, it[countryKey] as String) }
    )
}

@Composable
fun CityScreen() {
    var selectedCity = rememberSaveable(stateSaver = CitySaver) {
        mutableStateOf(City("Madrid", "Spain"))
    }
}
ListSaver

为了避免需要为映射定义key,也可以使用 listSaver 并将其索引用作key:

data class City(val name: String, val country: String)

val CitySaver = listSaver<City, Any>(
    save = { listOf(it.name, it.country) },
    restore = { City(it[0] as String, it[1] as String) }
)

@Composable
fun CityScreen() {
    var selectedCity = rememberSaveable(stateSaver = CitySaver) {
        mutableStateOf(City("Madrid", "Spain"))
    }
}

在键发生变化时重新触发 remember 计算

rememberAPI 经常与 MutableState结合使用:

var name by remember { mutableStateOf("") }

因此,使用 remember 函数可使 MutableState 值在重组后继续有效。

通常,remember 接受 calculation lambda 参数。remember 会在首次运行时调用 calculation lambda 并存储其结果。在重组期间,remember 会返回上次存储的值。

除了缓存状态之外,您还可以使用 remember 将初始化或计算成本高昂的对象或操作结果存储在组合中。因此,您可能不会在每次重组时都重复进行这种计算。例如,创建以下这个 ShaderBrush 对象就是一项非常耗费资源的操作:

val brush = remember {
    ShaderBrush(
        BitmapShader(
            ImageBitmap.imageResource(res, avatarRes).asAndroidBitmap(),
            Shader.TileMode.REPEAT,
            Shader.TileMode.REPEAT
        )
    )
}

重组后使用键存储状态

rememberSaveableAPI 是 remember 的封装容器,可在 Bundle 中存储数据。此 API 不仅能让状态在重组后保留下来,还能让状态在重新创建 activity 和系统发起的进程终止后继续留存。rememberSaveable 接收 input 参数的目的与 remember 接收 keys 的目的相同。只要输入发生更改,缓存就会失效。下次函数重组时,rememberSaveable 会对 lambda 块重新执行计算。

注意:您应留意 API 命名方式的不同。在 remember API 中,您是使用参数名称 keys;而在 rememberSaveable 中,则是将 inputs 用于相同目的。如果其中有任何参数发生变化,缓存的值便会失效。

在下面的示例中,rememberSaveable 会存储 userTypedQuery,直到 typedQuery 发生变化:

var userTypedQuery by rememberSaveable(typedQuery, stateSaver = TextFieldValue.Saver) {
    mutableStateOf(
        TextFieldValue(text = typedQuery, selection = TextRange(typedQuery.length))
    )
}

Compose 中的状态容器

您可以通过可组合函数本身管理简单的状态提升。但是,如果要跟踪的状态数增加,或者可组合函数中出现要执行的逻辑,最好将逻辑和状态事务委派给其他类(状态容器)。

关键术语状态容器用于管理可组合项的逻辑和状态。

请注意,在其他资料中,状态容器也称为“提升的状态对象”。

单向数据流 (UDF) 可作为为界面层提供和管理界面状态的方式,界面层指南介绍了这种方式。

单向数据流

状态容器及其责任

状态容器的责任是存储状态,以便应用读取状态。 在需要逻辑时,它会充当中介,并提供对托管所需逻辑的数据源的访问权限。这样,状态容器就会将逻辑委托给相应的数据源。

这会带来以下好处:

  • 简单的界面:界面仅绑定了其状态。
  • 可维护性:可以对状态容器中定义的逻辑进行迭代,而无需更改界面本身。
  • 可测试性:界面及其状态生成逻辑可独立进行测试。
  • 可读性:代码读者可以清楚地看出界面呈现代码与界面状态生成代码之间的差异。

无论大小或作用域如何,每个界面元素都与其对应的状态容器具有 1 对 1 关系。此外,状态容器必须能够接受和处理任何可能导致界面状态发生变化的用户操作,并且必须生成随后的状态变化。

注意:状态容器并非绝对必要。简单的界面可能会托管内嵌到其呈现代码中的逻辑。

状态容器的类型

与界面状态和逻辑的类型类似,界面层中有两种类型的状态容器,它们根据自身与界面生命周期的关系而定义:

  • 业务逻辑状态容器。
  • 界面逻辑状态容器。

注意:如果界面逻辑状态容器依赖于数据层或网域层中的信息,应从业务逻辑状态容器向界面逻辑状态容器传递该信息。这是因为与界面逻辑状态容器相比,业务逻辑状态容器的存在期更长,原因是后者不依赖于界面生命周期。

业务逻辑及其状态容器

业务逻辑状态容器会处理用户事件,并将数据从数据层或网域层转换为屏幕界面状态。在考虑 Android 生命周期和应用配置更改时,为了提供最佳用户体验,利用业务逻辑的状态容器应具有以下属性:

属性 详细信息
生成界面状态 业务逻辑状态容器负责为其界面提供界面状态。此界面状态通常是处理用户事件以及从网域层和数据层读取数据的结果。
在 activity 重新创建后保留下来 业务逻辑状态容器会在 Activity 重新创建后保留其状态和状态处理流水线,从而帮助提供无缝的用户体验。如果会重新创建(通常是在进程终止后)状态容器,但无法保留其状态,则状态容器必须能够轻松地重新创建最近一个状态,以确保一致的用户体验。
具有长期存在的状态 业务逻辑状态容器通常用于管理导航目的地的状态。因此,它们往往会在导航发生变化时保留其状态,直到从导航图中移除它们为止。
对界面来说独一无二,且不可重复使用 业务逻辑状态容器通常会针对某个应用函数(如 TaskEditViewModelTaskListViewModel)生成状态,因此仅适用于该应用函数。同一状态容器可以支持在不同外形规格的设备上使用这些应用函数。例如,应用的手机版本、电视版本和平板电脑版本都可以重复使用同一个业务逻辑状态容器。

注意:业务逻辑状态容器通常使用 ViewModel 实例来实现,因为 ViewModel 实例支持上述很多功能,尤其是在 Activity 重新创建后仍然有效。

警告:请勿将 ViewModel 实例向下传递到其他可组合函数。这样做会导致可组合函数与 ViewModel 类型形成耦合,从而降低其可重用性,而且会更难以测试和预览。此外,没有明确的单一数据源 (SSOT) 可以管理 ViewModel 实例。向下传递 ViewModel 可允许多个可组合项调用 ViewModel 函数并修改其状态,从而导致更难以调试 bug。作为替代方案,请遵循 UDF 最佳实践并仅向下传递必要的状态。同样,请向上传递传播事件,直到它们到达 ViewModel 的可组合 SSOT。也就是 用于处理事件并调用相应 ViewModel 方法的 SSOT。

将 ViewModel 用作业务逻辑状态容器

ViewModel 在 Android 开发中的优势使其适用于提供对业务逻辑的访问权限以及准备要在屏幕上呈现的应用数据。这些优势包括如下各项:

  • ViewModel 触发的操作在配置发生变化后仍然有效。
  • 与Navigation集成:
    • 当屏幕位于返回堆栈中时,Navigation 会缓存 ViewModel。这对在返回目标位置时即时提供之前加载的数据非常重要。使用遵循可组合项屏幕的生命周期的状态容器时,这种情况会更难处理。
    • 当目标位置从返回堆栈弹出后,ViewModel 也会被一并清除,以确保自动清理状态。这不同于监听可组合项的处理,监听的原因可能有多种,例如转到新屏幕、配置发生变化等。
  • 与其他 Jetpack 库(如 Hilt)集成。

注意:如果 ViewModel 的优势不适用于我们的用例,或我们以其他方式执行操作,则可以将 ViewModel 的责任转移到对普通状态容器类中。

界面逻辑及其状态容器

界面逻辑是对界面本身提供的数据执行操作的逻辑。它可能依赖于界面元素的状态或界面数据源(如权限 API 或 Resources)。利用界面逻辑的状态容器通常具有以下属性:

  • 生成界面状态并管理界面元素状态
  • Activity 重新创建后不再有效:托管在界面逻辑中的状态容器通常依赖于界面本身的数据源,并且在很多情况下,尝试在配置发生变化后保留此信息会导致内存泄漏。如果状态容器需要数据在配置发生变化后保持不变,则需要将其委托给更适合在 Activity 重新创建后继续留存的其他组件。例如,在 Jetpack Compose 中,使用 remembered 函数创建的可组合界面元素状态通常会委托给 rememberSaveable,以便在 Activity 重新创建后保留状态。此类函数的示例包括 rememberScaffoldState()rememberLazyListState()
  • 引用了界面范围的数据源:生命周期 API 和资源等数据源可以安全地引用和读取,因为界面逻辑状态容器与界面具有相同的生命周期。
  • 可在多个不同的界面中重复使用:同一界面逻辑状态容器的不同实例可以在应用的不同部分中重复使用。例如,用于管理条状标签组的用户输入事件的状态容器可用在过滤条件块的搜索页上,也可以用于表示电子邮件接收者的“收件人”字段。

界面逻辑状态容器通常使用普通类实现。这是因为界面本身负责创建界面逻辑状态容器,而界面逻辑状态容器与界面本身具有相同的生命周期。例如,在 Jetpack Compose 中,状态容器是组合的一部分,并遵循组合的生命周期。

注意:当界面逻辑足够复杂,可以移出界面时,会使用普通类状态容器。否则,界面逻辑可以在界面中以内嵌方式实现。

建议为可重用的界面部分(如搜索栏或条状标签组)使用普通状态容器类。在这种情况下,您不应使用 ViewModel,因为 ViewModel 最适合用于管理导航目的地的状态和对业务逻辑的访问权限。

为状态容器选择 ViewModel 和普通类

在上面几部分中,选择 ViewModel 还是普通类状态容器取决于对界面状态应用的逻辑以及执行该逻辑的数据源。

注意:大多数应用会选择执行内嵌在界面本身中的界面逻辑,而这些逻辑原本可以放在普通类状态容器中。这适用于简单的情况,但在其他情况下,您可以通过将逻辑拉取到普通类状态容器中来提高可读性。

总而言之,下图显示了状态容器在界面状态生成流水线中的位置:

界面状态生成流水线中的状态容器。箭头表示数据流。

最终,我们应根据离使用界面状态的位置最近的状态容器生成界面状态。一项不太正式的原则是,在尽可能低的位置存储状态,同时保留适当的所有权。如果我们需要使用业务逻辑并且希望只要导航到某个屏幕,界面状态就会持续存在(即使是在 Activity 重新创建后),那么 ViewModel 是实现业务逻辑状态容器的合适之选。对于存在时间较短的界面状态和界面逻辑,使用生命周期仅依赖于界面的普通类应该就足够了。

状态容器可组合

状态容器可以依赖于另一个状态容器,前提是依赖项的生命周期与状态容器相同或更短。示例如下:

  • 界面逻辑状态容器可以依赖于另一个界面逻辑状态容器。
  • 屏幕级状态容器可以依赖于界面逻辑状态容器。

以下代码段展示了 Compose 的 DrawerState 如何依赖于另一个内部状态容器,即 SwipeableState;还展示了应用的界面逻辑状态容器可以如何依赖于 DrawerState

@Stable
class DrawerState(/* ... */) {
  internal val swipeableState = SwipeableState(/* ... */)
  // ...
}

@Stable
class MyAppState(
  private val drawerState: DrawerState,
  private val navController: NavHostController
) { /* ... */ }

@Composable
fun rememberMyAppState(
  drawerState: DrawerState = rememberDrawerState(DrawerValue.Closed),
  navController: NavHostController = rememberNavController()
): MyAppState = remember(drawerState, navController) {
  MyAppState(drawerState, navController)
}

如果界面逻辑状态容器依赖于屏幕级状态容器,那么依赖项的生命周期就比状态容器更长。这会降低生命周期较短的状态容器的可重用性,并使其能够访问超出实际需要的逻辑和状态。

如果生命周期较短的状态容器需要来自较高层级范围的状态容器的某些信息,请仅将它需要的信息作为参数传递,而不是传递状态容器实例。例如,在以下代码段中,界面逻辑状态容器类仅从 ViewModel 接收所需信息,而不是将整个 ViewModel 实例作为依赖项传递。

class MyScreenViewModel(/* ... */) {
  val uiState: StateFlow<MyScreenUiState> = /* ... */
  fun doSomething() { /* ... */ }
  fun doAnotherThing() { /* ... */ }
  // ...
}

@Stable
class MyScreenState(
  // DO NOT pass a ViewModel instance to a plain state holder class
  // private val viewModel: MyScreenViewModel,

  // Instead, pass only what it needs as a dependency
  private val someState: StateFlow<SomeState>,
  private val doSomething: () -> Unit,

  // Other UI-scoped types
  private val scaffoldState: ScaffoldState
) {
  /* ... */
}

@Composable
fun rememberMyScreenState(
  someState: StateFlow<SomeState>,
  doSomething: () -> Unit,
  scaffoldState: ScaffoldState = rememberScaffoldState()
): MyScreenState = remember(someState, doSomething, scaffoldState) {
  MyScreenState(someState, doSomething, scaffoldState)
}

@Composable
fun MyScreen(
  modifier: Modifier = Modifier,
  viewModel: MyScreenViewModel = viewModel(),
  state: MyScreenState = rememberMyScreenState(
    someState = viewModel.uiState.map { it.toSomeState() },
    doSomething = viewModel::doSomething
  ),
  // ...
) {
  /* ... */
}

下图显示了上一个代码段中界面与不同状态容器之间的依赖关系:

依赖于不同状态容器的界面。箭头表示依赖关系。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容