欢迎光临
我们一直在努力

ch03 Kotlin 速成(只学这个项目用到的)

学习目标
读完本章,你应当能够:

  • 说清 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 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):

    步骤执行到哪此时 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 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 }

    回答:

  • 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 的"骨架"。

    赞(0)
    未经允许不得转载:171主机测评 » ch03 Kotlin 速成(只学这个项目用到的)
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址