[译]Jetpack Compose 中的 @Composable 组件最佳实践

最近更新: 2023年7月19日

这是一套构建可扩展、用户友好的 @Composable 组件的指导原则和建议。

对于每一项指导原则的要求级别,我们使用 RFC2119 中规定的术语来指定针对不同的开发者受众。如果某个受众没有被特别指定要求级别,则应当假定该指导原则对该受众是可选的。

Jetpack Compose 框架开发

对于 androidx.compose 库和工具的贡献通常需要严格遵循这些指导原则,以促进一致性,为各层面的消费者代码设定期望和示例。

基于 Jetpack Compose 的库开发

我们期望并希望会出现大量面向 Jetpack Compose 的外部库生态系统,这些库为应用和其他库暴露 @Composable 函数和支持类型的公共 API。虽然我们希望这些库能像 Jetpack Compose 框架开发一样遵循这些指导原则,但组织优先级和本地一致性可能会使某些纯粹的风格指导原则变得宽松。

基于 Jetpack Compose 的应用开发

应用开发通常要受到组织优先级、规范和与现有应用架构集成的要求的影响。这可能不仅需要在风格上偏离这些指导原则,在结构上也可能需要偏离。在可能的情况下,本文档会列出更适合此类情况的替代方案。

本文档中使用的词汇说明

@Composable 组件 - 返回 Unit 并在组合层次结构中发出 UI 的 @Composable 函数(后文简称组件)。

开发者 - 创建供应用或其他组件库中的用户使用的组件的人。

用户 - 组件的使用者 - 在可组合层次结构中使用组件来向最终用户展示 UI 的人。

最终用户 - 使用组件用户创建的应用的人。

这些指导原则概述了使用 Jetpack Compose 开发 UI 组件的最佳实践。最佳实践确保组件的 API 是:

  • 长期可扩展:作者能够演进 API,以对用户造成最小的摩擦。
  • 与其他组件一致:开发人员可以使用现有的知识和模式来处理由不同作者创建的新组件。
  • 引导开发人员走向正确的路径:组件将鼓励正确的实践和用法,并在可能的情况下禁止不正确的用法。

在创建组件之前

在创建新组件时:

  • 确保组件解决了单一问题。将组件拆分为子组件和构建块,直到每个组件解决用户的单一问题。
  • 确保你需要一个组件,并且它带来的价值足以证明其 API 的长期支持和演进是合理的。开发人员可能会发现,让用户自己编写组件代码更容易,这样他们可以在以后进行调整。

组件的目的

考虑新组件增加的价值和解决的问题。每个组件应只解决一个问题,并且每个问题应在一个地方解决。如果你的组件解决了多个问题,请寻找机会将其拆分为层次或子组件。较小、简洁且针对用例的 API 带来的好处是易于使用和清晰理解组件契约。

较低级别的构建块和组件通常添加某种新的单一功能,并且易于组合在一起。较高级别的组件的目的是结合构建块以提供有见地的、可直接使用的行为。

不要这样做

// 避免多用途组件:例如,这个按钮解决了多个问题  
@Composable  
fun Button(  
    // 问题 1:按钮是一个可点击的矩形    
    onClick: () -> Unit = {},    
    // 问题 2:按钮是一个类似复选框的选中/取消选中组件    
    checked: Boolean = false,    
    onCheckedChange: (Boolean) -> Unit
) { ... }  

这样做:

@Composable  
fun Button(  
    // 问题 1:按钮是一个可点击的矩形    
    onClick: () -> Unit
) { ... }  
  
@Composable  
fun ToggleButton(  
    // 问题 1:按钮是一个类似复选框的选中/取消选中组件    
    checked: Boolean,    
    onCheckedChange: (Boolean) -> Unit
) { ... }  

组件分层

在创建组件时,首先提供各种单一用途的构建块,这些构建块是组件工作所需的。随着从低级 API 到高级 API 的推进,增加内置的意见并减少自定义选项。高级组件应提供更多有见地的默认值和更少的自定义选项。

@Composable 组件的创建在 Compose 中被设计为低成本操作,以便用户可以创建自己的单一用途组件并根据需要进行调整。

这样做:

// 单一用途的构建块组件  
@Composable  
fun Checkbox(...) { ... }  
  
@Composable  
fun Text(...) { ... }  
  
@Composable  
fun Row(...) { ... }  
  
// 更高级的组件,是较低级别构建块的更有见地的组合  
@Composable  
fun CheckboxRow(...) {  
    Row {        
        Checkbox(...)        
        Spacer(...)        
        Text(...)    
    }
}  

你需要一个组件吗?

质疑创建组件的必要性。对于可以从构建块组合而成的高级组件,必须有一个强有力的理由证明其存在的合理性。较低级别的组件应解决用户的实际问题。

尝试从公开可用的构建块创建组件。这提供了作为需要你组件的开发人员的感觉。如果它看起来简单、可读且不需要隐藏知识来制作 - 这意味着用户可以自己完成。

考虑你的组件为用户带来的价值,如果他们选择使用它而不是自己动手。考虑组件给用户带来的负担,他们需要学习新的 API 才能使用它们。

例如,开发人员想要创建一个 RadioGroup 组件。为了适应各种需求,如垂直和水平布局、不同类型的数据和装饰,API 可能如下所示:

@Composable  
fun <T> RadioGroup(  
    // `options` 是一个泛型类型    
    options: List<T>,  
    // 水平或垂直    
    orientation: Orientation,    
    // 一些内容布局的调整    
    contentPadding: PaddingValues,    
    modifier: Modifier = Modifier,    
    optionContent: @Composable (T) -> Unit  
) { ... }  

在这样做时,首先看看用户如何使用现有的构建块自己编写它:

// Modifier.selectableGroup 添加了类似单选组行为的语义  
// 辅助功能服务将其视为各种选项的父级  
Column(Modifier.selectableGroup()) {
    options.forEach { item ->
        Row(
            modifier = Modifier.selectable(
                selected = (select.value == item),
                onClick = { select.value = item }
            ),        
            verticalAlignment = Alignment.CenterVertically
        ) {
            Text(item.toString())
            RadioButton(        
                selected = (select.value == item),
                onClick = { select.value = item }
            )
        }
    }
}

