「踩坑」SharedPreferences 更新 Set 竟然不生效

640 阅读6分钟

问题

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 文件写入。