从源码分析为什么SharePreference会导致ANR

一、背景

  SharePreference是Android系统中的持久化存储工具,使用xml文件存放数据,适用于存储数据量较小的场景,使用时一次性将数据读取到内存中。作为一个常用的存储工具,在bugly上的ANR率却很高,通过分析发现除了SharePreference自身设计缺陷外,开发者不规范使用也会使得应用出现ANR的概率提高,常见的几种ANR场景堆栈如下

堆栈1
java.lang.Object.wait(Native Method)
android.app.SharedPreferencesImpl.awaitLoadedLocked(SharedPreferencesImpl.java:225)
android.app.SharedPreferencesImpl.contains(SharedPreferencesImpl.java:288)

堆栈2
ANR_EXCEPTION: ANR Input dispatching timed out (Waiting to send key event because the focused window has not finished processing all of the input events that were previously delivered to it. Outbound queue length: 0. Wait queue length: 1.)
android.app.QueuedWork.processPendingWork(QueuedWork.java:251)
android.app.QueuedWork.waitToFinish(QueuedWork.java:177)
android.app.ActivityThread.handleServiceArgs(ActivityThread.java:4784)
android.app.ActivityThread.access$3100(ActivityThread.java:308)

接下来将从源码分析导致SharePreference频繁出现ANR的原因。

二、源码分析

1、初始化SharedPreferences

    @Override
    public SharedPreferences getSharedPreferences(String name, int mode) {
        ...
        File file;
        synchronized (ContextImpl.class) {
            if (mSharedPrefsPaths == null) {
                mSharedPrefsPaths = new ArrayMap<>();
            }
            file = mSharedPrefsPaths.get(name);
            if (file == null) {
                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) {
                checkMode(mode);
                ...省略
                sp = new SharedPreferencesImpl(file, mode);
                cache.put(file, sp);
                return sp;
            }
        }
        ...
        return sp;
    }
    @UnsupportedAppUsage
    private void startLoadFromDisk() {
        synchronized (mLock) {
            mLoaded = false;
        }
        new Thread("SharedPreferencesImpl-load") {
            public void run() {
                loadFromDisk();
            }
        }.start();
    }

    private void loadFromDisk() {
        synchronized (mLock) {
            if (mLoaded) {
                 //如果已经加载到内存过,则不再加载直接return返回
                return;
            }
           ...
        }
        Map<String, Object> map = null;
          // 读取xml文件
        map = (Map<String, Object>) XmlUtils.readMapXml(str);
        synchronized (mLock) {
            mLoaded = true;
            mThrowable = thrown;
            try {
                if (thrown == null) {
                    if (map != null) {
                         //把读取到的数据同步到内存中
                        mMap = map;
                        ...
                    } else {
                        mMap = new HashMap<>();
                    }
                }
            } catch (Throwable t) {
                mThrowable = t;
            } finally {
                mLock.notifyAll();
            }
        }
    }

可以看到getSharedPreferences做了几件事情:

(1)首次调用的情况下会先去初始化一个变量mSharedPrefsPaths,这个变量是用来存储对应sp的文件,每次调用getSharedPreferences会去判断对应sp名字的文件是否已经存在,存在的话就不再新建文件,不存在则新建并put进mSharedPrefsPaths中。

(2)通过getSharedPreferencesCacheLocked()拿到对应包名的所有sp数据(ArrayMap<File, SharedPreferencesImpl>),如果缓存变量sSharedPrefsCache中没有,则新建一个放入缓存sSharedPrefsCache中。

(3)判断缓存中是否存在对应File的SharedPreferencesImpl,如果不存在SharedPreferencesImpl的构造方法就会调用startLoadFromDisk()异步从磁盘中加载对应的xml文件数据,生成一个SharedPreferencesImpl对象,最后再释放掉锁,唤醒其他等待的线程。

从这里我们可以看出,在这里容易出现ANR的是第3步,假如子线程正在加载对应xml且文件很大,此时主线程又刚好需要读取数据,将会出现主线程等子线程的情况。

</br>

2、读取数据getString

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

@GuardedBy("mLock")
private void awaitLoadedLocked() {
    if (!mLoaded) {
        BlockGuard.getThreadPolicy().onReadFromDisk();
    }
    while (!mLoaded) {
        try {
            mLock.wait();
        } catch (InterruptedException unused) {
        }
    }
    if (mThrowable != null) {
        throw new IllegalStateException(mThrowable);
    }
}

  无论是getXxx()或者是contains()方法,在获取值的时候,会先调用awaitLoadedLocked判断数据是否已经读取到了内存,如果还没有就去读取并等待锁释放,直到上文提到的子线程执行完loadFromDisk()方法后,将mLoaded置为true,并notifyAll所有等待的线程时,再从mMap缓存中取值返回。假如我们在调用getXXX()contains()方法时,此时由于loadFromDisk()读取的文件较大,导致主线程一直处于等待状态,就有可能出现一开始我们提到的ANR堆栈1的情况。

