Android开发-数据存储SharedPreferences工具类、Set保存问题、源码分析

时间:2022-04-27 00:16:43

介绍

SharedPreferences作为Android提供给我们方便简单的存储数据的类。它内部的实现实际上是xml格式的文件存储数据,同时为了提升读写性能同时实现了内存缓存机制。关键源码在android.app包中的SharedPreferencesImpl类里面。值得一提的是Context实例的getSharedPreferences是抽象方法,看不到实现。因为整个Context套件被设计成装饰者模式
推荐一篇分析源码的博客:
Android SharedPreferences 源码分析,写得很好很详细。

读写工具类

SharedPreferences数据的存储作为一个常用的功能模块,我们都会专门写一个读写工具类方便使用。
一般有两种包装工具类方式:

确定每个读写方法

提前确定好每个确定的key值和保存类型,直接提供某个字段的读写方法,示例如下:

public static void setUserName(Context context,String value){
//做具体的写入操作 使用apply()没有返回值
}

这样写的好处是每个方法的操作明确易于理解。缺点是不易拓展每个字段都需要一个专门的方法去操作,如果存储的字段很多相应的方法也会变得很多。如果再抽象的写,一种存储类型一个方法

public static void putString(Context context,String key,String value){
//针对String类型 统一使用该方法
}

这里有个问题,如果这个存储类型的方法还需要区分apply()和commit()两种写入方式,代码量还是很多。

统一处理

统一传入参数类型为Object,然后用instanceof做类型判断简化代码。这也是我现在使用的方式,提供关键代码给大家参考。

/**
* 异步提交方法
* @param context
* @param key
* @param object
*/

public static void putApply(Context context, String key, Object object) {
SharedPreferences sp = context.getSharedPreferences(FILE_NAME,
MODE);
SharedPreferences.Editor editor = sp.edit();
judgePutDataType(key, object, editor);
editor.apply();
}

/**
* 同步提交方法
* @param context
* @param key
* @param object
* @return
*/

public static boolean putCommit(Context context, String key, Object object){
SharedPreferences sp = context.getSharedPreferences(FILE_NAME,
MODE);
SharedPreferences.Editor editor = sp.edit();
judgePutDataType(key, object, editor);
return editor.commit();
}

/**
* 根据不同类型 使用不同的写入方法
* @param key
* @param object
* @param editor
*/

private static void judgePutDataType(String key, Object object, SharedPreferences.Editor editor) {
if (object instanceof String) {
editor.putString(key, (String) object);
} else if (object instanceof Integer) {
editor.putInt(key, (Integer) object);
} else if (object instanceof Boolean) {
editor.putBoolean(key, (Boolean) object);
} else if (object instanceof Float) {
editor.putFloat(key, (Float) object);
} else if (object instanceof Long) {
editor.putLong(key, (Long) object);
} else if (object instanceof Set) {
editor.putStringSet(key, (Set<String>) object);
} else {
editor.putString(key, object.toString());
}
}

以上的代码已经可以覆盖所有的写入类型,然后再做不同的写入方法处理。简化了代码量。缺点是每次都是包装类型传入使用时需要注意,同时包装类也会占用更多内存空间。优点就是代码少。大家根据实际情况考虑使用。

存储Set的问题

当我们需要存储不关心存储顺序的String对象时候可以考虑使用putStringSet(String key, @Nullable Set<String> values)用Set<>集合存数据。
Set集合有一个特性就是,不会存入已经存在的元素,利用这个特性可以简化不少写入检查代码。但是使用不当会出现意想不到的问题。
当时我的代码如下:

private void saveSearchHistory(String key) {
//保存搜索记录
HashSet<String> hashSet = (HashSet<String>) SPUtils.get(mContext, Constant.HISTORYTEXT, new HashSet<String>());
//直接在返回对象上修改了值
hashSet.add(key);
boolean isSuccess = SPUtils.putCommit(mContext, Constant.HISTORYTEXT, hashSet);
//打印同步写入的结果
Logger.d("isSuccess=" + isSuccess);
}

这个代码片每次运行都能打印出写入成功的返回值。当存储的数据数量超过1个的时候就有问题。如果整个应用退出再次进入,读取保存的数据就有可能只会读到1个值。多次检查代码都没有问题。最后在*上看到和我一样的情况。幡然醒悟!
回到Android开发文档有这么一段话:

Note that you must not modify the set instance returned by this call. The consistency of the stored data is not guaranteed if you do, nor is your ability to modify the instance at all.
翻译:请注意,您不能修改此调用返回的集合实例。如果你做了,存储的数据的一致性是不保证的,也不是你的能力来修改的实例。

文档提示我们不能直接修改返回的实例,所以我们代码需要这么写。

private void saveSearchHistory(String key) {
//转到这个界面就表示 搜索成功 保存搜索记录
HashSet<String> hashSet = (HashSet<String>) SPUtils.get(mContext, Constant.HISTORYTEXT, new HashSet<String>());
//关键操作 需要在新的集合添加值 然后再提交修改
Set<String> changeData = new HashSet<>(hashSet);
changeData.add(key);

boolean isSuccess = SPUtils.putCommit(mContext, Constant.HISTORYTEXT, changeData);
Logger.d("isSuccess=" + isSuccess);
}

