第157篇Jetpack Compose 入门:声明式 UI 的思维转换

简介: 本文深入剖析 Jetpack Compose 核心机制:`@Composable` 并非普通函数,而是经编译器插件重写的“依赖注册函数”——编译期将状态读取转为订阅,运行时按需重组、差分更新。重点解析三大高频坑:无控件引用(状态即UI)、重计算未缓存(用 `remember`)、副作用混入函数体(须用 `LaunchedEffect` 等专用API),助你真正理解而非仅会使用。

先把结论放在前面:@Composable 函数不是"普通函数加了注解",而是被编译器插件改写过的函数——它把"描述 UI 的代码"变成"向运行时描述树(SnapshotTree)注册一份描述",运行时由 Composition 负责组合、状态变化时按需重组。 理解这一点,就理解了 Compose 的各项特性:为什么没有控件引用、为什么重组可以跳过、为什么副作用需要单独声明。

面试官验证 Compose 入门的真实功力有个土办法:聊坑。 默认行为是什么、边界在哪、出错时是什么表现。背资料的人在这里会集体沉默。 所以这篇的重点放在三个必答的坑上。

编译期与运行时的分工

@Composable 函数的处理分两个阶段:

编译期(Compose Compiler Plugin):

  • 检查 @Composable 调用只能出现在 @Composable 上下文(这就是"Composable 函数只能从 Composable 函数调用"这条限制的来源)
  • 生成"重组作用域"标记($composer、$changed 标记当前函数依赖了哪些状态)
  • 把状态读取位置编译成"依赖注册"——val x by state 会被转成"当前重组作用域订阅了 state 的读取"
  • 静态检查:Composable 里调用普通函数、Composable 里有副作用等都有 lint 提示

运行时(Composition / Recomposer):

  • 首次组合:执行 Composable 函数树,生成 LayoutNode 树
  • 状态变化:只有"订阅了该状态"的重组作用域进入待重组队列
  • 重组:重新执行该作用域的 Composable,产生新的 Node 树,与旧的对比(diff)后只更新变化的属性
  • 重组不是"重建",而是"重执行 + 差异化更新"——这是理解 Compose 性能的关键

能主动说清"编译器把状态读取变成依赖注册、运行时按依赖重组",说明不是只用过、而是理解了。

三个坑的机制解释

坑一:命令式思维找控件引用。 传统写法是 findViewById(R.id.tv) 拿到控件再 setText。Compose 里根本没有"控件"这个对象——Text("hello") 的含义是"在当前布局节点上描述一个文本节点",它返回 Unit,没有任何可持有的句柄。要改状态就改状态(state.value = "x"),Compose 会重新描述这一段。 规范是"改状态而不是改控件",这是 Compose 的核心心智模型。

坑二:把重计算写在 Composable 里。 因为重组会重新执行 Composable 函数,函数体里任何计算都会重复跑。典型是把 JSON 解析、格式化、列表过滤写在函数体里。规范是 remember { } 缓存纯计算结果,依赖变化时才重算;跨重组且需要跨配置变更保存的用 rememberSaveable。

坑三:在 Composable 里做副作用。 副作用指"影响外部世界"的操作:写文件、发网络请求、打日志、操作 View(Toast、跳转)。如果写在函数体里,每次重组都会执行一次——表现是"每次状态变化都发一次请求"。规范是用 LaunchedEffect、DisposableEffect、SideEffect 三个专用 API 声明副作用的时机**。

最常见的坑是

第一层坑:用命令式思维保存控件引用,Compose 中不存在控件句柄概念。

除了"拿不到句柄",这个思维还带来两个更隐蔽的问题:① 用 textView.text = x 这样的方式在 onClick 里改 UI,在 Compose 里对应的是"改 State",但很多人会试图用 var text by remember { mutableStateOf("") } 在点击回调里改一个局部 state,结果发现改不动——因为这个 state 属于点击回调所在的作用域,回调本身是被 Recomposer 重建的。② 用全局变量(object 里存 TextView)绕过,会造成状态与界面脱节。

规范是状态提升(State Hoisting):状态定义在共同的父级,通过参数传下去,事件通过回调传上来。"这个 Composable 是无状态的、只接收参数与回调"是 Compose 的设计准则——能主动说出这个准则,说明真的理解了 Compose 的架构意图。

第二层坑:把重计算直接写在 Composable 里,每次重组都执行。