现在,开发人员应做出有意识的决定,是否值得引入 RadioGroup API。在这个特定的示例中,用户使用了熟悉的构建块,如 RowText 和其他基本工具。他们还获得了定义所需布局或添加任何装饰和自定义的灵活性。可能会认为不引入任何 RadioGroup API。

发布一个组件是有成本的,至少包括开发、测试、长期支持和后续的 API 演进。

组件还是修饰符

如果组件具有无法应用于其他组件的独特 UI,或者组件希望在 UI 中进行结构性更改(添加/删除其他组件),则创建一个组件。

如果功能可以应用于任意单个组件以添加额外行为,则将其作为 Modifier。这在功能在同时应用于几个 UI 组件时具有未定义行为时尤为重要。

不要这样做

@Composable  
fun Padding(allSides: Dp) {  
    // 实现  
}  
  
// 用法  
Padding(12.dp) {  
    // 1. 这是围绕卡片和图片的填充,还是每个的填充?    
    // 2. 卡片和图片的布局期望是什么?    
    // 3. 如果没有内容(没有卡片和图片)怎么办?    
    UserCard()    
    UserPicture()
}  

这样做:

fun Modifier.padding(allSides: Dp): Modifier = // 实现  
  
// 用法  
UserCard(modifier = Modifier.padding(12.dp))  

如果功能可以应用于任何可组合项,但必须更改可组合项的层次结构,则必须是组件,因为修饰符无法更改层次结构:

这样做

@Composable  
fun AnimatedVisibility(  
    visibile: Boolean,    
    modifier: Modifier = Modifier,    
    content: @Composable () -> Unit
) {  
    // 实现
}  
  
// 用法:AnimatedVisibility 必须有权根据可见性标志从层次结构中删除/添加 UserCard  
AnimatedVisibility(visible = false) {  
    UserCard()
}  

组件的名称

请参阅相应的 Compose API 指南 部分以了解命名约定。然而,还有更多详细的考虑因素需要记住。

Jetpack Compose 框架开发 必须遵循本节中的规则。

库开发 必须遵循以下部分。

应用开发 可以遵循以下规则。

BasicComponent vs Component

考虑为提供无装饰和/或无设计系统基础视觉意见的组件使用 Basic* 前缀。这是一个 信号,表明用户应将其包装在自己的装饰中,因为该组件不应按原样使用。作为对比,没有前缀的 Component 名称可以表示根据某些设计规范装饰的可直接使用的组件。

这样做:

// 组件没有装饰,但具有基本功能  
@Composable  
fun BasicTextField(  
	value: TextFieldValue,  
	onValueChange: (TextFieldValue) -> Unit,  
	modifier: Modifier = Modifier,  
	...  
)  
  
// 根据设计规范装饰的可直接使用的组件  
@Composable  
fun TextField(  
	value: TextFieldValue,  
	onValueChange: (TextFieldValue) -> Unit,  
	modifier: Modifier = Modifier,  
	...  
)  

设计、用例或公司/项目特定前缀

避免使用 CompanyNameGoogleButton)或模块(WearButton)前缀,并考虑使用用例或领域特定的名称。如果你正在构建的组件库是使用 compose-foundationcompose-ui 构建块作为基础的组件库的一部分,大多数非前缀名称应可供开发人员使用而不会发生冲突:com.companyname.ui.Buttoncom.companyname.ui.Icon。简单的名称确保这些组件在使用时感觉像一等公民。

如果包装现有组件或在另一个设计系统之上构建,请首先考虑从用例派生的名称:ScalingLazyColumnCurvedText。如果不可能或用例与现有组件冲突,可以使用模块/库前缀,例如 GlideImage

如果你的设计系统规范引入了许多具有不同外观的类似组件,请考虑使用规范前缀:ContainedButtonOutlinedButtonSuggestionChip 等。使用前缀可以避免“样式”模式并保持 API 简单。请参阅 “ComponentColor/ComponentElevation” 部分了解更多详细信息。

如果你有一组带有前缀的组件,请考虑选择默认组件,即最有可能使用的组件,并保持其没有前缀。

这样做

// 这个按钮在规范中被称为 ContainedButton  
// 它没有前缀,因为它是最常见的  
@Composable  
fun Button(...) {}  
  
// 其他按钮变体如下:  
@Composable  
fun OutlinedButton(...) {}  
  
@Composable  
fun TextButton(...) {}  
  
@Composable  
fun GlideImage(...) {}  

也可以这样做(如果你的库基于 compose-foundation)

// 包 com.company.project  
// 依赖于 foundation,不依赖于 material 或 material3  
  
@Composable  
fun Button(...) {} // 简单的名称,感觉像一等公民按钮  
  
@Composable  
fun TextField(...) {} // 简单的名称,感觉像一等公民文本字段

组件依赖

Jetpack Compose 框架开发 必须遵循本节中的规则。

库开发 应遵循以下部分。

应用开发 可以遵循以下规则。

优先使用多个组件而不是样式类

以细粒度、语义上有意义的方式表达依赖关系。避免使用类似 ComponentStyleComponentConfiguration 的大杂烩样式参数和类。

当某一子集的同类型组件需要具有相同的配置或风格外观时,应鼓励用户创建自己的语义上有意义的组件版本。这可以通过包装组件或分叉它并使用较低级别的构建块来完成。组件开发人员有责任确保这两种方式都是低成本操作。

与其依赖 ComponentStyle 来指定组件库中的不同组件变体,不如考虑提供单独的 @Composable 函数,以不同的名称表示这些组件在样式和用例上的差异。

不要这样做

// 库代码  
class ButtonStyles(  
	/* 各种参数的集合,如颜色、填充、边框 */  
	background: Color,  
	border: BorderStroke,  
	textColor: Color,  
	shape: Shape,  
	contentPadding: PaddingValues  
)  
  
