24、组件库封装:通用 Button、TextField、Dialog 封装,主题化与可配置性,发布到 Maven 本地仓库
说实话,做 Android 开发这么多年,我见过太多项目里散落着各种「一次性」的 UI 代码。同一个 Button 样式,在三个页面里写了三遍,改个圆角半径要翻遍整个项目。嗯,这其实不是技术问题,是封装意识的问题。
今天我们就来聊聊,怎么用 Jetpack Compose 把通用组件封装好,再发布到本地 Maven 仓库。我个人习惯是:先搭好组件,再考虑复用,最后才考虑分发。
核心思路: 封装不是把代码藏起来,而是把变化隔离出来。主题化让样式统一,可配置性让组件灵活,Maven 仓库让团队共享。
24.1 通用 Button 封装
先看一个最简单的 Button 封装。你想想看,一个按钮有哪些东西是固定的?圆角、颜色、点击效果。哪些是变化的?文案、点击事件、加载状态。
@Composable
fun AppButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
isLoading: Boolean = false,
buttonType: ButtonType = ButtonType.Primary
) {
val colors = when (buttonType) {
ButtonType.Primary -> ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
)
ButtonType.Secondary -> ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondary
)
ButtonType.Text -> ButtonDefaults.textButtonColors()
}
Button(
onClick = onClick,
modifier = modifier.height(48.dp).fillMaxWidth(),
enabled = enabled && !isLoading,
colors = colors,
shape = RoundedCornerShape(12.dp)
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Text(text = text)
}
}
}
我在项目中遇到过一个问题:设计师要求按钮在加载时不能改变宽度。嗯,这里要注意,CircularProgressIndicator 默认会占满空间,所以最好固定尺寸。
小技巧: 用 ButtonType 枚举来区分按钮风格,比传一堆布尔值要清晰得多。我一般会定义 Primary、Secondary、Text、Danger 四种类型。
24.2 TextField 封装:带状态和校验
TextField 的封装比 Button 复杂一点。为什么?因为输入框涉及到状态管理、错误提示、焦点控制。说白了,你要把 ViewModel 里的逻辑和 UI 解耦。
@Composable
fun AppTextField(
value: String,
onValueChange: (String) -> Unit,
label: String,
modifier: Modifier = Modifier,
isError: Boolean = false,
errorMessage: String? = null,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
keyboardType: KeyboardType = KeyboardType.Text
) {
var isFocused by remember { mutableStateOf(false) }
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = { Text(label) },
modifier = modifier.fillMaxWidth(),
isError = isError,
leadingIcon = leadingIcon,
trailingIcon = trailingIcon,
keyboardOptions = KeyboardOptions(keyboardType = keyboardType),
shape = RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.outline,
errorBorderColor = MaterialTheme.colorScheme.error
),
supportingText = if (isError && errorMessage != null) {
{ Text(errorMessage, color = MaterialTheme.colorScheme.error) }
} else {
null
}
)
}
我曾经踩过一个坑:supportingText 在 Compose 里是直接显示在输入框下方的,如果你用 Modifier.padding 去调整位置,反而会破坏布局。嗯,这里直接用官方提供的参数就好。
注意: 不要把校验逻辑写在 Composable 里。校验应该在 ViewModel 中完成,然后通过 isError 和 errorMessage 传给 UI。否则你的组件会变得又臭又硬,难以测试。
24.3 Dialog 封装:灵活的内容插槽
Dialog 的封装,我个人习惯用「插槽模式」。什么意思?就是把标题、内容、按钮都做成可选的 Composable 参数,调用方想放什么就放什么。
@Composable
fun AppDialog(
title: String? = null,
onDismiss: () -> Unit,
confirmText: String = "确定",
onConfirm: (() -> Unit)? = null,
dismissText: String = "取消",
onDismissAction: (() -> Unit)? = null,
content: @Composable () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = title?.let { { Text(it) } },
text = { content() },
confirmButton = {
if (onConfirm != null) {
TextButton(onClick = onConfirm) {
Text(confirmText)
}
}
},
dismissButton = {
if (onDismissAction != null) {
TextButton(onClick = onDismissAction) {
Text(dismissText)
}
}
},
shape = RoundedCornerShape(16.dp)
)
}
你想想看,这种设计的好处是什么?调用方可以传入任何 Composable 作为内容,比如一个列表、一个表单、甚至一个 Lottie 动画。组件本身只负责弹窗的骨架,不关心血肉。
24.4 主题化与可配置性
组件封装好了,但颜色、字体、圆角都是硬编码的,这不行。我们需要把它们和 MaterialTheme 绑定起来。
我一般会定义一个 AppTheme 对象,里面放所有组件的默认配置:
object AppTheme {
val buttonShape: Shape = RoundedCornerShape(12.dp)
val textFieldShape: Shape = RoundedCornerShape(12.dp)
val dialogShape: Shape = RoundedCornerShape(16.dp)
val defaultButtonHeight: Dp = 48.dp
val defaultTextFieldHeight: Dp = 56.dp
}
然后在每个组件里,允许调用方覆盖这些值:
@Composable
fun AppButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
shape: Shape = AppTheme.buttonShape,
height: Dp = AppTheme.defaultButtonHeight
) {
Button(
onClick = onClick,
modifier = modifier.height(height).fillMaxWidth(),
shape = shape
) {
Text(text)
}
}
这样做的好处是:团队可以统一修改所有按钮的圆角,只需要改 AppTheme 里的一个值。我在项目中就用这个方式,把 30 多个页面的按钮风格一次性统一了。
24.5 发布到 Maven 本地仓库
组件封装好了,怎么让其他模块用?发布到本地 Maven 仓库是最直接的方式。
首先,在组件模块的 build.gradle.kts 里配置:
plugins {
id("maven-publish")
}
publishing {
publications {
create<MavenPublication>("release") {
groupId = "com.example.ui"
artifactId = "common-components"
version = "1.0.0"
afterEvaluate {
from(components["release"])
}
}
}
}
然后执行发布任务:
./gradlew :ui-components:publishToMavenLocal
发布成功后,在其他模块的 build.gradle.kts 里引用:
dependencies {
implementation("com.example.ui:common-components:1.0.0")
}
避坑指南: 我曾经因为忘记在 settings.gradle.kts 里添加 mavenLocal() 仓库,导致引用失败。嗯,这个细节很容易被忽略。
24.6 知识体系总览
下面这张图,是我对本章知识结构的梳理。你可以看到,从组件封装到主题化,再到仓库发布,是一条完整的链路。
24.7 总结
组件封装这件事,说白了就是「把变和不变分开」。不变的做成默认值,变的做成参数。主题化让样式统一,Maven 仓库让代码共享。
我个人建议,每个团队都应该维护一套自己的组件库。哪怕一开始只有三个组件,也比散落在各个页面里强。嗯,封装这件事,越早做越省心。
核心要点回顾:
- Button 封装:状态、类型、加载态
- TextField 封装:校验、图标、键盘类型
- Dialog 封装:插槽模式,内容灵活
- 主题化:AppTheme 统一管理默认值
- 发布:maven-publish 插件,本地仓库引用
公众号:蓝海资料掘金营,微信deep3321