Android存储之SharedPreferences源码解析

个人博客:haichenyi.com。感谢关注

<span id = "c1"></span>

1. 目录

<span id = "c2"></span>

2.简介

  从工作开始,Android存储数据最常见的应该就是SharePreference,但是,你真的用懂了吗?源码你看过吗?Google对sp的定位你知道吗?是不是所有数据都应该用sp来存储呢?

  为什么现在面试关于sp非常常见呢?不就是一个get,put键值对的东西吗?commit或者apply就提交存储了,这么简单的一个东西,有啥好问的?

  腾讯的mmkv,Google的dataSorce又是什么东西呢?

  先说说sp,Google对sp的定位是轻量级存储,轻量级是什么意思呢?数据量小,数据量大肯定不建议用,但是,往往很多程序员,不管三七二十一,全都是用sp做本地持久化,导致,或多或少的性能问题,当然,不可否认sp的设计也有它的弊端。

  sp 加锁多 xml解析 全量更新 速度慢 性能差 提交数据可能会ANR

<span id = "c3"></span>

3.getSharedPreferences会不会阻塞线程,为什么?

下面从用法一步一步来讲解sp的问题

SharedPreferences sp = getSharedPreferences("haichenyi", MODE_PRIVATE);

  上面这个是获取sp,第一个问题就来了,我们都知道获取sp这里是从磁盘读取数据,就是从文件中读取数据,我们一般从文件中读取数据都是要新开线程的,这里没有新开线程,会不会阻塞主线程,为什么?带着这个问题,我们往下走。

sp源码图1.png

  这里调用的是base的getSharedPreferences方法,我们看这个base的类型,会发现是Context类型的,Context我们都知道,是装饰者模式,它的实现类ContextImpl,我们到这个类里面区找上面的这个方法,如下图

sp源码图2.png

  这个里面逻辑并不复杂,就是一个name在 api19以下的空判断,然后就是file的空判断,用的ArrayMap存储,最后就是调用的重载方法,如下图

sp源码图3.png

  重点就是框起来的这里了,SharedPreferences的实现类SharedPreferencesImpl,这里也是装饰者模式(到处都是)。

SharedPreferencesImpl类.png

  看到这里就知道了,它的startLoadFromDisk方法,看名字就知道是从磁盘读取数据,这里有个synchronized锁,锁对象是mLock,这个mLock比较重要。里面有一个mLoaded的Boolean类型的值,它也比较重要。下面具体读数据的逻辑,loadFromDisk是新开线程读取的。

  所以,我们回到前面的问题会不会阻塞线程?虽然,它是同步返回的sp对象,但是,具体读数据的逻辑是新开线程的,所以,不会阻塞线程。

<span id = "c4"></span>

4.get操作,为什么有时候会卡顿?

  会卡顿吗?怎么没有碰到过呢?答案是肯定的,肯定会有卡顿的情况,这是为什么呢?我们来看看它的实现:

SharedPreferencesImpl类的get方法.png

  我们来看看这个getXXX这一系列的方法,里面都有同步锁,然后,都有一个awaitLoadedLocked方法,重点就是这个方法,看名字就知道:等待加载锁。什么意思呢?我们看一下具体实现

private void awaitLoadedLocked() {
    ...
    //while循环,当mLoaded为false的时候进入while
    while (!mLoaded) {
        try {
            //wait方法
            mLock.wait();
        } catch (InterruptedException unused) {
        }
    }
    ...
}

  这里有一个while循环,判断的就是上面我们提到过的mLoaded,然后,里面就是我们上面也提到过的mLock对象,mLock.wait()方法。

  当代码执行到这里的时候,如果mLoaded是false,就会进入while循环,然后,会调用mLoack的wait方法,进入等待状态。

  那,这个mLoaded的值又是在哪里修改的呢?mLock对象是时候唤醒呢?

  我们回到上面说的,新开线程执行loadFromDisk方法

