学习目标
读完本章,你应当能够:
前置: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 class
- app/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 —— 我保证它永远不是 null
- String? —— 它可能是 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):
| ① | 函数开始: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 val vs val 的区别——"编译期"和"运行时"到底差在哪:
- 编译期常量(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 }
回答:
解题思路提示
第 2 问:Kotlin 的"单表达式函数"(expression body)。
第 3 问:这是"带接收者的 Lambda"——大括号里的 it 其实可以省略不写(直接 TEMP_KEY 不行,
但 preferences[TEMP_KEY] = v 这种要看具体签名)。回忆 3.7.3 讲的 it。
参考答案要点
等耗时操作完成再继续。所以它只能在协程(或其他 suspend 函数)里调用。
这是 Kotlin 协程的核心机制,ch08 会用整整一章讲它。
可以省略 { } 和 return,用 = 连接。返回值由表达式类型推断。
这是 Kotlin 让代码变短的常见手法(本项目大量使用)。
这个 Lambda 由 DataStore 库调用,库在合适的时机把可修改的 Preferences 对象传进来,
你在里面改完,库负责写回磁盘。这就是"把一段代码交给库去执行"的模式,
和 3.6 讲的委托、3.7 讲的高阶函数是同一个思想的不同表现。
(edit 的事务语义见 ch11。)
3.16 自测题(附答案)
这一章的自测重点不是"记语法",而是"理解为什么"。
所以下面的简答题比判断题更重要。
一、判断对错
二、选择
A. 类型转换 B. 左边为 null 时用右边的值 C. 强制非空 D. 抛出异常
A. 继承 B. 扩展属性 C. 接口实现 D. 泛型
A. copy() B. main() C. run() D. map()
三、简答
答案与解析
一、判断
不代表对象内部内容不可变。
类比:遥控器被粘在茶几上(不能换),但按钮还能按(3.3.1)。
本项目用 ?. / ?: 代替(3.4.5)。
要加元素得用 session.messages + newMessage(返回新列表),
或者用 MutableList(3.5.4)。
原地改列表 → 还是同一个对象 → Compose 看不出变化 → 界面不刷新(3.5.5)。
二、选择
三、简答(要点)
本项目大量使用协程(ch08),如果对象是可变的,就得处处加锁;
用 val + copy 直接消除这类问题(3.3.3)。
有默认值就不会报错。
去掉默认值会让所有 ModelInfo(…) 调用点同时报错(3.10.3)。
原地改字段/列表,引用没变,Compose 认为"没变化",于是不刷新(3.5.5)。
评分建议:第 1、4、10 题最常错,而且都是"不可变"这个主题。
如果这三题答错,把 3.3 和 3.5 重读一遍。
下一章:ch04 Android 应用骨架。
你现在能读懂 Kotlin 的"词"了,但还看不懂"句子"——
比如 MainActivity 为什么叫 Activity、onCreate 是谁在什么时候调用的、
为什么旋转屏幕聊天记录不丢。下一章就讲这些 Android 的"骨架"。
网硕互联帮助中心



评论前必须登录!
注册