ch11 持久化:DataStore + Gson 多会话

学习目标

读完本章,你应当能够:

  1. 说清"为什么不用 SharedPreferences 存大 JSON"(三个具体原因);
  2. 理解 DataStore 的两个核心操作:data 流读edit { } 事务写
  3. 能复述本章的真实事故:读-改-写必须放在同一个 edit { },否则并发下会丢更新;
  4. 知道慢 IO 要放在事务外removeModel 删 GB 级文件为什么不写在事务里);
  5. 给 JSON 反序列化做兜底,让一条脏数据不会拖垮整个列表
  6. 看懂多会话的"排序键(updatedAt)"与"切换(current_session_id)"是怎么落地的;
  7. 理解 saveAllNow 这种阻塞版保存 存在的理由(配合 ch09 的 onCleared 收尾);
  8. 知道导出对话为什么不能直接用 EXTRA_TEXT(Binder 事务上限)。

前置 :ch09(状态容器)、ch10(收尾)

对应源码(本章会逐个打开):

  • app/src/main/java/com/example/llama/util/ChatSessionStore.kt(73 行)------ 会话持久化

  • app/src/main/java/com/example/llama/util/ModelManager.kt(127 行)------ 模型记录(含事故修复)

  • app/src/main/java/com/example/llama/model/ChatSession.kt(22 行)------ 会话数据单元

  • app/src/main/java/com/example/llama/util/ChatExporter.kt ------ 导出与分享
    🧭 读本章前,请先确认

  • 前置章节(缺一不可,缺了会卡在哪)

    • ch09(状态容器) :本章的 ChatSessionStore 是被 MainScreenState 调用的,
      不知道"工作副本 messages 什么时候折回 sessions、什么时候落盘",
      就读不懂 11.7 为什么需要"阻塞版保存"。
    • ch10(收尾) :本章 11.4.3 的"清理失败只记日志"模式和 ch10 一模一样,
      不了解会重复踩坑。
  • 需要的基础(具体到概念级别)

    • 知道"内存(断电就没) "和"磁盘(关机还在) "的区别(附录 A.6.2
      本章全章都在回答"怎么把内存里的数据写到磁盘");
    • 知道"键值对"是什么(附录 A.1.5,像字典:一个 key 查一个值);
    • 知道"JSON "大致长什么样(读 附录 A.5,本章会话列表就是 JSON 文本);
    • 听说过"序列化/反序列化 "这个词即可(附录 A.5.2:对象 ↔ 文本的互转),
      本章 11.2 第一次用时还会再解释。
      (以上基础够用了;本章术语第一次出现时都会解释,不必提前背。)
      (生词列表见下一行。)
  • 本章会出现的生词 :DataStore(Google 推荐的键值存储)、事务(transaction)
    原子性(atomicity)edit { } 事务、序列化(serialization)/ 反序列化(deserialization)
    Gson、TypeToken(类型令牌)、丢失更新(lost update) 、写入锁、
    .first()runBlocking、FileProvider、content:// URI、Binder。
    Android 存储查 附录 B.3 ,协程/Flow 查 附录 B.4
    以上生词不必提前背,读到哪个回来查即可。

  • 读法建议 :本章的核心可以压成一句话 ------
    "凡是'先读现有值、再基于它算出新值'的写操作,读和写必须在同一个事务里 "。
    抓住这句话,11.3 那个并发丢更新的事故就自然理解了。
    本章导读

前面你做的一切都在内存 里。MainScreenState 持有聊天记录、模型列表、设置......

但 App 一关,内存就清空了。

要让用户下次打开还在,就得写盘。这件事听起来简单,实则坑很多:

你可能这么想 实际情况
"存下来不就行了" 什么格式?整个列表一个 JSON 还是每条一个键?
"读了改了再写回去" 并发时会丢更新(本章最真实的事故)
"解析失败应该不会发生" 一条脏数据会让整个列表读不出来
"写盘很快" 主线程写盘会卡顿;而有些场景你必须同步写(退出时)

