前面几节把属性委托的机制讲完了——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-状态范式
有任何问题欢迎在评论区留言交流。