稀有猿诉

十年磨一剑,历炼出锋芒,说话千百句,不如码二行。

高效地在Jetpack Compose中设计UI组件

本文译自Designing Effective UI Components in Jetpack Compose,原文作者是Jaewoong Eum,原文发布于2025年2月7日。

译者按: 本文适合有一定Jetpack Compose经验的开发者阅读,假定读者熟悉Jetpack Compose的基本使用方法,以及熟悉常见 的Slot设计模式。否则理解上可能会有一些困难。

Google 宣布 Jetpack Compose 1.0 稳定版以来,许多公司都已采用 Jetpack Compose 来利用其众多优势。随着 Compose 在 Android 生态系统中的广泛采用,库和 SDK 也开始集成对 Compose 的支持。

传统上,在基于 XML 的项目中,UI 组件以自定义视图的形式提供,并通过属性(attributes)提供可自定义的选项。虽然这种方法可以轻松地将组件集成到 XML 布局中,但它带来了一些挑战,例如在多个组件之间应用主题样式时不一致,以及由于底层 View 类公开的方法而导致的 API 滥用。

与传统的自定义视图相比,Jetpack Compose 提供了一种完全不同的组件设计策略。其声明式结构允许更直观、更灵活的 API 设计。这种转变不仅有利于库和 SDK 开发人员,也有利于构建共享 UI 组件的大型团队,使他们能够实施更好的做法、减少误用并增强整体开发人员体验。

在本文中,你将发现在 Jetpack Compose 中设计 UI 组件的有效策略,借鉴 Stream Video SDK 的最佳实践。

Modifier的最佳实践

Modifier 是 Jetpack Compose 中一个功能强大的 API,可让你以链式和可组合的方式装饰和增强 UI 元素。但是,应谨慎使用它,因为它的属性可以传播到其他可组合函数,如果管理不当,可能会导致意想不到的效果。

Modifier函数的顺序尤其重要,因为每个函数都会修改前一个函数返回的Modifier或从可组合项外部传递的Modifier。此顺序直接影响最终输出。在本节中,我们将探讨三个关键原则和最佳实践,它们可以指导你在 Jetpack Compose 中设计有效且可预测的 UI 组件 API。

1. 将Modifier应用到组件最顶层的布局

Jetpack Compose 中的Modifier会通过布局层次结构向下传递,但理想情况下,它们应仅应用于可组合函数中最顶层的布局节点。在层次结构中的任意级别应用Modifier可能会导致意外行为,并增加用户误用的可能性,从而使组件更难以预测且更难以有效使用。

例如,假设你想要创建一个代表圆形按钮的组件,如下例所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Composable
fun RoundedButton(
  modifier: Modifier = Modifier,
  onClick: () -> Unit
) {
  Button(
    modifier = modifier.clip(RoundedCornerShape(32.dp)),
    onClick = onClick
  ) {
    Text(
      modifier = Modifier.padding(10.dp),
      text = "Rounded"
    )
  }
}

但是,你不应将 Modifier 应用于 Text ,而应应用于 Button ,后者是布局层次结构中最顶层的可组合函数,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Composable
fun RoundedButton(
  modifier: Modifier = Modifier,
  onClick: () -> Unit
) {
  Button(
    modifier = Modifier.clip(RoundedCornerShape(32.dp)),
    onClick = onClick
  ) {
    Text(
      modifier = modifier.padding(10.dp), // 别这么干
      text = "Rounded"
    )
  }
}

自定义可组合函数 RoundedButton 的主要用途是表示 Button ,而不是 Text 。因此,你应避免转移所创建主要组件的焦点或用途。

此外,如果布局层次结构变得复杂,并且你在可组合函数的中间级别应用Modifier,则用户可能很难预测提供的Modifier参数最终会影响哪个组件。这种不明确性可能会导致混淆和误用。

