安卓子线程就真的不能更新UI吗?

前言:

看到这个标题,初学者肯定说,怎么可能嘛,安卓不是规定UI线程才能刷新UI的吗?

稍微资深一些的安卓会说,不就是在刚启动的时候在子线程中去刷新嘛,这个面试经常被问到。

资深的安卓会说,安卓的定义是在UI创建线程去刷新,如果子线程创建的话,那么子线程也可以刷新UI的。

这些就完了吗?当然没有,这次就带了第三种子线程刷新UI的场景,并且对前两种场景的原理进行详细的分析。

场景一:

1.现象

onCreate方法里面创建子线程,然后线程里面去更新UI。

override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        Thread {
            mResult.visibility = View.GONE
        }.start()
    }

这样调用是不会有任何问题的,但是如果我们对代码稍加修改,改成下面这样,就会崩溃了。

 override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        Thread {
            Thread.sleep(1000)
            mResult.visibility = View.GONE
        }.start()
    }

2.崩溃触发点

这是为什么呢?那我们就根据崩溃日志反着找原因吧。

android.view.ViewRootImpl$CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views.
        at android.view.ViewRootImpl.checkThread(ViewRootImpl.java:9312)
        at android.view.ViewRootImpl.requestLayout(ViewRootImpl.java:1772)
        at android.view.View.requestLayout(View.java:25697)
        at android.view.View.requestLayout(View.java:25697)
        at android.view.View.requestLayout(View.java:25697)
        at android.view.View.requestLayout(View.java:25697)
        at android.widget.RelativeLayout.requestLayout(RelativeLayout.java:380)
        at android.view.View.requestLayout(View.java:25697)
        at android.view.View.setFlags(View.java:16377)
        at android.view.View.setVisibility(View.java:11896)
        at com.xt.client.activitys.ThreadRefreshActivity$onCreate$1.run(ThreadRefreshActivity.kt:15)
        at java.lang.Thread.run(Thread.java:920)

对应的ViewRootImpl的checkThread方法做的就检查,如果当前调用的线程不是绑定的线程,那么就会跑出异常发生崩溃。

void checkThread() {
        if (mThread != Thread.currentThread()) {
            throw new CalledFromWrongThreadException(
                    "Only the original thread that created a view hierarchy can touch its views.");
        }
    }

3.推测原因

我们可以发现checkThread是被requestLayout方法调用的,所以至此我们可以有这样一个猜测:

调用requestLayout这个方法才会发生崩溃,但是如果我们在子线程调用setVisibility,并不调用requestLayout方法。

后续在主线程调用requestLayout触发全局刷新,是不是就可以避免发生崩溃?

4.验证推测1-setVisibility方法

为了验证我们的推测,那我们就去看一下setVisibility方法,里面直接调用了setFlags方法。我们只要搜requestLayout()方法即可。这里我们发现,只要是显示状态状态的变化都会触发requestLayout方法,那么requestLayout在这里一定是会被调用的。那我们就继续往下看

 void setFlags(int flags, int mask) {
        ...
    /* Check if the GONE bit has changed */
    if ((changed & GONE) != 0) {
        needGlobalAttributesUpdate(false);
        requestLayout();
    }
    ...
    if ((changed & DRAW_MASK) != 0) {
            if ((mViewFlags & WILL_NOT_DRAW) != 0) {
                if (mBackground != null
                        || mDefaultFocusHighlight != null
                        || (mForegroundInfo != null && mForegroundInfo.mDrawable != null)) {
                    mPrivateFlags &= ~PFLAG_SKIP_DRAW;
                } else {
                    mPrivateFlags |= PFLAG_SKIP_DRAW;
                }
            } else {
                mPrivateFlags &= ~PFLAG_SKIP_DRAW;
            }
            requestLayout();
            invalidate(true);
        }
}

4.验证推测2-View.requestLayout()方法

requestLayout方法里面会调用mParent.requestLayout();

正常情况下,这里会一层一层的向上通知,最上面一层就是DecorView,而DecorView的mParent为ViewRootImpl,主线程检查也就是在ViewRootImpl中的requestLayout方法中执行的。

但是场景一的情况下,我们在这里断点调试一下就会发现,这里的mParent全部都是null,所以就不会一层一层的向上传递,所以也就不会发生崩溃了。

