/ Android  Jetpack Compose  UI组件  Kotlin  Material Design  自定义组件  实战教程  移动开发 

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 组件库的建设是一个持续演进的过程。建议从最高频的按钮、输入框、卡片开始封装,在实际项目使用中不断打磨,逐步沉淀出真正适合团队的组件体系。

完整代码已整理为模板项目,有需要的同学欢迎在评论区留言交流。

发布评论

热门评论区: