ARTICLE · 1089487
保姆级学习开发安卓手机软件实战(五)--保存数据(DataStore的使用)
内部自带协程 + 锁机制,天然支持多线程安全读写; 用 protobuf,写入时先写临时文件,成功后再替换原文件,原子写入,不会出现半截损坏; 原生支持基础类型,自动序列化(存布尔、Int、Long等); 返回 Flow/StateFlow,数据一变 UI 自动更新,完美适配 Compose; 自动帮你管理文件路径,不用手动拼路径。
如果说APP的数据就像我们开杂货铺,APP是杂货铺,而 DataStore 代码库,就是专门负责打开 / 锁上这个铁皮柜子、找到对应标签格子、放入 / 取出包裹的一套工具。我们之前已经定义了实体设备类(也就是货物是什么,有什么属性),现在还需要采购文件柜放置文件,要雇打包拆包工人,然后还需要有一个柜台人员,负责去调用工人、操作柜子(后续比如不用 DataStore 改用数据库,店长不用换,只换了柜台人员),然后我们还需要一个店长,接待客户,处理业务,顾客要拿货 / 存货,店长去找柜台人员。
那用到的逻辑如下表:


首先是gradle/libs.versions.toml文件,这我们前不久才操作过。

datastore="1.1.1"androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }接下来添加依赖,在app/build.gradle.kts:
implementation(libs.androidx.datastore.preferences)
记得要Snyc Now。