如果你想让用户灵活地修改按钮的内部内容,你可以使用插槽来实现,如下例所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Composable
fun RoundedButton(
  modifier: Modifier = Modifier,
  onClick: () -> Unit,
  content: @Composable RowScope.() -> Unit
) {
  Button(
    modifier = modifier.clip(RoundedCornerShape(32.dp)),
    onClick = onClick
  ) {
    content()
  }
}

2. 对Modifier使用单个参数

你可能想知道是否可以接受多个 Modifier 参数以应用于布局层次结构中的特定组件,同时限制组件的结构,如下例所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Composable
fun RoundedButton(
  modifier: Modifier = Modifier,
  textModifier: Modifier = Modifier,
  onClick: () -> Unit,
) {
  Button(
    modifier = modifier.clip(RoundedCornerShape(32.dp)),
    onClick = onClick
  ) {
    Text(
      modifier = textModifier.padding(10.dp),
      text = "Rounded"
    )
  }
}

但是,Modifier 本质上被设计为一个单一的、可链接的参数,使用户能够定义 Composable 函数的外部行为和外观。在 Composable 中引入多个 Modifier 参数会增加不必要的复杂性,增加误用的风险,并且偏离了 Jetpack Compose 保持 API 直观和可预测的原则。

最好使用基于插槽的方法,让用户能够灵活地自定义内部内容。例如,你可以定义一个插槽(slot),让用户提供自定义内容,同时仍保留单个Modifier以进行外部自定义,而不是添加多个Modifier参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
@Composable
fun RoundedButton(
  modifier: Modifier = Modifier,
  onClick: () -> Unit,
  content: @Composable RowScope.() -> Unit
) {
  Button(
    modifier = modifier.clip(RoundedCornerShape(32.dp)),
    onClick = onClick
  ) {
    content()
  }
}

3. 避免跨组件重复使用Modifier

设计组件时的另一个重要考虑因素是避免重复使用提供的 Modifier 实例。一些开发人员可能会担心为每个组件创建新的 Modifier 实例可能会导致内存使用量增加或对性能产生负面影响,尤其是在具有大量Modifier的复杂布局层次结构中。

然而,由于 Jetpack Compose 中Modifier实现的优化性质,这种担忧通常是没有根据的。Modifier旨在应用于可组合函数中的单个布局节点,以确保行为清晰且可预测。如果在布局层次结构中不同级别的多个可组合项中使用相同的Modifier,则可能导致意外的副作用和不可预测的行为,从而损害组件的一致性和可用性。

例如,考虑这样一种情况,其中相同的 Modifier 参数在整个布局层次结构中重复使用,如下例所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Composable
fun MyButtons(
  modifier: Modifier = Modifier,
  onClick: () -> Unit,
) {
  Column(modifier = modifier) {
    Button(
      modifier = modifier,
      onClick = onClick
    ) {
      Text(
        modifier = modifier.padding(10.dp),
        text = "Rounded"
      )
    }

    Button(
      modifier = modifier,
      onClick = onClick
    ) {
      Text(
        modifier = modifier.padding(10.dp),
        text = "Not Rounded"
      )
    }
  }
}

乍一看,代码似乎运行正常。但是,当你在调用点修改Modifier时,你会注意到意外的行为,可能会以意想不到的方式改变整个布局。

1
2
3
4
5
MyButtons(
  modifier = Modifier
    .clip(RoundedCornerShape(32.dp))
    .background(Color.Blue)
) {}

为了确保行为正确并避免意外问题,你应避免在多个组件中重复使用Modifier。在本节中,你了解了在设计 Compose 组件时管理Modifier的最佳实践。接下来,让我们通过实现主题来深入了解如何提供一致的 UI 样式。

主题确保 UI 一致性

现在,假设你需要提供多种 Compose 组件,这些组件应共享一致的样式。如果这些组件是独立提供的,那么维护这些组件之间一致样式的责任就完全落在用户身上。这可能非常具有挑战性,因为每个组件可能会公开不同的 API 来自定义其样式,从而使同步变得繁琐且容易出错。