if (mParent != null && !mParent.isLayoutRequested()) {
            mParent.requestLayout();
        }

5.mParent赋值时机

我们找到了原因是mParent为null所以不会崩溃,那我们接下来肯定就要了解下,mParent是什么时候被赋值的:这个我们分两种场景,ViewGroup和DecorView,

首先是ViewGroup,这个简单,就是在addView的时候:

 private void addViewInner(View child, int index, LayoutParams params,
            boolean preventRequestLayout) {
...
if (preventRequestLayout) {
            child.assignParent(this);
        } else {
            child.mParent = this;
        }
...
}

DecorView的话,则是在ViewRootImpl.setView的时候:

public void setView(View view, WindowManager.LayoutParams attrs, View panelParentView,
            int userId) {
...
 view.assignParent(this);
...
}

setView是在resume的时候被调用的:

 所以我们得出一个结论,最顶层的mParent是在resume方法之后被赋值的。

6.resume方法中子线程调用也不会发生崩溃

你猜这就完成了吗?当然没有,我们上面知道了是在resume的时候给mParent赋值的,那我们就把上面那个线程刷新UI的代挪到onResume方法中,如下,这时候mParent肯定是有值的,但是我们仍然发现,程序竟然还是正常运行没有崩溃。why?

 override fun onResume() {
        super.onResume()
        Thread {
            mResult.visibility = View.GONE
        }.start()
    }

我们回头看一下之前requestLayout的判断方法:

(mParent != null && !mParent.isLayoutRequested())有两个条件,虽然DecorView的mParent不为空了,但是后面这个返回的仍然是false,所以最终的requestLayout方法仍然没有执行。

这时候我们就要看一下判断条件了:

  public boolean isLayoutRequested() {
        return (mPrivateFlags & PFLAG_FORCE_LAYOUT) == PFLAG_FORCE_LAYOUT;
    }

全局搜索代码我们可以知道,PFLAG_FORCE_LAYOUT是在layout方法中赋值的。同样,我们打个断点,看看DecorView的layout方法什么时候被执行。如下图所示:

至此,我们终于全部了解清楚了。resume方法执行的时候,还只是关联到ViewRootImpl上而已,最终还需要执行一次doFrame全局刷新,才会开启对应的主线程检查,否则在此之前,子线程都是可以操作UI的。(PS:当然,只有执行了首次的全局刷新之后,UI才会呈现到屏幕上,在此之前屏幕上都是不会展示的)

场景二:

1.现象

我们在做一个实验,点击按钮后,启动一个子线程去显示dialog,代码如下:

这时候我们发现,弹窗是可以正常显示的,也不会报错。

override fun clickItem(position: Int) {
        if (position == 0) {
            Thread {
                //dialog显示
                Looper.prepare()
                Handler().post {
                    val dialog = Dialog(this)
                    dialog.setTitle("子线程刷新的内容-场景2")
                    dialog.show()
                }
                Looper.loop()
            }.start()
            return
        }
        if (position == 1) {
            Thread {
                mResult.text = "子线程刷新的内容-场景3"
            }.start()
            return
        }
    }

2.探索原因

一样,我们既然知道执行UI线程检查的方法在ViewRootImpl的requestLayout方法中。那我们就断点过去看看为什么没有报错。这时候我们发现,mThread竟然是一个子线程,而当前线程就是子线程,所以自然就不会报错了。

 void checkThread() {
        if (mThread != Thread.currentThread()) {
            throw new CalledFromWrongThreadException(
                    "Only the original thread that created a view hierarchy can touch its views.");
        }
    }

3.原因分析

所以,下一步我们就找一下,mThread是啥时候被赋值的。我们发现就是在初始化ViewRootImpl的时候取得当前的线程。

 public ViewRootImpl(@UiContext Context context, Display display, IWindowSession session,
            boolean useSfChoreographer) {
       ...
   mThread = Thread.currentThread();
...
}

而ViewRootImpl的初始化是在WIndowManagerGlobal.addView的时候;

我们回头看一下Dialog的show方法:

public void show() {
        ...
        mWindowManager.addView(mDecor, l);
        ...
    }

至此,我们知道所有的原因了。本身show方法就在子线程,所以addView也发生在子线程,因为ViewRootImpl绑定的就是子线程,因此继续在子线程中进行UI操作就不会报错。相反,如果切换到主线程操作Dialog,反而会报错

