ActivityClientRecord.nextIdle内存泄漏排查总结

背景

在近期项目需求开发过程中,需求性能测试发现页面跳转存在内存泄漏,重复路径每次内存上涨20-30M。 常规内存泄漏处理上,我们可以借助LeakCanary、Android Profiler等工具找到引用链,在对应容器的生命周期解除引用(如解除单例类监听、页面离开时暂停动画)进行解决。但在该场景内存泄漏排查上,泄漏的引用链都是系统堆栈,没有业务堆栈,整体比较模糊,没有定位的方向。在排查投入了比较多精力,且走了不少弯路,故整理此类非常规内存泄漏的排查思路,供后续类似case进行参考。

涉及知识点:

  • IdIeHandler机制
  • WindowInsets事件分发机制

分析

内存泄漏原因分析

分析过程

  1. 通过内存泄漏检测工具获取泄漏的引用链,如下图所示:

    Android Profiler gcRoot到泄漏的Activity层,都是系统方法,没有什么思路,从源码层开始查找。

  2. 根据引用链的信息,以ActivityClientRecord.nextIdle为线索进一步查阅资源和阅读源码,发现ActivityThread#handleResumeActivity方法中,会添加一个IdleHandler将前一个节点的nextIdle置为空。

    IdleHandler是在所在消息队列空闲的时候执行的,正常执行不会产生上面的泄漏的节点。可以推测上面泄漏的路径是由于主线程消息过多,IdleHandler的逻辑没有执行,导致内存泄漏。

  3. 简单验证下:在页面点击跳转的时候添加一个IdleHandler打印日志。

    kotlin 复制代码
    btn.setOnClickListener { _ ->
        Log.i("IdleHandlerTest", "click btn, add idleHandler")
        val idleHandler = {
            Log.i("IdleHandlerTest", "click btn, idleHandler exec")
            false
        }
        Looper.myQueue().addIdleHandler(idleHandler)
    }

    打印日志如下,未内存泄漏的情况下,日志可以正常打印,在复现内存泄漏的路径下,IdleHandler exec不会执行。

    arduino 复制代码
    // 正常路径下
    IdleHandlerTest[I]: click btn, add idleHandler
    IdleHandlerTest[I]: click btn, idleHandler exec
    
    // 检测出内存泄漏的路径下,不会执行 idleHandler exec
    IdleHandlerTest[I]: click btn, add idleHandler

原因小结

可以得出结论:因路径触发了某些逻辑,导致主线程消息队列一直占满,IdleHandler无法正常执行,最终导致内存泄漏。 另外,在主线程消息队列一直占满的情况下,Activity的生命周期函数也会晚10s执行。

下一步是定位具体哪些逻辑导致消息队列异常占满。

系统FrameDisplayEventReceiver消息分析

