第099篇 属性委托实战:SharedPreferences 的现代写法

简介: 本文详解Kotlin属性委托的实战应用:何时自研委托(满足SP/DataStore存储、读写加工、多处复用三条件之一)、如何落地(内存缓存、加密、过期校验、key动态派生),并避坑SP全量加载、主线程commit、key硬编码等问题。强调委托核心价值——将状态读写策略从业务代码中解耦抽离。

前面几节把属性委托的机制讲完了——getValue/setValue 运算符重载、标准库里的 lazy/observable/vetoable。这一节回到实战,回答一个更实际的问题:什么时候该自己写一个委托类,以及写了之后怎么落地到项目里。面试里这题被答好的比例不高,原因很直接——大多数人只知道 by lazy,一旦要求"用委托把 SharedPreferences 包成属性"就卡住。

先把结论放在前面:属性委托的价值是把"状态的存储与读写策略"从业务代码里抽出来。适用前提有三条:①这个状态有自己的存储位置(SP、DataStore、MMKV、内存、Bundle);②读写前后需要加工(加密、默认值、类型转换、埋点);③同一份读写逻辑要在多处复用。满足两条以上才值得写委托,否则直接写个 getter/setter 更直白。

机制拆解

委托的编译产物很直白:by 关键字会让编译器把属性读写改写成对委托对象的 getValue(thisRef, property) / setValue(thisRef, property, value) 调用。所以实现委托只需两步——实现 ReadWriteProperty<T, V>(或 ReadOnlyProperty),把 getValue 委托给一个"存储提供者"。

// 委托类的最小骨架
class PreferenceDelegate<T>(
    private val prefs: SharedPreferences,
    private val key: String,
    private val default: T,
    private val writer: (SharedPreferences, String, T) -> Unit,
    private val reader: (SharedPreferences, String, T) -> T,
    private val onWrite: ((String, T) -> Unit)? = null
) : ReadWriteProperty<Any?, T> {
   

    override fun getValue(thisRef: Any?, property: KProperty<*>): T =
        reader(prefs, key, default)

    override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
   
        writer(prefs, key, value)          // 真正的持久化
        onWrite?.invoke(key, value)        // 副作用:埋点 / 同步 / 校验
    }
}

这里有两点值得在面试里主动说。第一,thisRef 为 null 说明这是顶层属性(没有实例),委托里可以做兼容处理。第二,KProperty 参数给出属性名的 name——很多偷懒写法在构造时把 key 写死,其实可以直接用 property.name,属性改名时零成本跟随。

工程落地:真实项目里怎么用

场景一:登录态 token。三个字段(token、userId、expireAt)存在 SP,读取时要判过期,写入时要同步刷新到内存缓存。裸写法是每个字段一个 getXxx() 方法,散落各处;写成委托后,读取路径统一走 reader,过期判断只有一处。

场景二:搜索框的历史记录。用 Vetoable 风格的委托——只有输入长度超过阈值才允许写入:

class HistoryDelegate(private val maxLen: Int = 20) : ReadWriteProperty<Any?, List<String>> {
   
    private var cache: List<String> = emptyList()
    override fun getValue(thisRef: Any?, property: KProperty<*>): List<String> = cache
    override fun setValue(thisRef: Any?, property: KProperty<*>, value: List<String>) {
   
        val next = if (value.size > maxLen) value.take(maxLen) else value
        if (next != cache) {
              // veto:条件不满足则拒绝写入
            cache = next
            // 落盘
        }
    }
}

// 用起来像普通属性
var searchHistory: List<String> by HistoryDelegate(maxLen = 20)

场景三:SharedPreferences 读改写代价。每个 getter 都可能触发一次磁盘读(SP 首次加载全量解析,之后内存缓存,但 apply 未落盘时的跨进程读取会重解析)。委托层可以加一层内存字段缓存,命中就不进 SP——这是性能优化的经典落点。

这些坑的正确绕法

把大对象序列化后塞 SP,单条记录膨胀导致全量加载变慢。 SP 在初始化时会把整个 XML 解析成 Map,一份 500KB 的 JSON 列表足以让首屏卡住。修法:大数据进 Room 或文件(DataStore/Proto),SP 只存索引与轻量标量(id、开关、时间戳)。

其次是用 commit() 在主线程写 SP。commit 是同步阻塞,会触发磁盘写。修法:统一用 apply()(异步落盘),或在委托里对非关键字段做防抖。

还有一个更隐蔽的坑:委托里的 key 在构造时写死字符串,属性改名后 key 不变,导致新旧版本数据读不到(或者反过来,读到脏数据)。修法:优先用 property.name 派生 key,或在构造时集中列出 key 常量表并写明兼容策略。

代码里见真章

