暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

SharedPreference ANR 防治

周末随心分享 2021-10-30
1092

背景

在 Android 开发中比较常见的数据存储方式不外于数据库、ContentProvider、SharedPreferenece,从使用复杂度来说,一般情况下我们都会选择SharedPreferenece,它使用方法简单,适合用于记录一些简单字段,比如状态、settings、用户行为信息等。


不过一个奇怪的现象是现在很多开发者宁愿用数据库存储数据也不用SharedPreferenece了,这是为啥呢?主要是因为他们发现了SharedPreferenece的一个缺点,即容易导致ANR,接下来就进入我们的分析环节~


ANR 分析

先来说一下ANR现象吧,跟过线上问题的小伙伴应该都见过一个崩溃信息:

从上面图中可以看出信息很明确,在执行waitToFinish过程中等待超时导致了ANR,既然要分析它,我们首先要了解它的实现原理


1、SharedPreference 存储过程分析

说起存储,大家第一个想到 apply 和 commit 方法,两者区别这里就不多说了,我们直接看它们走到的共同逻辑:

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);


// Typical #commit() path with fewer allocations, doing a write on
// the current thread.
if (isFromSyncCommit) {
boolean wasEmpty = false;
synchronized (SharedPreferencesImpl.this) {
wasEmpty = mDiskWritesInFlight == 1;
}
if (wasEmpty) {
writeToDiskRunnable.run();
return;
}
}


QueuedWork.singleThreadExecutor().execute(writeToDiskRunnable);
}


开始划重点了,上面代码是 Android 8.0之前的代码,我们可以看到最后一行走到了 QueuedWork,这就是核心逻辑,整个过程主要是:

  • 判断 apply 还是 commit ,决定是立刻执行任务还是异步执行,这里我们主要关注异步执行

  • 异步执行会将任务抛到 QueuedWork ,这里在 Android 8.0前后实现逻辑有点不一样,等下会具体分析

  • 抛完任务后还会加个锁等待任务完成,在四大组件一些生命周期触发时,会等待这些任务完成,比如常见的 Activity -> onStop,Service -> onStartCommand, onStop等

  • 我们见到的 ANR 就是等待锁释放超时导致的


2、QueuedWork 原理分析

QueuedWork 在8.0前后实现逻辑不太一样,我们分别来看一下:

1)8.0之前 waitToFinish:

 /**
* Finishes or waits for async operations to complete.
* (e.g. SharedPreferences$Editor#startCommit writes)
* <p>
* Is called from the Activity base class's onPause(), after
* BroadcastReceiver's onReceive, after Service command handling,
* etc. (so async work is never lost)
*/
public static void waitToFinish() {
Runnable toFinish;
while ((toFinish = sPendingWorkFinishers.poll()) != null) {
toFinish.run();
}
    }

可以看到逻辑还是比较简单的,就是将每个任务加的锁运行起来,只有等任务执行完毕才会释放锁,这样做的目的就是保证所有数据都能保存下来,因为不确定什么时候应用会发生异常。


2)8.0开始的 waitToFinish

  public static void waitToFinish() {
long startTime = System.currentTimeMillis();
boolean hadMessages = false;


Handler handler = getHandler();


synchronized (sLock) {
if (handler.hasMessages(QueuedWorkHandler.MSG_RUN)) {
// Delayed work will be processed at processPendingWork() below
handler.removeMessages(QueuedWorkHandler.MSG_RUN);


if (DEBUG) {
hadMessages = true;
Log.d(LOG_TAG, "waiting");
}
}


// We should not delay any work as this might delay the finishers
sCanDelay = false;
}


StrictMode.ThreadPolicy oldPolicy = StrictMode.allowThreadDiskWrites();
try {
processPendingWork();
} finally {
StrictMode.setThreadPolicy(oldPolicy);
}


try {
while (true) {
                Runnable finisher;
synchronized (sLock) {
finisher = sFinishers.poll();
                }
if (finisher == null) {
break;
                }
finisher.run();
}
} finally {
sCanDelay = true;
        }
    }

