线程与更新UI,消除偏见,细谈原理

前言

相信很多读者都阅读过相相似的文章了,可是我仍是想完整的把这之间的关系梳理清楚,细节聊好,但愿你也能从中学到一些。html

进入正题,你们应该都听过这样一句话——“UI更新要在主线程,子线程更新UI会崩溃”。长此以往就感受这是个真理,甚至被认为是“官方结论”。java

可是若是问你,官方何时在哪里说过这句话,你会不会有点懵。并且就算是官方说的,也就不必定对的是吧,众所周知,Google官方文档一直都有点说的不清不楚,须要咱们进行大量实践得出实际的结论。android

就比如以前的Android11更新文档,我也是看了很久,经过一个个实践才写出了适配指南,而后就发现其中一个比较明显的BUGGoogle官方有说过这样一句:安全

下面是首先须要关注的行为变动 (不管您应用的 targetSdkVersion 是多少): 
外部存储访问权限  - 应用没法再访问外部存储空间中其余应用的文件。app

其实通过实践会发现,外部存储访问权限仍是会和targetSdkVersion有关,具体能够看这篇Android11适配指南ide

废话有点多了,今天仍是经过实践案例,看看这个关于线程和UI更新的 “官方结论” 正确吗?oop

案例一,子线程更新button文字

1)onCreate方法中更新了按钮显示文字,修改Button的宽度为固定或者wrap_content,都不崩溃。布局

<Button
        android:id="@+id/btn_ui"
        android:layout_width="100dp"
        android:layout_height="70dp"
        android:layout_centerInParent="true"
        android:text="我是一个按钮"
        />

        //或者

    <Button
        android:id="@+id/btn_ui"
        android:layout_width="wrap_content"
        android:layout_height="70dp"
        android:layout_centerInParent="true"
        android:text="我是一个按钮"
        />        


    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_ui)

        thread {
            btn_ui.text="年轻人要讲武德"
        }
    }

2)onCreate方法中更新了按钮显示文字,加了延时。post

Button的宽度为固定不崩溃。
Button的宽度为wrap_content,崩溃报错——Only the original thread that created a view hierarchy can touch its views学习

<Button
        android:id="@+id/btn_ui"
        android:layout_width="100dp"
        android:layout_height="70dp"
        android:layout_centerInParent="true"
        android:text="我是一个按钮"
        />

        //或者

    <Button
        android:id="@+id/btn_ui"
        android:layout_width="wrap_content"
        android:layout_height="70dp"
        android:layout_centerInParent="true"
        android:text="我是一个按钮"
        />   
        

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_ui)

        thread {
            Thread.sleep(3000)
            btn_ui.text="年轻人要讲武德"
        }
    }

案例一分析

有点懵的感受,不慌,来看看崩溃信息。

崩溃是在按钮宽度为wrap_content,也就是根据内容设定宽度,而后3秒以后去更新按钮文字,发生了崩溃。相比之下,有两个崩溃影响点须要注意下:

  • 宽度wrap_content。若是设置为固定值,是不会崩溃的,见案例2,因此是否是跟布局改变的逻辑有关呢?
  • 延时3秒。若是不延时的话,即便是wrap_content也不会崩溃,见案例1,因此是否是跟某些类的加载进度有关呢?

带着这些疑问去源码中找找答案。先看看崩溃日志:

android.view.ViewRootImpl$CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views.
        at android.view.ViewRootImpl.checkThread(ViewRootImpl.java:9219)
        at android.view.ViewRootImpl.requestLayout(ViewRootImpl.java:1600)
        at android.view.View.requestLayout(View.java:24884)

能够看到是ViewRootImplrequestLayout中检查线程的时候报错了,那咱们就看看这个方法:

@Override
    public void requestLayout() {
        if (!mHandlingLayoutInLayoutRequest) {
            checkThread();
            mLayoutRequested = true;
            scheduleTraversals();
        }
    }

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

在解开谜底以前,咱们先了解下ViewRootImpl

ViewRootImpl

Activity从建立到咱们看到界面,实际上是经历了两个过程:加载布局和绘制

  • 加载布局

加载布局其实就是咱们经常使用的setContentView(int layoutResID)方法,这个方法主要作的就是新建了一个DecorView,而后根据activity设置的主题(theme)或者特征(Feature)加载不一样的根布局文件,最后再加载layoutResID资源文件。为了方便你们理解,画了一张图:

加载布局流程

这里的最后一步是调用了LayoutInflaterinflate()方法,这个方法只作了一件事,就是解析xml文件,而后根据节点生成了view对象。最后造成了一个完整的DOM结构,返回最顶层的根布局View。(DOM是一种文档对象模型,他的层次结构是除了顶级元素,全部元素都被包括到另外的元素节点中,有点像家谱树结构,很典型的就是html代码解析)

