Android SharedPreferences 全面分析

我们经常用SharedPreferences用来存储一些比较小的键值对集合,适合保存应用的配置参数, 我们将会带着以下几个问题来分析SharedPreferences的源码实现:

  • 数据是如何保存到磁盘的
  • commit() 和apply()的区别
  • 为什么会造成ANR
  • SharedPreferences有哪些缺点

源码分析

本文参照Android-26的源码,并不介绍SharedPreferences的基础使用,而是从源码角度来分析它的原理

获取SharedPreferences

我们通过以下方法来获取SharedPreferences实例

  1. context.getSharedPreferences
  2. 在Activity中getSharedPreferences
  3. PreferenceManager.getDefaultSharedPreferences
    这三种方法最终都会调用到 ContextImpl.getSharedPreferences
  @Override
    public SharedPreferences getSharedPreferences(String name, int mode) {
        //SharedPreferences对应的xml文件,数据保存在其中
        File file;
        synchronized (ContextImpl.class) {
            ...//省略
            file = mSharedPrefsPaths.get(name);
            if (file == null) { 
                //如果没有该name命名的文件,则新建一个并放入缓存
                file = getSharedPreferencesPath(name);
                mSharedPrefsPaths.put(name, file);
            }
        }
        return getSharedPreferences(file, mode);
    }

 @Override
    public SharedPreferences getSharedPreferences(File file, int mode) {
        ...//省略
        SharedPreferencesImpl sp;
        synchronized (ContextImpl.class) {
            final ArrayMap<File, SharedPreferencesImpl> cache = getSharedPreferencesCacheLocked();
            sp = cache.get(file);
            if (sp == null) {
                sp = new SharedPreferencesImpl(file, mode);
                cache.put(file, sp);
                return sp;
            }
        }
       //mode设置为多进程模式时会检测SP文件最后修改的时间和大小,如果文件被其他进程改变时,则会重新加载
        if ((mode & Context.MODE_MULTI_PROCESS) != 0 ||
            getApplicationInfo().targetSdkVersion < android.os.Build.VERSION_CODES.HONEYCOMB) {
            sp.startReloadIfChangedUnexpectedly();
        }
        return sp;
    }

可以看到最终返回的是一个SharedPreferencesImpl对象,首先getSharedPreferencesCacheLocked()从一个静态的ArrayMap中获取SharedPreferences 缓存,如果有缓存中有SharedPreferencesImpl对象则返回,没有的话则创建一个并存入缓存中,同时synchronized 包裹可以保证多线程同步,由此可见无论getSharedPreferences调用多少次,返回的都是一个SharedPreferencesImpl对象

SharedPreferencesImpl

SharedPreferencesImpl 实现了SharedPreferences这个接口,是我们通过getSharedPreferences得到的实体对象,所有存取操作都由该类来实现

SharedPreferencesImpl(File file, int mode) {
        mFile = file;
        mBackupFile = makeBackupFile(file); //备份文件
        mMode = mode;
        mLoaded = false;
        mMap = null; 
        startLoadFromDisk();
    }

mBackupFile 代表发生异常时, 可通过备份文件来恢复数据.
mLoaded 表示是否已经将mFile中的数据都读取到mMap 中
mMap 用于在内存中缓存我们的配置数据, 也就是 getXxx 数据的来源
startLoadFromDisk()从方法名即可看出是从硬盘中读取数据,看一下源码

 private void startLoadFromDisk() {
        synchronized (mLock) {
            mLoaded = false;
        }
        new Thread("SharedPreferencesImpl-load") {
            public void run() {
                loadFromDisk();
            }
        }.start();
    }
 private void loadFromDisk() {
        synchronized (mLock) {
            if (mLoaded) {
                return;
            }
            if (mBackupFile.exists()) {
                mFile.delete();
                mBackupFile.renameTo(mFile);
            }
        }
        //...省略
        Map map = null;
        BufferedInputStream    str = new BufferedInputStream(new FileInputStream(mFile), 16*1024);
        map = XmlUtils.readMapXml(str);
       //...省略
        synchronized (mLock) {
            mLoaded = true;
            if (map != null) {
                mMap = map;
            } else {
                mMap = new HashMap<>();
            }
            mLock.notifyAll();
        }
    }