可以看到复杂了一点点。。,不过看过之后会发现这是对 SharedPreference ANR 的一个优化,主要区别是:

  • 改变原来被动等待线程调度执行写入的方式,改为主动去调用,涉及主要方法是SharedPreferencesImpl.waitToFinish

  • 增加版本号控制的逻辑,原来是所有的提交都会执行写入磁盘一遍,现在是只执行最后、最新的提交写入磁盘,涉及的主要方法是:

    SharedPreferencesImpl.writeToFile,下面是判断是否最新一次提交代码:

      // Only need to write if the disk state is older than this commit
    if (mDiskStateGeneration < mcr.memoryStateGeneration) {
    if (isFromSyncCommit) {
    needsWrite = true;
    } else {
    synchronized (mLock) {
    // No need to persist intermediate states. Just wait for the latest state to
    // be persisted.
    if (mCurrentMemoryStateGeneration == mcr.memoryStateGeneration) {
    needsWrite = true;
    }
    }
    }
    }



ANR 防治

经过上面对 ANR 的分析,相信大家对其过程有了大致的了解,既然知道了原因,那么如何来防治呢?有如下两个思路:

  • 既然是 waitToFinish 导致的 ANR,那么就针对这个方法进行处理

  • 优化业务逻辑,找出导致 ANR 的相关逻辑进行优化


下面我们分别针对这两种方式进行设计


1、waitToFinish 处理

通过之前的分析我们可以发现这个方法在8.0前后实现逻辑不一样,但是有一个共同点就是都卡在等待锁上面,那我们是不是可以修改这个方法逻辑让它跳过等待过程,直接结束?答案是可以的,先来看一段代码:

public static boolean hookPendingWork(Object newFieldValue) {
try {
Class<?> queueWork = Class.forName("android.app.QueuedWork");
Field field = queueWork.getDeclaredField("sPendingWorkFinishers");
if (field != null) {
try {
int accessFlags = field.getModifiers();
if (Modifier.isFinal(accessFlags)) {
Field modifiersField = Field.class.getDeclaredField("accessFlags");
modifiersField.setAccessible(true);
modifiersField.setInt(field, field.getModifiers() & ~Modifier.FINAL);
}
if (!field.isAccessible()) {
field.setAccessible(true);
}
field.set(null, newFieldValue);
return true;
} catch (Exception e) {
ExceptionPrinter.printStackTrace(e);
}
}
} catch (ClassNotFoundException e) {
e.printStackTrace();
} catch (NoSuchFieldException e) {
e.printStackTrace();
}
return false;
}

上面是针对8.0之前的一段 Hook 逻辑,通过之前分析我们发现等待锁任务主要存放到sPendingWorkFinishers这个静态变量中,那么我们就可以 hook 这个变量注入我们自己的代理,如下:

if (Build.VERSION.SDK_INT >= 26) {
SpHookUtil.hookQueueWork(object : LinkedList<Runnable>() {
override fun poll(): Runnable? {
            Log.d(TAG, "start waitToFinish above android O, size:${super.size}")
startReport()
return null
}
})
} else {
SpHookUtil.hookPendingWork(object : ConcurrentLinkedQueue<Runnable>() {
override fun poll(): Runnable? {
            Log.d(TAG, "start waitToFinish below android O, size:${super.size}")
startReport()
            return null
}
})
}

通过改写 poll 方法的返回值为 null 可以让 waitToFinish 直接结束,不过这种方式仍有一些问题:

  • 治标不治本,虽然强制结束 waitToFinish,但是业务层保存的数据可能会丢失

  • 8.0系统开始 waitToFinish 会先执行

     processPendingWork 再执行等待锁,这种方式就效果不明显了


2、监控业务逻辑,寻找归因

通过上面第一种方式的介绍我们发现只是临时解决办法,要想根本解决这个问题还是得找到业务层滥用 SharedPreference 情况,接下来就来介绍如何监控业务逻辑

