Android SharedPreference 详解
前提:基于 Android API 30
1. 认识 SharedPreference
SharedPreference
是 Android 提供的轻量级的,线程安全的数据存储机制,使用 key-value 键值对的方式将数据存储在 xml 文件中,存储路径为
/data/data/yourPackageName/shared_prefs
存储的数据是持久化(persistent)的,应用重启后仍然可以获取到存储的数据。由于SharedPreference
是轻量级的,所以不适合存储过多和过大的数据的场景,这种情况下应该考虑数据库
2. 获取 SharedPreference
SharedPreference
是一个接口,实现类是 android.app.SharedPreferencesImpl
,获取 SharedPreference
的方法由 Context 提供
val sp = context.getSharedPreferences("FreemanSp", MODE_PRIVATE)
第一个参数是 name,无论哪个 context 对象,只要传入的 name 一样,获取到的就是同一个SharedPreference
对象
第二个参数是 mode,Android 提供 4 中模式可选,其中三种已明确标识为过期
- MODE_PRIVATE,私有模式,只能由当前应用读写
- MODE_WORLD_READABLE,其他应用可读,已过期
- MODE_WORLD_WRITEABLE,其他应用可写,已过期
- MODE_MULTI_PROCESS,同一应用存在多进程情况下共享,不可靠,已过期
声明 MODE_WORLD_READABLE
和 MODE_WORLD_WRITEABLE
这两中模式,其他应用可以完全读写自己应用的数据,这是及其危险的,谷歌推荐使用 ContentProvier
方式去做数据共享,共享的数据可以完全由 provider 定义的 uri 做出限制。
甚至在 android N 后指定这两种方式,应用直接报错
private void checkMode(int mode) {if (getApplicationInfo().targetSdkVersion >= Build.VERSION_CODES.N) {if ((mode & MODE_WORLD_READABLE) != 0) {throw new SecurityException("MODE_WORLD_READABLE no longer supported");}if ((mode & MODE_WORLD_WRITEABLE) != 0) {throw new SecurityException("MODE_WORLD_WRITEABLE no longer supported");}}}
基于 mode 的限制,本文一切都已
MODE_PRIVATE
展开
创建 SharedPreferencesImpl 对象时,会起一个子线程,将 xml 文件解析的数据全量保存在内存中
private void startLoadFromDisk() {synchronized (mLock) {mLoaded = false;}new Thread("SharedPreferencesImpl-load") {public void run() {loadFromDisk();}}.start();}
如果存储着大量数据时会有占用较大内存
3. 写入数据
SharedPreference
通过调用edit()
方法获取到一个Editor
对象,其实现类是android.app.SharedPreferencesImpl.EditorImpl
。写入数据提供了同步 commit
和异步 apply
两个提交方法
3.1 commit()
调用方式
sp.edit().putLong("currentTime", System.currentTimeMillis()).commit()
该方法是个同步方法,IDE 会出现警告提示
Commit your preferences changes back from this Editor to the SharedPreferences object it is editing. This atomically performs the requested modifications, replacing whatever is currently in the SharedPreferences.
Note that when two editors are modifying preferences at the same time, the last one to call commit wins.
If you don’t care about the return value and you’re using this from your application’s main thread, consider using apply instead
结论是如果不考虑返回结果或者在主线程,应该考虑用 apply
方法替代,原因来看下原码
@Overridepublic boolean commit() {long startTime = 0;if (DEBUG) {startTime = System.currentTimeMillis();}// 先提交到内存MemoryCommitResult mcr = commitToMemory();// 写入磁盘SharedPreferencesImpl.this.enqueueDiskWrite(mcr, null /* sync write on this thread okay */);try {// 当前线程 await,确保 mcr 已经提交到了磁盘mcr.writtenToDiskLatch.await();} catch (InterruptedException e) {return false;} finally {if (DEBUG) {Log.d(TAG, mFile.getName() + ":" + mcr.memoryStateGeneration+ " committed after " + (System.currentTimeMillis() - startTime)+ " ms");}}notifyListeners(mcr);return mcr.writeToDiskResult;}
再详细看下 commitToMemory()
,这个方法的主要作用有两个:
- 写入内存
- 构造写入磁盘的map,需要注意的是这个map是全量写入的
private MemoryCommitResult commitToMemory() {long memoryStateGeneration;boolean keysCleared = false;List<String> keysModified = null; // 需要提交存储的键值对Set<OnSharedPreferenceChangeListener> listeners = null;Map<String, Object> mapToWriteToDisk; // 需要写入磁盘的键值对,这里会是整个 xml 文件的全量数据synchronized (SharedPreferencesImpl.this.mLock) {// We optimistically don't make a deep copy until// a memory commit comes in when we're already// writing to disk.if (mDiskWritesInFlight > 0) { // 多线程提交时,对 mMap 做深拷贝,这个 mMap 就是整个 xml 文件的全量键值对// We can't modify our mMap as a currently// in-flight write owns it. Clone it before// modifying it.// noinspection uncheckedmMap = new HashMap<String, Object>(mMap);}mapToWriteToDisk = mMap;// 同时要写入磁盘的提交计数标记为 +1,如果同时有异步多个提交,后面的 commit 有可能会提交到子线程,并且由于版本控制,最后一个 commit 的才会写入到磁盘mDiskWritesInFlight++; boolean hasListeners = mListeners.size() > 0;if (hasListeners) {keysModified = new ArrayList<String>();listeners = new HashSet<OnSharedPreferenceChangeListener>(mListeners.keySet());}synchronized (mEditorLock) {boolean changesMade = false;if (mClear) { // 只有在调用了 Editor#clear() 方法才会进入这段代码,因为 mEditorLock 锁if (!mapToWriteToDisk.isEmpty()) {changesMade = true;mapToWriteToDisk.clear();}keysCleared = true;mClear = false;}// 将用户要修改的数据合并到 mapToWriteToDiskfor (Map.Entry<String, Object> e : mModified.entrySet()) {String k = e.getKey();Object v = e.getValue();// "this" is the magic value for a removal mutation. In addition,// setting a value to "null" for a given key is specified to be// equivalent to calling remove on that key.if (v == this || v == null) {if (!mapToWriteToDisk.containsKey(k)) {continue;}mapToWriteToDisk.remove(k);} else {if (mapToWriteToDisk.containsKey(k)) {Object existingValue = mapToWriteToDisk.get(k);if (existingValue != null && existingValue.equals(v)) {continue;}}mapToWriteToDisk.put(k, v);}changesMade = true;if (hasListeners) {keysModified.add(k);}}mModified.clear();if (changesMade) {mCurrentMemoryStateGeneration++;}// 写入的版本管理,优化写入磁盘的写入次数,避免资源浪费和数据覆盖memoryStateGeneration = mCurrentMemoryStateGeneration;}}return new MemoryCommitResult(memoryStateGeneration, keysCleared, keysModified,listeners, mapToWriteToDisk);}
写入内存后接着就要执行写入磁盘
SharedPreferencesImpl.this.enqueueDiskWrite(mcr, null /* sync write on this thread okay */);
enqueueDiskWrite
方法的主要作用是
- 同步写入磁盘
- 如果此时有新的
commit
提交,旧提交将会被抛弃,新提交则有可能提交到子线程
private void enqueueDiskWrite(final MemoryCommitResult mcr,final Runnable postWriteRunnable) {final boolean isFromSyncCommit = (postWriteRunnable == null); // 同步commit,isFromSyncCommit = truefinal Runnable writeToDiskRunnable = new Runnable() {@Overridepublic void run() {synchronized (mWritingToDiskLock) {/** 真正执行写入到磁盘的操作,但是 writeToFile 会有 memoryStateGeneration 控制,如果当前的 mcr 版本和 memoryStateGeneration 不一致,则当前的写入会被对其* 这也是 commit 方法注释上写的:* Note that when two editors are modifying preferences at the same time, the last one to call commit wins*/ writeToFile(mcr, isFromSyncCommit);}synchronized (mLock) {mDiskWritesInFlight--;}if (postWriteRunnable != null) {postWriteRunnable.run();}}};// Typical #commit() path with fewer allocations, doing a write on// the current thread.if (isFromSyncCommit) { // apply 方法不会进入到这个代码块boolean wasEmpty = false;synchronized (mLock) {// 如果此时有新的 commit 执行到 commitToMemory ,则 mDiskWritesInFlight 则有可能大于 1,此时 wasEmpty = falsewasEmpty = mDiskWritesInFlight == 1;}if (wasEmpty) { // 如果 wasEmpty 为 true,则直接在当前线程执行写入磁盘writeToDiskRunnable.run();return;}}// 将写入磁盘的操作加入到子线程QueuedWork.queue(writeToDiskRunnable, !isFromSyncCommit);}
总结
- 如果主线程执行
commit
会在主线程执行IO
操作,数据量太大可能会造成 ANR edit()
方法每次都会返回一个新的Editor
对象,应该缓存一个Editor
对象重复使用,SP
是线程安全的- 如果实在需要使用
commit
,可以考虑将频繁写入的key
单独放到一个SP
,这样避免资源浪费 - 尽量避免多次调用
commit
,可以多次put
数据,在合适实际执行一次commit
- 避免使用
commit
3.2 apply()
调用方式
sp.edit().putLong("currentTime", System.currentTimeMillis()).apply()
该方法是个异步方法,Android 中推荐使用这个方法存储数据,直接看源码
public void apply() {final long startTime = System.currentTimeMillis();final MemoryCommitResult mcr = commitToMemory(); // 写入内存,构造写入磁盘的map,同commit一样,这个map是全量写入的final Runnable awaitCommit = new Runnable() { // 这个 Runnable 的作用就是为了统计写入磁盘的耗时@Overridepublic void run() {try {mcr.writtenToDiskLatch.await(); // block 待 mcr 写入磁盘完成后 writtenToDiskLatch 降为0,await()立刻返回,awaitCommit 继续执行} catch (InterruptedException ignored) {}if (DEBUG && mcr.wasWritten) {Log.d(TAG, mFile.getName() + ":" + mcr.memoryStateGeneration+ " applied after " + (System.currentTimeMillis() - startTime)+ " ms");}}};QueuedWork.addFinisher(awaitCommit); // 将 awaitCommit 加入到 QueuedWork 子线程的消息队列// postWriteRunnable 会在写入磁盘完成后触发执行Runnable postWriteRunnable = new Runnable() {@Overridepublic void run() {awaitCommit.run(); // 触发 awaitCommit ,统计写入耗时QueuedWork.removeFinisher(awaitCommit);}};// postWriteRunnable 会在真正执行写入磁盘的 runnable 调用SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);// Okay to notify the listeners before it's hit disk// because the listeners should always get the same// SharedPreferences instance back, which has the// changes reflected in memory.notifyListeners(mcr);
}
同commit
一样,apply
也是先写入内存后并构建全量写入磁盘的map
。不同的是在调用 enqueueDiskWrite
时传入了一个postWriteRunnable
,enqueueDiskWrite
通过判断这个参数来区别是否是异步执行,该方法在commit
流程中已分析过一部分,这里再来看apply
的流程
private void enqueueDiskWrite(final MemoryCommitResult mcr,final Runnable postWriteRunnable) { // apply 传入的 postWriteRunnable 不为 nullfinal boolean isFromSyncCommit = (postWriteRunnable == null); // isFromSyncCommit = falsefinal Runnable writeToDiskRunnable = new Runnable() {@Overridepublic void run() {synchronized (mWritingToDiskLock) {// 如果 memoryStateGeneration 一致,将完成磁盘的写入writeToFile(mcr, isFromSyncCommit);}synchronized (mLock) {mDiskWritesInFlight--;}if (postWriteRunnable != null) {postWriteRunnable.run(); // 通知写入完成}}};// Typical #commit() path with fewer allocations, doing a write on// the current thread.if (isFromSyncCommit) {boolean wasEmpty = false;synchronized (mLock) {wasEmpty = mDiskWritesInFlight == 1;}if (wasEmpty) {writeToDiskRunnable.run();return;}}// writeToDiskRunnable 直接加入子线程消息队列QueuedWork.queue(writeToDiskRunnable, !isFromSyncCommit);}
总结
edit()
方法每次都会返回一个新的Editor
对象,应该缓存一个Editor
对象重复使用,SP
是线程安全的- 多次
apply
也不会造成多次写入磁盘,因为有版本控制 - 推荐使用
apply
4. SharedPreference 造成ANR的原因
- 在主线程
commit
写入大量的数据 - 在 ActivityThread 共用四个地方会调用
QueuedWork.waitToFinish();
,分别是:
- handleServiceArgs
- handleStopService
- handlePauseActivity
- handleStopActivity
这些都是Android组件的生命周期管理,如果QueuedWork
有遗留的runnable
没有执行完,则会将剩余的runnable
在主线程执行,导致产生了ANR的风险
解决办法
- 避免使用
commit
,使用apply
- 将频繁写入的
key
放到独立的SharedPreference
- 使用 MMKV 迁移或者代替
SharedPreference
5. MMKV 简单分析
- mmkv 提供了 importFromSharedPreference 方法用来做数据迁移
- mmkv 不支持 getAll 方法,因为通过 protoBuf 二进制存取,在其他存取操作时都会指定数据类型,比如getInt, getBoolean。但是如果使用getAll,mmkv不知道每个key对应的数据类型,所以无法decode。为了能够代理 getAll,并避免第三方sdk有使用该方法,可以将类型转换上移,在使用mmkv存取时统一用 String 类型,再拿出值时再由业务自身去判断(因为业务知道自己在写入时,每个key对应的数据类型),SharedPreference 自身也只是返回了一个 Map<String, Object> 对象
- 支持多进程读写
- 每次都是增量写入,本身不提供缓存。当写入时,发现内存不够会动态申请内存空间