在这种情况下,你可以从 Compose Material 库提供的 MaterialTheme API 中汲取灵感。关键在于确保组件样式一致,同时允许用户无缝自定义并在各个组件之间保持一致的样式。

Theming Consistency

Compose 的 Stream Video SDK 通过提供名为 VideoTheme 的专用主题 API 展示了最佳实践。此 VideoTheme API 可确保 SDK 提供的所有 Compose 组件的样式一致,包括颜色、尺寸、排版、形状、涟漪效果等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
setContent {
    VideoTheme(
        colors = StreamColors.defaultColors().copy(appBackground = Color.Black),
        dimens = StreamDimens.defaultDimens().copy(callAvatarSize = 72.dp),
        shapes = StreamShapes.defaultShapes().copy(
            avatar = RoundedCornerShape(8.dp),
            callButton = RoundedCornerShape(16.dp),
            callControls = RectangleShape,
            callControlsButton = RoundedCornerShape(8.dp)
        )
    ) {
        CallContent(
            modifier = Modifier.fillMaxSize(),
            call = call,
            onBackPressed = { finish() },
        )
    }
}

通过将 Stream SDK 提供的组件与 VideoTheme 包装在一起(如上例所示),自定义样式将自动一致地应用于所有组件。这种方法使用户能够轻松保持其 UI 的一致性,同时调整主题以满足其应用程序的设计要求。

实现自定义主题

让我们深入研究如何实现自定义主题。第一步是定义设计规范,这些规范将在你的组件之间共享或为用户提供自定义功能。考虑包括颜色、形状和尺寸等方面,因为这些通常是确保设计系统一致性的最重要因素。

例如,在 Stream SDK 中,组件所需的所有颜色集均在 StreamColors 类中预定义,为用户提供了一种无缝的方式来保持其整个 UI 的一致性。以下是 Stream SDK 如何通过结构良好的颜色集确保一致性的示例:

1
2
3
4
5
6
7
8
9
10
11
public data class StreamColors(
    val brandPrimary: Color,
    val brandPrimaryLt: Color,
    val brandPrimaryDk: Color,
    val brandSecondary: Color,
    val brandSecondaryTransparent: Color,
    val brandCyan: Color,
    val brandGreen: Color,
    val brandYellow: Color,
    ..
  )

接下来,你应该创建一个 CompositionLocal 来保存设计规范。这将允许你的组件和用户通过使用 StreamTheme.colors 调用在自定义主题的上下文中无缝访问这些规范。

译注: CompositionLocal是Compose中用于在上下文函数调用中,隐式的传递常量性质参数的方法,可以参考这篇文章用Compose中的CompositionLocal来暗渡陈仓,以了解CompositionLocal的详细用法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
 * Local providers for various properties we connect to our components, for styling.
 */
private val LocalColors = compositionLocalOf<StreamColors> {
    error("No colors provided! Make sure to wrap all usages of Stream components in a VideoTheme.")
}

public interface StreamTheme {
    /**
     * Retrieves the current [StreamColors] at the call site's position in the hierarchy.
     */
    public val colors: StreamColors
        @Composable @ReadOnlyComposable
        get() = LocalColors.current
}

然后,你需要利用 CompositionLocal 将这些设计规范封装在自定义主题中。这种方法允许你在整个可组合层次结构中高效地提供和传播你的设计规范。

1
2
3
4
5
6
7
8
9
10
11
public fun VideoTheme(
    isInDarkMode: Boolean = isSystemInDarkTheme(),
    colors: StreamColors = StreamColors.defaultColors(),
    content: @Composable () -> Unit,
) {
    CompositionLocalProvider(
        LocalColors provides colors,
    ) {
        content()
    }
}

现在,你的所有组件都应假设它们都包含在自定义主题(例如本例中的 VideoTheme)中,并使用提供的设计规范来确保整个组件集的样式一致。这种方法不仅使你的组件 API 能够采用统一的样式,还允许用户利用这些设计规范进行自定义,从而同时提高灵活性和一致性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@Composable
fun VideoRendererCallContent(
    call: Call,
    video: ParticipantState.Video,
    onRendered: (View) -> Unit = {},
) {
    VideoRenderer(
        modifier = Modifier
            .fillMaxSize()
            .background(VideoTheme.colors.baseSheetTertiary), // use pre-defined color styles
        call = call,
        video = video,
        onRendered = onRendered,
    )
}

