Android Jetpack Compose 实战:从零构建现代化 UI 组件库

Jetpack Compose 已经成为 Android 开发的主流 UI 框架,Google 官方也在不断推动开发者从传统 View 体系迁移到 Compose。然而,很多团队在实际落地时面临一个共同问题:如何设计一套可维护、可复用的组件库,而不是到处复制粘贴代码?
本文将带你从零构建一套团队级的 Compose UI 组件库,涵盖主题系统、常用组件封装、动画效果和性能踩坑。所有代码均基于 Compose 1.6.x + Material3,可直接应用到生产项目。
一、项目结构与模块划分
在开始写组件之前,先确定好目录结构。一个合理的组件库模块应该像这样组织:
ui-components/ ├── src/main/java/com/yourapp/ui/ │ ├── theme/ │ │ ├── Color.kt # 颜色系统 │ │ ├── Typography.kt # 字体系统 │ │ ├── Shape.kt # 形状系统 │ │ └── Theme.kt # 主题入口 │ ├── components/ │ │ ├── button/ │ │ │ ├── AppButton.kt │ │ │ └── IconButton.kt │ │ ├── card/ │ │ │ └── AppCard.kt │ │ ├── input/ │ │ │ ├── AppTextField.kt │ │ │ └── SearchBar.kt │ │ └── feedback/ │ │ ├── AppToast.kt │ │ └── LoadingOverlay.kt │ └── preview/ │ └── ComponentPreview.kt # 统一 Preview 配置
这种结构的好处是:每个组件有独立目录,便于多人协作;theme 层统一管理设计 token;preview 集中管理,方便 UI Review。
二、主题系统设计——设计 Token 的正确实践
很多项目直接用硬编码的颜色值,这在换肤或品牌升级时会是噩梦。正确做法是建立设计 Token 体系:
// Color.kt val Primary = Color(0xFF1976D2) val PrimaryContainer = Color(0xFFBBDEFB) val OnPrimary = Color(0xFFFFFFFF) val Error = Color(0xFFB00020) val Surface = Color(0xFFFAFAFA) // 定义完整的 ColorScheme val LightColorScheme = lightColorScheme( primary = Primary, primaryContainer = PrimaryContainer, onPrimary = OnPrimary, error = Error, surface = Surface, // ... 其他颜色 ) val DarkColorScheme = darkColorScheme( primary = Color(0xFF90CAF9), primaryContainer = Color(0xFF1565C0), onPrimary = Color(0xFF003087), // ... 暗色适配 )
// Theme.kt
@Composable
fun AppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = false, // Android 12+ 动态取色
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context)
else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = AppTypography,
shapes = AppShapes,
content = content
)
}踩坑记录:不要直接在组件内调用 MaterialTheme.colorScheme.primary 来硬编码组件颜色,而是通过参数传入或使用 LocalContentColor。这样组件才能真正跟随主题变化。
三、Button 组件封装——状态与样式分离
官方的 Button 已经足够好用,但在项目中往往需要统一 loading 状态、disabled 样式和点击防抖。来看封装后的效果:
// AppButton.kt
@Composable
fun AppButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
loading: Boolean = false,
buttonType: ButtonType = ButtonType.Primary,
leadingIcon: ImageVector? = null
) {
val containerColor = when (buttonType) {
ButtonType.Primary -> MaterialTheme.colorScheme.primary
ButtonType.Secondary -> MaterialTheme.colorScheme.secondary
ButtonType.Danger -> MaterialTheme.colorScheme.error
ButtonType.Ghost -> Color.Transparent
}
Button(
onClick = onClick,
modifier = modifier.defaultMinSize(minHeight = 48.dp),
enabled = enabled && !loading,
colors = ButtonDefaults.buttonColors(
containerColor = containerColor,
disabledContainerColor = containerColor.copy(alpha = 0.38f)
),
shape = MaterialTheme.shapes.medium
) {
AnimatedContent(
targetState = loading,
transitionSpec = {
fadeIn() togetherWith fadeOut()
},
label = "button_content"
) { isLoading ->
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
color = contentColorFor(containerColor),
strokeWidth = 2.dp
)
} else {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
leadingIcon?.let {
Icon(imageVector = it, contentDescription = null, modifier = Modifier.size(18.dp))
}
Text(text = text)
}
}
}
}
}
enum class ButtonType { Primary, Secondary, Danger, Ghost }使用示例:
var isLoading by remember { mutableStateOf(false) }
AppButton(
text = "提交",
onClick = {
isLoading = true
viewModel.submit { isLoading = false }
},
loading = isLoading,
leadingIcon = Icons.Default.Send
)点击防抖处理:在快速点击场景下,可以在 ViewModel 层控制,也可以在组件内用 debounce 包装 onClick:
fun Modifier.clickDebounced(
debounceTime: Long = 500L,
onClick: () -> Unit
): Modifier = composed {
var lastClickTime by remember { mutableStateOf(0L) }
this.clickable {
val currentTime = System.currentTimeMillis()
if (currentTime - lastClickTime >= debounceTime) {
lastClickTime = currentTime
onClick()
}
}
}四、TextField 封装——统一错误状态与焦点管理
表单场景是 Compose 的高频痛点。官方 OutlinedTextField 用起来没问题,但项目里往往需要统一错误提示、字符计数和清除按钮:
// AppTextField.kt
@Composable
fun AppTextField(
value: String,
onValueChange: (String) -> Unit,
label: String,
modifier: Modifier = Modifier,
placeholder: String = "",
error: String? = null,
maxLength: Int = Int.MAX_VALUE,
showCount: Boolean = false,
clearable: Boolean = false,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default
) {
Column(modifier = modifier) {
OutlinedTextField(
value = value,
onValueChange = { if (it.length Icon(
Icons.Default.Error,
contentDescription = "错误",
tint = MaterialTheme.colorScheme.error
)
clearable && value.isNotEmpty() -> IconButton(onClick = { onValueChange("") }) {
Icon(Icons.Default.Clear, contentDescription = "清除")
}
showCount -> Text(
text = "${value.length}/$maxLength",
style = MaterialTheme.typography.labelSmall,
color = if (value.length >= maxLength)
MaterialTheme.colorScheme.error
else
MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
modifier = Modifier.fillMaxWidth()
)
// 错误信息行
AnimatedVisibility(visible = error != null) {
Text(
text = error ?: "",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(start = 16.dp, top = 4.dp)
)
}
}
}焦点链管理(多个输入框按回车切换焦点):
@Composable
fun LoginForm() {
val focusManager = LocalFocusManager.current
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
AppTextField(
value = username,
onValueChange = { username = it },
label = "用户名",
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Down) }
)
)
AppTextField(
value = password,
onValueChange = { password = it },
label = "密码",
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = { focusManager.clearFocus() }
)
)
}五、动画组件——让 UI 更有生命力
Compose 动画 API 非常强大,但很多开发者只停留在 AnimatedVisibility。这里介绍几个实用的动画模式:
1. 骨架屏(Shimmer 效果)
@Composable
fun ShimmerEffect(
modifier: Modifier = Modifier,
widthOfShadowBrush: Int = 500,
angleOfAxisY: Float = 270f,
durationMillis: Int = 1000
) {
val shimmerColors = listOf(
Color.White.copy(alpha = 0.3f),
Color.White.copy(alpha = 0.5f),
Color.White.copy(alpha = 1.0f),
Color.White.copy(alpha = 0.5f),
Color.White.copy(alpha = 0.3f),
)
val transition = rememberInfiniteTransition(label = "shimmer")
val translateAnimation = transition.animateFloat(
initialValue = 0f,
targetValue = (durationMillis + widthOfShadowBrush).toFloat(),
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = durationMillis, easing = LinearEasing),
repeatMode = RepeatMode.Restart,
),
label = "shimmer_translate"
)
val brush = Brush.linearGradient(
colors = shimmerColors,
start = Offset(x = translateAnimation.value - widthOfShadowBrush, y = 0.0f),
end = Offset(x = translateAnimation.value, y = angleOfAxisY),
)
Box(modifier = modifier.background(brush))
}
// 使用方式
@Composable
fun UserCardSkeleton() {
Row(modifier = Modifier.padding(16.dp)) {
ShimmerEffect(
modifier = Modifier.size(48.dp).clip(CircleShape)
)
Spacer(modifier = Modifier.width(12.dp))
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
ShimmerEffect(modifier = Modifier.width(120.dp).height(16.dp).clip(RoundedCornerShape(4.dp)))
ShimmerEffect(modifier = Modifier.width(80.dp).height(12.dp).clip(RoundedCornerShape(4.dp)))
}
}
}2. 展开/收起动画
@Composable
fun ExpandableCard(
title: String,
content: @Composable () -> Unit
) {
var expanded by remember { mutableStateOf(false) }
val rotationAngle by animateFloatAsState(
targetValue = if (expanded) 180f else 0f,
animationSpec = tween(durationMillis = 300),
label = "arrow_rotation"
)
Card(
onClick = { expanded = !expanded },
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(text = title, style = MaterialTheme.typography.titleMedium)
Icon(
Icons.Default.ExpandMore,
contentDescription = null,
modifier = Modifier.rotate(rotationAngle)
)
}
AnimatedVisibility(
visible = expanded,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut()
) {
Box(modifier = Modifier.padding(top = 8.dp)) {
content()
}
}
}
}
}六、LazyColumn 性能优化——避免常见陷阱
Compose 列表是性能问题的重灾区。这里总结最关键的几个优化点:
1. 始终为列表项指定 key
// ❌ 错误:不指定 key,滚动时 item 会被错误复用
LazyColumn {
items(userList) { user ->
UserCard(user = user)
}
}
// ✅ 正确:指定稳定的 key
LazyColumn {
items(
items = userList,
key = { user -> user.id }
) { user ->
UserCard(user = user)
}
}2. 避免在 items 中创建 Lambda
// ❌ 每次重组都会创建新的 Lambda 对象
LazyColumn {
items(userList, key = { it.id }) { user ->
UserCard(
user = user,
onClick = { viewModel.onUserClick(user.id) } // 每次重组都是新对象
)
}
}
// ✅ 将回调提升或用 remember 缓存
@Stable
data class UserActions(
val onUserClick: (String) -> Unit,
)
LazyColumn {
items(userList, key = { it.id }) { user ->
UserCard(user = user, actions = actions)
}
}3. 使用 derivedStateOf 优化滚动状态监听
// ❌ listState.firstVisibleItemIndex 变化时会触发不必要的重组
val showScrollTop = listState.firstVisibleItemIndex > 0
// ✅ 只有布尔值变化时才触发重组
val showScrollTop by remember {
derivedStateOf { listState.firstVisibleItemIndex > 0 }
}踩坑:图片加载导致的列表抖动:在加载网络图片时,如果没有指定尺寸,图片加载完成后高度变化会导致列表跳动。解决方案是始终为图片指定固定高度或宽高比:
AsyncImage( model = imageUrl, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier .fillMaxWidth() .aspectRatio(16f / 9f) // 固定宽高比,避免加载前后高度变化 )
七、Preview 管理——提升团队开发效率
一个良好的 Preview 体系能大幅提升 UI 开发效率,减少真机调试次数:
// ComponentPreview.kt —— 统一 Preview 配置
@Preview(name = "Light Mode", showBackground = true)
@Preview(name = "Dark Mode", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(name = "Large Font", showBackground = true, fontScale = 1.5f)
annotation class MultiPreview
// 在组件文件中使用
@MultiPreview
@Composable
private fun AppButtonPreview() {
AppTheme {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
AppButton(text = "Primary Button", onClick = {})
AppButton(text = "Loading...", onClick = {}, loading = true)
AppButton(text = "Disabled", onClick = {}, enabled = false)
AppButton(text = "Danger", onClick = {}, buttonType = ButtonType.Danger)
}
}
}通过自定义 @MultiPreview 注解,一次写 Preview 就能同时看到亮色/暗色/大字体效果,无需重复标注。
八、总结与最佳实践清单
经过以上几个核心模块的封装实践,整理出以下最佳实践清单,建议在团队内制定为规范:
✅ 所有颜色、字体、间距通过 Theme 系统管理,禁止硬编码
✅ 组件参数设计遵循"最小必要原则",复杂样式通过
Modifier扩展传入✅ 有副作用的 UI 状态(loading、error)统一作为参数传入,不在组件内部管理
✅ LazyColumn/LazyRow 中的 item 必须指定稳定的 key
✅ 监听滚动、键盘等高频状态时使用
derivedStateOf✅ 网络图片必须指定固定尺寸或宽高比
✅ 每个公共组件必须有覆盖亮暗模式的 Preview
✅ 动画使用
AnimatedContent/AnimatedVisibility而非手动控制透明度
Compose 组件库的建设是一个持续演进的过程。建议从最高频的按钮、输入框、卡片开始封装,在实际项目使用中不断打磨,逐步沉淀出真正适合团队的组件体系。
完整代码已整理为模板项目,有需要的同学欢迎在评论区留言交流。
发布评论
热门评论区: