学习目标
读完本章,你应当能够:
- 说清"命令式 UI"和"声明式 UI"的本质差别,以及为什么本项目选 Compose;
- 写出一个符合约定的
@Composable函数(命名、无返回值、幂等);- 理解状态提升:把可变状态放到调用方,让子界面变成"纯展示";
- 说清**重组(recomposition)**是什么、
remember保住的是什么、以及"没有 remember 会怎样";- 会用
mutableStateOf+remember做一个能工作的计数器,并用日志观察重组次数;- 看懂
LaunchedEffect的 key 参数,能解释MessageList里那句
LaunchedEffect(messages.size, lastMessage?.id, lastMessage?.content?.length)为什么是三个 key;- 理解
Modifier的作用,知道调用顺序会影响结果。前置 :ch04、ch05
对应源码(本章会逐个打开):
app/src/main/java/com/example/llama/ui/InputArea.kt(120 行)------ 状态提升的最佳范例
app/src/main/java/com/example/llama/ui/MessageList.kt(62 行)------LaunchedEffect+rememberLazyListState
app/src/main/java/com/example/llama/ui/ChatScreen.kt:347------ 状态提升的"上游"
🧭 读本章前,请先确认前置 :ch04(知道 Activity 与
setContent)、ch05(知道资源怎么引用)。
不需要懂 Compose。需要的基础 :能读懂函数 与参数 (ch03 已学);
理解"变量可以被重新赋值"。本章会出现的生词 (正文首次出现处都有大白话解释):声明式 UI、
@Composable(界面函数)、注解(annotation)、重组(recomposition)、状态提升(state hoisting)、回调(callback / onXxx)、副作用(side effect)、remember、mutableStateOf、LaunchedEffect、Modifier、单向数据流(UDF)、协程(ch08 细讲)。缺了会卡在哪:不懂"函数/参数/Lambda"(ch03)看不懂onXxx: (String) -> Unit;不懂"状态/变量可被重新赋值"会不理解"改状态为什么会触发重画"。一起查 附录 B.3 。
(本节生词已在上面一行列全,不再重复。)读法建议 :本章是全书第一个"换脑子"的地方 ------
你要从"手动改界面"切换到"改状态、界面自动重画 "。
如果卡住了,先做 6.7 的计数器实验 (能亲眼看到重组),再回头读 6.4。
一句话记住本章灵魂:UI = f(状态)。
本章导读前面五章你都在打地基。从这一章开始,进入本项目最大的一块代码 :界面。
本工程 6000 行 Kotlin 里,绝大部分是 Compose 界面和状态。
但 Compose 和你以前听过的"写界面"完全不是一回事 ------它不用 XML 布局,
而是用 Kotlin 函数直接描述界面 ,并且界面会自动跟着数据变化。
这个转变对新手来说有点反直觉,所以先给你一个类比:
💡 类比:点菜 vs 做菜
- 命令式(传统写法) = 你走进厨房,对着厨师一步步指挥:
"把火开大""放盐""翻面""装盘"。你得记住每一步做了什么 ,
而且下次做同一道菜还得再指挥一遍。- 声明式(Compose) = 你递给厨师一张菜谱 ,上面写着
"番茄炒蛋 = 番茄 2 个 + 鸡蛋 3 个 + 盐 3 克"。
食材(状态 )变了,菜(界面)自动跟着变。你再也不用管"火开多大"。再直白点:命令式是"手动改控件",声明式是"声明界面 = f(状态)"。
本章最重要的心智模型就一句话:
UI = f(状态)。状态变了,界面自动重画。你永远不手动"改界面"。
如果你带着"我要找到那个 TextView 然后 setText"的惯性来学 Compose,会非常痛苦。
请把那套思维先放下。
6.1 为什么需要 Compose:命令式 vs 声明式
🧒 先补一课:界面在手机上到底是怎么"画"出来的?
你在手机上看到的每个 App 界面,本质都是"一张画":这里有个按钮、按钮上写什么字、
字是什么颜色------这些都是 App 用代码画 上去的。问题只在于:谁负责画、你怎么指挥它画。
想象你在布置一间客厅,有两种完全不同的做法:
- 命令式(imperative)= 你亲手一件一件摆家具 :沙发放这边、茶几放那边、电视挂墙上。
摆完你还得自己记住每件东西放在哪 。哪天想把沙发挪到窗边,你得先找到沙发在哪、再亲手把它搬过去 。
摆错一件,也不会有任何人提醒你。- 声明式(declarative)= 你只写一句"客厅应该长这样" :贴一张客厅布置图,写清"沙发靠窗、茶几在中间"。
图上的布置一变,照着图施工的人自动把客厅重新摆成新样子------你不用自己去找沙发、自己去搬。这就是两种写界面方式的本质区别 。下面 6.1.1 要讲的
findViewById那套老代码,就是"命令式"的真身:你得先亲手把那个按钮对象拿到手里 (
findViewById),再亲手改它的文字 (setText)。而 Compose 是"声明式":你只写一句"这个位置该显示什么",剩下的重画它自动做。
还要先记住一句话:在 Compose 里,界面就是函数 。
那个带
@Composable标记的函数,本书叫它可组合项(composable,即可被 Compose 拿去执行的界面函数) 。它本质是你交给系统的一张**"怎么画"的说明书**。类比:你给装修师傅一张图纸(这就是那个
@Composable函数),师傅照着图纸一砖一瓦把房子盖出来(这就是系统在执行这个函数、把界面画到屏幕上)。
你不用自己刷墙,也不用自己去找那面墙补漆------图纸变了,师傅照着新图纸重新盖就行。
6.1.1 传统写法(命令式)
传统 Android 用 XML 写布局,然后在代码里拿到控件对象、手动改属性。
📝 示例(仅示意,本项目不用):
kotlin
// 命令式:你必须自己找到控件、自己改
val textView = findViewById<TextView>(R.id.title)
textView.text = "新消息"
textView.visibility = View.GONE
val list = findViewById<RecyclerView>(R.id.list)
list.adapter = MyAdapter(messages) // 数据变了还要手动 notifyDataSetChanged()
问题在哪?
| 问题 | 说明 |
|---|---|
| 状态散落 | "当前显示什么"这件事,一半在 XML、一半在代码里 |
| 容易漏改 | 数据变了要记得调 notifyDataSetChanged(),忘了就"数据变了界面没变" |
| 空指针风险 | findViewById 拿不到会返回 null |
| 代码量大 | 一个列表要写 XML + Adapter + ViewHolder 三件套 |
6.1.2 Compose 写法(声明式)
Compose 里,界面就是函数:
kotlin
@Composable
fun MessageBubble(message: Message) {
Text(text = message.content) // 直接描述"这里显示 message.content"
}
当 message.content 变化时,Compose 自动重新执行 这个函数,界面就更新了。
你不需要(也不能)手动去"改那个 Text"。
📌 Compose 会重新执行你的函数,这个动作叫「重组(recomposition)」。
这是本章的核心词,后面反复出现。
6.1.3 对比表
| 命令式(XML + View) | 声明式(Compose) | |
|---|---|---|
| 界面在哪定义 | XML 文件 | Kotlin 函数 |
| 怎么更新 | findViewById → setText |
改状态 → 自动重组 |
| 状态放在哪 | 散落在 Activity/Fragment | 集中的状态容器(ch09) |
| 列表 | RecyclerView + Adapter + ViewHolder | LazyColumn { items(...) } |
| 空安全 | findViewById 可能 null |
编译期安全 |
6.1.4 本项目为什么选它
本工程四个页面(模型/聊天/翻译/设置)全部用 Compose ,没有一行 XML 布局
(res/ 下只有图标、字符串、主题,没有 layout/ 目录------你可以自己确认)。
原因很实际:
- 聊天界面要高频刷新(流式输出时每 ~100ms 更新一次),命令式手动刷新极易出 bug;
- 状态本来就集中在
MainScreenState(ch09),和声明式天然契合; - 少写大量模板代码(Adapter/ViewHolder)。
6.2 @Composable 函数的约定
6.2.1 长什么样
✅ 取自本工程、已编译验证 ------ ui/InputArea.kt:30-39
kotlin
@Composable
fun InputArea(
userInput: String,
onUserInputChange: (String) -> Unit,
onSendMessage: () -> Unit,
isSendButtonEnabled: Boolean,
isGenerating: Boolean,
onStopGeneration: () -> Unit = {},
modifier: Modifier = Modifier
) {
6.2.2 四条约定
| 约定 | 说明 | 违反了会怎样 |
|---|---|---|
① 加 @Composable 注解 |
告诉编译器"这是个界面函数",只有它能调用其他 Composable。(那个 @ 开头的词叫注解/annotation:它不是代码逻辑,而是贴在函数头上的一张"标签",给编译器/工具看额外说明------像外卖盒上贴的"热餐、小心轻放"贴纸。) |
不加这个注解,编译器不把它当界面函数,里面连 Text、Button 都调不了,直接编译报错。 |
| ② 函数名用大驼峰 | 和普通 Kotlin 函数(小驼峰)区分开,一眼能看出是界面 | 编译器不报错,但团队读代码时会把它当成普通函数;IDE 也不给它界面高亮和预览------纯靠自觉,code review 时最容易被退回。 |
| ③ 不返回值 | 界面函数"描述"界面,不"产出"东西,所以返回 Unit |
如果硬要 return 一个东西,等于让"画画的师傅"交回一块墙皮------没人接、Compose 也用不上,纯属误导读者。 |
④ 最后一个参数惯例是 modifier |
让调用方能从外部调整尺寸/边距,见 6.6 | 没有它,组件尺寸/边距写死在内部,外部想"让这个列表占满宽度"都做不到,组件没法复用。 |
| ⑤ 直接读可观察状态,别先拷进普通变量 | 在 Composable 里直接读 count,而不是先 val s = count 存进普通变量再读 |
你把状态拷进普通局部变量,Compose 就跟踪不到它变化------状态明明变了,界面却纹丝不动(6.4.4 会讲为什么)。 |
| ⑥ 参数别太碎、别每帧都换新对象 | 尽量把相关参数打包成稳定类型,别一次传十几个、更别每次重组都 new 一个新对象传进去 |
Compose 无法判断"这个组件上次和这次到底变没变",只好把它连同它的整棵子树全部重画一遍------流式输出时就是肉眼可见的卡顿。 |
⚠️ 约束 :
@Composable函数只能 被另一个@Composable函数调用。如果你在普通函数里调用它,编译报错。反过来,Composable 里可以调普通函数------没问题。
6.2.3 一个重要的隐含要求:幂等
Compose 可能以任意频率重新执行你的函数。所以它必须:
- 可以安全地重复执行 ------这叫幂等(idempotent):同一件事做一次和做十次,结果一模一样(比如"画一个写着你好的按钮",画几遍都还是那个按钮);
- 不要在函数体里做"有副作用"的事(比如写文件、发网络请求、改全局变量)。("副作用"= 函数在"画图"之外还偷偷干了别的、会影响函数以外世界的事;因为这个函数会被反复执行,偷偷干的事也会被重复干很多次,就乱套了。)
需要副作用时,用专门的 API------就是 6.5 要讲的 LaunchedEffect。
💡 类比 :Compose 函数像菜谱,可以被读很多次 ,
但你不能在菜谱里写"现在立刻去菜市场买菜"(副作用)。
要做这种事,得走专门的"采购流程"(
LaunchedEffect)。
6.3 状态提升:让子界面变成"纯展示"
6.3.1 概念
状态提升(state hoisting) :把 Composable 内部的可变状态移到调用方 ,
让这个 Composable 变成"输入 → 输出"的纯函数。
提升前(有状态): InputArea 自己存 userInput,自己改
提升后(无状态): InputArea 接收 userInput(值)+ onUserInputChange(回调)
真正存数据的是它的调用方
6.3.2 本工程的写法
InputArea 是教科书级的无状态组件。看它的参数:
✅ 取自本工程 ------ ui/InputArea.kt:31-38
| 参数 | 类型 | 角色 |
|---|---|---|
userInput: String |
值 | 状态(显示什么) |
onUserInputChange: (String) -> Unit |
回调 | 事件(用户改了,通知上层) |
onSendMessage: () -> Unit |
回调 | 事件 |
isSendButtonEnabled: Boolean |
值 | 状态 |
isGenerating: Boolean |
值 | 状态 |
onStopGeneration: () -> Unit |
回调 | 事件 |
注意:InputArea 自己不存任何状态。 它只负责"根据给我的值画出来,用户操作时回调上去"。
🧒 先补一课:什么是"回调(callback)"?
看参数表里的
onUserInputChange: (String) -> Unit。这不是一个普通值,而是**"一个别人传进来的函数"**。你可以想成:InputArea 不关心"用户打了字之后怎么办",它只负责------等用户打字时,把新字喊一声"喂!变了!",具体怎么处理由外面传进来的那张纸条(函数)决定 。
这种"我不自己做,而是把决定权交回给调用我的人"的做法,就叫回调。
命名上的约定:回调参数一律以
on开头(onXxx),一看就知道"这是用户做了某事后的通知"。
函数体里对应的用法(✅ 同文件):
kotlin
// InputArea.kt:54-56
TextField(
value = userInput, // ← 显示"传进来的值"
onValueChange = onUserInputChange, // ← 用户打字时,调回调告诉上层
...
)
这就是 Compose 里最核心的数据流模式:
状态向下传(value) ↓
事件向上抛(onXxx) ↑
单向数据流(Unidirectional Data Flow, UDF)。
6.3.3 为什么这么麻烦?
看起来"多此一举"------让 InputArea 自己存 userInput 不是更简单吗?
✅ 取自本工程、已编译验证 ------ ui/ChatScreen.kt:347
kotlin
isSendButtonEnabled = isModelReady && !isGenerating,
这一行就是答案:"发送按钮能不能点"取决于多个条件 :
模型加载好了(isModelReady)且 没在生成中(!isGenerating)。
如果 userInput 存在 InputArea 内部,那么:
InputArea知道"输入框内容"(自己的状态);ChatScreen决定"按钮能不能点"(需要isModelReady等);- 两者要读同一份真相,否则就会出现"输入框有字但按钮点不了"或"没字却能点"的错位。
把状态提到上层后:
InputArea(展示文字)和ChatScreen(决定按钮可用)读的是同一份userInput;- 只有一个地方能改它 → 不可能不一致。
📌 状态提升的三大好处:
- 单一真相源:不会出现两处状态打架。
- 可复用 :
InputArea不依赖任何具体业务,换任何页面都能用。- 可测试/可预览:传什么值就显示什么,不需要构造复杂的环境。
6.3.4 本工程里的另一个例子
✅ 取自本工程 ------ ui/InputArea.kt:110-114
kotlin
onClick = {
if (isSendButtonEnabled) {
onSendMessage()
}
},
注意 isSendButtonEnabled 是从外面传进来的 ,InputArea 自己不判断"能不能发",
只是"收到点击就回调,至于是不是该发由上层决定"。
📝 一个细节 :这里既在
onClick里判了isSendButtonEnabled,按钮本身通常也应该
enabled = isSendButtonEnabled(视觉上置灰)。本工程在按钮上额外做了
widthIn(min = 100.dp)等样式处理。读代码时留意这类"逻辑判断 + 视觉状态"是否一致,是找 UI bug 的常用角度。
6.4 重组与 remember 的边界
6.4.1 什么是重组
这是什么(大白话) :先把"状态"这个词钉死。
**状态(state)**就是"界面此刻要记住的那些数据"------计数器当前是几、输入框里打了什么字、抽屉开着还是关着。
它就是 6.1 类比里"客厅的布置":布置没变,画就不用重画;布置一变,照着图纸施工的人就得重新摆。
**重组(recomposition)**就是上面这件事的学名:当 Composable 读取 过的状态发生变化时,
Compose 重新执行这个函数,拿新值把界面重画一遍。
状态变化 → Compose 找到"读了它的"那些 Composable → 重新执行它们 → 界面更新
为什么需要(没有它会怎样) :如果没有重组,状态变了界面不会自动更新------
你点了按钮,后台计数确实加了,但屏幕上还显示老数字,等于"换了布置图却没人照着重新摆"。
你就得像 6.1.1 命令式那样,自己记得"改完状态还要手动去刷一下界面",一忘就出 bug。
关键点 :Compose 只 重新执行"状态真的变了、且真的读了它"的那部分函数,
不是把整个界面全部重画。这也是它快的原因------6.4.4 结尾的计数器走例会让你亲眼看到这点。
6.4.2 没有 remember 会怎样
Compose 函数在每次重组时会被重新执行 。函数里的局部变量也会重新创建。
📝 最小反例(需自行验证):
kotlin
@Composable
fun Counter() {
var count = 0 // ❌ 每次重组都重新变成 0
Button(onClick = { count++ }) {
Text("点了 $count 次")
}
}
这个计数器永远显示 0 ------因为点按钮后 count++,但这个值存不住,
下次重组时 var count = 0 又把它重置了。
6.4.3 remember 解决什么
📝 最小示例(需自行验证):
kotlin
@Composable
fun Counter() {
var count by remember { mutableStateOf(0) } // ✅ 记住这个值
Button(onClick = { count++ }) {
Text("点了 $count 次")
}
}
拆开看三个东西:
| 部分 | 作用 |
|---|---|
mutableStateOf(0) |
创建一个可被 Compose 观察的状态对象(初始值 0) |
remember { ... } |
让这个对象在重组之间存活,不会被重新创建 |
by |
属性委托(ch03 讲过),让你直接写 count 而不是 count.value |
💡
remember的本质 :把值存在"Composition"(Compose 维护的一棵树)里,重组时同一个位置还能取回上一次的那个对象。
类比:把便签贴在工位上 (不是揣兜里)。
重组 = 换了个班次,但便签还贴在工位上,下一个人能接着看。
6.4.4 为什么必须配合 mutableStateOf
只用 remember 不够:
kotlin
var count = remember { 0 } // ❌ 记住了,但 Compose 观察不到变化
count++ // 界面不会刷新
remember 只负责"别丢",不负责"通知 Compose 我变了" 。
通知这件事由 mutableStateOf 完成------它创建的是 State<T>,
Compose 会记录"谁读了我",变化时通知它们重组。
所以正确写法永远是:remember { mutableStateOf(默认值) }。
从上到下走一遍:跟着计数器点两次
把上面那个能工作的 Counter 摊开,从头走一遍,你就彻底懂"重组"了。
第 0 步(App 刚打开,一次都没点):
remember { mutableStateOf(0) }执行一次,记下count = 0;Button画出来,它里面的Text画出来,显示"点了 0 次"。- 屏幕上就是:一个按钮,旁边写着 点了 0 次。
第 1 次点按钮:
-
按钮的
onClick跑了:count++→count从 0 变成 1。 -
因为
count是mutableStateOf包起来的可观察状态 ,它一变,Compose 立刻收到通知:"有谁读过我?"------哦,是
Text("点了 $count 次")这一段读过我。 -
Compose 只把"读过
count的那一小段"重新执行一遍 (这就是重组 ):函数再跑一次,这次
count是 1,Text就显示"点了 1 次"。 -
屏幕更新成:点了 1 次。
注意:
Button本身的样子(颜色、圆角、尺寸)没变,Compose 没必要把整个按钮连同背景都重画一遍------它只关心"读了
count的那一小段"。这就是"局部重画",不是"整个界面推倒重来"。
第 2 次点按钮 :一模一样,再来一遍------count++ 变 2 → 通知 → 重跑读 count 的那段 → 显示"点了 2 次"。
📌 把这条链背下来 :
改状态(
count++)→ Compose 发现状态变了 → 只重新执行读了它的那部分(重组)→ 界面跟上。你从头到尾没有 写过一句"把按钮文字改成 2",全是状态一变、界面自己跟上的。
这就是 6.1 那句 UI = f(状态) 在代码里真正长什么样。
📌 顺带认识一个词:Snapshot(快照) 。你现在不用深究------它指 Compose 在一次重组开始时,给当时所有状态拍了一张"照片",这次重组里读状态都按这张照片来。
6.8 表里那条
Reading a state that was created after the snapshot was taken的报错,意思就是"你在这次拍照之后才新建的状态,这次重组读不到它"。知道有这个东西、报错能对上号即可,ch09 会再提。
6.4.5 本工程里的真实用法
✅ 取自本工程、已编译验证 ------ ui/MessageList.kt:25
kotlin
val listState = rememberLazyListState()
rememberLazyListState() 内部就用了 remember------它记住列表的滚动位置。
如果不记住,每次重组列表都会"忘记"滚到哪了,滚回顶部。
同类还有 ch04 见过的:
✅ 取自本工程 ------ MainActivity.kt:59
kotlin
val settings = remember { AppSettings(this) }
不 remember 的话,每次重组都会新建一个 AppSettings,
导致 Flow 反复重建、DataStore 反复读盘。
6.4.6 remember 的 key:什么时候该重新计算
remember 还可以接受 key。key(键)就像这次计算的"身份证号" :
key 还是原来那个,就沿用上次算好的结果;key 一旦换成新的,就重新算一遍:
kotlin
val blocks = remember(content) { parseMarkdown(content) }
// ↑ key:content 变了才重新解析
这在本工程的 MarkdownText.kt:246 用到------Markdown 解析比较耗时,
只在文本内容真的变了时才重新解析。ch07 会细讲。
📌
remember的两种形态:
remember { ... }------ 永远只算一次,一直沿用。remember(key) { ... }------ key 变了就重算,否则沿用。
6.4.7 rememberSaveable:跨配置变更存活
remember 只在重组 之间保住值。但旋转屏幕 (配置变更)会让 Activity 重建,
Composition 也重建------remember 的值会丢。
要跨配置变更存活,用 rememberSaveable:
kotlin
var text by rememberSaveable { mutableStateOf("") }
它会自动把值存进 savedInstanceState。
⚠️ 注意:本工程里聊天记录不用
rememberSaveable------因为它在 ViewModel 里(ch04 讲过 ViewModel 跨配置变更存活)。
只有"纯 UI 的临时状态"(比如输入框草稿、展开/收起)才需要
rememberSaveable。
6.5 LaunchedEffect:在 Composable 里安全地做"副作用"
6.5.1 问题
Composable 函数会被频繁重新执行,所以不能在函数体里直接做耗时/有副作用的事:
kotlin
@Composable
fun Bad() {
// ❌ 每次重组都发一次请求!
viewModel.loadData()
}
需要"在界面出现时做某件事、且只做一次(或在特定条件下重做)"的机制------
这就是 LaunchedEffect。
6.5.2 基本形式
kotlin
LaunchedEffect(key1, key2) {
// 这个大括号里的代码运行在协程里
// key 不变就不重启;key 变了就取消旧的、启动新的
}
两个要点:
- 它开了一个协程 :大括号里可以调
suspend函数(ch08 详讲协程)。 - key 决定生命周期 :
- key 不变 → 协程继续跑;
- key 变了 → 取消旧协程,启动新协程;
- Composable 离开界面 → 协程自动取消(不会泄漏)。
6.5.3 本工程最精彩的一处
✅ 取自本工程、已编译验证 ------ ui/MessageList.kt:25-39
kotlin
val listState = rememberLazyListState()
// 流式输出期间消息条数不变,因此同时监听最后一条的内容长度,保证持续滚动到底。
// 注意用 scrollToItem 而非 animateScrollToItem:动画是挂起的,内容每变化一次就重启一次
// 动画会导致动画永远跑不完且反复取消/重排;生成结束后再用动画平滑滚动一次。
val lastMessage = messages.lastOrNull()
LaunchedEffect(messages.size, lastMessage?.id, lastMessage?.content?.length) {
if (messages.isEmpty()) return@LaunchedEffect
val lastIndex = messages.size - 1
if (isGenerating) {
listState.scrollToItem(lastIndex)
} else {
listState.animateScrollToItem(lastIndex)
}
}
这是全书信息密度最高的 Compose 代码之一,值得逐段拆:
| 行 | 代码 | 说明 |
|---|---|---|
| 25 | rememberLazyListState() |
记住滚动位置(6.4.5) |
| 30 | messages.lastOrNull() |
最后一条消息(可能 null) |
| 31 | LaunchedEffect(三个 key) |
见下方详解 |
| 32 | if (messages.isEmpty()) return@LaunchedEffect |
空列表直接返回 |
| 33 | lastIndex = messages.size - 1 |
最后一项下标 |
| 34-38 | scrollToItem / animateScrollToItem |
见下方详解 |
6.5.4 为什么是三个 key
kotlin
LaunchedEffect(messages.size, lastMessage?.id, lastMessage?.content?.length)
要理解这个,先想清楚"什么时候该滚到底":
| 场景 | 哪个值变了 | 需要滚吗 |
|---|---|---|
| 用户发了一条新消息 | messages.size +1 |
✅ 要 |
| 模型开始回复(新增一条消息) | messages.size +1 |
✅ 要 |
| 流式输出中,最后一个字在变长 | size 不变,但内容长度变 |
✅ 要(否则看不到新字) |
| 切换到另一个会话 | lastMessage?.id 变 |
✅ 要 |
关键在第三种 :流式输出时,消息条数不变 (还是那一条),
只有内容在变长 。所以只监听 messages.size 是不够的------
必须再监听"最后一条内容的长度"。
那为什么还要 lastMessage?.id?
因为切换会话时,可能新旧两条消息长度恰好相同 ,只比长度会漏掉。
用 id 能确保"换了另一条消息"一定触发。
📌 一句话总结三个 key:
messages.size------ 消息条数变了lastMessage?.id------ 最后是哪一条变了lastMessage?.content?.length------ 最后一条的内容长度变了三者合起来,覆盖了"任何需要滚到底的情况"。
6.5.5 为什么生成中用 scrollToItem 而不是动画
注释里已经写明(同文件 28-29 行):
注意用
scrollToItem而非animateScrollToItem:动画是挂起的,内容每变化一次就重启一次动画会导致动画永远跑不完且反复取消/重排;
生成结束后再用动画平滑滚动一次。
翻译成人话:
animateScrollToItem是动画 ,需要时间,而且是suspend的。- 流式输出时内容每 ~100ms 变一次 → 每次都重启动画 → 动画永远跑不完,界面抖动。
- 所以用瞬时 的
scrollToItem(直接跳过去)。 - 等生成结束(
isGenerating == false),再用动画平滑滚一次,体验更好。
💡 这是本项目一个很典型的"被现实逼出来的细节" :
光看 API 名字,
animateScrollToItem更"高级",但在高频更新场景下它反而会坏事。选 API 要看场景,不是看名字。
6.6 Modifier:控制尺寸、边距、点击
6.6.1 概念
先打个比方:修饰 = 给组件穿衣服、摆位置。
组件本身(Text、Button)就像一个人;Modifier 就是你给他穿的一身衣服和站的姿势:
padding是贴身内衣(把内容往里面收一收);background是外套(在外面罩一层颜色);size是尺码(这个人占多大块地方);clickable是"这人被碰一下会有反应"。
穿衣服的顺序不同,穿出来的样子也不同 ------先穿内衣再套外套,和先套外套再穿内衣,结果当然不一样。
Modifier 就是这样一串"按顺序穿上去"的修饰,用 . 连起来,从左到右依次生效。
它能设置大小、边距、背景、点击行为等。
✅ 取自本工程 ------ ui/InputArea.kt:82-84
kotlin
modifier = Modifier
.weight(1f) // 占满剩余宽度
.padding(horizontal = 8.dp, vertical = 8.dp) // 内边距
6.6.2 常用 Modifier
| Modifier | 作用 | 本工程例子 |
|---|---|---|
padding(...) |
外边距/内边距 | .padding(horizontal = 16.dp, vertical = 12.dp) |
size(...) / widthIn(...) |
尺寸 | .size(48.dp)、.widthIn(min = 100.dp) |
fillMaxWidth() |
填满父容器宽度 | Modifier.fillMaxWidth() |
weight(1f) |
在 Row/Column 里占剩余空间 | .weight(1f)(输入框) |
background(...) |
背景色 | --- |
clickable { } |
点击 | --- |
6.6.3 ⚠️ 顺序很重要
这是新手最常踩的坑:Modifier 的链式调用顺序会改变结果。
kotlin
// A:先设背景,再加 padding
Modifier.background(Color.Red).padding(16.dp)
// → 红色区域包含 padding(外圈是红的)
// B:先加 padding,再设背景
Modifier.padding(16.dp).background(Color.Red)
// → padding 之后才开始画红色(红色区域更小)
💡 类比:想象给一幅画装框。
- 先刷底色再留白边 → 白边在底色之上(等于 A:红色铺满,padding 是内容缩进)
- 先留白边再刷底色 → 底色只画在白边之内(等于 B)
规则:Modifier 从左到右依次"包裹",后面的作用于"前面结果的内部"。
为什么顺序会改变结果(执行直觉) :可以把每一步都想象成"拿到上一步的成品,再在它上面加工一道"。
以 16dp 的
padding和红色background为例:
- 先
padding再background(= B:.padding(16).background(红)):
先在内容四周留出 16dp 空白,然后 在"剩下这块区域"刷红。
结果:红色只包住内容 ,那 16dp 空白在红色背景外面、是白的。- 先
background再padding(= A:.background(红).padding(16)):
先把整块刷红,再 在红色区域内部往内容方向收 16dp。
结果:红色区域包含了那 16dp(外圈也是红的),白边只是内容往里缩了。一句话:每一步都在前一步的产出上继续加工,谁先谁后,"谁罩住谁"就不同。
6.6.4 本工程里的 modifier 参数惯例
回看 InputArea 的签名(InputArea.kt:38):
kotlin
modifier: Modifier = Modifier
每个可复用的 Composable 都应该有这个参数 ,并且是最后一个参数 (有默认值)。
这样调用方可以:
kotlin
InputArea(
userInput = text,
...,
modifier = Modifier.fillMaxWidth() // 从外部决定它多大
)
这是 Compose 的官方惯例,本项目所有界面组件都遵守
(MessageList.kt:23、MessageItem 等同理)。
6.7 动手验证:用计数器观察重组与 remember
6.7.1 实验代码
在工程的任意 .kt 文件里临时加这个(📝 示例,验证完删掉):
kotlin
@Composable
fun CounterDemo() {
var withRemember by remember { mutableStateOf(0) }
var withoutRemember = 0
Log.d("RECOMP", "重组了一次:withRemember=$withRemember, withoutRemember=$withoutRemember")
Column(modifier = Modifier.padding(16.dp)) {
Text("有 remember: $withRemember")
Text("无 remember: $withoutRemember")
Button(onClick = {
withRemember++
withoutRemember++
}) {
Text("点我 +1")
}
}
}
需要 import:
kotlin
import androidx.compose.runtime.*
import androidx.compose.material3.*
import androidx.compose.foundation.layout.*
import android.util.Log
6.7.2 预期现象
点几次"点我 +1",然后看 Logcat(过滤 RECOMP):
RECOMP: 重组了一次:withRemember=1, withoutRemember=0
RECOMP: 重组了一次:withRemember=2, withoutRemember=0
RECOMP: 重组了一次:withRemember=3, withoutRemember=0
结论(这是本节要你亲眼看到的):
| 变量 | 行为 |
|---|---|
withRemember |
能累加 (1→2→3),因为 remember 保住了它 |
withoutRemember |
永远是 0 ,因为每次重组都重新执行 var withoutRemember = 0 |
而且每点一次,日志新增一行------证明重组确实发生了。
6.7.3 再加一个对照:只用 remember 不用 mutableStateOf
kotlin
var noState by remember { mutableIntStateOf(0) } // 对照组用 mutableStateOf
var plainRemember = remember { 0 } // 只 remember,不是 State
点击时 plainRemember 的值其实被改不了(它是 val 语义的整数),
即使改成可变容器,Compose 也观察不到 它的变化 → 界面不刷新。
这验证 6.4.4 的结论:记住(remember)和通知(mutableStateOf)是两件事,必须配套。
6.7.4 看不到日志?
| 现象 | 原因 | 解决 |
|---|---|---|
| Logcat 没输出 | 这个 Composable 没被真正显示 | 确认它被某个界面的 setContent 调用到 |
| 只有一行 | 界面没重组(说明状态没被 Compose 观察) | 检查是不是漏了 mutableStateOf |
| 报 "not a composition local" 之类 | 在非 Composable 上下文调用了 | 确保这段代码在 @Composable 函数里 |
6.8 常见错误与排查
| 现象 / 报错 | 原因 | 解决 |
|---|---|---|
| 计数器永远显示 0 | 没用 remember,值被重置 |
remember { mutableStateOf(0) } |
| 值变了但界面不刷新 | 只 remember 没 mutableStateOf |
用 mutableStateOf(6.4.4) |
java.lang.IllegalStateException: Reading a state that was created after the snapshot was taken |
在重组过程中读了新建的状态 | 检查状态创建时机,一般把 remember 放函数顶部 |
| 界面反复抖动/滚动不停 | 流式输出用了 animateScrollToItem |
改用 scrollToItem(6.5.5) |
| 流式输出时列表不跟着滚 | LaunchedEffect 只监听了 messages.size |
加上内容长度 key(6.5.4) |
| 旋转屏幕后 UI 临时状态丢了 | remember 不跨配置变更 |
用 rememberSaveable,或把状态放进 ViewModel |
| Modifier 效果不对 | 链式顺序错了 | 调整顺序(6.6.3) |
@Composable invocations can only happen from the context of a @Composable function |
在普通函数里调用了 Composable | 只能从 Composable 里调 |
界面闪退:NPE 在 Composable 里 |
重组时读到了 null | 用 ?. / ?: 做空安全处理(ch03) |
6.9 小结
| 概念 | 一句话 | 工程示例 |
|---|---|---|
| 声明式 UI | UI = f(状态),不手动改控件 | 全部界面 |
| 重组 | 状态变了,Compose 重新执行 Composable | 流式输出时高频发生 |
@Composable |
界面函数标记;大驼峰、无返回值、可重复执行 | InputArea(:30)、MessageList(:15) |
| 状态提升 | 状态放调用方,子组件只展示 + 回调 | InputArea 的 userInput + onUserInputChange |
| 单向数据流 | 状态向下、事件向上 | value = / onValueChange = |
remember |
让值在重组之间存活 | rememberLazyListState()(:25) |
mutableStateOf |
让 Compose 能观察到变化并触发重组 | 计数器 |
remember(key) |
key 变了才重算 | remember(content) { parseMarkdown(content) } |
rememberSaveable |
跨配置变更存活(UI 临时状态) | 本项目聊天记录不用(在 ViewModel) |
LaunchedEffect |
在协程里做副作用,key 控制重启 | 三 key 自动滚到底(:31) |
scrollToItem vs 动画 |
高频更新用瞬时,结束用动画 | isGenerating 分支(:34-38) |
Modifier |
尺寸/边距/点击,顺序敏感 | .weight(1f).padding(...) |
6.10 本章你学会了什么
逐条自测。任何一条打不了勾,回到对应小节再看一遍。
- 我能说清命令式和声明式的区别(并举个例子)。
- 我知道为什么本项目不用 XML 布局。
- 我能写出一个符合四条约定的
@Composable函数。 - 我理解"Composable 可能被频繁重新执行"意味着什么。
- 我能解释什么是状态提升,以及它带来的三个好处。
- 我能画出"状态向下、事件向上"的单向数据流。
- 我知道
InputArea为什么自己不存userInput。 - 我能解释"没有
remember会怎样"。 - 我知道
remember和mutableStateOf各自负责什么、为什么要配套。 - 我亲手做过计数器实验,看到过"有/无 remember"的差别。
- 我能解释
MessageList三个 key 各自解决什么问题。 - 我知道为什么生成中用
scrollToItem而不用动画。 - 我知道
Modifier顺序会影响结果。 - 我知道每个可复用 Composable 都该有
modifier: Modifier = Modifier参数。
6.11 练习
练习 1:解释 MessageList.kt:31 的三个 key
题目 :去掉第三个 key lastMessage?.content?.length,流式输出时列表会怎样?为什么?
解题思路提示
回想流式输出时"变的是什么、不变的是什么"。
messages.size 在流式输出期间会变吗?
参考答案要点
- 现象 :只有新消息刚出现 的那一刻滚一次;之后模型一个字一个字往外吐时,
列表不会继续跟着滚 → 用户看不到最新的字(被挡在屏幕下方)。 - 原因 :流式输出期间,消息条数不变 (还是那一条 assistant 消息),
所以messages.size不变、lastMessage?.id也不变 →LaunchedEffect不会重启 →
滚动代码不再执行。 - 第三个 key 的作用 :
lastMessage?.content?.length每来一个 token 就变一次,
于是 effect 重启、重新滚到底。 - 这正是注释(:27)说的"流式输出期间消息条数不变,因此同时监听最后一条的内容长度"。
练习 2:写一个"记住滚动位置"的列表
用 rememberLazyListState 写一个 100 项的列表,滚到中间,
然后触发一次重组(比如点个按钮改变某个状态),观察滚动位置是否保持。
解题思路提示
关键是:如果把 rememberLazyListState() 换成每次新建(比如写在 remember 之外,
或者用 LazyListState() 直接构造),会发生什么?
参考答案要点
kotlin
@Composable
fun LongList() {
val listState = rememberLazyListState() // ✅ 记住滚动位置
var tick by remember { mutableStateOf(0) }
Column {
Button(onClick = { tick++ }) { Text("触发重组 ($tick)") }
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
items(100) { i -> Text("第 $i 项", modifier = Modifier.padding(16.dp)) }
}
}
}
- 用
rememberLazyListState():滚到中间 → 点按钮触发重组 → 位置保持 ✅ - 如果换成
LazyListState()(不 remember):每次重组都新建 → 滚回顶部 ❌ - 这就是
remember在真实场景的价值 ------它保住的不只是数字,还包括滚动位置、
动画状态、展开/收起等一切"UI 自己的记忆"。
练习 3:把 InputArea 改成"有状态"版本,体会状态提升
写一个 StatefulInputArea,让它自己 remember 输入框内容。
然后思考:如果页面需要"清空输入框"(比如发送后清空),这个版本会遇到什么麻烦?
解题思路提示
发送后要清空输入框。在有状态版本里,状态在子组件内部,
父组件怎么让它清空?
参考答案要点
kotlin
@Composable
fun StatefulInputArea(onSend: (String) -> Unit) {
var text by remember { mutableStateOf("") }
TextField(value = text, onValueChange = { text = it })
Button(onClick = { onSend(text) }) { Text("发送") }
}
- 麻烦 :父组件想清空输入框时,拿不到这个状态 。
你只能:① 把text提升上去(回到无状态方案);
② 或者传一个clearSignal之类的标志,让子组件用LaunchedEffect监听它再自己清空(绕远路)。 - 结论 :凡是"父组件需要控制/读取的状态",都应该提升 。
这正是本工程InputArea设计成无状态的原因------
发送后清空、切换会话时清空、加载草稿......都需要外部控制。 - 这也是 Compose 官方推荐"状态提升"的核心理由:让状态的所有者 = 需要控制它的那一层。
练习 4:Modifier 顺序实验
写两个 Box,分别用
Modifier.size(100.dp).background(Color.Red).padding(16.dp) 和
Modifier.padding(16.dp).size(100.dp).background(Color.Red),
观察红色区域大小有什么不同,并解释。
解题思路提示
记住规则:Modifier 从左到右依次"包裹",后面的作用于前面结果的内部 。
size 也会受前面 padding 的影响。
参考答案要点
- 第一条
size(100.dp).background(Red).padding(16.dp):
先定 100dp 大小 → 在这个区域画红色 → 再在红色区域内部 留 16dp 内边距。
结果:红色方块 100×100dp,里面的内容缩进了 16dp。 - 第二条
padding(16.dp).size(100.dp).background(Red):
先留 16dp 外边距 → 在剩下空间里定 100dp → 画红色。
结果:红色方块仍是 100×100dp,但整体向右下偏移了 16dp。 - 核心规律 :
background画的是"当前区域",前面如果有padding,红色就不含那部分。size定的是"当前可用区域里的尺寸",前面的 padding 会把它挤小或推移。
- 实践建议 :按顺序写
size → background → padding(先定尺寸、再画背景、最后留内容边距)
通常最符合直觉。
练习 5:给 MessageList 加"只在用户手动滚动时才停止自动滚动"
真实聊天 App 有个体验细节:如果用户自己往上翻 看历史,
此时模型还在生成,你不该强行把他拽回底部。
请思考:怎么利用 listState 判断"用户是否手动滚动过"?
解题思路提示
LazyListState 提供了 isScrollInProgress(是否正在滚动)、
firstVisibleItemIndex、canScrollForward 等信息。
canScrollForward == true 意味着"还能往下滚"= 当前不在底部。
参考答案要点
-
思路 :在自动滚动前先判断"用户是不是已经在看别处"。
最简单的判据:listState.canScrollForward------
如果为true,说明列表还能往下滚,即用户没在底部(可能在翻历史),此时不强制滚到底。 -
伪代码:
kotlinLaunchedEffect(size, id, len) { if (messages.isEmpty()) return@LaunchedEffect // 用户手动往上翻时,不打断他 if (listState.canScrollForward) return@LaunchedEffect listState.scrollToItem(messages.size - 1) } -
进阶 :更严谨的做法是区分"程序滚动"和"用户滚动"
(用isScrollInProgress+ 记录上次程序滚动的时间)。 -
这也说明 :本项目当前实现(无条件滚到底)在"用户翻历史 + 模型在生成"这个组合下
体验有优化空间。读代码时保持这种"挑刺"的眼光,是提升能力的捷径。
6.12 自测题(附答案)
一、判断对错
- 用
remember记住的值,旋转屏幕后还在。 - 只写
remember { 0 }就能让界面随值变化自动刷新。 - 本工程里
InputArea自己保存输入框的内容。 - Composable 函数会被反复执行,所以不能在里面直接做副作用(比如发请求)。
二、选择
- "改状态 → 界面自动重画"这个动作叫?
A. 渲染 B. 重组 C. 重绘 D. 编译 - 让 Compose 能观察到 变化的是?
A.rememberB.mutableStateOfC.ModifierD.LaunchedEffect LaunchedEffect的 key 变了 会怎样?
A. 什么都不做 B. 取消旧协程、启动新协程 C. 编译报错 D. 崩溃
三、简答
- 什么是状态提升?它带来什么好处?
MessageList里LaunchedEffect(messages.size, lastMessage?.id, lastMessage?.content?.length)
为什么需要三个 key?- 为什么流式输出时要用
scrollToItem而不是animateScrollToItem?
答案与解析
一、判断
- ❌ 错 。
remember只在重组之间 存活;
旋转屏幕会让 Composition 重建,remember的值会丢 。
要跨配置变更存活得用rememberSaveable,
或者(更常见的做法)把状态放进 ViewModel(6.4.7)。 - ❌ 错 。
remember只负责"别丢",不负责通知 Compose 。
必须配mutableStateOf(6.4.4)。这是本章最常错的点。 - ❌ 错 。
InputArea是无状态 的------
它接收userInput(值)+onUserInputChange(回调),自己不存(6.3.2)。 - ✅ 对 。这是 Composable 的幂等要求 :
它可能被任意频率执行,所以不能在里面做副作用。
要做副作用得用LaunchedEffect(6.2.3、6.5)。
二、选择
- B(重组)(6.4.1)。
- B(
mutableStateOf)(6.4.4)。 - B。key 变 → 取消旧的、启动新的(6.5.2)。
三、简答(要点)
- 把状态从子组件移到调用方 ,子组件变成"输入 → 输出"的纯展示 + 回调。
好处(6.3.3):① 单一真相源 (不会两处状态打架);
② 可复用 ;③ 可测试/可预览。 - 因为"需要滚到底"有三种情况 (6.5.4):
messages.size→ 消息条数变了(新消息);lastMessage?.id→ 换了一条(切会话时可能长度恰好相同);lastMessage?.content?.length→ 流式输出时内容在变长 (条数不变!)。
缺第三个 key,流式输出时列表不会跟着滚。
- 因为
animateScrollToItem是动画 ,而动画是suspend的。
流式输出时内容每 ~100ms 变一次 → 每次都重启动画 → 动画永远跑不完 ,界面抖动。
所以生成中用瞬时 的scrollToItem;等生成结束再用动画平滑滚一次(6.5.5)。
评分建议 :第 2 题和第 9 题是本章的核心。
第 2 题答错说明"remember 和 mutableStateOf 的分工"没搞清,务必重读 6.4.3--6.4.4。
下一章 :ch07 Compose 实战:拆解聊天页。
这一章你建立了"状态驱动 UI"的心智模型,并读懂了两个小组件。
下一章我们要啃真正的页面 :带抽屉、列表、气泡、手写 Markdown 渲染的
ChatScreen(419 行),看看这些零件是怎么组装成一个完整聊天界面的。