💡 类比:文件柜

  • DataStore = 一个带锁的文件柜,同一时刻只允许一个人开抽屉写
  • edit { } 事务 = "开抽屉 → 取出文件 → 改 → 放回去 → 关抽屉"是一气呵成 的,
    中途不会有别人插进来。
  • 读-改-写在事务外 = 你取出文件回家改,改完回去放------
    期间同事可能已经改过同一份,你这一放就把他的改动覆盖了
  • Gson = 把对象"拍扁"成字符串存档、再从字符串"复原"成对象的工具。
  • Binder 上限 = 你要把文件递给隔壁办公室,但传话窗口只有 1MB 宽 ------
    厚文件塞不过去,只能给个"取件单"(content:// URI)。

本章的核心:持久化不只是"存",更是"在并发和异常下仍然正确"。


11.1 为什么不是 SharedPreferences

🧭 从零开场白:本章到底在解决什么问题?

你在 App 里聊了半天、设置了一堆偏好、还导入了一个模型------

现在把手机重启一下 ,打开 App:聊天记录没了、设置还原了、导入的模型也找不到了。

为什么?因为刚才那些东西全都只活在内存里

内存这东西,一断电、一杀进程就被清空,它的本职就是"临时放一放",不是"长期保存"。

持久化(persistence)就是回答一句话:"怎么把内存里的数据写到磁盘上,下次打开 App 还能读回来?"

💡 白板与笔记本的类比

  • 内存 = 会议室里那块白板。你在上面写代码、画表格、记会议要点,写得很爽;
    每天下班保洁阿姨都会把它擦干净------第二天来,白板空空如也。
  • 磁盘 = 你自己桌上的笔记本。白板上写的东西,下班前抄到笔记本上 锁进抽屉,
    第二天来翻开笔记本,昨天写的东西还在。
  • 本章干的事 = 写一个"抄黑板 → 抄笔记本"的抄写员(DataStore),
    并且保证:① 抄的时候别人不能同时改黑板(并发);② 抄到一半笔断了,
    不能留下半页烂纸(原子写入);③ 笔记本上某一页被水泡烂了,
    你也不能因此整本本子都扔掉(兜底)。

建立这个直觉之后,我们再看 Android 给了哪两种"笔记本"。

11.1.1 这是什么:SharedPreferences 和 DataStore 两本"笔记本"

Android 早期的持久化首选是 SharedPreferences(后面简称 SP )。

它就是一个 XML 文本文件,存一堆键值对 (key → value):

apply() 异步写、commit() 同步写,用起来像一本小记事本。

DataStore 是 Google 后来推出的新一代存储,建立在协程和 Flow 之上,

支持异步、类型安全、edit { } 事务,专为"既要存得稳、又要跟 UI 联动"的场景设计。

💡 小抽屉 vs 文件柜

  • SharedPreferences = 办公桌最上面那个小抽屉 ,放几张便签、一两个回形针正合适;
    它设计上就是给"三五个开关、几个设置项"用的。
  • DataStore = 旁边那个带锁的文件柜 ,一叠档案、一本台账都放得下,
    而且同一时刻只准一个人开箱取件。
  • 不会把 100 页档案硬塞进小抽屉 ------塞不下、找不着、还容易把抽屉卡死。
    本章工程要存的就是"一叠档案"(整张会话列表),所以选文件柜(DataStore)。

11.1.2 为什么不用 SP 存大 JSON

听起来 SP 也够用?但本项目要存的是整张会话列表的 JSON (可能几百 KB 到几 MB),

SP 有三个不适合的地方:

问题 说明
① 主线程读写会卡顿 SP 的 getXxx() 是同步的,在首次加载前调用会阻塞主线程。大字符串尤其明显
② 没有事务 SP 的 apply() 只是"异步落盘",没有"读-改-写原子性"------多人同时改同一个 key 会互相覆盖
③ 没有类型安全和流式 API SP 拿到的是原始类型,也没有 Flow 让你"跟着数据变化更新 UI"

11.1.3 最小示例 📝:SP 存大字符串 vs DataStore 存对象

下面这段是对照示意(📝 需自行验证,不是本工程代码),把"把一张列表塞进存储"这件事的两种写法摆在一起看:

kotlin 复制代码
// 📝 写法 A:用 SharedPreferences 存一大段 JSON 字符串(不推荐)
sp.edit().putString("sessions", bigJson).apply()      // ① 主线程 getString 会同步把整个 XML 读进内存
val json = sp.getString("sessions", "[]")            // ② 大 JSON 解析也在调用线程,卡住没人通知你

// 📝 写法 B:用 DataStore 存同一个键(本工程的做法)
suspend fun save(json: String) =
    context.dataStore.edit { it[SESSIONS_KEY] = json }   // ① 异步、不卡主线程
val flow: Flow<String?> = context.dataStore.data       // ② 数据一变自动重新发射,UI 跟着刷新
    .map { it[SESSIONS_KEY] }

两段代码存的其实是同一串字符,差别在"怎么存、怎么读":

维度 写法 A(SP) 写法 B(DataStore)
读的时机 getString 同步返回,首次会阻塞主线程 data 是 Flow,在后台线程给你推
数据变了 你得自己记得重新读一遍 Flow 自动重新发射,collect 里的代码自动跑
并发写 两人各 putString 一次,后写的盖掉先写的 edit { } 排队执行,不会互相盖

📌 这个对照先记住"DataStore 异步 + 流式 + 有事务"三件事,edit { } 具体怎么用,11.2 才正式拆。

11.1.4 工程真身 ✅:我们选了 DataStore

DataStore 正是为解决上面这些而生的:

DataStore 特性 带来什么
基于协程 + Flow 天然异步,不阻塞主线程;数据变化可观察
edit { } 事务 块内读改写是原子的,不会和别的写交错
类型安全的键 stringPreferencesKey() / intPreferencesKey()
原子落盘 用临时文件 + 重命名,不会因为写一半崩溃而损坏文件

📌 DataStore 有两个版本

  • Preferences DataStore(本工程用的):键值对,无需预定义 schema。
  • Proto DataStore:用 protobuf 定义结构化数据,类型更强但配置更重。

本工程选 Preferences 版,然后把对象序列化成 JSON 字符串存------

"键值对 + JSON" 是最轻量的结构化存储方案。


11.2 DataStore 基础:三个零件

在拆本工程代码之前,先把 DataStore 的用法压缩成一个最小可跑例子(📝 需自行验证),只看一个计数器怎么读、怎么写:

kotlin 复制代码
// 📝 最小例子:一个叫 "counter" 的键,存一个 Int
private val Context.counterStore by preferencesDataStore(name = "counter_demo")
private val COUNT_KEY = intPreferencesKey("count")

// ① 读:data 是一个 Flow,数据一变就把最新值发出来
fun observeCount(): Flow<Int> =
    context.counterStore.data.map { prefs -> prefs[COUNT_KEY] ?: 0 }

// ② 写:edit { } 是一个事务,花括号里读-改-写一气呵成
suspend fun increment() {
    context.counterStore.edit { prefs ->
        val cur = prefs[COUNT_KEY] ?: 0   // 在事务里读当前值
        prefs[COUNT_KEY] = cur + 1        // 在同一个事务里写回新值
    }
}

// ③ 用法:collect 订阅,界面自动跟着数据刷新
lifecycleScope.launch {
    observeCount().collect { now ->
        Log.d("demo", "现在的计数是 $now")   // 每次 increment() 后这里会自动再跑一次
    }
}

为什么要这么设计? 对照一下你就明白两个零件各自的职责:

零件 大白话 没有它会怎样
data 流(Flow) "数据一变,自动通知你最新版" 你得每隔几百毫秒主动去读一次,既费电又容易读到旧值
edit { } 事务 "花括号里的读和改,中间没人能插队" 两个人同时改 count,后写的把先写的盖掉(11.3 的事故)

🧾 事务的直觉(银行转账) :从 A 账户扣 100 元、给 B 账户加 100 元,这两步必须同时成功 。如果"扣了 A 的钱"做完了、"加到 B 头上"之前系统崩了,那 100 元就凭空消失了------这谁也受不了。事务保证的就是"要么两步都做完,要么一步都不做",不会出现中间态。edit { } 花括号圈起来的就是这样一个柜台窗口,下一位顾客必须排队。

下面三个小节,把本工程里真实的三段代码对着这个最小例子逐个拆开。

11.2.1 ① 声明:扩展属性 + 委托

✅ 取自本工程、已编译验证 ------ ChatSessionStore.kt:19

kotlin 复制代码
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "chat_sessions")

拆开看(ch03 讲过这两个语法):

部分 含义
Context.dataStore 扩展属性 :给 Context 加了一个属性
by preferencesDataStore(name = ...) 属性委托:创建与缓存逻辑交给库
private 只在本文件内可见(封装)

⚠️ 两个关键约束

  1. 必须声明在顶层(文件级),且是 val ------不是类的成员。
    否则同一个 name 可能创建多个实例,导致数据不一致。
  2. name 决定文件名 。本工程有两个 DataStore:
    • "chat_sessions" → 文件 chat_sessions.preferences_pbChatSessionStore.kt:19
    • "model_manager" → 文件 model_manager.preferences_pbModelManager.kt:18

11.2.2 ② 读:data

✅ 取自本工程 ------ ChatSessionStore.kt:31-42

kotlin 复制代码
    fun getAllSessions(): Flow<List<ChatSession>> {
        return context.dataStore.data.map { prefs ->
            val json = prefs[SESSIONS_KEY] ?: "[]"
            runCatching { gson.fromJson<List<ChatSession>>(json, SESSION_LIST_TYPE) ?: emptyList() }
                .onFailure { Log.e(TAG, "Parse sessions failed: ${it.message}", it) }
                .getOrDefault(emptyList())
        }
    }

    fun getCurrentSessionId(): Flow<String?> {
        return context.dataStore.data.map { it[CURRENT_ID_KEY] }
    }

注意返回类型是 Flow<...> 而不是直接的值:

  • dataStore.data 是一个 Flow,每次数据变化都会重新发射
  • .map { }Preferences 转换成我们要的类型;
  • 调用方可以用 .first() 取一次,也可以 collect 持续观察(ch09 讲过两者的取舍)。

prefs[SESSIONS_KEY] ?: "[]" ------ 键不存在时用空数组的 JSON

这样后面解析不会拿到 null。

🧩 顺解释一下这段里的 runCatching { ... }.onFailure { ... }.getOrDefault(...)

这是 Kotlin 对"可能出错的操作"的安全写法,拆开就是三步:

runCatching { 解析 } = "试着解析,解析失败也别让 App 崩 ,把失败结果先装起来";

.onFailure { 打日志 } = "真失败了就记一笔,方便事后排查";

.getOrDefault(emptyList()) = "成功就用解析结果,失败就退一步给个空列表"。

整体效果:JSON 坏了 → 记日志 → 返回空会话列表,而不是把整个 App 带崩(11.5 详讲)。

11.2.3 ③ 写:edit { } 事务