开启一个子线程来从硬盘读取数据,如果备份文件存在则直接使用灾备文件回滚,使用XmlUtils把文件所有的数据读取到内存中的mMap中,mLoaded = true 标志SharedPreferencesImpl已经将数据读取完成,notifyAll()唤醒getXXX系列方法等待状态的线程,由于已经将数据中磁盘读取到内存中,此时调用getXXX系列方法就可以获取值了

getString分析

 public String getString(String key, @Nullable String defValue) {
        synchronized (mLock) {
            awaitLoadedLocked();
            String v = (String)mMap.get(key);
            return v != null ? v : defValue;
        }
    }

synchronized 关键字保证了线程安全,然后直接从mMap中获取对应的键值对就可以了,当我们调用getSharedPreferences 之后马上调用getString方法有可能SharedPreferencesImpl在子线程中还没有将文件中的数据读取完,此时mMap 还没有被赋值,所以awaitLoadedLocked()将会阻塞当前线程,直到读取完毕

private void awaitLoadedLocked() {
        while (!mLoaded) {
            try {
                mLock.wait();
            } catch (InterruptedException unused) {
            }
        }
    }

mLoaded为false表示尚未读取完成,其他的getXXX系列方法和getString如出一辙,都是先等待文件读取完毕,然后从mMap中获取相应的value

数据保存

我们通过getSharedPreferences().edit()来put各种值,看一下.edit()获取的是一个什么对象

public Editor edit() {
        synchronized (mLock) {
            awaitLoadedLocked();
        }
        return new EditorImpl();
    }

保证磁盘读取完毕后,返回了一个新的EditorImpl对象

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

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

     public Editor remove(String key) {
            synchronized (mLock) {
                mModified.put(key, this);
                return this;
            }
        }
      ...//省略
}

EditorImpl 中有两个重要属性,mModified 用来暂时保存put方法提供的值,当调用commit()或者apply()才会将mModified中的数据存储到mMap,进而保存到磁盘中,mClear标志是否要清空文件中所有数据。接下来需要注意看remove()方法,调用getSharedPreferences().edit().remove()时是将当前key的value置为this,删除数据时检测到value为this即可删除
总结:调用put()后,数据只是暂存到了EditorImpl 的mModified** 对象中,并没有回写到磁盘,调用commit()apply才会将数据写到磁盘中**

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;
            } 
            return mcr.writeToDiskResult;
        }

主要有三步

  • commitToMemorymModified 中的数据写到内存mMap
  • SharedPreferencesImpl.this.enqueueDiskWrite 将内存中mMap的数据回写到磁盘中
  • mcr.writtenToDiskLatch.await() 线程等待,直到回写磁盘完毕
  1. commitToMemory()
    我们逐个分析,首先分析commitToMemory()返回一个MemoryCommitResult对象,代表了提交到内存的返回结果
 private static class MemoryCommitResult {
           //...省略代码
        final Map<String, Object> mapToWriteToDisk;
        //此处初始换CountDownLatch 的计数器为1
        final CountDownLatch writtenToDiskLatch = new CountDownLatch(1);

        volatile boolean writeToDiskResult = false;
        boolean wasWritten = false;

        void setDiskWriteResult(boolean wasWritten, boolean result) {
            this.wasWritten = wasWritten;
            writeToDiskResult = result;
            writtenToDiskLatch.countDown();
        }
    }

其中关键有 writtenToDiskLatch 是一个 CountDownLatch 对象,它允许一个或多个线程一直等待,直到回写磁盘线程的操作执行完后再执行,mapToWriteToDisk引用内存中的mMapwriteToDiskResult代表回写磁盘是否成功,接下来继续分析commitToMemory()