private void loadFromDisk() {
    ...
    //开始从文件读取数据
    Map<String, Object> map = null;
    StructStat stat = null;
    Throwable thrown = null;
    try {
        stat = Os.stat(mFile.getPath());
        if (mFile.canRead()) {
            BufferedInputStream str = null;
            try {
                str = new BufferedInputStream(
                            new FileInputStream(mFile), 16 * 1024);
                //把数据赋值给map
                map = (Map<String, Object>) XmlUtils.readMapXml(str);
            } catch (Exception e) {
                Log.w(TAG, "Cannot read " + mFile.getAbsolutePath(), e);
            } finally {
                IoUtils.closeQuietly(str);
            }
        }
    } catch (ErrnoException e) {
        // An errno exception means the stat failed. Treat as empty/non-existing by
        // ignoring.
    } catch (Throwable t) {
        thrown = t;
    }

    synchronized (mLock) {
        //修改mLoaded的值
        mLoaded = true;
        mThrowable = thrown;

        // It's important that we always signal waiters, even if we'll make
        // them fail with an exception. The try-finally is pretty wide, but
        // better safe than sorry.
        try {
            if (thrown == null) {
                if (map != null) {
                    //把刚才读取的数据赋值给全局变量mMap
                    mMap = map;
                    mStatTimestamp = stat.st_mtim;
                    mStatSize = stat.st_size;
                } else {
                    mMap = new HashMap<>();
                }
            }
            // In case of a thrown exception, we retain the old map. That allows
            // any open editors to commit and store updates.
        } catch (Throwable t) {
            mThrowable = t;
        } finally {
            //唤醒mLock
            mLock.notifyAll();
        }
    }
}

  源码里面注释都写的比较清楚。mLoaded值的修改是在从磁盘中把数据读取完之后,然后,在finally里面调用notifyAll唤醒mLock。

  数据读完之后,修改boolean值,然后,在finally里面唤醒,finally是try-catch最后执行的。

  为什么会卡顿呢?我们平时用sp,是不是像下面这样用的:

//这里是直接返回对象,
//但是,它实际上数据量大的时候,是需要时间去从磁盘读数据的
SharedPreferences sp = getSharedPreferences("haichenyi", MODE_PRIVATE);
//当代码执行到这里的时候,我们虽然,拿到了sp对象,
//但是,数据可能没有读取完,上面说的boolean的值,还是false,
//就进入while循环,调用awit方法,进入等待状态
//然后,过一会从磁盘读取完数据,修改boolean值为true,在finally调用notifyAll唤醒mLock
//然后,唤醒了while循环,boolean的值这个时候是true,跳出while循环
//所以,这个卡顿的时间,就是从磁盘读取数据的时间
String s = sp.getString("xxx","aaa");

  上面代码的注释写的很清楚了吧?不用在做过多的解释了吧?

PS:它这个卡顿仅限于第一次,为什么呢?因为它从磁盘读出数据之后,把数据存到内存中了(也就是loadFromDisk方法中的mMap对象),我在那里加了注释,可以去看一下。读取数据都是从这个mMap对象返回的,也就是从内存中返回,这也是为什么从另一方面说,sp返回数据比较快的原因。它都是从内存中返回的,并不是每次都是从磁盘读数据,然后返回的。

<span id = "c5"></span>

5.commit和apply的区别

  先说结论:

类型 返回值 同步 ANR
commit 同步提交 可能会
apply 异步提交 可能会

  我们一个一个来慢慢说这个结论:

是否有返回值

  这个很简单吧?用过如果没注意,可能不知道,因为,我们平时也不用处理这些东西,我们来看一下源码就知道了。

//看到这个方法有返回值就知道了,boolean类型的返回值
public boolean commit() {
        ...
        //为什么说每次都是全量更新,就在这里,这个commitToMemory方法里面,
        //它每次都会把内存的map数据全部都更新好
        MemoryCommitResult mcr = commitToMemory();
        //然后通过这个enqueueDisk方法全部都重新写到硬盘上
        //第二个参数是null,它的注释也比较清楚
        SharedPreferencesImpl.this.enqueueDiskWrite(
            mcr, null /* sync write on this thread okay */);
        try {
            //然后,写入等待
            mcr.writtenToDiskLatch.await();
        } catch (InterruptedException e) {
            return false;
        } finally {
            ...
        }
        notifyListeners(mcr);
        //返回写入结果
        return mcr.writeToDiskResult;
    }

//这个方法没有返回值
public void apply() {
        ...
    }

  我们先说返回值的问题,上面这两个方法就是commit和apply的大致结构。所以,commit是有个boolean类型的返回值,apply是没有返回值的

是否有同步

  这个问题,上面commit的源码注释那里已经解释清楚了,它会有一个wait方法等待数据更新完,所以commit是同步的。

  我们再来看看apply方法,上面主要是说返回值的问题,所以,没有把apply的源码贴出来,下面我们来看看apply的源码