@Composable
fun MyScreen() {

  VideoRendererCallContent(..)

  // some complicated components
}

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

         setContent {
            VideoTheme {
               MyScreen()
            }
         }
}

这种方法不仅对于实现 API(库或 SDK)有效,对于构建应用程序也同样有效,因为它能够使用结构良好、预定义的设计规范轻松维护设计一致性。对于实际示例和实际用例,你可以在 GitHub 上探索实际最佳实践。

可定制性

在实现 UI 组件时,尤其是对于库或 SDK,为 UI 和 UX 行为提供强大的自定义和灵活性至关重要。这可确保用户可以轻松重复使用组件并根据其特定要求进行调整。你可以采用各种策略在 Jetpack Compose 中有效地实现这种级别的可定制性。

1. 利用样式类

如果你希望为特定组件提供更具针对性的定制,请考虑提供专用的样式类。此类可以定义并允许用户轻松修改组件的 UI 和 UX 行为以满足他们的特定需求。

一个很好的例子是 TextStyle ,它是 Compose UI 库提供的默认类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Immutable
class TextStyle internal constructor(
  ..
) {
    constructor(
        color: Color = Color.Unspecified,
        fontSize: TextUnit = TextUnit.Unspecified,
        fontWeight: FontWeight? = null,
        fontStyle: FontStyle? = null,
        fontSynthesis: FontSynthesis? = null,
        fontFamily: FontFamily? = null,
        ..
     )
}

如上面的代码所示,TextStyle 类封装了 Text 可组合项的所有样式属性。只需将 TextStyle 实例传递给 Text 可组合项,你就可以轻松自定义其设计,如下例所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
Text(
    modifier = Modifier
        .fillMaxWidth()
        .padding(top = 16.dp),
    text = "Stats",
    style = TextStyle(
        fontSize = 16.sp,
        lineHeight = 24.sp,
        fontWeight = FontWeight(600),
        color = Color.White,
        textAlign = TextAlign.Center,
    ),
)

使用样式类的优点是,它们允许组件开发人员将所有设计规范整合到一个集中的类中。这种方法可以防止设计元素分散在多个布局中,从而使代码库更简洁、更易于管理。

对于用户来说,样式类提供了一种直接且直观的修改设计的方法。此外,用户可以在多个布局中重复使用相同的样式实例,从而更方便地在不同布局中应用一致的自定义。

一个潜在的缺点是,由于重组机制(Recomposition),每当输入发生变化时,Compose 运行时都会比较样式类的所有属性,以确定是否需要重组。与直接在可组合函数中定义单个参数相比,这使其成本略高。然而,从 API 设计的角度来看,改进的用户体验和简化的 API 管理通常超过了这一成本,因此在许多情况下,这是一种值得的权衡。

2. 借助插槽(Slots)的灵活性

增强自定义灵活性的另一种有效策略是提供接受可组合函数的插槽,让用户根据自己的需求定义特定的实现。通过提供默认实现,你可以确保用户无需付出额外努力即可利用所提供的功能,同时仍然可以根据需要进行自定义。

例如,Stream Video SDK 提供的 CallContent 组件是一个高级 API,它集成了多个子组件,包括顶部应用栏、视频渲染器、布局结构、网格参与者等。虽然 CallContent API 包含默认实现以方便使用,但它还通过允许通过插槽参数进行自定义来确保灵活性,如以下代码所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
fun CallContent(
    call: Call,
    modifier: Modifier = Modifier,
    style: VideoRendererStyle = RegularVideoRendererStyle(),
    appBarContent: @Composable (call: Call) -> Unit = {
        CallAppBar(..)
    },
    videoRenderer: @Composable (..) -> Unit = {
        ParticipantVideo(..)
    },
    videoContent: @Composable RowScope.(call: Call) -> Unit = {
        ParticipantsLayout(..)
    },
) {
  ..
}