val PrimaryButtonStyle = ButtonStyle(...)  
val SecondaryButtonStyle = ButtonStyle(...)  
val AdditionalButtonStyle = ButtonStyle(...)  
  
@Composable  
fun Button(  
	onClick: () -> Unit,  
	style: ButtonStyle = SecondaryButtonStyle  
) {  
	// 实现  
}  
  
// 用法  
val myLoginStyle = ButtonStyle(...)  
Button(style = myLoginStyle)  

这样做:

// 库代码  
@Composable  
fun PrimaryButton(  
	onClick: () -> Unit,  
	background: Color,  
	border: BorderStroke,  
	// 其他相关参数  
) {  
	// 实现  
}  
  
@Composable  
fun SecondaryButton(  
	onClick: () -> Unit,  
	background: Color,  
	border: BorderStroke,  
	// 其他相关参数  
) {  
	// 实现  
}  
  
// 用法 1:  
PrimaryButton(onClick = { loginViewModel.login() }, border = NoBorder)  
// 用法 2:  
@Composable  
fun MyLoginButton(  
	onClick: () -> Unit  
) {  
// 委托并包装其他组件或其构建块  
SecondaryButton(  
	onClick,  
	background = MyLoginGreen,  
	border = LoginStroke  
)  
}  

显式与隐式依赖

优先在组件中使用显式输入和配置选项,例如函数参数。组件的显式输入使其行为易于预测、调整、测试和使用。

避免通过 CompositionLocal 或其他类似机制提供隐式输入。这些输入增加了组件和每次使用的复杂性,使用户难以跟踪自定义的来源。为了避免隐式依赖,使用户能够轻松创建自己的有见地的组件,并提供他们希望自定义的显式输入子集。

不要这样做

// 避免使用 composition locals 进行组件特定的自定义  
// 它们是隐式的。组件变得难以更改、测试、使用。  
val LocalButtonBorder = compositionLocalOf<BorderStroke>(...)  
  
@Composable  
fun Button(  
	onClick: () -> Unit,  
) {  
	val border = LocalButtonBorder.current  
}  

这样做:

@Composable  
fun Button(  
	onClick: () -> Unit,  
	// 显式请求显式参数,可能具有合理的默认值  
	border: BorderStroke = ButtonDefaults.borderStroke,  
) {  
	// 实现  
}  

考虑使用 CompositionLocal 提供全局应用或屏幕样式(如果需要)。例如,material 库中的设计主题或排版可以为整个应用或屏幕隐式指定。在这样做时,请确保在组件参数的默认表达式中读取这些 CompositionLocals,以便用户可以覆盖它们。

由于这些对象很少更改并覆盖不同类型组件的大子树,应用范围的自定义灵活性通常值得上述隐式输入的缺点。在这种情况下,应避免在实现中读取此 CompositionLocal,而应在默认表达式中读取它,以便在自定义或包装组件时易于覆盖。

不要这样做

// 这是可以的:主题是全局的,但...  
class Theme(val mainAppColor: Color)  
val LocalAppTheme = compositionLocalOf { Theme(Color.Green) }  
  
@Composable  
fun Button(  
	onClick: () -> Unit,  
) {  
	// 在实现中读取主题使其无法选择退出  
	val buttonColor = LocalAppTheme.current.mainAppColor  
	Box(modifier = Modifier.background(buttonColor)) { ... }  
}  

这样做:

// 这是可以的:主题是全局的  
class Theme(val mainAppColor: Color)  
val LocalAppTheme = compositionLocalOf { Theme(Color.Green) }  
  
@Composable  
fun Button(  
	onClick: () -> Unit,  
	// 易于查看值的来源并更改它  
	backgroundColor: Color = LocalAppTheme.current.mainAppColor  
) {  
	Box(modifier = Modifier.background(backgroundColor)) { ... }  
}  

有一篇 博客文章 描述了在“保持 API 一致性”一章中的详细原因。

组件参数

关于 @Composable 组件参数的一组考虑因素。

Jetpack Compose 框架开发 必须遵循以下部分中的规则。

Compose 库开发 应遵循以下部分中的规则。

应用开发 应遵循。

组件上的参数与修饰符

不要引入可以通过修饰符添加的可选参数。参数应允许设置或自定义组件内部存在的行为。

不要这样做:

@Composable  
fun Image(  
	bitmap: ImageBitmap,  
	// 不是核心功能,点击可以通过 Modifier.clickable 添加  
	onClick: () -> Unit = {},  
	modifier: Modifier = Modifier,  
	// 可以通过 `Modifier.clip(CircleShape)` 指定  
	clipToCircle: Boolean = false  
)  

这样做:

@Composable  
fun Button(  
	onClick: () -> Unit,  
	// 修饰符参数已指定,以便可以添加宽度、填充等  
	modifier: Modifier = Modifier,  
	// 按钮是一个有颜色的矩形,可以点击,因此背景被视为核心功能,可以作为参数  
	backgroundColor: Color = MaterialTheme.colors.primary  
)  

modifier 参数

每个发出 UI 的组件都应具有修饰符参数。确保修饰符参数:

  • 类型为 Modifier
    • 类型 Modifier 确保可以将任何修饰符传递给组件。
  • 是第一个可选参数。
    • 如果组件具有非零默认大小 - 修饰符应为可选参数,因为组件是自给自足的。对于默认大小为零的组件,修饰符参数可以是必需参数。
    • 由于修饰符是任何组件推荐的,并且经常使用,将其放在第一个可选参数确保可以在不使用命名参数的情况下设置它,并为任何组件提供一致的位置。
  • 具有无操作默认值 Modifier
    • 无操作默认值确保当用户为组件提供自己的修饰符时不会丢失任何功能。
  • 是参数列表中唯一的 Modifier 类型参数。
    • 由于修饰符旨在修改组件的外部行为和外观,一个修饰符参数应该足够。考虑请求特定参数或重新考虑组件的分层(例如,将组件拆分为两个)。
  • 作为链中的第一个修饰符应用于组件实现中最根本的布局。
    • 由于修饰符旨在修改组件的外部行为和外观,它们必须应用于最外层的布局,并且是链中的第一个修饰符。可以将其他修饰符链接到作为参数传递的修饰符。