public void apply() {
    final long startTime = System.currentTimeMillis();
    final MemoryCommitResult mcr = commitToMemory();
    //重点就是这里,它这里是新建了一个runnable来执行await方法,
    //上面commit方法是直接在当前线程执行的
    final Runnable awaitCommit = new Runnable() {
        @Override
        public void run() {
            try {
                mcr.writtenToDiskLatch.await();
            } catch (InterruptedException ignored) {
            }
            ...
        }
    };
    //然后调用了QueuedWork.addFinisher,把这个runnable传了进去
    //这个QueuedWork是什么呢?下面我贴出来了这个方法,addFinisher
    QueuedWork.addFinisher(awaitCommit);
    //上面其实就是把runnable加到了一个队列当中
    
    //又新开了一个runnable执行了上面runnable的run方法,
    //这是什么意思还要我做过多的解释吗?runnable.run()
    Runnable postWriteRunnable = new Runnable() {
        @Override
        public void run() {
            awaitCommit.run();
            QueuedWork.removeFinisher(awaitCommit);
        }
    };
    //回过头看看,commit的这个方法,第二个参数是null,上面我特意做了解释的。
    //看这里,第二个参数,传的runnable
    //就是在runnable的线程里面执行
    SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);

    // Okay to notify the listeners before it's hit disk
    // because the listeners should always get the same
    // SharedPreferences instance back, which has the
    // changes reflected in memory.
    notifyListeners(mcr);
}


@UnsupportedAppUsage
public static void addFinisher(Runnable finisher) {
    synchronized (sLock) {
        //等于,就是把我们的runnable给add到了sFinishers,这个sFinishers又是什么呢?
        sFinishers.add(finisher);
    }
}
//没错,它其实就是一个队列,再回到上面
private static final LinkedList<Runnable> sFinishers = new LinkedList<>();

  我这里的源码解释的是比较清楚的了吧?所以,apply是异步的。

是否会ANR

  说到ANR,我们先来聊一下ANR,什么是ANR?多长时间会造成ANR提示?

  ANR:Android系统是消息驱动的,每个消息都需要在一定的时间范围内处理完,如果在规定的时间内未处理完,系统就会提示ANR异常

  那么,这个时间是怎么限定的呢?

类型 Service Broadcast Activity ContentProvider
前台(单位:s) 20 10 5 10
后台(单位:s) 20*10 60 - -

  前两个都好理解,这个ANR就很难理解了,commit是同步提交,可能会ANR我可以理解,sp每次都是全量更新,commit又是在主线程提交,cpu峰值的时候,数据量大,可能就会卡住主线程,造成ANR。

  辣么,问题就来了,commit同步卡能会卡住主线程,造成ANR,apply是异步的呀?为什么apply也会造成ANR呢?是结论错了吗?

  龙儿筝给我说了一个结论:Google认为SP是数据持久化,数据的安全性要大于页面的行为,当页面离开时,会等待持久化完成。

  这个结论怎么理解呢?有一个场景,当你调用apply去更新数据,然后,紧接着下一行代码就执行了finish方法,或者跳转了下一个界面,此时会怎么样呢?

  我们先来简单聊一聊activity启动相关的问题

  简单的从AMS开始说两句吧,当zygote进程启动了系统主要的服务之后,里面就有一个AMS(ActivityManagerService),AMS通过Binder创建了ActivityThread,然后通过handler开始发消息,创建Activity,我们activity的生命周期的回调,都说是系统调用了,系统是怎么调用的呢?都是通过handler发送一个一个的消息,然后去执行对应的回调方法。

  然后,再是,我们常说的onCreate—onStart(可见,不能与用户交互)—onResume(可见,与用户交互)—activity running—onPause—onStop—onDestroy

  除了activity running,其余的每一种生命周期都对应着一个handler的message。handler的机制,在我的博文深入理解handler机制里面讲的很清楚了

  辣么,从Activity A跳转Activity B,两个activity的生命周期是怎么变化的呢?

  当A中启动B,生命周期的流程是这样的:

  1. A的onPause
  2. B的onCreate-onStart-onResume
  3. A的onStop

  扯了这么远,回到上面说的apply的问题,为什么它会ANR线程呢?

  问题就出在这个A的onStop方法里面,我们来看看这个onStop放的源码:

activity的onStop方法.png

  如上图,我们通过handler发送消息,最终定位到执行的是这一块的代码,看到我框起来的代码了吗?