🧩 先补两个词:什么叫"事务(transaction)"和"原子性(atomicity)"?

  • 事务 :把"读 → 改 → 写"打包成一个不可分割的整体 ,要么整包做完,要么整包不做。
    生活类比:银行转账------"从 A 扣钱、往 B 加钱"必须一起成功或一起失败
    不能出现"扣了 A 的钱却没加上 B"。edit { ... } 的花括号圈起来的就是一个事务。
  • 原子性 :在这个整体进行期间,别人插不进来 。就像你在银行柜台办转账,
    柜台只有一个人,下一位顾客必须等你办完。
  • 为什么需要它 :如果读和写分成两步、中间没有"关门",
    两个人同时办就会互相把对方的结果覆盖掉------这就是 11.3 要讲的真实事故。

一句话:edit { } 花括号 = 柜台窗口,里面的事一气呵成,外面的人排队等。

✅ 取自本工程 ------ ChatSessionStore.kt:44-54

kotlin 复制代码
    suspend fun saveAllSessions(list: List<ChatSession>) {
        runCatching {
            context.dataStore.edit { it[SESSIONS_KEY] = gson.toJson(list) }
        }.onFailure { Log.e(TAG, "Save sessions failed: ${it.message}", it) }
    }

    suspend fun setCurrentSessionId(id: String) {
        runCatching {
            context.dataStore.edit { it[CURRENT_ID_KEY] = id }
        }.onFailure { Log.e(TAG, "Save current session id failed: ${it.message}", it) }
    }
要点 说明
suspend fun edit 是挂起函数,必须在协程里调
edit { ... } 的块 参数是 MutablePreferences,你在里面改键值
原子性 结束前,DataStore 保证不会和别的写入交错
整体覆盖 本工程把整个列表序列化后覆盖一个键(会话量级小,没必要做增量)

11.2.4 键对象要提出来当常量

✅ 取自本工程 ------ ModelManager.kt:24-30

kotlin 复制代码
    companion object {
        private const val TAG = "ModelManager"

        /** 键对象是常量,提出来复用,避免每次读写都重建 */
        private val MODELS_KEY = stringPreferencesKey("models")
        private val CURRENT_MODEL_ID_KEY = stringPreferencesKey("current_model_id")
    }

📌 为什么?

stringPreferencesKey("models") 每次调用都会新建一个对象

如果不提出来,每次读写都新建,既浪费又容易出错(比如两个地方拼错了 key 名)。

这是本工程在审查里改掉的一个小问题------注释就写在代码里(:27

同理,ChatSessionStore 把键放在 companion object:69-71),

还把 Gson 的类型令牌 也提出来了(:71):

kotlin 复制代码
        private val SESSION_LIST_TYPE = object : TypeToken<List<ChatSession>>() {}.type

💡 TypeToken 是什么?

Java/Kotlin 的泛型有"类型擦除"------运行时 List<ChatSession> 只剩下 List

Gson 不知道该把元素还原成什么类型。

TypeToken 用一个匿名子类"记住"完整类型信息,Gson 才能正确反序列化。

凡是反序列化泛型集合,都要用 TypeToken


11.3 真实事故:读-改-写必须在同一个事务里

这是本章最重要的内容。

11.3.0 先用一个最简单的数看明白:未读消息数 3 → 4

先别管"模型列表"那么复杂的结构,就看一个整数 :屏幕角标的"未读消息数"。

当前存的是 3,你想让它变成 4(新来一条消息)。

错误做法(在事务外读、在事务外改、再写回去)

kotlin 复制代码
// 📝 错误示意:读-改-写分三步,中间没有"关门"
suspend fun wrongIncrement() {
    val cur = store.data.first()[UNREAD_KEY] ?: 3   // ① 读出来:3
    val next = cur + 1                              // ② 在协程里加 1:4
    store.edit { it[UNREAD_KEY] = next }            // ③ 写回去:4
}

问题来了 :假设在你 ② 和 ③ 之间,另一个协程也收到了一条消息,它把未读数从 3 改成了 5。时间线长这样:

时刻 协程你(来消息 A) 协程同事(来消息 B) 存储里的未读数
t1 读到 3 --- 3
t2 --- 读到 3 3
t3 算出 4,准备写 算出 4?不,是 5,写入 5
t4 写入 4(基于你 t1 看到的旧值) --- 4

你和同事各收到一条消息,本应该是 3 → 5,结果最后成了 4------同事那条消息"丢"了。

这就叫丢失更新(lost update) :你写回去的那个 4,是基于你很久以前看到的旧值 算出来的,它把同事刚写的 5 整个盖掉了。

