查看原文
其他

Android 避坑指南:实际经历来说说IdleHandler的坑

琼珶和予 鸿洋 2021-10-13

本文作者


作者:琼珶和予

链接:

https://www.jianshu.com/p/50a9bbb72d4e

本文由作者授权发布。


避坑系列文章:


Android 避坑指南:Gson 又搞了个坑!
Android避坑指南,发现了一个极度不安全的操作


最近楼主都在做性能优化相关的事,性能优化一般都会跟IdleHandler打交道。本文将介绍,楼主在实际开发过程中使用IdleHandler遇到的坑,主要包括自定义View以及View的动画。


1概述


我们都知道IdleHandler的含义,一般表示当前主线程在不忙碌的时候会执行IdleHandler里面的任务。


具体的内容是,当Looper在从MessageQueue中获取当前需要执行的Message时,如果当前MessageQueue中没有Message或者还没有到第一条Message执行的时间,此时MessageQueue会尝试执行IdleHandler的里面的任务,这个我们可以从MessageQueuenext方法里面得到应证:


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永远不会被回调。


  1. View的onDraw方法里面无限制的直接或者间接调用View的invalidate方法。


  2. 做一个无限轮询的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的方法(例如上面的介绍的setImageDrawablesetImageBitmap方法),最终又会执行onDraw方法,从而形成了一个类似于死递归的情形,即不断的向任务队列里面增加任务。


有人可能会想,就算不断的往任务队列里面增加任务,但是一帧的时间有那么长--16ms,不可能都在执行重绘的任务,应该总有机会idle的啊?相信很多人都会这么想,包括我在最开始的时候也是这么想的。但是仔细看了源码之后,发现自己的Android还是没有学到家。


View的
invalidate方法不断的向上调用,最终会调用到ViewRootImplscheduleTraversals方法里面去,而在scheduleTraversals方法里面做了一件容易让人忽视的事,我们先来看一下源码:


void scheduleTraversals() {

    if (!mTraversalScheduled) {
        mTraversalScheduled = true;
        mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier();
        mChoreographer.postCallback(
                Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
        if (!mUnbufferedInputDispatch) {
            scheduleConsumeBatchedInput();
        }
        notifyRendererOfFramePending();
        pokeDrawLockIfNeeded();
    }
}


这里面,我们先忽略其他的操作,只看两件事:


1、调用了MessageQueuepostSyncBarrier方法,向任务队列post了一个同步屏障。这一步非常的重要,也是因为有这么一个同步屏障的存在,导致了MessageQueue不会idle。


2、往Choreographer里面post了一个traversal类型的任务,保证在下一个垂直信号到来时,可以正常的重绘View的内容。这里的mTraversalRunnable执行,最终会到CutomImageViewonDraw方法。


上面提到了同步屏障,相信大家都比较熟悉它的作用,但是我在这里还是要说明一下:


Message有一个标记位,名为FLAG_ASYNCHRONOUS,用来表示当前Message是否是异步的。而同步屏障的作用用来挡住所有同步的Message,只允许异步的Message通过。如果当前任务队列中没有异步Message,那么主线程就会休眠,直到任务队列中添加了一个异步Message,或者同步屏障被移除。


同步屏障跟普通的消息比较类似,都是一个
Message对象,只是同步屏障MessageTarget为空而已。


上面知道了,当调用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)是一个同步屏障,这个方法里面执行流程就变得非常有意思:


1、当mMessages是同步屏障,且后续没有异步消息,那么获取异步消息和获取同步消息这两步都会失败了,即nextPollTimeoutMillis会被赋值为-1,表示无限制的休眠。


2、pendingIdleHandlerCount 默认是-1,所以会尝试着赋值。其中,由于同步屏障的存在,所以mMessages肯定不为空,同时now < mMessages.when 肯定是不成立的,因为同步屏障在ViewRootImplscheduleTraversals方法里面就添加进去,所以这个时间肯定比当前时间要早很多。


因此,结合上面两点,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)
}


上面的代码有两个特点:

  1. 是普通的View动画。

  2. 是无限轮询的动画。

  

如果你写了类似上面的代码,那么恭喜你,你的IdleHandler永远不会被回调。究其原因,其实还是因为无限制的调用invalidate方法,有兴趣的可以参考View 动画 Animation 运行原理解析这篇文章,了解一下View动画的实现原理,这里就不过多的介绍了。

https://www.cnblogs.com/dasusu/p/8287822.html


那么怎么解决这种问题,最简单的办法就是换成属性动画,那么肯定又有人要问了,为什么属性动画无限轮询不会影响的
IdleHandler的调用呢?这就要了解属性动画的原理了,这里我简单的介绍一下,有兴趣的同学可以参考 属性动画 ValueAnimator 运行原理全解析这篇文章。

https://www.cnblogs.com/dasusu/p/8595422.html


属性动画的实现原理不同于View动画。View动画的每一帧都是通过invalidate方法来触发重绘,而属性动画每一帧的绘制都是通过Choreographer的回调实现。


本质上就是当动画开始时,会向Choreographer的任务队列里面post 一个动画类型的任务,当垂直信号到来时,会执行这里面的任务,从而回调我们的任务。


同时为了保证动画能够流畅的进行,当当前帧绘制完成,会再向Choreographer的任务队列post一个任务,保证下一帧动画能够正常绘制,从而实现了动画。


从本质上来说,属性动画少了一个很重要的步骤,就是post 一个同步屏障。在属性动画中,没有同步屏障,那么后续的任务能够继续执行,当队列中没有任务时,自然就会回调
IdleHandler


3怎么排查问题的原因


当我们使用IdleHandler时,发现Idle永远不会被回调,应该要积极排查代码上是否有类似上面的问题。但是,在实际项目中,业务代码成千上万,不可能每一个人都会看完,所以我们需要有一个更为高效的方式来排查这种问题。


(1). 给Looper设置一个Printer


Looperloop方法里面,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,并且重写相关的方法,比如说invalidaterequestLayout等方法,可以在这些方法里面打印相关堆栈,来监听哪些地方出现了问题。


之所以可以在父
ViewGroup可以监听到调用,是因为子View在调用invalidaterequestLayout等方法,最终都会走到父ViewGroup对应方法里面去。


我这里只抛出两种比较简单的解决方法,不一定适用于所有场景,因为具体的问题还需要依赖于具体的场景来看待。不管怎么样,大家在开发过程中尽量不要书写上面两种类型的代码。


本文参考资料:

View 动画 Animation 运行原理解析

https://www.cnblogs.com/dasusu/p/8287822.html

属性动画 ValueAnimator 运行原理全解析

https://www.cnblogs.com/dasusu/p/8595422.html

Android 源码分析 - Handler的同步屏障机制

https://www.jianshu.com/p/2535f24d291c


注意,本文源码均来自于API 29。




最后推荐一下我做的网站,玩Android: wanandroid.com ,包含详尽的知识体系、好用的工具,还有本公众号文章合集,欢迎体验和收藏!


推荐阅读


难得的Android 启动优化好文!
用LiveData打造EventBus有很多问题?
Jetpack完整解析,ViewModel 全面掌握!



扫一扫 关注我的公众号

如果你想要跟大家分享你的文章,欢迎投稿~


┏(^0^)┛明天见!

: . Video Mini Program Like ,轻点两下取消赞 Wow ,轻点两下取消在看

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存