为什么? 修饰符是 Compose 的基本部分,用户对其行为和 API 有期望。本质上,修饰符提供了一种修改组件外部行为和外观的方法,而组件实现将负责内部行为和外观。

不要这样做:

@Composable  
fun Icon(  
	bitmap: ImageBitmap,  
	// 没有修饰符参数  
	tint: Color = Color.Black  
)  

不要这样做:

@Composable  
	fun Icon(  
	bitmap: ImageBitmap,  
	tint: Color = Color.Black,  
	// 1:修饰符不是第一个可选参数  
	// 2:一旦用户设置了自己的修饰符,填充将丢失  
	modifier: Modifier = Modifier.padding(8.dp)  
)  

不要这样做:

@Composable  
fun CheckboxRow(  
	checked: Boolean,  
	onCheckedChange: (Boolean) -> Unit,  
	// 不要 - 修饰符旨在指定 CheckboxRow 本身的外部行为,而不是其子部分。将它们作为插槽  
	rowModifier: Modifier = Modifier,  
	checkboxModifier: Modifier = Modifier  
)  

不要这样做:

@Composable  
fun IconButton(  
	buttonBitmap: ImageBitmap,  
	modifier: Modifier = Modifier,  
	tint: Color = Color.Black  
) {  
	Box(Modifier.padding(16.dp)) {  
	Icon(  
		buttonBitmap,  
		// 修饰符应应用于最外层的布局,并且是链中的第一个  
		modifier = Modifier.aspectRatio(1f).then(modifier),tint = tint)  
	}  
}  

这样做:

@Composable  
fun IconButton(  
	buttonBitmap: ImageBitmap,  
	// 好的:第一个可选参数,唯一的同类参数  
	modifier: Modifier = Modifier,  
	tint: Color = Color.Black  
) {  
	// 好的:应用于最外层布局之前的其他修饰符  
	Box(modifier.padding(16.dp)) {  
	Icon(buttonBitmap, modifier = Modifier.aspectRatio(1f), tint = tint)}  
}  

也可以这样做:

@Composable  
fun ColoredCanvas(  
	// 好的:画布没有固有大小,要求大小修饰符  
	modifier: Modifier,  
	color: Color = Color.White,  
	...  
) {  
	// 好的:应用于最外层布局之前的其他修饰符  
	Box(modifier.background(color)) {  
		...  
	}  
}  

参数顺序

组件中参数的顺序必须如下:

  1. 必需参数。
  2. 单个 modifier: Modifier = Modifier
  3. 可选参数。
  4. (可选)尾随 @Composable lambda。

为什么? 必需参数表示组件的契约,因为它们必须传递并且是组件正常工作所必需的。将必需参数放在首位,API 清楚地表明了组件的要求和契约。可选参数表示组件的一些自定义和附加功能,不需要用户立即关注。

参数顺序的解释:

  1. 必需参数。没有默认值的参数,用户必须传递这些参数的值才能使用组件。首先出现,它们允许用户在不使用命名参数的情况下设置它们。
  2. modifier: Modifier = Modifier。修饰符应作为 @composable 函数中的第一个可选参数。它必须命名为修饰符,并具有 Modifier 的默认值。参数列表中应只有一个修饰符参数,并应应用于实现中最根本的布局。有关更多信息,请参阅“修饰符参数”部分。
  3. 可选参数。具有默认值的参数,如果用户不覆盖这些默认值,将使用这些默认值。紧随必需参数和修饰符参数之后,它们不需要用户立即做出选择,并允许使用命名参数逐个覆盖。
  4. (可选)尾随 @Composable lambda,表示组件的主要内容,通常命名为 content。它可以具有默认值。具有非 @composable 尾随 lambda(例如 onClick)可能会误导,因为用户期望在组件中具有尾随 lambda 是 @Composable。对于 LazyColumn 和其他类似 DSL 的例外情况,具有非 @composable lambda 是可以的,因为它仍然表示主要内容。

考虑“必需”和“可选”子组内参数的顺序。类似于必需参数和可选参数之间的划分,首先看到数据或组件的“是什么”部分对读者和 API 用户是有益的,而元数据、自定义、组件的“如何”部分应放在后面。

在必需或可选组内按语义分组参数是有意义的。如果你有多个颜色参数(backgroundColorcontentColor),请考虑将它们放在一起,以便用户轻松查看自定义选项。

这样做

@Composable  
fun Icon(  
	// 图像位图和内容描述是必需的  
	// 位图首先出现,因为它是图标的必需数据  
	bitmap: ImageBitmap,  
	// 内容描述紧随其后,必需,但它是“元数据”,因此放在“数据”之后。  
	contentDescription: String?,  
	// 修饰符是第一个可选参数  
	modifier: Modifier = Modifier,  
	// tint 是可选的,默认值使用类似主题的 composition locals  
	// 因此很清楚它的来源以及如何更改它  
	tint: Color = LocalContentColor.current.copy(alpha = LocalContentAlpha.current)
)

这样做

@Composable  
fun LazyColumn(  
	// 除内容外没有必需参数,修饰符是第一个可选参数 
	modifier: Modifier = Modifier,  
	// 状态很重要,是“数据”:第二个可选参数  
	state: LazyListState = rememberLazyListState(),  
	contentPadding: PaddingValues = PaddingValues(0.dp),  
	reverseLayout: Boolean = false,  
	// 布局和对齐方式一个接一个,因为它们是相关的  
	verticalArrangement: Arrangement.Vertical =  
	if (!reverseLayout) Arrangement.Top else Arrangement.Bottom,  
	horizontalAlignment: Alignment.Horizontal = Alignment.Start,  
	flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(),  
	userScrollEnabled: Boolean = true,  
	// 带有内容的尾随 lambda  
	content: LazyListScope.() -> Unit  
)  

可空参数

