拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Android首选项存储从SharedPreferences到DataStore的迁移与最佳实践

Android首选项存储从SharedPreferences到DataStore的迁移与最佳实践 不知道你开发应用的时候有没有遇到过这种场景用户明明选了深色主题退出再打开又变回默认登录账号后切到后台过几分钟再回来登录态莫名其妙丢掉了上次在列表里滑到的位置也从来没有被记住。这些问题几乎都出在同一个地方——你们对“首选项”的读写处理得不够稳。“首选项”这个说法听起来有点学术但它本质上就是应用里最常用的轻量级存储方案把用户的选择、开关、登录信息、上次浏览位置这些零散的小数据以键值对的形式保存在本地下次启动时再读出来。无论在Android、iOS还是桌面端这套逻辑都大同小异只是具体的API和实现方式不一样。这篇文章我把Android生态里最常见也最容易踩坑的几条路子一次性讲透老牌SharedPreferences为什么会有问题新的DataStore怎么用“首选项的读写”到底该怎么设计才不容易翻车。看完之后不管你是刚入行的初级开发者还是准备给老项目做存储重构的中级工程师都应该能直接照着落地。1. “首选项”到底是什么先把这个基础概念说透1.1 首选项在应用里的定位首选项英文通常叫Preference它的模型特别朴素一个Key对应一个Value你往里写我就存下来你下次来读我把原来的值还给你。它没有表结构不支持SQL也不做复杂的关联查询解决的根本问题只有一个应用和用户之间那些“这次选了什么、下次还要继续用”的小选择需要一个落点。你可以把它想成办公桌上贴的那张便签上面写“下次开会带充电器”。下次进门看便签就知道该做什么不需要搬出整个记事本系统来管理。首选项在应用里的定位也类似登录态的token、主题模式、通知开关、引导页是否看过、上次播放进度、列表筛选条件、语言选择——这些数据都很轻每次只是覆盖更新不要求历史版本也不需要多端实时同步用首选项顺手又合适。从我见过的项目来看首选项最忌讳的是大而全。有人把用户配置和业务数据混在一个文件里结果写起来痛快后面维护起来就想哭。先记住一个原则首选项只存“选择”不存“内容”。凡是需要展示的大块业务数据比如聊天记录、商品列表、文章正文都不应该塞进首选项。1.2 什么时候该用首选项什么时候该用数据库不少新人分不清“什么时候用SharedPreferences/DataStore什么时候上SQLite”。我一般用三个标准来判断数据量单文件最好控制在几十KB以内。超过这个量读写速度会明显下降启动加载也会变得卡顿。查询方式如果数据需要按条件筛选、排序、分页、多表关联那就应该老老实实用SQLite或者Room。键值存储做不了这种精细查询。一致性要求如果同一个数据会被多个模块频繁修改、需要事务保证、要求要么全部成功要么全部失败那首选项也不是好选择数据库的事务机制更适合。举个例子一个新闻App用户设置“只看科技频道”可以放首选项但用户收藏的100篇新闻绝对不行。再举一个反例有人把一个购物车JSon塞进首选项每次加购都要重新序列化整个大对象结果用户加购几次后App启动慢一半。这不是首选项不行是放错了地方。为了方便对比整理了一张常用本地存储方案的对比表方案存储模型数据量查询能力事务/一致性适用场景SharedPreferencesXML键值小几十KB内仅按key读弱apply异步不可靠简单配置、开关、小状态Preferences DataStore键值文件小仅按key读支持Flow强事务更新简单配置推荐替代SharedPreferencesProto DataStore自定义Schema中小类型安全读取强结构化配置字段需要演进SQLite/Room关系型表大完整SQL强业务数据、列表、缓存这张表不是让每个人都立刻迁移而是帮你建立一种判断框架不同数据有不同归宿别用一把钥匙开所有锁。2. SharedPreferences老方案的核心原理与读写要点2.1 底层文件长什么样、读写流程是怎么回事SharedPreferences虽然是上古组件但今天仍然有大量Android项目在用它。理解它的实现原理你才知道为什么它会有那些问题和坑。它内部其实就是把键值对写进了一个XML文件位置在应用私有目录下/data/data/你的包名/shared_prefs/配置文件名.xml文件内容大概长这样?xml version1.0 encodingutf-8? map string nameuser_namecode小生/string boolean nameis_login valuetrue / int nametheme_mode value1 / /map读取的时候它会把这个文件整个load进内存缓存在一个Map里。之后的每次getXxx()实际上都在读内存不会一次次跑磁盘。但是写入的逻辑要特别留意调用edit()拿到Editor后修改操作先作用于内存中的Map然后系统需要把整个Map重新序列化写回XML文件。也就是说哪怕你只改了一个布尔值最终写盘时也是把整个文件重新覆盖一遍。这个设计在老机型上非常伤性能也是很多启动慢、操作卡顿的根源之一。获取实例的常用写法是val prefs context.getSharedPreferences(app_config, Context.MODE_PRIVATE)app_config就是文件名可以按业务拆成多个文件。模式参数建议一律用MODE_PRIVATE不要使用从API 17开始就废弃的MODE_WORLD_READABLE、MODE_WORLD_WRITEABLE这些模式它们会把你的数据开放给其他应用属于严重的安全隐患。2.2 apply和commit到底差在哪怎么选读写SharedPreferences本身不难难的是搞清楚apply()和commit()的区别。这两个方法看着都是提交修改实际语义差了十万八千里。先看读取val userName prefs.getString(user_name, 默认名) val isLogin prefs.getBoolean(is_login, false)再看写入prefs.edit() .putString(user_name, 张三) .putBoolean(is_login, true) .apply()这里apply()是异步写入。它会把新值先更新到内存缓存让后续读操作立刻能拿到新数据然后在后台线程负责落盘。好处是不阻塞UI线程坏处是如果进程在后台落盘完成之前被杀这次写入就丢了。更隐蔽的是Android系统在某些生命周期节点会等待尚未完成的写入任务比如Activity的onStop、onPause附近如果同一时间堆积了大量apply()主线程依然可能出现卡顿极端情况下还会ANR。而commit()是同步写入。它会直接在当前线程完成磁盘写入并返回一个Boolean表示是否成功。在UI线程调用commit()尤其在文件较大或慢速存储上很容易卡掉帧。官方文档倾向用apply()因为它大体上能保证UI流畅。但我个人的实践建议是普通配置、主题、开关更新用apply()没问题涉及到“写入成功后必须立即做其他操作”的流程比如退出登录后清空状态再跳转、保存关键token后才能继续交互最好用commit()或者干脆把数据放到DataStore里用协程处理。另外要记住一次edit()可以连写多个key尽量把所有修改合并到同一次提交减少全量写盘次数。2.3 一个够用的读写封装模板直接裸写getString/putString不是不行但key字符串会散落到项目各处到时候改个名字或迁移存储方案你会想骂人。我习惯用一个单例来集中管理至少起到“key统一收口”的作用object AppPreferences { private const val FILE_NAME app_prefs private const val KEY_DARK_MODE dark_mode private const val KEY_LOGIN_USER login_user private lateinit var prefs: SharedPreferences fun init(context: Context) { prefs context.getSharedPreferences(FILE_NAME, Context.MODE_PRIVATE) } fun isDarkMode(): Boolean prefs.getBoolean(KEY_DARK_MODE, false) fun setDarkMode(enabled: Boolean) { prefs.edit().putBoolean(KEY_DARK_MODE, enabled).apply() } fun loginUser(): String? prefs.getString(KEY_LOGIN_USER, null) fun setLoginUser(name: String?) { prefs.edit().putString(KEY_LOGIN_USER, name).apply() } }这个封装的价值不在“少写两行代码”而在于所有读写入口都集中在一个文件里。以后你想加缓存统计、做数据迁移、或者整体替换成DataStore只需要动这一个单例业务层完全无感。如果你是Kotlin项目还可以用委托属性做得更优雅把每对读写方法包成一个委托var darkMode: Boolean by prefsBoolean(KEY_DARK_MODE, false)但要注意委托的初始化时机不要在init()之前访问否则会抛空异常。我的建议是init()放在自定义Application的onCreate()里越早越好。3. 为什么值得迁移到DataStore新方案的核心设计3.1 DataStore解决了SharedPreferences哪些老大难问题Google自己也清楚SharedPreferences的问题所以推出了Jetpack DataStore目标就是取代它。DataStore最大的改变是引入了协程和Flow把所有磁盘IO都放到了Dispatchers.IO调度器上执行。也就是说你读取数据的时候不会卡主线程写入数据的时候不会出现“UI卡一下”的尴尬。具体来说DataStore有几个关键特性异步读取data属性暴露出来的是一个FlowPreferences你可以用collect观察每一次变化UI层能自动响应配置更新不用再手动监听。事务性更新edit{}里所有修改要么全部成功要么全部失败不会出现写一半的脏状态避免崩溃后配置文件损坏。异常恢复机制DataStore支持corruptionHandler检测到文件损坏时可以按自定义逻辑重置或恢复。SharedPreferences遇到损坏XML经常直接抛异常让人束手无策。线程安全所有方法都能在任意线程安全调用不再像SharedPreferences那样对多线程读写有隐性负担。用一句话总结DataStore把首选项从“同步的本地文件”变成了“响应式的数据流”。它保存的仍然是键值对但整个读写模型更符合现代Android应用的单向数据流架构。3.2 Preferences DataStore和Proto DataStore到底怎么选DataStore本身分两种很多同学一开始会困惑。简单说Preferences DataStore和SharedPreferences一模一样就是Key-Value存储不需要预先定义数据结构使用成本极低。适合保存主题、开关、字符串配置这类松散数据。Proto DataStore需要你用Protocol Buffers定义Schema所有字段有类型、有默认值读出来的是类型安全的自定义对象。适合配置结构复杂、字段经常演进、希望避免手写key错误的应用。大部分应用用Preferences DataStore就够了。如果你在SharedPreferences里保存了一个很大的复杂JSON可以先考虑用Room或普通文件存储如果这个JSON确实属于“配置类数据”且你想享受类型安全再考虑Proto DataStore。说句实在话我见过不少项目强行上Proto DataStore结果为了定义一个配置结构引入整个protobuf插件链维护成本直线上升最后收益却很有限。日常开发优先选Preferences DataStore确实需要类型安全或复杂结构再升级Proto这个路径尝起来最稳妥。4. 实操用Preferences DataStore完整实现首选项读写4.1 准备工作依赖、初始化与全局实例首先在build.gradle里引入依赖implementation androidx.datastore:datastore-preferences:1.1.1然后创建DataStore实例。官方推荐在模块顶层定义一个扩展属性整个模块复用同一个实例private val Context.userDataStore by preferencesDataStore( name user_preferences )这里有个关键细节同一个文件名在一个进程中只能有一个DataStore实例。如果不同类里各自创建同名实例运行时会抛异常。所以最好的做法就是把实例定义在顶层或放在单例中所有调用方都复用这一个。4.2 定义数据Key与读写方法假设我们要保存三个数据登录用户名、深色模式开关、上次播放进度。先定义Keyprivate object Keys { val LOGIN_USER stringPreferencesKey(login_user) val DARK_MODE booleanPreferencesKey(dark_mode) val LAST_PLAY_POSITION longPreferencesKey(last_play_position) }注意这里用的是stringPreferencesKey、booleanPreferencesKey、longPreferencesKey这类函数不是SharedPreferences里的裸字符串。好处是类型安全写错key在编译期就能发现。然后写一个Store类class UserPreferencesStore(private val context: Context) { val loginUser: FlowString? context.userDataStore.data .map { prefs - prefs[Keys.LOGIN_USER] } val darkMode: FlowBoolean context.userDataStore.data .map { prefs - prefs[Keys.DARK_MODE] ?: false } val lastPlayPosition: FlowLong context.userDataStore.data .map { prefs - prefs[Keys.LAST_PLAY_POSITION] ?: 0L } suspend fun setLoginUser(name: String) { context.userDataStore.edit { prefs - prefs[Keys.LOGIN_USER] name } } suspend fun setDarkMode(enabled: Boolean) { context.userDataStore.edit { prefs - prefs[Keys.DARK_MODE] enabled } } suspend fun setLastPlayPosition(position: Long) { context.userDataStore.edit { prefs - prefs[Keys.LAST_PLAY_POSITION] position } } }读取的时候因为拿到的是Flow你可以把多个数据源combine起来形成一个UI状态流。写入的时候因为edit{}是挂起函数放在viewModelScope.launch中执行即可天然异步、不卡UI。4.3 在业务层调用收集Flow与执行写入典型的使用方式是在ViewModel中做一次状态聚合class MainViewModel(private val store: UserPreferencesStore) : ViewModel() { val uiState: StateFlowMainUiState combine( store.loginUser, store.darkMode, store.lastPlayPosition ) { name, dark, position - MainUiState(name, dark, position) }.stateIn( scope viewModelScope, started SharingStarted.WhileSubscribed(5000), initialValue MainUiState(null, false, 0L) ) fun enableDarkMode(enabled: Boolean) { viewModelScope.launch { store.setDarkMode(enabled) } } }UI层只需要订阅uiState任何配置变化都会自动刷新界面。这比SharedPreferences的好处太明显了你再也不用写一个OnSharedPreferenceChangeListener然后在Activity销毁时记得注销监听器。还有一个容易踩的坑FlowPreferences读取文件时如果发生IOException它会直接抛异常。严谨场景需要做一次降级处理val safeData: FlowPreferences context.userDataStore.data .catch { exception - if (exception is IOException) { emit(emptyPreferences()) } else { throw exception } }生产环境不要静默吞异常至少要把异常信息记到日志里否则排查问题时会很痛苦。4.4 从SharedPreferences迁移以及测试避开的问题老项目迁移到DataStore最不想看到的结果就是用户老配置丢失。Google提供了官方的SharedPreferencesMigration可以一键把旧文件里的键值对搬进DataStore并在迁移完成后清理旧文件。代码大概长这样private val Context.dataStore by lazy { PreferenceDataStoreFactory.create( scope CoroutineScope(Dispatchers.IO SupervisorJob()), produceFile { File(this.filesDir, datastore/settings.preferences_pb) }, migrations listOf( SharedPreferencesMigration(this, old_settings) ) ) }需要提醒的是DataStore版本不同构造函数的细节可能有差异建议以你项目当前依赖的官方文档为准。迁移这件事我踩过的坑是别自己写“读旧key、写新key、删旧key”的脚本很容易漏掉某个key而且删除顺序出错还会导致老数据丢失。测试方面不要在测试里直接依赖真机目录的DataStore实例。一般做法是构造一个指向临时目录的DataStore测试结束再清理。如果你用Room那DataStore的测试思路和它很像核心就是注入依赖方便替换。5. 常见问题排查实录那些让我熬夜的坑5.1 设置明明写了重启后又变回原样这个问题的出现频率非常高。如果用的是SharedPreferences首先怀疑apply()异步落盘。用户改了设置后立刻杀进程、或者系统在写入完成前回收了进程数据自然就丢了。第二个疑似点是读写文件不一致写入用的getSharedPreferences(app)读取用的却是getSharedPreferences(config)。文件都不同怎么可能读到。还有一个很隐蔽的问题在Activity.onDestroy()里调用apply()然后开发工具主动Stop应用这种情况下丢数据也常见。想复现的话做好写入后立刻关闭应用再手动查看shared_prefs目录下的XML文件内容大概率能看到文件还是旧数据。这种问题最彻底的解法是换DataStore因为edit{}是挂起函数并且是事务性的正常情况下不会出现“内存改了、磁盘没改”的中间状态。如果暂时不能迁移建议对重要的数据使用commit()至少同步落盘后你心里有底。5.2 多进程访问导致的数据不同步有人为了让后台进程能读取登录状态把SharedPreferences设置了MODE_MULTI_PROCESS。这个模式从API 23开始就废弃了就算你打开它进程之间也不会主动同步缓存。表现就是A进程改了值B进程拿到的还是缓存里的旧版本。正确的设计应该保证只有单一进程负责读写首选项其他进程需要配置时通过其他方式获取。DataStore同样不推荐多进程同时操作它的设计也假设同一时间只有一个进程使用这个文件。真有跨进程共享配置的需求用ContentProvider、Binder或者Room都要更靠谱。5.3 文件膨胀与启动变慢“首选项文件越来越臃肿”是我在不少老项目里看到的问题。因为SharedPreferences每次启动都整文件加载文件一旦膨胀到几百KB启动耗时肉眼可见。就算换成DataStore它虽然改成了异步读取但每次更新仍然涉及整个对象的序列化大对象依然扛不住。遇到这种问题最直接的手段是“拆文件、瘦数据”。一个业务域一个文件比如login_prefs.xml、theme_prefs.xml、play_prefs.xml而非什么都塞进一个config.xml。其次是数据形态分离大JSON提取出必要字段存首选项完整内容放数据库或文件。我见过一个项目把一张2MB的图片转成Base64字符串后塞进DataStore结果每次读取都要反序列化一个巨大的字符串内存和IO双双爆炸。记住一句话首选项是“选项”不是“仓库”。5.4 敏感数据备份与加密首选项默认是明文存储没有加密能力。登录token、会话凭证这类敏感信息直接明文写入在备份文件泄露或设备被root后就是裸奔状态。Android提供了EncryptedSharedPreferences可以基于Keystore生成密钥再用AES加密后存储。这个方案能用但也要注意几个坑密钥由系统Keystore管理设备迁移或备份恢复后旧密文可能无法用新环境解锁容易导致用户被强制重新登录。加密库更新不活跃使用前要评估依赖风险。如果只是保存用户偏好比如主题色、语言、通知开关完全没必要加密。加密会带来性能和迁移成本别为了“安全”而给普通数据上刑。我的习惯是真正的敏感凭据使用专用安全存储方案普通用户偏好直接用DataStore明文存储即可。安全和不便之间永远要做一个清醒的权衡。聊到这里核心内容基本都说完了。最后再分享一点我个人的体会刚开始做Android那几年我也为“记住登录状态”折腾到半夜后来才明白问题不在于API不会用而在于没想清楚数据的写入时机、读取线程和失败回退。现在不管项目大小我习惯把所有存储访问收敛到一个Store层里业务代码只面向接口底层用SharedPreferences还是DataStore只是随时可以替换的实现细节。这个习惯帮我少踩了很多坑也推荐给你试试。
返回列表