分析过程

  1. 打印消息队列的日志,对比两个路径的队列情况。 在activity#onCreate中添加

    kotlin 复制代码
    Looper.getMainLooper().setMessageLogging {
        Log.i("MessageLog", it)
    }

    对比发现在内存泄漏的场景下,会重复刷以下日志

    bash 复制代码
    MessageLog[I] >>>>> Dispatching to Handler android.view.Choreographer$FrameDisplayEventReceiver: 0
    MessageLog[I] <<<<< Finish to Handler android.view.Choreographer$FrameDisplayEventReceiver

    Choreographer主要负责UI渲染和动画,初步怀疑是某些页面发生动画泄漏,一直触发屏幕刷新,但通过二分法屏蔽路径上相关动画,还是会内存泄漏。

  2. 通过Hook方式,定位发送事件的堆栈。 hookChoreographer#postRunnable方法。这里借助司内的库,开源库可使用 pine

    重复泄漏路径,获取到的堆栈信息如下图所示。通过堆栈信息,可以定位到是requestFitSystemWindows方法发送了事件,整条堆栈链路为windowInsets分发链路。但从堆栈上看没有定位到业务上的信息,比较上层的类是ViewPager。

    通过堆栈的信息,定位是在ViewCompat#requestApplyInset(view)方法中调用了requestFitSystemWindows。阅读ViewCompat的源码,其是在ViewCompat.Api21Impl#setOnApplyWindowInsetsListener进行回调。查看调用链,主动设置该监听的View包含ViewPager、AppBarLayout、CollapsingToolbarLayout。

  3. 分析泄漏路径相关页面的布局

    发现泄漏路径中有一个页面的根布局为ViewPager,在里面套用AppBarLayoutCollapsingToolbarLayout,常见的多页面折叠布局结构。

    推测这些View一直收到OnApplyWindowInsetsListener的回调,触发了requestFitSystemWindows,导致一直调用Choreographer#postRunnable。

    那么为什么会一直收到OnApplyWindowInsetsListener的回调呢? 到这里就需要了解windowInsets的分发链路了。

  4. 结合堆栈学习windowInsets的分发链路

    WindowInsets是在安卓Window发生插入时,分发给应用布局的进行窗口适配的信息,包含窗口插入的Rect信息。分发链路为深度遍历,从顶层到底层传递,直到事件被消费,即WindowInsets#isConsumed等于true。

    传递链路参考下图(引用),在View进行事件处理的时候,如果有设置OnApplyWindowInsetsListener,会走listener的链路进行处理。

    结合堆栈的信息,阅读ViewPager对该事件处理的代码。ViewPager没有复用ViewGroup的分发处理,因为它需要分发给多个子View消费WindowInsets事件。在给子View分发完后,再返回一个子类处理完的WindowInsets。

    注意,这里返回的WindowInsets是未消费的,在ViewGroup中,未消费的WindowInsets事件会继续分发给子View,也就是说,在这个场景下,系统分发一次WindowInsets事件,ViewPager的子View会收到两次回调。

    如果事件被消费了,在fitSystemWindowInsets的场景下,两次WindowInsets的值会不一样,第一次的top为系统状态栏的高度,第二次的top会变为0(因为子类消费了)。

    在CollasingToolbarLayout中,是会消费WindowInsets事件的。

    结合堆栈分析,在ViewCompat OnApplyWindowInsetsListener的处理中,当两次WindowInsets不一样时,在API<30的机型中,会调用ViewCompat#requestApplyInsets,进而调用View#fitSystemWindowInsets进行窗口刷新,窗口刷新又会重新分发WindowInsets事件,形成循环。

    上述流程整理为时序图如下所示,可以看到事件最初由Choreographer分发,最后由于ViewPager#OnApplyWindowInsetsListener的bug返回了未消费的windowInset事件,导致二次分发windowInsets给ViewGroup的子View,在子View CollapsingToolbarLayout设置的监听Wrapper中,触发了requestApplyInsets,最后由回到Choreographer#postRunnable,导致事件死循环。

    ViewPager通过ViewCompat#setOnApplyWindowInsetsListener 设置的监听为ViewPagerListener,ViewCompat内部包装的Listener为ViewPagerListenerWrapper。
    CollapsingToolbarLayout通过ViewCompat#setOnApplyWindowInsetsListener 设置的监听为CollapsingListener,ViewCompat内部包装的Listener为CollapsingListenerWrapper。

  5. ViewPager返回消费的事件验证

    通过上面的梳理,问题点其实就是ViewPager不应该在给子View分发完WindowInsets事件后,返回一个未消费的WindowInsets事件。在源码的注释中,google官方其实也是说返回一个消费的事件,但实现确是一个未消费的事件。

    重写覆盖该监听进行验证,在自定义ViewPager中添加OnApplyWindowInsetsListener回调返回消费事件的逻辑。

    koltin 复制代码
    class FixedViewPager(context: Context, attr: AttributeSet?): ViewPager(context, attr) {
        init {
            ViewCompat.setOnApplyWindowInsetsListener(this,
                object : androidx.core.view.OnApplyWindowInsetsListener {
                    private val mTempRect = Rect()
                    override fun onApplyWindowInsets(
                        v: View,
                        originalInsets: WindowInsetsCompat
                    ): WindowInsetsCompat {
                        // ...
                        // 拷贝viewpager原来的监听处理逻辑
                        // ...
    
                        // 最后返回的windowInset添加consumeSystemWindowInsets()调用
                        return applied.replaceSystemWindowInsets(
                            res.left, res.top, res.right, res.bottom
                        ).consumeSystemWindowInsets()
                    }
                })
        }
    }
  6. 日志验证已不会重复打印FrameDisplayEventReceiver绘制事件,IdleHandler在返回主页时也会执行。

