关于SharedPreferences你要知道的一切

今天看到一个有趣的现象,比较好奇,由于这个是好几亿用户的大项目,所以我相信这里面代码有一定权威性,由于很好奇为什么不用原生的SharedPreferences,而是用ContentProvider的方式代替。究竟是为什么呢?要知道这个原因,那就必须要看看原生SharedPreferences有什么相对于ContentProvider的缺点了。

好神奇,不知道谷歌什么时候有一个看源码的链接哈哈。


image.png

使用

SharedPreferences sharedPreferences = getSharedPreferences("xxx", Context.MODE_PRIVATE);

Editor editor = sharedPreferences.edit();
editor.putString("aaa", "xxx");
editor.putInt("bbb", 10);
editor.commit();

生成的文件如下:

<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
   <string name="aaa">xxx</string>
   <int name="bbb" value="10" />
</map>

源码解析

会有两种方式使用SharedPreferences 但是最终原理一样。

Activity.java

public SharedPreferences getPreferences(int mode) {
    return getSharedPreferences(getLocalClassName(), mode);
}

PreferenceManager.java

public static SharedPreferences getDefaultSharedPreferences(Context context) {
    return context.getSharedPreferences(getDefaultSharedPreferencesName(context),
           getDefaultSharedPreferencesMode());
}

第二种方式以包名加上_preferences作为文件名, 以MODE_PRIVATE模式创建SP文件. 即packgeName_preferences.xml.

最终都会调到:

ContextImpl.java

class ContextImpl extends Context {
    private ArrayMap<String, File> mSharedPrefsPaths;

    public SharedPreferences getSharedPreferences(String name, int mode) {
        File file;
        synchronized (ContextImpl.class) {
            if (mSharedPrefsPaths == null) {
                mSharedPrefsPaths = new ArrayMap<>();
            }
            //先从mSharedPrefsPaths查询是否存在相应文件
            file = mSharedPrefsPaths.get(name);
            if (file == null) {
                //如果文件不存在, 则创建新的文件,文件的路径是/data/data/package name/shared_prefs/
                file = getSharedPreferencesPath(name);
                mSharedPrefsPaths.put(name, file);
            }
        }
        return getSharedPreferences(file, mode);
    }
}
public File getSharedPreferencesPath(String name) {
    return makeFilename(getPreferencesDir(), name + ".xml");
}

private File getPreferencesDir() {
    synchronized (mSync) {
        if (mPreferencesDir == null) {
            //创建目录/data/data/package name/shared_prefs/
            mPreferencesDir = new File(getDataDir(), "shared_prefs");
        }
        return ensurePrivateDirExists(mPreferencesDir);
    }
} 

最后会调用到重载函数中

public SharedPreferences getSharedPreferences(File file, int mode) {
    checkMode(mode); 
    SharedPreferencesImpl sp;
    synchronized (ContextImpl.class) {
        final ArrayMap<File, SharedPreferencesImpl> cache = getSharedPreferencesCacheLocked();
        sp = cache.get(file);
        if (sp == null) {
            //创建SharedPreferencesImpl
            sp = new SharedPreferencesImpl(file, mode);
            cache.put(file, sp);
            return sp;
        }
    }

    //指定多进程模式, 则当文件被其他进程改变时,则会重新加载
    if ((mode & Context.MODE_MULTI_PROCESS) != 0 ||
        getApplicationInfo().targetSdkVersion < android.os.Build.VERSION_CODES.HONEYCOMB) {
        sp.startReloadIfChangedUnexpectedly();
    }
    return sp;
}

关于checkMode

private void checkMode(int mode) {
    if (getApplicationInfo().targetSdkVersion >= Build.VERSION_CODES.N) {
        if ((mode & MODE_WORLD_READABLE) != 0) {
            throw new SecurityException("MODE_WORLD_READABLE no longer supported");
        }
        if ((mode & MODE_WORLD_WRITEABLE) != 0) {
            throw new SecurityException("MODE_WORLD_WRITEABLE no longer supported");
        }
    }
}

可以看出来N包括N以上的版本都不支持MODE_WORLD_READABLEMODE_WORLD_WRITEABLE这两种模式了。

上面getSharedPreferences是把文件缓存起来,这次又要缓存啥?我们看看