正确做法(读-改-写塞进同一个 edit { }

kotlin 复制代码
// 📝 正确示意:读和写在同一个事务里,中间没人能插队
suspend fun rightIncrement() {
    store.edit { prefs ->
        val cur = prefs[UNREAD_KEY] ?: 3   // ① 在事务里读
        prefs[UNREAD_KEY] = cur + 1        // ② 在同一个事务里写
    }                                       // ③ 事务结束前,同事的 edit 只能在门外排队
}

为什么这版就对了?因为 edit { } 是原子的:你 t1 进去读到 3、还没出来之前,同事的 edit { } 根本进不来,他必须等你写完、存里变成 4 之后才轮到他,他读到的就是 4,再加 1 变成 5。两条消息都在。

💡 两个人改同一份文档的同一行

这个事故在生活里天天发生:你和同事同时打开同一份在线文档的同一行,> 你改成"方案 A"、他改成"方案 B",谁后点保存,谁的版本就把对方的整个改掉 ------> 因为你们俩打开时看到的都是旧版本,谁也不知道对方改过。

edit { } 就是给这行加了一把锁:同一时刻只准一个人编辑,另一个人必须等

等的人拿到的是"锁刚放出来的最新版本",自然不会盖掉别人的活。

把"未读消息数"换成"模型列表",就是本工程 11.3.1 要讲的真实事故------只不过列表是一串 JSON 字符串,盖住的不是一个数字,而是一整个模型记录

11.3.1 代码里的注释直接说了

✅ 取自本工程、已编译验证 ------ ModelManager.kt:32-37

kotlin 复制代码
    /**
     * 新增或替换模型记录。
     *
     * 读-改-写必须放在**同一个 DataStore 事务**里:`edit {}` 的 block 是原子的,
     * 而原先"先 `getAllModels().first()` 读、再 `edit` 写"会在并发时互相覆盖(丢更新)。
     */

11.3.2 事故版长什么样

kotlin 复制代码
// ❌ 事故版(示意,非当前代码)
suspend fun addModel(modelInfo: ModelInfo) {
    val models = getAllModels().first().toMutableList()   // ① 在事务外读
    models.add(modelInfo)
    context.dataStore.edit {                               // ② 单独写
        it[MODELS_KEY] = gson.toJson(models)
    }
}

看起来没问题?看看两个协程并发时会发生什么:

时刻 协程 A(导入模型 X) 协程 B(导入模型 Y)
t1 读到 [M1, M2] ---
t2 --- 读到 [M1, M2]
t3 算出 [M1, M2, X],写入 ---
t4 --- 算出 [M1, M2, Y]写入
结果 X 被覆盖了,凭空消失

这就是经典的 lost update(丢失更新)

11.3.2.1 这个事故在本工程里长什么样:孤儿文件

把上面那张表套到本工程的"导入模型"上,事故就变得很具体:

用户快速连续导入两个模型文件 (B 和 C,假设列表里已有 A)。

导入流程是两步:① 先把 .gguf 文件复制到 App 私有目录 ;② 再往 DataStore 的模型列表里追加一条记录

时刻 协程 1(导入模型 B) 协程 2(导入模型 C) 存储中的模型列表 磁盘上的文件
t0 --- --- [A] A 在
t1 把 B 文件复制到磁盘(耗时几秒) --- [A] A、B 都在
t2 读列表 [A] 把 C 文件复制到磁盘 [A] A、B、C 都在
t3 --- 读列表 [A](旧快照) [A] A、B、C 都在
t4 追加 B → 写回 [A, B] --- [A, B] A、B、C 都在
t5 --- 追加 C → 写回 [A, C] [A, C] A、B、C 都在

结果 :列表里只剩 A 和 C,B 从界面上"消失"了 ------

但 B 的 .gguf 文件已经安安静静躺在磁盘上占着几个 GB ,UI 上又没有它的记录,用户从哪里都删不掉它

这就是孤儿文件(orphan file):文件在磁盘上,但存储里没有任何条目指向它。

为什么会这样?和"未读消息数"那个例子一模一样

协程 2 在 t3 读到的列表是旧的 [A],它基于这个旧值算出 [A, C] 整个写回去,把协程 1 刚写的 [A, B] 整个覆盖了。

📌 修复思路有两种,本工程选了第二种

  1. 用一把 Mutex(互斥锁)把所有写操作串起来:> 导入 B 的全程(复制文件 + 写记录)都持锁,导入 C 必须等。> 简单粗暴,但锁的临界区被拉得很长(复制 GB 文件也在锁里),会卡。
  2. edit { } 事务的原子性 (本工程做法):
    复制文件在事务外做(不持锁、不挡别人),> 只把"读列表 → 追加 → 写回"这一小段塞进 edit { }。> 这样协程 1 的事务没结束,协程 2 根本读不到旧的 [A],自然不会覆盖。

第二种把"锁"的范围缩到最小------只锁必须原子的那几行内存操作,> 这正是 11.4 还要再讲一遍的原则。

11.3.3 修复版

✅ 取自本工程、已编译验证 ------ ModelManager.kt:38-55

kotlin 复制代码
    suspend fun addModel(modelInfo: ModelInfo) {
        try {
            context.dataStore.edit { preferences ->
                val models = parseModels(preferences[MODELS_KEY]).toMutableList()   // ① 在事务内读
                // 检查是否已经存在相同路径的模型
                val existingIndex = models.indexOfFirst { it.path == modelInfo.path }
                if (existingIndex != -1) {
                    models[existingIndex] = modelInfo
                } else {
                    models.add(modelInfo)
                }
                preferences[MODELS_KEY] = gson.toJson(models)                        // ② 同一个块内写
            }
            Log.d(TAG, "Model added: ${modelInfo.name}")
        } catch (e: Exception) {
            Log.e(TAG, "Error adding model: ${e.message}", e)
        }
    }

关键改动就一处 :把"读"从事务外搬到了 edit { }

因为 edit { } 是原子的,A 和 B 只能排队执行:

时刻 协程 A 协程 B
t1 进入事务,读到 [M1, M2] 等待
t2 写入 [M1, M2, X],退出事务 ---
t3 --- 进入事务,读到 [M1, M2, X]
t4 --- 写入 [M1, M2, X, Y]
结果 X 和 Y 都在

📌 一句话规则(请背下来)

凡是"先读现有值 → 基于它算出新值 → 写回"的操作,读和写必须塞进同一个 edit { }

本工程的 addModel(:38)、removeModel(:57)都遵守这条。

只有"无条件覆盖整个值"的情况(如 saveAllSessions)才可以只读不写地直接覆盖。

11.3.4 什么时候其实无所谓

注意 ChatSessionStore.saveAllSessions(:44)没有读,直接覆盖:

kotlin 复制代码
    suspend fun saveAllSessions(list: List<ChatSession>) {
        runCatching {
            context.dataStore.edit { it[SESSIONS_KEY] = gson.toJson(list) }
        }.onFailure { ... }
    }

这没问题------因为调用方(MainScreenState.persistAll)传进来的是完整的会话列表快照

不是"基于旧值算出的增量"。整体覆盖本来就是幂等的。

💡 判断标准

  • 写入的值依赖当前存储的值 → 必须事务内读改写;
  • 写入的值不依赖(整体覆盖)→ 直接写即可。

11.4 慢 IO 要放在事务外

11.4.1 removeModel 的设计

✅ 取自本工程、已编译验证 ------ ModelManager.kt:57-74

kotlin 复制代码
    suspend fun removeModel(modelId: String) {
        // 文件路径先在事务外读出来:删除 GB 级文件是慢 IO,塞进事务会白白拖长写入锁
        val target = getModelById(modelId)
        try {
            context.dataStore.edit { preferences ->
                val models = parseModels(preferences[MODELS_KEY]).filterNot { it.id == modelId }
                preferences[MODELS_KEY] = gson.toJson(models)
            }

            // 同步删除内部存储中的模型文件:只删记录会让 GB 级 .gguf 永久残留且无法从 UI 清理。
            // 调用方(MainScreenState.deleteModel)已保证先卸载引擎。
            target?.let { deleteModelFile(it) }

            Log.d(TAG, "Model removed: $modelId")
        } catch (e: Exception) {
            Log.e(TAG, "Error removing model: ${e.message}", e)
        }
    }

11.4.2 三个设计点

说明
① 路径在事务外读:59 getModelByIdedit 之前调用
② 事务里只改记录:61-64 快,锁持有时间短
③ 删文件在事务外:68 慢 IO 绝不塞进事务

⚠️ 为什么删文件不能在事务里?

事务持有写入锁 。删除一个 2GB 的文件可能要几秒------

这几秒里其它所有写入都被挡住 (比如正在流式落盘的会话),

轻则卡顿,重则让用户觉得"删除时整个 App 都卡住了"。

原则:事务里只做"读 + 改内存态",把重活(删文件、网络)挪出去。

这和 ch09 讲的 persistAll 先做快照、ch10 讲的清理不外抛,是同一类思路。

11.4.3 删文件失败只记日志

✅ 取自本工程 ------ ModelManager.kt:76-86

kotlin 复制代码
    /** 删除模型文件,失败只记日志,不影响记录清理 */
    private fun deleteModelFile(modelInfo: ModelInfo) {
        val file = File(modelInfo.path)
        runCatching {
            if (file.exists() && !file.delete()) {
                Log.w(TAG, "Failed to delete model file: ${modelInfo.path}")
            }
        }.onFailure {
            Log.w(TAG, "Error deleting model file: ${modelInfo.path}", it)
        }
    }

和 ch10 的 deletePartialFile 完全同一个模式 ------

清理逻辑用 runCatching 包住,失败只记日志,不外抛、不覆盖主流程

📌 注释(:66-67)还点明了另一个坑:

"只删记录会让 GB 级 .gguf 永久残留且无法从 UI 清理 "------

这正是 ch10 那个半成品文件事故的同类问题。

而且这里强调"调用方已保证先卸载引擎"------

删一个正在被 mmap 的模型文件是危险操作,必须先卸载。


11.5 JSON 兜底:别让一条脏数据拖垮整个列表

11.5.1 这是什么:从磁盘读回来的字节,不一定是好的

先回顾一下 11.2 讲过的"对象 ↔ 文本互转":

你把一个 List<ChatSession> 用 Gson **序列化(serialization)**成一行 JSON 字符串,存进 DataStore;

下次打开 App,再把这行 JSON 反序列化(deserialization)变回 List<ChatSession>
这个往返在
理想情况下
天衣无缝。但磁盘上那串字节是活的,它可能在你没注意的时候变烂:

  • App 写到一半闪退/被系统杀死,文件只写了一半(半截 JSON);
  • 用户用别的工具(文件管理器、adb)手工改了那个文件;
  • 你升级了 App,类里多了/少了字段,旧 JSON 对不上新结构;
  • 磁盘本身出了问题(极少,但不是零概率)。

这时候你调 gson.fromJson(json, type),Gson 读到半截字符串、拼不出对象,

它不会"温柔地返回 null",而是直接抛一个异常 (比如 JsonSyntaxException)。

💡 从抽屉里掏出一张被水泡烂的纸条

想象你从笔记本里抽出一页,发现这页被水浸过、字糊成一团看不清。

你不会因为"这页看不清"就把整本笔记本撕了不工作了 ------

你会:① 看一眼、确认这页坏了;② 在心里记一笔"这页坏了";③ 拿一张新纸重新开始 (用默认值)。

反序列化兜底干的就是这件事:坏数据 → 记日志 → 用默认值继续干活,而不是让整个 App 陪葬。

11.5.2 为什么必须兜底:不兜底,用户就永远打不开 App

getAllSessions()App 启动时就要调 的(ch09 里加载会话列表用)。

如果反序列化抛异常、又没人接,异常会一路冒到 UI 层------

用户看到的就是白屏/闪退,连 App 图标都点不开

而这一切的起因,仅仅是那一行 JSON 的某个字符坏了。

更糟的是:这种 bug 你在开发时根本复现不出来 ------

你自己机器上的数据好好的,只有某个用户在某次崩溃后留下了半截文件,他从此就打不开你的 App。

所以兜底不是"锦上添花",是启动路径上的保命措施

11.5.3 最小示例 📝:try-catch 包一圈,失败就给默认值

把"可能炸的反序列化"放进 try { }catch 里返回一个干净的默认值:

kotlin 复制代码
// 📝 最小示意:从磁盘读一个模型列表 JSON,坏了就返回空表
fun parseModelsSafe(json: String?): List<ModelInfo> {
    if (json.isNullOrEmpty()) return emptyList()      // ① 空字符串直接给空表
    return try {
        gson.fromJson(json, ModelInfoListType)        // ② 试着解析
            ?: emptyList()                            // ③ 解析成功但结果是 null,也当空表
    } catch (e: Exception) {
        Log.e("safe", "broken json: ${e.message}")     // ④ 坏了:记一笔日志,方便事后查
        emptyList()                                    // ⑤ 返回默认值,不把异常抛出去
    }
}

五个动作里最重要的是 ④ 和 ⑤

记日志是为了你以后能查"到底什么时候坏的、坏成什么样";返回默认值是为了调用方永远不用处理异常 ,直接拿到一个能用的空列表往下走。

Kotlin 里这种写法还能更顺一点,就是 runCatching { ... }.onFailure { ... }.getOrDefault(...),本工程两种写法都用了(11.5.4 看)。

11.5.4 工程真身 ✅:本工程的两处兜底

模型列表 (✅ ModelManager.kt:91-101):

kotlin 复制代码
    /** 解析 models JSON;坏数据只记日志并返回空表,避免一条脏数据让整个列表读不出来 */
    private fun parseModels(json: String?): List<ModelInfo> {
        if (json.isNullOrEmpty()) return emptyList()
        return try {
            val type = object : TypeToken<List<ModelInfo>>() {}.type
            gson.fromJson(json, type) ?: emptyList()
        } catch (e: Exception) {
            Log.e(TAG, "Error parsing models: ${e.message}", e)
            emptyList()
        }
    }

会话列表 (✅ ChatSessionStore.kt:34-36):

kotlin 复制代码
            runCatching { gson.fromJson<List<ChatSession>>(json, SESSION_LIST_TYPE) ?: emptyList() }
                .onFailure { Log.e(TAG, "Parse sessions failed: ${it.message}", it) }
                .getOrDefault(emptyList())

两种写法等价(try/catchrunCatching 都能达到目的),

核心都是:解析失败 → 记日志 → 返回空列表

11.5.5 为什么"返回空列表"是可接受的

场景 后果 可接受吗
会话 JSON 损坏 用户看不到历史会话,但 App 能正常用 ✅ 可接受(比崩溃好太多)
模型 JSON 损坏 模型列表空,用户重新导入 ✅ 可接受
不做兜底 App 崩溃 / 白屏 ❌ 不可接受

💡 这是"优雅降级"的思想

次要数据坏了,保证主体功能还能用

总比"因为一条脏数据导致整个 App 打不开"强得多。

如果数据特别重要,可以做得更细------比如逐条解析 ,跳过坏的那条、保留好的。

本工程选择整体兜底,因为会话/模型量级都不大,且坏了可以重建。


11.6 多会话:排序键与切换

11.6.1 数据单元

✅ 取自本工程 ------ model/ChatSession.kt:16-22

kotlin 复制代码
data class ChatSession(
    val id: String,
    val title: String,
    val messages: List<Message>,
    val createdAt: Long,
    val updatedAt: Long
)

同文件 KDoc(:3-15)说明了设计意图,其中两处关键:

  • messages不可变快照"写入时整体替换");
  • updatedAt最后更新时间戳,用于会话列表排序

