问题
SharedPreferences 维护了一个字符串集合。从 sp 中使用 getStringSet 取出 Set,向 Set 里面 add 一个值,然后再 putStringSet 把 Set 存回 sp。
在 APP 重启以后,发现 Set 里面还是老值,不是更新以后的值。
有问题代码的简化版是这样的:
private fun changeSetValue() {
val sharedPreferences: SharedPreferences = getSharedPreferences("data", MODE_PRIVATE)
// spSet 不为 null,已经存在了,存了一个元素: “1”
val spSet = sharedPreferences.getStringSet("set", null)
if (spSet != null) {
// 向 spSet 添加元素:"2"
spSet.add("2")
val editor: SharedPreferences.Editor = sharedPreferences.edit()
editor.putStringSet("set", spSet)
editor.commit()
}
}
上面的执行完毕以后,不杀 APP,通过 getStringSet 读取 set,set 里面的值有2个,是 “1” 和 “2”。
但是 APP 重启以后,通过 getStringSet 读取 set,set 里面只有 “1” 一个值。
解决方案
解决方案1: 调用 putStringSet 传入的 Set ,不要使用从 getStringSet 读出来的 spSet。而是对 spSet 复制一份,使用这个新的就可以了。 对上面的代码做如下改动:
private fun changeSetValue() {
val sharedPreferences: SharedPreferences = getSharedPreferences("data", MODE_PRIVATE)
// spSet 不为 null,已经存在了,存了一个元素: “1”
val spSet = sharedPreferences.getStringSet("set", null)
if (spSet != null) {
// 方案1的修复:新建一个 Set
val newSet = HashSet(spSet)
// 向新复制的 newSet 添加元素:"2"
newSet.add("2")
val editor: SharedPreferences.Editor = sharedPreferences.edit()
// putStringSet 使用 newSet
editor.putStringSet("set", newSet)
editor.commit()
}
}
解决方案2: 解决方案2可能看起来很奇怪,先看修复代码。
private fun changeSetValue() {
val sharedPreferences: SharedPreferences = getSharedPreferences("data", MODE_PRIVATE)
// spSet 不为 null,已经存在了,存了一个元素: “1”
val spSet = sharedPreferences.getStringSet("set", null)
if (spSet != null) {
// 向 spSet 添加元素:"2"
spSet.add("2")
val editor: SharedPreferences.Editor = sharedPreferences.edit()
editor.putStringSet("set", spSet)
// 方案2的修复:putString 存入一个一定和 sp 里面不同的 string 值。
// 让这个 editor 是 「脏」的就行
editor.putString("UniqueString", UUID.getUUID())
editor.commit()
}
}
不一定是 putString 方法,putInt , putLong 啥的也行。只要存入一个一定和 sp 里面不同的值就行。
原因分析
有问题的写法非常符合大家的直觉,但是为啥那么写就不 work 呢?
简单版原因说明:
两个方案是同一个原理。问题的关键是 sp 的 diff 机制。
sp 中有一个 diff 机制,用来判断是否把 SharedPreferences.Editor 中的数据写入文件中。
getStringSet 拿到的 spSet 实例是被 sp 持有的,对 spSet 的修改等于直接修改了 sp 的内存数据。 所以在 diff 后,sp 判定 spSet 和 sp 持有的数据一致,不做文件写入操作。
方案一通过新建一个 newSet,让 spSet 和 newSet 的数据不同,使得 diff 机制判定 editor 是脏的,需要文件写入。
方案二通过 putXXX 方法,存入一个一定和 sp 里面不同的值,来让 diff 判定 editor 是脏的,需要文件写入。
详细版原因说明:
这个说明聚焦于和本问题相关的链路,不涉及其他部分。
1. 全局 mMap 的构建
sp 在构造的时候,会把磁盘上的 xml 文件数据全部读取到内存。以一个 map 的形式存在(下文称之为 mMap)。
sp 中所有的 getXXX 接口都是从这个 map 中直接读值。getStringSet 也不例外。
看 SharedPreferencesImpl 构造函数,里面会调用 startLoadFromDisk 开始读取 xml 文件。读取成功以后,所有的 kv 都会保存到 mMap。
SharedPreferencesImpl(File file, int mode) {
// ..... 省略部分代码
startLoadFromDisk();
}
private void startLoadFromDisk() {
synchronized (mLock) {
mLoaded = false;
}
// 起线程调用 loadFromDisk
new Thread("SharedPreferencesImpl-load") {
public void run() {
loadFromDisk();
}
}.start();
}
private void loadFromDisk(){
// ..... 省略部分代码
// 读取成功,map 里面所有 sp 的内容,
mMap = map;
// ..... 省略部分代码
}
2. getXXX 的实现
所有的 getXXX 都是从 mMap 中读取,下面看两个实现:
@Override
@Nullable
public Set<String> getStringSet(String key, @Nullable Set<String> defValues) {
synchronized (mLock) {
awaitLoadedLocked();
Set<String> v = (Set<String>) mMap.get(key);
return v != null ? v : defValues;
}
}
@Override
public int getInt(String key, int defValue) {
synchronized (mLock) {
awaitLoadedLocked();
Integer v = (Integer)mMap.get(key);
return v != null ? v : defValue;
}
}
3. putXXX 的实现
SharedPreferences.Editor 对象内部有一个 mModified 变量,其是一个 map。 mModified 记录了所有 putXXX 的信息。 看 putXXX 的实现,发现都是直接记录到了 mModified 中。
@Override
public Editor putStringSet(String key, @Nullable Set<String> values) {
synchronized (mEditorLock) {
mModified.put(key,
(values == null) ? null : new HashSet<String>(values));
return this;
}
}
@Override
public Editor putInt(String key, int value) {
synchronized (mEditorLock) {
mModified.put(key, value);
return this;
}
}
3.1 HashSet 的构造函数和 equal 函数
构造函数:
值得注意的是,在 putStringSet 方法中,放入 mModified 的,是 values 的一个拷贝:HashSet<String>(values)
看一下 HashSet 的构造函数,发现HashSet<String>(values) 是 new 了一个新的 HashSet,然后把 values 的元素放进新 HashSet 里面。
public HashSet(Collection<? extends E> c) {
map = new HashMap<>(Math.max((int) (c.size()/.75f) + 1, 16));
addAll(c);
}
public boolean addAll(Collection<? extends E> c) {
boolean modified = false;
for (E e : c)
if (add(e))
modified = true;
return modified;
}
equal 函数: HashSet 没有重写构造函数,用的是其父类 AbstractSet 的构造函数:
public boolean equals(Object o) {
if (o == this)
return true;
if (!(o instanceof Set))
return false;
Collection<?> c = (Collection<?>) o;
if (c.size() != size())
return false;
try {
return containsAll(c);
} catch (ClassCastException unused) {
return false;
} catch (NullPointerException unused) {
return false;
}
}
可以看到,其判断逻辑是 size 一致 && containsAll。 总结是,看 set 中的所有元素是不是完全 equal。
4. diff 的实现
这个 diff 很简单,是通过 equal 方法判等的。
changesMade 变量很重要,其用来记录 diff 的结果。
mapToWriteToDisk 是前面提到的 全局 mMap。在后面的文件写入部分,如果 changesMade 是 true,mapToWriteToDisk 会被全部写入文件。(对~ sp 都是全量写文件,没有增量写 o(╥﹏╥)o
apply 和 commit 中首先会调用 commitToMemory 函数,commitToMemory 函数中会遍历 SharedPreferences.Editor 中的 mModified Map,去和全局 mMap 中对应的 kv 去对比。如果有任意一对 kv 不一致,changesMade 会是 true,在后面的流程中就会进行 sp 文件写入操作。
// Returns true if any changes were made
private MemoryCommitResult commitToMemory() {
// ..... 省略部分代码
for (Map.Entry<String, Object> e : mModified.entrySet()) {
// #1.1 取出 putXXXX() 中的 k
String k = e.getKey();
// #1.2 取出 putXXXX() 中的 v
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 {
// #2.1 mapToWriteToDisk 就是全局 mMap, 如果全局 mMap 里面有对应的 k
if (mapToWriteToDisk.containsKey(k)) {
// #2.2 把mMap 里面有对应的 k 的 v(existingValue) 取出来
Object existingValue = mapToWriteToDisk.get(k);
// #2.3 existingValue 和 v 对比。用的是 equals 方法
if (existingValue != null && existingValue.equals(v)) {
continue;
}
}
mapToWriteToDisk.put(k, v);
}
// #3. Map mModified 中,只要有一对 kv ,和全局 mMap 中的不一样
// 就会走到这里,changesMade 是 true。让后面触发文件写入。
changesMade = true;
if (hasListeners) {
keysModified.add(k);
}
}
// ..... 省略部分代码
}
5. 问题原因 && 解法原因 详细版
详细版问题原因:
回看问题代码:
private fun changeSetValue() {
val sharedPreferences: SharedPreferences = getSharedPreferences("data", MODE_PRIVATE)
// #1 spSet 不为 null,已经存在了,存了一个元素: “1”
val spSet = sharedPreferences.getStringSet("set", null)
if (spSet != null) {
// #2 向 spSet 添加元素:"2"
spSet.add("2")
val editor: SharedPreferences.Editor = sharedPreferences.edit()
// #3
editor.putStringSet("set", spSet)
// #4
editor.commit()
}
}
#1 中获取的 spSet 也被 sp 中的 mMap 持有着。
所以 #2 add 操作污染了 mMap 中 k 对应的 map
#3 调用 putStringSet,sp 用 new HashSet<String>(values) 创建了一个 set 的副本。
#4 中,commit 的第一个函数 commitToMemory 中走 diff 机制。把 「mMap 中 k 对应的 map(已经包含了kv)」 和 #3 中 set 的副本进行对比。
对比使用 equal 函数,而 set 的 equal 是判断其内部元素是否完全一致。
对比后,判定二者一致,所以后续不进行写文件操作。
详细版解法原因:
方案一通过新建一个 newSet,让 #2 中 add 操作不再污染 mMap 中 k 对应的 map。从而 diff 中让 changesMade 就是 true,触发 sp 文件写入。
方案二通过 putXXX 方法,存入一个一定和 sp 里面不同的值。在「4. diff 的实现」中我们发现:只要有任意一对 kv 不一致,changesMade 就是 true。从而触发 sp 文件写入。