1)监控 SharedPreference key 值变化,通过分析 API 我们发现了系统提供了监控 key 变化的回调,如下:

 sp.registerOnSharedPreferenceChangeListener(spListener)

针对上面接口我们开始设计监控方法:

private val spListener =
SharedPreferences.OnSharedPreferenceChangeListener { sharedPreferences, key ->
val spName = spMap[sharedPreferences]
            Log.d(TAG, "sharedPreferences:$spName content change, key:$key")
spName?.let {
val curNum = changeMap[spName] ?: 0
changeMap[spName] = curNum + 1
}
}

设计初衷主要下面几点:

  • 监控 SharedPreference 种类

  • 监控每个 SharedPerference key值变化数量

  • 在触发 waitToFinish 时进行上报

之所以监控上面提到的两个数据,主要考虑到8.0系统虽然优化过 SharedPreference 写入磁盘逻辑,即取最后一次提交,但是耗时仍然受到两个因素影响:

  • SharedPreference 数量

  • 写入 key 值数量

既然知道影响因素,肯定要针对这些因素进行统计分析,不过还面临一个问题:如何确定是哪个 SharedPreference 呢?毕竟我们到时候要找出业务层元凶肯定要知道具体的 sp_name!


针对上面最后一个问题,我们再次分析了源码,发现 

OnSharedPreferenceChangeListener 回调里的 sp 对象和我们通过 context.getSharedPreferences拿到的对象是同一个,原因就是系统会缓存 SharedPrefernece 对象来提高每次访问速度,有了这个作为基础,我们就能设计如下两个变量:

 private val spMap = hashMapOf<SharedPreferences, String>()
 private val changeMap = hashMapOf<String, Long>()

针对每次 key 值变化,我们会把当前 sp 对象 key 值变化数量加1,到时候上报的时候再取每个 sp 对应的 name 和 key_number 就行了


3、埋点设计,数据分析

经过上面监控体系设计,我们已经能够统计到业务层对 SharedPreference 使用情况,接下来就是如何利用这些数据找出元凶了,因此我们设计如下埋点:

  private fun startReport() {
if (changeMap.size > 0) {
val map = hashMapOf<String, Any>()
val data = hashMapOf<String, String>()
for (spName in changeMap.keys) {
data[spName] = changeMap[spName].toString()
}
map["device_type"] = Build.MODEL
map["sdk_version"] = Build.VERSION.SDK_INT
map["device_id"] = ReportManager.getDeviceInfo().getServerDeviceId().toString()
map["time"] = System.currentTimeMillis()
map["data"] = data
            ReportManager.onEvent("sp_monitor", map)
changeMap.clear()
}
}

按照上面埋点设计我们到时候会看到如下排列的数据:


typesdkid
time
data
1
Mi 10
31
324
435
{"..."}
2
Mi 10
31
324
324
{"..."}
3
Mix 10
30
452
234
{"..."}

根据上面数据我们就能分析出:

  • 每种机型、每种系统版本下、每台设备在不同时间段操作 SharedPreference 写入数据情况

  • 通过分析 data 就能了解到 SharedPreference 种类、key 数量等数据,这样就能根据 sp_name 找出业务层相关逻辑进行优化



总结

这篇文章简单介绍了 SharedPrefernece 导致 ANR 的原因以及设计了一套监控体系来找出具体业务归因,也提供了对 QueuedWork hook 相关变量进行临时解决,当然如果大家有更好的方式欢迎探讨^_^


最后还有一种方式没提,那就是直接使用数据库或者其他第三方库替换 SharedPreference,不过这样做就会使数据存储变得复杂一些,如果只是简单存储一些状态就显得有些浪费,而且根本原因还是业务层调用不合理导致的 IO 堵塞,不管使用什么方式,业务优化还是有必要的,所以监控才是灵魂^_^


好了,今天的分享就到这了。下次继续~

文章转载自周末随心分享,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论