在参数的语义意义或其缺失之间做出有意识的选择。参数的默认值、空值和缺失值之间存在差异。在为 API 选择正确的语义时必须做出有意识的选择。

  • 参数的可空性应作为信号,允许用户表示参数的“缺失”及相应的组件功能。
  • 避免使参数可空,以利用 null 作为“在实现中使用默认值”的信号。
  • 避免使参数可空,以表示值存在但为空,优先使用有意义的空默认值。

不要这样做

@Composable  
fun IconCard(  
	bitmap: ImageBitmap,  
	// 避免使用 null 作为信号来获取默认值  
	elevation: Dp? = null  
) {  
	// 提供有意义的默认值,而不是基于实现的默认解析  
	val resolvedElevation = elevation ?: DefaultElevation  
}  

这样做:

@Composable  
fun IconCard(  
	bitmap: ImageBitmap,  
	elevation: Dp = 8.dp  
) { ... }  

或者这样做(null 在这里有意义):

@Composable  
fun IconCard(  
bitmap: ImageBitmap,  
	// null 描述与 "" 描述不同  
	// 因为当它为 null 时 - 我们不会添加任何辅助功能信息。  
	contentDescription: String?  
) { ... }  

默认表达式

开发人员应确保可选参数的默认表达式是公开可用且有意义的。最佳实践:

  • 默认表达式不包含私有/内部调用。这允许包装/扩展组件的用户提供相同的默认值。或者,用户可以在 if 语句中使用此默认值:if (condition) default else myUserValue
  • 默认值应具有有意义的值,应清楚默认值是什么。避免使用 null 作为内部使用默认值的标记。请参阅 “可空参数” 部分了解更多信息。在这种情况下,值的缺失(null)是有效的默认值。
  • 如果你有多个默认值,请使用 ComponentDefaults 对象来命名空间默认值。

不要这样做

@Composable  
fun IconCard(  
	bitmap: ImageBitmap,  
	//backgroundColor 有意义的默认值,但用户无法访问  
	backgroundColor: Color = DefaultBackgroundColor,  
	// 避免使用 null 作为信号来获取默认值  
	elevation: Dp? = null  
) {  
	// 提供有意义的默认值,而不是基于实现的默认解析  
	val resolvedElevation = elevation ?: DefaultElevation  
}  
  
// 这个默认表达式是私有的。  
// 用户无法在包装你的组件时访问它。  
private val DefaultBackgroundColor = Color.Red
private val DefaultElevation = 8.dp

这样做:

@Composable  
fun IconCard(  
	bitmap: ImageBitmap,  
	//所有参数都有有意义的默认值,并且是可访问的  
	backgroundColor: Color = IconCardDefaults.BackgroundColor,  
	elevation: Dp = IconCardDefaults.Elevation  
) { ... }  
  
// 默认值在 ComponentNameDefaults 对象中命名空间并公开  
object IconCardDefaults {  
    val BackgroundColor = Color.Redval Elevation = 8.dp
}  
Note

注意:如果你的组件有数量有限的参数,并且具有简短且可预测的默认值(elevation = 0.dp),则可以省略 ComponentDefaults 对象,而使用简单的内联常量。

MutableState<T> 作为参数

不鼓励使用 MutableState<T> 类型的参数,因为它促进了组件和其用户之间对状态的共同所有权。如果可能,请考虑使组件无状态,并将状态更改让给调用方。如果组件中需要更改父级拥有的属性,请考虑创建一个 ComponentState 类,其中包含由 mutableStateOf() 支持的领域特定有意义字段。

当组件接受 MutableState 作为参数时,它获得了更改它的能力。这导致状态的共同所有权,并且拥有状态的使用方现在无法控制组件实现中何时以及如何更改它。

不要这样做

@Composable  
fun Scroller(  
	offset: MutableState<Float>  
) {}

这样做(如果可能,无状态版本):

@Composable  
fun Scroller(  
    offset: Float,  
    onOffsetChange: (Float) -> Unit,  
) {}  

或者这样做(基于状态的组件版本,如果无状态版本不可行):

class ScrollerState {  
    val offset: Float by mutableStateOf(0f)  
}  
  
@Composable  
fun Scroller(  
    state: ScrollerState  
) {}  

State<T> 作为参数

不鼓励使用 State<T> 类型的参数,因为它不必要地缩小了可以传递给函数的对象类型。给定参数:State<Float>,根据用例,有两种更好的替代方案:

  1. param: Float。如果参数不经常更改,或者在组件(组合)中立即读取,则开发人员可以仅提供一个普通参数,并在更改时重新组合组件。
  2. param: () -> Float。为了延迟读取值直到稍后通过 param.invoke(),可以将 lambda 作为参数提供。这允许组件开发人员仅在需要时读取值,并避免不必要的工作。例如,如果仅在绘图操作期间读取值,仅会发生重绘。这使用户可以提供任何表达式,包括 State<T> 的读取:
    • param = { myState.value } - 读取 State<T> 的值
    • param = { justValueWithoutState } - 不由 State<T> 支持的普通值
    • param = { myObject.offset } - 用户可以拥有一个自定义状态对象,其中字段(例如 offset)由 mutableStateOf() 支持

不要这样做

fun Badge(position: State<Dp>) {}  
  
// 不可能,因为只允许 State<T>  
Badge(position = scrollState.offset) // 无法编译  

这样做:

fun Badge(position: () -> Dp) {}  
  
// 工作正常  
Badge(position = { scrollState.offset })  

插槽参数

什么是插槽

插槽是一个 @Composable lambda 参数,用于指定组件的某个子层次结构。按钮中的内容插槽可能如下所示:

@Composable  
fun Button(  
onClick: () -> Unit,  
content: @Composable () -> Unit) {}  
  
// 用法  
Button(onClick = { /* 处理点击 */}) {  
Icon(...)  
}  

这种模式允许按钮对内容没有意见,同时扮演绘制必要装饰、处理点击和显示涟漪的角色。

为什么使用插槽

编写按钮可能很诱人,如下所示:

不要这样做

