从一次实际经历来说说IdleHandler的坑

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

  1. View 动画 Animation 运行原理解析
  2. 属性动画 ValueAnimator 运行原理全解析
  3. Android 源码分析 - Handler的同步屏障机制

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

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方法里面无限制的直接或者间接调用Viewinvalidate方法。
  2. 做一个无限轮询的View动画。

2. 说说坑在哪里?

  上面我枚举了queueIdle方法不会被回调的两种场景,接下来,我们就分开来看一下。

(1). View的invalidate方法

  我们在自定义View的时候,经常会手动的调用Viewinvalidate方法,用来保证我们的某些设置能够立即生效。但是在很多的时候,我们非常容易错误的调用了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还是没有学到家。
  Viewinvalidate方法不断的向上调用,最终会调用到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对象,只是同步屏障Message的Target为空而已。

  上面知道了,当调用Viewinvalidate时,会向任务队列里面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动画的实现原理,这里就不过多的介绍了。
  那么怎么解决这种问题,最简单的办法就是换成属性动画,那么肯定又有人要问了,为什么属性动画无限轮询不会影响的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,并且重写相关的方法,比如说invalidaterequestLayout等方法,可以在这些方法里面打印相关堆栈,来监听哪些地方出现了问题。
  之所以可以在父ViewGroup可以监听到调用,是因为子View在调用invalidaterequestLayout等方法,最终都会走到父ViewGroup对应方法里面去。

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

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,684评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,143评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,214评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,788评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,796评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,665评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,027评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,679评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,346评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,664评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,766评论 1 331
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,412评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,015评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,974评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,073评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,501评论 2 343

推荐阅读更多精彩内容

  • [TOC] 1 JAVA: String为什么这么设计 在源码中string是用final 进行修饰,它是不可更改...
    寄浮生阅读 805评论 0 0
  • 夜莺2517阅读 127,709评论 1 9
  • 版本:ios 1.2.1 亮点: 1.app角标可以实时更新天气温度或选择空气质量,建议处女座就不要选了,不然老想...
    我就是沉沉阅读 6,876评论 1 6
  • 我是黑夜里大雨纷飞的人啊 1 “又到一年六月,有人笑有人哭,有人欢乐有人忧愁,有人惊喜有人失落,有的觉得收获满满有...
    陌忘宇阅读 8,520评论 28 53
  • 兔子虽然是枚小硕 但学校的硕士四人寝不够 就被分到了博士楼里 两人一间 在学校的最西边 靠山 兔子的室友身体不好 ...
    待业的兔子阅读 2,583评论 2 9