private MemoryCommitResult commitToMemory() {
            Map<String, Object> mapToWriteToDisk;
            synchronized (SharedPreferencesImpl.this.mLock) {
                mapToWriteToDisk = mMap;
                //需要写入磁盘次数+1
                mDiskWritesInFlight++;
                synchronized (mLock) {
                    if (mClear) {
                        //...省略代码,
                        //如果调用了edit().clear()则清空内存中的数据
                        mMap.clear();
                        mClear = false;
                    }
                    
                    //将putXXX()的数据提交到内存中
                    for (Map.Entry<String, Object> e : mModified.entrySet()) {
                        String k = e.getKey();
                        Object v = e.getValue();
                        //value为this则删除,与之前的getSharePreferences().edit().remove()对应
                        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);
                        }
                    }
                    mModified.clear();
                }
            }
            return new MemoryCommitResult(memoryStateGeneration, keysModified, listeners,
                    mapToWriteToDisk);
        }

mDiskWritesInFlight代表写入磁盘这个操作的次数,也是由synchronized 保证线程安全,首先判断是否需要clear,如果需要这把mMap中的数据清空,需要注意此时mModified中数据还没有复制到mMap中,所以以下代码并不能将"foo" clear掉

sharedPreferences.edit()
        .putBoolean("foo";, true)        // foo 无法被 clear 掉
        .clear()
        .putBoolean("bar", true)
        .commit()

然后通过for循环将put到mModified中的数据添加到mMap中,mModified.clear()之后返回MemoryCommitResult
总结commitToMemory()只是将数据都写入到内存中

  1. SharedPreferencesImpl.this.enqueueDiskWrite
private void enqueueDiskWrite(final MemoryCommitResult mcr,
                                  final Runnable postWriteRunnable) {
        final boolean isFromSyncCommit = (postWriteRunnable == null);

        final Runnable writeToDiskRunnable = new Runnable() {
                public void run() {
                    synchronized (mWritingToDiskLock) {
                        writeToFile(mcr, isFromSyncCommit);
                    }
                    synchronized (mLock) {
                        mDiskWritesInFlight--;
                    }
                    if (postWriteRunnable != null) {
                        postWriteRunnable.run();
                    }
                }
            };
        if (isFromSyncCommit) {
            boolean wasEmpty = false;
            synchronized (mLock) {
                wasEmpty = mDiskWritesInFlight == 1;
            }
            if (wasEmpty) {
                writeToDiskRunnable.run();
                return;
            }
        }
        //异步执行任务
        QueuedWork.queue(writeToDiskRunnable, !isFromSyncCommit);
    }

commit() 时postWriteRunnable参数为null,所以isFromSyncCommit == true,进入到if (isFromSyncCommit) 语句中,如果此时只有一个commit()操作,则直接在当前线程执行writeToFile()将内存中的数据回写到磁盘中,如果此时有多个commit()则,排队进入QueuedWork中等待执行,看一下writeToFile()的实现

private void writeToFile(MemoryCommitResult mcr, boolean isFromSyncCommit) {
        //...省略
        boolean fileExists = mFile.exists();
        boolean backupFileExists = mBackupFile.exists();
            if (!backupFileExists) {
                if (!mFile.renameTo(mBackupFile)) {
                    mcr.setDiskWriteResult(false, false);
                    return;
                }
            } else {
                mFile.delete();
            }
        }
        try {
            FileOutputStream str = createFileOutputStream(mFile);
            if (str == null) {
                mcr.setDiskWriteResult(false, false);
                return;
            }
            XmlUtils.writeMapXml(mcr.mapToWriteToDisk, str);
            str.close();
            mBackupFile.delete();
            mcr.setDiskWriteResult(true, true);
            return;
        } catch (Exception e) {
        }
        //如果写入操作出现异常,则将半成品删掉
        if (mFile.exists()) {
            if (!mFile.delete()) {
                Log.e(TAG, "Couldn't clean up partially-written file " + mFile);
            }
        }
        mcr.setDiskWriteResult(false, false);
    }
  • 将之前的配置文件mFile备份为buckup文件,然后删除
  • mcr.mapToWriteToDisk即内存中数据,全部写入到新的mFile
  • 写入成功,删掉备份文件,如果写入失败则把半成品mFile删掉
  1. mcr.writtenToDiskLatch.await()
    CountDownLatch.await()会阻塞当前线程,直到CountDownLatch.countDown()使计数器值到达0时,它表示磁盘写入线程已经完成了任务,然后在锁上等待的线程就可以恢复执行任务。在writeToFile()中,写入完成之后会调用mcr.setDiskWriteResult()中的writtenToDiskLatch.countDown()
 void setDiskWriteResult(boolean wasWritten, boolean result) {
            this.wasWritten = wasWritten;
            writeToDiskResult = result;
            writtenToDiskLatch.countDown();
        }