11.6.2 排序:updatedAt

UI 层排序(✅ ChatScreen.kt:107):

kotlin 复制代码
    val sortedSessions = sessions.sortedByDescending { it.updatedAt }

updatedAt 在每次折回快照时刷新(✅ MainScreenState.kt:495):

kotlin 复制代码
            updatedAt = System.currentTimeMillis()

所以:最近有活动的会话自动排到最上面

11.6.3 切换:独立的 current_session_id

本工程把"当前会话 ID"存在单独的键 里(ChatSessionStore.kt:70):

kotlin 复制代码
        private val CURRENT_ID_KEY = stringPreferencesKey("current_session_id")

为什么不塞进会话列表里 (比如给每个会话加个 isCurrent: Boolean)?

方案 问题
每个会话加 isCurrent 切换时要改两条记录(旧的置 false、新的置 true),还可能不一致
独立键 切换时只动一个小键,不动整张列表;且天然只有一个值,不可能不一致

这又是一次"用更简单的数据结构消除不一致可能 "(和 ch07 的 ChatSession?

String? 菜单是同一类思路)。

11.6.4 加载时的容错

✅ 取自本工程 ------ MainScreenState.kt:472-483

kotlin 复制代码
        val target = if (savedId.isNotEmpty() && list.any { it.id == savedId }) {
            savedId
        } else {
            list.firstOrNull()?.id
        }

        if (target != null) {
            currentSessionId = target
            loadMessages(sessions.first { it.id == target })
        } else {
            createDefaultSession()
        }

** savedId 可能指向一个已不存在的会话**(比如数据被清理过)。

所以要先校验 list.any { it.id == savedId }

  • 存在 → 用它;
  • 不存在 → 退而用第一个会话
  • 一个都没有 → 创建一个默认会话

📌 这就是"读取外部数据时永远不要假设它是有效的 "。

存进去的和读出来的可能不一样(用户清过数据、版本升级、文件损坏)。

每一处都要有 fallback。


11.7 阻塞版保存:saveAllNow

✅ 取自本工程 ------ ChatSessionStore.kt:56-65

kotlin 复制代码
    /** 阻塞式整体保存(供 ViewModel.onCleared 主线程收尾,避免丢失最后一次会话) */
    fun saveAllNow(list: List<ChatSession>) {
        runCatching { runBlocking(Dispatchers.IO) { saveAllSessions(list) } }
            .onFailure { Log.e(TAG, "saveAllNow failed: ${it.message}", it) }
    }

    fun saveCurrentIdNow(id: String) {
        runCatching { runBlocking(Dispatchers.IO) { setCurrentSessionId(id) } }
            .onFailure { Log.e(TAG, "saveCurrentIdNow failed: ${it.message}", it) }
    }

