ch06 Jetpack Compose 入门:状态驱动 UI

学习目标

读完本章,你应当能够:

  1. 说清"命令式 UI"和"声明式 UI"的本质差别,以及为什么本项目选 Compose;
  2. 写出一个符合约定的 @Composable 函数(命名、无返回值、幂等);
  3. 理解状态提升:把可变状态放到调用方,让子界面变成"纯展示";
  4. 说清**重组(recomposition)**是什么、remember 保住的是什么、以及"没有 remember 会怎样";
  5. 会用 mutableStateOf + remember 做一个能工作的计数器,并用日志观察重组次数;
  6. 看懂 LaunchedEffectkey 参数,能解释 MessageList 里那句
    LaunchedEffect(messages.size, lastMessage?.id, lastMessage?.content?.length) 为什么是三个 key;
  7. 理解 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)、remembermutableStateOfLaunchedEffectModifier、单向数据流(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 函数
怎么更新 findViewByIdsetText 改状态 → 自动重组
状态放在哪 散落在 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:它不是代码逻辑,而是贴在函数头上的一张"标签",给编译器/工具看额外说明------像外卖盒上贴的"热餐、小心轻放"贴纸。) 不加这个注解,编译器不把它当界面函数,里面连 TextButton 都调不了,直接编译报错。
② 函数名用大驼峰 和普通 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
  • 只有一个地方能改它 → 不可能不一致

📌 状态提升的三大好处

  1. 单一真相源:不会出现两处状态打架。
  2. 可复用InputArea 不依赖任何具体业务,换任何页面都能用。
  3. 可测试/可预览:传什么值就显示什么,不需要构造复杂的环境。

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 次点按钮

  1. 按钮的 onClick 跑了:count++count 从 0 变成 1

  2. 因为 countmutableStateOf 包起来的可观察状态 ,它一变,Compose 立刻收到通知:

    "有谁读过我?"------哦,是 Text("点了 $count 次") 这一段读过我。

  3. Compose 只把"读过 count 的那一小段"重新执行一遍 (这就是重组 ):

    函数再跑一次,这次 count 是 1,Text 就显示"点了 1 次"。

  4. 屏幕更新成:点了 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 变了就取消旧的、启动新的
}

两个要点:

  1. 它开了一个协程 :大括号里可以调 suspend 函数(ch08 详讲协程)。
  2. 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 概念

先打个比方:修饰 = 给组件穿衣服、摆位置。

组件本身(TextButton)就像一个人;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 为例:

  • paddingbackground (= B:.padding(16).background(红)):
    先在内容四周留出 16dp 空白,然后 在"剩下这块区域"刷红。
    结果:红色只包住内容 ,那 16dp 空白在红色背景外面、是白的。
  • backgroundpadding (= 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:23MessageItem 等同理)。


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) }
值变了但界面不刷新 remembermutableStateOf 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)
状态提升 状态放调用方,子组件只展示 + 回调 InputAreauserInput + 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 会怎样"。
  • 我知道 remembermutableStateOf 各自负责什么、为什么要配套。
  • 我亲手做过计数器实验,看到过"有/无 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(是否正在滚动)、

firstVisibleItemIndexcanScrollForward 等信息。

canScrollForward == true 意味着"还能往下滚"= 当前不在底部。
参考答案要点

  • 思路 :在自动滚动前先判断"用户是不是已经在看别处"。
    最简单的判据:listState.canScrollForward ------
    如果为 true,说明列表还能往下滚,即用户没在底部(可能在翻历史),此时不强制滚到底

  • 伪代码:

    kotlin 复制代码
    LaunchedEffect(size, id, len) {
        if (messages.isEmpty()) return@LaunchedEffect
        // 用户手动往上翻时,不打断他
        if (listState.canScrollForward) return@LaunchedEffect
        listState.scrollToItem(messages.size - 1)
    }
  • 进阶 :更严谨的做法是区分"程序滚动"和"用户滚动"
    (用 isScrollInProgress + 记录上次程序滚动的时间)。

  • 这也说明 :本项目当前实现(无条件滚到底)在"用户翻历史 + 模型在生成"这个组合下
    体验有优化空间。读代码时保持这种"挑刺"的眼光,是提升能力的捷径。


6.12 自测题(附答案)

一、判断对错

  1. remember 记住的值,旋转屏幕后还在
  2. 只写 remember { 0 } 就能让界面随值变化自动刷新。
  3. 本工程里 InputArea 自己保存输入框的内容。
  4. Composable 函数会被反复执行,所以不能在里面直接做副作用(比如发请求)。

