学习目标
读完本章,你应当能够:
- 说清"为什么不用 SharedPreferences 存大 JSON"(三个具体原因);
- 理解
DataStore的两个核心操作:data流读 与edit { }事务写;- 能复述本章的真实事故:读-改-写必须放在同一个
edit { }里,否则并发下会丢更新;- 知道慢 IO 要放在事务外 (
removeModel删 GB 级文件为什么不写在事务里);- 给 JSON 反序列化做兜底,让一条脏数据不会拖垮整个列表;
- 看懂多会话的"排序键(
updatedAt)"与"切换(current_session_id)"是怎么落地的;- 理解
saveAllNow这种阻塞版保存 存在的理由(配合 ch09 的onCleared收尾);- 知道导出对话为什么不能直接用
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 |
只在本文件内可见(封装) |
⚠️ 两个关键约束:
- 必须声明在顶层(文件级),且是
val------不是类的成员。
否则同一个 name 可能创建多个实例,导致数据不一致。name决定文件名 。本工程有两个 DataStore:
"chat_sessions"→ 文件chat_sessions.preferences_pb(ChatSessionStore.kt:19)"model_manager"→ 文件model_manager.preferences_pb(ModelManager.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] 整个覆盖了。
📌 修复思路有两种,本工程选了第二种:
- 用一把
Mutex(互斥锁)把所有写操作串起来:> 导入 B 的全程(复制文件 + 写记录)都持锁,导入 C 必须等。> 简单粗暴,但锁的临界区被拉得很长(复制 GB 文件也在锁里),会卡。- 用
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) |
getModelById 在 edit 之前调用 |
② 事务里只改记录 (: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/catch 和 runCatching 都能达到目的),
核心都是:解析失败 → 记日志 → 返回空列表。
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_sessions、current_session_id、
以及你会话的标题和消息内容。
💡 为什么是
.preferences_pb后缀?DataStore 用 protobuf 存储(即使你存取的是字符串)。
这也是它比 XML 的 SharedPreferences 更紧凑、更快的原因之一。
11.10.2.1 如果命令跑不通,按顺序排查
adb不是内部命令 :没配环境变量。直接用 Android Studio 右侧
Device File Explorer 图形界面,定位到/data/data/work.ai4easy.offlineai/files/datastore/
双击就能看/拉文件,效果一样。run-as报权限错 /unknown package:确认跑的是调试版 (本书 ch02 构建的就是),
且设备/模拟器上真的装着这个 App 并打开过一次 (DataStore 文件是 App 第一次写数据后才生成的,
全新安装没写过数据时目录可能为空,这正常------先发两条消息再来看)。cat出来一屏乱码 :这是正常的,不是失败 。DataStore 内部用 protobuf 二进制存,
本来就不是给人读的。你要找的是在乱码里辨认出可读的字符串 :
比如你的会话标题、某条消息的前几个字。全是纯乱码、一个认得的字都没有,才说明没写到这个文件里。- 只看到一个文件,缺一个 :对应那个 DataStore 还没被写过。
比如model_manager.preferences_pb要你成功导入过一个模型后才会出现。 - 看到
.tmp结尾的临时文件 :正常 ,那是 DataStore"先写临时文件再改名"原子写入的中间产物,
写完成功后会自动消失;一直大量存在才是问题。
11.10.3 验证事务修复(进阶)
想验证"读改写在同一事务"确实起作用,可以:
- 在
addModel的edit {}内加Log.d("TX", "read: ${models.size}"); - 连续快速导入两个模型;
- 观察日志里第二次读到的 size 是否包含第一次写入的结果 。
- 修复后:第二次读到的是已包含第一个模型的列表 ✅
- 事故版:两次都读到同一个旧值 ❌
11.11 小结
| 概念 | 一句话 | 工程示例 |
|---|---|---|
| 为什么不用 SP | 主线程卡顿 / 无事务 / 无流式 API | 本工程存整张列表的 JSON |
| 声明 DataStore | 顶层 val + by preferencesDataStore(name) |
ChatSessionStore.kt:19、ModelManager.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 卡住"。
- 正在流式落盘的会话(
- 正确做法 (本工程):
- 事务外先把路径读出来(
:59); - 事务里只改 JSON 记录(快);
- 事务外再删文件(
: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"要单独存一个键
如果改成给每个 ChatSession 加 isCurrent: 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。
参考答案要点
实现要点:
-
取会话 :
chatStore.getAllSessions().first().firstOrNull { it.id == id }
(用.first()取一次即可,不需要持续监听------ch09 讲过) -
格式化 :
formatExportContent(session.title, session.messages)
------直接复用,不用重写。这就是纯函数的好处。 -
写文件 :写到
cache/exports/目录
(这个目录已经在file_paths.xml里声明过了,见 ch04) -
生成 URI :
kotlinFileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) -
分享 :
ACTION_SEND+EXTRA_STREAM+FLAG_GRANT_READ_URI_PERMISSION
为什么不需要"整张列表的写入事务"?
因为导出是只读操作 ------你只读了一个会话,没有修改任何持久化数据。
这印证了 11.3.4 的判断标准:只有"写入的值依赖当前值"时才需要事务内读改写。
文件名处理 :buildFileName(ChatExporter.kt:82)会净化中文/非法字符并限长 30,
避免生成无法写入的文件名。
延伸 :如果想支持"导出全部会话",
可以把所有会话的 formatExportContent 结果拼接起来,
其余流程完全一样------这就是把核心逻辑抽成纯函数带来的可扩展性。
11.14 自测题(附答案)
难度递进说明:第 1--4 题是基础题 (判断对错,记住本章事实即可);
第 5--7 题是应用题 (从选项里分辨"该用哪种写法");
第 8--10 题是综合题 (第 8 题要能亲手画出并发时序表,画不出来就回 11.3.2 再看)。
建议先合上书写一遍,再翻答案对。
一、判断对错
- 存大 JSON 用
SharedPreferences就行,没必要上 DataStore。 - "先
getAllModels().first()读、再edit写"是安全的做法。 - 删除 GB 级文件应该放进事务里,保证原子性。
- JSON 解析失败时,应该让整个列表读不出来(这样能提醒用户数据有问题)。
二、选择
- "读-改-写"必须在哪里完成?
A. 两次独立的editB. 同一个edit { }块内 C. 事务外 D. 不需要事务 - 反序列化泛型集合 (如
List<ChatSession>)必须用什么?
A.TypeTokenB.ArrayC.ClassD.Map - "当前会话 ID"为什么要单独存一个键 ?
A. 省空间 B. 切会话只动一个小键,且不可能出现不一致 C. 兼容旧版本 D. 必须这么写
三、简答
- 画出"两个协程并发
addModel(事故版) "的时序,
说明哪一次更新被丢了、为什么。 - 为什么删文件要放在事务外?
- 为什么导出对话不能 直接把长文本塞进
EXTRA_TEXT?
答案与解析
一、判断
- ❌ 错 。SP 有三个问题:主线程读写会卡顿 、没有事务 、没有流式 API(11.1)。
- ❌ 错 。这正是本工程修过的事故:读在事务外 → 并发时会互相覆盖(丢更新)(11.3)。
- ❌ 错 。事务持有写入锁 ;删 2GB 文件要几秒,
这几秒会挡住所有其它写入 。
正确做法:事务外读路径 → 事务里只改记录 → 事务外再删文件(11.4)。 - ❌ 错 。应该返回空列表并记日志 ------
让 App 还能正常用(优雅降级),而不是因为一条脏数据整个打不开(11.5.5)。
二、选择
- B(11.3.3)。
- A 。Java/Kotlin 泛型有类型擦除 ,运行时
List<ChatSession>只剩List,
必须用TypeToken把完整类型信息"记住"(11.2.4)。 - B 。如果用"每个会话加一个
isCurrent字段",切换时要改两条 记录,
而且可能出现"两个都是 true"或"都是 false"的不一致 。
独立键只有一个值,结构上不可能不一致(11.6.3)。
三、简答(要点)
-
时序(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(丢失更新)。
-
因为事务持有写入锁 。在锁里做慢 IO(删几 GB 文件)
会让所有其它写入排队 ,用户体验是"删个模型整个 App 都卡住"(11.4.2)。
原则:事务里只做"读 + 改内存态",把重活挪出去。
-
因为 Android 的 Binder 有约 1MB 上限 ,
长对话会超过它 → 抛
TransactionTooLargeException(分享失败甚至崩溃)。正确做法是"写文件 + 用
content://URI ",只把很短的 URI 传给对方(11.8)。
评分建议 :第 2、3、8、10 题是本章核心。第 8 题(丢失更新)是必须理解的。
下一章 :ch12 系统能力接入:TTS、剪贴板、分享、文件选择器。
这一章你把数据存到了磁盘。下一章走向"与系统打交道 ":
让手机读出文字(TTS)、复制到剪贴板、把文件分享给别的 App、
以及用 ActivityResult 拉起文件选择器。
这些能力都有各自的"异步"和"生命周期"陷阱,本工程踩过不少。