private ArrayMap<File, SharedPreferencesImpl> getSharedPreferencesCacheLocked() {
    if (sSharedPrefsCache == null) {
        sSharedPrefsCache = new ArrayMap<>();
    }

    final String packageName = getPackageName();
    ArrayMap<File, SharedPreferencesImpl> packagePrefs = sSharedPrefsCache.get(packageName);
    if (packagePrefs == null) {
        packagePrefs = new ArrayMap<>();
        sSharedPrefsCache.put(packageName, packagePrefs);
    }
    return packagePrefs;
}

ArrayMap<packageName,<ArrayMap<File,SharedPreferencesImpl>>>
形成了上面这种结构


第一次肯定是空,所以我们要创建SharedPreferencesImpl。

SharedPreferencesImpl(File file, int mode) {
    mFile = file;
    //创建为.bak为后缀的备份文件
    mBackupFile = makeBackupFile(file);
    mMode = mode;
    mLoaded = false;
    mMap = null;
    startLoadFromDisk();
}
private void startLoadFromDisk() {
    synchronized (this) {
        mLoaded = false;
    }
    new Thread("SharedPreferencesImpl-load") {
        public void run() {
            loadFromDisk(); 
        }
    }.start();
}
private void loadFromDisk() {
    synchronized (SharedPreferencesImpl.this) {
        if (mLoaded) {
            return;
        }
        if (mBackupFile.exists()) {
            mFile.delete();
            mBackupFile.renameTo(mFile);
        }
    }

    Map map = null;
    StructStat stat = null;
    try {
        stat = Os.stat(mFile.getPath());
        if (mFile.canRead()) {
            BufferedInputStream str = null;
            try {
                str = new BufferedInputStream(new FileInputStream(mFile), 16*1024);
                map = XmlUtils.readMapXml(str);
            } catch (XmlPullParserException | IOException e) {
                ...
            } finally {
                IoUtils.closeQuietly(str);
            }
        }
    } catch (ErrnoException e) {
        ...
    }

    synchronized (SharedPreferencesImpl.this) {
        mLoaded = true;
        if (map != null) {
            mMap = map; //从文件读取的信息保存到mMap
            mStatTimestamp = stat.st_mtime; //更新修改时间
            mStatSize = stat.st_size; //更新文件大小
        } else {
            mMap = new HashMap<>();
        }
        notifyAll(); //唤醒处于等待状态的线程
    }
}

我们看整体流程就是,首次会创建xml文件,然后会把整个文件在子线程中加载到内存中,但是要注意的是此时异步是加锁的就是没加载完成要是读取文件内容的话,会一直等待,所以我们就可以想象,如果一个文件很大,是不是我们就得等很长时间才可以读内容。而且我们看到缓存是根据文件缓存,当每个文件都比较大的时候就悲剧了。这也就是不能用SharedPreferences存储大量信息的原因。

继续说查询

SharedPreferencesImpl.java

public String getString(String key, @Nullable String defValue) {
    synchronized (this) {
        //检查是否加载完成
        awaitLoadedLocked();
        String v = (String)mMap.get(key);
        return v != null ? v : defValue;
    }
}
private void awaitLoadedLocked() {
    if (!mLoaded) {
        BlockGuard.getThreadPolicy().onReadFromDisk();
    }
    while (!mLoaded) {
        try {
            wait(); //当没有加载完成,则进入等待状态
        } catch (InterruptedException unused) {
        }
    }
}

当xml文件没有加载到内存中时就等待。

根据我们的用法我们要存储就得先有Editor

public Editor edit() {
    synchronized (this) {
        awaitLoadedLocked(); 
    }
    return new EditorImpl(); //创建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对象
            mModified.put(key, value);
            return this;
        }
    }
    //移除数据
    public Editor remove(String key) {
        synchronized (this) {
            mModified.put(key, this);
            return this;
        }
    }

    //清空全部数据
    public Editor clear() {
        synchronized (this) {
            mClear = true;
            return this;
        }
    }
}

我们添加数据移除数据清空数据,仅仅只是操作mModified 这个在内存中的Map而已。

最后一步提交

EditorImpl.java

public boolean commit() {
    //将数据更新到内存
    MemoryCommitResult mcr = commitToMemory();
    //将内存数据同步到文件
    SharedPreferencesImpl.this.enqueueDiskWrite(mcr, null);
    try {
        //进入等待状态, 直到写入文件的操作完成
        mcr.writtenToDiskLatch.await();
    } catch (InterruptedException e) {
        return false;
    }
    //通知监听则, 并在主线程回调onSharedPreferenceChanged()方法
    notifyListeners(mcr);
    // 返回文件操作的结果数据
    return mcr.writeToDiskResult;
}