到这里,一个有完整view结构的DecorView就建立出来了,可是它尚未被绘制,也没有被显示到手机界面上。

  • 绘制

绘制的流程发生在handleResumeActivity中,熟悉app启动流程的朋友应该知道,handleResumeActivity方法是用来触发onResume方法的,这里也完成了DecorView的绘制。再来一张图:

绘制流程

  • 总结

由此咱们能够得出一些结论:
1)setContentView用来新建DecorView并加载布局的资源文件。
2)onResume方法以后,会新建一个ViewRootImpl,做为DecorViewparentDecorView进行测量,布局和绘制等操做。
3)PhoneWindow做为Window的惟一子类,存储了DecorView变量,并对其进行管理,属于ActivityView交互的中间层。

分析崩溃

好了。再回来看看崩溃的缘由:

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

能够看到是由于当前线程currentThread不是mThread的时候,就会崩溃,报的错误是 “只有建立视图层次结构的原始线程才能触摸它的视图” ,看到这里是否是猜到一些了,这个mThread难道就是“建立视图的原始线程”?

经过查找,其实这个mThread是在ViewRootImpl被建立的时候赋值的:

public ViewRootImpl(Context context, Display display) {
    mThread = Thread.currentThread();
}

而经过上方分析Activity加载布局过程得知,ViewRootImpl实例化发生在onResume以后,用来绘制DecorViewwindow上。

因此咱们就能够得知崩溃的真正缘由,就是当前线程不是ViewRootImpl建立时候的线程就会崩溃。翻译的仍是比较准确的,只有建立视图的原始线程才能修改这个视图,听起来也蛮有道理的,我创造了你才有权利改变你,有那味了。

而后再看看前面的案例:

  • 案例一,在onCreate中修改Button,这时候只是在修改DecorView,都没建立ViewRootImpl,也就没走到因此checkThread方法,固然不会崩溃了。ViewRootImpl的建立是在onResume以后。

  • 案例二,延时3秒以后,界面也绘制完成了,建立ViewRootImpl显然是在主线程完成的,因此mThread为主线程,而改变Button的线程为子线程,因此setText方法会触发requestLayout方法从新绘制,最终致使崩溃。

可是,Button的宽度设置为固定值咋又不崩溃了?难道就不会执行checkThread方法了?奇怪。

找找setText的源码能够发现,有一个方法是负责检查是否须要新的布局——checkForRelayout()

private void checkForRelayout() {
        // If we have a fixed width, we can just swap in a new text layout
        // if the text height stays the same or if the view height is fixed.

        if ((mLayoutParams.width != LayoutParams.WRAP_CONTENT
                || (mMaxWidthMode == mMinWidthMode && mMaxWidth == mMinWidth))
                && (mHint == null || mHintLayout != null)
                && (mRight - mLeft - getCompoundPaddingLeft() - getCompoundPaddingRight() > 0)) {
            

            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;
                }

               //...
            }

            // 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();
        }
    }

能够看到,若是布局大小没有改变的话,咱们是不会去执行requestLayout方法从新进行布局绘制的,只会调用autoSizeText方法计算文字大小,invalidate绘制文字自己,因此当咱们宽高设置为固定值,setText()方法就不会执行到requestLayout()方法了,天然也就执行不到checkThread()方法了。

反思

解决了问题,还须要反思下,为何须要checkThread检查线程呢?

  • 检查线程,其实就是检查更新UI操做的当前线程是否是当初建立UI的那个线程,这样就保证了线程安全,由于UI控件自己不是线程安全的,可是加锁又显得过重,会下降View加载效率,毕竟是跟交互相关的。因此就直接经过判断线程这一逻辑来造成一个单线程模型,保证View操做的线程安全。

案例二,子线程和主线程分别showToast

1)onCreate方法中弹出toast,崩溃——Can't toast on a thread that has not called Looper.prepare()

override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_ui)

        thread {
            showToast("年轻人要讲武德")
        }
    }

2)onCreate方法中弹出toast,增长Looper.prepare(),Looper.loop()方法。不崩溃。

加上延时3秒,不崩溃。

override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_ui)

        thread {
            //Thread.sleep(3000)

            Looper.prepare()
            showToast("年轻人要讲武德")
            Looper.loop()
        }
    }

3)使用同一个Toast实例,在子线程中的Toast没消失以前点击按钮,在主线程中修改Toast文字并显示,则程序崩溃——Only the original thread that created a view hierarchy can touch its views.。
(主线程更新UI也会崩溃!你没有看错!)