这种方法允许用户实现自己的顶部应用栏、视频渲染器、布局结构、网格参与者等自定义版本。此外,另一种有效的策略是将相似类型的组件分组,并通过插槽使它们可自定义,这通常称为复合组件模式。

复合组件模式涉及创建一个父组件来管理子组件集合,通过为每个子组件公开插槽来提供自定义。此模式允许用户替换或自定义单个子组件,同时保持整体结构和功能的一致性。

想象一下视频通话屏幕上的控制面板包含多个操作按钮,如下图所示:

Control Panel

有些用户可能喜欢不同的操作按钮顺序,例如将麦克风按钮放在第一位,而其他用户可能希望根据其特定用例添加、删除或自定义按钮。在这种情况下,复合组件模式被证明在满足这些不同的要求方面非常有效,例如下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Composable
public fun ControlActions(
    call: Call,
    modifier: Modifier = Modifier,
    actions: List<(@Composable () -> Unit)>
) {
    Box(
        modifier = modifier,
    ) {
        LazyRow(
            verticalAlignment = Alignment.CenterVertically,
            horizontalArrangement = Arrangement.spacedBy(
                VideoTheme.dimens.spacingM,
                Alignment.CenterHorizontally,
            ),
        ) {
            items(actions) { action ->
                action.invoke()
            }
        }
    }
}

上面的代码演示了一个接受 Composable 函数列表的单个插槽,然后使用 Row 或 LazyRow 进行渲染。这种方法允许你提供高度灵活的组件 API,同时保持对组件布局预期结构的控制。

之后,你还可以提供默认实现,如下所示,以增加便利性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@Composable
public fun buildDefaultCallControlActions(
    onCallAction: (CallAction) -> Unit,
): List<@Composable () -> Unit> {

    return listOf(
        {
            ToggleCameraAction(
                onCallAction = onCallAction,
            )
        },
        {
            ToggleMicrophoneAction(
                onCallAction = onCallAction,
            )
        },
        {
            FlipCameraAction(
                onCallAction = onCallAction,
            )
        },
    )
}

@Composable
public fun ControlActions(
    call: Call,
    modifier: Modifier = Modifier,
    onCallAction: (CallAction) -> Unit = { DefaultOnCallActionHandler.onCallAction(call, it) },
    actions: List<(@Composable () -> Unit)> = buildDefaultCallControlActions(onCallAction)
) {
}

如需了解更多 ControlActions 的真实示例,你可以探索 GitHub 上的实现。

3. 使用主题进行定制

设计 Compose 组件时的另一个常见挑战是,随着组件层次结构的变大,提供清晰、直接的可定制性变得更加困难。例如,假设你想为组件多个部分使用的视频渲染器提供可定制性,但 UI 层次结构嵌套很深且很复杂,如下例所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Composable
fun CallContent() {
    ParticipantsLayout {
      ParticipantsRegularGrid {
        OrientationVideoRenderer {
          LazyVerticalGrid {
            VideoRenderer() // <-- users want to customize this renderer style
          }
        }
      }

      FloatingVideoRenderer {
        VideoRenderer() // <-- users want to customize this renderer style
      }
    }
}

在这种情况下,将插槽或样式参数从最顶层的组件一直传递到 VideoRenderer 组件并不理想。随着你在不同组件之间添加更多可定制性,最顶层的组件 (CallContent) 可能会因大量插槽和样式参数而变得过载。这不仅使你的 API 更难维护,而且还增加了用户混淆或误用的可能性,因为不清楚哪个参数用于什么用途。

为了解决这个问题,你可以利用自定义主题和 CompositionLocal来实现可定制性,同时保持 API 更清晰、更易于管理,如下例所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Immutable
public data class VideoRendererStyle(
    val backgroundColor: Color = Color.Black,
    ..
)

