学习目标
读完本章,你应当能够:
- 说清
val和var的区别,以及为什么本工程默认全用val;- 读懂并写出空安全三件套
?/?./?:,并解释为什么本工程几乎不用!!;- 用
data class+copy()做"不可变更新",并说清它和"原地改列表"对 UI 刷新的影响;- 看懂
Context.dataStore这种扩展属性和by委托,知道它为什么能"凭空多出一个属性";- 读懂
formatFileSize与ThinkStripper里的循环与集合操作,并能自己改写;- 用
when写分支,知道它为什么比switch强、以及密封类搭配它的用法;- 亲手给
ModelInfo加一个字段并让工程编译通过——这是本章的通关验证。前置:ch01、ch02(你已经能把工程构建起来了)
对应源码(本章会逐个打开):
app/src/main/java/com/example/llama/model/ModelInfo.kt(24 行)—— 装数据 + 工具函数app/src/main/java/com/example/llama/model/Message.kt(13 行)—— 最小的 data classapp/src/main/java/com/example/llama/model/ChatSession.kt(22 行)—— 不可变列表 + 时间戳app/src/main/java/com/example/llama/util/AppSettings.kt(74 行)—— 扩展属性 + Flow + 委托app/src/main/java/com/example/llama/util/ThinkStripper.kt(59 行)—— 单例 + 正则 + 字符串本书 Kotlin 版本:2.3.0(见
gradle/libs.versions.toml的kotlin = "2.3.0")。
// Playground.kt —— ch03 语法点自包含示例
// 不依赖工程类,纯 Kotlin/JVM,用 kotlinc 即可运行(见 code/ch03/README.md)。
// 每个块对应书里一个小节。
// ---------- 3.2 val / var ----------
fun demoValVar() {
val name = "离线AI" // 只读
var count = 0 // 读写
count += 1
println("[3.2] name=$name count=$count")
}
// ---------- 3.3 空安全 ----------
fun demoNullSafety() {
var b: String? = null
val lenSafe = b?.length // null 安全调用 -> null
val lenElvis = b?.length ?: 0 // Elvis 兜底 -> 0
b = "hello"
val lenReal = b?.length ?: 0 // -> 5
println("[3.3] lenSafe=$lenSafe lenElvis=$lenElvis lenReal=$lenReal")
}
// ---------- 3.4 data class + copy ----------
data class Message(val id: String, val content: String, val isUser: Boolean)
data class Session(val title: String, val messages: List<Message>, val updatedAt: Long)
fun demoDataClass() {
val s1 = Session("会话A", emptyList(), 0L)
val newMsg = Message("m1", "你好", true)
// 不可变更新:copy 生成新对象,旧对象不变
val s2 = s1.copy(messages = s1.messages + newMsg, updatedAt = 1L)
println("[3.4] s1 消息数=${s1.messages.size}, s2 消息数=${s2.messages.size}")
println("[3.4] s1 == s2 ? ${s1 == s2}") // data class 的 equals 按字段比较 -> false
}
// ---------- 3.5 扩展函数 ----------
fun String.shout(): String = this.uppercase() + "!"
fun demoExtension() {
println("[3.5] ${( "离线AI" ).shout()}")
}
// ---------- 3.6 集合操作 ----------
fun formatFileSize(bytes: Long): String {
if (bytes <= 0) return "未知大小"
val units = arrayOf("B", "KB", "MB", "GB", "TB")
val unit = (Math.log(bytes.toDouble()) / Math.log(1024.0))
.toInt().coerceIn(0, units.lastIndex) // 替代 while:直接算级数
val value = bytes.toDouble() / Math.pow(1024.0, unit.toDouble())
return "%.1f %s".format(value, units[unit])
}
fun demoCollections() {
val tags = listOf("<think>", "</think>")
var best = 0
for (tag in tags) best = maxOf(best, tag.length)
println("[3.6] 最长标签长度=$best")
println("[3.6] 1_932_178_688 -> ${formatFileSize(1_932_178_688L)}")
}
// ---------- 3.7 when 表达式 ----------
const val THEME_SYSTEM = 0
const val THEME_LIGHT = 1
const val THEME_DARK = 2
fun themeLabel(mode: Int): String = when (mode) {
0 -> "跟随系统"
1 -> "浅色"
2 -> "深色"
else -> "未知"
}
fun demoWhen() {
println("[3.7] mode=2 -> ${themeLabel(2)}")
}
fun main() {
demoValVar()
demoNullSafety()
demoDataClass()
demoExtension()
demoCollections()
demoWhen()
println("全部示例运行完毕。")
}
🧭 读本章前,请先确认
- 前置:ch01、ch02(已经能构建成功)。
- 需要的基础:知道"变量、函数、循环"是什么——如果不知道,
先读附录 A.1(约 10 分钟)。不需要事先学过 Kotlin。- 本章会出现的生词:
val/var、空安全(?.?:)、data class、copy、
扩展函数、属性委托(by)、单例(object)、when、Lambda。
一起查 附录 B.2。- 读法建议:这是全书最该慢读的一章。
不要试图记住语法——只记"它在工程里长什么样"。
每看完一节,就去对应文件里找到那段代码看一眼(本章都给了文件路径)。
最后一定要做 3.10 的动手验证(给ModelInfo加字段并编译通过),
那是"真学会了"和"看懂了"的分界线。
本章导读
上一章你把工具装好了、APK 也打出来了。但打开任意一个.kt文件,大概率还是一脸懵:
val、Flow<Float>、data class、by、?:、object……这些是什么?好消息是:Kotlin 是一门很大的语言,但本项目只用了它的一个子集。
你不需要先啃完一本 800 页的 Kotlin 语法书再回来看工程——那样效率极低,
因为书里 80% 的特性本项目根本没用到。本章的讲法是反过来的:从工程里真实出现的那几行代码出发,遇到什么语法就讲什么。
💡 类比
学外语有两种方法:① 背完整词典再去读小说;② 拿一本小说边读边查词。
本章用第 ② 种——你现在手里已经有"小说"(本工程)了,缺的只是查词表。
读完本章你会认得工程里约 80% 的 Kotlin;剩下 20%(协程suspend、Flow、Composable)
不是"语法"而是"怎么组织异步和 UI",会在 ch06 之后专门讲——它们才是本项目真正的难点。
3.1 先建立正确预期:你不用学"全部 Kotlin"
本章只讲八块,每一块都对应工程里真实存在的代码:
| 你将在本章学会 | 工程里的出处 |
|---|---|
val / var 与不可变优先 | ModelInfo、Message、ChatSession 的字段 |
空安全(? / ?. / ?: / !!) | AppSettings 的 ?: 默认值 |
data class 与 copy | 三个 model 类 |
| 扩展函数 / 扩展属性 | Context.dataStore(AppSettings.kt:21) |
by 属性委托 | by preferencesDataStore(...) |
| 函数式集合操作 | formatFileSize、trailingPartialTagLength |
when 表达式 | AppSettings 的主题常量 + MainScreenState 状态机(ch09) |
object 单例 | ThinkStripper |
📝 标记说明:本章代码块右上角的标记含义——
✅ 取自本工程、已编译验证 ⚙️ 取自本工程、未在上板运行 📝 示例(需自行验证)
3.2 编程最基本的四件事(从没写过代码也别慌)
如果你从没写过代码,先别急着背语法。程序这件事说穿了只有最基本的几件事,
把这一节读完,后面所有语法都只是在给这几件事"贴标签、加包装"。
① 程序 = 数据 + 操作
数据是"是什么"(比如一个模型的名字、大小),操作是"做什么"(比如把字节数算成 MB、剥掉思考标签)。
写程序就是:把数据摆好,然后安排一连串操作去处理它。
📝 1536000.0 // 这是一个"数据"
size / 1024 // 这是一个"操作":把数据除以 1024
② 变量 = 给数据贴名字的标签
光把数据写在代码里,下次想用就找不到了。变量就是给数据贴一个标签,
以后喊标签名,计算机就把标签下面的那份数据递给你。
类比:抽屉上贴着写有"模型大小"的纸条——你不用记抽屉长什么样,喊名字就能拿到里面的东西。
📝 val size = 1536000.0 // 把数据 1536000.0 贴上标签 size
size / 1024 // 以后写 size,就是在喊那个标签
③ 类型 = 数据的"形状"
每个数据都有自己的形状:整数是"能加减的数字",字符串是"一段文字"。
形状不同,能做的操作就不同——你不能对一句问候语做除法。
类比:圆形插孔插不进方形插头,类型一不配对,编译器当场报警。
📝 val n: Int = 1024 // Int = 整数,能加减乘除
val s: String = "离线AI" // String = 一段文字,能拼接、能取长度
④ 函数 = 起了名字的一串操作
一个功能往往要好几步操作才完成(比如"显示文件大小"要除好几次 1024 再加单位)。
把这一串操作打包、起个名字,以后按一下这个名字就整套执行。
类比:洗衣机上的"标准洗"按钮——按一下就自动执行洗、漂、脱三步,
你不用每次都手动安排这三步。
📝 fun 格式化(字节: Long): String { /* 一连串操作打包在这里面 */ return "1.5 MB" }
格式化(1536000) // 按一下按钮,里面那串操作自动跑完
⑤ 语句按顺序执行
计算机不会"猜你想干什么"。写在前面的语句先执行,写在后面的后执行,
一步接一步,除非遇到循环、分支这种专门改变顺序的结构(本章后面会讲)。
读代码时记住:从上往下读,就是计算机真正干活的顺序。
📌 附录 A.1 有更详细的展开,本章只需理解到这个程度就能往下读。
3.3 val / var:不可变优先
3.3.1 这是什么、为什么需要
这是什么(大白话):变量就是 3.2 讲的"贴了名字的数据标签"。
Kotlin 里贴标签只有两种写法:
val name = "离线AI" // val = value,只读;赋值后不能再改(类似 Java 的 final)
var count = 0 // var = variable,可读写
试着给 val 重新赋值会编译不过(这是好事,错误被拦在编译期):
val name = "离线AI"
name = "别的" // ❌ 编译错误
你会看到:
error: val cannot be reassigned
为什么要分 val 和 var 两种?
因为代码里有一半数据你压根不希望它被改(比如一个模型的 id、名字)——
写成 val,谁要是手滑改了,编译器立刻拦下;
另一半本来就要变(比如循环计数、累加的体积值),才用 var。
Kotlin 的选择是:默认全部 val,真的需要变才用 var(为什么这么选,3.3.3 讲)。
💡
val不代表"对象内容不可变",只代表"这个变量不能指向别的对象"。
类比:val像把遥控器用胶水粘在茶几上——你不能换遥控器(变量不能重新指向),
但遥控器上的按钮你还是能按(对象内部的状态可能可变)。
真正"内容也不可变"要靠data class+ 全val字段 + 不可变集合(见 3.5)。
3.3.2 工程里怎么用
✅ 取自本工程、已编译验证 —— model/ModelInfo.kt:3-11
data class ModelInfo(
val id: String,
val name: String,
val path: String,
val size: Long = 0,
val isLoaded: Boolean = false,
val architecture: String = "",
val quantization: String = ""
)
逐行拆开看:
| 行 | 代码 | 说明 |
|---|---|---|
| 3 | data class ModelInfo( | 声明一个数据类(3.5 详讲) |
| 4 | val id: String, | 唯一标识,必填(无默认值) |
| 5 | val name: String, | 模型名,必填 |
| 6 | val path: String, | 文件路径,必填 |
| 7 | val size: Long = 0, | 字节数,有默认值 0 |
| 8 | val isLoaded: Boolean = false, | 是否已加载,默认 false |
| 9-10 | val architecture: String = "" 等 | 架构 / 量化信息,默认空串 |
全是 val,没有一个是 var。这不是巧合,而是工程惯例:
一个模型的信息一旦构造出来,就不该被随便改字段。需要"改"的时候,用 copy(3.5 讲)。
⚠️ 注意"必填 vs 有默认值"这个区别——它直接决定你 new 一个对象时要传几个参数:
ModelInfo(id = "m1", name = "Hy-MT2", path = "/sdcard/x.gguf") // ✅ 后四个用默认值 ModelInfo(id = "m1", name = "Hy-MT2") // ❌ 少传 path这个设计在 3.10 的动手验证里会变成你的"保护伞"。
3.3.3 为什么"不可变优先"
在多线程 / 协程环境里,不可变对象天然没有"读写竞争"问题:
两个协程同时读一个不会变的对象,不可能读到"改到一半"的状态。
本项目大量使用协程(ch08),聊天记录、模型列表都在协程里传递。
如果这些对象是 var 可变的,你就得处处加锁。用 val + copy,问题直接消失。
3.4 空安全:Kotlin 最有名的特性
3.4.1 问题从哪来
在 Java(以及很多语言)里,null 是个巨大的坑:
String s = null;
s.length(); // 编译能过,运行时炸:NullPointerException(NPE)
这个错误只在运行时才暴露,而且经常在你最想不到的地方炸。
业界把它称为"十亿美元的错误"。
3.4.2 Kotlin 的解法:把"能不能为 null"写进类型
var a: String = "hi" // 非空类型:不能赋 null
var b: String? = null // 可空类型:类型后面多个 ?
关键区别在类型后面那个问号:
String—— 我保证它永远不是 nullString?—— 它可能是 null,你必须处理
试着违反它,编译器立刻拦住:
var a: String = "hi"
a = null // ❌ 编译错误
var b: String? = null
val len = b.length // ❌ 编译错误
第二条你会看到:
error: only safe (?.) or non-null asserted (!!.) calls are allowed
on a nullable receiver of type String?
3.4.3 三件套:?. / ?: / !!
对可空类型,有三个工具:
| 写法 | 名字 | 含义 |
|---|---|---|
b?.length | 安全调用 | 如果 b 不是 null 就取 length;是 null 就整个表达式返回 null,不崩 |
b?.length ?: 0 | Elvis 运算符 | 左边为 null 时,用右边的值兜底 |
b!!.length | 非空断言 | “我发誓它不是 null”——发誓错了就 NPE 崩溃 |
组合起来的效果(b 是 String?):
val len1 = b.length // ❌ 编译错误
val len2 = b?.length // ✅ 类型变成 Int?(可能 null)
val len3 = b?.length ?: 0 // ✅ 类型变成 Int(null 时用 0)
💡 Elvis 为什么叫 Elvis?
因为?:侧过来看像猫王(Elvis Presley)的飞机头和眼睛。这是 Kotlin 官方的冷笑话。
3.4.4 工程里最典型的用法
⚠️ 先打个预防针:下面这行里的
context.dataStore.data.map { ... }是 ch08 才教的"异步数据流(Flow)“,
现在完全不用懂它。你只需要把它整体当成"一个可能为 null 的值”,
眼睛只盯住?:这一个符号怎么用。
怕混淆的话,先看这个 📝 最小版本——它和工程代码里的?:用法一模一样:📝 val name = userInput ?: "默认名" // userInput 可能是 null;是 null 就用 "默认名"
✅ 取自本工程、已编译验证 —— util/AppSettings.kt:32
val temperature: Flow<Float> = context.dataStore.data.map { it[TEMP_KEY] ?: DEFAULT_TEMPERATURE }
拆开看这一行为什么叫"空安全教科书":
| 片段 | 含义 |
|---|---|
context.dataStore.data | 一个数据流(Flow),每次设置变化会重新发值 |
.map { ... } | 把流里的每个元素做一次转换 |
it | Lambda 的隐式参数名,这里指"流里的那个元素"(一个 Preferences 对象) |
it[TEMP_KEY] | 按键取值。类型是 Float?——可能没存过这个值 |
?: DEFAULT_TEMPERATURE | 没存过就用默认温度 0.3 |
同样的模式在这几行重复出现(✅ 同文件):
// AppSettings.kt:35
val topP: Flow<Float> = context.dataStore.data.map { it[TOP_P_KEY] ?: DEFAULT_TOP_P }
// AppSettings.kt:38
val topK: Flow<Int> = context.dataStore.data.map { it[TOP_K_KEY] ?: DEFAULT_TOP_K }
// AppSettings.kt:41
val predictLength: Flow<Int> = context.dataStore.data.map { it[PREDICT_KEY] ?: DEFAULT_PREDICT_LENGTH }
📌 记住这个套路:
读一个可能不存在的值 ?: 默认值。本项目里到处都是,
它让你"永远不必写 if (x == null)"。
3.4.5 为什么本工程几乎不用 !!
!! 的意思是"我保证不为 null,否则你崩给我看"。它把编译期错误推迟成运行时崩溃,
恰恰是 Kotlin 想消灭的东西。
本工程里几乎看不到 !!。 你也应该养成习惯:看到 !! 先问自己
“我真的能保证吗?能不能用 ?. 或 ?: 代替?”
3.5 data class 与 copy:装数据就用它
3.5.1 这是什么、为什么需要
这是什么(大白话):凡是"主要用来装数据"的类,都用 data class。它自动帮你生成四个东西:
equals()、hashCode()、toString(),以及一个 copy()。
先把这四个自动生成的东西说人话:
equals():判断"两个对象装的内容是不是相同"。
比如两条Message的 id、content、isUser 都一样,m1 == m2就返回 true。hashCode():给对象算一个"快速编号",方便把它放进 HashMap、HashSet 这类容器里快速查找。
内容相同的对象必须算出相同的编号,否则 HashMap 会认错东西。toString():把对象转成一段文字方便打印。
不写它,println(message)只会打出一串乱码般的内存地址;写了它,
打出来是Message(id=m1, content=你好, isUser=true)——调试时能救命。copy():复制一份、只改你想改的字段(3.5.4 详讲)。
为什么需要(没有它要手写多少):不用 data class 的话,上面四个方法你得一个一个手写,
大约 30 行样板代码,而且手写很容易写错(比如 equals 漏比一个字段)。
下面是同一个 Message 的两个版本,对照着看:
📝 不用 data class 的手写版(约 30 行,只为让你感受"样板代码"有多烦):
class Message(
val id: String,
val content: String,
val isUser: Boolean
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Message) return false
return id == other.id && content == other.content && isUser == other.isUser
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + content.hashCode()
result = 31 * result + isUser.hashCode()
return result
}
override fun toString(): String =
"Message(id=$id, content=$content, isUser=$isUser)"
fun copy(id: String = this.id, content: String = this.content,
isUser: Boolean = this.isUser) = Message(id, content, isUser)
}
📝 Kotlin 的 data class 版(上面 30 行的全部效果,只要 5 行):
data class Message(
val id: String,
val content: String,
val isUser: Boolean
)
工程真身:下一节 3.5.2 就是工程里真实的 Message。
3.5.2 工程里最小的例子
✅ 取自本工程、已编译验证 —— model/Message.kt:9-13
data class Message(
val id: String,
val content: String,
val isUser: Boolean
)
三个字段:唯一 ID、内容、是不是用户发的。就这些。
📌 顺带看一眼上面的注释(同文件 3-8 行),它记录了一次真实的重构:
这个类原先定义在MessageAdapter.kt里,后来随 RecyclerView 适配器一起删掉、迁移到model包,
目的是让 UI 层只依赖 model 包。读工程注释经常能挖到这种"为什么这样设计"的信息,值得养成习惯。
3.5.3 带列表的例子
✅ 取自本工程、已编译验证 —— model/ChatSession.kt:16-22
data class ChatSession(
val id: String,
val title: String,
val messages: List<Message>,
val createdAt: Long,
val updatedAt: Long
)
注意 messages: List<Message> —— 这里的 <Message> 是泛型(generic),意思是"这是一个专门装 Message 的列表"。你可以读成"装消息的列表"。
为什么需要泛型? 想象一个没贴标签的空箱子:你往里塞苹果、塞橘子、塞书都可以,
但等你伸手去取的时候,根本不知道这次拿到的是苹果还是橘子——只能靠猜,猜错了就报错。
<Message> 就是贴在箱子上的标签:“这个箱子只准放 Message”。
你要是想往里塞一个 String,编译器当场拦住;你从里面取东西时,
编译器也知道"这一定是 Message",不用你再做"类型转换"这道猜测工序。
Kotlin 的 List 默认是只读的(没有 add/remove)。
要可变列表必须显式写 MutableList。这是 Kotlin 在类型层面帮你防错。
同文件上方还有一段很有价值的 KDoc(3-15 行),解释了多会话功能(F2)为什么需要这个类,
以及 updatedAt 的用途是会话列表排序。
3.5.4 copy:改一个字段、其余不变
因为字段全是 val、列表是只读 List,要"加一条消息"不能这么写:
session.messages.add(newMessage) // ❌ List 没有 add
正确做法是用 copy 生成一个新对象:
val updated = session.copy(
messages = session.messages + newMessage,
updatedAt = System.currentTimeMillis()
)
逐段解释:
| 片段 | 含义 |
|---|---|
session.copy(...) | 复制一份,copy 的参数都是可选的,只传你想改的 |
messages = session.messages + newMessage | + 对 List 是"返回一个新列表",原列表不变 |
updatedAt = System.currentTimeMillis() | 顺手更新最后修改时间 |
未提到的 id/title/createdAt | 原样保留 |
这叫不可变更新:旧 session 一点没变,你得到一个新对象 updated。
3.5.5 为什么非要这样?——因为它决定了 UI 刷不刷新
这是本章最重要的一个认知,请务必理解:
📝 Compose 靠"引用是否变了"来判断要不要重画 UI(ch06 详讲)。
如果你原地改列表(mutableList.add(...)),列表还是同一个对象,
Compose 看不出变化 → 界面不刷新,你会看到"数据变了但屏幕没变"的诡异 bug。
copy++会产生新的对象引用,Compose 立刻发现"哦,变了",于是重画。
所以"不可变更新"不是语法洁癖,而是让 UI 正常工作的必要条件。
ch09 讲 MainScreenState 时会看到,整个聊天记录的更新都建立在这个手法上。
3.6 扩展函数 / 扩展属性:给现有类"加方法"而不改它
3.6.1 这是什么、为什么需要
Kotlin 允许你给一个已经存在的类添加函数或属性——哪怕这个类不是你写的、你没有源码
(比如 Android 框架的 Context)。写法是在名字前面加 类名.:
为什么需要它? 你写 App 时会频繁用到 Android 框架的 Context、String、List 这些类,
但它们是 Google 写好的黑盒:你改不了源码,也没法给它们"加个新方法"。
扩展函数就是让你在不碰原类的前提下,像给它加了个成员一样调用。
// 给 String 加一个方法
fun String.stripThink(): String = ThinkStripper.strip(this)
// 之后就能这么用
val cleaned = rawText.stripThink()
函数体里的 this 指"被扩展的那个对象"。
3.6.2 工程里最精彩的一处
✅ 取自本工程、已编译验证 —— util/AppSettings.kt:21
private val Context.dataStore by preferencesDataStore(name = "app_settings")
这一行干了三件事,每一件都值得细看:
① Context.dataStore —— 这是扩展属性。
它给 Android 的 Context 类加了一个叫 dataStore 的属性。
之后在这个文件里,context.dataStore 就能直接用(见 3.4.4 那些 context.dataStore.data)。
② by preferencesDataStore(name = ...) —— 这是属性委托。
by 的意思是:“我不自己实现这个属性,把’怎么得到它’交给右边那个东西。”
preferencesDataStore(...) 是 DataStore 库提供的委托,它负责:
- 创建 DataStore;
- 保证同一个 name 只创建一次(进程内单例);
- 处理好线程安全。
你只写"我要一个叫 app_settings 的 DataStore",其余全交给库。
③ private —— 只在本文件内可见。
外部拿不到 context.dataStore,只能通过 AppSettings 的公开方法访问。这是封装。
💡 为什么叫"扩展"而不是"继承"?
因为你没有Context的源码,也不可能让所有Context的实现类都去继承你的子类。
扩展让你"在不碰原类的前提下,像给它加成员一样调用"。
这是 Kotlin 写 Android 最舒服的特性之一——Android 框架里遍地是"想加个方法却改不了"的类。
3.6.3 委托的另一种常见形式
by 在本书里还会以另一种形式出现(ch04 会见到):
private val mainState: MainScreenState by viewModels()
意思是"把获取 ViewModel 这件事委托给 viewModels() 这个函数"。
看到 by,就想成"这个东西的获取/存储逻辑交给别人了"。
3.7 函数式集合操作:少写循环
Kotlin 的集合有一堆好用的高阶函数(接受函数作为参数的函数)。本项目规模不大,但下面这些都用到了。
为什么需要它? 遍历一个列表做"逐个加工 / 挑出符合条件的 / 找第一个",
如果手写 for 循环,每次都要先备好一个临时容器、再写循环、再往里塞——
三行活缩成一行函数调用,读代码的人一眼就看出意图(map 就是"逐个变换",filter 就是"挑出来")。
🧒 先补一课:Lambda(匿名函数)和"高阶函数"是什么? 如果你从没写过代码,这里需要停一下。
- 函数 = 一段起了名字、能反复调用的操作(附录 A.1.3 讲过)。
- Lambda = 一段没起名字、临时塞在别人括号里的小操作,写法是
{ 它 -> 做什么 }。你可以想成"一张写着临时做法的小纸条,递给别人帮你执行"。- 高阶函数 = 接收这张纸条的函数。比如
list.map(纸条)的意思是"对列表里每个元素,都照这张纸条加工一下,给我新列表"。
对照:list.map { it.name }里,{ it.name }就是 Lambda(纸条),it是纸条里"当前这个元素"的临时名字。看不懂没关系——下面 3.7.3 那张表把本工程用到的写法都列了,照着认脸即可。
本项目规模不大,但下面这些都用到了。
3.7.1 先看一个真实的工具函数
✅ 取自本工程、已编译验证 —— model/ModelInfo.kt:13-24
/** 把字节数格式化为人类可读的体积(如 1.8 GB / 376 MB) */
fun formatFileSize(bytes: Long): String {
if (bytes <= 0) return "未知大小"
val units = arrayOf("B", "KB", "MB", "GB", "TB")
var value = bytes.toDouble()
var unit = 0
while (value >= 1024 && unit < units.lastIndex) {
value /= 1024
unit++
}
return "%.1f %s".format(value, units[unit])
}
逐行拆解:
| 行 | 代码 | 说明 |
|---|---|---|
| 13 | /** ... */ | KDoc 注释,说明这个函数干嘛 |
| 14 | fun formatFileSize(bytes: Long): String { | 函数声明:入参 Long,返回 String |
| 15 | if (bytes <= 0) return "未知大小" | 防御:非正数直接返回 |
| 16 | val units = arrayOf(...) | 单位表 |
| 17-18 | var value = ... / var unit = 0 | 注意这里是 var——循环里要改 |
| 19 | while (value >= 1024 && unit < units.lastIndex) | 还有得除、且没到最大单位 |
| 20-21 | value /= 1024 / unit++ | 除一次 1024,单位升一级 |
| 23 | return "%.1f %s".format(value, units[unit]) | 格式化成 “1.8 GB” |
几个语法点:
fun 名字(参数: 类型): 返回类型—— Kotlin 函数声明的固定格式。units.lastIndex——Array自带的属性,等于"最后一个下标"(这里是 4)。
等价于units.size - 1,但更不容易写错。"%.1f %s".format(...)—— 和 Java 的String.format一样:%.1f是保留 1 位小数,%s是字符串。arrayOf(...)—— 造数组;要列表用listOf(...)。
📌 为什么 17-18 行用
var而不是val?
因为它们在while循环里被重新赋值(value /= 1024就是value = value / 1024)。
这正好说明 3.3 讲的"能用 val 就用 val"不是教条——真要变的时候就用 var。
用具体输入把这段代码"跑一遍"(强烈建议跟着走一遍)
光看表容易觉得"懂了",其实没真正跑过。我们拿一个真实输入 1536000(字节,约 1.5 MB),
一步步看计算机执行时发生了什么(注意:代码里循环变量叫 value,不叫 size):
| 步骤 | 执行到哪 | 此时 value | 此时 unit |
|---|---|---|---|
| ① | 函数开始:units = ["B","KB","MB","GB","TB"],value = 1536000.0,unit = 0 | 1536000.0 | 0(B) |
| ② | while 判断:1536000 >= 1024 成立,且 unit(0) < 4 → 进循环 | 1536000.0 | 0 |
| ③ | value /= 1024 → 1500.0;unit++ → 1 | 1500.0 | 1(KB) |
| ④ | 再判断:1500 >= 1024 成立 → 再循环。value /= 1024 → 约 1.4648;unit++ → 2 | ≈1.4648 | 2(MB) |
| ⑤ | 再判断:1.4648 >= 1024 不成立 → 退出循环 | ≈1.4648 | 2(MB) |
| ⑥ | return "%.1f %s".format(value, units[2]) | — | 输出 "1.5 MB" |
注意最后输出是 "1.5 MB" 而不是 1.46——格式化串做了四舍五入,只留一位小数。
顺手解释 "%.1f %s".format(...) 这个格式化串:
%.1f= “一个数字,保留 1 位小数”(f 来自 float);%s= “一段文字”(s 来自 string);- 两个占位符中间的空格就是普通空格;
.format(value, units[2])按从左到右的顺序把括号里的两个参数填进%占位符:
先填 1.4648(→ “1.5”),再填 “MB”,拼出"1.5 MB"。
以后看到 "%.2f"、"%d" 这类写法就知道:百分号开头的是占位符,format 负责按顺序填空。
3.7.2 再看一个带集合遍历的
✅ 取自本工程、已编译验证 —— util/ThinkStripper.kt:48-58
private fun trailingPartialTagLength(text: String): Int {
if (text.isEmpty()) return 0
var best = 0
for (tag in listOf(OPEN_TAG, CLOSE_TAG)) {
val maxLen = minOf(tag.length - 1, text.length)
for (len in 1..maxLen) {
if (text.endsWith(tag.substring(0, len))) best = maxOf(best, len)
}
}
return best
}
语法点:
| 写法 | 含义 |
|---|---|
for (tag in listOf(...)) | 遍历一个只读列表 |
listOf(a, b) | 造只读 List(要可变的用 mutableListOf) |
1..maxLen | 区间,表示 1 到 maxLen(含两端) |
minOf(a, b) / maxOf(a, b) | 取较小 / 较大值 |
text.endsWith(x) | 字符串是否以 x 结尾 |
tag.substring(0, len) | 取前 len 个字符 |
这段在干什么?(这是理解流式输出的关键细节)
它在检查文本末尾是不是 <think> 或 </think> 的不完整前缀。
比如流式输出正好断在 "<th" 这里,函数返回 2,调用方就把这两个字符扣留,
等下一段数据来了再判断——避免把 <th 当成正文显示给用户。
3.7.3 常见的集合/字符串操作速查
| 操作 | 写法 | 说明 |
|---|---|---|
| 变换 | list.map { it.name } | 每个元素转一次,返回新列表 |
| 过滤 | list.filter { it.size > 0 } | 留下满足条件的 |
| 遍历 | list.forEach { println(it) } | 对每个元素执行操作(无返回值) |
| 查找 | list.firstOrNull { it.id == id } | 找第一个满足的,找不到返回 null |
| 排序 | list.sortedByDescending { it.updatedAt } | 按某字段倒序 |
| 求和 | list.sumOf { it.size } | 求和 |
| 拼接 | "a" + "b"、list.joinToString(", ") | 拼字符串 |
📝
it是单参数 Lambda 的隐式名字。写{ it.name }等价于{ x -> x.name }。
当 Lambda 只有一个参数时可以用it,多个参数就必须显式命名:{ a, b -> a + b }。
3.8 object:单例就这么写
3.8.1 这是什么、为什么需要
这是什么(大白话):**单例(singleton)**就是"整个 App 里只造一个、大家共用同一个"的对象。
Kotlin 用一个关键字 object 就能声明它。
为什么需要单例? 拿本工程的 ThinkStripper(负责剥掉模型输出的 <think>...</think> 思考块)举例:
“削皮"这件事不需要每聊一句就买一个削皮器——整个 App 共用一个就够了。
如果每次剥一段文字都"新造一个削皮器”,会白白浪费内存;
更糟的是,多个削皮器各自记着自己的中间状态,可能剥得前后不一致。
"只造一个、全局共用"就同时解决了这两个问题。
最小示例:
📝 object ThinkStripper {
fun strip(text: String): String { ... } // 里面写一串操作
}
// 使用:直接用名字调用,不需要"new 一个新的"
ThinkStripper.strip(rawText)
工程真身:3.8.2 是工程里真实的 ThinkStripper(59 行,本项目唯一一个 object)。
object 声明的类在整个进程里只有一个实例,由 Kotlin 保证线程安全地创建。
3.8.2 工程里的真身
在看工程代码之前,先用三句话认识一下 正则表达式(regex):
- 它是什么:一种"按规则批量找文字"的迷你语言。手动判断"第几个字符是不是
<“,得一个字符一个字符地看;正则用一行符号就能描述"找一段以<think>开头、</think>结尾的文字”。 - 为什么需要:逐字符手写判断太啰嗦,还容易漏掉边角(比如标签中间换了一行怎么办)。正则把"按模式找文字"这件事交给专门的引擎做。
- 本工程用它做什么:剥掉模型输出里的
<think>...</think>思考块,只留正文。下面代码里三个Regex(...)分别负责:已闭合的思考块、还没闭合的思考块、漏掉的孤立闭合标签。
正则语法细节本章不展开(你现在也不需要会写),先认脸即可。
✅ 取自本工程、已编译验证 —— util/ThinkStripper.kt:20-42
object ThinkStripper {
private const val OPEN_TAG = "<think>"
private const val CLOSE_TAG = "</think>"
/** 已闭合的思考块;(?s) 让 . 匹配换行,非贪婪避免吞掉后续正文 */
private val COMPLETE_THINK = Regex("""(?s)\s*<think>.*?</think>\s*""")
/** 未闭合的思考块:正在思考中,其后所有内容都还不是正文 */
private val OPEN_THINK = Regex("""(?s)\s*<think>.*""")
/** 防御:模板注入开头标签时流里可能只有孤立的闭合标签 */
private val STRAY_CLOSE = Regex("""\s*</think>\s*""")
/** 剥离思考块;结果只做首尾去空白,尾部可能扣留不完整的标签片段 */
fun strip(text: String): String {
val cleaned = text.replace(COMPLETE_THINK, "")
.replace(OPEN_THINK, "")
.replace(STRAY_CLOSE, "")
.trim()
val partial = trailingPartialTagLength(cleaned)
return if (partial > 0) cleaned.dropLast(partial) else cleaned
}
}
新语法点:
| 写法 | 含义 |
|---|---|
object 名字 { } | 单例对象声明 |
private const val | 编译期常量(只能修饰基本类型/String) |
private val X = Regex(...) | 正则对象,预先编译好复用(不要每次调用都重建,很慢) |
"""...""" | 原始字符串,里面 \ 不用转义,可直接写 \s |
Regex("...") | 构造正则 |
链式 .replace().replace().trim() | 每个都返回新字符串,可链式调用 |
if (cond) a else b | Kotlin 的 if 是表达式,可以直接 return if... |
📌
const valvsval的区别——"编译期"和"运行时"到底差在哪:
- 编译期常量(
const val):打包 App 的那一刻,值就死死定下来了,
会被直接写进安装包。类比:菜谱上印死的"盐 3 克"——印出来以后谁也改不了。
只能是数字、布尔、字符串这种简单类型。- 运行时只读(
val):App 跑起来之后才算出来的值。类比:做菜时尝了一口,
根据咸淡临场加的那勺盐——菜谱上没法预先写死。上面的Regex(...)就要在运行时才构造好,
所以只能是val,不能const val。本工程里
AppSettings的那些默认值用的就是const val(见 3.9)。
这段代码的业务含义也值得记一下:Qwen3 这类推理模型会先输出 <think>思考过程</think>
再给正文。本函数在应用层把思考块剥掉,让 UI 只显示正文、TTS 只朗读正文。
注意它是"流式安全"的——逐 token 到达时也不会把标签碎片漏出来。
3.9 when:比 switch 强得多的分支
3.9.1 基本用法
when 是 Kotlin 的"增强版 switch",它可以作为表达式直接返回值。
为什么需要它? 一个变量"等于不同值就走不同分支"的情况,如果用 if...else if...else
一层层写下去,又长又容易漏分支;when 把"一个值的几种情况"整齐列成一张清单,
还能直接当值赋给变量。
val label = when (themeMode) {
0 -> "跟随系统"
1 -> "浅色"
2 -> "深色"
else -> "未知"
}
注意:分支用 ->,不用 :;而且整个 when 是一个有值的表达式,可以直接赋给 label。
3.9.2 工程里的对应常量
✅ 取自本工程、已编译验证 —— util/AppSettings.kt:56-58
const val THEME_SYSTEM = 0
const val THEME_LIGHT = 1
const val THEME_DARK = 2
有了常量,上面的 when 就能写得更清楚:
val label = when (themeMode) {
AppSettings.THEME_SYSTEM -> "跟随系统"
AppSettings.THEME_LIGHT -> "浅色"
AppSettings.THEME_DARK -> "深色"
else -> "未知"
}
📌 为什么要用常量而不是直接写 0/1/2?
因为when(mode)里写0是"魔法数字",半年后你根本想不起 0 是什么意思。
用THEME_SYSTEM则望文生义。这是本项目的一贯做法。
3.9.3 when 还能怎么玩
// ① 匹配多个值
when (x) { 0, 1 -> "小"; 2 -> "中"; else -> "大" }
// ② 匹配范围
when (score) { in 0..59 -> "不及格"; in 60..100 -> "及格"; else -> "无效" }
// ③ 不带参数的 when(当 if-else 链用)
when {
text.isEmpty() -> "空"
text.length > 100 -> "太长"
else -> "正常"
}
// ④ 匹配类型(配合密封类,强制处理所有情况)
when (state) {
is State.ModelReady -> 显示输入框()
is State.LoadingModel -> 显示进度条()
is State.Error -> 显示错误(state.exception)
}
💡 第 ④ 种是本项目最重要的用法,请把它读懂。
什么是密封类(sealed class)? 它是一种"家谱被编译器看得一清二楚"的类:
你声明了它有哪几个子类,编译器就知道"它的后代只有这几个,不会再冒出新的"。这为什么重要? 既然编译器知道状态"只可能有这几种长相",
那你写when时如果漏写了其中一种(比如加了一种新状态,却忘了在界面上处理它),
编译器就直接报错提醒你"漏了这种情况"——这个能力叫穷尽性检查(exhaustive check)。
反过来,如果是普通类,编译器不知道以后还会冒出什么子类,
就只能让你写一个else兜底;真漏了它也不吭声。一句话:密封类 + 把所有
is分支写全 = 编译器替你盯着"每种情况都处理到了"。
本项目引擎的状态机就是这么写的(ch01 1.5.6 见过),ch09 会深入。
3.10 动手验证:给 ModelInfo 加一个字段
光看不够,亲手编译一次才叫学会。我们来给模型信息加一个 description 字段。
3.10.1 步骤
① 打开 app/src/main/java/com/example/llama/model/ModelInfo.kt
② 加一行(放在最后,带默认值,保证已有代码仍能编译):
data class ModelInfo(
val id: String,
val name: String,
val path: String,
val size: Long = 0,
val isLoaded: Boolean = false,
val architecture: String = "",
val quantization: String = "",
val description: String = "" // ← 新增
)
③ 在 llama.android 目录执行:
gradlew :app:compileDebugKotlin
3.10.2 预期结果
✅ 预期:BUILD SUCCESSFUL。
为什么加了字段却不会破坏已有代码?
因为新字段有默认值(= "")。Kotlin 会在所有没传这个参数的地方自动补上默认值。
这正是"data class + 默认参数"的威力——扩展数据结构不会引发连锁修改。
3.10.3 进阶:故意把它变成必填(强烈建议做一次)
把 = "" 去掉,让它变成必填字段:
val description: String // ← 去掉了默认值
再编译一次,你会看到一串报错,指向所有 ModelInfo(...) 的调用点,类似:
error: no value passed for parameter 'description'
这个实验的价值:你会直观地看到"默认参数如何保护已有调用点"。
以后你设计数据结构时就会明白——给新字段加默认值是向后兼容的关键。
做完记得把 = "" 加回去(或者干脆保留这个字段,当作你自己的第一个改动)。
3.11 常见错误与排查
| 报错信息(你会看到的原文) | 原因 | 怎么改 |
|---|---|---|
val cannot be reassigned | 给 val 重新赋值了 | 改成 var,或者换个新变量 |
only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver | 在可空类型上直接调方法 | 改用 ?. 或 ?: 默认值 |
null can not be a value of a non-null type String | 把 null 赋给非空类型 | 类型后加 ?,或给个真实值 |
no value passed for parameter 'xxx' | 必填参数没传 | 传值,或给它加默认值 |
type mismatch: inferred type is Int but Long was expected | 类型不匹配(如把 Int 当 Long 用) | 显式转换:x.toLong() |
unresolved reference: xxx | 名字不存在(拼错 / 没 import) | 检查拼写和 import |
function invocation 'xxx(...)' expected | 把属性当函数调了(或反之) | 检查是不是多了/少了 () |
📌 读 Kotlin 报错的经验:
Kotlin 编译器(尤其是配合 IDE)的报错通常很准,而且会直接给你"quick fix"。
看到报错先读第一行和error:后面那句,再对着上表找。
不要一看到红色就慌——大部分是上面这几种之一。
3.12 动手验证:确认你真的会了
3.12.1 用 formatFileSize 验证你的理解
不用运行 App,纯推算:一个 1,932,178,688 字节的模型会显示成什么?
推算过程1,932,178,688 B
÷1024 = 1,886,893 B → 1,886,893.0 KB
÷1024 = 1,842.67 KB → 1,842.7 MB
÷1024 = 1.799 GB → 1.8 GB
while 会一直除到 value < 1024(这里是 1.799)且没到最大单位,
unit 停在 3(GB),所以输出 1.8 GB。
3.12.2 验证编译链路通了
执行 3.10 的 gradlew :app:compileDebugKotlin,确认 BUILD SUCCESSFUL。
这一步同时验证了:你的 Kotlin 改动能进入编译流程(ch02 的环境是好的)。
3.13 小结
| 概念 | 一句话 | 工程示例 |
|---|---|---|
val / var | 只读 / 读写,优先 val | ModelInfo 全字段 val;循环计数器才用 var |
空安全 ? | 把"会不会 null"写进类型 | it[TEMP_KEY] 是 Float? |
?. 安全调用 | null 时整个表达式返回 null,不崩 | — |
?: Elvis | 左边为 null 时用右边兜底 | ?: DEFAULT_TEMPERATURE(0.3) |
!! 非空断言 | 发誓非空,错了就崩——本项目几乎不用 | — |
data class | 装数据的类,自带 equals/hashCode/toString/copy | ModelInfo/Message/ChatSession |
copy() | 改部分字段、其余不变,产生新对象 | session.copy(messages = ...) |
| 不可变更新 | 新引用才能触发 Compose 刷新 | ch06、ch09 |
| 扩展属性 | 给没源码的类加成员 | Context.dataStore(AppSettings.kt:21) |
by 委托 | 获取/存储逻辑交给别人 | by preferencesDataStore、by viewModels() |
object | 单例 | ThinkStripper |
const val | 编译期常量(基本类型/String) | THEME_SYSTEM = 0 |
when | 强分支,可作表达式,配合密封类免 else | 主题判断、状态机 |
原始字符串 """ | \ 不用转义 | Regex("""(?s)\s*<think>.*""") |
你现在已经能读懂本项目约 80% 的 Kotlin。剩下 20%(协程 suspend、Flow、Composable)
会在 ch06 之后专门讲——它们不是"语法",而是"怎么组织异步与 UI",是本项目真正的难点。
3.14 本章你学会了什么
逐条自测。任何一条打不了勾,回到对应小节再看一遍。
- 我能说清
val和var的区别,以及为什么默认用val。 - 我理解
val只是"变量不能重新指向",不代表对象内容不变。 - 我能读懂
String?里的问号是什么意思。 - 我会用
?.和?:,并知道!!为什么危险。 - 我能看懂
it[TEMP_KEY] ?: DEFAULT_TEMPERATURE这一行在干什么。 - 我知道
data class自动帮我生成了哪些东西。 - 我会用
copy()做不可变更新,并说清它对 UI 刷新的意义。 - 我理解为什么
List不能add,以及+对 List 的含义。 - 我能看懂
Context.dataStore是扩展属性、by是委托。 - 我能读懂
formatFileSize的每一行(包括units.lastIndex和"%.1f %s".format)。 - 我会用
when写分支,知道它能当表达式用。 - 我知道密封类 +
when能免除else并获得"穷尽性检查"。 - 我理解
object就是单例,以及const val和val的区别。 - 我亲手给
ModelInfo加过字段并编译通过,还试过"去掉默认值"看报错。
3.15 练习
练习 1:完成加字段验证(含进阶)
按 3.10 做一遍:加 description 字段 → 编译通过 → 去掉默认值 → 看报错 → 加回默认值。
重点是理解"默认参数 = 向后兼容"。注意编译命令用 compileDebugKotlin 而不是
assembleRelease——只编 Kotlin 更快(几十秒 vs 两分钟),够用来验证语法。
- 带默认值
= "":BUILD SUCCESSFUL,所有已有ModelInfo(...)调用点不受影响。 - 去掉默认值:所有
ModelInfo(...)调用点报no value passed for parameter 'description'。 - 这说明:新增字段时给默认值,是让数据结构可演进的关键手段。
- 实际工程中,你还会需要在 UI 或存储逻辑里真正用上这个字段(比如显示模型描述),
那属于 ch07/ch11 的内容。
练习 2:给 Message 加时间戳
在 Message.kt 里给 Message 加一个 val timestamp: Long = 0,编译通过。
然后思考:为什么这个值要带默认值?
想想 Message 在多少个地方被创建。如果改成必填,你要改多少处?
data class Message(
val id: String,
val content: String,
val isUser: Boolean,
val timestamp: Long = 0 // ← 新增,带默认值
)
- 带默认值 → 已有创建点全部不受影响,直接编译通过。
- 如果改必填 → 所有
Message(...)调用点都要补参数。 - 默认值 0 的含义:“未知/未设置”。读取时可用
if (timestamp > 0)判断有没有值。
这也是本项目处理"可选字段"的常见手法(另一个手法是用可空类型Long?)。
练习 3:改写一个循环
把 formatFileSize 改写成不用 while 的版本(用对数算出该除几次 1024)。
需要"1024 的几次方 ≈ bytes",就是求 log(bytes) / log(1024) 再向下取整。
别忘了用 coerceIn 把结果夹在合法范围内(防止数组越界)。
fun formatFileSize(bytes: Long): String {
if (bytes <= 0) return "未知大小"
val units = arrayOf("B", "KB", "MB", "GB", "TB")
val unit = (Math.log(bytes.toDouble()) / Math.log(1024.0))
.toInt().coerceIn(0, units.lastIndex)
val value = bytes.toDouble() / Math.pow(1024.0, unit.toDouble())
return "%.1f %s".format(value, units[unit])
}
coerceIn(0, units.lastIndex)把结果夹在[0, 4],等价于原while的"到顶就停"。coerceIn是 Kotlin 标准库函数,比手写maxOf(minOf(x, max), min)清晰得多。- 思考:两个版本哪个更好?原版
while更直白、没有浮点误差;
对数版更短但引入了浮点运算。本项目保留while版——可读性优先。
这也提醒你:"更短的写法"不一定更好。
练习 4:改写遍历方式
把 ThinkStripper 里的
for (tag in listOf(OPEN_TAG, CLOSE_TAG)) { ... }
改成 listOf(OPEN_TAG, CLOSE_TAG).forEach { tag -> ... },编译验证两者等价。
forEach 接受一个 Lambda。注意:在 for 循环里可以用 break/continue,
但在 forEach 的 Lambda 里不能用 break(要用 return@forEach 实现 continue 的效果)。
本题的循环体里没有 break,所以直接改是安全的。
listOf(OPEN_TAG, CLOSE_TAG).forEach { tag ->
val maxLen = minOf(tag.length - 1, text.length)
for (len in 1..maxLen) {
if (text.endsWith(tag.substring(0, len))) best = maxOf(best, len)
}
}
- 两者语义等价,都能编译通过。
- 差别:
for更灵活(可break/continue);forEach更"函数式"、可与其他高阶函数链式组合。 - 本项目保留
for版——因为这里逻辑简单,for的可读性更好。 - 延伸:Kotlin 的惯例是"简单遍历用
for,需要链式组合(map/filter 之后)用forEach"。
练习 5:读懂一段你没见过的代码
打开 util/AppSettings.kt,读 43-53 行的几个 setter:
✅ 取自本工程 —— util/AppSettings.kt:47
suspend fun setTemperature(v: Float) = context.dataStore.edit { it[TEMP_KEY] = v }
回答:
suspend是什么意思?(提示:ch08 会细讲,现在只需知道"它只能在协程里被调用")= 表达式这种写法叫什么?为什么可以不写{ return ... }?edit { }的大括号里为什么能直接改it[TEMP_KEY]?
第 2 问:Kotlin 的"单表达式函数"(expression body)。
第 3 问:这是"带接收者的 Lambda"——大括号里的 it 其实可以省略不写(直接 TEMP_KEY 不行,
但 preferences[TEMP_KEY] = v 这种要看具体签名)。回忆 3.7.3 讲的 it。
suspend标记这是一个"可挂起函数"——它执行到一半可以暂停、让出线程,
等耗时操作完成再继续。所以它只能在协程(或其他 suspend 函数)里调用。
这是 Kotlin 协程的核心机制,ch08 会用整整一章讲它。= 表达式叫单表达式函数(expression body)。当函数体只有一个表达式时,
可以省略{ }和return,用=连接。返回值由表达式类型推断。
这是 Kotlin 让代码变短的常见手法(本项目大量使用)。edit { }的大括号是一个 Lambda,it是它的隐式参数(这里是MutablePreferences)。
这个 Lambda 由 DataStore 库调用,库在合适的时机把可修改的 Preferences 对象传进来,
你在里面改完,库负责写回磁盘。这就是"把一段代码交给库去执行"的模式,
和 3.6 讲的委托、3.7 讲的高阶函数是同一个思想的不同表现。
(edit的事务语义见 ch11。)
3.16 自测题(附答案)
这一章的自测重点不是"记语法",而是"理解为什么"。
所以下面的简答题比判断题更重要。
一、判断对错
val声明的对象,它的内部内容也不能改。- 本项目里几乎不用
!!。 session.messages.add(newMessage)可以给不可变列表加元素。- 原地改列表内容(不产生新对象),Compose 也能察觉到并刷新界面。
二、选择
it[TEMP_KEY] ?: DEFAULT_TEMPERATURE里?:的作用是?
A. 类型转换 B. 左边为 null 时用右边的值 C. 强制非空 D. 抛出异常Context.dataStore用的是什么语法?
A. 继承 B. 扩展属性 C. 接口实现 D. 泛型data class会自动生成下面哪个?
A.copy()B.main()C.run()D.map()
三、简答
- 为什么本项目"能用
val就用val"? - 给
data class加字段时为什么要带默认值? - 为什么
copy()会触发 Compose 刷新,而"原地改字段"不会?
一、判断
- ❌ 错。
val只表示"变量不能重新指向别的对象",
不代表对象内部内容不可变。
类比:遥控器被粘在茶几上(不能换),但按钮还能按(3.3.1)。 - ✅ 对。因为
!!一旦判断失误就 NPE 崩溃,
本项目用?./?:代替(3.4.5)。 - ❌ 错。Kotlin 的
List是只读的,没有add。
要加元素得用session.messages + newMessage(返回新列表),
或者用MutableList(3.5.4)。 - ❌ 错。Compose 靠"引用是否变了"判断要不要重画。
原地改列表 → 还是同一个对象 → Compose 看不出变化 → 界面不刷新(3.5.5)。
二、选择
- B。这就是 Elvis 运算符:左边为 null 时用右边兜底(3.4.3)。
- B(扩展属性)。它给 Android 的
Context加了一个成员(3.6.2)。 - A(
copy())。data class还自动生成equals/hashCode/toString(3.5.1)。
三、简答(要点)
- 因为不可变对象天然没有读写竞争问题。
本项目大量使用协程(ch08),如果对象是可变的,就得处处加锁;
用val+copy直接消除这类问题(3.3.3)。 - 因为保证向后兼容:已有代码没传这个参数,
有默认值就不会报错。
去掉默认值会让所有ModelInfo(...)调用点同时报错(3.10.3)。 copy()会产生一个新的对象引用,Compose 发现"引用变了"就重画;
原地改字段/列表,引用没变,Compose 认为"没变化",于是不刷新(3.5.5)。
评分建议:第 1、4、10 题最常错,而且都是"不可变"这个主题。
如果这三题答错,把 3.3 和 3.5 重读一遍。
下一章:ch04 Android 应用骨架。
你现在能读懂 Kotlin 的"词"了,但还看不懂"句子"——
比如MainActivity为什么叫 Activity、onCreate是谁在什么时候调用的、
为什么旋转屏幕聊天记录不丢。下一章就讲这些 Android 的"骨架"。
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/m0_64703412/article/details/165633153