3、修改数据putString

SharedPreferences.Editor editor = mSharedPreferences.edit();
editor.putString(key1, value1);
editor.putString(key2, value2);
editor.putString(key3, value3);
editor.apply();//或commit()

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

  修改数据是调用EditorImpl(Editor的实现类)中的putXxx()方法,通过这些方法可以批量预处理数据,每次put数据后是存储在mModified(Map类型)进行修改,最后通过ommit()或者apply()将数据持久化到xml文件中。

</br>

4、写入数据commit&&apply

  提交数据时,可以用SharedPreferences.Editor的**commit()或者apply()方法,他们本质上的区别是:前者是在主线程中同步将数据写入文件,而后者则是在子线程中异步将数据写入文件。为了不影响UI线程,我们一般使用apply()来写入数据,仅当需要确认写入结果且sp文件不大时,才使用commit()。接下来我们通过源码看下这两个方法在实现上的区别,首先看下commit()。

@Override
public boolean commit() {
    long startTime = 0;
    ...
    // 比较数据是否发生改变并写入内存
    MemoryCommitResult mcr = commitToMemory();
    // 将内存中的数据写入文件
    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;
}

可以看到commit()中主要由5步组成:

(1)通过commitToMemory()将写操作数据同步刷新到内存中。

(2)利用SharedPreferencesImplenqueueDiskWrite()将内存中的数据写入文件。

(3)利用CountDownLatch.await()实现线程同步,等待数据写入完成。

(4)OnSharedPreferenceChangeListener监听回调。

(5)返回数据写入文件的结果。

</br>

接着看下apply()

@Override
public void apply() {
    final long startTime = System.currentTimeMillis();
      // 比较数据是否发生改变并写入内存
    final MemoryCommitResult mcr = commitToMemory();
    final Runnable awaitCommit = new Runnable() {
            @Override
            public void run() {
                try {
                     // 等待数据写入完成
                    mcr.writtenToDiskLatch.await();
                } catch (InterruptedException ignored) {
                }
            }
        };
    QueuedWork.addFinisher(awaitCommit);
    Runnable postWriteRunnable = new Runnable() {
            @Override
            public void run() {
                awaitCommit.run();
                QueuedWork.removeFinisher(awaitCommit);
            }
        };
       // 将内存中的数据写入文件
    SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);
    notifyListeners(mcr);
}

apply()的流程如下:

(1)通过commitToMemory()将写操作数据同步刷新到内存中。

(2)往QueuedWork中添加awaitCommit任务,该任务用于等待数据写入完成。

(3)当awaitCommit执行时,阻塞当前线程,直到postWriteRunnable执行完毕后,将awaitCommitQueuedWork 中移除。

(4)调用enqueueDiskWrite()将内存中的数据写入文件。

</br>

从上面分析可以看出,commit()和apply()方法都会用到**commitToMemory()和enqueueDiskWrite()方法,接下来我们分别看下它们具体做了什么,首先看下commitToMemory()。

private MemoryCommitResult commitToMemory() {
            long memoryStateGeneration;
            List<String> keysModified = null;
            Set<OnSharedPreferenceChangeListener> listeners = null;
            Map<String, Object> mapToWriteToDisk;
            synchronized (SharedPreferencesImpl.this.mLock) {
                if (mDiskWritesInFlight > 0) {
                    // 拷贝内存中的数据mMap
                    mMap = new HashMap<String, Object>(mMap);
                }
                mapToWriteToDisk = mMap;
                // 待写入磁盘标识+1
                mDiskWritesInFlight++;
                boolean hasListeners = mListeners.size() > 0;
                if (hasListeners) {
                    keysModified = new ArrayList<String>();
                    listeners = new HashSet<OnSharedPreferenceChangeListener>(mListeners.keySet());
                }
                synchronized (mEditorLock) {
                    boolean changesMade = false;
                    // mClear是在EditrImpl.clear()被调用时置为true,代表清除内存中的数据
                    if (mClear) {
                        if (!mapToWriteToDisk.isEmpty()) {
                            changesMade = true;
                            mapToWriteToDisk.clear();
                        }
                        mClear = false;
                    }
                    // mModified是putXxx()时候用来存储数据变化的变量,用它跟内存中的数据进行对比,将发生变化的数据同步到内存中
                    for (Map.Entry<String, Object> e : mModified.entrySet()) {
                        String k = e.getKey();
                        Object v = e.getValue();
                        if (v == this || v == null) {
                            if (!mapToWriteToDisk.containsKey(k)) {
                                continue;
                            }
                            // 值为空的时候移除该数据
                            mapToWriteToDisk.remove(k);
                        } else {
                            if (mapToWriteToDisk.containsKey(k)) {
                                Object existingValue = mapToWriteToDisk.get(k);
                                if (existingValue != null &amp;&amp; existingValue.equals(v)) {
                                    // 数据相等说明没有发生改变,则不作操作直接跳过
                                    continue;
                                }
                            }
                            // 内存中未包含该数据,则添加该数据到内存中
                            mapToWriteToDisk.put(k, v);
                        }
                        changesMade = true;
                        if (hasListeners) {
                            keysModified.add(k);
                        }
                    }
                    mModified.clear();
                    if (changesMade) {
                        mCurrentMemoryStateGeneration++;
                    }
                    memoryStateGeneration = mCurrentMemoryStateGeneration;
                }
            }
            // 构建MemoryCommitResult对象返回,传入的参数在后续写入文件时候会用到
            return new MemoryCommitResult(memoryStateGeneration, keysModified, listeners,
                    mapToWriteToDisk);
}