二、选择

  1. "改状态 → 界面自动重画"这个动作叫?
    A. 渲染 B. 重组 C. 重绘 D. 编译
  2. 让 Compose 能观察到 变化的是?
    A. remember B. mutableStateOf C. Modifier D. LaunchedEffect
  3. LaunchedEffectkey 变了 会怎样?
    A. 什么都不做 B. 取消旧协程、启动新协程 C. 编译报错 D. 崩溃

三、简答

  1. 什么是状态提升?它带来什么好处?
  2. MessageListLaunchedEffect(messages.size, lastMessage?.id, lastMessage?.content?.length)
    为什么需要三个 key?
  3. 为什么流式输出时要用 scrollToItem 而不是 animateScrollToItem

答案与解析

一、判断

  1. remember 只在重组之间 存活;
    旋转屏幕会让 Composition 重建,remember 的值会丢
    要跨配置变更存活得用 rememberSaveable
    或者(更常见的做法)把状态放进 ViewModel(6.4.7)。
  2. remember 只负责"别丢",不负责通知 Compose
    必须配 mutableStateOf(6.4.4)。这是本章最常错的点。
  3. InputArea无状态 的------
    它接收 userInput(值)+ onUserInputChange(回调),自己不存(6.3.2)。
  4. 。这是 Composable 的幂等要求
    它可能被任意频率执行,所以不能在里面做副作用。
    要做副作用得用 LaunchedEffect(6.2.3、6.5)。

二、选择

  1. B(重组)(6.4.1)。
  2. B(mutableStateOf(6.4.4)。
  3. B。key 变 → 取消旧的、启动新的(6.5.2)。

三、简答(要点)

  1. 把状态从子组件移到调用方 ,子组件变成"输入 → 输出"的纯展示 + 回调。
    好处(6.3.3):① 单一真相源 (不会两处状态打架);
    可复用 ;③ 可测试/可预览
  2. 因为"需要滚到底"有三种情况 (6.5.4):
    • messages.size → 消息条数变了(新消息);
    • lastMessage?.id换了一条(切会话时可能长度恰好相同);
    • lastMessage?.content?.length流式输出时内容在变长 (条数不变!)。
      缺第三个 key,流式输出时列表不会跟着滚
  3. 因为 animateScrollToItem动画 ,而动画是 suspend 的。
    流式输出时内容每 ~100ms 变一次 → 每次都重启动画 → 动画永远跑不完 ,界面抖动。
    所以生成中用瞬时scrollToItem;等生成结束再用动画平滑滚一次(6.5.5)。

评分建议 :第 2 题和第 9 题是本章的核心。

第 2 题答错说明"remember 和 mutableStateOf 的分工"没搞清,务必重读 6.4.3--6.4.4。


下一章 :ch07 Compose 实战:拆解聊天页。

这一章你建立了"状态驱动 UI"的心智模型,并读懂了两个小组件。

下一章我们要啃真正的页面 :带抽屉、列表、气泡、手写 Markdown 渲染的 ChatScreen

(419 行),看看这些零件是怎么组装成一个完整聊天界面的。

相关推荐
JMchen1 天前
第 12 篇|项目整合与打包发布 —— 从 Demo 到可安装 APK 的完整收官指南
kotlin·android studio
Android打工仔1 天前
不要在 Data 层随意把 Cold Flow 转换成 Hot Flow
android·架构·kotlin
杉氧1 天前
拒绝重复造轮子:我写了一个生产级的 Kotlin 协程与 Flow 工具库(CoroutineKit)
android·kotlin·workflow
JMchen2 天前
第 9 篇|网络请求基础 —— 从 HttpURLConnection 到 OkHttp
kotlin·android studio
hai_android3 天前
Kotlin Flow combine 源码剖析:一个 Channel 如何优雅合并多条流
android·java·kotlin
敲代码的瓦龙3 天前
Jetpack?ViewModel!!!
android·开发语言·kotlin·android-studio
swithun3 天前
不用 WebView,我用 Kotlin + Compose Multiplatform 重写 Mermaid,并做了 2048 组对拍
android·开源·kotlin
JMchen3 天前
第 8 篇|本地存储技术选型 —— SP 与文件读写
kotlin·android studio
码农哈丁4 天前
从 JVM 到 Cargo:把整个 Kotlin 项目移植到 Rust 的踩坑实录
jvm·rust·kotlin