// 反模式:JSON 解析在重组时反复执行
@Composable
fun ProfileCard(raw: String) {
   
    val user = remember {
    Json.decodeFromString<User>(raw) }   // 正确:缓存
    val display = formatUser(user)                              // 错误:每次重组都跑
    Text(display)
}

关键区分是"纯计算"与"有副作用的操作":

@Composable
fun ProfileCard(raw: String) {
   
    // 纯计算 → remember 缓存,key 变化时重算
    val display = remember(raw) {
    formatUser(decodeUser(raw)) }

    // 派生状态 → 组合多个状态且有昂贵计算时用 derivedStateOf
    val isLong by remember {
    derivedStateOf {
    display.length > 40 } }

    // 副作用 → 单独声明
    LaunchedEffect(raw) {
   
        analytics.track("profile_card_shown", mapOf("len" to display.length))
    }

    Column {
   
        Text(display, maxLines = if (isLong) 2 else 5)
    }
}

这段代码里 derivedStateOf 的用途值得单独说:它的作用是"只有当派生值真正变化时才通知使用者"——如果直接用 val isLong = display.length > 40 写在函数体里,display 变化就会重组;而 derivedStateOf 会先比较新旧值,相同就不触发。这是列表里做"是否显示加载更多"这类判断的标准写法。

第三层坑:在重组中执行副作用,结果被反复执行多次。

三种副作用 API 的语义必须分清:

@Composable
fun Screen(viewModel: ScreenVm) {
   
    val state by viewModel.state.collectAsStateWithLifecycle()

    // LaunchedEffect:key 变化时取消旧协程并重启新协程
    // 适用:网络请求、一次性初始化
    LaunchedEffect(state.userId) {
   
        viewModel.loadDetail(state.userId)
    }

    // DisposableEffect:进入组合时执行,退出时清理
    // 适用:注册监听、绑定服务
    DisposableEffect(Unit) {
   
        val listener = object : SensorListener {
    /* ... */ }
        sensorManager.registerListener(listener, sensor)
        onDispose {
                        // 规范:必须清理,否则泄漏
            sensorManager.unregisterListener(listener)
        }
    }

    // SideEffect:每次成功重组后执行(无 key,不重启)
    // 适用:日志上报、埋点
    SideEffect {
   
        analytics.track("screen_rendered")
    }
}

这段代码的三个区别是这题的核心:LaunchedEffect 有 key(key 变了重启)、DisposableEffect 有清理回调、SideEffect 无 key 且每次重组都跑。"什么时候用哪个"答不清,这题就过不了。

还有一个更隐蔽的坑:LaunchedEffect 的 key 用错了导致请求不发出或反复发出。 常见错误是 LaunchedEffect(Unit) 里请求依赖了会变的状态(状态变了但 effect 不重启),或者把会变的状态写进 key 里导致每次变化都重启请求(表现为"输入框每敲一个字就发一次请求")。规范是 key 要精确表达"这个 effect 依赖的数据",并在请求层做防抖。

代码里见真章

先看一个体现"无状态 + 状态提升"的完整组件:

// 无状态组件:只接收参数与回调,不持有状态
@Composable
fun CounterCard(
    count: Int,                                   // 状态由外部传入
    onIncrement: () -> Unit,                      // 事件回调向上传
    modifier: Modifier = Modifier,
    title: String = "点击次数"
) {
   
    Column(modifier = modifier.padding(16.dp)) {
   
        Text(text = title, style = MaterialTheme.typography.titleMedium)
        Text(
            text = "$count",
            style = MaterialTheme.typography.displayMedium
        )
        Button(onClick = onIncrement) {
    Text("加一") }
    }
}

// 有状态包装:把状态与回调收口在这里
@Composable
fun CounterScreen(viewModel: CounterVm = viewModel()) {
   
    val count by viewModel.count.collectAsStateWithLifecycle()
    val title by remember {
    mutableStateOf("计数器") }

    // derivedStateOf:只有 visible 真正变化时才通知使用者
    val visible by remember {
   
        derivedStateOf {
    count >= MAX_FREE_USE }
    }

    // 副作用:key 变化才重启
    LaunchedEffect(visible) {
   
        if (visible) viewModel.reportQuotaReached(count)
    }

    Scaffold(topBar = {
    TopAppBar(title = {
    Text(title) }) }) {
    pad ->
        Box(Modifier.padding(pad)) {
   
            CounterCard(
                count = count,
                onIncrement = viewModel::increment,
                title = title
            )
            if (visible) QuotaBanner(Modifier.align(Alignment.TopCenter))
        }
    }
}