看一段能直接跑的代码,把上面的机制落到具体写法上:

// 完整可用:SP 委托 + 加密 + 过期判断 + 内存缓存 + key 派生
class SecurePrefs(
    private val prefs: SharedPreferences,
    private val cipher: ValueCipher
) {
   
    private val memCache = mutableMapOf<String, Any?>()

    fun <T> delegate(
        key: String? = null,
        default: T,
        write: (SharedPreferences.Editor, String, T) -> Unit,
        read: (SharedPreferences, String, T) -> T
    ): ReadWriteProperty<Any?, T> = object : ReadWriteProperty<Any?, T> {
   
        // key 优先用构造传入,否则用属性名派生 —— 改名时零成本跟随
        private val realKey: (KProperty<*>) -> String = {
    prop -> key ?: prop.name }

        override fun getValue(thisRef: Any?, property: KProperty<*>): T {
   
            val k = realKey(property)
            memCache[k]?.let {
    return it as T }        // 命中内存,不读盘
            val v = read(prefs, k, default)
            memCache[k] = v
            return v
        }

        override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
   
            val k = realKey(property)
            memCache[k] = value                        // 先更内存
            prefs.edit().apply {
    write(this, k, value) }.apply()  // 异步落盘
        }
    }

    // 声明成属性:读写真得像普通字段
    var token: String by delegate(
        key = "auth_token",
        default = "",
        write = {
    e, k, v -> e.putString(k, cipher.encrypt(v)) },
        read = {
    p, k, d -> p.getString(k, null)?.let {
    cipher.decrypt(it) } ?: d }
    )
}

// 用法
class AuthRepo(prefs: SharedPreferences) {
   
    private val sp = SecurePrefs(prefs, AesCipher())
    var authToken: String by sp.delegate(
        key = "auth_token", default = "",
        write = {
    e, k, v -> e.putString(k, spCipher(v)) },
        read = {
    p, k, d -> p.getString(k, null) ?: d }
    )

    fun isLoggedIn(now: Long): Boolean = authToken.isNotBlank() &&
        tokenExpireAt > now                      // 过期判断只有这一处
}

// 记忆化委托:真正需要时才初始化,线程安全由 lazy 保证
val heavyIndex: Map<String, Any> by lazy {
    buildIndex() }

关键行解读:realKey 用 property.name 派生 key,属性改名自动跟随;memCache 避免重复读盘;prefs.edit().apply { }.apply() 双 apply 分别是 Editor 的作用域与异步落盘;cipher 负责加密/解密让声明处保持干净;by lazy 处理初始化时机。

这题在面试里怎么问、怎么答

"委托和继承/组合怎么选?" 答:委托是"把实现委托给另一个对象"(by),适合复用与解耦;继承是"is-a"关系;组合是"has-a"。委托不需要类型继承、不受单继承限制,比继承更灵活,代价是少了一层类型约束。

"怎么自己实现一个委托?" 答:实现 ReadWriteProperty<T, V> 的 getValue/setValue,或 ReadOnlyProperty 的 getValue;用 by 声明。关键是记住 thisRef 与 KProperty 两个参数——thisRef 是实例(顶层属性为 null),KProperty 提供属性名与类型信息。

"by lazy 的三种模式?" 答:SYNCHRONIZED(默认,双检锁,线程安全)、PUBLICATION(可能多次初始化)、NONE(不保证,仅单线程或已有其他同步机制时用)。多线程共享的懒初始化只能用默认或 PUBLICATION。

"委托能替代 get/set 吗?" 答:能,当读写策略本身是可复用的抽象时。判据是"这套逻辑是否在多个属性上重复";若只有一个属性且逻辑简单,显式 getter/setter(或自定义 accessor)更直白,也更好读。

给正在准备面试的你

1. 项目里把 SP/加密/默认值这套读写策略做成一个委托工厂(如上面的 SecurePrefs.delegate),属性声明处只写 key 与默认值,不要在每个属性里重复加密逻辑。 2. 定一条分工规范:SP 只放轻量标量与索引,对象、列表、Blob 一律进 Room 或 DataStore;这条一旦破例,首屏卡顿会以很难排查的形式出现。 3. 内存缓存字段要提供 invalidate(),供登出、切账号等场景主动清空;否则会出现"换用户后读到上个用户 token"这种串号问题。 4. 自动化预防:给委托层写单测,断言"默认值正确""加密后的值不等于明文""缓存命中后不再读盘";把"SP 单条 value 超过 4KB"做成打包期检查。

