第180篇 组件化 UI 收尾:从控件思维到组件思维

简介: 本文深入解析UI组件化本质:从“控件思维”跃迁至“组件思维”,核心在于将“状态+样式+行为”收敛为可复用契约。强调三条硬标准——不写死视觉token、不感知业务语义、不持有外部上下文。以Compose无状态组件与View体系实践对比,揭示状态归属是复用关键,并指出设计系统沉淀与常见落地坑。

先把结论放在前面:组件化的 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-系统架构分层:从应用到内核的全景视角

有任何问题欢迎在评论区留言交流。

相关文章
|
23天前
|
人工智能 JSON API
全网刷屏的 Jev 模型正式开放!一手实战测评 + 保姆级教程
全网爆火的 Jev 模型是什么?有什么用?怎么使用?怎么接入 AI 编程工具?效果真的好么?傻子可懂的 Jev 保姆级实战教程 + 项目实战测评来啦
9365 30
|
9天前
|
存储 人工智能 并行计算
大模型本地部署终端选型方法论:以 Qwen3.8-27B 为例的四档分层完整流程
本文提出一套大模型本地部署终端选型方法论:定约束、定档位、定框架、定参数四步决策法,配合入门、主力、质量、无损四档分层模型。以 Qwen3.8-27B 实测数据为例,逐环节解读显存、带宽、存储、散热、系统、预算等要素,给出面向不同预算的优选方案、决策自查清单与市场观察框架。文末前瞻 AI 笔记本的 CPU+GPU 与统一内存两条路线,论证四步决策法在新品类上的延续性。
|
8天前
|
人工智能 JSON 自然语言处理
2026 年 Jev 决策模型深度拆解:原理解读、实战测评与保姆级落地教程
有一款特殊AI模型在开发者圈子刷屏,它摒弃传统大模型擅长的对话聊天能力,专注做高速结构化决策,它就是TypeSafe AI推出的Jev模型。该模型由ChatGPT共同发明人Diogo Almeida主导研发,定位为**System One Model(系统一模型)**,对标人类大脑快速直觉判断的思维模式,在响应延迟、调用成本、结构化输出稳定性上相比传统生成式大模型有着巨大差异。本文会完整拆解Jev底层原理、三大核心原语能力、适用业务场景,同时提供可直接运行的curl、Python代码示例,并且结合多组实测数据,客观分析模型优势与能力边界,帮助普通开发者和AI应用从业者快速上手落地。
638 1
|
21天前
|
人工智能 测试技术 API
最近全网爆火的 Jev 到底是什么?适合干什么、怎么用,一篇讲透!
Jev是TypeSafe AI推出的“系统一模型”,不生成文本,专做毫秒级结构化决策:Choice(多选)、Score(打分)、Noul(是非概率)。响应快193倍、成本低444倍,适合工单路由、内容审核、测试定级等高频判断场景。
2478 4
最近全网爆火的 Jev 到底是什么?适合干什么、怎么用,一篇讲透!
|
4天前
|
人工智能 JSON 自然语言处理
2026 年 Jev 决策模型全解析:核心原理拆解,性能实战测评与落地部署教程
有一款特殊AI模型在开发者圈子刷屏,它摒弃传统大模型擅长的对话聊天能力,专注做高速结构化决策,它就是TypeSafe AI推出的Jev模型。该模型由ChatGPT共同发明人Diogo Almeida主导研发,定位为System One Model(系统一模型),对标人类大脑快速直觉判断的思维模式,在响应延迟、调用成本、结构化输出稳定性上相比传统生成式大模型有着巨大差异。本文会完整拆解Jev底层原理、三大核心原语能力、适用业务场景,同时提供可直接运行的curl、Python代码示例,并且结合多组实测数据,客观分析模型优势与能力边界,帮助普通开发者和AI应用从业者快速上手落地。
413 0
|
3天前
|
存储 缓存 测试技术
QWT 3D 模块重构记:旧渲染管线改进介绍及实时瀑布图示例
QWT 3D模块完成重大重构:解耦窗口与图形(Plot+Item),摒弃OpenGL 1.x固定管线,迁至Core Profile着色器渲染;支持多图元同场景叠加,并以实时3D瀑布图验证——滑动窗口、固定色域、64帧×256点达61fps。兼容Qt 5.12+/6,LGPL协议。
157 120
|
15天前
|
Windows
【保姆级】.NET Framework 3.5离线安装教程(2026最新实测)
.NET Framework 3.5是微软经典运行库,专为兼容老软件、老游戏设计。Win7自带,Win10/11需手动启用(在线或离线安装)。它与4.x系列并存不冲突,缺它会导致“找不到组件”报错。本文详解两种安装方式及常见问题解决,小白也能轻松上手。(239字)
1874 0
|
10天前
|
人工智能 Linux Windows
千问办公(QwenWork)官网入口:其实有2个,一个是网页端千问办公,一个是介绍指南页面
千问办公(QwenWork)是阿里云推出的AI智能办公平台,支持网页端直接使用及Windows/Mac/Linux客户端下载。提供PPT生成、财报分析、网页搭建等AI功能,个人版免费,企业版198元/席/月。详情见官网qwenwork.cn或阿里云产品页。
1194 0
千问办公(QwenWork)官网入口:其实有2个,一个是网页端千问办公,一个是介绍指南页面