最近楼主都在做性能优化相关的事,性能优化一般都会跟IdleHandler打交道。本文将介绍,楼主在实际开发过程中使用IdleHandler遇到的坑,主要包括自定义View以及View的动画。
本文参考资料:
注意,本文源码均来自于API 29。
1. 概述
我们都知道IdleHandler的含义,一般表示当前主线程在不忙碌的时候会执行IdleHandler里面的任务。具体的内容是,当Looper
在从MessageQueue
中获取当前需要执行的Message
时,如果当前MessageQueue
中没有Message或者还没有到第一条Message执行的时间,此时MessageQueue
会尝试执行IdleHandler的里面的任务,这个我们可以从MessageQueue
的next
方法里面得到应证:
Message next() {
//······
// 当Message为空,或者当前Message执行时间还未到
if (pendingIdleHandlerCount < 0
&& (mMessages == null || now < mMessages.when)
) {
pendingIdleHandlerCount = mIdleHandlers.size();
}
if (pendingIdleHandlerCount <= 0) {
// No idle handlers to run. Loop and wait some more.
mBlocked = true;
continue;
}
if (mPendingIdleHandlers == null) {
mPendingIdleHandlers = new IdleHandler [Math.max(pendingIdleHandlerCount, 4)];
}
mPendingIdleHandlers = mIdleHandlers.toArray(mPendingIdleHandlers);
// Run the idle handlers.
// We only ever reach this code block during the first iteration.
for (int i = 0; i < pendingIdleHandlerCount; i++) {
final IdleHandler idler = mPendingIdleHandlers[i];
mPendingIdleHandlers[i] = null; // release the reference to the handler
boolean keep = false;
try {
keep = idler.queueIdle();
} catch (Throwable t) {
Log.wtf(TAG, "IdleHandler threw exception", t);
}
if (!keep) {
synchronized(this) {
mIdleHandlers.remove(idler);
}
}
}
// Reset the idle handler count to 0 so we do not run them again.
pendingIdleHandlerCount = 0;
// While calling an idle handler, a new message could have been delivered
// so go back and look again for a pending message without waiting.
nextPollTimeoutMillis = 0;
}
一般来说,我们正常使用IdleHandler都没有什么问题,queueIdle
方法也会被正常的回调。但是当你做了如下操作的时候,你会发现,不管等多久,queueIdle
永远不会被回调。
- 在View的onDraw方法里面无限制的直接或者间接调用
View
的invalidate
方法。- 做一个无限轮询的View动画。
2. 说说坑在哪里?
上面我枚举了queueIdle
方法不会被回调的两种场景,接下来,我们就分开来看一下。
(1). View的invalidate方法
我们在自定义View的时候,经常会手动的调用View
的invalidate
方法,用来保证我们的某些设置能够立即生效。但是在很多的时候,我们非常容易错误的调用了invalidate
方法,从而导致陷入一种无限制重绘的状态。
举一个简单的例子,ImageView
内部有setImageDrawable
,setImageBitmap
等设置Drawable的方法,我们在日常的开发中,也会经常用到这些方法,用来展示某些特殊的内容。但是,一旦我们在自定义View时,在onDraw方法里面调用这些方法就会让主线程的任务队列永远不会idle。大家可以尝试一下如下的代码:
class CustomImageView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : AppCompatImageView(context, attrs, defStyleAttr) {
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
override fun onDraw(canvas: Canvas?) {
setImageDrawable(context.getDrawable(R.drawable.ic_launcher_background))
super.onDraw(canvas)
}
}
在这里,我将解释为什么如上的操作会导致主线程任务队列永远不会idle。我们都知道,View的重绘流程是:invalidate
-> onDraw
。也就是说,当我们在View的onDraw方法里面调用invalidate
时或者其他会调用invalidate
的方法(例如上面的介绍的setImageDrawable
和setImageBitmap
方法),最终又会执行onDraw
方法,从而形成了一个类似于死递归的情形,即不断的向任务队列里面增加任务。
有人可能会想,就算不断的往任务队列里面增加任务,但是一帧的时间有那么长--16ms,不可能都在执行重绘的任务,应该总有机会idle的啊?相信很多人都会这么想,包括我在最开始的时候也是这么想的。但是仔细看了源码之后,发现自己的Android还是没有学到家。
View
的invalidate
方法不断的向上调用,最终会调用到ViewRootImpl
的scheduleTraversals
方法里面去,而在scheduleTraversals
方法里面做了一件容易让人忽视的事,我们先来看一下源码:
void scheduleTraversals() {
if (!mTraversalScheduled) {
mTraversalScheduled = true;
mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier();
mChoreographer.postCallback(
Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
if (!mUnbufferedInputDispatch) {
scheduleConsumeBatchedInput();
}
notifyRendererOfFramePending();
pokeDrawLockIfNeeded();
}
}
这里面,我们先忽略其他的操作,只看两件事:
- 调用了
MessageQueue
的postSyncBarrier
方法,向任务队列post了一个同步屏障。这一步非常的重要,也是因为有这么一个同步屏障的存在,导致了MessageQueue不会idle。- 往Choreographer里面post了一个traversal类型的任务,保证在下一个垂直信号到来时,可以正常的重绘View的内容。这里的
mTraversalRunnable
执行,最终会到CutomImageView
的onDraw
方法。
上面提到了同步屏障,相信大家都比较熟悉它的作用,但是我在这里还是要说明一下:
Message有一个标记位,名为
FLAG_ASYNCHRONOUS
,用来表示当前Message是否是异步的。而同步屏障的作用用来挡住所有同步的Message,只允许异步的Message通过。如果当前任务队列中没有异步Message,那么主线程就会休眠,直到任务队列中添加了一个异步Message,或者同步屏障被移除。
同步屏障跟普通的消息比较类似,都是一个Message对象,只是同步屏障Message的Target为空而已。
上面知道了,当调用View
的invalidate
时,会向任务队列里面post一个同步屏障。接下来,我们来看一下MessageQueue对同步屏障是怎么处理的,同时看一下为啥idle永远不会被调用。
Message next() {
// ······
for (;;) {
// ······
// 休眠指定时间
// ······
synchronized (this) {
// ······
final long now = SystemClock.uptimeMillis();
Message prevMsg = null;
Message msg = mMessages;
// 获取异步消息
if (msg != null && msg.target == null) {
// Stalled by a barrier. Find the next asynchronous message in the queue.
do {
prevMsg = msg;
msg = msg.next;
} while (msg != null && !msg.isAsynchronous());
}
//······
// 获取同步消息
//······
// 处理idle
if (pendingIdleHandlerCount < 0
&& (mMessages == null || now < mMessages.when)) {
pendingIdleHandlerCount = mIdleHandlers.size();
}
// ·······
}
// ······
}
}
我简化了一下next方法,next方法最终的目的就是获取下一个需要被执行Message,如果获取不到的话,就会休眠。但是,当任务队列的队头(即mMessages
)是一个同步屏障,这个方法里面执行流程就变得非常有意思:
- 当
mMessages
是同步屏障,且后续没有异步消息,那么获取异步消息
和获取同步消息
这两步都会失败了,即nextPollTimeoutMillis
会被赋值为-1,表示无限制的休眠。- pendingIdleHandlerCount 默认是-1,所以会尝试着赋值。其中,由于同步屏障的存在,所以
mMessages
肯定不为空,同时now < mMessages.when
肯定是不成立的,因为同步屏障在ViewRootImpl
的scheduleTraversals
方法里面就添加进去,所以这个时间肯定比当前时间要早很多。
因此,结合上面两点,idle是不会回调的,并且会让主线程休眠,直到一个异步Message添加到队列中。这个Messgae就是Choreographer$FrameDisplayEventReceiver
,我们可以简单的看一下源码:
@Override
public void onVsync(long timestampNanos, long physicalDisplayId, int frame) {
// ······
Message msg = Message.obtain(mHandler, this);
msg.setAsynchronous(true);
mHandler.sendMessageAtTime(msg, timestampNanos / TimeUtils.NANOS_PER_MS);
}
这个Message的作用就是把Choreographer
四种类型的任务全部执行,其中前面invalidate
方法添加的任务就包含在这里。
到这里,我们就知道了为啥Idle永远不会回调了,我做一个总结,方便大家理解:
当我们在onDraw方法直接或者间接调用
invalidate
方法,ViewRootImpl会向MessageQueue
里面post 一个同步屏障。当MessageQueue
轮询到这个同步屏障时,会等到Choreographer$FrameDisplayEventReceiver
这个异步任务执行之后,才会执行其他任务,即才有可能触发idle。但是Choreographer$FrameDisplayEventReceiver
这个任务里面又会执行View
的onDraw方法,从而形成了一个无限循环。进而,idle永远不会回调。
那么我们知道了原因所在,怎么来解决这种类似的问题呢?原则是:尽量不在onDraw方法里面直接或者间接调用invalidate
方法。如果真的要这么做,应该怎么做呢?可以过滤无效的重绘。就拿上面的例子来说,我们可以将Drawable缓存成一个成员变量:
class CustomImageView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : AppCompatImageView(context, attrs, defStyleAttr) {
private var mDrawable: Drawable? = null
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
override fun onDraw(canvas: Canvas?) {
if (mDrawable == null) {
mDrawable = context.getDrawable(R.drawable.ic_launcher_background)
}
setImageDrawable(mDrawable)
super.onDraw(canvas)
}
}
这里还有一个疑问,我们发现了,就算我们在onDraw
方法直接或者间接调用invalidate
方法,但是并不会影响我们正常的使用App,比如说使用上不能感受到明显的卡顿,这也是我们难以发现这种问题的原因。那么这是为什么呢?
其实,简单的来说,如上的操作只是每一帧的时间里面多了一个任务而已,从体验上来说,几乎没法区分这里面的差别。除非,你想要在IdleHandler处理一些事情,在这种情形下是永远不会被执行的。
(2) 无限轮询的View动画
在一个App,动画是一件再正常不过的事情,而错误的使用动画也会导致IdleHandler永远不会回调。注意,这里指的是动画是普通的View动画,而不是属性动画,属性动画没有这个问题,而针对属性动画的分析,后文会有内容介绍。
通常来说,我们会写类似如下的代码来展示一个动画:
private fun startAnimation() {
val animation = AlphaAnimation(1f, 0.5f)
animation.duration = 100
animation.repeatCount = -1
animation.repeatMode = Animation.REVERSE
val view = findViewById<View>(R.id.view)
view.startAnimation(animation)
}
上面的代码有两个特点:
- 是普通的View动画。
- 是无限轮询的动画。
如果你写了类似上面的代码,那么恭喜你,你的IdleHandler永远不会被回调。究其原因,其实还是因为无限制的调用invalidate
方法,有兴趣的可以参考View 动画 Animation 运行原理解析这篇文章,了解一下View动画的实现原理,这里就不过多的介绍了。
那么怎么解决这种问题,最简单的办法就是换成属性动画,那么肯定又有人要问了,为什么属性动画无限轮询不会影响的IdleHandler的调用呢?这就要了解属性动画的原理了,这里我简单的介绍一下,有兴趣的同学可以参考 属性动画 ValueAnimator 运行原理全解析这篇文章。
属性动画的实现原理不同于View动画。View动画的每一帧都是通过
invalidate
方法来触发重绘,而属性动画每一帧的绘制都是通过Choreographer
的回调实现,本质上就是当动画开始时,会向Choreographer
的任务队列里面post 一个动画类型的任务,当垂直信号到来时,会执行这里面的任务,从而回调我们的任务;同时为了保证动画能够流畅的进行,当当前帧绘制完成,会再向Choreographer的任务队列post一个任务,保证下一帧动画能够正常绘制,从而实现了动画。
从本质上来说,属性动画少了一个很重要的步骤,就是post 一个同步屏障。在属性动画中,没有同步屏障,那么后续的任务能够继续执行,当队列中没有任务时,自然就会回调IdleHandler。
3. 怎么排查问题的原因?
当我们使用IdleHandler时,发现Idle永远不会被回调,应该要积极排查代码上是否有类似上面的问题。但是,在实际项目中,业务代码成千上万,不可能每一个人都会看完,所以我们需要有一个更为高效的方式来排查这种问题。
(1). 给Looper设置一个Printer
在Looper的loop方法里面,Message在执行前后,都会通过一个Printer
对象,打印当前执行的Message信息。我们可以通过Message的相关信息找到对应位置上的问题。
public static void loop() {
// ······
for (;;) {
// ······
if (logging != null) {
logging.println(">>>>> Dispatching to " + msg.target + " " +
msg.callback + ": " + msg.what);
}
// ······
if (logging != null) {
logging.println("<<<<< Finished to " + msg.target + " " + msg.callback);
}
// ······
}
(2). 自定义顶级ViewGroup,并且重写相关方法
当我们在实际场景种发现了这种问题,还有一种排查的方法,就是将界面最顶级的ViewGroup换成我们自定义的ViewGroup,并且重写相关的方法,比如说invalidate
和requestLayout
等方法,可以在这些方法里面打印相关堆栈,来监听哪些地方出现了问题。
之所以可以在父ViewGroup可以监听到调用,是因为子View在调用invalidate
,requestLayout
等方法,最终都会走到父ViewGroup对应方法里面去。
我这里只抛出两种比较简单的解决方法,不一定适用于所有场景,因为具体的问题还需要依赖于具体的场景来看待。不管怎么样,大家在开发过程中尽量不要书写上面两种类型的代码。