Android Handler(二) 同步屏障泄露检测

系列文章


上文解释了因为同步屏障泄露,导致的页面假死问题。那么有没有什么手段可以检测同步屏障泄露呢?

当然有的,我们可以参考APM中,检测页面卡顿、页面ANR的方法,使用轮询的方法,检测同步屏障的泄露。

效果如下:在02:34模拟同步屏障泄露,时间卡在了02:34。Gif后半段,检测到泄露后进行移除,计时器继续运行。

一、同步屏障

1.同步屏障消息的生成

同步屏障消息是一种特殊的同步消息,他的msg.target=null

先看正常的Handler消息,调用sendMessage后,最终会调用enqueueMessage,这里会强制将Messagetarget指向当前handler。最终再调用MessageQueue.enqueueMessage,将消息插入到消息队列中。

当调用postSyncBarrier,插入同步屏障时,这个方法来自MessageQueue,没有设置target相关逻辑。

这里可以关注一下,为了避免内存抖动,Handler的消息使用了对象池,即享元模式,进行对象的复用,大大减少了创建对象的过程。

我们都知道,Handler中,是依靠Looper.loopfor循环,让主线程永远运转。也是依靠这个for循环,我们才能实现跨线程通信,子线程可以给主线程发消息。

looperOnce方法中,当messageA执行完毕后,会调用messageA.recycleUnchecked

所以在postSyncBarrier中,使用Message.obtain获取到的是一个没有target的特殊Message,这也就是同步屏障。

系统没有开放插入同步屏障的方法。我们可以通过反射的方法,插入一个同步屏障,用来模拟同步屏障泄露。

kotlin 复制代码
/**
 * 通过反射插入同步屏障
 */
@SuppressLint("DiscouragedPrivateApi")
private fun insertSyncBarrier() {
    try {
        MainScope().launch {
            Toast.makeText(this, "插入同步屏障", Toast.LENGTH_SHORT).show()
        }
        // 获取当前线程的 Looper 和 MessageQueue
        val looper = Looper.getMainLooper()
        val messageQueueField: Field = Looper::class.java.getDeclaredField("mQueue")
        messageQueueField.isAccessible = true
        val messageQueue: Any? = messageQueueField.get(looper)

        // 通过反射调用 postSyncBarrier 方法
        val messageQueueClass: Class<*> = messageQueue!!.javaClass
        val postSyncBarrierMethod: Method = messageQueueClass.getDeclaredMethod("postSyncBarrier")
        postSyncBarrierMethod.isAccessible = true
        Log.d(TAG, "Barrier token: ${postSyncBarrierMethod.invoke(messageQueue)}")
    } catch (e: Exception) {
        e.printStackTrace()
    }
}

2.同步屏障消息的取出

MessageQueuenext方法中,首先进入的逻辑,就是判断当前消息是不是同步屏障,如果是同步屏障,则已知查找消息队列中的异步消息。

3.消息执行顺序

回顾一下上文内容,如果当前MessageQueue中的消息如上图所示,那么执行顺序就是:

1->4->2->3->6

因为同步屏障没有target,所以他本身没有要执行的任务,他只是一个标记,遇到他之后,就去查找队列中的异步消息。

二、同步屏障检测+移除

同步屏障消息的定义很明确,即msg.target为空的消息。那么我们可以通过反射方式,拿到消息队列中,msg.target=null的消息,并且判断msg.when是否存在时间过长,如果存在超过3秒(or 5秒),就可以初步判断,这个同步屏障泄露了。

当然,为了避免误判,我们可以再发送一个同步消息,一个异步消息到主线程,进行竞速。如果1秒后,异步消息处理完成了,但是同步消息还未处理,就更加可以印证,该同步屏障泄露了。

我们可以通过发射获取到该同步屏障的token,然后通过反射调用removeSyncBarrier移除该同步屏障。

具体代码如下:

kotlin 复制代码
class BarrierLeakChecker {
    private val TAG = "BarrierLeakChecker"

    // 使用协程进行重复检查的任务
    // limitedParallelism(1)将协程并发限制在1个,因为我们不需要结构化并发,排队做任务即可
    @OptIn(ExperimentalCoroutinesApi::class)
    private val barrierLeakCheckScope = CoroutineScope(Dispatchers.IO.limitedParallelism(1) + SupervisorJob())

    // 记录下正在执行的任务,方便取消
    // CoroutineScope.cancel会导致协程作用域进入取消状态,无法启动新任务,所以还是用Job.cancel
    private var job: Job? = null

