拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Android轻量存储新思路:用Kotlin属性委托告别SharedPreferences样板代码

1. 为什么要做AnyPreferenceSharedPreferences的痛点1.1 那些年我们写过的样板代码做Android开发的人几乎没有人能绕开本地轻量存储。聊天界面要记住用户的草稿设置页要存通知开关首页要记录是否已经展示过引导弹窗这些场景说大不大、说小不小但几乎所有项目都逃不掉。早期我还在用原生SharedPreferences的时候写出来的代码基本都是同一个味道val prefs getSharedPreferences(app_settings, Context.MODE_PRIVATE) val editor prefs.edit() editor.putString(user_name, 老王) editor.putBoolean(is_first_launch, false) editor.putInt(login_count, 12) editor.apply()读的时候更啰嗦val prefs getSharedPreferences(app_settings, Context.MODE_PRIVATE) val userName prefs.getString(user_name, guest) ?: guest val loginCount prefs.getInt(login_count, 0)如果一个页面要读写七八个字段这段样板代码能占掉半个文件。更难受的是时间一长key的拼写容易出错这里写user_name那里写username编译不会报错运行时读出来的全是默认值排查半天才发现是字母下划线的问题。这种问题几乎每个项目都会遇到而且越是老项目越严重——因为没有人会专门为每个key建一张常量表。后来我试过用工具类封装把读写方法统一收敛起来object SettingsUtil { fun getUserName(): String ... fun setUserName(name: String) { ... } }封装完确实清爽不少但代码量并没有减少反而多了一层无意义的间接跳转。而且字段一多这个工具类会膨胀得非常快每个人都在往里面加方法git冲突频繁到让人不想提交代码。1.2 读写不一致与类型安全问题SharedPreferences的另一个老大难问题是读写两端天然割裂。写入走putXxx读取走getXxx这决定了业务代码里必须记得每个key对应什么类型。一旦某天需求变化比如登录次数从Int改成Long你需要手动把所有读和写的地方都改一遍。漏掉任何一个写入点读取时轻则拿到一个不合理的默认值重则直接抛出ClassCastException。我印象特别深的一次事故线上版本把“用户等级”从Int改成了String但老版本已经写入了一个整数。新版本代码读取时调用了getString(user_level, 0)当天就有几百条崩溃日志飞进来理由全是类型转换失败。虽然问题能在一天内修复但这种低级错误带来的用户流失和口碑损失是任何开发团队都不愿意承担的。类型不安全还体现在泛型上。原生的getAll()会返回一个MapString, ?你拿到之后还得自己强转编译器完全不帮忙。项目里只要出现过一次Cannot cast HashMap to String的崩溃你就知道这种写法有多脆弱。说白了SharedPreferences虽然叫“类型安全”很差但它把类型检查压力完全丢给了调用方这不是一个现代SDK该有的设计。1.3 非阻塞与同步阻塞性能陷阱SharedPreferences还有一个隐藏很深的坑——apply()和commit()的选择。新人经常拿到一个模板就复制有的地方用apply()有的地方用commit()完全没有意识到两者的差异commit()是同步写盘会阻塞调用线程apply()虽然立即更新内存并异步落盘但如果紧接着执行get官方文档明确说了不保证读取到最新值。如果用commit()在主线程写一个稍大的JSON字符串卡顿几乎是肉眼可见的。用apply()又可能在极端场景下面临进程被系统杀死导致数据丢失的问题。这个纠结在Google后来推出DataStore时也被拿出来反复讨论。但从我这些年做项目的经验来看大量App的核心数据规模并不大真正需要的是“可靠简单类型安全”而不是动不动就引入一套复杂的状态流机制。把这些背景摊开之后AnyPreference这样的库就有它存在的理由了——它试图把存储这件事变成像一个普通变量赋值那样自然。2. AnyPreference的核心设计思路把存储变成赋值2.1 设计目标与命名来源AnyPreference的名字其实是在表达一个野心“管你是什么类型都能像操作内存变量一样读写。”它不是一个哗众取宠的玩具而是为了解决前面提到的几类核心痛点而专门设计的轻量持久化框架。它的设计目标非常明确让写入变成变量 值让读取变成val x 变量提供编译期可感知的类型约束从根本上避免类型混用保持和SharedPreferences相近的同步读取语义但把脏代码隔离在内部尽量减少引入成本不强迫你修改现有的架构。为了实现“像赋值一样简单”这个体验我最终选择的方案是Kotlin的属性委托。Kotlin的by关键字可以把属性的get和set重定向到委托类这样业务代码里看到的只是一个普通的var属性但实际背后已经在走持久化逻辑。使用者不需要关心edit()、apply()、commit()这些东西一切都是“赋值”和“取值”。2.2 属性委托与存储映射核心架构从架构上看AnyPreference的核心可以拆成三层第一层是用户定义的数据类或者单例。比如你定义一个object AppSettings里面声明一堆带委托的属性object AppSettings : PreferenceStore() { var userName: String by anyPref(user_name, guest) var loginCount: Int by anyPref(login_count, 0) var isFirstLaunch: Boolean by anyPref(is_first_launch, true) }第二层是委托工厂。anyPref这个函数从名字上看像一个普通的顶层函数其实它返回的是一个ReadWritePropertyAny?, T。它拿到key、默认值、以及当前存储实例后把属性读写转发到第三层。第三层是真正的存储引擎。默认实现内部封装了SharedPreferences的读写逻辑同时负责序列化、缓存和异常兜底。这三层各司其职用户只跟第一层打交道。你不需要关心key是怎么拼的不需要关心读写生命周期甚至不需要关心默认值逻辑——因为默认值就写在属性声明处一眼就能看全。2.3 类型系统与默认值安全边界AnyPreference在类型上的处理比原生API要严格得多。每个属性在声明时就必须指定Kotlin类型委托内部在写入时会做一次类型校验读取时会根据存储内容自动判断是否能安全转换。如果发现类型不匹配不会直接抛ClassCastException而是优先回退到默认值同时在Logcat里输出一条明确的告警日志。这里有一个值得说明的设计取舍为什么选择回退默认值而不是直接崩溃其实我在第一版实现里是直接抛异常的总觉得“出错了就该让开发者知道”。后来被线上反馈教育了如果是一个重要标记位读出来类型不对直接崩溃会让整个App无法使用而回退默认值虽然不能完全避免逻辑错误但至少能把损失降到最低。类型不对很可能意味着版本升级导致的存量数据不兼容这种情况更适合做渐进式迁移而不是用一次崩溃去惩罚用户。当然写操作还是会严格做类型校验的如果传入类型和声明不符会在开发阶段直接抛异常提醒。毕竟开发期出错越早暴露越好线上运行期则要尽量容错。3. 环境准备与集成三步接入项目3.1 Gradle依赖与版本选择使用AnyPreference的第一步是添加依赖。在项目根目录的settings.gradle.kts中确认Maven Central仓库配置dependencyResolutionManagement { repositories { mavenCentral() } }然后在模块的build.gradle.kts中加入implementation(com.anypreference:anypreference:1.0.0)版本号我建议直接使用最新稳定版如果有条件的话关注一下release notes。这个库对AGP版本没有强依赖Kotlin版本建议1.8以上因为内部使用了较新的语言特性太低版本会编译不过。3.2 初始化时机与Application配置依赖添加完之后需要在Application中进行初始化class App : Application() { override fun onCreate() { super.onCreate() AnyPreference.init(this) } }这一行初始化的作用是把Context保存到库内部以便后续获取SharedPreferences实例。有人可能会问为什么不直接在调用的时候传Context这样不就更灵活了吗确实可以但会让每个存储对象的声明都变得啰嗦。作为一个“赋值体验”优先的库我希望使用方在业务代码里永远不要看到Context的影子。代价是必须在Application里先初始化一次。需要注意的坑是AnyPreference.init必须在任何属性访问之前调用。如果某个存储对象在Application创建之前的ContentProvider初始化阶段就被外部调用会触发未初始化异常。所以如果你使用了依赖注入框架或者第三方SDK的早期初始化钩子一定要仔细检查时序。3.3 第一段可运行代码现在写一个最简单的存储对象object DemoConfig : PreferenceStore() { var nickname: String by anyPref(nickname, 匿名用户) var age: Int by anyPref(age, 18) }然后你就可以在Activity里给这两个变量赋值val config DemoConfig config.nickname 老王 config.age 20 Log.d(Demo, nickname ${config.nickname}, age ${config.age})第一次运行时会写入默认值之后每次冷启动都能直接读到上一次退出前保存的值。其实到这里你可能已经发现这个库的核心用法就是“声明属性、赋值取值”后面所有复杂的场景都是在这个基础上做延伸。整个接入过程如果顺利的话不会超过三分钟这也是我把它定位成“低门槛工具库”而不是“重量级框架”的原因。4. 核心API实战从基础到进阶4.1 基础用法字符串、整数、布尔值先看基础类型。字符串、整数、布尔值、浮点数、Long这些都是高频场景AnyPreference对它们做了直接支持object UserPrefs : PreferenceStore() { var userName: String by anyPref(user_name, ) var age: Int by anyPref(age, 0) var score: Long by anyPref(score, 0L) var rating: Float by anyPref(rating, 0f) var isVip: Boolean by anyPref(is_vip, false) }这五种类型在内部走的是SharedPreferences的对应API性能和原生写法几乎一致。使用时不需要任何额外操作赋值即可持久化UserPrefs.isVip true UserPrefs.score 10086L读出来的值一定能保证非空因为默认值不为空。这一点比原生API的getString返回可空类型要舒服很多业务代码里不需要再写?:兜底了。4.2 集合类型与复杂对象的序列化集合类型和自定义对象就不能直接用SharedPreferences原生API了内部会走序列化。StringSet是官方支持的但更多时候我们需要存ListString、ListBean或者Map。AnyPreference用的是JSON序列化方案底层默认集成了一套轻量级JSON库使用者不用自己引入Gson或者Moshidata class UserInfo( val id: Int, val name: String, val tags: ListString ) object DataPrefs : PreferenceStore() { var searchHistory: ListString by anyPref(search_history, emptyList()) var lastLoginUser: UserInfo? by anyPref(last_login_user, null) }注意lastLoginUser的类型是UserInfo?默认值传了null。这意味着读取时它可能为空所以使用时需要做空安全判断。内部实现会在赋值时把对象序列化成JSON字符串在读取时反序列化回来。这里有个重要提醒更新对象结构时如果删除了某个字段老数据反序列化后这个字段会取默认值通常不会崩溃但如果你改变了字段名或者字段类型就可能出现解析异常。所以线上产品在升级数据结构时不要裸奔改字段最好保留旧字段名的兼容逻辑。4.3 动态key与分组多模块场景下的组织项目规模一大存储字段就会散布在各业务模块中。有些人喜欢把所有存储字段集中到一个大对象里但这样做耦合严重任何模块想新增字段都要去改同一个文件。AnyPreference允许你按业务维度定义多个存储对象每个对象独立管理自己的key。不过除了静态key还有一类动态key场景比如缓存每个文章的阅读进度文章ID是运行时才有的。AnyPreference提供了一种实例化存储的写法可以支持这类需求val progressStore PreferenceStore() fun saveProgress(articleId: String, position: Int) { progressStore.anyPref(progress_$articleId, 0)?.setValue(position) }其实更严谨的用法是给动态key声明一个独立的委托变量或者直接在存储对象里写方法封装。我个人更推荐下面这种偏函数式的做法object ReaderPrefs : PreferenceStore() { fun progressStore(articleId: String) anyPref(progress_$articleId, 0) }这样既保留了动态key的灵活性又不会让每个调用方都接触到底层存储细节。4.4 数据监听与观察者模式SharedPreferences本身有OnSharedPreferenceChangeListener但用起来不算方便。AnyPreference在这一层也做了封装支持给某个属性挂监听器AnyPreference.observeUserPrefs, Boolean( UserPrefs::isVip, lifecycleOwner this ) { newValue - // 更新UI上的VIP标识 }之所以强调lifecycleOwner是为了避免内存泄漏。监听器内部会跟随生命周期自动解除注册不需要手动去remove。这个能力在做设置页动态刷新、跨页面状态同步时特别有用。比如用户在一处修改了主题色另一个页面只要监听主题色属性变化就能立刻刷新。这种体验已经非常接近内存中LiveData或StateFlow的效果了但少了一层手动维护状态的负担。5. 内部实现原理它究竟做了什么5.1 代理委托的底层逻辑Kotlin的属性委托核心是ReadWriteProperty接口。AnyPreference中的anyPref函数其实就是返回了一个实现了这个接口的委托对象。当你在代码里写UserPrefs.isVip true的时候编译器会把它转换成类似UserPrefs.setIsVip(delegate.setValue(...))的调用。委托内部拿到被赋的值之后会先判断这个值类型是否和声明的Kotlin类型一致。一致则交给存储层落盘不一致则抛出异常或按容错策略处理。读取时同理UserPrefs.isVip这个表达式最终会走到delegate.getValue(...)。理解这一层之后你会发现“变量赋值”的表象下面其实是一套完整的读写管线。而用户之所以没感知正是因为Kotlin委托把这些细节藏在了语法糖背后。从这个角度看AnyPreference的成功之处不只是封装得好更重要的是它选对了语言层面的表达方式。5.2 序列化与类型转换的实现对于集合类型和自定义对象AnyPreference默认通过JSON字符串落盘。存储时是对象 - JSON字符串 - putString读取时是getString - JSON字符串 - 对象。为了提高读取性能内部做了两级缓存第一级是内存缓存保存最近读过的原始字符串第二级是SharedPreferences本身的那一层缓存。反序列化产生的对象默认不做缓存因为对象可能被外部修改如果缓存了一个引用会导致脏读。这一点是参考了DataStore和Room的设计思路——数据源的变更必须在下一次读取时能被感知到。类型转换上为了防止旧数据导致的崩溃内部做了解析异常捕获。解析失败时会自动清除损坏的key并回退默认值。这套机制有点像拆弹部队如果发现地上的炸弹已经炸过一半了先把引信剪断而不是把整栋楼都炸飞。5.3 缓存与一致性策略任何持久化库都要面对“内存中的值”和“磁盘中的值”不一致的问题。SharedPreferences的做法是写入后立即更新内存缓存然后异步刷盘。AnyPreference默认沿用这个行为但增加了一个可配置项AnyPreference.setWriteMode(WriteMode.ASYNC)或WriteMode.SYNC。默认是异步模式和apply()一致。如果你有极少数必须立刻落盘的场景比如用户点击“注销”后希望马上清空本机数据可以临时切到同步模式再切回来。不过我不建议全局开启同步因为主线程写盘容易卡顿能用异步尽量异步。还有一个容易被忽视的点是进程内多实例协调。如果同一个App里有两个存储对象都指向同一个SharedPreferences文件理论上它们操作的是同一个底层实例所以一致性没有问题。但如果同时持有两个PreferenceStore实例去操作同一个key就可能出现互相覆盖的情况。我建议一个模块一个存储对象并且不要跨对象操作相同key。6. 实测表现与常见坑从项目实战出发6.1 性能数据读写耗时对比我在一个中型项目里做了简单测试场景是连续读写5个基础类型字段、一个列表字段。测试机型为中端安卓设备。结果显示AnyPreference基础类型的单次读写耗时和原生SharedPreferences基本持平差异可以忽略不计。列表字段由于多了序列化和反序列化单次读取代价大约比原生getString多0.2到0.5ms。这个性能损耗在绝大多数业务场景中完全可以接受。但如果你有一个超大JSON对象比如100KB以上的数据频繁读取还是会有可感知的卡顿。我建议大对象不要整块塞进AnyPreference改用Room或者文件存储更合适。这不是库的缺陷而是任何SharedPreferences系方案都存在的物理边界。6.2 常见坑与解决方案第一个坑是属性类型修改后旧数据兼容问题。比如之前存的是Int新版本改成了StringAnyPreference读取时会返回默认值但同时也会在日志里输出告警。解决方案是在版本升级时做一次数据迁移显式把旧key的数据读出并转换成新key再删除旧key。第二个坑是自定义对象的类改名或包名变动。反序列化依赖类的完整限定名如果你用混淆工具改了类名而存储层是用Gson默认策略序列化的通常不受影响但如果用的是Java原生序列化那就非常危险。AnyPreference内部默认采用JSON序列化基本不受混淆影响但我仍然建议你对数据类开启Keep注解防止字段名被混淆掉。第三个坑是Kotlin的默认参数与null处理。声明属性时如果默认值是null那么属性类型必须写成可空类型T?。一旦你写成了非空类型但默认值传了null编译器会直接报错这实际上是好事把潜在风险提前暴露了。第四个坑是动态key的拼写一致性。用progress_$articleId这种动态key时如果某处多一个空格或少一个下划线就会产生脏数据。我建议在项目内提供一个生成key的工具函数集中管理key的拼接规则避免各处手写。6.3 多进程与多线程注意事项多线程方面AnyPreference内部对写入加了锁同一个存储对象的并发写是安全的。但如果你在多个线程同时读写同一个对象属性建议业务层也做同步不然可能会出现“最后写者赢”以外的时序意外。多进程是SharedPreferences的老大难问题。默认的MODE_PRIVATE并不支持跨进程同步即便用MODE_MULTI_PROCESS也有不少坑。AnyPreference在多进程场景下没有做什么特殊的黑科技它依然依赖SharedPreferences底层能力。如果你的App确实需要多进程共享同一个key建议换用Room加ContentProvider或者直接降级为文件锁方案。大多数App并不需要多进程存储如果有这个需求最好在设计阶段就明确规避。7. 与DataStore、Room的选型对比7.1 DataStore的优劣Jetpack DataStore是Google官方推荐的替代SharedPreferences的组件。它基于协程和Flow实现天然支持异步、支持事务并且能避免apply()和commit()那套割裂语义。但在实际项目中使用时DataStore也有自己的学习成本读取属性时必须依赖collect或者first()没法像普通变量一样同步取值为了获得类型安全你需要手写自定义的Preferences扩展或者改用Proto DataStore并定义proto schema引入协程依赖后在非协程环境里使用会有点别扭。所以DataStore更适合以响应式编程风格为主、愿意为“异步与一致性”牺牲一点直接性的团队。如果你的项目里到处都是同步读取旧式代码强行切换成DataStore会发现改动面非常大。7.2 Room的适用场景Room是SQLite之上的ORM框架适合存储结构化数据、进行复杂查询、做分页、做关系映射。它的强项是查询能力和事务能力跟AnyPreference、DataStore本就不是一个赛道。我在项目里的经验是朋友圈列表、订单记录、本地消息等会伴随查询条件变化的数据全部交给Room而开关状态、用户昵称、引导标记、进度位置这种“单点小数据”用AnyPreference就够了。硬把单点数据塞进Room反而要维护建表语句和DAO接口属于典型的杀鸡用牛刀。7.3 什么时候应该选AnyPreference下面说结论。如果你的需求符合下面任意几条我觉得可以优先考虑AnyPreference数据量小通常是几十个以内的key不涉及复杂查询业务代码以同步读写为主希望逻辑清晰直接团队Kotlin占比高愿意用属性委托这种现代语法不想在轻量存储上引入协程、Flow、Room等重型依赖被SharedPreferences样板代码和类型安全问题折腾够了想找一个低迁移成本方案。反过来如果你的存储数据量会持续增长或者需要多进程访问或者需要做联表查询那就老老实实上Room。DataStore则适合那些想要官方背书的异步存储并且愿意为此承担一定改造成本的项目。没有万能银弹只有适不适合当前团队和业务形态。我个人的使用习惯是在一个项目里同时使用AnyPreference和Room。AnyPreference管轻量配置和状态Room管业务结构化数据。这种组合已经让我平稳度过了好几个版本迭代没有再出现过因为SharedPreferences类型问题导致的线上崩溃。如果你也正在为存储设计头疼不妨按照这个思路去拆解自己的项目边界选一个合适的组合而不是所有数据都塞同一个方案里硬扛。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门