commitToMemory()主要做了以下几件事:

(1)拷贝内存数据赋值给mapToWriteToDisk变量,将写入磁盘标识mDiskWritesInFlight加1。

(2)判断mClear是否为true,是的话清空内存数据。

(3)将mModified中记录的写操作数据同步到mapToWriteToDisk中,mapToWriteToDisk代表最终要写入文件的数据集合。

(4)清空mModified数据,生成MemoryCommitResult对象返回。

</br>
看下enqueueDiskWrite()源码

private void enqueueDiskWrite(final MemoryCommitResult mcr,
                              final Runnable postWriteRunnable) {
    final boolean isFromSyncCommit = (postWriteRunnable == null);
    final Runnable writeToDiskRunnable = new Runnable() {
            @Override
            public void run() {
                synchronized (mWritingToDiskLock) {
                     //写入文件
                    writeToFile(mcr, isFromSyncCommit);
                }
                synchronized (mLock) {
                     //待写入磁盘标识+1
                    mDiskWritesInFlight--;
                }
                  // 如果是apply()调用,则postWriteRunnable会不为null,执行等待写入结束的postWriteRunnable
                if (postWriteRunnable != null) {
                    postWriteRunnable.run();
                }
            }
        };
    if (isFromSyncCommit) {
        boolean wasEmpty = false;
        synchronized (mLock) {
             // 待写入磁盘标识为1
            wasEmpty = mDiskWritesInFlight == 1;
        }
        if (wasEmpty) {
             //如果是commit()调用且待写入磁盘标识为1时直接执行写入文件的writeToDiskRunnable
            writeToDiskRunnable.run();
            return;
        }
    }
    //apply()调用则将任务放入QueuedWork队列
    QueuedWork.queue(writeToDiskRunnable, !isFromSyncCommit);
}

梳理下enqueueDiskWrite()流程:

(1)构建一个writeToDiskRunnable任务,用于执行writeToFile()写入数据。

(2)如果是commit()调用,则直接在主线程中写入文件。如果是apply()调用,执行等待写入结束的postWriteRunnable任务,并将writeToDiskRunnable加入QueuedWork队列,通过QueuedWork里的QueuedWorkHandler在异步线程中写入文件。

</br>

  看到这里可能大家都已经清楚了commit()是在主线程进行数据写入文件操作,操作不当会导致应用卡顿或者ANR,所以平时尽量用apply()。但是apply()真的就安全了吗?会不会也会有问题?答案是会的,虽然它是在非主线程写入文件,但是它是将写入任务放到QueuedWork中操作,而在ActivityThreadhandleStopActivity()handleServiceArgs()或者handlePauseActivity() 等方法的时候都会调用QueuedWork.waitToFinish()方法,在异步任务完成前,主线程会因此阻塞,假如此时QueuedWork中的任务很多又或者sp文件过大时,就有可能出现文章一开始提到的ANR堆栈2情况。

</br>

三、总结

1、出现ANR的原因

(1)调用getSharedPreferences()后又调用getXxx()或者contains()等方法,如果此时需要从文件读取数据到缓存,是异步去读取,且调用getXxx()或者contains()所在线程会等待读取完成,如果文件过大就会导致主线程等待时间边长,进而导致ANR。

(2)使用commit()提交数据时,由于sp文件过大,导致主线程阻塞导致ANR。

(3)由于ActivityThreadhandleStopActivity()等方法会等待QueuedWork任务执行完,如果用户频繁调用apply()或者sp文件较大时,会导致QueuedWork中等待处理的任务过多或者耗时很长,阻塞主线程,最终导致ANR。

2、如何避免

(1)非必要场景尽量避免使用commit(),要使用的话需确保数据量小。

(2)无论使用commit()还是apply()都不要存放过大的value,因为数据会存在内存中,内存使用过高会导致频繁GC,导致丢帧或者ANR。

(3)不要频繁调用apply()写入数据,尽量批量修改然后再提交,减少锁竞争和QueuedWork任务数量,降低内存消耗。

(4)提前初始化SharedPreferences,避免getXxx()时读取文件线程未结束,出现等待的情况。

(5)避免将所有key-value都放在同个文件中,频繁使用的key-value应该统一放到一个文件中,因为每次读写都是全量操作,文件过大会导致读取或者写入速度慢。

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

推荐阅读更多精彩内容