夜雨聆风学习资料网

ARTICLE · 1089487

保姆级学习开发安卓手机软件实战(五)--保存数据(DataStore的使用)

保姆级学习开发安卓手机软件实战(五)--保存数据(DataStore的使用)
本节讲讲怎么把数据进行保存吧,本地存储,重启不丢。
简单介绍为什么要用DataStore
一开始我想着用txt吧,简单,反正就是恢复设备的编号、启用状态。
但是查询发现,TXT在 Android 里坑非常多,例如并发读写冲突、数据损坏风险(APP 闪退如果正好在写文件,下次文件就直接无法用)、类型处理麻烦(txt 只能存字符串)、没有响应式监听(修改后不会主动触发改变UI)、路径权限问题(Android 高版本,随便放 txt 文件会碰到沙盒限制)。
而DataStore 是谷歌封装好的、专门给键值配置场景的方案(底层依然是单个二进制文件),解决原生文件读写的一堆麻烦。
  • 内部自带协程 + 锁机制,天然支持多线程安全读写;
  • 用 protobuf,写入时先写临时文件,成功后再替换原文件,原子写入,不会出现半截损坏;
  •  原生支持基础类型,自动序列化(存布尔、Int、Long等);
  • 返回 Flow/StateFlow,数据一变 UI 自动更新,完美适配 Compose;
  • 自动帮你管理文件路径,不用手动拼路径。
然后我们用直白的语言来概括一下我们今天要做的工作哈:

如果说APP的数据就像我们开杂货铺,APP是杂货铺,而 DataStore 代码库,就是专门负责打开 / 锁上这个铁皮柜子、找到对应标签格子、放入 / 取出包裹的一套工具。我们之前已经定义了实体设备类(也就是货物是什么,有什么属性),现在还需要采购文件柜放置文件,要雇打包拆包工人,然后还需要有一个柜台人员,负责去调用工人、操作柜子(后续比如不用 DataStore 改用数据库,店长不用换,只换了柜台人员),然后我们还需要一个店长,接待客户,处理业务,顾客要拿货 / 存货,店长去找柜台人员。

那用到的逻辑如下表:

唯一需要说明的是:适合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段:设备id                enable = parts[1] == "1", // 第1段:"1"代表true启用,其他就是false                onLine = 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)    }}
三、新建仓库层 Repository
现在我们来做柜台吧~~~

在 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 stored        context.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            )        }    }}
四、修改 ViewModel 
需要修改DeviceListViewModel.kt(店长要上任啦)
新增引用及修改类型:
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)
init函数进行修改,订阅修改:
// ① 订阅设备清单: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.DONE            else -> Process.STOP        },        valves = List(7) { false }//没有全部拿出来,这里也就是自己的逻辑,根据你自己的属性来    )
这里需要注意的是,因为我们改变了
classDeviceListViewModel(application: Application) : AndroidViewModel(application)
UI界面上会报错,因为暂时没有其他需求,就不拆分UI了,直接屏蔽吧,我们直接看模拟器效果也成,后续如果需要,再UI分离吧。
这里运行应该和之前是一致的,没有UI区别,唯一的区别就是这次我们点击开关,再次打开软件,被关闭的设备还是关闭的状态~~~
下一节熟悉网络通信吧,APP作为服务器,和硬件连接一个路由。

相关学习资料