Android 源码分析问题(一)—— 子线程不能更新UI吗?

问题引入

Android 开发法则之一不能在子线程更新 UI,这个问题主要是 Android 关于 View 的一系列操作有一套十分复杂的逻辑。

举个很简单的例子,如果一个 View 被修改,如果修改的是尺寸,那么势必引起一系列的重新测量,布局,绘制。这个过程是非常耗时的,如果在一个并发的情况下,那么就可能会引起各种问题,比如给一个 View 修改了尺寸,那么这个 View 会引起界面的重绘,这时另一个 View 也修改了尺寸,那么也会引起重绘,这时候一系列的计算和操作会在并发的情况下穿插进行,不说对程序会造成什么影响,但是每个过程中的计算肯定是错误百出的。

所以 Google 在我们对 View 在子线程进行更新做了一个线程判断:如果修改 View 的线程不在主线程,那么便会抛出异常,引起程序崩溃。

源码分析

为什么不能在子线程更新UI

我们先画一个 TextView

    <TextView
        android:id="@+id/tv_text"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Hello World!"/>

我在 onCreate() 方法中为一个 textView 修改 text。

    new Thread(new Runnable() {
        @Override
        public void run() {
            try{
                //为什么要睡眠后面会说
                Thread.sleep(200);
            }catch (InterruptedException e){
            }
            textView.setText();
        }
    }).start(); 
    android.view.ViewRootImpl$CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views.
        at android.view.ViewRootImpl.checkThread(ViewRootImpl.java:7753)
        at android.view.ViewRootImpl.requestLayout(ViewRootImpl.java:1225)

这里我贴出了相关的报错,可以看到其中报错方法中有一个很眼熟的 requestLayout() 方法。

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

这里如果对 requestLayout() 方法不熟悉的朋友可以去查阅下,它在 ViewRootImpl 中的功能就是让 ViewRoot 开始 scheduleTraversals() 方法,这个方法里便包含了我们熟悉的测量,布局,绘制。

如果我们修改了一个 View,如果修改结果影响了它的尺寸,那么就会触发这个方法,并开始新的测量、布局、绘制等操作,在它里面有个 checkThread() 方法。

这时候我们看向 ViewRootImpl 中的 checkThread() 方法。

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

可以看出这个方法对当前线程做了判断,如果不是主线程,那么就抛出异常。

那接下来就去看下 mThread 这个值是在什么时候被赋值的。

    //这里我对这句赋值代码的上下部分做了省略。
    public ViewRootImpl(Context context, Display display) {
        ...
        mThread = Thread.currentThread();
        ...
    }

可以看到 mThread 是在 ViewRootImpl 的构造方法中被赋值,此时的 Thread.currentThread() 就是主线程。

看到这里已经可以知道为什么不能在子线程更新UI了。

在子线程更新UI

我先解决一下上面的问题,为什么我在 onCreate() 方法中开启新线程先要让它 sleep 一会再更新 View 呢。

如果有兴趣可以写一个这样的例子,分别运行下有无 sleep 的情况看下。

我们去掉 sleep() 方法后可以发现,程序正常运行,并没有报错。

这里我比较好奇的试了下,不 sleep 子线程的情况下分别在 onStart()、onResume() 、 onPause()等方法依次尝试。

神奇的发现,在 onPause() 方法中,才会引起程序崩溃。

这时候可以简单的分析一下,首先我们是在 ViewRootImpl 中判断是否在主线程的,而 ViewRootImpl 中的 mThread 是在构造方法中被赋值的,这时候我们应该可以猜测下 ViewRootImpl 是在 onResume() 回调和 onPause() 回调之间被构造。

直接源码走到 ActivityThread 的 handleResumeActivity() 方法。

这里只显示关键代码


    @Override
    public void handleResumeActivity(IBinder token, boolean finalStateRequest, boolean isForward,
            String reason) {
        // TODO Push resumeArgs into the activity for consideration
        //其实在这里执行的 onResume() 回调
        final ActivityClientRecord r = performResumeActivity(token, finalStateRequest, reason);
        if (r == null) {
            // We didn't actually resume the activity, so skipping any follow-up actions.
            return;
        }
        
        final Activity a = r.activity;
        
        if (r.activity.mVisibleFromClient) {
            //这时候视图才可见
            r.activity.makeVisible();
        }
    }