private val LocalVideoRendererStyle = compositionLocalOf<VideoRendererStyle> {
    error("No VideoRendererStyle provided! Make sure to wrap all usages of Stream components in a VideoTheme.")
}

@Composable
public fun VideoTheme(
    videoRendererStyle: VideoRendererStyle = VideoRendererStyle(),
    content: @Composable () -> Unit,
) {
    CompositionLocalProvider(
        LocalVideoRendererStyle provides videoRendererStyle,
    ) {
        content()
    }
}

现在,你可以通过在不同的组件中使用提供的样式来确保组件样式的一致性,而无需将它们作为参数反复传递。此外,用户可以通过创建自己的自定义主题轻松自定义样式,如下例所示:

1
2
3
4
5
6
7
setContent {
    VideoTheme(
        videoRendererStyle = VideoRendererStyle(backgroundColor = Color.Blue)
    ) {
      ..
    }
}

如果你希望更广泛地应用此策略并高效地管理更多样式,则可以将它们合并为一个类,例如 StreamStyles ,并提供统一的样式类,而不是 CompositionLocal 中的多个单独样式。有些同学可能会担心 CompositionLocal 带来的性能影响,因为它会在值更改时触发布局层次结构的重组,但主题通常不会在应用程序中频繁更新。它们通常是静态的,以确保设计一致性,因此在这种情况下使用 CompositionLocal 是一种合适且有效的选择。

预览(Preview)的兼容性

提供预览非常重要,尤其是在构建组件 API 时,因为它们允许开发人员直接在 Android Studio 中可视化和验证他们的 UI 设计。 一些同学依靠 Live Literals 来动态展示预览中的变化,而其他同学则使用屏幕截图测试来确保其组件的视觉一致性。因此,在实现 Compose 组件时,必须确保它们与 Android Studio 中的预览功能完全兼容,如下面的屏幕截图所示:

Preview

有时,你的组件可能会产生副作用,例如在发出网络请求或处理动态状态后渲染图像,这可能会导致预览出现故障。在这种情况下,你可以利用 LocalInspectionMode

LocalInspectionMode 允许你确定可组合项是否在预览模式下呈现,从而使你可以呈现专门用于预览目的的专用布局。这种方法可确保预览保持功能,即使你的组件依赖于复杂的逻辑或外部资源。你可以从 Landscapist 中找到一个用于网络图像加载的 Jetpack Compose 库的真实示例,它演示了处理预览的最佳实践。

下面的可组合函数会检查它是否处于预览模式。如果是,它会渲染静态图像,而不是执行诸如获取网络数据之类的副作用。这种方法允许用户为 GlideImage 可组合函数构建自己的预览,而不会在预览渲染期间遇到运行时错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Composable
public fun GlideImage(
  imageModel: () -> Any?,
  modifier: Modifier = Modifier,
  previewPlaceholder: Painter? = null,
  ..
) {
  if (LocalInspectionMode.current && previewPlaceholder != null) {
      Image(
        modifier = modifier,
        painter = previewPlaceholder,
        ..
      )
      return
  }

  // complex logic about requesting network data and render it
  ..
)

为了增强项目中的整体预览策略,请考虑探索设计有效的 UI 以增强 Compose 预览。此资源提供了有价值的见解和技术,可有效优化你的 Compose 预览。

结论

在本文中,我们探讨了在 Jetpack Compose 中制作有效 UI 组件的策略,重点关注最佳实践,例如高效处理Modifier、确保设计与主题一致、实施可定制性策略以及增强预览兼容性。设计直观且强大的 API 始终是一项挑战,但努力终将获得回报,因为用户体验和开发者满意度显著提升。

如果你对本文有任何疑问或反馈,可以在 Twitter @github_skydoves GitHub 上找到作者。如果你想随时了解 Stream 的最新动态,请在 Twitter 上关注我们 @getstream_io,获取更多精彩的技术内容。

老规矩,祝你编码愉快! – Jaewoong

最初发布于 GetStream.io

Comments