这一步就是采购文件柜放置文件,要雇打包拆包工人的过程。
新建 1 个 DataStore 实例 = 买 1 个文件柜(生成 1 个 .preferences_pb 文件),当然新建 2 个不同name的实例就是买两个文件柜,我们把 List<Device>翻译成一段文本(货物),放到对应的.preferences_pb 文件柜上的某一个格子(打上一个标签,也就是 KEY_DEVICES,后续可以通过这个标签找到这个格子取出货物或者放入新的货物)
那首先,我们先创建DataStore类:
1.在 data/ 上 右键 → New → Directory → 输入 local → 回车
2.在新建的 local/ 上 右键 → New → Kotlin File/Class → 输入 DevicePreferences → 选 File → 回车
文件内容如下(解析也在里面):
package com.example.jstz1.data.localimport android.content.Contextimport androidx.datastore.core.DataStoreimport androidx.datastore.preferences.core.Preferencesimport androidx.datastore.preferences.core.editimport androidx.datastore.preferences.core.stringPreferencesKeyimport androidx.datastore.preferences.preferencesDataStoreimport com.example.jstz1.data.model.Deviceimport kotlinx.coroutines.flow.Flowimport kotlinx.coroutines.flow.map// ============== 1. DataStore 实例 文件柜==============// "device_store" 是文件名,存在 app 私有目录下:// /data/data/com.example.jstz1/files/datastore/device_store.preferences_pb//// ⚠️ 同一个 name 全进程只能声明一次,重复声明会抛异常。(也就是文件夹不能重名)//Context 就是「当前 App / 页面的环境凭证」,有了它,代码才知道:这个文件柜(DataStore)属于哪个 App,该去 App 的私有目录创建`.preferences_pb`。在 ViewModel 或者 Activity 里:this 就是Context(场地证明)val Context.deviceDataStore: DataStore<Preferences>//我要给 Context 这个东西**挂一个叫 deviceDataStore 的新属性,就像给杂货店(Context)**装一个文件柜**(deviceDataStore)by preferencesDataStore(name = "device_store")//"device_store" =磁盘文件名(柜名)// ============== 2. Key(声明我们准备存什么类型的什么字段) 货架上贴着"设备清单"标签的格子==============// stringPreferencesKey 表示这个 key 只能存 String,不能存 Int/Bool//用字符串的原因:DataStore 的格子,每个格子只能放一个值,booleanPreferencesKey("x"),只能放一个 true/false// 格子的名字必须在写代码时就定死。 但你的设备数量是动态的——以后点"+ 添加设备"会多出 aaaa0007、aaaa0008……你不可能在代码里预先写一万个格子// ,stringPreferencesKey则可以把所有字段打包成字符串放入。private val KEY_DEVICES = stringPreferencesKey("devices")// ============== 3. 编码:List<Device> → String 工人 A:把一堆货物 → 打成 1 个包裹 ==============// 每行一台:devId,enable// aaaa0001,1// aaaa0002,0funencodeDevices(devices: List<Device>): String =devices.joinToString(separator = "\n") { d ->"${d.devId},${if (d.enable) 1 else 0}"}// ============== 4. 解码:String → List<Device> 工人 B:把 1 个包裹 → 拆成一堆货物==============// ⚠️ onLine 不落盘,一律 false。// enable 是用户意图(要不要连),onLine 是运行时事实(现在连上没有)。// app刚启动还没建连,不可能"在线"。如果存 onLine,下次重启会出现// "在线但没数据"的假状态。fundecodeDevices(raw: String): List<Device> =raw.lineSequence() // 把整个大字符串,按换行切成一行一行【序列】(懒加载,省内存).map { it.trim() } // 每一行,前后去掉空格.filter { it.isNotEmpty() } // 删掉空行.mapNotNull { line -> // 遍历每一行;返回null就直接丢弃这一行,不会抛异常val parts = line.split(",") // 用逗号切割这一行,切成字符串数组if (parts.size < 2) return@mapNotNull null // 如果逗号分割后,字段不够2个 → 这行是脏数据,扔掉Device(devId = parts[0], // 第0段:设备idenable = parts[1] == "1", // 第1段:"1"代表true启用,其他就是falseonLine = false // 在线状态固定写死false(注意这里!CSV里没有存onLine))}.toList() // 把处理完的全部有效设备,转成List返回// ============== 5. 读:DataStore → 设备清单(响应式 Flow) 取货口(拿走一份清单,并且有变化会自动调用工人B拆包,推送新设备清单给上层。)==============//调用 `readDevices()` 就是:申请**长期订阅这个格子里的货物**。//只要文件柜里`KEY_DEVICES`格子的包裹更新了,就自动把新包裹交给工人 B 拆包,产出最新的 `List<Device>`// 返回 Flow 而不是单个值:DataStore 里数据一改,Flow 自动再发一次。// 后面 VM 直接 collect 就能拿到最新。fun Context.readDevices(): Flow<List<Device>> =deviceDataStore.data // 拿到咱们的文件柜,监听柜子里全部格子的数据(Flow,会持续发射新数据).map { prefs -> // prefs:柜子当前全部格子的所有KV(一整盘格子快照)decodeDevices(prefs[KEY_DEVICES].orEmpty() // 取出KEY_DEVICES这个格子里的文本包裹;// 如果格子是空/从没存过,orEmpty() 返回空字符串"",防止null)}// ============== 6. 写:设备清单 → DataStore 存货口(工人A打包,把新清单塞进那个格子)==============// edit 是 suspend,原子操作。多个协程同时写也不会互相覆盖。suspend fun Context.writeDevices(devices: List<Device>) {deviceDataStore.edit { prefs ->prefs[KEY_DEVICES] = encodeDevices(devices)}}
在 data/ 上 右键 → New → Directory → 输入 repository → 回车
在新建的 repository/ 上 右键 → New → Kotlin File/Class → 名字 DeviceRepository→ 选 File → 回车
文件内容如下:
package com.example.jstz1.data.repositoryimport android.content.Contextimport com.example.jstz1.data.local.readDevicesimport com.example.jstz1.data.local.writeDevicesimport com.example.jstz1.data.model.Deviceimport kotlinx.coroutines.flow.Flowimport kotlinx.coroutines.flow.first/*** 柜台(仓库层) —— 给店长(VM)开两个窗口:** 窗口A:devices —— 我要一份设备清单(带自动变化通知)* 窗口B:save() —— 这份新清单存一下** 柜子怎么开、格子在哪、打包工人是哪个 —— 店长一律不知道。* 以后要把 DataStore 换成 Room 数据库,只改这个文件,VM 一字不动。*/class DeviceRepository(private val context: Context) {// ========== 窗口A:取货口(响应式 Flow) ==========val devices: Flow<List<Device>> = context.readDevices()// ========== 首次启动 ==========/*** 本地一台设备都没存过(新装/清数据)→ 写入默认清单。* 已经有数据 → 原样返回,绝不覆盖用户的开关状态。*/suspend funloadOrSeed(): List<Device> {val stored = context.readDevices().first()if (stored.isNotEmpty()) return storedcontext.writeDevices(DEFAULT_DEVICES)return DEFAULT_DEVICES}// ========== 窗口B:存货口 ==========suspend funsave(devices: List<Device>) {context.writeDevices(devices)}companion object {/*** 默认设备清单。目的是没有连接实际设备的时候,能模拟看到UI效果* 注意 onLine = false:刚装好 app 还没连任何设备,* 显示"离线"才是诚实的状态。等 E 阶段通信层连上再变"在线"。*/private val DEFAULT_DEVICES: List<Device> = List(6) { i ->Device(devId = "aaaa000${i + 1}",enable = true,onLine = false)}}}
import android.app.Applicationimport androidx.lifecycle.AndroidViewModelimport androidx.lifecycle.viewModelScopeimport com.example.jstz1.data.repository.DeviceRepositoryimport kotlinx.coroutines.flow.updateimport kotlinx.coroutines.launch
classDeviceListViewModel(application: Application) : AndroidViewModel(application){/** 仓库:所有数据来源都从这里走,VM 不认识 DataStore */private val repo = DeviceRepository(application)

// ① 订阅设备清单:DataStore 一变 → 这里就收到 → UI 自动刷新viewModelScope.launch {//开了一个后台监听任务repo.devices.collect { list -> //collect 就是专门用来订阅 / 消费 Flow 的终端操作函数,必须和 Flow 配套使用。_uiState.update { state ->//更新设备信息,UI 会自动跟着刷新state.copy(devices = list,devicesStates = mockStatesOf(list)//这里是模拟设备状态信息,后续补充)}}}// ② 首次启动播种(已有数据则什么都不做)viewModelScope.launch { repo.loadOrSeed() }

按钮状态改变后要记得存盘:
funtoggleEnabled(devId: String) {val newList = _uiState.value.devices.map { device ->if (device.devId == devId) device.copy(enable = !device.enable)else device}_uiState.value = _uiState.value.copy(devices = newList)viewModelScope.launch { repo.save(newList) } //保存}
删 loadMockDevices 整段 + 加 mockStatesOf
// ==================== 以下是临时 mock,E 阶段整体删除 ====================/*** 给每台设备配一份假状态** 关键:以 devices 为基准生成 → 永远不会有孤儿状态* (map里有、列表里没有的那种 bug)** 多样性参考了你原来 loadMockDevices 里手写的差异。*/private funmockStatesOf(devices: List<Device>): Map<String, DeviceState> =devices.associate { d -> d.devId to mockStateOf(d.devId) }private funmockStateOf(devId: String): DeviceState = DeviceState(devId = devId,process = when (devId) {"aaaa0002", "aaaa0004" -> Process.COLLECT_GAS"aaaa0005" -> Process.DONEelse -> Process.STOP},valves = List(7) { false }//没有全部拿出来,这里也就是自己的逻辑,根据你自己的属性来)
classDeviceListViewModel(application: Application) : AndroidViewModel(application)