美文网首页Android开发Android技术知识Android开发
线程与更新UI,消除偏见,细谈原理

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

作者: 积木zz | 来源:发表于2020-11-18 09:29 被阅读0次

    前言

    相信不少读者都阅读过相类似的文章了,但是我还是想完整的把这之间的关系梳理清楚,细节聊好,希望你也能从中学到一些。

    进入正题,大家应该都听过这样一句话——“UI更新要在主线程,子线程更新UI会崩溃”。久而久之就感觉这是个真理,甚至被认为是“官方结论”。

    但是如果问你,官方什么时候在哪里说过这句话,你会不会有点懵。而且就算是官方说的,也就不一定对的是吧,众所周知,Google官方文档一直都有点说的不清不楚,需要我们进行大量实践得出实际的结论。

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

    下面是首先需要关注的行为变更 (无论您应用的 targetSdkVersion 是多少):
    外部存储访问权限 - 应用无法再访问外部存储空间中其他应用的文件。

    其实经过实践会发现,外部存储访问权限还是会和targetSdkVersion有关,具体可以看这篇Android11适配指南

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

    案例一,子线程更新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方法中更新了按钮显示文字,加了延时。

    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也会崩溃!你没有看错!)

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

    换个手机——三星s8,重新运行,在子线程中的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)但是但是,为啥换个手机又不崩溃了呢?

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

    所以我猜测在部分手机上,应该是改变了Toast的设置,当调用setText方法的时候,就会马上结束当前的Toast展示,调用hide方法。然后再进行Toast文字修改并展示,也就是刚才第三点的做法。

    当然这只是我的猜测,有研究过手机源码的大神也可以补充下。

    总结

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

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

    参考

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

    https://www.cnblogs.com/fangg/p/12917235.html

    拜拜

    有一起学习的小伙伴可以关注下我的公众号——码上积木❤️❤️
    每日三问知识点/面试题,积少成多。

    相关文章

      网友评论

        本文标题:线程与更新UI,消除偏见,细谈原理

        本文链接:https://www.haomeiwen.com/subject/jvvqiktx.html