上面的注解引出了一个小知识点,这里不详解了。

一个 Activity 在被用户可见时是执行了 makeVisible() 方法之后,进去一看。

    void makeVisible() {
        if (!mWindowAdded) {
            ViewManager wm = getWindowManager();
            //这里添加了 view
            wm.addView(mDecor, getWindow().getAttributes());
            mWindowAdded = true;
        }
        mDecor.setVisibility(View.VISIBLE);
    }

进到 addView() 方法中一看。

    @Override
    public void addView(@NonNull View view, @NonNull ViewGroup.LayoutParams params) {
        applyDefaultToken(params);
        mGlobal.addView(view, params, mContext.getDisplay(), mParentWindow);
    }

这里又调用了 WindowManagerGlobal 中的 addView 方法。

 public void addView(View view, ViewGroup.LayoutParams params,
            Display display, Window parentWindow) {
              view.setLayoutParams(wparams);
            ...
            root = new ViewRootImpl(view.getContext(), display);
            mViews.add(view);
            mRoots.add(root);
            mParams.add(wparams);

            // do this last because it fires off messages to start doing things
            try {
                root.setView(view, wparams, panelParentView);
            } catch (RuntimeException e) {
                // BadTokenException or InvalidDisplayException, clean up.
                if (index >= 0) {
                    removeViewLocked(index, true);
                }
                throw e;
            }
            ...
    }

可以看见这里终于创建了一个 ViewRootImpl,并且将所有 View 的根 View —— DecorView 传入(这里就不对 DecorView 介绍了)。

总结?

知道了 ViewRootImpl 的构造时机,我们终于知道了可以在 onResume() 以及其之间的回调在子线程更新 UI, 但这样我们就可以随意在子线程更新 UI 了吗,不,虽然不会有隐患,但是我们给 UI 的更新时序将会因为异步问题被打乱,并且如果其中有耗时操作,当 ViewRootImpl 被创建后,那么就会引起报错。。

为什么总结后面有个问号? 不要以为这样就是最终答案了。

我们换个思路,如何避开 checkThread() 方法的检测。

怎么样更新 UI 不会被检测当前线程

这是一个比较有意思的发现。

这里我们将 TextView 的高宽改成固定大小。

    <TextView
        android:id="@+id/tv_text"
        android:layout_width="100dp"
        android:layout_height="100dp"
        android:text="Hello World!"/>

这时候我们在 ViewRootImpl 构造完之后的时机修改它的文本内容。

另人惊讶的是程序仍在正常运行,这是为什么呢?

或许我们还可以调用 View 的 layout 方法修改它的布局,调用 invalidate() 让 View 重绘,但是我们发现程序并没出现我们想象的崩溃问题。

还记得我们之前抛出的报错信息中的 requestLayout() 方法吗,这个方法中有一个 checkThread() 方法来检测线程。

很明显,如果我们触发了 checkThread() 方法,那么一定会抛出异常。

很明显,当我们更新 View 时,如果引起了它的尺寸变化,那么将会重新引起测量。需要重新测量时将会调用到 ViewRootImpl 的一系列 request 方法,在这里 ViewRootImpl 将会检测线程。

总结 二

当我们更新 UI 时不涉及到测量相关的操作,那么不会抛出异常。

所以 Google 定义的这个规则主要是防止并发情况下测量导致数据错误等等一系列并发问题。

所以我们的更新操作如果不涉及到尺寸变动,就可以放心的在子线程更新 UI 吗?

我的答案依然是不,还是老实点比较好,说不定就凉了呢?

那如果需要在子线程更新 UI 怎么办

那可太简单了!

在子线程调用 runOnUiThread()方法,使用 View 的 postInvalidate() 方法都可以解决问题。

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