提交就比较有水平了我们一个个分析对应的方法:

private MemoryCommitResult commitToMemory() {
    MemoryCommitResult mcr = new MemoryCommitResult();
    synchronized (SharedPreferencesImpl.this) {
        if (mDiskWritesInFlight > 0) {
            mMap = new HashMap<String, Object>(mMap);
        }
        mcr.mapToWriteToDisk = mMap;
        mDiskWritesInFlight++;

        //是否有监听key改变的监听者
        boolean hasListeners = mListeners.size() > 0;
        if (hasListeners) {
            mcr.keysModified = new ArrayList<String>();
            mcr.listeners = new HashSet<OnSharedPreferenceChangeListener>(mListeners.keySet());
        }

        synchronized (this) {
            //当mClear为true, 则直接清空mMap
            if (mClear) {
                if (!mMap.isEmpty()) {
                    mcr.changesMade = true;
                    mMap.clear();
                }
                mClear = false;
            }

            for (Map.Entry<String, Object> e : mModified.entrySet()) {
                String k = e.getKey();
                Object v = e.getValue();
                //注意此处的this是个特殊值, 用于移除相应的key操作.
                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);
                }

                mcr.changesMade = true; // changesMade代表数据是否有改变
                if (hasListeners) {
                    mcr.keysModified.add(k); //记录发生改变的key
                }
            }
            mModified.clear(); //清空EditorImpl中的mModified数据
        }
    }
    return mcr;
}

我们上面如果设置清空,则真正清空的地方是在这里。我们把mModified对应的数据都拿出来,这个是添加数据,移除数据等操作的内存数据映射,然后在mMap这个是读入文件的内存数据映射。然后过滤包含的等操作,如果发生变化,则记录这个发生变化的key,因为已经将数据转移到了mMap中,所以此时清空mModified在内存中的数据。

哦对了,这里还有mDiskWritesInFlight++操作

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--;
                }
                //此时postWriteRunnable为null不执行该方法
                if (postWriteRunnable != null) {
                    postWriteRunnable.run();
                }
            }
        };

    final boolean isFromSyncCommit = (postWriteRunnable == null);

    if (isFromSyncCommit) { //commit方法会进入该分支
        boolean wasEmpty = false;
        synchronized (SharedPreferencesImpl.this) {
            //commitToMemory过程会加1,则wasEmpty=true
            wasEmpty = mDiskWritesInFlight == 1;
        }
        if (wasEmpty) {
            //跳转到上面
            writeToDiskRunnable.run();
            return;
        }
    }
    //不执行该方法
    QueuedWork.singleThreadExecutor().execute(writeToDiskRunnable);
}

首先映入眼帘的是我们的中国梦之队跳水运动员...额跑的有点偏,mDiskWritesInFlight--操作,在run里面。说明写成功了这个值就成0了。

这里wasEmpty = mDiskWritesInFlight == 1;由于上面操作此时为true,所以执行writeToDiskRunnable.run();

private void writeToFile(MemoryCommitResult mcr) {
    if (mFile.exists()) {
        if (!mcr.changesMade) { //没有key发生改变, 则直接返回
            mcr.setDiskWriteResult(true);
            return;
        }
        if (!mBackupFile.exists()) {
            //当备份文件不存在, 则把mFile重命名为备份文件
            if (!mFile.renameTo(mBackupFile)) {
                mcr.setDiskWriteResult(false);
                return;
            }
        } else {
            mFile.delete(); //否则,直接删除mFile
        }
    }

    try {
        FileOutputStream str = createFileOutputStream(mFile);
        if (str == null) {
            mcr.setDiskWriteResult(false);
            return;
        }
        //将mMap全部信息写入文件
        XmlUtils.writeMapXml(mcr.mapToWriteToDisk, str);
        FileUtils.sync(str);
        str.close();
        ContextImpl.setFilePermissionsFromMode(mFile.getPath(), mMode, 0);
        try {
            final StructStat stat = Os.stat(mFile.getPath());
            synchronized (this) {
                mStatTimestamp = stat.st_mtime;
                mStatSize = stat.st_size;
            }
        } catch (ErrnoException e) {
            ...
        }
        //写入成功, 则删除备份文件
        mBackupFile.delete();
        //返回写入成功, 唤醒等待线程
        mcr.setDiskWriteResult(true);
        return;
    } catch (XmlPullParserException e) {
        ...
    } catch (IOException e) {
        ...
    }
    //如果写入文件的操作失败, 则删除未成功写入的文件
    if (mFile.exists()) {
        if (!mFile.delete()) {
            ...
        }
    }
    //返回写入失败, 唤醒等待线程
    mcr.setDiskWriteResult(false);
}