为什么需要"阻塞版"

ch09 讲过 onCleared 的困境:

  • onCleared主线程
  • 此时 viewModelScope 正在被取消launch 不可靠(可能立刻被取消,根本没执行);
  • 但我们必须保证最后一次会话写进去

所以:

kotlin 复制代码
runBlocking(Dispatchers.IO) { saveAllSessions(list) }
//          ↑ 切换到 IO 线程执行,但当前线程(主线程)阻塞等待它完成

⚠️ 注意 runBlocking(Dispatchers.IO) 这个写法

runBlocking 会阻塞调用方线程 (主线程),

但通过指定 Dispatchers.IO实际干活的协程跑在 IO 线程 ------

这样既保证了"完成才返回",又没让"真正的磁盘 IO"跑在主线程上。

注释(ch09 :1069-1070)也说明了这是可接受的:

"写入量是单个 JSON,开销可接受"。
📌 这条规则要记住

平时用异步写(suspend + launch),只有在"必须保证完成才能继续"的收尾场景才用阻塞版。

滥用 runBlocking 会导致 ANR。


11.8 导出:为什么不能直接 EXTRA_TEXT

11.8.1 注释里的原因

ChatExporter 里有一段注释说明(✅ ChatExporter.kt:36-41):

长对话会超出 Binder 事务上限(约 1MB),直接 ACTION_SEND + EXTRA_TEXT 会分享失败甚至崩溃

所以一律走"文件 + content:// URI"。

11.8.2 什么是 Binder 上限

Android 的跨进程通信(IPC)用 Binder 机制,

而 Binder 事务的缓冲区有大小限制 (约 1MB,且是整个进程共享的)。

当你 intent.putExtra(Intent.EXTRA_TEXT, 长文本) 时,

文本要通过 Binder 传给目标 App。超过上限会抛 TransactionTooLargeException

kotlin 复制代码
// ❌ 长对话这样分享会崩
val intent = Intent(Intent.ACTION_SEND).apply {
    type = "text/plain"
    putExtra(Intent.EXTRA_TEXT, veryLongConversation)   // 超过 1MB 就炸
}

11.8.3 本工程的解法

写文件 → 用 FileProvider 生成 content:// URI → 分享 URI

✅ 取自本工程 ------ ChatExporter.kt:49-66(简化)

kotlin 复制代码
    fun createShareIntent(session: ChatSession): Intent? = runCatching {
        // 1. 生成 Markdown 内容
        val content = formatExportContent(session.title, session.messages)
        // 2. 写入 cache/exports/ 下的文件
        val file = ...
        // 3. 用 FileProvider 拿到 content:// URI
        val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
        // 4. 构造分享 Intent,只传 URI(很小)
        Intent(Intent.ACTION_SEND).apply {
            type = "text/markdown"
            putExtra(Intent.EXTRA_STREAM, uri)
            addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
        }
    }.onFailure { Log.e(...) }.getOrNull()

FLAG_GRANT_READ_URI_PERMISSION ------ 临时授权接收方读这个文件(ch04 讲过 FileProvider)。

11.8.4 一个纯函数的好处

✅ 取自本工程 ------ ChatExporter.kt:26

kotlin 复制代码
    fun formatExportContent(title: String, messages: List<Message>): String {

注释说它是纯函数、不依赖 Context

所以"复制全部"和"导出文件"能复用同一份格式化逻辑 ------

不会出现"复制出来和导出出来格式不一样"的问题。

💡 可复用的设计原则

把"数据 → 字符串"的转换写成不依赖 Android 框架的纯函数

它就能被 UI、导出、分享、测试随意复用,还容易单测。


11.9 常见错误与排查

现象 / 报错 原因 解决
并发导入后某个模型"消失了" 读-改-写在事务外,丢失更新 把读搬进 edit { }(11.3)
删除模型时 App 明显卡住 在事务里删了 GB 级文件 慢 IO 挪到事务外(11.4)
只删记录,文件还在占空间 没调 deleteModelFile 事务外删文件(11.4.2)
JsonSyntaxException 导致崩溃/白屏 反序列化没兜底 runCatching 返回空表(11.5)
泛型集合反序列化成 LinkedTreeMap 没用 TypeToken object : TypeToken<List<T>>() {}.type
IllegalStateException: There are multiple DataStores active for the same file 同一个 name 创建了多个实例 声明必须是顶层 val,不能是类成员
退出 App 后最后一条消息丢了 收尾只用了异步 launch saveAllNow 阻塞版(11.7)
TransactionTooLargeException Intent 里塞了超过 ~1MB 的文本 走文件 + content:// URI(11.8)
FileUriExposedException file:// URI 传给别的 App 用 FileProvider(ch04)
切换会话后数据错乱 savedId 指向了不存在的会话 加载时校验 + fallback(11.6.4)

11.10 动手验证:看 DataStore 落盘文件

11.10.1 找到文件

DataStore 的文件在 App 的 files/datastore/ 目录下:

bash 复制代码
adb shell run-as work.ai4easy.offlineai ls -la /data/data/work.ai4easy.offlineai/files/datastore/

预期看到两个文件(对应两个 DataStore):

复制代码
chat_sessions.preferences_pb
model_manager.preferences_pb

(可能还有 .tmp 临时文件------那是 DataStore 原子写入的中间产物,属正常。)

11.10.2 验证内容与我们的键一致

把文件拉下来看:

bash 复制代码
adb shell run-as work.ai4easy.offlineai cat /data/data/work.ai4easy.offlineai/files/datastore/chat_sessions.preferences_pb

因为是 protobuf 二进制格式,直接 cat 会看到乱码,

但你应该能辨认出字符串chat_sessionscurrent_session_id

以及你会话的标题和消息内容。

💡 为什么是 .preferences_pb 后缀?

DataStore 用 protobuf 存储(即使你存取的是字符串)。

这也是它比 XML 的 SharedPreferences 更紧凑、更快的原因之一。

11.10.2.1 如果命令跑不通,按顺序排查

  1. adb 不是内部命令 :没配环境变量。直接用 Android Studio 右侧
    Device File Explorer 图形界面,定位到 /data/data/work.ai4easy.offlineai/files/datastore/
    双击就能看/拉文件,效果一样。
  2. run-as 报权限错 / unknown package :确认跑的是调试版 (本书 ch02 构建的就是),
    且设备/模拟器上真的装着这个 App 并打开过一次 (DataStore 文件是 App 第一次写数据后才生成的,
    全新安装没写过数据时目录可能为空,这正常------先发两条消息再来看)。
  3. cat 出来一屏乱码这是正常的,不是失败 。DataStore 内部用 protobuf 二进制存,
    本来就不是给人读的。你要找的是在乱码里辨认出可读的字符串
    比如你的会话标题、某条消息的前几个字。全是纯乱码、一个认得的字都没有,才说明没写到这个文件里。
  4. 只看到一个文件,缺一个 :对应那个 DataStore 还没被写过。
    比如 model_manager.preferences_pb 要你成功导入过一个模型后才会出现。
  5. 看到 .tmp 结尾的临时文件正常 ,那是 DataStore"先写临时文件再改名"原子写入的中间产物,
    写完成功后会自动消失;一直大量存在才是问题。

11.10.3 验证事务修复(进阶)

想验证"读改写在同一事务"确实起作用,可以:

  1. addModeledit {} 内加 Log.d("TX", "read: ${models.size}");
  2. 连续快速导入两个模型;
  3. 观察日志里第二次读到的 size 是否包含第一次写入的结果
    • 修复后:第二次读到的是已包含第一个模型的列表 ✅
    • 事故版:两次都读到同一个旧值 ❌

11.11 小结

概念 一句话 工程示例
为什么不用 SP 主线程卡顿 / 无事务 / 无流式 API 本工程存整张列表的 JSON
声明 DataStore 顶层 val + by preferencesDataStore(name) ChatSessionStore.kt:19ModelManager.kt:18
data 读;每次变化重新发射 getAllSessions()(:31)
edit { } 写;块内读改写原子 addModel(:38)
读改写必须同事务 否则并发丢更新 事故修复(11.3)
慢 IO 在事务外 别拖长写入锁 removeModel 删文件(:68)
JSON 兜底 坏数据返回空表,不崩溃 parseModels(:92)、:34-36
TypeToken 反序列化泛型集合必需 SESSION_LIST_TYPE(:71)
键对象常量化 避免重复创建、避免拼错 companion object(:28-29)
updatedAt 排序 最近活动的会话置顶 ChatScreen.kt:107
独立 current id 键 消除"两个 isCurrent"的不一致 current_session_id(:70)
读取要 fallback 外部数据可能已失效 MainScreenState.kt:472-483
saveAllNow 收尾用阻塞版,保证写完 :57-60
Binder 1MB 上限 长文本不能塞 Intent 走文件 + content://(11.8)
纯函数格式化 导出/复制复用同一逻辑 formatExportContent(:26)

11.12 本章你学会了什么

逐条自测。任何一条打不了勾,回到对应小节再看一遍。

  • 我能说出不用 SharedPreferences 的三个理由。
  • 我知道 DataStore 声明必须放在顶层的原因。
  • 我能区分 data(读)和 edit(写)的用法。
  • 我能复述"读-改-写必须在同一事务"这个事故,并画出并发时序。
  • 我知道什么时候可以"直接覆盖"而不需要事务内读。
  • 我知道为什么删 GB 级文件不能放在事务里。
  • 我理解"清理失败只记日志"的模式(和 ch10 一致)。
  • 我知道 JSON 解析为什么要兜底,以及兜底返回什么。
  • 我知道 TypeToken 是干什么的。
  • 我能解释 updatedAt 和独立 current_session_id 键的设计。
  • 我知道加载外部数据时为什么必须校验 + fallback。
  • 我知道 saveAllNow 为什么存在,以及滥用 runBlocking 的风险。
  • 我能解释 Binder 1MB 上限,以及导出为什么要走文件 URI。

11.13 练习

练习 1:画出丢失更新的并发时序

用表格画出"两个协程并发 addModel(事故版)"的时序,标出哪一个更新被丢了。
解题思路提示

两个协程都读到同一个旧值,各自算出新值,后写的覆盖先写的。

回看 11.3.2 那张表。
参考答案要点

时刻 协程 A(加模型 X) 协程 B(加模型 Y) 存储中的值
t0 --- --- [M1, M2]
t1 读到 [M1, M2] --- [M1, M2]
t2 --- 读到 [M1, M2] [M1, M2]
t3 算出 [M1,M2,X] → 写入 --- [M1, M2, X]
t4 --- 算出 [M1,M2,Y]写入 [M1, M2, Y]
结果 X 丢失
  • 根因 :B 计算新值时用的旧快照[M1,M2])已经过期了,
    但它的写入是基于这个过期快照算出来的完整值,于是把 A 的写入整个覆盖。
  • 修复后 :B 必须等 A 的事务结束,然后在事务内重新读 [M1,M2,X]
    在此基础上加 Y → [M1,M2,X,Y]
  • 这就是数据库里经典的 lost update 问题,在 DataStore、SQL、任何"读改写"场景都会出现。