@Composable  
fun Button(  
    onClick: () -> Unit,  
    text: String? = null,  
    icon: ImageBitmap? = null  
) {}  

其中 text 或 icon 或两者都存在,按钮负责安排显示。虽然它处理了基本用例或示例用法,但它在灵活性方面存在一些根本缺陷:

  • 限制样式选择:仅使用 String,按钮不允许用户在需要时使用 AnnotatedString 或其他文本信息源。为了提供一些样式,按钮将不得不接受 TextStyle 参数以及其他一些参数。这将迅速膨胀按钮的 API。
  • 限制组件选择:虽然按钮可能希望显示文本,但 String 可能不够。如果用户有自己的 MyTextWithLogging() 组件,他们可能希望在按钮中使用它来执行一些额外的逻辑,如记录事件等。这在 String API 中是不可能的,除非用户分叉按钮。
  • 重载爆炸:如果我们希望有一些灵活性,例如接受 ImageBitmapVectorPainter 作为图标,我们必须为此提供一个重载。我们可以将其乘以每个这样的参数(textStringAnnotatedStringCharSequence),导致我们必须提供大量重载以满足用户的用例。
  • 限制组件布局能力:在上述示例中,按钮对文本和图标之间的排列有意见。如果用户有一个特殊的图标,他们希望使用自定义排列(例如在按钮的文本基线上或额外的 4dp 填充),他们将无法做到这一点。

组件中的插槽 API 不存在这些问题,因为用户可以在插槽中传递任何组件,并且具有任何样式。插槽的代价是简单用法稍微冗长,但这种缺点在实际应用中很快消失。

这样做

@Composable  
fun Button(  
    onClick: () -> Unit,  
    text: @Composable () -> Unit,
    icon: @Composable () -> Unit
) {}  

单个“内容”插槽重载

对于负责布局其接受的多个插槽 API 的组件,请考虑提供一个带有单个插槽的重载,通常命名为内容。这允许在需要时在使用端具有更大的灵活性,因为可以更改插槽布局逻辑。

这样做

@Composable  
fun Button(  
    onClick: () -> Unit,  
    content: @Composable () -> Unit
) {}  
  
// 用法  
Button(onClick = { /* 处理点击 */}) {  
    Row {  
        Icon(...)  
        Text(...)  
    }  
}  

插槽 API 的布局策略范围

如果适用,请考虑为插槽 lambda 选择适当的布局策略。这对于单个内容重载尤为重要。在上述示例中,按钮的开发人员可能会注意到最常见的用法模式包括:单个文本、单个图标、行中的图标和文本、行中的文本然后是图标。可能有意义的是在内容插槽中提供 RowScope,使用户更容易使用按钮

这样做

@Composable  
fun Button(  
    onClick: () -> Unit,  
    content: @Composable RowScope.() -> Unit
) {}  
  
// 用法  
Button(onClick = { /* 处理点击 */ }) { // this: RowScopeIcon(...)  
    Text(...)  
}  

ColumnScopeBoxScope 是其他类型布局策略的良好候选者。组件的作者应始终考虑如果在插槽中传递多个组件会发生什么,并通过范围(上述按钮示例中的 RowScope)将此行为传达给用户。

插槽参数的生命周期期望

开发人员应确保可见和组合的插槽参数可组合项的生命周期与接受该插槽的可组合项相同,或与插槽在视口中的可见性相关联。

在插槽中传递的 @Composable 组件不应在父组件中的结构或视觉更改时被处置并重新组合。

如果需要进行影响插槽可组合项生命周期的内部结构更改,请使用 remember{}movableContentOf()

不要这样做

@Composable  
fun PreferenceItem(  
    checked: Boolean,  
    content: @Composable () -> Unit) {  
        // 不要:此逻辑将在 `checked` 布尔值更改时从头开始处置并重新组合 content() 可组合项  
        if (checked) {  
            Row {  
                Text("Checked")  
                content()  
            }  
        } else {  
            Column {  
            Text("Unchecked")  
            content()  
        }  
    }  
}  

这样做

@Composable  
fun PreferenceItem(  
    checked: Boolean,  
    content: @Composable () -> Unit) {  
    Layout({  
        Text("Preference item")  
        content()  
    }) {  
    // 自定义布局,当 `checked` 更改时重新布局相同的 `content` 实例  
    } 
}  

或者这样做

@Composable  
fun PreferenceItem(  
    checked: Boolean,  
    content: @Composable () -> Unit) {  
    // 此调用在行和列之间保留 `content` 的生命周期  
    val movableContent = remember(content) { movableContentOf(content)}  
    if (checked) {  
        Row {  
            Text("Checked")  
            movableContent()  
        }  
    } else {  
        Column {  
            Text("Unchecked")  
            movableContent()  
        }  
    }  
}  

期望插槽在离开 UI 或视口时被处置,并在再次可见时重新组合:

这样做:

@Composable  
fun PreferenceRow(  
    checkedContent: @Composable () -> Unit,checked: Boolean  
) {  
    // 由于 checkedContent() 仅在选中状态下可见  
    // 可以在不显示时处置此插槽  
    // 并在再次显示时重新组合  
    if (checked) {  
        Row {  
            Text("Checked")  
            checkedContent()  
        }  
    } else {  
        Column {  
            Text("Unchecked")  
        }  
    }  
}  

基于 DSL 的插槽

避免基于 DSL 的插槽和 API,并优先使用简单的插槽 @Composable lambda。虽然让开发人员控制用户可能在特定插槽中放置的内容,但 DSL API 仍然限制了组件和布局能力的选择。此外,DSL 为用户学习和开发人员支持引入了新的 API 开销。

不要这样做

@Composable  
fun TabRow(  
    tabs: TabRowScope.() -> Unit  
) {}  
  
interface TabRowScope {  
    // 可以是字符串  
    fun tab(string: String)  
    // 也可以是 @composable  
    fun tab(tabContent: @Composable () -> Unit)
}  

考虑依赖带有参数的普通插槽。这允许用户使用他们已经知道的工具,而不会牺牲任何灵活性。