原因小结

在沉浸式导航栏的页面中,ViewPager+CollasingToolbarLayout的组合安卓10及以下的机型由于系统bug会导致系统绘制消息死循环。

ViewPager和任意一个通过ViewCompat#setOnApplyWindowInsetsListener消费windowInsets事件子view搭配使用,都会触发以上问题。

解决方案

  • 继承ViewPager,重新实现ViewCompat.setOnApplyWindowInsetsListener接口,返回已消费的WindowInsets。
koltin 复制代码
    class FixedViewPager(context: Context, attr: AttributeSet?): ViewPager(context, attr) {
        init {
            ViewCompat.setOnApplyWindowInsetsListener(this,
                object : androidx.core.view.OnApplyWindowInsetsListener {
                    private val mTempRect = Rect()
                    override fun onApplyWindowInsets(
                        v: View,
                        originalInsets: WindowInsetsCompat
                    ): WindowInsetsCompat {
                        // ...
                        // 拷贝viewpager原来的监听处理逻辑
                        // ...

                        // 最后返回的windowInset添加consumeSystemWindowInsets()调用
                        return applied.replaceSystemWindowInsets(
                            res.left, res.top, res.right, res.bottom
                        ).consumeSystemWindowInsets()
                    }
                })
        }
    }
  • 升级为ViewPager2,ViewPager2内部采用RecyclerView实现,性能、功能相比于ViewPager更强大。ViewPager2没有额外实现ViewCompat.setOnApplyWindowInsetsListener接口,整体是复用ViewGroup的分发机制。

总结

本次内存泄漏的根本原因为ViewPager+CollasingToolbarLayout的组合在安卓10以下系统windowInset事件会循环分发,主线程消息队列被FrameDisplayEvent消息占满,主线程IdleHandler无法立即执行,最终导致内存泄漏。

在平常写业务逻辑的时候也需要注意不要不断往主线程队列塞消息,不仅会导致内存泄漏,还会加大应用ANR的概率。

参考资料

相关推荐
花花鱼5 小时前
android studio 设置让开发更加的方便,比如可以查看变量的类型,参数的名称等等
android·ide·android studio
alexhilton6 小时前
为什么你的App总是忘记所有事情
android·kotlin·android jetpack
AirDroid_cn10 小时前
OPPO手机怎样被其他手机远程控制?两台OPPO手机如何相互远程控制?
android·windows·ios·智能手机·iphone·远程工作·远程控制
尊治10 小时前
手机电工仿真软件更新了
android
xiangzhihong813 小时前
使用Universal Links与Android App Links实现网页无缝跳转至应用
android·ios
车载应用猿13 小时前
基于Android14的CarService 启动流程分析
android
没有了遇见14 小时前
Android 渐变色实现总结
android
雨白16 小时前
Jetpack系列(四):精通WorkManager,让后台任务不再失控
android·android jetpack
mmoyula18 小时前
【RK3568 驱动开发:实现一个最基础的网络设备】
android·linux·驱动开发
sam.li19 小时前
WebView安全实现(一)
android·安全·webview