1. 为什么要做AnyPreference:SharedPreferences的痛点
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()会返回一个Map<String, ?>,你拿到之后还得自己强转,编译器完全不帮忙。项目里只要出现过一次"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这个函数从名字上看像一个普通的顶层函数,其实它返回的是一个ReadWriteProperty<Any?, 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是官方支持的,但更多时候我们需要存List<String>、List<Bean>或者Map。AnyPreference用的是JSON序列化方案,底层默认集成了一套轻量级JSON库,使用者不用自己引入Gson或者Moshi:
data class UserInfo( val id: Int, val name: String, val tags: List<String> ) object DataPrefs : PreferenceStore() { var searchHistory: List<String> 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.observe<UserPrefs, 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,新版本改成了String,AnyPreference读取时会返回默认值,但同时也会在日志里输出告警。解决方案是在版本升级时做一次数据迁移,显式把旧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类型问题导致的线上崩溃。如果你也正在为存储设计头疼,不妨按照这个思路去拆解自己的项目边界,选一个合适的组合,而不是所有数据都塞同一个方案里硬扛。