练习 2:为什么删文件要放在事务外

如果写在事务里会怎样?请从"锁"的角度回答。
解题思路提示

事务持有写入锁。删除一个 2GB 文件要多久?这段时间其它写入能进行吗?
参考答案要点

  • edit { } 持有 DataStore 的写入锁 ,块内期间其它 edit 必须排队。
  • 删除 2GB 文件是磁盘 IO,可能要几百毫秒到几秒
  • 放在事务里 → 这几秒内所有其它写入都被挡住
    • 正在流式落盘的会话(persistAll)会堆积;
    • 用户切换模型、改设置等写操作会卡顿;
    • 极端情况下用户感知为"删除时整个 App 卡住"。
  • 正确做法 (本工程):
    1. 事务外先把路径读出来(:59);
    2. 事务里只改 JSON 记录(快);
    3. 事务外再删文件(:68)。
  • 原则推广事务/锁的临界区要尽可能小 ,只放"必须原子"的操作。
    这个原则在数据库、多线程、分布式事务里都成立。

练习 3:给会话加载加一条"更细"的兜底

当前 parseModels 是"整张列表坏了 → 返回空表"。

如果改为逐条解析、跳过坏的,该怎么实现?各有什么取舍?
解题思路提示

Gson 可以先把 JSON 解析成 JsonArray,再逐个元素尝试 fromJson

想想这样做的代价。
参考答案要点

思路

kotlin 复制代码
// 📝 示例
private fun parseModelsTolerant(json: String?): List<ModelInfo> {
    if (json.isNullOrEmpty()) return emptyList()
    return runCatching {
        val arr = JsonParser.parseString(json).asJsonArray
        arr.mapNotNull { elem ->
            runCatching { gson.fromJson(elem, ModelInfo::class.java) }
                .onFailure { Log.w(TAG, "Skip broken model entry", it) }
                .getOrNull()
        }
    }.getOrDefault(emptyList())
}

对比

整体兜底(本工程) 逐条兜底
实现 简单(一个 try/catch) 复杂(要手动遍历 JsonArray)
坏一条的后果 整张列表丢失 只丢那一条
性能 一次解析 逐条解析,略慢
适用场景 数据量小、可重建 数据重要、不可重建

本工程为什么选整体兜底?

注释(:91)说明目标是"避免一条脏数据让整个列表读不出来"------

重点是不崩溃。模型/会话量级小,坏了用户重新导入或新建即可。

什么时候该用逐条兜底?

如果数据无法重建(比如用户几个月积累的聊天记录),

就值得多写这段代码------保住 99 条好的,比全丢强

给你的启示 :工程决策往往是"投入 vs 收益"的权衡,

本工程在每处都写了注释说明理由------这就是好的代码文档。

练习 4:为什么"当前会话 ID"要单独存一个键

如果改成给每个 ChatSessionisCurrent: Boolean 字段,会有什么问题?
解题思路提示

想想切换会话时要改几条记录?如果两个会话都是 isCurrent = true 会怎样?
参考答案要点

问题一:切换要改两条记录

kotlin 复制代码
// ❌ 假设的方案
sessions = sessions.map {
    it.copy(isCurrent = it.id == newId)     // 要在事务里改整张列表
}

不但要改整张列表(更慢、更容易和其他写冲突),还不是原子的两步变一步 ------

一旦中间失败,就会出现"两个都是 true"或"两个都是 false"。

问题二:可能出现不一致

isCurrent冗余信息 ------它表达的是"N 个里面恰好一个为真"这个约束,

但布尔字段本身无法在类型层面保证 这个约束。

数据一多,就可能出现 0 个或 2 个 true。

独立键的方案