改为这样做:

@Composable  
fun TabRow(  
    tabs: @Composable () -> Unit
) {}  
  
@Composable  
fun Tab(...) {}  
  
// 用法  
TabRow {  
    tabsData.forEach { data ->  
        Tab(...)  
    }  
}  

定义组件内容或其子项的 DSL 应被视为例外情况。在某些情况下,DSL 方法是有益的,特别是当组件希望仅懒惰地显示和组合子项的子集时(例如 LazyRowLazyColumn)。

允许,因为需要懒惰和灵活性以处理不同的数据类型:

@Composable  
fun LazyColumn(  
    content: LazyListScope.() -> Unit  
) {}  
  
// 用法:DSL 很好,因为它允许 LazyColumn 懒惰地组合子项的子集  
LazyColumn {  
    // 允许定义不同类型的子项并以不同方式处理它们  
    // 因为 sticky header 可以同时充当项目和 sticky header  
    stickyHeader {  
        Text("Header")  
    }  
    items(...) {  
        Text($index)  
    }  
}  

即使在像 LazyColumn 这样的情况下,也可以定义没有 DSL 的 API 结构,因此应首先考虑简单版本

这样做。更简单、更易于学习和使用的 API,仍然提供子项组合的懒惰性:

@Composable  
fun HorizontalPager(  
    // pager 仍然在需要时懒惰地组合页面  
    // 但 API 更简单、更易于使用;不需要 DSL  
    pageContent: @Composable (pageIndex: Int) -> Unit) {
    
}  

组件相关的类和函数

Jetpack Compose 框架开发 必须遵循本节中的规则。

库开发 应遵循以下部分。

应用开发 可以遵循以下规则。

State

有关状态的核心设计实践,请访问 compose api 指南中的相应部分

ComponentDefault 对象

所有组件默认表达式应内联或位于名为 ComponentDefaults 的顶级对象中,其中 Component 是实际组件名称。有关详细信息,请参阅“默认表达式”部分。

ComponentColor/ComponentElevation 对象

考虑在默认语句中使用简单的 if-else 表达式进行简单的分支逻辑,或专用的 ComponentColor/ComponentElevation 类,明确定义特定颜色/高度可以反映的输入。

有多种方法可以根据组件的状态(例如启用/禁用、聚焦/悬停/按下)提供和/或允许自定义某种单一类型的参数(例如颜色、dp)。

这样做(如果颜色选择逻辑简单)

@Composable  
fun Button(  
    onClick: () -> Unit,  
    enabled: Boolean = true,  
    backgroundColor =  
        if (enabled) ButtonDefaults.enabledBackgroundColor  
        else ButtonDefaults.disabledBackgroundColor,  
    elevation =  
        if (enabled) ButtonDefaults.enabledElevation  
        else ButtonDefaults.disabledElevation,  
    content: @Composable RowScope.() -> Unit
) {}  

虽然这很好用,但这些表达式可能会迅速增长并污染 API 空间。这就是为什么将其隔离到特定领域和参数类可能是明智的。

这样做(如果颜色条件逻辑更复杂)

class ButtonColors(  
    backgroundColor: Color,  
    disabledBackgroundColor: Color,  
    contentColor: Color,  
    disabledContentColor: Color  
) {  
    fun backgroundColor(enabled: Boolean): Color { ... }  
  
    fun contentColor(enabled: Boolean): Color { ... }}  
  
    object ButtonDefaults {  
        // 类的默认工厂  
        // 可以是 @Composable 以访问主题 composition locals  
        fun colors(  
        backgroundColor: Color = ...,  
        disabledBackgroundColor: Color = ...,  
        contentColor: Color = ...,  
        disabledContentColor: Color = ...  
    ): ButtonColors { ... }  
}  
  
@Composable  
fun Button(  
    onClick: () -> Unit,  
    enabled: Boolean = true,  
    colors: ButtonColors = ButtonDefaults.colors(),  
    content: @Composable RowScope.() -> Unit  
) {  
    val resolvedBackgroundColor = colors.backgroundColor(enabled)  
}  

这样做,在不引入“样式”模式的开销和复杂性的情况下,我们隔离了组件的特定部分配置。此外,与普通默认表达式不同,ComponentColorsComponentElevation 类允许更细粒度的控制,用户可以分别指定启用和禁用的颜色/高度。

Note

注意:这种方法不同于 compose “无样式”章节中不鼓励使用样式的原因。ComponentColor 和其他此类类针对组件的某种类型功能,允许根据显式输入定义颜色。此类的实例必须作为组件的显式参数传递。

注意:虽然 ComponentColorsComponentElevation 是最常见的模式,但还有其他组件参数可以以类似的方式隔离。

组件的文档

Jetpack Compose 框架开发 应遵循本节中的规则。

Compose 库开发 可以遵循以下部分。

应用开发 可以遵循。

@Composable 组件的文档应遵循 JetBrains 的 ktdoc 指南和语法。此外,文档必须通过多种渠道向开发人员传达组件的功能:组件目的的描述、参数及其期望、用法示例。

文档结构和顺序

每个组件应具有以下文档结构:

  1. 总结组件及其功能的一句话段落。
  2. 更详细地描述组件的段落,概述功能、行为,并可能包括以下内容之一或多个:
    • 提供此组件及其状态、默认值等用法示例的 @sample 标签。如果你无法访问 @sample 功能,请考虑在 ktdoc 中的内联示例。
    • 指向其他相关 API 的 @see 标签。
    • 链接到设计或其他材料,以帮助充分利用组件。
  3. 描述组件每个参数的内容,从 @param paramname 开始。
    • 开发人员可以选择性地省略尾随 @Composable 内容 lambda 的文档,因为它始终表示组件的主要内容插槽。

文档示例

这样做

