先把结论放在前面:组件化的 UI 收尾,本质是从"控件思维"切换到"组件思维"——组件不是把布局 XML 搬进一个 merge 标签,而是把"状态 + 样式 + 行为"三件事收敛成一份可复用的契约。 判断是否到位有三条硬标准:组件内部不写死视觉 token、不感知业务语义、不持有外部上下文。 达不到这三条,就还停留在控件封装阶段。
这题是 UI 阶段的收口题,面试官问的多是"你怎么做组件化的、遇到过什么问题",答法必须落到"契约设计"这个层面,而不是"停留在 <merge> 与自定义属性这一层"。
从控件思维到组件思维
两者的差别可以用一张表说清:
| 维度 | 控件封装 | 组件(Compose) | 组件(View 体系) |
|---|---|---|---|
| 复用单元 | 自定义 View | 无状态 Composable + 状态契约 | merge + 属性集(attrs.xml) |
| 数据输入 | 构造参数或 setter | 参数 | 自定义属性 + setter |
| 状态归属 | 常在内部持有 | 提到调用方 | 靠 setXxx 外部推入 |
| 样式来源 | 组件内写死 | MaterialTheme token |
读 ?attr/,需显式支持 |
| 事件出口 | 回调接口 | lambda 参数 | 回调接口 |
| 测试 | 依赖 Robolectric | 纯函数逻辑可单测 | 依赖 Robolectric |
关键差别在"状态归属"这一行。控件封装天然把状态藏在内部(private boolean selected),而组件要求状态由调用方持有,组件只负责渲染。这条差别决定了组件能不能被复用——隐藏状态是复用最大的敌人。
// 无状态组件:状态由调用方持有,组件只渲染
@Composable
fun UserCard(
state: UserUi, // 数据输入
modifier: Modifier = Modifier,
onClick: () -> Unit, // 行为出口
onAvatarClick: (() -> Unit)? = null
) {
Row(
modifier.clickable(onClick = onClick).padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Avatar(state.avatarUrl, size = 40.dp, onClick = onAvatarClick)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(state.name, style = MaterialTheme.typography.titleMedium)
Text(state.subtitle, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
// 容器组件:持有状态并把状态喂给无状态组件
@Composable
fun UserCardContainer(vm: UserViewModel = viewModel()) {
val state by vm.uiState.collectAsStateWithLifecycle()
UserCard(
state = state,
onClick = {
vm.select(state.id) },
onAvatarClick = {
vm.openAvatar(state.id) }
)
}
这套"无状态 + 容器"的双层结构是 Compose 组件化的标准范式,也是预览(@Preview)、测试、Storybook 式文档三者能成立的前提。
状态契约的三条判据
状态归属确定之后,还要把"状态长什么样"定下来,否则组件之间会互相渗透。判据有三条,按优先级排:
第一条:状态用不可变数据类表达,字段与业务概念对应而不是与界面元素对应。 反例是 data class CardState(val isTitleVisible: Boolean, val titleTextColor: Int)——把样式塞进状态,组件就退化成"由外部遥控的控件"。正例是 data class UserUi(val name: String, val avatarUrl: String, val badge: Int?),样式由组件自己从主题取。判据是看一眼状态类就能说出业务含义,如果字段名是 isXxxVisible 居多,说明状态层在替组件做决定。
第二条:状态里只放"外部无法推导"的数据。 派生数据不进状态。典型反例是把"是否可提交"同时存在两个字段里(isEditing 与 isSubmitEnabled),两者需要保持同步,一旦漏改就出现"编辑中却不能提交"。正确做法是只存 isEditing,可提交与否由组件内部根据表单完整性推导。这条判据的价值不只是避免不一致,还在于减少了状态机的可能状态数——两个布尔字段有四种组合,其中一种是非法的;合成一个只剩两种。
第三条:状态的产生方唯一。 同一份状态不允许两个来源。Compose 侧常见的违规是"既有 remember 出来的本地状态,又有 ViewModel 推来的状态,然后在 LaunchedEffect 里互相同步"——双向同步的状态会持续漂移。规范是要么它是本地 UI 状态(展开/收起、输入草稿),要么是业务状态(列表数据、加载态),不跨类。
// 单一来源:加载态由 VM 的状态流驱动,输入草稿是本地状态,两者职责分开
@Composable
fun SearchScreen(vm: SearchViewModel = viewModel()) {
val uiState by vm.uiState.collectAsStateWithLifecycle() // 业务状态:数据与加载态
var keyword by rememberSaveable {
mutableStateOf("") } // 本地状态:输入草稿
SearchBar(
keyword = keyword,
onKeywordChange = {
keyword = it; vm.query(it) }, // 触发副作用,不互相回写
loading = uiState.loading
)
// 派生数据不进状态
val canSubmit = keyword.isNotBlank() && !uiState.loading
SubmitButton(enabled = canSubmit)
}
这三条判据合起来能回答一个高频追问:"组件化的状态应该放哪儿?" 答案是:业务数据与加载态放 ViewModel,瞬时交互状态放 remember/rememberSaveable,样式与派生数据不进状态。
View 体系的对应做法
<!-- 1) 声明属性 -->
<declare-styleable name="BadgeView">
<attr name="badgeText" format="string" />
<attr name="badgeCount" format="integer" />
<attr name="badgeColor" format="color" />
<attr name="badgeMax" format="integer" />
</declare-styleable>
// 2) 自定义属性 + 支持主题(关键:不写死)
class BadgeView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : AppCompatTextView(context, attrs, defStyleAttr) {
private var maxCount = 99
init {
val ta = context.obtainStyledAttributes(attrs, R.styleable.BadgeView, defStyleAttr, 0)
try {
maxCount = ta.getInt(R.styleable.BadgeView_badgeMax, 99)
val count = ta.getInt(R.styleable.BadgeView_badgeCount, 0)
// 主题优先:读 attr 而不是硬编码
val color = ta.getColor(
R.styleable.BadgeView_badgeColor,
resolveThemeColor(context, com.google.android.material.R.attr.colorPrimary)
)
bind(count, color)
} finally {
ta.recycle() // 少了这行会内存泄漏,TypedArray 持有资源
}
}
fun bind(count: Int, color: Int) {
text = if (count > maxCount) "$maxCount+" else count.toString()
setTextColor(color)
isVisible = count > 0
}
}
三个必答细节:① ta.recycle() 不能漏,否则 TypedArray 持有的资源不释放(这是自定义 View 最常见的泄漏点);② 默认色从主题 attr 取而不是硬编码,这是"组件跟随主题"的关键;③ 重载构造函数三个签名(Context、Context+AttributeSet、Context+AttributeSet+defStyleAttr)不能少,否则在某些布局里无法通过 XML 标签创建。
组件库沉淀:design-system 模块
组件化走到最后会形成一层"设计系统"模块,它的职责是收敛 token 而不是业务组件:
// design-system 模块:对外暴露语义 token,业务侧不接触具体尺寸与色值
object Dimens {
val spaceXs = 4.dp; val spaceSm = 8.dp; val spaceMd = 16.dp
}
@Composable
fun AppTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = if (isSystemInDarkTheme()) DarkColors else LightColors,
typography = AppTypography,
shapes = AppShapes,
content = content
)
}
这套 token 层的价值在第164篇已经讲过(Material 3 的 shape scale 与 motion scheme),这里是它在组件化视角下的落地:业务组件只引用 token,主题切换与换肤时改一处即全站生效。
最常见的坑是
第一层坑:组件内部写死颜色与字号,主题切换后风格割裂。
现场表现是深色模式切过去,组件里的标题还是黑色、在深色底上看不清。规范是组件内只引用 MaterialTheme.colorScheme 与 typography 中的语义 token,缺 token 时在 design-system 里补齐而不是在组件里写死。
第二层坑:组合控件直接持有业务回调,复用场景被迫改源码。
典型是自定义 UserCard 内部直接持有 onOpenOrder(业务语义),换个页面想改成"查看详情"就得改组件源码。规范是回调只表达"发生了什么"(onClick、onLongPress)而不是"要去哪",跳转逻辑放在容器层。
第三层坑:把有状态逻辑塞进无状态组件,导致无法预览与测试。
表现是 @Preview 里组件显示空白(因为内部状态没初始化),单测也写不出来。规范是组件拆成"无状态渲染 + 容器持有状态"两层,预览与单测只测无状态那一层。
这题在面试里怎么问、怎么答
问:你怎么做组件化的?判断标准是什么?
答:三条标准。① 无状态化——组件只接收数据与回调,状态由调用方持有,这样组件可预览、可测试、可复用。② token 化——内部只引用主题 token,不写死颜色尺寸。③ 语义化回调——回调只表达发生了什么,不带业务目的地。再补一句落地路径:先在重复三次以上的 UI 里提取,再抽到 design-system 模块形成 token,最后才是业务组件库。
问:无状态组件和有状态组件怎么区分?
答:无状态组件是纯函数式的(输入参数 + 回调,输出 UI),不持有任何 remember 之外的可变状态;有状态组件是容器,持有 ViewModel 或 remember 状态并把状态喂给无状态组件。区分的意义在于:无状态组件可以 @Preview 与单测,有状态组件不行。 实际工程里还多一层"有状态但无业务"的中间态(内部 remember 展开/收起状态),可用于预览但不可注入数据。
问:自定义 View 有哪些必须处理的细节?
答:四个。① 三个构造函数重载不能少,否则 XML 里创建不了。② obtainStyledAttributes 后的 recycle() 不能漏,否则泄漏。③ 默认色从主题 attr 取而不是硬编码,才能跟随主题。④ resolveSize 收敛尺寸,自定义属性(如 maxWidth)才生效。能主动提 recycle() 这个点,说明真踩过泄漏。
问:Compose 组件怎么写预览和测试?
答:预览是 @Preview 注解 + 造一份假数据传给无状态组件;测试是 createComposeRule() 渲染后用语义节点断言(onNodeWithText(...).assertIsDisplayed()),交互用 performClick()。关键前提是无状态——组件内部持有状态时,预览与测试都要先安排好状态初始化。能提到"语义节点断言"这条,说明真的写过 Compose 测试。
落到项目里怎么做
一条能写进规范的红线:新增组件必须满足"无状态 + token 化 + 语义化回调"三条,并提供 @Preview 与一个组件测试;三条缺失不予合入组件库。 这一条能让组件库的质量随时间上升而不是下降。
配套实践三条:① 建一个可见的组件浏览页(把所有组件与各状态罗列出来),既是对组件库的自检也是给设计同学的对照物;② token 层由专人维护,业务提 token 需求走同一个 PR,避免散乱;③ 给组件库定版本与变更记录,业务侧按版本升级,避免"改一个组件炸三个页面"。
给正在准备面试的你
这题的答法要落到"从控件思维到组件思维的差别在状态归属与样式来源"这条主线。推荐这条线:先给两套思维对比表 → 讲 Compose 的"无状态 + 容器"双层结构与它的三个好处(预览、测试、复用)→ 讲 View 体系的属性集写法与三个必答细节(recycle()、主题色、resolveSize)→ 讲 token 层沉淀 → 补三个坑 → 收在"三条硬标准"。能被追问"怎么保证组件可复用"并答出"状态由调用方持有、回调只表达发生了什么",说明抓到本质;能主动提 obtainStyledAttributes 的 recycle() 或 resolveSize 这类细节,说明真写过自定义 View。
如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。你的支持就是这个系列持续更新的动力。
「Android软件开发面试·从入门到精通」连载系列
上一篇:通知进阶:渠道、样式与权限
下一篇预告:Android-系统架构分层:从应用到内核的全景视角
有任何问题欢迎在评论区留言交流。