问题解决了。
另外一种解决方案是:先取出集合数据,再删掉这个key值保存的数据,修改取出的集合数据,再次提交写入。需要做两次修改。感觉这个方案比较乱就没有实践。
具体为什么会这样去看源码或许能找到你自己的答案。推荐Android SharedPreferences 源码分析帮助理解。

保证存储顺序的数组

当我们需要存储的数据需要确定每次的存储顺序,目前的网络上的解决方案是拼接String字符串用逗号“,”分割,和逗号一起顺序写入,取出时用逗号“,”,做分割符用String对象的public String[] split(String regularExpression) {}方法或得数组。这样顺序就可以得到保证。具体实现很简单而且方法可以多种我就不说明了。

关键源码

既然使用到SharedPreferences作为读取数据的实现组件,就有必要了解到一些它的内部实现原理。
Android源码的app包的SharedPreferencesImpl是SharedPreferences组件的真正实现类。
Android开发-数据存储SharedPreferences工具类、Set保存问题、源码分析

成员变量

final class SharedPreferencesImpl implements SharedPreferences {
private final File mFile;//文件存储数据
private final File mBackupFile;//备份文件
private final int mMode;//读写模式

private Map<String, Object> mMap; // guarded by 'this' 内部缓存
private int mDiskWritesInFlight = 0; // guarded by 'this'
private boolean mLoaded = false; // guarded by 'this'
private long mStatTimestamp; // guarded by 'this'
private long mStatSize; // guarded by 'this'

private final Object mWritingToDiskLock = new Object();//写锁对象
private static final Object mContent = new Object();
private final WeakHashMap<OnSharedPreferenceChangeListener, Object> mListeners =
new WeakHashMap<OnSharedPreferenceChangeListener, Object>();
//弱引用持有的 数据变化监听器 防止内存泄露的写法
//省略很多代码
}

从这几个成员变量就可以知道大概的实现原理

  • SharedPreferences以文件形式存储数据。File mFile就是读写的文件对象。
  • 为了提升读写效率,Map

构造方法

从构成方法的逻辑一步一步的看源码。

SharedPreferencesImpl构造方法–>startLoadFromDisk开启子线程读取文件–>loadFromDiskLocked读到文件流格式转换–>赋值给缓存Map

读文件肯定是在子线程执行:

//startLoadFromDisk 部分源码
new Thread("SharedPreferencesImpl-load") {
public void run() {
synchronized (SharedPreferencesImpl.this) {
loadFromDiskLocked();
}
}
}.start();

XML格式文件使用XmlUtils工具加载解析

取数据

取数据相对简单 ,比如getString就很简单。

@Nullable
public String getString(String key, @Nullable String defValue) {
synchronized (this) {
awaitLoadedLocked();//等待文件读取完成 才能下一步
String v = (String)mMap.get(key);//先从缓存取数据
return v != null ? v : defValue;//根据取出的缓存数据 做判断返回值
}
}

写数据

我们都知道使用SharedPreferences写数据是需要Editor对象,而它的实现类是EditorImpl

public final class EditorImpl implements Editor {
private final Map<String, Object> mModified = Maps.newHashMap();
private boolean mClear = false;

public Editor putString(String key, @Nullable String value) {
synchronized (this) {
mModified.put(key, value);
return this;
}
}

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);//向写队列 存入Runnable操作

// 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

同步写操作

 public boolean commit() {
MemoryCommitResult mcr = commitToMemory();//修改内存数据
SharedPreferencesImpl.this.enqueueDiskWrite(
mcr, null /* sync write on this thread okay */);
try {
mcr.writtenToDiskLatch.await();
} catch (InterruptedException e) {
return false;
}
//等待写入之后 发出通知
notifyListeners(mcr);
return mcr.writeToDiskResult;//并返回写操作结果
}

其他

SharedPreferences内部有一个写队列,当发出指令,会在线程池执行写操作。单线程执行。

QueuedWork.singleThreadExecutor().execute(writeToDiskRunnable);

水平有限,只能写这么多了。借用Android SharedPreferences 源码分析的话

writeToDiskRunnable中调用writeToFile写文件。如果参数中的postWriteRunable为null,则该Runnable会被同步执行,而如果不为null,则会将该Runnable放入线程池中异步执行。在这里也验证了之前提到的commit和apply的区别。

总结

  1. 本文主要总结有关使用SharedPreferences组件的记录
  2. 提供包装工具类的两种实现方法,并分析优缺点。
  3. 总结使用过程中,发现当存储Set集合的遇到的存储数据出错问题,并提供解决方案。
  4. 记录保证存储顺序的数组实现思路。
  5. 分析了部分源码,了解内部构造和实现,当我们使用SharedPreferences存储数据时要结合实际思考。

思考

我的项目中使用SharedPreferences存储用户信息,目前是直接的读写数据。为了统一数据操作。

思考:是否有必要再写一个用户对象单例,缓存用户数据?

问题是在我看《App研发录》-User是唯一例外的全局变量章节的思考。

参考

  1. Android-API
  2. Android SharedPreferences 源码分析
  3. 《App研发录》