从新运行,在子线程中显示并消失后,点击按钮,不崩溃。

换个手机——三星s9,从新运行,在子线程中的Toast没消失以前点击按钮,不崩溃。

lateinit var mToast: Toast

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_ui)

        thread {
            Looper.prepare()
            mToast=Toast.makeText(this@UIMainActivity,"年轻人要讲武德",Toast.LENGTH_LONG)
            mToast.show()
            Looper.loop()
        }

        btn_ui.setOnClickListener {
            mToast.setText("耗子尾汁")
            mToast.show()
        }
    }

案例二分析

在解开谜底以前,咱们先了解下Toast

Toast原理

Toast.makeText(this,msg,Toast.LENGTH_SHORT).show()

简单又经常使用的一句代码,仍是经过流程图的方式看看它是怎么建立并展现的。

Toast流程图

DecorView加载绘制流程一模一样,首先加载了布局文件,建立了View。而后经过addView方法,再次新建一个ViewRootImpl实例,做为parent,进行测量布局和绘制。

崩溃分析

1)首先,说下第一次崩溃——Can't toast on a thread that has not called Looper.prepare(),也就是在建立Toast的线程必需要有Looper在运行。

根据源码咱们也得知Toast的显示和隐藏都是经过Handler传递消息的,因此必需要有Handler使用环境,也就是绑定Looper对象,而且经过loop方法开始循环处理消息。

2)第二次崩溃——Only the original thread that created a view hierarchy can touch its views

这里的崩溃和以前更新Button同样的报错,因此咱们有理由怀疑也是同样的缘由,在不一样的线程调用了ViewRootImplrequestLayout方法。

咱们看到点击按钮的时候,调用了mToast.setText()方法,咦,这不就跟案例一如出一辙了吗。

setText方法中调用了TextViewsetText()方法,而后因为Toast中的TextView宽高都是wrap_content的,因此会触发requestLayout方法,最后会调用到最上层View也就是ViewRootImplrequestLayout方法。

因此崩溃的缘由就是由于Toast在第一次在子线程中show的时候,新建了一个ViewRootImpl实例,绑定了当前线程也就是子线程到mThread变量。
而后同一个Toast,在主线程调用setText方法,最终会调用到ViewRootImpl的requestLayout方法,引发线程检查,当前线程也就是主线程并非当初那个建立ViewRootImpl实例的线程,因此致使崩溃。

3)那为何等Toast消失以后,点击按钮又不崩溃了呢?

缘由就在Toast的hide方法中,最终会调用到View的assignParent方法,将Toast的mParent设置为null,也就是ViewRootImpl设置为null了。因此调用setText方法的时候也就执行不到requestLayout方法了,也就不会到checkThread方法检查线程了。贴下代码:

public void handleHide() {
    if (mView != null) {
        if (mView.getParent() != null) {
            mWM.removeViewImmediate(mView);
        }
        mView = null;
    }
}

removeViewImmediate--->removeViewLocked

private void removeViewLocked(int index, boolean immediate) {
    ViewRootImpl root = mRoots.get(index);
    View view = root.getView();
    
    //...
    if (view != null) {
        view.assignParent(null);
        if (deferred) {
            mDyingViews.add(view);
        }
    }
}

void assignParent(ViewParent parent) {
        if (mParent == null) {
            mParent = parent;
        } else if (parent == null) {
            mParent = null;
        } else {
            throw new RuntimeException("view " + this + " being added, but"+ " it already has a parent");
        }
}

4)可是可是,为啥换个手机又不崩溃了呢?

这是我偶然发现的,在个人三星S9手机上,运行时不会崩溃的,并且界面给个人反馈并非修改当前页面上Toast上的文字,而是像新建了一个Toast展现,即时代码中写的是setText方法。

因此我猜想在部分手机上,应该是改变了Toast的设置,当调用setText方法的时候,就会立刻结束当前的Toast展现,调用hide方法。而后再进行Toast文字修改并展现,也就是刚才第三点的作法。

固然这只是个人猜想,有研究过手机源码的大神也能够补充下。

总结

任何线程均可以更新UI,也都有更新UI致使崩溃的可能。

其中的关键就是view被绘制到界面时候的线程(也就是最顶层ViewRootImpl被建立时候的线程)和进行UI更新时候的线程是否是同一个线程,若是不是就会报错。

参考

https://www.jianshu.com/p/1cdd5d1b9f3d

http://www.javashuo.com/article/p-zqlbhvut-nv.html

拜拜

有一块儿学习的小伙伴能够关注下❤️个人公众号——码上积木,天天剖析一个知识点,咱们一块儿积累知识。