    /**
     * 启动SyncBarrier泄漏检测
     * 通过协程每秒检查一次消息队列,发现泄漏的同步屏障会自动移除
     */
    fun startCheckBarrier() {
        /// 避免重复启动任务
        job?.cancel()
        job = barrierLeakCheckScope.launch {
            // 启动延迟20秒,避免应用启动期间误报
            delay(20_000L)
            Log.d(TAG, "barrier leak check init.")

            // 持续监控,每1秒检查一次
            while (true) {
                try {
                    Log.i(TAG, "barrier leak detect start")
                    detectSyncBarrierMessage()
                    // 每1秒检查一次
                    delay(1000L)
                } catch (e: Exception) {
                    Log.e(TAG, "barrier leak error: ${e.message}")
                    break // 出现异常时退出循环
                }
            }
        }
    }

    /// 在业务需要时,取消任务
    fun stopCheck() {
        job?.cancel()
    }

    /**
     * 检测消息队列中是否存在SyncBarrier泄漏
     * 检测超过3秒且target为null的消息,判定为泄漏并自动清理
     */
    @SuppressLint("DiscouragedPrivateApi")
    private fun detectSyncBarrierMessage() {
        try {
            // 获取主线程消息队列
            val mainQueue = Looper.getMainLooper().queue
            // 反射获取Handler消息队列
            val field = mainQueue.javaClass.getDeclaredField("mMessages")
            field.isAccessible = true
            val mMessage = field[mainQueue] as? Message

            if (mMessage != null) {
                // 计算消息存在时间
                val existTime = SystemClock.uptimeMillis() - mMessage.`when`

                // 如果存在超过3秒且target为null,判定为SyncBarrier泄漏
                if (existTime > 3000L && mMessage.target == null) {
                    val token = mMessage.arg1
                    Log.w(TAG, "barrier leak detected, token=$token, existTime=${existTime}ms")
                    removeSyncBarrier(token)
                }
            }
        } catch (e: Exception) {
            Log.e(TAG, "barrier leak error: ${e.message}")
        }
    }

    /**
     * 移除指定token的SyncBarrier
     * @param token SyncBarrier的标识token
     */
    @SuppressLint("DiscouragedPrivateApi")
    private fun removeSyncBarrier(token: Int) {
        try {
            val mainQueue = Looper.getMainLooper().queue
            val method = mainQueue.javaClass.getDeclaredMethod("removeSyncBarrier", Integer.TYPE)
            method.isAccessible = true
            method.invoke(mainQueue, token)
            Log.i(TAG, "removeSyncBarrier success,token=$token")
        } catch (e: Exception) {
            Log.e(TAG, "removeSyncBarrier failed: ${e.message}")
        }
    }
}

三、工具使用

  1. 因为该检测使用到了反射手段,会对性能有一定影响,需要评估轮询时间,建议轮询时间大于1秒。

  2. 在进程启动后,延迟启动,一是避免误判,而是避免对冷启动造成影响。

  3. 自动移除泄露的同步屏障,理论上是没有问题的。因为本身同步屏障只是一种UI优先级的调整手段,所以即使误判移除了同步屏障,也只是某小段时间内的UI状态可能会错乱,最终态是正确的。

  4. 可以辅助增加上报机制,将检测到泄露的执行栈上报到后台。真正的解决该问题,避免再次出现。

相关推荐
手机不死我是天子4 小时前
《Android 核心组件深度系列 · 第 3 篇 BroadcastReceiver》
android·android studio
用户17345666963474 小时前
Android 日志库:高性能压缩加密日志系统
android
恋猫de小郭5 小时前
React 和 React Native 不再直接归属 Meta,React 基金会成立
android·前端·ios
bst@微胖子5 小时前
鸿蒙实现滴滴出行项目之侧边抽屉栏以及权限以及搜索定位功能
android·华为·harmonyos
zcz16071278215 小时前
Docker Compose 搭建 LNMP 环境并部署 WordPress 论坛
android·adb·docker
Pika15 小时前
深入浅出 Compose 测量机制
android·android jetpack·composer
木易 士心20 小时前
MPAndroidChart 用法解析和性能优化 - Kotlin & Java 双版本
android·java·kotlin
消失的旧时光-194320 小时前
Kotlin Flow 与“天然背压”(完整示例)
android·开发语言·kotlin
ClassOps20 小时前
Kotlin invoke 函数调用重载
android·开发语言·kotlin