Jetpack Compose Codelab

Codelab

Jetpack Compose 主题设置

一个 Material 主题由颜色排版形状属性组成。如果您自定义这些属性,相应设置会自动反映在您用来构建应用的组件中

Material 主题设置
定义主题
  • 在 Jetpack Compose 中实现主题设置的核心元素是 MaterialTheme 可组合项。如果将此可组合项放在 Compose 层次结构中,您就可以为其中的所有组件指定颜色、字体和形状的自定义设置
  1. 创建主题

    @Composable
    fun JetnewsTheme(content: @Composable () -> Unit) {
      MaterialTheme(content = content)
    }
    
  2. 颜色

    • ompose 中的颜色是使用 Color 类定义的。借助多个构造函数,您可以将颜色指定为 ULong,也可以按单独的颜色通道来指定颜色

      val Red700 = Color(0xffdd0d3c)
      val Red800 = Color(0xffd00036)
      val Red900 = Color(0xffc20029)
      

      注意:若要从用于指定颜色的常用“#dd0d3c”格式进行转换,请将“#”替换为“0xff”,即 Color(0xffdd0d3c),其中前两位表示透明度,“ff”表示完整的 Alpha 值,即不透明

      注意:在定义颜色时,我们要根据颜色值“照字面意义”命名颜色,而不要“从语义上”命名颜色。例如,命名为 Red500 而不是 primary。这样一来,我们就可以定义多个主题。例如,在深色主题中或样式设置不同的屏幕上,系统可能会将另一种颜色视为 primary

    • 使用 [lightColors](https://developer.android.google.cn/reference/kotlin/androidx/compose/material/package-summary#lightColors(androidx.compose.ui.graphics.Color, androidx.compose.ui.graphics.Color, androidx.compose.ui.graphics.Color, androidx.compose.ui.graphics.Color, androidx.compose.ui.graphics.Color, androidx.compose.ui.graphics.Color, androidx.compose.ui.graphics.Color, androidx.compose.ui.graphics.Color, androidx.compose.ui.graphics.Color, androidx.compose.ui.graphics.Color, androidx.compose.ui.graphics.Color, androidx.compose.ui.graphics.Color)) 函数来构建 Colors,这样即可提供合理的默认值,让我们不必将构成 Material 调色板的所有颜色全都指定出来

      private val LightColors = lightColors(
          primary = Red700,
          primaryVariant = Red900,
          onPrimary = Color.White,
          secondary = Red700,
          secondaryVariant = Red900,
          onSecondary = Color.White,
          error = Red800
      )
      
  3. 排版

    • Compose 目前不支持 Android 的可下载字体功能

    • 利用 FontFamily(结合了每个 Font 的不同粗细)定义字体

      private val Montserrat = FontFamily(
          Font(R.font.montserrat_regular),
          Font(R.font.montserrat_medium, FontWeight.W500),
          Font(R.font.montserrat_semibold, FontWeight.W600)
      )
      
    • 利用Typography定义排版

      val JetnewsTypography = Typography(
          h4 = TextStyle(
              fontFamily = Montserrat,
              fontWeight = FontWeight.W600,
              fontSize = 30.sp
          ),
          subtitle1 = TextStyle(
              fontFamily = Montserrat,
              fontWeight = FontWeight.W600,
              fontSize = 16.sp
          ),
          ...
      )
      
  4. 形状

    • Compose 提供了 RoundedCornerShape 类和 CutCornerShape 类,可用于定义形状主题

      val JetnewsShapes = Shapes(
          small = CutCornerShape(topStart = 8.dp),
          medium = CutCornerShape(topStart = 24.dp),
          large = RoundedCornerShape(8.dp)
      )
      
  5. 深色主题

    • Material 提供了关于如何创建深色主题的设计指南
    1. 添加颜色

      val Red200 = Color(0xfff297a2)
      val Red300 = Color(0xffea6d7e)
      
    2. 定义颜色集合

      private val DarkColors = darkColors(
          primary = Red300,
          primaryVariant = Red700,
          onPrimary = Color.Black,
          secondary = Red300,
          onSecondary = Color.Black,
          error = Red200
      )
      
    3. 定义(或更新)主题

      @Composable
      fun JetnewsTheme(
        // 默认设为查询设备的全局设置,但也可以通过传参修改为特定值
        darkTheme: Boolean = isSystemInDarkTheme(),
        content: @Composable () -> Unit
      ) {
        MaterialTheme(
          colors = if (darkTheme) DarkColors else LightColors,
          typography = JetnewsTypography,
          shapes = JetnewsShapes,
          content = content
        )
      }
      

    一般深色主题只需要修改颜色即可,排版和形状不变

处理颜色
  • 对于自定义主题,所有 Material 组件开箱即可使用这些自定义功能。例如,FloatingActionButton 可组合项默认使用主题中的 secondary 颜色

    @Composable
    fun FloatingActionButton(
      backgroundColor: Color = MaterialTheme.colors.secondary,
      ...
    ) {
    
  • 原色

    • 在静态声明颜色定义时,请务必小心,因为这些定义会导致更难/无法支持不同的主题(例如,浅色/深色主题)

      Surface(color = Color.LightGray) {
        Text(
          text = "Hard coded colors don't respond to theme changes :(",
          // 硬编码,不会随主题而改变
          textColor = Color(0xffff00ff)
        )
      }
      
  • 主题颜色

    • 一种更灵活的方法是从主题中检索颜色,其 colors 属性会返回在 MaterialTheme 可组合项中设置的 Colors

      Surface(color = MaterialTheme.colors.primary)
      
    • 由于主题中的每种颜色都是 Color 实例,因此我们还可以使用 copy 方法轻松地“派生”颜色

      // alpha 不透明度
      val derivedColor = MaterialTheme.colors.onSurface.copy(alpha = 0.1f)
      
  • Surface 颜色和内容颜色

    • 许多组件都接受一对颜色和“内容颜色(为包含在其中的可组合项提供默认颜色)“

      Surface(
        color: Color = MaterialTheme.colors.surface,
        contentColor: Color = contentColorFor(color),
        ...
      
      TopAppBar(
        backgroundColor: Color = MaterialTheme.colors.primarySurface,
        contentColor: Color = contentColorFor(backgroundColor),
        ...
      

      contentColorFor 方法可以为任何主题颜色检索适当的“on”颜色,例如,如果您设置 primary 背景,它就会返回 onPrimary 作为内容颜色。如果您设置非主题背景颜色,则应自行提供合理的内容颜色

    • 可以使用 LocalContentColor 来检索与当前背景形成对比的颜色

      BottomNavigationItem(
        selectedContentColor = LocalContentColor.current ...
      
    • 当设置任何元素的颜色时,最好使用 Surface 来实现此目的,因为它会设置适当的内容颜色 CompositionLocal 值。请慎用直接 Modifier.background 调用,这种调用不会设置适当的内容颜色

      -Row(Modifier.background(MaterialTheme.colors.primary)) {
      +Surface(color = MaterialTheme.colors.primary) {
      +  Row(
      ...
      
  • 内容 Alpha 值

    • 通常情况下,我们希望通过强调或弱化内容来突出重点并体现出视觉上的层次感。Material Design 建议采用不同的不透明度来传达这些不同的重要程度

    • Jetpack Compose 通过 LocalContentAlpha 实现此功能。您可以通过为此 CompositionLocal 提供一个值来为层次结构指定内容 Alpha 值,子可组合项可以使用此值。Material 指定了一些标准 Alpha 值(highmediumdisabled),这些值由 ContentAlpha 对象建模。请注意,MaterialTheme 默认将 LocalContentAlpha 设置为 ContentAlpha.high

      // By default, both Icon & Text use the combination of LocalContentColor &
      // LocalContentAlpha. De-emphasize content by setting a different content alpha
      CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
          Text(...)
      }
      CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.disabled) {
          Icon(...)
          Text(...)
      }
      
  • 深色主题

    • 检查是否在浅色主题中运行

      // 此值由 lightColors/darkColors 构建器函数设置
      val isLightTheme = MaterialTheme.colors.isLight
      
    • 在 Material 中,如果采用的是深色主题,则高度较高的 Surface 会获得高度叠加层(其背景颜色会变浅)。在使用深色调色板时,系统会自动实现此效果

      Surface(
        elevation = 2.dp,
        color = MaterialTheme.colors.surface, // color will be adjusted for elevation
        ...
      

      默认情况下, TopAppBarCard 的高度分别设为 4dp 和 1dp,因此,在深色主题中,它们的背景颜色会自动变浅,以更好地表现相应高度

    • Material Design 建议避免在深色主题中使用大面积的明亮颜色。一种常见模式是在浅色主题中将容器设为 primary 颜色,并在深色主题中将其设为 surface 颜色;许多组件都默认使用此策略,例如应用栏底部导航栏。为了便于实现,Colors 提供了 primarySurface 颜色,以准确完成上述行为,并且这些组件都默认使用此颜色

处理文本
  • 组件本身往往不会显示文本,而是提供槽 API,让您能够传入 Text 可组合项。那么,组件是如何设置主题排版样式的呢?在后台,它们使用 ProvideTextStyle 可组合项(本身就使用 CompositionLocal)来设置“current”TextStyle。如果您未提供具体的 textStyle 参数,Text 可组合项会默认查询此“current”样式

    @Composable
    fun Button(
        // many other parameters
        content: @Composable RowScope.() -> Unit
    ) {
      ...
      ProvideTextStyle(MaterialTheme.typography.button) { //set the "current" text style
        ...
        content()
      }
    }
    
    @Composable
    fun Text(
        // many, many parameters
        style: TextStyle = LocalTextStyle.current // get the value set by ProvideTextStyle
    ) { ...
    
  • 主题文本样式

    • MaterialTheme.typography 会检索在 MaterialTheme 可组合项中设置的 Typography 实例,让您能够使用自己定义的样式

    • 如果您需要自定义 TextStyle,可以对其执行 copy 操作并替换相关属性(它只是一个 data class),或者让 Text 可组合项接受大量样式参数,这些参数会叠加到任何 TextStyle 的上层

      Text(
        text = "Hello World",
        style = MaterialTheme.typography.body1.copy(
          background = MaterialTheme.colors.secondary
        )
      )
      
      Text(
        text = "Hello World",
        style = MaterialTheme.typography.subtitle2,
        fontSize = 22.sp // explicit size overrides the size in the style
      )
      

      TopAppBar 将其 title 的样式设为 h6,而 ListItem 将其主要文本和辅助文本的样式分别设为 subtitle1body2

  • 多种样式

    • 如果需要对某些文本应用多种样式,可以使用 AnnotatedString 类来应用标记,从而为一系列文本添加 SpanStyle。您可以动态添加这些元素,也可以使用 DSL 语法来创建内容

      val text = buildAnnotatedString {
        append("This is some unstyled text\n")
        withStyle(SpanStyle(color = Color.Red)) {
          append("Red text\n")
        }
        withStyle(SpanStyle(fontSize = 24.sp)) {
          append("Large text")
        }
      }
      

      可以使用MaterialTheme.typography.xxx.toSpanStyle().copy()定义与主题类似的SpanStyle

处理形状
  • 与颜色一样,Material 组件使用默认参数,因此您可以直接查看组件将要使用的形状类别,或提供替代方案。如需查看组件和形状类别的完整对应关系,请参阅此文档

    @Composable
    fun Button( ...
      shape: Shape = MaterialTheme.shapes.small
    ) {
    

    请注意,有些组件会使用经过修改的主题形状,以适应其上下文的要求,例如TextField

  • 创建自己的组件时,可以自行使用各种形状;为此,需要使用接受形状的可组合项或 Modifier(例如,SurfaceModifier.clipModifier.backgroundModifier.border 等)

组件“样式”
  • 所有组件都是由较低级别的构建块构造而成的,可以参考各种组件的源码使用同样的构建块来自定义 Material 组件库

Jetpack Compose 动画

为简单的值变化添加动画效果
  • animate*AsState API

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-PYENmI3x-1668352183140)(https://api.onedrive.com/v1.0/shares/s!AoF7mN_tKB-G0B_4ymnWAKSURzoi/root/content)]

  • 举例

    val backgroundColor = if (tabPage == TabPage.Home) Purple100 else Green300
    

    其中,tabPage 是由 State 对象支持的一项 Int。背景颜色可以在紫色和绿色之间切换,具体取决于其值。如需为诸如此类的简单值变化添加动画效果,我们可以使用 animate*AsState API。使用 animate*AsState 可组合项的相应变体(在本例中为 animateColorAsState封装更改值,即可创建动画值。返回的值是 State<T> 对象,因此我们可以使用包含 by 声明的本地委托属性,以将该值视为普通变量

    val backgroundColor by animateColorAsState(if (tabPage == TabPage.Home) Purple100 else Green300)
    
    不同标签页之间的颜色变化动画效果
为可见性添加动画效果
  • 为可见性变化添加动画效果使用 AnimatedVisibility 可组合项,每次指定的 Boolean 值发生变化时,AnimatedVisibility 会运行其动画。默认情况下,AnimatedVisibility 会以淡入和展开的方式显示元素,以淡出和缩小的方式隐藏元素,也可以自定义行为

    AnimatedVisibility(extended) {
        Text(
            text = stringResource(R.string.edit),
            modifier = Modifier
                .padding(start = 8.dp, top = 3.dp)
        )
    }
    
    悬浮操作 Edit 按钮动画效果
  • 使用 AnimatedVisibility 为进入和消失添加动画效果,可以使用 slideInVertically 函数为进入过渡创建 EnterTransition ,使用 slideOutVertically 函数为退出过渡创建 ExitTransitionslideInVerticallyslideOutVertically 的默认行为只使用项高度的一半,也就是说只有一半有动画效果

    AnimatedVisibility(
        visible = shown,
        enter = slideInVertically(),
        exit = slideOutVertically()
    )
    
    在中途垂直滑出
  • 对于进入过渡:我们可以通过设置 initialOffsetY 参数来调整默认行为,以便使用项的完整高度来正确添加动画效果。initialOffsetY 应该是返回初始位置的 lambda。

    lambda 会收到一个表示元素高度的参数。为确保项从屏幕顶部滑入,我们会返回其负值,因为屏幕顶部的值为 0。我们希望动画从 -height 开始到 0(其最终静止位置),以便其从屏幕上方开始以动画形式滑入。

    使用 slideInVertically 时,滑入后的目标偏移量始终为 0(像素)。可使用 lambda 函数将 initialOffsetY 指定为绝对值或元素全高度的百分比。

    同样,slideOutVertically 假定初始偏移量为 0,因此只需指定 targetOffsetY

    AnimatedVisibility(
        visible = shown,
        enter = slideInVertically(
            // Enters by sliding down from offset -fullHeight to 0.
            initialOffsetY = { fullHeight -> -fullHeight }
        ),
        exit = slideOutVertically(
            // Exits by sliding up from offset 0 to -fullHeight.
            targetOffsetY = { fullHeight -> -fullHeight }
        )
    )
    
    使用偏移量的滑入动画效果
  • 可以使用 animationSpec 参数进一步自定义动画效果。animationSpec 是包括 EnterTransitionExitTransition在内的许多动画 API 的通用参数。可以传递各种 AnimationSpec 类型中的一种,以指定动画值应如何随时间变化

    AnimatedVisibility(
        visible = shown,
        enter = slideInVertically(
            // Enters by sliding down from offset -fullHeight to 0.
            initialOffsetY = { fullHeight -> -fullHeight },
            animationSpec = tween(durationMillis = 150, easing = LinearOutSlowInEasing)
        ),
        exit = slideOutVertically(
            // Exits by sliding up from offset 0 to -fullHeight.
            targetOffsetY = { fullHeight -> -fullHeight },
            animationSpec = tween(durationMillis = 250, easing = FastOutLinearInEasing)
        )
    )
    

    在本示例中,我们使用基于时长的简单 AnimationSpec。它可以使用 tween 函数创建。时长为 150 毫秒,加/减速选项为 LinearOutSlowInEasing。对于退出动画,我们为 animationSpec 参数使用相同的 tween 函数,但时长为 250 毫秒,加/减速选项为 FastOutLinearInEasing

    “Edit feature is not supported”消息从顶部滑入的动画效果
为内容大小变化添加动画效果
  • 可以添加 animateContentSize 修饰符,为大小变化添加动画效果,也可以使用自定义 animationSpec 来自定义 animateContentSize

    Column(
        modifier = Modifier
            .fillMaxWidth()
            .padding(16.dp)
            .animateContentSize()
    ) {
        // ... the title and the body
    }
    
    主题列表展开和缩小动画效果
为多个值添加动画效果
  • Transition API

借助该 API,可以制作更复杂的动画。可以使用 Transition API 跟踪 Transition 上的所有动画何时完成,而使用前述各个 animate*AsState API 却无法做到这一点。Transition API 还让我们能够在不同状态之间转换时定义不同的 transitionSpec

  • 如需同时为多个值添加动画效果,可使用 Transition。可使用 updateTransition 函数创建 Transition ,每个动画值都可以使用 Transitionanimate* 扩展函数进行声明

    val transition = updateTransition(tabPage, label = "Tab indicator")
    val indicatorLeft by transition.animateDp(label = "Indicator left") { page ->
       tabPositions[page.ordinal].left
    }
    val indicatorRight by transition.animateDp(label = "Indicator right") { page ->
       tabPositions[page.ordinal].right
    }
    val color by transition.animateColor(label = "Border color") { page ->
       if (page == TabPage.Home) Purple700 else Green800
    }
    

    更改 tabPage 状态的值时,与 transition 关联的所有动画值会开始以动画方式切换至为目标状态指定的值

    “Home”标签页与“Work”标签页之间的动画效果
  • 可以指定 transitionSpec 参数来自定义动画行为

    val transition = updateTransition(
        tabPage,
        label = "Tab indicator"
    )
    val indicatorLeft by transition.animateDp(
        transitionSpec = {
            if (TabPage.Home isTransitioningTo TabPage.Work) {
                // Indicator moves to the right.
                // The left edge moves slower than the right edge.
                spring(stiffness = Spring.StiffnessVeryLow)
            } else {
                // Indicator moves to the left.
                // The left edge moves faster than the right edge.
                spring(stiffness = Spring.StiffnessMedium)
            }
        },
        label = "Indicator left"
    ) { page ->
        tabPositions[page.ordinal].left
    }
    val indicatorRight by transition.animateDp(
        transitionSpec = {
            if (TabPage.Home isTransitioningTo TabPage.Work) {
                // Indicator moves to the right
                // The right edge moves faster than the left edge.
                spring(stiffness = Spring.StiffnessMedium)
            } else {
                // Indicator moves to the left.
                // The right edge moves slower than the left edge.
                spring(stiffness = Spring.StiffnessVeryLow)
            }
        },
        label = "Indicator right"
    ) { page ->
        tabPositions[page.ordinal].right
    }
    val color by transition.animateColor(
        label = "Border color"
    ) { page ->
        if (page == TabPage.Home) Purple700 else Green800
    }
    

    可以让靠近目标页面的一边比另一边移动得更快来实现指示器的弹性效果。可以在 transitionSpec lambda 中使用 isTransitioningTo infix 函数来确定状态变化的方向

    标签页切换的自定义弹性效果
  • Android Studio 支持在 Compose 预览中检查过渡效果。如需使用动画预览,请在预览中点击可组合项右上角的“Start Animation Preview”图标(9c05a5608a23b407.png 图标),以开始交互模式。如果找不到该图标,则应按照此处的说明,在实验设置中启用此功能。尝试点击 PreviewHomeTabBar 可组合项的图标。系统随即会打开一个新的“Animations”窗格。

    可以点击“Play”图标按钮来播放动画,也可以拖动拖动条来查看各个动画帧。为了更好地描述动画值,可在 updateTransitionanimate* 方法中指定 label 参数。

    Android Studio 中的跳转动画
重复呈现动画效果
  • InfiniteTransition API

此 API 与上一部分中的 Transition API 类似。两者都是为多个值添加动画效果,但 Transition 会根据状态变化为值添加动画效果,而 InfiniteTransition 则无限期地为值添加动画效果

  • 使用 rememberInfiniteTransition 函数创建 InfiniteTransition ,可以使用 InfiniteTransition 的一个 animate* 扩展函数声明每个动画值变化。还可以为此动画指定 AnimationSpec,但此 API 仅接受 InfiniteRepeatableSpec ,可以使用 infiniteRepeatable 函数创建一个,此 AnimationSpec 会封装任何基于时长的 AnimationSpec,使其可重复

    val infiniteTransition = rememberInfiniteTransition()
    val alpha by infiniteTransition.animateFloat(
        initialValue = 0f,
        targetValue = 1f,
        animationSpec = infiniteRepeatable(
            animation = keyframes {
                durationMillis = 1000
                // 多个关键帧
                0.7f at 500
                0.9f at 800
            },
            repeatMode = RepeatMode.Reverse
        )
    )
    

    repeatMode 的默认值为 RepeatMode.Restart ,这会从 initialValue 过渡为 targetValue,并再次从 initialValue 开始。将 repeatMode 设置为 RepeatMode.Reverse 后,动画会从 initialValue 播放到 targetValue,然后从 targetValue 播放到 initialValue

    keyFrames 动画是另一种类型的 animationSpec(另外还有一些是 tweenspring),可允许以不同的毫秒数来更改播放中的值。最初将 durationMillis 设置为 1000 毫秒。然后,我们可以在动画中定义关键帧,例如,在动画播放 500 毫秒时,我们希望 alpha 值为 0.7f。这会更改动画的播放进度:动画在 500 毫秒内会从 0 快速播放到 0.7,而在 500 毫秒到 1000 毫秒之间会从 0.7 慢速播放到 1.0

    实现了重复动画效果的占位符内容
手势动画
private fun Modifier.swipeToDismiss(
    onDismissed: () -> Unit
): Modifier = composed {
    // This Animatable stores the horizontal offset for the element.
    val offsetX = remember { Animatable(0f) }
    pointerInput(Unit) {
        // Used to calculate a settling position of a fling animation.
        val decay = splineBasedDecay<Float>(this)
        // Wrap in a coroutine scope to use suspend functions for touch events and animation.
        coroutineScope {
            while (true) {
                // Wait for a touch down event.
                val pointerId = awaitPointerEventScope { awaitFirstDown().id }
                // Interrupt any ongoing animation.
                offsetX.stop()
                // Prepare for drag events and record velocity of a fling.
                val velocityTracker = VelocityTracker()
                // Wait for drag events.
                awaitPointerEventScope {
                    horizontalDrag(pointerId) { change ->
                        // Record the position after offset
                        val horizontalDragOffset = offsetX.value + change.positionChange().x
                        launch {
                            // Overwrite the Animatable value while the element is dragged.
                            offsetX.snapTo(horizontalDragOffset)
                        }
                        // Record the velocity of the drag.
                        velocityTracker.addPosition(change.uptimeMillis, change.position)
                        // Consume the gesture event, not passed to external
                        change.consumePositionChange()
                    }
                }
                // Dragging finished. Calculate the velocity of the fling.
                val velocity = velocityTracker.calculateVelocity().x
                // Calculate where the element eventually settles after the fling animation.
                val targetOffsetX = decay.calculateTargetValue(offsetX.value, velocity)
                // The animation should end as soon as it reaches these bounds.
                offsetX.updateBounds(
                    lowerBound = -size.width.toFloat(),
                    upperBound = size.width.toFloat()
                )
                launch {
                    if (targetOffsetX.absoluteValue <= size.width) {
                        // Not enough velocity; Slide back to the default position.
                        offsetX.animateTo(targetValue = 0f, initialVelocity = velocity)
                    } else {
                        // Enough velocity to slide away the element to the edge.
                        offsetX.animateDecay(velocity, decay)
                        // The element was swiped away.
                        onDismissed()
                    }
                }
            }
        }
    }
        // Apply the horizontal offset to the element.
        .offset { IntOffset(offsetX.value.roundToInt(), 0) }
}

Jetpack Compose 中的状态

应用的“状态”是指可以随时间变化的任何值。这是一个非常宽泛的定义,从 Room 数据库到类的变量,全部涵盖在内。所有 Android 应用都会向用户显示状态

Compose 中的状态
  • 优秀实践是为所有可组合函数提供默认的 Modifier,从而提高可重用性。它应作为第一个可选参数显示在参数列表中,位于所有必需参数之后
  • 任何会导致状态修改的操作都称为“事件
Compose 中的事件
  • 应用的状态说明了要在界面中显示的内容,而事件则是一种机制,可在状态发生变化时导致界面发生变化

  • 所有 Android 应用都有核心界面更新循环,如下所示:

    f415ca9336d83142.png

    • 事件:由用户或程序的其他部分生成
    • 更新状态:事件处理脚本会更改界面所使用的状态
    • 显示状态:界面会更新以显示新状态
  • 警告:您可能想要在 Logcat 中添加日志来调试可组合函数,以尝试确定这些函数是否正常运行。不过请注意,在使用 Compose 时,此过程并不一定非常可靠。这有多种原因,例如,重组被舍弃,如 Compose 编程思想中所述

可组合函数中的记忆功能
  • Compose 应用通过调用可组合函数将数据转换为界面。组合是指 Compose 在执行可组合项时构建的界面描述。如果发生状态更改,Compose 会使用新状态重新执行受影响的可组合函数,从而创建更新后的界面。这一过程称为“重组”。Compose 还会查看各个可组合项需要哪些数据,以便仅重组数据发生了变化的组件,而避免重组未受影响的组件

    • 组合:Jetpack Compose 在执行可组合项时构建的界面描述

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

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

  • 使用 Compose 的 StateMutableState 类型让 Compose 能够观察到状态。Compose 会跟踪每个读取状态 value 属性的可组合项,并在其 value 更改时触发重组

  • 可以使用 mutableStateOf 函数来创建可观察的 MutableState,它接受初始值作为封装在 State 对象中的参数,这样便可使其 value 变为可观察

       Column(modifier = modifier.padding(16.dp)) {
          // Changes to count are now tracked by Compose
           val count: MutableState<Int> = mutableStateOf(0)
    
           Text("You've had ${count.value} glasses.")
            Button(onClick = { count.value++ }, Modifier.padding(top = 8.dp)) {
               Text("Add one")
           }
       }
    
  • 可以使用 remember 可组合内嵌函数,系统会在初始组合期间将由 remember 计算的值存储在组合中,并在重组期间一直保持存储的值

    可以将 remember 视为一种在组合中存储单个对象的机制,就像私有 val 属性在对象中执行的操作一样

  • remembermutableStateOf 通常在可组合函数中一起使用

        Column(modifier = modifier.padding(16.dp)) {
            val count: MutableState<Int> = remember { mutableStateOf(0) }
            Text("You've had ${count.value} glasses.")
            Button(onClick = { count.value++ }, Modifier.padding(top = 8.dp)) {
                Text("Add one")
            }
        }
    
  • 可以使用关键字 bycount 定义为 var,通过添加委托的 getter 和 setter 导入内容,可以间接读取 count 并将其设置为可变,而无需每次都显式引用 MutableStatevalue 属性

    Column(modifier = modifier.padding(16.dp)) {
           var count by remember { mutableStateOf(0) }
    
           Text("You've had $count glasses.")
           Button(onClick = { count++ }, Modifier.padding(top = 8.dp)) {
               Text("Add one")
           }
       }
    
  • 可能已经在使用其他可观察类型,例如使用 LiveDataStateFlowFlow 和 RxJava 的 Observable 在应用中存储状态。如需允许 Compose 使用此状态,并在状态发生变化时自动执行重组,您需要将其映射到 State。一些扩展函数可以实现此目的,因此请务必在 Compose 和其他库文档中查询这些函数

状态驱动型界面
  • Compose 是一个声明性界面框架。它描述界面在特定状况下的状态,而不是在状态发生变化时移除界面组件或更改其可见性。调用重组并更新界面后,可组合项最终可能会进入或退出组合。

    7d3509d136280b6c.png

    此方法可避免像针对视图系统那样手动更新视图的复杂性。这也不太容易出错,因为您不会忘记根据新状态更新视图,因为系统会自动执行此过程

  • 如果在初始组合期间或重组期间调用了可组合函数,则认为其存在于组合中。未调用的可组合函数(例如,由于该函数在 if 语句内调用且未满足条件)不存在于组合中

  • 界面是相对用户而言的,界面状态是相对应用而言的

  • Android Studio 的布局检查器工具可用于检查 Compose 生成的应用布局

    如需在检查器中查看 Compose 节点,请使用 API 大于或等于 29 的设备,并且使用的是 Compose 1.2.0-alpha03 或更高版本

  • 界面的不同部分可以依赖于相同的状态

组合中的记忆功能
  • remember 会将对象存储在组合中,而如果在重组期间未再次调用之前调用 remember 的来源位置,则会忘记对象
在 Compose 中恢复状态
  • 如果更改语言、在深色模式与浅色模式之间切换、改变屏幕方向,或者执行任何导致 Android 重新创建运行中 activity 的其他配置更改,则系统会在配置更改后重新创建 activity,因此已保存状态会被忘记

  • remember 可在重组后保持状态,但不会在配置更改后保持状态;在重新创建 activity 或进程后,可以使用 rememberSaveable 恢复界面状态,除了在重组后保持状态之外,rememberSaveable 还会在重新创建 activity 和进程之后保留状态

    rememberSaveable 会自动保存可保存在 Bundle 中的任何值,对于其他值,可以将其传入自定义 Saver 对象。如需详细了解如何在 Compose 中恢复状态,请参阅相关文档

状态提升
  • 使用 remember 存储对象的可组合项包含内部状态,这会使该可组合项有状态。在调用方不需要控制状态,并且不必自行管理状态便可使用状态的情况下,“有状态”会非常有用。但是,具有内部状态的可组合项往往不易重复使用,也更难测试。不保存任何状态的可组合项称为无状态可组合项,相应的可组合函数称为无状态函数。如需创建无状态可组合项,一种简单的方法是使用状态提升

    无状态可组合项是指不具有任何状态的可组合项,这意味着它不会存储、定义或修改新状态

    有状态可组合项是一种具有可以随时间变化的状态的可组合项

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

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

    状态下降、事件上升的这种模式称为单向数据流 (UDF),而状态提升就是我们在 Compose 中实现此架构的方式。如需了解相关详情,请参阅 Compose 架构文档

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

    • 单一可信来源:通过移动状态,而不是复制状态,我们可确保只有一个可信来源。这有助于避免 bug
    • 可共享:可与多个可组合项共享提升的状态
    • 可拦截:无状态可组合项的调用方可以在更改状态之前决定忽略或修改事件
    • 分离:无状态可组合函数的状态可以存储在任何位置。例如,存储在 ViewModel 中
  • 提升状态时,有三条规则可帮助您弄清楚状态应去向何处:

    1. 状态应至少提升到使用该状态(读取)的所有可组合项的最低共同父项
    2. 状态应至少提升到它可以发生变化(写入)的最高级别
    3. 如果两种状态发生变化以响应相同的事件,它们应提升到同一级别
  • 由于可以共享提升的状态,因此请务必仅传递可组合项所需的状态,以避免不必要的重组并提高可重用性,设计可组合项的最佳实践是仅向它们传递所需要的参数

使用列表
  • 如果在 Android Studio 的编辑器区域键入 WC,系统会打开一个建议框。如果您按下 Enter 并选择第一个选项,系统会显示可供使用的 Column 模板。如需详细了解 Android Studio 中适用于 Compose 的实时模板和其他实用工具,请参阅 Compose 工具文档

  • 当一个项退出组合时,系统会忘记之前记住的状态。对于 LazyColumn 上的项,当您滚动至项不可见的位置时,这些不可见的项会完全退出组合,使用 rememberSaveable,因为它采用保存的实例状态机制,可确保存储的值在重新创建 activity 或进程(如项退出组合)之后继续保留

  • 状态参数使用由公共 rememberX 函数提供的默认值是内置可组合函数中的常见模式,如 LazyColumnLazyRowScafflod

    @Composable
    fun LazyColumn(
    ...
        state: LazyListState = rememberLazyListState(),
    ...
    
可观察的可变列表
  • 如需添加从列表中移除任务的行为,需要创建一个可由 Compose 观察的 MutableList 实例(即可观察的可变的列表),此结构可允许 Compose 跟踪更改,以便在列表中添加或移除项时重组界面

  • 扩展函数 toMutableStateList() 用于根据初始可变或不可变的 Collection(例如 List)来创建可观察的 MutableList,或者,也可以使用工厂方法 mutableStateListOf 来创建可观察的 MutableList,然后为初始状态添加元素

    mutableStateOf 函数会返回一个类型为 MutableState<T> 的对象

    mutableStateListOftoMutableStateList 函数会返回一个类型为 SnapshotStateList 的对象,“可观察的 MutableList”一词表示此类

  • items 方法会接收一个 key 参数。默认情况下,每个项的状态均与该项在列表中的位置相对应。在可变列表中,当数据集发生变化时,这会导致问题,因为实际改变位置的项会丢失任何记住的状态,可以指定 key 参数来确保项的状态不会因数据集改变而丢失

  • 不应使用 rememberSaveable 来存储需要长时间序列化或反序列化操作的大量数据或复杂数据结构,如列表,否则会报错 cannot be saved using the current SaveableStateRegistry... ,使用 activity 的onSaveInstanceState 时,应遵循类似的规则

    请参阅保存界面状态文档

ViewModel 中的状态
  • 界面状态描述屏幕上显示的内容,而应用逻辑则描述应用的行为方式以及应如何响应状态变化。逻辑分为两种类型:第一种是界面行为或界面逻辑,第二种是业务逻辑

    • 界面逻辑涉及如何在屏幕上显示状态变化(例如导航逻辑或显示信息提示控件)
    • 业务逻辑决定如何处理状态更改(例如付款或存储用户偏好设置)。该逻辑通常位于业务层或数据层,但绝不会位于界面层
  • ViewModel 提供界面状态以及对位于应用其他层中的业务逻辑的访问。此外,ViewModel 还会在配置更改后继续保留,因此其生命周期比组合更长。ViewModel 可以遵循 Compose 内容(即 activity 或 fragment)的主机的生命周期,也可以遵循导航图的目的地的生命周期(如果使用的是 Compose Navigation 库

    ViewModel 并不是组合的一部分。因此,不应保留可组合项中创建的状态(例如,记住的值),因为这可能会导致内存泄漏

  • viewModel() 会返回一个现有的 ViewModel,或在给定作用域内创建一个新的 ViewModel。只要作用域处于活动状态,ViewModel 实例就会一直保留。例如,如果在某个 activity 中使用了可组合项,则在该 activity 完成或进程终止之前,viewModel() 会返回同一实例

    //引入依赖 implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.4.1"
    @Composable
    fun WellnessScreen(
        modifier: Modifier = Modifier,
        wellnessViewModel: WellnessViewModel = viewModel()
    ) { ... }
    
  • ViewModel 在任何情况下(例如,对于系统发起的进程终止)都不会自动保留应用的状态

    如需详细了解如何保留应用的界面状态,请参阅相关文档

  • 建议将 ViewModel 用于屏幕级可组合项,即靠近从导航图的 activity、fragment 或目的地调用的根可组合项。绝不应将 ViewModel 传递给其他可组合项,而是应当仅向它们传递所需的数据以及以参数形式执行所需逻辑的函数

    如需了解详情,请参阅 ViewModel 和状态容器部分以及 Compose 和其他库的相关文档

Jetpack Compose 中的高级状态和附带效应

  • 接入Google地图需要获取个人 API 密钥,如“地图”文档中所述,并按如下方式在 local.properties 文件中添加该密钥

    // local.properties file
    google.maps.key={insert_your_api_key_here}
    
从 ViewModel 使用流
  • 在可组合函数中使用 StateFlow.collectAsState() 函数时,collectAsState() 会从 StateFlow 收集值,并通过 Compose 的状态 API 表示最新值,这样会使读取该状态值的 Compose 代码在发出新项时重组

  • Compose 为最热门的基于数据流的 Android 解决方案提供了 API:

    • LiveData.observeAsState() 包含在 androidx.compose.runtime:runtime-livedata:$composeVersion 工件中
    • Observable.subscribeAsState() 包含在 androidx.compose.runtime:runtime-rxjava2:$composeVersionandroidx.compose.runtime:runtime-rxjava3:$composeVersion 工件中
LaunchedEffect 和 rememberUpdatedState
  • 着陆屏幕将占据整个屏幕,并在屏幕中间显示应用的徽标。理想情况下,我们会显示该屏幕,在所有数据加载完毕之后,我们会通知调用方可以使用回调关闭着陆屏幕

  • 建议使用 Kotlin 协程在 Android 中执行异步操作,Jetpack Compose 提供了可在界面层中安全使用协程的 API

  • **Compose 中的附带效应是指发生在可组合函数作用域之外的应用状态的变化。**例如,将状态更改为显示/隐藏着陆屏幕的操作将发生在 onTimeout 回调中,由于在调用 onTimeout 之前我们需要先使用协程加载内容,因此状态变化必须发生在协程的上下文中

  • 如需从可组合项内安全地调用挂起函数,请使用 LaunchedEffect API,该 API 会在 Compose 中触发协程作用域限定的附带效应。当 LaunchedEffect 进入组合时,它会启动一个协程,并将代码块作为参数传递。如果 LaunchedEffect 退出组合,协程将取消

    @Composable
    fun LandingScreen(modifier: Modifier = Modifier, onTimeout: () -> Unit) {
        Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
            // Start a side effect to load things in the background
            // and call onTimeout() when finished.
            // Passing onTimeout as a parameter to LaunchedEffect
            // is wrong! Don't do this. We'll improve this code in a sec.
            LaunchedEffect(onTimeout) {
                delay(SplashWaitTime) // Simulates loading things
                onTimeout()
            }
            Image(painterResource(id = R.drawable.ic_crane_drawer), contentDescription = null)
        }
    }
    

    错误原因:不希望在 onTimeout 发生更改时重新开始执行效应

  • 某些附带效应 API(如 LaunchedEffect)将可变数量的键作为参数,用于在其中一个键发生更改时重新开始执行效应。如需在可组合项的生命周期内仅触发一次附带效应,请将常量用作键,例如 LaunchedEffect(true) { ... }

  • 上述代码中,如果 onTimeout 在附带效应正在进行时发生更改,不能保证在效应结束时会调用最后一个 onTimeout(即更改后的onTimeOut)。如需通过捕获和更新到新值来保证这一点,请使用 rememberUpdatedState API:

    @Composable
    fun LandingScreen(modifier: Modifier = Modifier, onTimeout: () -> Unit) {
        Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
            // This will always refer to the latest onTimeout function that
            // LandingScreen was recomposed with
            val currentOnTimeout by rememberUpdatedState(onTimeout)
    
            // Create an effect that matches the lifecycle of LandingScreen.
            // If LandingScreen recomposes or onTimeout changes,
            // the delay shouldn't start again.
            LaunchedEffect(true) {
                delay(SplashWaitTime)
                currentOnTimeout()
            }
    
            Image(painterResource(id = R.drawable.ic_crane_drawer), contentDescription = null)
        }
    }
    
rememberCoroutineScope
  • DrawerState 具有以程序化方式打开和关闭抽屉式导航栏的方法,DrawerState包含在 ScaffoldState 中,可以使用 rememberScaffoldState() 获得 ScaffoldState
  • 挂起函数除了能够运行异步代码之外,还可以帮助表示随着时间的推移出现的概念。由于打开抽屉式导航栏需要一些时间和移动,而且还有可能需要动画,这可以通过挂起函数完美地反映出来,所以 DrawerStateopen()close() 均为挂起函数
  • 理想情况下,我们希望 CoroutineScope 能够遵循其调用点的生命周期。为此,请使用 rememberCoroutineScope API。一旦退出组合,作用域将自动取消。利用该作用域,不在组合中(例如,在 openDrawer 回调中)时,可以启动协程
  • LaunchedEffect 与 rememberCoroutineScope区别
    • LaunchedEffect 可以保证当对该可组合项的调用使其进入组合时将会执行附带效应
    • 使用 rememberCoroutineScopescope.launch,则 Compose 每次调用该可组合项时都会执行协程,而不管该调用是否使其进入组合
状态容器
@Composable
fun CraneEditableUserInput(
    hint: String,
    caption: String? = null,
    @DrawableRes vectorImageId: Int? = null,
    onInputChanged: (String) -> Unit
) {
    // TODO Codelab: Encapsulate this state in a state holder
    var textState by remember { mutableStateOf(hint) }
    val isHint = { textState == hint }

    ...
}

用于更新 textState 以及确定显示的内容是否对应于提示的逻辑全部都在 CraneEditableUserInput 可组合项的主体中。这就带来了一些缺点:

  • TextField 的值未提升,因而无法从外部进行控制,这使得测试更加困难
  • 此可组合项的逻辑可能会变得更加复杂,并且内部状态可能会更容易不同步

通过创建负责此可组合项的内部状态的状态容器,可以将所有状态变化集中在一个位置。这样,状态不同步就更难了,并且相关的逻辑全部归在一个类中。此外,此状态很容易向上提升,并且可以从此可组合项的调用方使用

创建状态容器
// 在同一文件中创建状态容器
class EditableUserInputState(private val hint: String, initialText: String) {

    var text by mutableStateOf(initialText)

    val isHint: Boolean
        get() = text == hint
}

该类应具有以下特征:

  • textString 类型的可变状态,就像在 CraneEditableUserInput 中一样。请务必使用 mutableStateOf,以便 Compose 跟踪值的更改,并在发生更改时重组
  • text 是一个 var,这样就可以直接从该类外部改变它
  • 该类将 initialText 作为用于初始化 text 的依赖项
  • 用于判断 text 是否为提示的逻辑在按需执行检查的 isHint 属性中
记住状态容器
// 最好在同一文件中创建一个方法,始终记住状态容器,以使其留在组合中,而不是每次都创建一个新的
@Composable
fun rememberEditableUserInputState(hint: String): EditableUserInputState =
    remember(hint) {
        EditableUserInputState(hint, hint)
    }

使用 remember 记住此状态,它在 activity 重新创建后不会继续留存。对于可以存储在 Bundle 内的对象,rememberSaveable 可以在 activity 和进程重新创建后会继续留存存储的值,而无需任何额外的操作。对于在项目中创建的 EditableUserInputState 类,需要告知 rememberSaveable 如何使用 Saver 保存和恢复此类的实例

创建自定义保存器

Saver 描述了如何将对象转换为 Saveable(可保存)的内容。Saver 的实现需要替换两个函数:

  • save - 将原始值转换为可保存的值
  • restore - 将恢复的值转换为原始类的实例

可以使用一些现有的 Compose API,如 listSavermapSaver(用于存储要保存在 ListMap 中的值),以减少我们需要编写的代码量,而不是从头为类创建 Saver 的自定义实现

class EditableUserInputState(private val hint: String, initialText: String) {
    var text by mutableStateOf(initialText)

    val isHint: Boolean
        get() = text == hint

    companion object {
        // 最好将 Saver 定义放置在与其一起使用的类附近,可以使用 companion object 提供静态访问
        val Saver: Saver<EditableUserInputState, *> = listSaver(
            save = { listOf(it.hint, it.text) },
            restore = {
                EditableUserInputState(
                    hint = it[0],
                    initialText = it[1],
                )
            }
        )
    }
}

修改之前创建的 rememberEditableUserInputState 方法:

@Composable
fun rememberEditableUserInputState(hint: String): EditableUserInputState =
    rememberSaveable(hint, saver = EditableUserInputState.Saver) {
        EditableUserInputState(hint, hint)
    }
使用状态容器

状态提升,以便调用方可以控制 CraneEditableUserInput 的状态:

@Composable
fun CraneEditableUserInput(
    state: EditableUserInputState = rememberEditableUserInputState(""),
    caption: String? = null,
    @DrawableRes vectorImageId: Int? = null
) { /* ... */ }

注意:onInputChanged 参数不存在了!由于状态可以提升,因此如果调用方想要知道输入是否发生了更改,它们可以控制状态并将该状态传入此函数

状态容器调用方
@Composable
fun ToDestinationUserInput(onToDestinationChanged: (String) -> Unit) {
    val editableUserInputState = rememberEditableUserInputState(hint = "Choose Destination")
    CraneEditableUserInput(
        state = editableUserInputState,
        caption = "To",
        vectorImageId = R.drawable.ic_plane
    )
}
snapshotFlow

上面的代码缺少在输入更改时通知 ToDestinationUserInput 的调用方的功能,可以在每次输入更改时使用 LaunchedEffect 触发附带效应,并调用 onToDestinationChanged lambda:

@Composable
fun ToDestinationUserInput(onToDestinationChanged: (String) -> Unit) {
    ...

    val currentOnDestinationChanged by rememberUpdatedState(onToDestinationChanged)
    LaunchedEffect(editableUserInputState) {
        snapshotFlow { editableUserInputState.text }
            .filter { !editableUserInputState.isHint }
            .collect {
                currentOnDestinationChanged(editableUserInputState.text)
            }
    }
}

snapshotFlow API 将 Compose State<T> 对象转换为 Flow。当在 snapshotFlow 内读取的状态发生变化时,Flow 会向收集器发出新值。在本例中,我们将状态转换为 Flow,以使用 Flow 运算符的强大功能。这样,我们就可以在 text 不是 hint 时使用 filter 进行过滤,并使用 collect 收集发出的项,以通知父级发生了变化

DisposableEffect
  • 如果在 Compose 中使用了 View ,那么View应该遵循使用它的 activity 的生命周期,而不是组合的生命周期

例如,由于 MapView 是 View 而不是可组合项,意味着需要创建一个 LifecycleEventObserver 来监听生命周期事件并在 MapView 上调用正确的方法,并将此观察器添加到当前 activity 的生命周期

private fun getMapLifecycleObserver(mapView: MapView): LifecycleEventObserver =
    LifecycleEventObserver { _, event ->
        when (event) {
            Lifecycle.Event.ON_CREATE -> mapView.onCreate(Bundle())
            Lifecycle.Event.ON_START -> mapView.onStart()
            Lifecycle.Event.ON_RESUME -> mapView.onResume()
            Lifecycle.Event.ON_PAUSE -> mapView.onPause()
            Lifecycle.Event.ON_STOP -> mapView.onStop()
            Lifecycle.Event.ON_DESTROY -> mapView.onDestroy()
            else -> throw IllegalStateException()
        }
    }

现在,我们需要将此观察器添加到当前的生命周期,可以使用当前的 LifecycleOwnerLocalLifecycleOwner 组合局部函数来获取该生命周期

@Composable
fun rememberMapViewWithLifecycle(): MapView {
    val context = LocalContext.current
    val mapView = remember {
        MapView(context).apply {
            id = R.id.map
        }
    }

    val lifecycle = LocalLifecycleOwner.current.lifecycle
    DisposableEffect(key1 = lifecycle, key2 = mapView) {
        // Make MapView follow the current lifecycle
        val lifecycleObserver = getMapLifecycleObserver(mapView)
        lifecycle.addObserver(lifecycleObserver)
        onDispose {
            lifecycle.removeObserver(lifecycleObserver)
        }
    }

    return mapView
}

仅仅添加观察器是不够的,还需要将其移除

  • DisposableEffect 会在键发生变化或可组合项退出组合后调用 onDispose 方法,可以在方法中作清理操作,然后再次调用重启

    对于上例 DisposableEffect 中的 key,如果 lifecyclemapView 发生变化,系统会移除观察器并再次将其添加到正确的 lifecycle

produceState
  • 屏幕状态建模

    data class DetailsUiState(
        // 要在屏幕上显示的数据
        val cityDetails: ExploreModel? = null,
        // 加载
        val isLoading: Boolean = false,
        // 错误信号
        val throwError: Boolean = false
    )
    
  • 可以使用一个数据流(即 DetailsUiState 类型的 StateFlow)映射屏幕需要显示的内容和 ViewModel 层中的 UiState,ViewModel 会在信息准备就绪时更新该数据流,而 Compose 会使用 collectAsState() API 收集该数据流

  • produceState 可将非 Compose 状态转换为 Compose 状态。它会启动一个作用域限定为组合的协程,该协程可使用 value 属性将值推送到返回的 StateproduceState 采用键来取消和重新开始计算

    val uiState by produceState(initialValue = DetailsUiState(isLoading = true)) {
        // In a coroutine, this can call suspend functions or move
        // the computation to different Dispatchers
        val cityDetailsResult = viewModel.cityDetails
        value = if (cityDetailsResult is Result.Success<ExploreModel>) {
            DetailsUiState(cityDetailsResult.data)
        } else {
            DetailsUiState(throwError = true)
        }
    }
    
    when {
        uiState.cityDetails != null -> {
            DetailsContent(uiState.cityDetails!!, modifier.fillMaxSize())
        }
        uiState.isLoading -> {
            Box(modifier.fillMaxSize()) {
                CircularProgressIndicator(
                    color = MaterialTheme.colors.onSurface,
                    modifier = Modifier.align(Alignment.Center)
                )
            }
        }
        else -> { onErrorLoading() }
    }
    
    derivedStateOf
    • 当想要的某个 Compose State 衍生自另一个 State 时,会使用 derivedStateOf。使用此函数可保证仅当计算中使用的状态之一发生变化时才会进行计算

      // Show the button if the first visible item is past
      // the first item. We use a remembered derived state to
      // minimize unnecessary compositions
      val showButton by remember {
          derivedStateOf {
              listState.firstVisibleItemIndex > 0
          }
      }
      

      该 API 仅在 listState.firstVisibleItemIndex 发生变化时计算 showButton

Jetpack Compose Navigation

Navigation 是一个 Jetpack 库,用于在应用中从一个目的地导航到另一个目的地。Navigation 库还提供了一个专用工件( navigation-compose ),用于使用 Jetpack Compose 实现一致而惯用的导航方式

Navigation 的 3 个主要部分是 NavControllerNavGraphNavHost

  • NavController 始终与一个 NavHost 可组合项相关联
  • NavHost 充当容器,负责显示导航图的当前目的地。当在可组合项之间进行导航时,NavHost 的内容会自动进行重组。此外,它还会将 NavController 与导航图 (NavGraph) 相关联
  • NavGraph 用于标出能够在其间进行导航的可组合目的地,它实际上是一系列可提取的目的地
NavController
  • 使用 Compose 中的 Navigation 时,NavController 是核心组件。它可跟踪返回堆栈可组合条目、使堆栈向前移动、支持对返回堆栈执行操作,以及在不同目的地状态之间导航。由于 NavController 是导航的核心,因此在设置 Compose Navigation 时必须先创建它

  • NavController 是通过调用 rememberNavController() 函数获取的。这将创建并记住 NavController,它可以在配置更改后继续存在(使用 rememberSaveable

  • 应始终创建 NavController 并将其放置在可组合项层次结构的顶层(通常位于 App 可组合项中)。之后,所有需要引用 NavController 的可组合项都可以访问它。这遵循状态提升的原则,并且可确保 NavController 是在可组合屏幕之间导航和维护返回堆栈的主要可信来源

  • 使用 Compose 中的 Navigation 时,导航图中的每个可组合目的地都与一个路线相关联。路线用字符串表示,用于定义指向可组合项的路径,并指引 navController 到达正确的位置。可以将其视为指向特定目的地的隐式深层链接。每个目的地都必须有一条唯一的路线

  • 通过 navController.navigate(route) 执行导航操作

    为了使代码具有可测试性且可重复使用,建议不要将整个 navController 直接传递给可组合项。不过,应该始终提供回调,定义希望触发的确切导航操作

  • 可以使用 navController.currentBackStackEntryAsState()State 的形式获取返回堆栈,然后获取 destination 以获取当前目的地的实时更新

    现在也支持直接通过 Navigation Component 进行返回行为导航,无需为它进行任何其他设置。在目的地之间切换,然后按返回按钮后,系统会正确弹出返回堆栈并转到上一个目的地

NavHost
  • 每个 NavController 都必须与一个 NavHost 相关联

  • NavHost 需要一个 startDestination 路线才能知道在应用启动时显示哪个目的地

  • NavHost 最后一个形参 builder: NavGraphBuilder.() -> Unit 负责定义和构建导航图

@Composable
public fun NavHost(
    navController: NavHostController,
    startDestination: String,
    modifier: Modifier = Modifier,
    route: String? = null,
    builder: NavGraphBuilder.() -> Unit
) { ... }
NavGraph
  • Navigation Compose 提供了 [NavGraphBuilder.composable](https://developer.android.google.cn/reference/kotlin/androidx/navigation/compose/package-summary#(androidx.navigation.NavGraphBuilder).composable(kotlin.String, kotlin.collections.List, kotlin.collections.List, kotlin.Function1)) 扩展函数,以便轻松将各个可组合目的地添加到导航图中,并定义必要的导航信息

    NavHost(
        // NavController
        navController = navController,
        // 应用启动页面
        startDestination = Overview.route,
        modifier = Modifier.padding(innerPadding)
    ) {
        // 唯一字符串route,将目的地添加到导航图中
        composable(route = Overview.route) {
            // 定义导航到此目的地时要显示的实际界面
            Overview.screen()
        }
    }
    
NavOptionsBuilder

NavOptionsBuilder 中提供了一些标志进一步控制和自定义导航行为

  • launchSingleTop = true - 可确保返回堆栈顶部最多只有给定目的地的一个副本

  • popUpTo(destination) { saveState = true } - 一直弹出直到遇到第一个匹配的目的地,例如弹出到导航图的起始目的地,以免在选择标签页时在返回堆栈上构建大型目的地堆栈

  • restoreState = true - 确定此导航操作是否应恢复 PopUpToBuilder.saveStatepopUpToSaveState 属性之前保存的任何状态。请注意,如果之前未使用要导航到的目的地 ID 保存任何状态,此项不会产生任何影响

    navController.navigate(route) {
        launchSingleTop = true
        popUpTo(Overview.route) { saveState = true }
        restoreState = true
    }
    

    如果需要有关管理多个返回堆栈的更多指导,请参阅有关支持多个返回堆栈的文档

实参
  • 实参会将一个或多个实参传递给路线,从而使导航路线变为动态形式。它支持根据所提供的不同实参显示不同的信息

  • 如需在导航时随路线一起传递实参,需要按照以下模式将它们附加在一起:"route/{argument}"

    composable(
        // 为了提高代码安全性和处理任何极端情况,可以将默认值设置为实参并明确指定其类型
        route = "${SingleAccount.route}/{${SingleAccount.accountTypeArg}}"
    ) {
        SingleAccountScreen()
    }
    

    具名实参的定义方式是附加到路线并用花括号括起来,如下所示:{argument}。其语法与 Kotlin 的 String 模板语法类似,均在必要时使用美元符号 $ 来转义变量名称,例如:{${argument}}

  • 可以定义其 arguments 形参让 composable 知道它应该接受实参,可以根据需要定义任意数量的实参,因为 composable 函数默认接受实参列表

    composable(
        route =
            "${SingleAccount.route}/{${SingleAccount.accountTypeArg}}",
        arguments = listOf(
            // 将其类型指定为 String,可提高安全性,如果未明确设置类型,系统将根据此实参的默认值推断出其类型
            navArgument(SingleAccount.accountTypeArg) { type = NavType.StringType }
        )
    ) {
        SingleAccountScreen()
    }
    
  • 在 Compose Navigation 中,每个 NavHost 可组合函数都可以访问当前的 NavBackStackEntry,该类用于保存当前路线的相关信息,以及返回堆栈中条目的已传递实参。可以使用该类从 navBackStackEntry 中获取所需的 arguments 列表,然后搜索并检索所需的确切实参,将其进一步向下传递给可组合屏幕

    NavHost(...) {
        // ...
        composable(
            route =
              "${SingleAccount.route}/{${SingleAccount.accountTypeArg}}",
            arguments = SingleAccount.arguments
        ) { navBackStackEntry ->
            // Retrieve the passed argument
            val accountType =
                navBackStackEntry.arguments?.getString(SingleAccount.accountTypeArg)
    
            // Pass accountType to SingleAccountScreen
            SingleAccountScreen(accountType)
        }
    }
    

    可以为实参提供一个默认值(如果尚未提供)作为占位符,并包含这种极端情况,以提高代码的安全性

  • 起始目的地提供并传递实参,到达目的地接受实参并使用它显示正确信息

    // 注意:传递时不需要花括号
    navController.navigate("${SingleAccount.route}/$accountType")
    
深层链接
  • 在 Android 中,深层链接是指将用户直接转到应用内特定目的地的链接,简单来说,就是提供一个接口,可以让其他应用调用,从而直接打开该应用的特定页面

  • 除了添加实参之外,还可以添加深层链接,将特定网址、操作和/或 MIME 类型与可组合项关联起来

  • 由于向外部应用公开深层链接这一功能默认处于未启用状态,因此还必须向应用的 manifest.xml 文件添加 <intent-filter> 元素

    1. 向应用的 AndroidManifest.xml 添加深层链接,需要通过 <activity> 内的 <intent-filter> 创建一个新的 intent 过滤器,相应操作为 VIEW,类别为 BROWSABLEDEFAULT

    2. 在该过滤器内,需要使用 data 标记添加 scheme(例如 rally - 应用名称)和 host(例如 single_account - 导航到可组合项的路线),以定义精确的深层链接,这将提供 rally://single_account 作为深层链接网址

      <activity
          android:name=".RallyActivity"
          android:windowSoftInputMode="adjustResize"
          android:label="@string/app_name"
          android:exported="true">
          <intent-filter>
              <action android:name="android.intent.action.MAIN" />
              <category android:name="android.intent.category.LAUNCHER" />
          </intent-filter>
      	
          <!--无需在 AndroidManifest 中声明实参,实参会附加到 NavHost 可组合函数内-->
          <intent-filter>
              <action android:name="android.intent.action.VIEW" />
              <category android:name="android.intent.category.DEFAULT" />
              <category android:name="android.intent.category.BROWSABLE" />
              <data android:scheme="rally" android:host="single_account" />
          </intent-filter>
      
      </activity>
      
  • 可以定义其 navDeepLink 形参让 composable 知道它应该接受实参,可以根据需要定义多个指向同一目的地的深层链接,因为 composable 函数默认接受深层链接列表。传递 uriPattern,匹配清单 AndroidManifest.xmlintent-filter 中定义的一个 uriPattern,但还需附加其 accountTypeArg 实参

composable(
    route = SingleAccount.routeWithArgs,
    // ...
    deepLinks = listOf(navDeepLink {
        uriPattern = "rally://${SingleAccount.route}/{${SingleAccount.accountTypeArg}}"
    })
)
  • 使用 adb 测试深层链接

    # 有空格时要加 \ zhuan'yi
    adb shell am start -d "rally://single_account/Checking" -a android.intent.action.VIEW
    

    Android Studio自带的ADB工具在 {SDK目录}\platform-tools

在 Jetpack Compose 中进行测试

  • 测试包含许多内容:

    • 测试标签页是否会显示预期图标和文本

    • 测试动画是否符合规范

    • 测试触发的导航事件是否正确

    • 测试界面元素在不同状态下的放置位置和距离

    • 截取该栏的屏幕截图,并将其与之前截取的屏幕截图进行比较

  • Compose 提供一个 ComposeTestRule,调用 createComposeRule() 即可获得此规则

    class TopAppBarTest {
    
        @get:Rule
        val composeTestRule = createComposeRule()
    
        // TODO: Add tests
    }
    
  • 在 Compose 中,可以通过对组件进行隔离测试来大幅简化测试工作。可以选择要在测试中使用的 Compose 界面内容,这可通过 ComposeTestRulesetContent 方法完成,并且可以在任何位置调用它(但只能调用一次)

  • 查找界面元素、检查其属性和执行操作是按照以下模式通过测试规则完成的:

    composeTestRule{.finder}{.assertion}{.action}
    
  • Compose 测试使用称为语义树的结构来查找屏幕上的元素并读取其属性

    // 在Logcat中搜索 currentLabelExists 查看输出
    composeTestRule.onRoot().printToLog("currentLabelExists")
    

    警告:可组合项没有 ID,也无法使用树中显示的节点编号来匹配它们。如果将节点与其语义属性匹配不可行或不可能,可以将 testTag 修饰符和 hasTestTag 匹配器作为最后手段

  • 语义树总是会尽可能地精简,仅显示相关的信息,属性 MergeDescendants = 'true' 表示,此节点有后代,但已合并到此节点中。在测试中,常常需要访问所有节点,所有查找器都有一个名为 useUnmergedTree 的参数,可以将 useUnmergedTree = true 传递给查找器,查询未合并的语义树

  • 所编写的任何测试都必须与被测对象正确同步。例如,当使用 onNodeWithText 等查找器时,测试会一直等到应用进入空闲状态后才查询语义树。如果不同步,测试就可能会在元素显示之前查找元素,或者不必要地等待

    // 不同步报错
    androidx.compose.ui.test.junit4.android.ComposeNotIdleException: Idling resource timed out: possibly due to compose being busy.
    IdlingResourceRegistry has the following idling resources registered:
    - [busy] androidx.compose.ui.test.junit4.android.ComposeIdlingResource@d075f91
    

    出现不同步的原因可能是不会停止的动画,但无限动画是 Compose 测试可以理解的一种特殊情况,因此不会导致测试一直忙碌

测试备忘单
模式通过测试规则完成的:

composeTestRule{.finder}{.assertion}{.action}
  • Compose 测试使用称为语义树的结构来查找屏幕上的元素并读取其属性

    // 在Logcat中搜索 currentLabelExists 查看输出
    composeTestRule.onRoot().printToLog("currentLabelExists")
    

    警告:可组合项没有 ID,也无法使用树中显示的节点编号来匹配它们。如果将节点与其语义属性匹配不可行或不可能,可以将 testTag 修饰符和 hasTestTag 匹配器作为最后手段

  • 语义树总是会尽可能地精简,仅显示相关的信息,属性 MergeDescendants = 'true' 表示,此节点有后代,但已合并到此节点中。在测试中,常常需要访问所有节点,所有查找器都有一个名为 useUnmergedTree 的参数,可以将 useUnmergedTree = true 传递给查找器,查询未合并的语义树

  • 所编写的任何测试都必须与被测对象正确同步。例如,当使用 onNodeWithText 等查找器时,测试会一直等到应用进入空闲状态后才查询语义树。如果不同步,测试就可能会在元素显示之前查找元素,或者不必要地等待

    // 不同步报错
    androidx.compose.ui.test.junit4.android.ComposeNotIdleException: Idling resource timed out: possibly due to compose being busy.
    IdlingResourceRegistry has the following idling resources registered:
    - [busy] androidx.compose.ui.test.junit4.android.ComposeIdlingResource@d075f91
    

    出现不同步的原因可能是不会停止的动画,但无限动画是 Compose 测试可以理解的一种特殊情况,因此不会导致测试一直忙碌

[外链图片转存中…(img-zrASNCEG-1668352183142)]

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值