// Make sure any pending writes are now committed.
//看这个注释写的,确认没有正在写入的操作,就是这个waitToFinish方法,
//这个if判断,可以看实现,只要taget小于11判断就是true,我们现在都是大于11了,所以,这里是false
//然后,前面取了一个非,所以,我们现在这里能进去
//如果要是小于11呢?看activity的onPause方法里面,你会有新发现
if (!r.isPreHoneycomb()) {
    QueuedWork.waitToFinish();
}

  上面的注释很清楚了,这个waitToFinish方法里面的逻辑是什么样的呢?大致贴一下主要的逻辑:

public static void waitToFinish() {
    ...
    try {
        //while循环
        while (true) {
            Runnable finisher;
            synchronized (sLock) {
              //从sFinishers队列中取出runnable
                finisher = sFinishers.poll();
            }
            if (finisher == null) {
                //空(队列中没有数据了)就中止while循环
                break;
            }
            //执行runnable
            finisher.run();
        }
    } finally {
        sCanDelay = true;
    }
    ...
}

  我们想想之前apply方法里面,是不是新建的runnable,然后,把这个runnable是不是添加到sFinishers队列中去了?

  所以,整个逻辑就是

  1. 你在Activity A中apply之后(假设数据量大),立马跳转Activity B
  2. 当Activity B正常启动完,走完它自己的onResume方法,执行了Activity A的onStop方法
  3. 执行的时候发现,QueueWork里面还有提交正在执行,就会等待它执行完,就会阻塞当前线程

<span id = "c6"></span>

6.sp写入异常会怎么处理?

  在代码执行getSharedPreferences的时候,你会发现,磁盘上会多一个同名文件,扩展名不一样,扩展名是 点bak,这个文件是备份文件。写入成功就会删除这个文件,写入失败,下次就会用这个备份文件。

  我们再来找一下源码你会发现:

static File makeBackupFile(File prefsFile) {
    return new File(prefsFile.getPath() + ".bak");
}

  这个方法在哪调用的呢?在SharedPreferencesImpl的构造方法里面

SharedPreferencesImpl(File file, int mode) {
    mFile = file;
    //就是这个变量
    mBackupFile = makeBackupFile(file);
    ...
}

  我们再来看看这个写入方法,你会发现,在写入完成之后,会把这个文件删除

private void writeToFile(MemoryCommitResult mcr, boolean isFromSyncCommit) {
    ...
    // Attempt to write the file, delete the backup and return true as atomically as
    // possible.  If any exception occurs, delete the new file; next time we will restore
    // from the backup.
    try {
        FileOutputStream str = createFileOutputStream(mFile);
        if (DEBUG) {
            outputStreamCreateTime = System.currentTimeMillis();
        }
        if (str == null) {
            mcr.setDiskWriteResult(false, false);
            return;
        }
        XmlUtils.writeMapXml(mcr.mapToWriteToDisk, str);
        writeTime = System.currentTimeMillis();
        FileUtils.sync(str);
        fsyncTime = System.currentTimeMillis();
        str.close();
        ContextImpl.setFilePermissionsFromMode(mFile.getPath(), mMode, 0);
        if (DEBUG) {
            setPermTime = System.currentTimeMillis();
        }
        try {
            final StructStat stat = Os.stat(mFile.getPath());
            synchronized (mLock) {
                mStatTimestamp = stat.st_mtim;
                mStatSize = stat.st_size;
            }
        } catch (ErrnoException e) {
            // Do nothing
        }
        if (DEBUG) {
            fstatTime = System.currentTimeMillis();
        }
        // Writing was successful, delete the backup file if there is one.
        //就是这里,写入操作在前面,如果前面没有异常,那就正常写完了,自然就会走到这里
        //如果前面异常,自然就不会走到这里
        mBackupFile.delete();
    ...
}

<span id = "c7"></span>

7.优化sp操作

  1. sp获取的时候,会新开线程去读取数据,我们可以提前读取sp,不要每次到用的时候,再去读取。
  2. sp每次提交都是全量更新,不要每次修改了就直接提交,可以多修改几次之后,一起提交
  3. sp是轻量级存储,数据量大,不要存sp
  4. sp的内容不要太多,sp内容过多,读取的时候会非常慢,可以适当的拆分sp的内容,分多个sp存储,但是也不要太多

  但是,这些优化操作,都是基于sp原有的性能做的优化操作,不能从根本上解决问题。

  sp的源码就说完了,为什么Google更新了十多版本之后的sp,最终还是放弃维护了?而是新出推出了Jetpack的组件之一DataStore这是人性的扭曲还是道德的沦丧,我们且听下回分解。

  下一篇来聊聊MMKV,它是怎么从根本上解决问题的。

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

推荐阅读更多精彩内容