mcr.changesMade这个只有在数据更新有变化的时候才会为true。

其次当备份文件不存在了,把mFile重命名成备份文件,所以写之前一定有一份备份文件,这也类似于我们写一句代码按下Ctrl+S哈哈,下来才是写的过程,将mMap的全部信息写入到文件按照XML格式。写成功了,删除备份,并且唤醒等待线程。

所以我们每一次commit都是将全部文件进行写入操作。所以耗时耗时耗时啊。


我们还有一种提交方式

image.png

那我们就看看这个操作:

public void apply() {
    //把数据更新到内存
    final MemoryCommitResult mcr = commitToMemory();
    final Runnable awaitCommit = new Runnable() {
            public void run() {
                try {
                    //进入等待状态
                    mcr.writtenToDiskLatch.await();
                } catch (InterruptedException ignored) {
                }
            }
        };

    //将awaitCommit添加到QueuedWork
    QueuedWork.add(awaitCommit);

    Runnable postWriteRunnable = new Runnable() {
            public void run() {
                awaitCommit.run();
                //从QueuedWork移除
                QueuedWork.remove(awaitCommit);
            }
        };

    SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);
    notifyListeners(mcr);
}
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);
    if (isFromSyncCommit) {
        ... //postWriteRunnable不为空
    }
    //将任务放入单线程的线程池来执行
    QueuedWork.singleThreadExecutor().execute(writeToDiskRunnable);
}

首先我们的postWriteRunnable参数不为null了,其次我们看到线程池了。

public class QueuedWork {

    private static final ConcurrentLinkedQueue<Runnable> sPendingWorkFinishers =
       new ConcurrentLinkedQueue<Runnable>();

    public static void add(Runnable finisher) {
        sPendingWorkFinishers.add(finisher);
    }

    public static void remove(Runnable finisher) {
        sPendingWorkFinishers.remove(finisher);
    }

    public static void waitToFinish() {
        Runnable toFinish;
        while ((toFinish = sPendingWorkFinishers.poll()) != null) {
            toFinish.run();
        }
    }

    public static boolean hasPendingWork() {
        return !sPendingWorkFinishers.isEmpty();
    }
}

所以区别就是,我们第二种方式用线程池来管理写的过程,这个过程首先会将awaitCommit放入QueuedWork队列当写执行完之后就会移除。

这个QueuedWork存在的意义是什么呢?具体这里我也不清楚,只是看网上的大虾,说用于在Stop Service, finish BroadcastReceiver过程用于 判定是否处理完所有的异步SP操作.

对比一下提交的两种方式:

apply commit
没有返回值 有返回值可判断是否写入成功
apply是将修改提交到内存再异步提交到磁盘文件 同步的提交到磁盘文件
只是原子更新到内存,后调用apply函数会直接覆盖前面内存数据 多并发的提交commit时,需等待正在处理的commit数据更新到磁盘文件后才会继续往下执行

从对比中我们可以看出来一些合理的方式:

  • 尽量不要在sp里面存储特别大的key/value, 有助于减少卡顿/anr
  • 尽量批量apply与commit,不要频繁
  • 不要使用MODE_MULTI_PROCESS模式
  • 因为key是文件,所以可以拆分成多个文件,减少同步锁的竞争
  • 不要连续多次edit(), 应该获取一次获取edit(),然后多次执行putxxx(), 减少内存波动; 经常看到大家喜欢封装方法, 结果就导致anr这种情况的出现.
  • 每次commit时会把全部的数据更新的文件, 所以整个文件是不应该过大的, 影响整体性能;
  • 不要一上来就执行getSharedPreferences().edit(), 应该分成两大步骤来做, 中间可以执行其他代码.

参考:辉辉大神的建议。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,542评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,596评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,021评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,682评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,792评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,985评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,107评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,845评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,299评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,612评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,747评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,441评论 4 333
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,072评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,828评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,069评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,545评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,658评论 2 350

推荐阅读更多精彩内容