这段代码体现了 Compose 的架构准则:CounterCard 无状态可测(给参数就能测),CounterScreen 有状态并负责收口。 能主动画出这个分界,说明理解了"无状态组件可组合、可测试"的意图。

Modifier 链与重计算规避的完整写法:

@Composable
fun Chart(data: List<Point>, modifier: Modifier = Modifier) {
   
    // 关键:格式化结果缓存,data 变化才重算
    val labels = remember(data) {
    data.map {
    formatLabel(it) } }
    val maxValue = remember(data) {
    data.maxOf {
    it.y } }

    Column(
        // Modifier 顺序:size 决定测量约束,padding 缩进内容
        modifier = modifier
            .size(width = 300.dp, height = 180.dp)      // 1 尺寸
            .padding(12.dp)                               // 2 内边距
            .background(Color(0xFFF5F5F5), RoundedCornerShape(8.dp))  // 3 背景
    ) {
   
        // 关键:Modifier 复用避免每次重组重复创建
        val barModifier = Modifier
            .fillMaxHeight(0.6f)
            .clip(RoundedCornerShape(topStart = 4.dp, topEnd = 4.dp))
        labels.forEach {
    label ->
            Text(text = label, modifier = Modifier.padding(vertical = 2.dp))
        }
        // 图条用 drawBehind,避免为每根条创建独立 Composable
        Canvas(Modifier.fillMaxWidth().height(120.dp)) {
   
            val barWidth = size.width / (data.size.coerceAtLeast(1))
            data.forEachIndexed {
    index, point ->
                val barHeight = size.height * (point.y.toFloat() / maxValue)
                drawRect(
                    color = Color(0xFF2196F3),
                    topLeft = Offset(barWidth * index, size.height - barHeight),
                    size = Size(barWidth - 2f, barHeight)
                )
            }
        }
    }
}

这段代码里 remember(data) { ... } 与 Canvas + drawRect 的组合是这题的实用答案:图条不创建独立 Composable(避免 N 个组合节点),直接在 Canvas 的绘制 lambda 里画——这在数据量大时是显著的性能差异。

remember 与 rememberSaveable 的正确区分:

@Composable
fun SearchScreen() {
   
    // remember:只在重组间保留,进程被杀/配置变更后丢失
    val tab = remember {
    mutableStateOf(0) }

    // rememberSaveable:通过 Bundle 保存,配置变更与进程重建后可恢复
    val keyword = rememberSaveable {
    mutableStateOf("") }
    val scrollIndex = rememberSaveable {
    mutableIntStateOf(0) }

    // 规范:rememberSaveable 只放可序列化的基本类型或 Parcelable
    // 大对象(列表、ViewModel)不要放进去

    // 需要自定义 Saver 时用 rememberSaveable(saver = ...)
    val time = rememberSaveable(
        saver = Saver<Date, Long>(
            save = {
    it.time },
            restore = {
    Date(it) }
        )
    ) {
    mutableStateOf(Date()) }

    Column {
   
        SearchField(keyword.value, onChange = {
    keyword.value = it })
        LazyColumn {
   
            items(count = results.size) {
    i ->
                ResultRow(results[i], scrollState = {
    scrollIndex.value = i })
            }
        }
    }
}

这段代码里 rememberSaveable 的适用边界是这题的判分点——"只放可序列化的基本类型"这条规则能说出来,说明理解它依赖 Bundle 的机制。

derivedStateOf 与 mutableStateOf 的取舍场景:

@Composable
fun LazyColumnDemo(items: List<Item>) {
   
    val listState = rememberLazyListState()
    // 错误示范:每帧计算 isAtEnd,但只有布尔值变化才有意义
    val isAtEndDirect = listState.layoutInfo.visibleItemsInfo
        .lastOrNull()?.index == items.size - 1

    // 正确:用 derivedStateOf 做去重,只有布尔值翻转才触发重组
    val isAtEnd by remember {
   
        derivedStateOf {
   
            val info = listState.layoutInfo
            val last = info.visibleItemsInfo.lastOrNull()?.index ?: 0
            last >= items.size - 3
        }
    }

    LaunchedEffect(isAtEnd) {
   
        if (isAtEnd && items.size < TOTAL) loadMore()
    }
}

