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的概率。

参考资料

相关推荐
雮尘1 小时前
Android性能优化之枚举替代
android
2501_915909063 小时前
苹果上架App软件全流程指南:iOS 应用发布步骤、App Store 上架流程、uni-app 打包上传与审核技巧详解
android·ios·小程序·https·uni-app·iphone·webview
2501_915921433 小时前
iOS 文件管理与能耗调试结合实战 如何查看缓存文件、优化电池消耗、分析App使用记录(uni-app开发与性能优化必备指南)
android·ios·缓存·小程序·uni-app·iphone·webview
2501_915918414 小时前
App 苹果 上架全流程解析 iOS 应用发布步骤、App Store 上架流程
android·ios·小程序·https·uni-app·iphone·webview
2501_916007474 小时前
苹果上架全流程详解,iOS 应用发布步骤、App Store 上架流程、uni-app 打包上传与审核要点完整指南
android·ios·小程序·https·uni-app·iphone·webview
PuddingSama5 小时前
Android 高级绘制技巧: BlendMode
android·前端·面试
2501_915921435 小时前
iOS App 性能监控与优化实战 如何监控CPU、GPU、内存、帧率、耗电情况并提升用户体验(uni-app iOS开发调试必备指南)
android·ios·小程序·uni-app·iphone·webview·ux
Digitally6 小时前
如何将视频从安卓手机传输到电脑?
android·智能手机·电脑
CV资深专家6 小时前
Android 相机框架的跨进程通信架构
android
前行的小黑炭6 小时前
Android :如何提升代码的扩展性,方便复制到其他项目不会粘合太多逻辑,增强你的实战经验。
android·java·kotlin