writtenToDiskLatch初始时计数器为1,countDown()之后为0,此时磁盘已经回写完毕,commit()方法继续执行,返回结果
commit()总结

  • 流程是先写入内存写入磁盘
  • 写入磁盘完成之前调用线程会一直等待,直到内存和磁盘都已经同步完毕
  • 每次写入磁盘时都会从内存中将所有数据都全量写入,效率并不高

apply()

       public void apply() { 
            //第一步:提交到内存
            final MemoryCommitResult mcr = commitToMemory();
            final Runnable awaitCommit = new Runnable() {
                    public void run() {
                        try {
                            mcr.writtenToDiskLatch.await();
                        } catch (InterruptedException ignored) {
                        }

                        if (DEBUG && mcr.wasWritten) {
                            Log.d(TAG, mFile.getName() + ":" + mcr.memoryStateGeneration
                                    + " applied after " + (System.currentTimeMillis() - startTime)
                                    + " ms");
                        }
                    }
                };
            //第二步:确保异步磁盘写入完毕
            QueuedWork.addFinisher(awaitCommit);

            Runnable postWriteRunnable = new Runnable() {
                    public void run() {
                        awaitCommit.run();
                        QueuedWork.removeFinisher(awaitCommit);
                    }
                };
            // 第三步:写入磁盘
            SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);
        }
  • 第一步提交到内存和commit()是一样的
  • 第二步中将任务mcr.writtenToDiskLatch.await()提交到QueuedWork之中,该任务的作用是让线程等待,而释放的时机跟commit()一样(详细代码看上述commit()),但是QueuedWork.addFinisher()将线程等待的任务提交之后并没有立即运行,而是保存在了一个队列之中,当应用收到系统广播,或者被调用 onPause 等一些时机才会运行(详情查看QueuedWork源码,在ActivityThread中可以找到调用任务的方法waitToFinish())
  • 第三步同commit,不同点在于enqueueDiskWrite(mcr, postWriteRunnable)传递了Runnable,在异步线程中写入磁盘
    apply总结
  • 异步写入磁盘,没有等待结果,直接返回
  • 应用收到系统广播,或者被调用 onPause等时机,如果磁盘写入未完成则主线程会等待其完成
  • commit()写入过程一样,都是全量写入

SharedPreferences总结

通过上文对SharedPreferences分析,我们已经可以对开头的几个问题进行回答并总结了

  • 数据是如何保存到磁盘的
    答:通过putXXX系列方法将数据先保存到内存中,调用commit()或者apply(之后将所有数据全量写入磁盘文件中
  • commit() 和apply()的区别
    答:commit()线程同步写入,写入完成时才会返回,如果在主线程调用,写入过程比较费时可能会阻塞主线程
    apply异步线程写入,但是应用收到系统广播,或者被调用 onPause等时机,未完成写入任务时主线程会等待其完成
  • commit()和apply()相同点
    答:都是全量写入,如果SharedPreferences中数据量很多,则每次写入都会很慢
  • 为什么会造成ANR
    答:commit()和apply()都可能在成ANR,分析如上
  • SharedPreferences有哪些缺点
    答:1. 全量写入:commit() 还是 apply(),即使我们只改动其中一条数据,都会把整个数据写入到文件中
    2. 卡顿:commit() 还是 apply()都有可能造成ANR
    3. 跨进程不安全:MODE_MULTI_PROCESS已被谷歌标为Deprecated
    总之:系统提供的 SharedPreferences 的应用场景是用来存储一些简单、轻量的数据,例如配置文件等,不适合json、html等,并且每个SharedPreference不宜过大,考虑将频繁修改的配置项单独隔离
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,684评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,143评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,214评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,788评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,796评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,665评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,027评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,679评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,346评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,664评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,766评论 1 331
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,412评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,015评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,974评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,073评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,501评论 2 343