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 中完成,然后通过 isErrorerrorMessage 传给 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 知识体系总览

下面这张图,是我对本章知识结构的梳理。你可以看到,从组件封装到主题化,再到仓库发布,是一条完整的链路。

组件库封装知识体系 组件封装 主题化配置 仓库发布 通用组件 Button(状态/类型) TextField(校验/图标) Dialog(插槽模式) 主题化 AppTheme 对象 默认 Shape / 高度 可覆盖参数 Maven 本地仓库 maven-publish 插件 publishToMavenLocal implementation 引用 目标:统一、可复用、可配置的组件库 从封装到发布,一条完整的组件化链路

24.7 总结

组件封装这件事,说白了就是「把变和不变分开」。不变的做成默认值,变的做成参数。主题化让样式统一,Maven 仓库让代码共享。

我个人建议,每个团队都应该维护一套自己的组件库。哪怕一开始只有三个组件,也比散落在各个页面里强。嗯,封装这件事,越早做越省心。

核心要点回顾:

  • Button 封装:状态、类型、加载态
  • TextField 封装:校验、图标、键盘类型
  • Dialog 封装:插槽模式,内容灵活
  • 主题化:AppTheme 统一管理默认值
  • 发布:maven-publish 插件,本地仓库引用

公众号:蓝海资料掘金营,微信deep3321