SharedPreferences作为一种数据持久化的方式,是处理简单的key-value类型数据时的首选。
一般用法:
1 | //demo是该sharedpreference对应文件名,对应的是一个xml文件,里面存放key-value格式的数据. |
每个SharedPreferences都对应了当前package的data/data/package_name/share_prefs/
目录下的一个文件
源码解析
Context.java中getSharedPreferences接口说明:
1 | /** |
ContextImpl中getSharedPreferences实现:
1 | @Override |
这段代码里,我们可以看出,
- SharedPreferencesImpl是保存在全局个map cache里的,只会创建一次。
- MODE_MULTI_PROCESS模式下,每次获取都会尝试去读取文件reload。当然会有一些逻辑尽量减少读取次数,比如当前是否有正在进行的读取操作,文件的修改时间和大小与上次有没有变化等。
Context.java中提供了以下四种mode:
1 | //这是默认模式,仅caller uid的进程可访问 |
MODE_MULTI_PROCESS
当设置MODE_MULTI_PROCESS这个参数的时候,即使当前进程内已经创建了该SharedPreferences,仍然在每次获取的时候都会尝试从本地文件中刷新。在同一个进程中,同一个文件只有一个实例。MODE_MULTI_PROCESS的作用如上getSharedPreferences实现.这个方法先判断是否已创建SharedPreferences实例,若未创建,则先创建。之后判断mode如果为MODE_MULTI_PROCESS, 则调用startReloadIfChangeUnexpectedly(),看下其实现: SharedPreferencesImpl.java
1 | void startReloadIfChangedUnexpectedly() { |
可以看出MODE_MULTI_PROCESS的作用就是在每次获取SharedPreferences实例的时候尝试从磁盘中加载修改过的数据,并且读取是在异步线程中,因此一个线程的修改最终会反映到另一个线程,但不能立即反映到另一个进程,所以通过SharedPreferences无法实现多进程同步。 综合: 如果仅仅让多进程可访问同一个SharedPref文件,不需要设置MODE_MULTI_PROCESS, 如果需要实现多进程同步,必须设置这个参数,但也只能实现最终一致,无法即时同步。
由于SharedPreference内容都会在内存里存一份,所以不要使用SharedPreference保存较大的内容,避免不必要的内存浪费。
注意有一个锁mLoaded ,在对SharedPreference做其他操作时,都必须等待该锁释放:
1 | @Nullable |
写操作有两个commit apply 。 commit 是同步的,写入内存的同时会等待写入文件完成,apply是异步的,先写入内存,在异步线程里再写入文件。apply肯定要快一些,优先推荐使用apply:
1 | /** |
注册/解注册sharedpreference变动监听:
1 | /** |
为什么不推荐使用MODE_MULTI_PROCESS?
android文档已经Deprected了这个flag,并且说明不应该通过SharedPreference做进程间数据共享?这是为啥呢?从前面但分析可看到当设置这个flag后,每次获取(获取而不是初次创建)SharedPreferences实例的时候,会判断shared_pref文件是否修改过:
1 | private boolean hasFileChangedUnexpectedly() { |
这里先判断mDiskWritesInFlight>0,如果成立,说明是当前进程修改了文件,不需要重新读取。然后通过文件最后修改时间,判断文件是否修改过。如果修改了,则重新读取:
1 | private void startLoadFromDisk() { |
这里起码有3个坑!
- 使用MODE_MULTI_PROCESS时,不要保存SharedPreference变量,必须每次都从context.getSharedPreferences 获取。如果你图方便使用变量存了下来,那么无法触发reload,有可能两个进程数据不同步。
- 前面提到过,load数据是耗时的,并且其他操作会等待该锁。这意味着很多时候获取SharedPreference数据都不得不从文件再读一遍,大大降低了内存缓存的作用。文件读写耗时也影响了性能。
- 修改数据时得用commit,保证修改时写入了文件,这样其他进程才能通过文件大小或修改时间感知到。
重点是这段:
1 | if (mBackupFile.exists()) { |
重新读取时,如果发现存在mBackupFile,则将原文件mFile删除,并将mBackupFile重命名为mFile。mBackupFile又是如何创建的呢?答案是在修改SharedPreferences时将内存中的数据写会磁盘时创建的:
1 | private void writeToFile(MemoryCommitResult mcr) { |
这段代码只保留了核心流程,忽略了错误处理流程。可以看到,写文件的步骤大致是:
- 将原文件重命名为mBackupFile
- 重新创建原文件mFile, 并将内容写入其中
- 删除mBackupFile
所以,只有当一个进程正处于写文件的过程中的时候,如果另一个进程读文件,才会看到mBackupFile, 这时候读进程会将mBackupFile重命名为mFile, 这样读结果是,读进程只能读到修改前的文件,同时,由于mBackupFile重命名为了mFile, 所以写进程写那个文件就没有文件名引用了,因此其写入的内容无法再被任何进程访问到。所以其内容丢失了,可认为写入失败了,而SharedPreferences对这种失败情况没有任何重试机制,所以就可能出现数据丢失的情况。 回到这段的重点:为什么不推荐用MODE_MULTI_PROCESS?从前面分析可知,这种模式下,每次获取SharedPreferences都会检测文件是否改变,只要读的时候另一进程在写,就会导致写丢失。这样失败概率就会大幅度提高。反之,若不设置这个模式,则只在第一次创建SharedPreferences的时候读取,导致写失败的概率就会大幅度降低,当然,仍然存在失败的可能。
为什么不做写失败重试?
为什么android不做写失败重试呢?原因是写进程并不能发现写失败的情况。难道写的过程中,目标文件被删不会抛异常吗?答案是不会。删除文件只是从文件系统中删除了一个节点信息而已,重命名也是新建了一个具有相同名称的节点信息,并把文件地址指向另一个磁盘地址而已,原来,之前的写过程仍然会成功写到原来的磁盘地址。所以目前的实现方案并不能检测到失败。
有没有办法解决写失败呢?
个人觉得是可以做到的,读里面读那段关键操作:
1 | if (mBackupFile.exists()) { |
mBackupFile存在,意味着当前正处于写读过程中,这时候是不是可以考虑直接读mBackupFile文件,而不删除mFile呢?这样读话,读取效果一样,都是读的mBackupFile,同时写进程写的mFile也不会被mBacupFile覆盖,写也就能成功了。即使通过这段代码重命名,写进程写完后发现mBackupFile不存在了,其实也能认为发生了读重命名,大可以重试一次。
多进程使用SharedPreference方案
说简单也简单,就是依据google的建议使用ContentProvider了。我看过网上很多的例子,但总是觉得少了点什么
有的方案里将所有读取操作都写作静态方法,没有继承SharedPreference 。 这样做需要强制改变调用者的使用习惯,不怎么好。 大部分方案做成ContentProvider后,所有的调用都走的ContentProvider。但如果调用进程与SharedPreference 本身就是同一个进程,只用走原生的流程就行了,不用拐个弯去访问ContentProvider,减少不必要的性能损耗。
我这里也写了一个跨进程方案,简单介绍如下 SharedPreferenceProxy 继承SharedPreferences。其所有操作都是通过ContentProvider完成。简要代码:
1 | public class SharedPreferenceProxy implements SharedPreferences { |
OpEntry只是一个对Bundle操作封装的类。 所有跨进程的操作都是通过SharedPreferenceProvider的call方法完成。SharedPreferenceProvider里会访问真正的SharedPreference
1 | public class SharedPreferenceProvider extends ContentProvider{ |
重要差别的地方在这里:在调用getSharedPreferences时,会先判断caller的进程pid是否与SharedPreferenceProvider相同。如果不同,则返回SharedPreferenceProxy。如果相同,则返回ctx.getSharedPreferences。只会在第一次调用时进行判断,结果会保存起来。
1 | public static SharedPreferences getSharedPreferences(@NonNull Context ctx, String preferName) { |
这样,只有当调用者是正真跨进程时才走的contentProvider。对于同进程的情况,就没有必要走contentProvider了。对调用者来说,这都是透明的,只需要获取SharedPreferences就行了,不用关心获得的是SharedPreferenceProxy,还是SharedPreferenceImpl。即使你当前没有涉及到多进程使用,将所有获取SharedPreference的地方封装并替换后,对当前逻辑也没有任何影响。