• Android技术分享|【Android踩坑】怀疑人生,主线程修改UI也会崩溃?


    前言

    某天早晨,吃完早餐,坐回工位,打开电脑,开启chrome,进入友盟页面,发现了一个崩溃信息:

    java.lang.RuntimeException: Unable to resume activity {com.youdao.youdaomath/com.youdao.youdaomath.view.PayCourseVideoActivity}: android.view.ViewRootImpl$CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views.
        at android.app.ActivityThread.performResumeActivity(ActivityThread.java:3824)
        at android.app.ActivityThread.handleResumeActivity(ActivityThread.java:3856)
        at android.app.servertransaction.ResumeActivityItem.execute(ResumeActivityItem.java:51)
        at android.app.servertransaction.TransactionExecutor.executeLifecycleState(TransactionExecutor.java:145)
        at android.app.servertransaction.TransactionExecutor.execute(TransactionExecutor.java:70)
        at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1831)
        at android.os.Handler.dispatchMessage(Handler.java:106)
        at android.os.Looper.loop(Looper.java:201)
        at android.app.ActivityThread.main(ActivityThread.java:6806)
        at java.lang.reflect.Method.invoke(Native Method)
        at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:547)
        at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:873)
    Caused by: android.view.ViewRootImpl$CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views.
        at android.view.ViewRootImpl.checkThread(ViewRootImpl.java:8000)
        at android.view.ViewRootImpl.requestLayout(ViewRootImpl.java:1292)
        at android.view.View.requestLayout(View.java:23147)
        at android.view.View.requestLayout(View.java:23147)
        at android.widget.TextView.checkForRelayout(TextView.java:8914)
        at android.widget.TextView.setText(TextView.java:5736)
        at android.widget.TextView.setText(TextView.java:5577)
        at android.widget.TextView.setText(TextView.java:5534)
        at android.widget.Toast.setText(Toast.java:332)
        at com.youdao.youdaomath.view.common.CommonToast.showShortToast(CommonToast.java:40)
        at com.youdao.youdaomath.view.PayCourseVideoActivity.checkNetWork(PayCourseVideoActivity.java:137)
        at com.youdao.youdaomath.view.PayCourseVideoActivity.onResume(PayCourseVideoActivity.java:218)
        at android.app.Instrumentation.callActivityOnResume(Instrumentation.java:1413)
        at android.app.Activity.performResume(Activity.java:7400)
        at android.app.ActivityThread.performResumeActivity(ActivityThread.java:3816)
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29

    一眼看上去似乎是比较常见的子线程修改UI的问题。并且是在Toast上面报出的,常识告诉我Toast在子线程弹出是会报错,但是应该是提示Looper没有生成的错,而不应该是上面所报出的错误。那么会不会是生成Looper以后报的错的?

    一、Demo 验证

    所以我先做了一个demo,如下:

        @Override
        protected void onResume() {
            super.onResume();
            Thread thread = new Thread(new Runnable() {
                @Override
                public void run() {
                    Toast.makeText(MainActivity.this,"子线程弹出Toast",Toast.LENGTH_SHORT).show();
                }
            });
            thread.start();
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    运行一下,果不其然崩溃掉,错误信息就是提示我必须准备好looper才能弹出toast:

        java.lang.RuntimeException: Can't toast on a thread that has not called Looper.prepare()
            at android.widget.Toast$TN.<init>(Toast.java:393)
            at android.widget.Toast.<init>(Toast.java:117)
            at android.widget.Toast.makeText(Toast.java:280)
            at android.widget.Toast.makeText(Toast.java:270)
            at com.netease.photodemo.MainActivity$1.run(MainActivity.java:22)
            at java.lang.Thread.run(Thread.java:764)
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    接下来就在toast里面准备好looper,再试试吧:

            Thread thread = new Thread(new Runnable() {
                @Override
                public void run() {
                    Looper.prepare();
                    Toast.makeText(MainActivity.this,"子线程弹出Toast",Toast.LENGTH_SHORT).show();
                    Looper.loop();
                }
            });
            thread.start();
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    运行发现是能够正确的弹出Toast的:

    在这里插入图片描述

    那么问题就来了,为什么会在友盟中出现这个崩溃呢?

    二、再探堆栈

    然后仔细看了下报错信息有两行重要信息被我之前略过了:

    at com.youdao.youdaomath.view
    .PayCourseVideoActivity.onResume(PayCourseVideoActivity.java:218)
    android.widget.Toast.setText(Toast.java:332)
    
    • 1
    • 2
    • 3

    发现是在主线程报了Toast设置Text的时候的错误。这就让我很纳闷了,子线程修改UI会报错,主线程也会报错?
    感觉这么多年Android白做了。这不是最基本的知识么?
    于是我只能硬着头皮往源码深处看了:
    先来看看Toast是怎么setText的:

        public static Toast makeText(@NonNull Context context, @Nullable Looper looper,
                @NonNull CharSequence text, @Duration int duration) {
            Toast result = new Toast(context, looper);
    
            LayoutInflater inflate = (LayoutInflater)
                    context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
            View v = inflate.inflate(com.android.internal.R.layout.transient_notification, null);
            TextView tv = (TextView)v.findViewById(com.android.internal.R.id.message);
            tv.setText(text);
    
            result.mNextView = v;
            result.mDuration = duration;
    
            return result;
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    很常规的一个做法,先是inflate出来一个View对象,再从View对象找出对应的TextView,然后TextView将文本设置进去。

    至于setText在之前有详细说过,是在ViewRootImpl里面进行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.");
            }
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    这一看,还真的似乎给我了一点头绪,系统在checkThread的时候并不是将Thread.currentThread和MainThread作比较,而是跟mThread作比较,那么有没有一种可能mThread是子线程?

    一想到这里,我就兴奋了,全类查看mThread到底是怎么初始化的:

        public ViewRootImpl(Context context, Display display) {
            ...代码省略...
            mThread = Thread.currentThread();
           ...代码省略...
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    可以发现全类只有这一处对mThread进行了赋值。那么会不会是子线程初始化了ViewRootimpl呢?似乎我之前好像也没有研究过Toast为什么会弹出来,所以顺便就先去了解下Toast是怎么show出来的好了:

        /**
         * Show the view for the specified duration.
         */
        public void show() {
            if (mNextView == null) {
                throw new RuntimeException("setView must have been called");
            }
    
            INotificationManager service = getService();
            String pkg = mContext.getOpPackageName();
            TN tn = mTN;
            tn.mNextView = mNextView;
    
            try {
                service.enqueueToast(pkg, tn, mDuration);
            } catch (RemoteException e) {
                // Empty
            }
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    调用Toast的show方法时,会通过Binder获取Service即NotificationManagerService,然后执行enqueueToast方法(NotificationManagerService的源码就不做分析),然后会执行Toast里面如下方法:

            @Override
            public void show(IBinder windowToken) {
                if (localLOGV) Log.v(TAG, "SHOW: " + this);
                mHandler.obtainMessage(SHOW, windowToken).sendToTarget();
            }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    发送一个Message,通知进行show的操作:

            @Override
            public void show(IBinder windowToken) {
                if (localLOGV) Log.v(TAG, "SHOW: " + this);
                mHandler.obtainMessage(SHOW, windowToken).sendToTarget();
            }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    在Handler的handleMessage方法中找到了SHOW的case,接下来就要进行真正show的操作了:

            public void handleShow(IBinder windowToken) {
                if (localLOGV) Log.v(TAG, "HANDLE SHOW: " + this + " mView=" + mView
                        + " mNextView=" + mNextView);
                // If a cancel/hide is pending - no need to show - at this point
                // the window token is already invalid and no need to do any work.
                if (mHandler.hasMessages(CANCEL) || mHandler.hasMessages(HIDE)) {
                    return;
                }
                if (mView != mNextView) {
                    // remove the old view if necessary
                    handleHide();
                    mView = mNextView;
                    Context context = mView.getContext().getApplicationContext();
                    String packageName = mView.getContext().getOpPackageName();
                    if (context == null) {
                        context = mView.getContext();
                    }
                    mWM = (WindowManager)context.getSystemService(Context.WINDOW_SERVICE);
                    // We can resolve the Gravity here by using the Locale for getting
                    // the layout direction
                    final Configuration config = mView.getContext().getResources().getConfiguration();
                    final int gravity = Gravity.getAbsoluteGravity(mGravity, config.getLayoutDirection());
                    mParams.gravity = gravity;
                    if ((gravity & Gravity.HORIZONTAL_GRAVITY_MASK) == Gravity.FILL_HORIZONTAL) {
                        mParams.horizontalWeight = 1.0f;
                    }
                    if ((gravity & Gravity.VERTICAL_GRAVITY_MASK) == Gravity.FILL_VERTICAL) {
                        mParams.verticalWeight = 1.0f;
                    }
                    mParams.x = mX;
                    mParams.y = mY;
                    mParams.verticalMargin = mVerticalMargin;
                    mParams.horizontalMargin = mHorizontalMargin;
                    mParams.packageName = packageName;
                    mParams.hideTimeoutMilliseconds = mDuration ==
                        Toast.LENGTH_LONG ? LONG_DURATION_TIMEOUT : SHORT_DURATION_TIMEOUT;
                    mParams.token = windowToken;
                    if (mView.getParent() != null) {
                        if (localLOGV) Log.v(TAG, "REMOVE! " + mView + " in " + this);
                        mWM.removeView(mView);
                    }
                    if (localLOGV) Log.v(TAG, "ADD! " + mView + " in " + this);
                    // Since the notification manager service cancels the token right
                    // after it notifies us to cancel the toast there is an inherent
                    // race and we may attempt to add a window after the token has been
                    // invalidated. Let us hedge against that.
                    try {
                        mWM.addView(mView, mParams);
                        trySendAccessibilityEvent();
                    } catch (WindowManager.BadTokenException e) {
                        /* ignore */
                    }
                }
            }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54

    代码有点长,我们最需要关心的就是mWm.addView方法。

    相信看过ActivityThread的同学应该知道mWm.addView方法是在ActivityThread的handleResumeActivity里面也有调用过,意思就是进行ViewRootImpl的初始化,然后通过ViewRootImp进行View的测量,布局,以及绘制。

    看到这里,我想到了一个可能的原因:

    那就是我的Toast是一个全局静态的Toast对象,然后第一次是在子线程的时候show出来,这个时候ViewRootImpl在初始化的时候就会将子线程的对象作为mThread,然后下一次在主线程弹出来就出错了吧?想想应该是这样的。

    三、再探Demo

    所以继续做我的demo来印证我的想法:

        @Override
        protected void onResume() {
            super.onResume();
            Thread thread = new Thread(new Runnable() {
                @Override
                public void run() {
                    Looper.prepare();
                    sToast = Toast.makeText(MainActivity.this,"子线程弹出Toast",Toast.LENGTH_SHORT);
                    sToast.show();
                    Looper.loop();
                }
            });
            thread.start();
        }
    
        public void click(View view) {
            sToast.setText("主线程弹出Toast");
            sToast.show();
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    做了个静态的toast,然后点击按钮的时候弹出toast,运行一下:

    在这里插入图片描述

    发现竟然没问题,这时候又开始怀疑人生了,这到底怎么回事。ViewRootImpl此时的mThread应该是子线程啊,没道理还能正常运行,怎么办呢?debug一步一步调试吧,一步一步调试下来,发现在View的requestLayout里面parent竟然为空了:

    在这里插入图片描述

    然后在仔细看了下当前View是一个LinearLayout,然后这个View的子View是TextView,文本内容是"主线程弹出toast",所以应该就是Toast在new的时候inflate的布局

    View v = inflate.inflate(com.android.internal.R.layout.transient_notification, null);
    
    • 1

    找到了对应的toast布局文件,打开一看,果然如此:

    <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical"
        android:background="?android:attr/toastFrameBackground">
    
        <TextView
            android:id="@android:id/message"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:layout_marginHorizontal="24dp"
            android:layout_marginVertical="15dp"
            android:layout_gravity="center_horizontal"
            android:textAppearance="@style/TextAppearance.Toast"
            android:textColor="@color/primary_text_default_material_light"
            />
    
    </LinearLayout>
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    也就是说此时的View已经是顶级View了,它的parent应该就是ViewRootImpl,那么为什么ViewRootImpl是null呢,明明之前已经show过了。看来只能往Toast的hide方法找原因了

    四、深入源码

    所以重新回到Toast的类中,查看下Toast的hide方法(此处直接看Handler的hide处理,之前的操作与show类似):

    public void handleHide() {
        if (localLOGV) Log.v(TAG, "HANDLE HIDE: " + this + " mView=" + mView);
        if (mView != null) {
            // note: checking parent() just to make sure the view has
            // been added...  i have seen cases where we get here when
            // the view isn't yet added, so let's try not to crash.
            if (mView.getParent() != null) {
                if (localLOGV) Log.v(TAG, "REMOVE! " + mView + " in " + this);
                mWM.removeViewImmediate(mView);
            }
    
            // Now that we've removed the view it's safe for the server to release
            // the resources.
            try {
                getService().finishToken(mPackageName, this);
            } catch (RemoteException e) {
            }
    
            mView = null;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21

    此处调用了mWm的removeViewImmediate,即WindowManagerImpl里面的removeViewImmediate方法:

        @Override
        public void removeViewImmediate(View view) {
            mGlobal.removeView(view, true);
        }
    
    • 1
    • 2
    • 3
    • 4

    会调用WindowManagerGlobal的removeView方法:

    public void removeView(View view, boolean immediate) {
            if (view == null) {
                throw new IllegalArgumentException("view must not be null");
            }
    
            synchronized (mLock) {
                int index = findViewLocked(view, true);
                View curView = mRoots.get(index).getView();
                removeViewLocked(index, immediate);
                if (curView == view) {
                    return;
                }
    
                throw new IllegalStateException("Calling with view " + view
                        + " but the ViewAncestor is attached to " + curView);
            }
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    然后调用removeViewLocked方法:

    private void removeViewLocked(int index, boolean immediate) {
            ViewRootImpl root = mRoots.get(index);
            View view = root.getView();
    
            if (view != null) {
                InputMethodManager imm = InputMethodManager.getInstance();
                if (imm != null) {
                    imm.windowDismissed(mViews.get(index).getWindowToken());
                }
            }
            boolean deferred = root.die(immediate);
            if (view != null) {
                //此处调用View的assignParent方法将viewParent置空
                view.assignParent(null);
                if (deferred) {
                    mDyingViews.add(view);
                }
            }
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    所以也就是说在Toast时间到了以后,会调用hide方法,此时会将parent置成空,所以我刚才试的时候才没有问题。那么按道理说只要在Toast没有关闭的时候点击再次弹出toast应该就会报错。

    所以还是原来的代码,再来一次,这次不等Toast关闭,再次点击:

    在这里插入图片描述

    果然如预期所料,此时在主线程弹出Toast就会崩溃。

    五、发现原因

    那么问题原因找到了:

    是在项目子线程中有弹出过Toast,然后Toast并没有关闭,又在主线程弹出了同一个对象的toast,会造成崩溃。

    此时内心有个困惑:

    如果是子线程弹出Toast,那我就需要写Looper.prepare方法和Looper.loop方法,为什么我自己一点印象都没有。

    于是我全局搜索了Looper.prepare,发现并没有找到对应的代码。所以我就全局搜索了Toast调用的地方,发现在JavaBridge的回调当中找到了:

        class JSInterface {
            @JavascriptInterface
            public void handleMessage(String msg) throws JSONException {
                LogHelper.e(TAG, "msg::" + msg);
                JSONObject jsonObject = new JSONObject(msg);
                String callType = jsonObject.optString(JS_CALL_TYPE);
                switch (callType) {
                    ...代码省略..
                    case JSCallType.SHOW_TOAST:
                        showToast(jsonObject);
                        break;
                    default:
                        break;
                }
            }
        }
    
        /**
         * 弹出吐司
         * @param jsonObject
         * @throws JSONException
         */
        public void showToast(JSONObject jsonObject) throws JSONException {
            JSONObject payDataObj = jsonObject.getJSONObject("data");
            String message = payDataObj.optString("data");
            CommonToast.showShortToast(message);
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27

    但是看到这段代码,又有疑问了,我并没有在Javabridge的回调中看到有任何准备Looper的地方,那么为什么Toast没有崩溃掉?

    所以在此处加了一段代码:

        class JSInterface {
            @JavascriptInterface
            public void handleMessage(String msg) throws JSONException {
                LogHelper.e(TAG, "msg::" + msg);
                JSONObject jsonObject = new JSONObject(msg);
                String callType = jsonObject.optString(JS_CALL_TYPE);
                Thread currentThread = Thread.currentThread();
                Looper looper = Looper.myLooper();
                switch (callType) {
                    ...代码省略..
                    case JSCallType.SHOW_TOAST:
                        showToast(jsonObject);
                        break;
                    default:
                        break;
                }
            }
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    并且加了一个断点,来查看下此时的情况:

    在这里插入图片描述

    确实当前线程是JavaBridge线程,另外JavaBridge线程中已经提前给开发者准备好了Looper。所以也难怪一方面奇怪自己怎么没有写Looper的印象,一方面又很好奇为什么这个线程在开发者没有准备Looper的情况下也能正常弹出Toast。

    总结

    至此,真相终于找出来了。

    相比较发生这个bug 的原因,解决方案就显得非常简单了。

    只需要在CommonToast的showShortToast方法内部判断是否为主线程调用,如果不是的话,new一个主线程的Handler,将Toast扔到主线程弹出来。

    这样就会避免了子线程弹出。

    PS:本人还得吐槽一下Android,Android官方一方面明明宣称不能在主线程以外的线程进行UI的更新,**另一方面在初始化ViewRootImpl的时候又不把主线程作为成员变量保存起来,而是直接获取当前所处的线程作为mThread保存起来,这样做就有可能会出现子线程更新UI的操作。**从而引起类似我今天的这个bug。

    在这里插入图片描述

  • 相关阅读:
    怎么实现在微信公众号点外卖的功能
    Node.js知识点总结:从入门到入土
    20221130 RabbitMQ
    SpringMVC第六阶段:数据在域中的保存(02)
    PTE-精听学习(三)
    Node学习笔记之MongoDB
    函数调用的代价与优化
    springMVC中统一异常处理@ControllerAdvice
    使用python编程数学建模-数据模块理论数据相似性常用基础指标(课程3)
    wordpress的lnmp环境一键安装和手工配置
  • 原文地址:https://blog.csdn.net/anyRTC/article/details/126299961