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

相关推荐
市场部需要一个软件开发岗位14 分钟前
JAVA开发常见安全问题:Cookie 中明文存储用户名、密码
android·java·安全
JMchen1232 小时前
Android后台服务与网络保活:WorkManager的实战应用
android·java·网络·kotlin·php·android-studio
crmscs3 小时前
剪映永久解锁版/电脑版永久会员VIP/安卓SVIP手机永久版下载
android·智能手机·电脑
localbob3 小时前
杀戮尖塔 v6 MOD整合版(Slay the Spire)安卓+PC端免安装中文版分享 卡牌肉鸽神作!杀戮尖塔中文版,电脑和手机都能玩!杀戮尖塔.exe 杀戮尖塔.apk
android·杀戮尖塔apk·杀戮尖塔exe·游戏分享
机建狂魔3 小时前
手机秒变电影机:Blackmagic Camera + LUT滤镜包的专业级视频解决方案
android·拍照·摄影·lut滤镜·拍摄·摄像·录像
hudawei9963 小时前
flutter和Android动画的对比
android·flutter·动画
lxysbly5 小时前
md模拟器安卓版带金手指2026
android
儿歌八万首5 小时前
硬核春节:用 Compose 打造“赛博鞭炮”
android·kotlin·compose·春节
消失的旧时光-19438 小时前
从 Kotlin 到 Dart:为什么 sealed 是处理「多种返回结果」的最佳方式?
android·开发语言·flutter·架构·kotlin·sealed
Jinkxs8 小时前
Gradle - 与Groovy/Kotlin DSL对比 构建脚本语言选择指南
android·开发语言·kotlin