背景
在近期项目需求开发过程中,需求性能测试发现页面跳转存在内存泄漏,重复路径每次内存上涨20-30M。 常规内存泄漏处理上,我们可以借助LeakCanary、Android Profiler等工具找到引用链,在对应容器的生命周期解除引用(如解除单例类监听、页面离开时暂停动画)进行解决。但在该场景内存泄漏排查上,泄漏的引用链都是系统堆栈,没有业务堆栈,整体比较模糊,没有定位的方向。在排查投入了比较多精力,且走了不少弯路,故整理此类非常规内存泄漏的排查思路,供后续类似case进行参考。
涉及知识点:
- IdIeHandler机制
- WindowInsets事件分发机制
分析
内存泄漏原因分析
分析过程
-
通过内存泄漏检测工具获取泄漏的引用链,如下图所示:
Android Profiler
gcRoot到泄漏的Activity层,都是系统方法,没有什么思路,从源码层开始查找。
-
根据引用链的信息,以ActivityClientRecord.nextIdle为线索进一步查阅资源和阅读源码,发现ActivityThread#handleResumeActivity方法中,会添加一个IdleHandler将前一个节点的nextIdle置为空。
IdleHandler是在所在消息队列空闲的时候执行的,正常执行不会产生上面的泄漏的节点。可以推测上面泄漏的路径是由于主线程消息过多,IdleHandler的逻辑没有执行,导致内存泄漏。
-
简单验证下:在页面点击跳转的时候添加一个IdleHandler打印日志。
kotlinbtn.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消息分析
分析过程
-
打印消息队列的日志,对比两个路径的队列情况。 在activity#onCreate中添加
kotlinLooper.getMainLooper().setMessageLogging { Log.i("MessageLog", it) }
对比发现在内存泄漏的场景下,会重复刷以下日志
bashMessageLog[I] >>>>> Dispatching to Handler android.view.Choreographer$FrameDisplayEventReceiver: 0 MessageLog[I] <<<<< Finish to Handler android.view.Choreographer$FrameDisplayEventReceiver
Choreographer主要负责UI渲染和动画,初步怀疑是某些页面发生动画泄漏,一直触发屏幕刷新,但通过二分法屏蔽路径上相关动画,还是会内存泄漏。
-
通过Hook方式,定位发送事件的堆栈。 hook
Choreographer#postRunnable
方法。这里借助司内的库,开源库可使用 pine。重复泄漏路径,获取到的堆栈信息如下图所示。通过堆栈信息,可以定位到是requestFitSystemWindows方法发送了事件,整条堆栈链路为windowInsets分发链路。但从堆栈上看没有定位到业务上的信息,比较上层的类是ViewPager。
通过堆栈的信息,定位是在ViewCompat#requestApplyInset(view)方法中调用了requestFitSystemWindows。阅读ViewCompat的源码,其是在ViewCompat.Api21Impl#setOnApplyWindowInsetsListener进行回调。查看调用链,主动设置该监听的View包含ViewPager、AppBarLayout、CollapsingToolbarLayout。
-
分析泄漏路径相关页面的布局
发现泄漏路径中有一个页面的根布局为
ViewPager
,在里面套用AppBarLayout
和CollapsingToolbarLayout
,常见的多页面折叠布局结构。推测这些View一直收到OnApplyWindowInsetsListener的回调,触发了requestFitSystemWindows,导致一直调用Choreographer#postRunnable。
那么为什么会一直收到OnApplyWindowInsetsListener的回调呢? 到这里就需要了解windowInsets的分发链路了。
-
结合堆栈学习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。 -
ViewPager返回消费的事件验证
通过上面的梳理,问题点其实就是ViewPager不应该在给子View分发完WindowInsets事件后,返回一个未消费的WindowInsets事件。在源码的注释中,google官方其实也是说返回一个消费的事件,但实现确是一个未消费的事件。
重写覆盖该监听进行验证,在自定义ViewPager中添加OnApplyWindowInsetsListener回调返回消费事件的逻辑。
koltinclass 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() } }) } }
-
日志验证已不会重复打印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的概率。