场景三:

前两种都是相对老一些的,网上也有一些其它的分析文章。近期在做项目的时候,发现还有一种场景子线程进行UI操作也不会抛出异常。

1.现象

这次我们更简单了,直接点击按钮触发一个子线程,子线程中修改textView的显示文本。

这次,我们还是发现,正常运行,text文本会变掉,并且不会报错。这又是为什么呢?

 override fun clickItem(position: Int) {
    if (position == 1) {
            Thread {
                mResult.text = "子线程刷新的内容-场景3"
            }.start()
            return
        }
」

xml中text显示如下:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/fragment_layout"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@android:color/white"
    android:orientation="vertical">


    ...

    <TextView
        android:id="@+id/result_text"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_alignParentBottom="true"
        android:gravity="center"
        android:text="result" />


</RelativeLayout>

2.原因排查

有了之前的排查套路,所以我们自然的,仍然断点到checkThread方法中,看看为什么没有发生异常。这时候我们惊奇的发现,修改了text后,checkThread方法并没有被执行。甚至继续追查下去,发现ViewGroup的requestLayout方法都没有被执行。

这又是为什么呢?

3.checkForRelayout检查

这次我们从前向后排查,最终定位到了textView的checkForRelayout方法。

setText()->checkForRelayout();

 @UnsupportedAppUsage
    private void checkForRelayout() {
       ...

            if (mEllipsize != TextUtils.TruncateAt.MARQUEE) {
                // In a fixed-height view, so use our new text layout.
                if (mLayoutParams.height != LayoutParams.WRAP_CONTENT
                        && mLayoutParams.height != LayoutParams.MATCH_PARENT) {
                    autoSizeText();
                    invalidate();
                    return;
                }

                // Dynamic height, but height has stayed the same,
                // so use our new text layout.
                if (mLayout.getHeight() == oldht
                        && (mHintLayout == null || mHintLayout.getHeight() == oldht)) {
                    autoSizeText();
                    invalidate();
                    return;
                }
            }

            // We lose: the height has changed and we have a dynamic height.
            // Request a new view layout using our new text layout.
            requestLayout();
            invalidate();
        } else {
            // Dynamic width, so we have no choice but to request a new
            // view layout with a new text layout.
            nullLayouts();
            requestLayout();
            invalidate();
        }
    }

我们看代码有两处直接return了,所以并没有执行后面的requestLayout方法。

简单看一下判断条件后我们可以知道,如果宽度不是自适应宽度,并且当前不是跑马灯效果的情况下,存在两种情况直接会直接return:

1.高度写死。

2.高度相对于设置之前没有变化。

哦吼,知道了,细想一下也可以理解,既然高度没有变化,宽度自身变化又不影响外界,所以自己刷新就好了,就没必要通知父级也随之刷新了。因此,子线程刷新也不会有问题。

2022年7月31日补充:

还是上面那个例子,我们在极限一点,即然高度没有变化就不会出问题,那我们就高度变化一下试一下呢?把本来一行显示的长度加长到2行。

最终实验结果果然是崩溃了。安卓的渲染体系,针对自身进行判断,一旦调用了requestLayout方法,就会开始逐级上报,一直通知到最上层ViewRootImpl,然后再由ViewRootImpl发起整个绘制的流程。也就是说哪怕一共5个层级(ViewRootImpl,DecorView,ContentView,LinearLayout,TextView),仅仅只是下面两级的内部发生变化,也会导致整颗渲染树进行完整的绘制流程,这一点,我觉得其实是不好的。

结论:

通过对这三种情况的分析,我们可以得出这样一个结论, 子线程操作UI报不报错,其实关键进行checkThread方法有没有被执行,执行的结果如何。(PS:有点废话的感觉,但结论就是这样)

google之所以设计UI线程才能操作UI,怕的就是也是多线程操作UI会出现各种显示的异常。如果界面还没有显示,或者影响的范围只是自身,那么相互之间就不会有影响,所以就没必要进行UI线程检查。

本文示例代码链接:

android_all_demo/ThreadRefreshActivity.kt at master · aa5279aa/android_all_demo · GitHub

  • 6
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

失落夏天

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值