android中的文件存储方式有三种读写数据库,读写本地文件,读写SharePreferences。
其实读写SharePreferences也属于读写本地文件,android为了方便开发者存储轻量的数据,实现了这个工具。其实android为了方便开发者的使用,提供了很多类似的工具,最典型的就是SyncTask.
好,闲话不多说,今天聊聊SharePreferences的实现原理以及android实现这个简单的工具的闪光点。
假如让我们实现一个本地存储,那么我们需要注意那些东西呢?我觉得至少有三点:
1.读取策略
2.更新策略
3.容错处理
本地存储的基本流程也很简单:
1.程序将内存中的数据按照合适的格式写入文件
2.程序在有权限的情况下,从文件中读取数据
但是如果真的按照上面说的两步,那么显然会有很多的IO操作,那么SharePreferences是怎么做到的呢?我们按照本地存储的三点,分析源码。
首先说第一点,SharePreferences存储的格式,查看代码之前,先看下一个实例。通过adb命令找到包名/data/data/share_pref目录下的文件,这个目录下的文件就是当前包名的app创建的SharePreferences文件,可以看到文件是xml格式的。目录结构如下图所示:
随便打开一个xml文件,可以看到xml中存储的是标准的xml格式字符串,如图2所示。通过图2,我们可以感性的知道,SharePreferences存储格式是xml。
好的,查看了SharePreferences具体存储文件,接着看代码实现,SharePreferences的代码实现类是SharePreferencesImpl。
图3是一个典型的SharePreferences调用方式:
private static SharedPreferences sharedPreferences = AppUtils.context.getSharedPreferences(ConstantInPreference.FILE_NAME, Context.MODE_PRIVATE);
sharedPreferences.getBoolean(key, defaultValue);
sharedPreferences.edit().putBoolean(key, value);
通过Apputil的上下文会生成一个跟文件名绑定的SharePreferences实例。这也知道我们,应用程序在运行期间,对于单个文件,只需要有一个SharePreferences实例就可以了。SharePreferences的实例实际上就是SharePreferencesImpl实例,创建对象,自然离不开构造函数,先看下SharePreferencesImpl的构造函数。
SharedPreferencesImpl(File file, int mode) {
mFile = file;
mBackupFile = makeBackupFile(file);
mMode = mode;
mLoaded = false;
mMap = null;
startLoadFromDisk();
}
构造函数的入参是file,mode。这里面的file就是创建实例时,传入的filename对应的File对象,mode时传入的文件模式,实例中用的是private。构造函数的第二行
mBackupFile = makeBackupFile(file);
是创建一个备份文件,那么为什么这儿需要创建一个备份文件呢?这个等下说,接着看构造函数的最后一行。
startLoadFromDisk();
这行代码是将文件中存储的数据转为map,加载到内存中,这就是读操作。具体的方法实现,如下图。
private void startLoadFromDisk() {
synchronized (this) {
mLoaded = false;
}
new Thread("SharedPreferencesImpl-load") {
public void run() {
loadFromDisk();
}
}.start();
}
1.将mLoaded状态设置为false
2.开启一个线程,加载文件中的内容,也就是读文件。
这段代码,可以知道我们在创建完SharePreference的时候,并不能立刻获取到磁盘文件中的内容,因为读文件的操作是在子线程中进行的。那么不禁有人要问了,我在平时使用SharePreference的时候,是在创建完对象之后就可以正常获取数据了呀。比如
private SharedPreferences sharedPreferences = AppUtils.context.getSharedPreferences(ConstantInPreference.FILE_NAME, Context.MODE_PRIVATE);
boolean value = sharedPreferences.getBoolean(key, defaultValue);
上面代码中的value是有值的,这是为什么呢?按理说SharePreference对象创建完成之后,是在另外一个线程中,加载磁盘文件的呀,为什么下面的同步代码,可以立刻获取到文件中的值?这里面的关键就在mLoaded这个变量,这是一个全局的变量,看一下getBoolan值的代码。
public boolean getBoolean(String key, boolean defValue) {
synchronized (this) {
awaitLoadedLocked();
Boolean v = (Boolean)mMap.get(key);
return v != null ? v : defValue;
}
}
1.awaitLoadedLock
2.获取mMap中的key对应的value值
getBoolean方法能够获取到正确的值的关键就在awaitLoadedLock。
private void awaitLoadedLocked() {
if (!mLoaded) {
// Raise an explicit StrictMode onReadFromDisk for this
// thread, since the real read will be in a different
// thread and otherwise ignored by StrictMode.
BlockGuard.getThreadPolicy().onReadFromDisk();
}
while (!mLoaded) {
try {
wait();
} catch (InterruptedException unused) {
}
}
}
这个方法,如果发现mLoaded为false,那么为一致处于wait状态,当然如果初始化完成了,之后再进行读取操作,会获取内存中的mMap中的值,速度会很快,但是同样也会导致其他的app更新了sp文件,不能感知到。到这儿,基本的读流程就可以很清晰了,流程如下所示:
这儿最重要的是调用线程挂起,等待读文件线程完成。这实际上就是说,初始化sharepreference后,立即调用的获取值的操作最好不要在UI线程进行,因为这会导致线程挂机,直到初始化工作中读取磁盘文件完成。初始化完成之后,sharepreference在获取值,会从内存中的mMap中取,速度会很快,减少IO次数,这也导致,不能及时感知其他app对文件做的更改。总结下初始化与读取过程有两点优化:
1.第一次初始化将磁盘中的数据同步到内存的mMap中,这会使得后续的读取操作不需要进行IO操作,提高速度。
2.读取值的方法都会在初始化完成之后返回,内部做了线程同步,简化了开发者的使用方式。
文件读操作的具体实现,其实很简单,解析xml,将数据存储到内存的mMap中,具体loadFromDisk方法不贴了,感兴趣的可以去查一下。
到这里,我们可以回答第一个问题,读取策略:
1.存储xml格式
2.初始化的时候,同步xml文件中的内容到内存的mMap中
3.初始化完成之后,获取值都从mMap中获取。
接着说更新策略
按照之前的分析,sharepreference读取的是内存中的mMap中的值,那么更新的时候呢?也就是说sharepreference是怎么存储数据的呢?下面的代码是一个常用的存储sharepreference的代码。
sharedPreferences.edit().putBoolean(key, value).apply();
sharedPreferences.edit()
这行代码是获取一个编辑器,sharepreference对象本身不具备存储的功能,他提供了一个editor对象,以便进行存储键值对数据,当然如果这个时候还没有同步完本地的xml文件,那么线程会处于阻塞状态,知道本地的xml文件数据全部同步到内存中,edit方法返回一个EditorImpl的一个实例,真正做存储的功能是在这个类中实现的。
public Editor edit() {
// TODO: remove the need to call awaitLoadedLocked() when
// requesting an editor. will require some work on the
// Editor, but then we should be able to do:
//
// context.getSharedPreferences(..).edit().putString(..).apply()
//
// ... all without blocking.
synchronized (this) {
awaitLoadedLocked();
}
return new EditorImpl();
}
EditorImpl是sharepreference的一个内部类,也就是说EditorImpl可以读取到sharepreference中存储的mMap得值,这也保证了Editor可以将mMap中的值同步到xml文件中。EditorImpl中包含两个全局变量,如下所示:
private final Map mModified = Maps.newHashMap();
private boolean mClear = false;
这两个全局变量中mModified是一个hashMap,存储的是往editorimpl中存放的数据,也就是用户想要存储到xml中的数据,这个mModify也意味着,存储数据的时候,数据首先会被存储到内存中,那么什么时候会同步到本地的xml文件中呢?是在调用apply方法之后,内存中的数据才会被同步到本地的xml文件中,下面的代码是apply方法的实现:
public void apply() {
final MemoryCommitResult mcr = commitToMemory()
final Runnable awaitCommit = new Runnable() { public void run() { try { mcr.writtenToDiskLatch.await(); } catch (InterruptedException ignored) { } } }; QueuedWork.add(awaitCommit); Runnable postWriteRunnable = new Runnable() { public void run() { awaitCommit.run(); QueuedWork.remove(awaitCommit); } }; 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); }
appy方法比较简洁,第一行方法如下,这行方法调用了commitToMemory方法,通过方法名也可以很清晰的看到,是将mModify中的键值对数据同步到内存中,也就是说,当我们调用了apply方法之后,sharepreference中的mMap会添加editor中的mModify中存储的键值对数据,这保证了内存数据的同步,此处不贴commitToMemory的详细代码,只想说下,这个方法会操作mMap对象,所以加了同步锁,同步完成之后会将mModify清空掉。
final MemoryCommitResult mcr = commitToMemory()
SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);
这个方法就是调用enqueueDiskWrite方法,这个方法会将同步内存中mMap的数据到xml中。代码详细如下所示,这段代码首先汇创建一个包含同步任务的runnable,然后根据postWriteRunnable的值判断是否需要立即执行,apply方法传入的postWriteRunnable是不为空的,所以apply方法会将同步任务的runnable放到单线程池中执行。
private void enqueueDiskWrite(final MemoryCommitResult mcr,
final Runnable postWriteRunnable) {
final Runnable writeToDiskRunnable = new Runnable() {
public void run() {
synchronized (mWritingToDiskLock) {
writeToFile(mcr);
}
synchronized (SharedPreferencesImpl.this) {
mDiskWritesInFlight--;
}
if (postWriteRunnable != null) {
postWriteRunnable.run();
}
}
};
final boolean isFromSyncCommit = (postWriteRunnable == null);
// Typical #commit() path with fewer allocations, doing a write on
// the current thread.
if (isFromSyncCommit) {
boolean wasEmpty = false;
synchronized (SharedPreferencesImpl.this) {
wasEmpty = mDiskWritesInFlight == 1;
}
if (wasEmpty) {
writeToDiskRunnable.run();
return;
}
}
QueuedWork.singleThreadExecutor().execute(writeToDiskRunnable);
}
通过分析也知道了sharepreference是通过创建一个editimpl的实例进行更新逻辑的,调用方会先将需要存储的键值对数据存在editimpl的mModify中,然后调用apply放将mModify中的数据更新到sharepreference的内存中的mMap数据中去,这保证了调用了apply方法之后,editor的所属sharepreference对象能够及时得到同步,然后edit会根据需要选择合适的线程执行同步sharepreference中的mMap到本地磁盘对应的mMap中。
目前为止更新策略已经很明确了。主要分为以下几步:
1.创建一个EditImpl对象,用来存储需要更新的键值对
2.将存储的键值对同步到sharepreference的内存对象mMap中
3.更新sharepreference中的mMap到本地的xml中
最后分析以下容错处理,如果更新策略的最后一步,写入xml文件,出现了异常,写入失败,那么又该怎么办呢?一个策略是不断循环,直到写成功为止,显然这不是一个有效的办法,特别浪费电量,而且如果由于环境的原因,还会导致无限循环。另外一个策略就是忽略这次写入,为了性能降低正确性,至少内存中包含了正确的数据,还是有机会在下一次apply的时候,同步到xml中的。
sharepreference使用的就是第二个策略,放弃这次写入,此时内存中的数据与xml中的数据是不一致的,具体的实现,是借助backupfile实现的。步骤如下:
1.初始化sharepreference的时候,如果发现有同文件名,并且以.bak结尾的文件存在,那么会使用.bak文件的数据,将.bak文件中的数据同步到内存mMap中
2.更新的时候,如果.bak文件不存在,先将本地的xml文件rename为.bak文件,否则删除xml文件,通常.bak文件存在,xml文件是不存在的
3.创建新的xml文件,同步内存中的mMap到文件中,如果同步成功了,那么删除.bak文件,否则删除新创建的文件,保留.bak文件
通过这三步,就能保证xml同步内存的mMap失败之后,也能将上一次的数据存在.bak中,以便保证下一次同步的时候使用,这其实也暗示我们。sharepreference中内存中的数据与xml中的数据不一定一样,同一个xml的不同sharepreference对象中的键值对数据也不一定一样。
总结:
1.初始化sharepreference的时候,同步xml中的数据到内存的mMap中,后续取值都会从内存中获取
2.同步过程结束之前,任何获取数据都会陷入锁等待状态
3.更新的时候,先将数据存储在EditorImpl中,直到调用了apply方法的时候,才会将EditorImpl同步到sharepreference
4.同步Editor中的键值对的时候,线同步修改的数据到sharepreference的内存对象mMap中,然后才会将mMap中的数据写入xml中
5.写入xml的时候,为了容错,还需将当前文件备份,防止写入失败,数据全部丢失,写入成功之后会将备份文件删除,否则这个备份文件会一直存在,只要备份文件存在,sharepreference初始化的时候,就会用备份文件中的数据
6.sharepreference的读取,更新,容错机制说明sharepreference对象中的数据与xml中的数据不一定一直,同一个xml对象的不同sharepreference中的数据也不不一定一致,因为apply会线同步数据到内存中,然后再将数据写入到xml中,这个时候有可能写入失败,而sharepreference读取数据的时候,都会从内存中获取