胡琪

为今天工作,为明天投资,为未来孵化一些东西!

你真的会用View的post方法吗

安卓中在非UI线程中需要更新界面的时候,我们往往使用Handler的post方法或者View的post方法(当然还可以使用Activity的runOnUiThread),当我们已经有一个view对象的时候往往会选择后者,即使用该view对象调用post方法。但是你真的了解View的post方法吗?有没有遇到过View的post的Runnable对象中的代码在某些机型上未被执行?遇到之后深入探究过是什么原因吗?

背景

最近在开发智能无感知SDK的时候遇到过一个很奇怪的坑,这里简单交代下背景,用户点击登录按钮后智能无感知SDK会使用一个WebView去加载一个url,然后进行java代码和js代码的相互通信,在java代码端的onReady回调中会去执行一个js端的函数captchaVerify()进行智能无感知校验,这个WebView控件是使用一个自定义对话框来承载的,是通过在自定义对话框的onCreate方法中调用setContentView将该WebView对象添加到对话框中的。在进行智能无感知验证的时候不会将该对话框显示出来。也即不会调用show函数。只有当captchaVerify()验证失败才会show出对话框展示传统验证码。

java端和js端相互通信的最开始的代码大致如下:

熟悉WebView的同学都知道在java代码和js代码进行通信时java端的回调函数都是运行在一个叫做JavaBridge的线程中的,所以onReady()中的代码是运行在非UI线程中的,而mCaptchaWebView.loadUrl方法会对界面进行更新,因此该操作必须post到主线程中去,所以最开始我使用了如上的一段代码(注:最开始的代码无注释2处和注释3处对应的打印log代码,这里为了减小篇幅,将后面才添加的排查问题的日志代码也直接放上去了),即当java端的onReady方法被js代码回调时如果是智能无感知类型会调用callCaptchaVerify方法,在该方法中调用js端的captchaVerify()方法。逻辑上是完全符合业务逻辑的,没任何问题。但是在实际测试时发现上述代码在三星SM-C7100机型(安卓版本为7.1.1)上运行ok,可以正确调用js端的captchaVerify()函数完成智能无感知验证,但是在360的QiKU手机青春版(安卓版本为5.1)上智能无感知始终不能通过。最开始以为是在360的奇酷手机上不能使用WebView的loadUrl来调用js端代码,但是改为使用evaluateJavascript方法还是不行,于是添加了注释2和注释3处的打印log的日志,发现在三星那款测试机和奇酷测试机上输入日志分别如下:

看到对比结果震惊了,日志表明在奇酷手机上post的Runnable对象中的代码根本没执行!!!单解决这个问题很容易,使用Activity的runOnUiThread方法替换View的post就行了,但是作为一个优秀的工程师,保持好奇心是很重要的,为何View的post方法在奇酷手机上就不能work呢?是360的ROM修改了什么还是其他原因呢?于是用安卓5.1系统的模拟器运行最开始的代码,结果发现还是不行,说明和厂商自定义ROM修改无关,这个锅不能让360来背,那就只有一个可能View的post方法在不同版本的SDK中实现细节不一样,然后去研究了下发现还真是这样,View的post方法在API24(7.0系统)以上(含)和以下的实现逻辑区别较大。更准确的说可以认为是在7.0以下View的post方法在实现逻辑上存在缺陷。在7.0系统谷歌修正了这个缺陷。

深入源码寻找原因

7.0及其以上系统的View#post实现

先来看下安卓7.1.1系统API25的View的post的实现

从上面可以看到post的实现包括2种情况,即mAttachInfo是否为空。当其不为空时使用该对象内的mHandler的post方法,也就是调用Handler对象的post方法,容易理解,没啥好说的。那么这个mAttachInfo是在什么时候赋值的呢?答案是在View的dispatchAttachedToWindow这个方法中,代码如下所示:

所以这里的关键就是dispatchAttachedToWindow方法在什么时候被调用?该方法被调用的时候传入的AttachInfo对象是否为空?熟悉安卓View绘制原理的同学应该知道,这个方法是当系统将我们创建的View(xml布局中的也包含)添加到其对应的窗口中的时候通过ViewRootImol 的 performTraversals() 方法对该窗口所有的子View进行测量,布局和绘制时调用的。该函数传入的AttachInfo对象即是ViewRootImpl的mAttachInfo 成员。因此只要调用子View的dispatchAttachedToWindow方法,AttachInfo就不会为null,所以View的mAttachInfo为null只存在一种情况就是该View的dispatchAttachedToWindow未被调用过。所以如果你的View是动态创建的,而不是写在xml布局中的时候,只有当调用了类似addView功能将该View添加到窗口中的时候才能保证该View的mAttachInfo不为空。

再来看下当View的mAttachInfo为null时的执行逻辑getRunQueue().post(action)。这里的getRunQueue得到是一个HandlerActionQueue对象。

其post方法的主要作用就是将要执行的action添加到一个mActions数组中存储起来等待后续被执行,该类的代码如下所示:

所以关键就是HandlerActionQueue的executeActions方法是在什么时候被执行的,在前面已经按暗示过是在dispatchAttachedToWindow函数中。