/**  
* Material Design 徽章框。  
*  
* 徽章表示动态信息,例如导航栏中的待处理请求数量。徽章可以是仅图标或包含简短文本。  
*  
* ![徽章图像](https://developer.android.com/images/reference/androidx/compose/material3/badge.png)  
*  
* 一个常见的用例是在导航栏项目中显示徽章。  
* 有关更多信息,请参阅 [导航栏](https://m3.material.io/components/navigation-bar/overview)  
*  
* 一个简单的带有徽章的图标示例如下:  
* @sample androidx.compose.material3.samples.NavigationBarItemWithBadge  
*  
* @param badge 要显示的徽章 - 通常是 [Badge]  
* @param modifier 应用于此 BadgedBox 的 [Modifier]  
* @param content 徽章将定位到的锚点  
*/  
@ExperimentalMaterial3Api
@Composable  fun BadgedBox(  
    badge: @Composable BoxScope.() -> Unit,  
    modifier: Modifier = Modifier,  
    content: @Composable BoxScope.() -> Unit  
)  

组件的可访问性

考虑使用基础构建块,如 Modifier.clickableImage 以提高可访问性。这些构建块将在可能的情况下提供良好的默认值,或明确要求所需信息。使用 ui 级别的块(如 LayoutModifier.pointerInput)时需要手动处理可访问性。本节包含有关可访问 API 设计和可访问性实现调整的最佳实践。

语义合并

Jetpack Compose 使用语义合并来实现可访问性。因此,带有内容插槽的按钮不必为辅助功能服务设置文本以进行宣布。相反,内容的语义(图标的 contentDescription 或文本的 text)将合并到按钮中。有关更多信息,请参阅 官方文档

要手动创建一个将合并其所有子项的节点,可以将 Modifier.semantics(mergeDescendants = true) 修饰符设置为组件。这将强制所有非合并子项收集并传递数据到你的组件,因此它将被视为单个实体。一些基础层修饰符默认合并后代(例如 Modifier.clickableModifier.toggleable)。

与可访问性相关的参数

对于特别常见的可访问性需求,开发人员可能希望接受一些与可访问性相关的参数,以便用户提供更好的可访问性。这对于叶组件如 ImageIcon 尤其重要。Image 具有必需参数 contentDescription,以向用户发出传递图像必要描述的信号。在开发组件时,开发人员需要在实现中内置什么与通过参数向用户请求什么之间做出有意识的决定。

请注意,如果你遵循提供普通修饰符参数的正常最佳实践,并将其放在根布局元素上,这本身就提供了大量隐式可访问性自定义。因为组件的用户可以提供自己的 Modifier.semantics,它将应用于你的组件。此外,这还为开发人员提供了一种覆盖组件默认语义部分的方法:如果在一个修饰符链上有两个具有相同键的 SemanticsProperties,Compose 通过让第一个获胜并忽略后面的来解决冲突。

因此,你不需要为组件可能需要的每个语义添加一个参数。你应该将它们保留给特别常见的情况,在这种情况下,每次写出语义块都很不方便,或者在某些情况下修饰符机制不起作用(例如,你需要将语义添加到组件的内部子项)。

可访问性调整

虽然使用基础层构建块将授予基本的可访问性功能,但开发人员可以使组件更具可访问性。

对于个别类别的组件,期望有特定的语义:简单组件通常需要 1-3 个语义,而更复杂的组件如文本字段、滚动容器或时间/日期选择器需要非常丰富的语义集才能与屏幕阅读器正确配合。在开发新的自定义组件时,首先考虑它最类似于现有的标准 Compose 组件,并模仿该组件实现提供的语义以及使用的确切基础构建块。从那里开始微调并在需要时添加更多语义操作和/或属性。

工具支持

Jetpack Compose 框架开发 应遵循本节中的规则。

Compose 库开发 可以遵循以下部分。

应用开发 可以遵循。

考虑组件在应用开发工具中的行为,包括 Android Studio 预览和测试基础设施。组件应在这些环境中正常工作,以提高开发人员的生产力。

Compose 预览工具

组件应在非交互式预览模式下显示初始状态。

组件应避免延迟初始渲染到后续帧的模式。避免使用 LaunchedEffects 或异步逻辑进行初始组件状态设置。

如果需要,请使用 LocalInspectionMode.current 检测何时作为预览运行,并进行最小的更改以确保预览功能正常。避免在预览中用一些占位符图像替换复杂组件。确保你的组件在通过预览工具提供的各种参数下正常工作。

在交互模式下,预览应允许直接使用组件,并具有与在应用中运行时相同的交互体验。

截图测试

组件应支持截图测试。

优先使用无状态组件,其中状态作为参数传递,以确保组件在各种状态下可进行截图测试。或者,支持使用 Compose 测试 API,如 SemanticsMatcher 以影响内部状态。

特定于 Android 的组件应理想地支持 Compose 预览截图测试 和 Robolectric (RNG) 以实现有效的截图测试。

组件 API 的演进

Jetpack Compose 框架开发 必须遵循以下部分中的规则。

Compose 库开发 必须遵循以下部分中的规则。

应用开发 可以遵循。

有关更多信息,请参阅 kotlin 向后兼容性指南

由于每个组合都是一个函数,以下规则适用于组件 API 更改:

  • 函数的参数不得删除。
  • 新添加的参数必须具有默认表达式。
  • 新参数可以作为最后一个参数添加,或在尾随 lambda 之前添加。
    • 开发人员可能决定将新参数放在与新参数语义上接近的其他参数旁边。请记住,如果用户在不使用命名参数的情况下使用组件,这可能会破坏源代码兼容性。

添加新参数到组件的工作流程:

  1. 创建一个带有新参数的重载,并包含默认值。
  2. 使用 DeprecationLevel.Hidden 弃用现有函数以保持二进制兼容性。
  3. 使弃用版本调用你的新版本。

这样做:

// 现有 API 我们希望扩展  
@Deprecated(
    "为了兼容性保留。使用另一个重载", 
    level = DeprecationLevel.HIDDEN
)  
@Composable  
fun Badge(color: Color) {  
    // 默认值
}

// 必须创建新的重载  
@Composable  
fun Badge(  
    color: Color,    // 必须提供默认值    
    secondaryColor: Color = Color.Blue
) {}