ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

面试必问 PreferenceManager 手写实现避坑指南

面试必问 PreferenceManager 手写实现避坑指南 面试必问 PreferenceManager 手写实现避坑指南 面试现场,面试官盯着屏幕问:“手写一个 PreferenceManager,要求支持持久化。”你心里一紧,脑子里只有 SharedPreferences 的 API,却说不清底层怎么把 Map 存成文件,更别提多线程同步和类型转换了。这是典型的面试必问原理题,答不上来直接挂。 很多学员觉得 PreferenceManager 就是个配置读取器,调调 getInt、getString 完事。但在 Android 开发高阶面试中,这往往考察的是对单例模式、线程安全、IO 优化的综合掌握。今天拆解 3 个高频坑,带你从源码级理解如何手写一个健壮的 PreferenceManager。 坑一:单例初始化不线程安全,并发下出现“双亲” 现象 在多线程环境下(比如后台线程写配置,主线程读配置),偶尔会出现两个不同的 PreferenceManager 实例。一个实例读到的数据是空的,另一个实例有数据,导致状态不一致,甚至崩溃。 根本原因 这是最经典的 DCL(Double-Checked Locking) 缺失或实现错误导致的。 很多初学者会写这样的单例: public class PreferenceManager {private static PreferenceManager instance;public static PreferenceManager getInstance() {if (instance == null) {instance = new PreferenceManager(); // 这里有问题}return instance;} }在 JVM 内存模型中,new PreferenceManager() 并非原子操作,它分为三步:分配内存空间。 初始化对象(执行构造函数)。 将引用指向内存地址。如果线程 A 执行完第 1 步被中断,此时 instance 已经被赋值(非 null),但对象还没初始化完。线程 B 进来检查 instance == null 为假,直接返回这个半成品对象。后续调用方法时,字段都是默认值(null/0),导致 NPE 或数据错乱。 正确写法对比 必须使用 volatile 关键字修饰静态变量,禁止指令重排序。 错误写法(非原子初始化): // ❌ 错误:缺少 volatile,存在指令重排风险 public class PreferenceManager {private static PreferenceManager instance;public static PreferenceManager getInstance() {if (instance == null) {synchronized (PreferenceManager.class) {if (instance == null) {instance = new PreferenceManager();}}}return instance;} }正确写法(DCL + Volatile): // ✅ 正确:volatile 保证可见性与有序性 public class PreferenceManager {private static volatile PreferenceManager instance;private PreferenceManager() {// 私有构造}public static PreferenceManager getInstance() {if (instance == null) {synchronized (PreferenceManager.class) {if (instance == null) {instance = new PreferenceManager();}}}return instance;} }复现与修复代码 在实际项目中,建议直接参考 Android 官方库 androidx.preference 的设计思路,它内部虽然没直接暴露单例,但其 PreferenceManager.getDefaultSharedPreferences 内部对 Context 和 File 的获取做了严格的上下文绑定,避免了跨进程或跨 Context 导致的文件冲突。 如果你手写,务必在单元测试中用 CountDownLatch 模拟 100 个线程同时调用 getInstance(),验证返回的 hashCode 是否一致。 坑二:多线程读写 SharedPreferences,数据丢失或格式损坏 现象 主线程调用 putString,同时子线程调用 putInt。结果发现,文件里的 XML 结构乱了,或者某个 Key 的值变成了另一个 Key 的值。重启 App 后,部分配置丢失。 根本原因 SharedPreferences 底层基于 XML 文件持久化。它的读写操作不是线程安全的。写操作:edit().apply() 是异步的,会将数据先存到内存 mMap,再后台线程写入文件。 读操作:直接从内存 mMap 读。如果两个线程同时 edit(),或者一个线程 apply 正在写文件,另一个线程也在 apply,底层 XML 序列化器(XmlUtils)并没有加锁保护整个文件的写入过程。虽然 SharedPreferencesImpl 内部对 mMap 加了 synchronized,但文件 IO 阶段是并发的。 更严重的坑是:commit() 是同步阻塞的,apply() 是异步的。 很多开发者混用。如果在 apply() 后立即调用 commit(),或者在应用退出的瞬间调用 apply(),可能导致内存数据还没刷盘,进程就被杀了,数据丢失。 正确写法对比 手写 PreferenceManager 时,不能直接透传 SharedPreferences.Editor,必须封装串行化写入队列或全局锁。 错误写法(直接透传 Editor): // ❌ 错误:多线程并发 edit 可能导致文件写入冲突 public void putString(String key, String value) {SharedPreferences.Editor editor = mSharedPreferences.edit();editor.putString(key, value);editor.apply(); // 异步写,无全局同步控制 }正确写法(使用 ReentrantLock 串行化 IO): // ✅ 正确:使用锁保证同一时间只有一个写操作进入文件层 public class SafePreferenceManager {private final SharedPreferences mSharedPreferences;private final ReentrantLock mWriteLock = new ReentrantLock();public void putString(String key, String value) {mWriteLock.lock();try {SharedPreferences.Editor editor = mSharedPreferences.edit();editor.putString(key, value);// 使用 commit 确保在锁内同步写完,或者确保 apply 的任务被正确调度// 注意:这里为了面试演示安全,用 commit 阻塞,生产环境建议用 Handler 单线程池处理 applyeditor.commit(); } finally {mWriteLock.unlock();}} }进阶技巧:单线程池优化 在高性能场景下,ReentrantLock 会阻塞调用线程。更好的做法是维护一个单线程 ExecutorService,所有写操作投递到这个线程执行,天然串行,无需加锁。 private final ExecutorService mWriteExecutor = Executors.newSingleThreadExecutor();public void putStringAsync(String key, String value) {mWriteExecutor.execute(() - {SharedPreferences.Editor editor = mSharedPreferences.edit();editor.putString(key, value);editor.apply();}); }坑三:类型强转异常与默认值处理不当 现象 调用 getInt(user_age, 0),但之前存入的是字符串 18。运行时抛出 ClassCastException,App 闪退。或者,当 Key 不存在时,没有返回默认值,而是返回了 null,导致后续逻辑 NPE。 根本原因 SharedPreferences 存储的是 MapString, ?,值可以是 String, int, long, float, boolean, SetString。 底层在读取时,是直接 map.get(key) 然后强转。如果你存入的是 String,取出时强转 int,必崩。 此外,很多手写实现忽略了 Key 不存在 的情况,直接 return (int) map.get(key),当 map.get 返回 null 时,拆箱 null 会抛 NPE。 正确写法对比 必须做类型检查和默认值兜底。 错误写法(盲目强转): // ❌ 错误:无类型检查,无默认值保护 public int getInt(String key) {MapString, ? map = mSharedPreferences.getAll();return (int) map.get(key); // 如果是 String 或 null,直接崩 }正确写法(安全转换 + 默认值): // ✅ 正确:类型检查 + 默认值 public int getInt(String key, int defValue) {Object value = mSharedPreferences.getAll().get(key);if (value instanceof Integer) {return (Integer) value;} else if (value instanceof String) {try {return Integer.parseInt((String) value);} catch (NumberFormatException e) {return defValue; // 解析失败返回默认值}}return defValue; }复现与修复代码 在测试中,故意存入 putString(age, 18),然后调用 getInt(age, 0)。错误写法:崩溃。 正确写法:返回 18。如果存入 putString(age, abc),调用 getInt(age, 0)。正确写法:捕获异常,返回默认值 0,不崩溃。坑四:内存泄漏与 Context 绑定错误 现象 Activity 销毁后,PreferenceManager 依然持有 Activity 的 Context 引用,导致内存泄漏。或者在 Application 中初始化时,传入了 Activity Context,导致文件路径错误。 根本原因 SharedPreferences 的创建依赖于 Context。如果传入 Activity Context,生成的 SharedPreferences 文件会关联到该 Activity 的生命周期。虽然文件本身不会删除,但如果在 Activity 中持有 Manager 引用,Activity 泄漏会导致 Manager 泄漏。 如果传入 Application Context,文件路径稳定,生命周期长。核心原则:PreferenceManager 必须使用 Application Context 初始化。 正确写法对比 错误写法(使用 Activity Context): // ❌ 错误:在 Activity 中用 this 初始化 public class MyActivity extends Activity {private PreferenceManager manager;@Overrideprotected void onCreate(Bundle savedInstanceState) {super.onCreate(savedInstanceState);manager = new PreferenceManager(this); // this 是 Activity Context} }正确写法(强制 Application Context): // ✅ 正确:内部强制转为 Application Context public class PreferenceManager {private final Context mContext;private final SharedPreferences mSharedPreferences;public PreferenceManager(Context context) {// 关键:无论传入什么 Context,都转成 Application Contextif (context.getApplicationContext() != null) {mContext = context.getApplicationContext();} else {mContext = context;}mSharedPreferences = mContext.getSharedPreferences(config, Context.MODE_PRIVATE);} }规避建议 在构造函数中,不要信任调用者传入的 Context。始终通过 context.getApplicationContext() 获取应用级 Context。这样可以确保:文件路径全局唯一且稳定。 不会因为 Activity 销毁而意外影响配置文件的访问(虽然 SP 文件本身不随 Activity 销毁,但引用链会断)。 避免内存泄漏。总结与面试应答策略 手写 PreferenceManager 不是让你重写 Android 系统代码,而是考察你对并发、IO、内存、异常处理的综合理解。 面试回答模板:单例:使用 DCL 模式,volatile 保证线程安全。 线程安全:SharedPreferences 本身非线程安全,写操作需加锁或使用单线程池串行化。 类型安全:读取时做 instanceof 检查,提供默认值兜底,防止 ClassCastException 和 NPE。 内存安全:内部强制使用 Application Context,避免 Activity 泄漏。 IO 优化:区分 commit(同步)和 apply(异步),关键配置用 commit,非关键用 apply,且注意进程退出前的数据落盘。避坑清单:别信 apply 是线程安全的。 别直接强转 SharedPreferences 的值。 别用 Activity Context 初始化全局 Manager。 别忘了 volatile。这个知识点你面试被问过吗?留言说说,看看还有谁踩过“并发写 SP 导致文件损坏”的坑。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进