所以从API24开始View#post方法的逻辑就很容易理解了,梳理下:

  • 当调用View#post方法时如果该View已经被添加到了某个父容器中(当然该父View得直接或间接依附于某个Window),那么会通过其内部的mAttachInfo的mHandler成员post方法来执行,最终调用的是其依附的Window所关联的ViewRootImol对象的mHandler成员的post方法
  • View的内部成员mAttachInfo是该View对象被系统调用dispatchAttachedToWindow的时候传入的入参info
  • 当调用View#post方法时如果该View还未被添加到任何容器中,那么此时该View的dispatchAttachedToWindow还未被调用,那么会将该post方法创建的Runnable对象添加到内部的一个名为mRunQueue中存储起来,等待dispatchAttachedToWindow被调用的时候通过传入的info对象的mHandler对象来执行,此时和第一种情况本质上是一样的,也是通过其依附的Window所关联的ViewRootImol对象的mHandler成员的post方法
  • 不管哪种情况,如果该Runnable能够被执行,那么最终的本质都是通过该View所依附的Window的ViewRootImol对象的mHandler成员的post方法来完成的

7.0以下系统的View#post实现

再来看下API23的View的post的实现

可以看到当mAttachInfo不为null时的逻辑和7.0系统是一样的,这里就不在赘述了,可以看到当其为null的时候的逻辑和7.0系统是不一样的,这里是直接调用了 ViewRootImpl.getRunQueue()来执行post方法。看下ViewRootImpl#getRunQueue()的实现逻辑。

这里的sRunQueues是ViewRootImpl内的一个静态的ThreadLocal,我们知道ThreadLocal是和线程相关的,也就是说存储的RunQueue对象是和线程相关的。注意这很重要后面会提到。再来看下getRunQueue返回的是一个RunQueue对象,该对象是ViewRootImpl内的一个静态内部类,其作用和前面提到的HandlerActionQueue完全一样,代码也几乎完全相同,也包含一个executeActions方法来执行存储的Runnable,这里就不贴上来了,有了前面的分析经验,我们就知道只需要找到RunQueue的executeActions方法是在哪里被调用的就行了,答案是在performTraversals中,伪代码如下:

而我们知道performTraversals是运行在UI线程中的,所以如果前面 ViewRootImpl.getRunQueue().post(action);的逻辑是运行在子线程中的,那么则在performTraversals中通过getRunQueue得到的RunQueue对象是一个新创建的实例,而不是前面在子线程中调用ViewRootImpl.getRunQueue().post(action);创建时存储的那个实例,因此在子线程中调用View#post的Runnable永远也不会被执行。另外在RunQueue的注释上也可以看到RunQueue中保存的Runnable谷歌工程师期望的是将会在下一次调用performTraversals的时候执行。如下所示:

但通过前面的分析我们知道除非是在UI线程中调用View#post,否则下一次执行performTraversals仍然是不会被执行的。所以这可以说是安卓的一个bug,谷歌工程师在7.0系统上将这个缺陷修复了,具体修复逻辑就是最开始分析的源码了。而且谷歌在官网文档的7.0系统相关说明页上已经做了说明:https://developer.android.com/about/versions/nougat/android-7.0-changes#other

到这里最开始提到的在奇酷手机上Runnable中的代码不被执行的原因就清楚了,因为调用View#post的时候该WebView控件还未被添加到对话框的Window中,所以导致mAttachInfo为null,进而导致该Runnable被存储到RunQueue中期待在下一次调用performTraversals时执行,但是因为这段逻辑存在bug,导致如果是在子线程中调用的View#post,存储的Runnable永远不会被执行,即使后面将该WebView添加到对话框中。而在7.0及其以上系统,调用View#post时虽然也未被添加到对话框的Window中导致mAttachInfo也为null,但是当后面将WebView添加到对话框的时候会执行Runnable中的代码。

另外从前面的源码分析中可以看到,不管系统版本是7.0以上还是以下,当View被attach到某个window之后,本质上和Handler#post一样。

总结

  • 所以可以看到从安卓7.0 系统(API24)开始View#post还是比较好理解的,简单来说就是当该View被attach到某个Window过才会执行该View post的Runnable中的代码。如果调用post的时候该View已经附加到某个Window上,那么会将Runnable post到UI线程中,如果调用post的时候该View还未被添加到任何Window中,那么会将其存储起来,等待附加的时候执行。所以如果post某个Runnable,但是从未将该View添加到某个窗口中,那么该Runable中的代码将永远不会被执行。注意这和7.0以下的永远不被执行的情况不同,这种情况是符合常理的,因为可以认为一个不依附任何Window的View是没有意义的,此时它只是一个简单的java类,不应该具备视图的功能。
  • 对于7.0以下系统:当调用post时如果该View已经attach到了某个Window上,此时和7.0系统处理逻辑是一样的;如果调用post的时候该View还未被添加到任何Window中,那么会将Runnable存储起来,等待下一次执行performTraversals时执行,如果post是在子线程中调用的,那么其方法体中的代码将永远不会被执行,如果是在UI线程中调用的那么会在下一次performTraversals时执行。所以在7.0以下系统一定要注意确保View被attach到某个Window之后,在调用其post方法,否则post的Runnable中的代码很可能不会执行,尤其是如果post在子线程中调用那么可以肯定永远不会执行。
  • 当View被attach到某个Window上后,其效果等同于Handler#post,此时2者可以认为等价

 

 

打赏

点赞
  1. 终于找到个可以联系你的方式了!!!小哥哥,不能留个微信号的莫!!!如果可以,给个微信号呗。

发表评论

电子邮件地址不会被公开。 必填项已用*标注