小序

做过 Android 开发的对 SharedPreferences 应该是再熟悉不过了,除非你只写过小 Demo,否则不可能没用过 SharedPreferences,不过今天我还真被 SharedPreferences 小坑了一把。

起因

有个应用添加白名单需求,说白了就是保存一个包名的 Set,当时一想简单的需求没必要存在数据库里,存在 SharedPreferences 中最合适不过了,因为 SharedPreferences 提供 getStringSetputStringSet 接口,简直量身定制。于是把白名单包名的 Set 直接通过 putStringSet 存进 SharedPreferences,需要的时候再通过 getStringSet 读取出来,一切都是那么的迅速和顺利。

经过

当白名单需要更新时,我大概是这么实现的:

Set<String> whiteList = config.getStringSet(Config.KEY_CLEAN_WHITELIST);
if (checkBox.isChecked()) {
  whiteList.add(runningApp.pkgName);
} else {
  whiteList.remove(runningApp.pkgName);
}
config.putStringSet(Config.KEY_CLEAN_WHITELIST, whiteList);

看过代码之后你有没有发现什么问题,如果没有那么恭喜你,你也入坑了!不过看没看出来都没关系,谁没踩过坑。好了,不卖关子了,上面的代码是无法正确更新 SharedPreferences 中的 Set 字段的,为什么?先看下官方 API 吧。

// getStringSet Added in API level 11
Set<String> getStringSet (String key, Set<String> defValues)

// Retrieve a set of String values from the preferences.

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

看到最后一段的 Note 没,『一定不要修改 getStringSet 的返回值,否则不能保证数据的一致性』,问题就出在这里了。那么来看一下为什么不能改

public Set<String> getStringSet(String key, @Nullable Set<String> defValues) {
    synchronized (mLock) {
      awaitLoadedLocked();
      Set<String> v = null;
      try {
        v = (Set<String>) mMap.get(key);
      } catch (ClassCastException e) {
        e.printStackTrace();
      }
      return v != null ? v : defValues;
    }
  }

这里我们可以看出 getStringSet 内部直接将缓存里 ‘mMap’ 的 Set 直接返回了,并没有做深拷贝或者浅拷贝,如果我们对返回的 Set 直接修改,实际上会对缓存 mMap 的直接进行修改,而修改的结果有没有序列化到存储 SharedPreferences 的 xml 文件中,就会导致内存里的值和 xml 文件里的值不一致的问题。接下来我们看一下为什么调用 ‘putStringSet’ 不生效

public final class EditorImpl implements SharedPreferences.Editor {
    @GuardedBy("mLock")
    private final Map<String, Object> mModified = new HashMap<>();

    // 省略无关代码。。。

    public SharedPreferences.Editor putStringSet(String key, @Nullable Set<String> values) {
        synchronized (mLock) {
            mModified.put(key,
                    (values == null) ? null : new HashSet<String>(values));
            return this;
        }
    }

    private SharedPreferencesImpl.MemoryCommitResult commitToMemory() {
        // 省略无关代码。。。
        synchronized (SharedPreferencesImpl.this.mLock) {
            synchronized (mLock) {
                for (Map.Entry<String, Object> e : mModified.entrySet()) {
                    String k = e.getKey();
                    Object v = e.getValue();
                    if (v == this || v == null) {
                        if (!mMap.containsKey(k)) {
                            continue;
                        }
                        mMap.remove(k);
                    } else {
                        if (mMap.containsKey(k)) {
                            Object existingValue = mMap.get(k);
                            // 注意此处
                            if (existingValue != null && existingValue.equals(v)) {
                                continue;
                            }
                        }
                        mMap.put(k, v);
                    }
                }
            }
        }
        // 省略无关代码。。。
    }
}

SharedPreferences 的更新是通过 Editor 来更新的,Editor 会创建一个临时的 mModified 缓存,然后再批量 commit 到 SharedPreferences 中,最终通过 commitToMemory 函数进行提交修改,在提交过的过程中会判断每一个 key 对应的 value 是否发生改变,如果没有改变就跳过更新,此时问题来了,如果我们前面拿的 Set 是通过 getStringSet 返回的,那么提交时修改后的 Set 一定和 mMap 里面原有的 Set 是同一份,此时会判断认为该 Set 没有发生改变,自然也不会走后面的更新逻辑,这样就会导致 ‘putStringSet’ 不生效。

结果

知道问题所在当然就很好解决了,做一层额外的浅拷贝,然后对拷贝后的 Set 进行修改,再把结果通过 putStringSet 写回到 SharedPreferences 就 OK 了。

Set<String> whiteList = config.getStringSet(Config.KEY_CLEAN_WHITELIST);
Set<String> result = new HashSet<>(whiteList);
if (checkBox.isChecked()) {
  result.add(runningApp.pkgName);
} else {
  result.remove(runningApp.pkgName);
}
config.putStringSet(Config.KEY_CLEAN_WHITELIST, result);