kotlin 复制代码
dataStore.edit { it[CURRENT_ID_KEY] = newId }    // 只改一个小键
  • 只有一个值,结构上不可能不一致
  • 改动量小(不用重写整张列表);
  • 读取也简单。

这就是反复出现的设计原则

用更简单的数据结构,让"非法状态"无法被表示出来。

同类例子(本书前面出现过):

  • ch07:菜单展开用 String?(存 id)而不是 Boolean;
  • ch07:对话框目标用 ChatSession? 而不是 Boolean + 对象
  • ch11:当前会话用独立键而不是每项的 isCurrent

掌握这个思路,你的状态设计水平会有质的提升。

练习 5:实现"导出单个会话"

要求:导出指定 id 的会话为 Markdown 文件并分享。

(提示:复用 formatExportContent,参考 ch11.8。)
解题思路提示

需要:① 取出指定会话;② 格式化;③ 写文件;④ FileProvider 生成 URI;⑤ 分享。

注意 formatExportContent 是纯函数,不依赖 Context。
参考答案要点

实现要点

  1. 取会话chatStore.getAllSessions().first().firstOrNull { it.id == id }
    (用 .first() 取一次即可,不需要持续监听------ch09 讲过)

  2. 格式化formatExportContent(session.title, session.messages)
    ------直接复用,不用重写。这就是纯函数的好处。

  3. 写文件 :写到 cache/exports/ 目录
    (这个目录已经在 file_paths.xml 里声明过了,见 ch04)

  4. 生成 URI

    kotlin 复制代码
    FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
  5. 分享ACTION_SEND + EXTRA_STREAM + FLAG_GRANT_READ_URI_PERMISSION

为什么不需要"整张列表的写入事务"?

因为导出是只读操作 ------你只读了一个会话,没有修改任何持久化数据。

这印证了 11.3.4 的判断标准:只有"写入的值依赖当前值"时才需要事务内读改写

文件名处理buildFileNameChatExporter.kt:82)会净化中文/非法字符并限长 30,

避免生成无法写入的文件名。

延伸 :如果想支持"导出全部会话",

可以把所有会话的 formatExportContent 结果拼接起来,

其余流程完全一样------这就是把核心逻辑抽成纯函数带来的可扩展性


11.14 自测题(附答案)

难度递进说明:第 1--4 题是基础题 (判断对错,记住本章事实即可);

第 5--7 题是应用题 (从选项里分辨"该用哪种写法");

第 8--10 题是综合题 (第 8 题要能亲手画出并发时序表,画不出来就回 11.3.2 再看)。

建议先合上书写一遍,再翻答案对。

一、判断对错

  1. 存大 JSON 用 SharedPreferences 就行,没必要上 DataStore。
  2. "先 getAllModels().first() 读、再 edit 写"是安全的做法。
  3. 删除 GB 级文件应该放进事务里,保证原子性。
  4. JSON 解析失败时,应该让整个列表读不出来(这样能提醒用户数据有问题)。

二、选择

  1. "读-改-写"必须在哪里完成?
    A. 两次独立的 edit B. 同一个 edit { } 块内 C. 事务外 D. 不需要事务
  2. 反序列化泛型集合 (如 List<ChatSession>)必须用什么?
    A. TypeToken B. Array C. Class D. Map
  3. "当前会话 ID"为什么要单独存一个键
    A. 省空间 B. 切会话只动一个小键,且不可能出现不一致 C. 兼容旧版本 D. 必须这么写

三、简答

  1. 画出"两个协程并发 addModel(事故版) "的时序,
    说明哪一次更新被丢了、为什么。
  2. 为什么删文件要放在事务外
  3. 为什么导出对话不能 直接把长文本塞进 EXTRA_TEXT

答案与解析

一、判断

  1. 。SP 有三个问题:主线程读写会卡顿没有事务没有流式 API(11.1)。
  2. 。这正是本工程修过的事故:读在事务外 → 并发时会互相覆盖(丢更新)(11.3)。
  3. 。事务持有写入锁 ;删 2GB 文件要几秒,
    这几秒会挡住所有其它写入
    正确做法:事务外读路径 → 事务里只改记录 → 事务外再删文件(11.4)。
  4. 。应该返回空列表并记日志 ------
    让 App 还能正常用(优雅降级),而不是因为一条脏数据整个打不开(11.5.5)。

二、选择

  1. B(11.3.3)。
  2. A 。Java/Kotlin 泛型有类型擦除 ,运行时 List<ChatSession> 只剩 List
    必须用 TypeToken 把完整类型信息"记住"(11.2.4)。
  3. B 。如果用"每个会话加一个 isCurrent 字段",切换时要改两条 记录,
    而且可能出现"两个都是 true"或"都是 false"的不一致
    独立键只有一个值,结构上不可能不一致(11.6.3)。

三、简答(要点)

  1. 时序(11.3.2):

    时刻 协程 A(加 X) 协程 B(加 Y) 存储中的值
    t1 读到 [M1, M2] --- [M1, M2]
    t2 --- 读到 [M1, M2] [M1, M2]
    t3 算出 [M1,M2,X],写入 --- [M1, M2, X]
    t4 --- 算出 [M1,M2,Y]写入 [M1, M2, Y]

    X 被丢了 。原因:B 计算新值时用的是过期的旧快照

    而它写入的是基于旧快照算出的完整值 ,于是把 A 的写入整个覆盖。

    → 这就是经典的 lost update(丢失更新)。

  2. 因为事务持有写入锁 。在锁里做慢 IO(删几 GB 文件)

    会让所有其它写入排队 ,用户体验是"删个模型整个 App 都卡住"(11.4.2)。

    原则:事务里只做"读 + 改内存态",把重活挪出去。

  3. 因为 Android 的 Binder 有约 1MB 上限

    长对话会超过它 → 抛 TransactionTooLargeException(分享失败甚至崩溃)。

    正确做法是"写文件 + 用 content:// URI ",

    只把很短的 URI 传给对方(11.8)。

评分建议 :第 2、3、8、10 题是本章核心。第 8 题(丢失更新)是必须理解的


下一章 :ch12 系统能力接入:TTS、剪贴板、分享、文件选择器。

这一章你把数据存到了磁盘。下一章走向"与系统打交道 ":

让手机读出文字(TTS)、复制到剪贴板、把文件分享给别的 App、

以及用 ActivityResult 拉起文件选择器。

这些能力都有各自的"异步"和"生命周期"陷阱,本工程踩过不少。

相关推荐
JMchen4 小时前
实战案例:实现120fps流畅的渐变进度条
android·kotlin·canvas
传奇开心果编程1 天前
【Jetpack Compose基础语法学与练】第8课 rememberSaveable,页面旋转/系统重建保留状态
android·学习·ui·kotlin·android jetpack
传奇开心果编程1 天前
【Jetpack Compose基础语法学与练】第7课 if条件渲染 + 列表基础(LazyColumn)
学习·ui·kotlin·android jetpack
ai2work1 天前
ch12 系统能力接入:TTS、剪贴板、分享、文件选择器
kotlin
传奇开心果编程1 天前
【Jetpack Compose基础语法学与练】第9课 SideEffect副作用 + LaunchedEffect,处理异步任务和生命周期
android·学习·ui·kotlin·android jetpack
不要喷香水2 天前
00-总览与架构
kotlin·app·档案管理
hai_android2 天前
Android 组件化开发实践
android·java·kotlin
传奇开心果编程3 天前
【Jetpack Compose基础语法学与练】第6课 TextField文本输入,字符串状态与输入交互
学习·前端框架·kotlin·android jetpack
ai2work3 天前
ch06 Jetpack Compose 入门:状态驱动 UI
kotlin