这段代码是 derivedStateOf 最经典的用法——"每帧都变的原始量"经 derivedStateOf 变成"偶尔才变的布尔量",重组次数从每帧降到实际变化时。

面试中的经典考点

问:@Composable 到底被编译成了什么?

答:编译器插件把它改写成"接收 Composer 参数的函数",并把状态读取位置转成"向当前重组作用域注册依赖";运行时 Composition 执行函数树生成 LayoutNode,状态变化时只重执行订阅了该状态的重组作用域,并对结果做 diff 后局部更新。 "重执行 + 差异化更新"而不是"重建",是这套机制的核心。

问:重组的作用域是什么?一个 Composable 里哪些变化会触发重组?

答:作用域就是该 @Composable 函数的执行范围。只有在该函数体内被读取的状态变化才会触发它的重组;读取状态在子函数里,则子函数的重组作用域被触发(子重组不会连带父重组)。这解释了"为什么把状态读放到子 Composable 能缩小重组范围"——这是性能优化的第一手段。

问:remember 的 key 是什么意思?

答:key 变化时 remember 的缓存被丢弃并重新计算。 无 key 表示"整个 Composable 生命周期内只算一次";key 要精确表达"这个计算依赖什么"。规范是依赖哪个状态就把哪个状态(和相关参数)放 key 里,避免"漏了 key 导致不更新"或"key 太宽导致频繁重算"。

问:为什么 Compose 里没有 findViewById?

答:因为 Composable 不产生"控件对象",只向 Composition 描述节点树。 传统模式下控件是"可变的状态载体",Compose 模式下状态是唯一的真相,UI 是状态的函数。所以改 UI 就是改状态,没有句柄可找。能延伸出"状态提升"这个准则,说明理解到了架构层面。

问:LaunchedEffect / DisposableEffect / SideEffect 什么时候用?

答:LaunchedEffect 用于"异步任务随 key 变化重启"(网络请求、订阅流),key 变化时自动取消旧协程;DisposableEffect 用于"注册与成对清理"(监听、传感器),退出组合时执行 onDispose;SideEffect 用于"每次成功重组后执行一次"(埋点、日志),无 key。 三选错就是泄漏或重复执行。

问:Compose 的性能怎么优化?

答:按收益排序五条。① 缩小重组范围——把状态读下沉到子 Composable;② remember 缓存纯计算;③ derivedStateOf 降低触发频率;④ 列表用 key + contentType(LazyColumn);⑤ 避免在 Composable 里创建高成本对象,用 Canvas 批量绘制替代大量子节点。再补一条:用 Trace 或 Layout Inspector 的重组计数来定位,而不是凭感觉优化。

落到项目里怎么做

一条能写进规范的红线:Composable 函数保持无状态——只接收参数与回调,不在内部持有业务状态。 这条准则同时解决了可测性、复用性与性能三个问题,是投入产出比最高的一条 Compose 规范。

配套实践三条:① 用 ComposeContentTestRule + createComposeRule 为每个无状态组件写截图与交互测试,状态外置后测试成本降到最低;② 重计算一律走 remember,跨配置变更需要保留的走 rememberSaveable,在 code review 清单里把"函数体里的重计算"列为必查项;③ 用 Layout Inspector 的 Recomposition 面板在真机上核对重组次数,发现某屏每秒重组上百次就说明有状态读放错了层级。

给正在准备面试的你

这题的答法要落到"编译期改写 + 运行时重组 + 三个副作用 API"这条主线。推荐这条线:先讲"Composable 被编译成带 Composer 的函数、状态读取变成依赖注册"→ 讲"重组是重执行 + diff,不是重建" → 讲三个坑:命令式找控件、重计算没缓存、副作用混在函数体 → 落到"无状态组件 + remember/derivedStateOf/LaunchedEffect 分工"这套规范。能被追问到"重组的作用域粒度"并答出"子重组不连带父重组",说明理解了优化空间;能说清三个副作用 API 的差异与各自适用场景,说明真的在项目里用过而不只是看过文档。


如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。你的支持就是这个系列持续更新的动力。

「Android软件开发面试·从入门到精通」连载系列

上一篇:Paging-分页库:加载更多与占位

下一篇预告:Compose-重组原理:何时执行何时跳过

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

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