把这题画成"业务代码里该出现什么"的对比图:左边是裸写法——一屏里有 prefs.getString(...)、if (token == null) ...、prefs.edit().putString(...)、再加一段过期判断,密密麻麻;右边是委托写法——var authToken: String by sp.delegate(...) 一行,下面注明"加密/默认值/缓存/落盘/过期都在委托层"。图旁标一句结论:"把策略从 20 处收到 1 处,且只有 1 处需要维护"。

再补一个能体现真实项目经验的角度:为什么 DataStore 出来之后还需要委托。DataStore 提供了 Flow 化的异步读写和类型安全(stringPreferencesKey),确实更现代;但它返回的是 Flow<String?>,取值要 first() 或 collect。这时自定义委托正好可以把"读一次并缓存"这层封装掉,业务侧仍然写同步属性访问。这就是委托的价值没被 DataStore 取代的原因——DataStore 解决的是存储,委托解决的是访问方式。

复习时别孤立刷题:这一节还可以往两个方向延伸。其一是与 DataStore 的关系——DataStore 提供了 Flow 化的异步读写和类型安全 key,但它返回的是 Flow<String?>,取值要 first() 或 collect;此时自定义委托正好可以把"读一次并缓存"这层封装掉,业务侧仍然写同步属性访问。DataStore 解决的是存储,委托解决的是访问方式,两者不是替代关系。其二是与 ViewModel 的关系——把"读一次缓存起来"交给委托、把"变化时通知界面"交给 StateFlow,是同一份状态的两个方向:委托管同步读,StateFlow 管响应式订阅。理解这一点,就不会再纠结"有了 StateFlow 还要不要 SharedPreferences 委托"。


如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。你的支持就是这个系列持续更新的动力。

「Android软件开发面试·从入门到精通」连载系列

上一篇:空安全与-Java-混编:平台类型的风险控制

下一篇预告:密封类加-when-的状态机:ViewModel-状态范式

有任何问题欢迎在评论区留言交流。

相关文章
|
18天前
|
人工智能 JSON API
全网刷屏的 Jev 模型正式开放!一手实战测评 + 保姆级教程
全网爆火的 Jev 模型是什么?有什么用?怎么使用?怎么接入 AI 编程工具?效果真的好么?傻子可懂的 Jev 保姆级实战教程 + 项目实战测评来啦
8618 25
|
16天前
|
人工智能 并行计算 PyTorch
秋叶 ComfyUI 2026 整合包 v3.2 完整部署教程:Python 3.13 + Torch 2.13 全栈升级
秋叶aaaki ComfyUI 2026年8月整合包v3.2正式发布!全面升级Python 3.13.11、PyTorch 2.13.0+cu130及ComfyUI v0.30.2,原生支持MiniMax H3、Wan 2.2、Qwen-Image-2.1等2026主流音视频/图像模型,解压即用,无需环境配置。
3041 14
|
16天前
|
人工智能 测试技术 API
最近全网爆火的 Jev 到底是什么?适合干什么、怎么用,一篇讲透!
Jev是TypeSafe AI推出的“系统一模型”,不生成文本,专做毫秒级结构化决策:Choice(多选)、Score(打分)、Noul(是非概率)。响应快193倍、成本低444倍,适合工单路由、内容审核、测试定级等高频判断场景。
2111 4
最近全网爆火的 Jev 到底是什么?适合干什么、怎么用,一篇讲透!
|
5天前
|
人工智能 JSON Linux
【全网最详细】ComfyUI使用教程:下载+本地部署+配置+工作流搭建一篇搞定(2026最新版)
ComfyUI是一款免费开源的本地AI绘图工具,采用节点式工作流设计,支持文生图、图生图、局部重绘、放大、换脸等多种功能。可离线运行,依赖显卡加速,无需联网。支持自定义流程保存与分享,插件生态丰富,适合进阶用户。(239字)
|
16天前
|
云安全 人工智能 安全
|
11天前
|
人工智能 Linux 开发者
【2026国内使用】Codex安装过程一篇讲透(Win/Mac/Linux全支持)
Codex是OpenAI推出的AI编程智能体,可读取本地项目、理解需求并自动修改代码。支持桌面GUI、命令行(CLI)及VS Code/Cursor插件三种形态,覆盖可视化操作、终端高效开发与编辑器无缝集成场景,助开发者用自然语言驱动编码全流程。(239字)
【2026国内使用】Codex安装过程一篇讲透(Win/Mac/Linux全支持)
|
11天前
|
人工智能 JSON 编解码
【2026最新版】ComfyUI本地部署教程,新手也能看懂!
ComfyUI是本地运行的AI绘画工具,采用节点式工作流设计:通过拖拽连接“加载模型”“提示词编码”“采样”“解码”等模块,实现高度可控的文生图。新手推荐使用秋叶整合包,一键启动、内置模型管理与插件安装器,轻松上手。(239字)

热门文章

最新文章