写点什么

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

作者:anyRTC开发者
  • 2022 年 8 月 12 日
    上海
  • 本文字数:9113 字

    阅读完需:约 30 分钟

前言

某天早晨,吃完早餐,坐回工位,打开电脑,开启 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)
复制代码


一眼看上去似乎是比较常见的子线程修改 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();    }
复制代码


运行一下,果不其然崩溃掉,错误信息就是提示我必须准备好 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)
复制代码


接下来就在 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();
复制代码


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



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

二、再探堆栈

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


at com.youdao.youdaomath.view.PayCourseVideoActivity.onResume(PayCourseVideoActivity.java:218)android.widget.Toast.setText(Toast.java:332)
复制代码


发现是在主线程报了 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; }
复制代码


很常规的一个做法,先是 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.");        }    }
复制代码


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


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


    public ViewRootImpl(Context context, Display display) {        ...代码省略...        mThread = Thread.currentThread();       ...代码省略...    }
复制代码


可以发现全类只有这一处对 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 } }
复制代码


调用 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();        }
复制代码


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


        @Override        public void show(IBinder windowToken) {            if (localLOGV) Log.v(TAG, "SHOW: " + this);            mHandler.obtainMessage(SHOW, windowToken).sendToTarget();        }
复制代码


在 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 */                }            }        }
复制代码


代码有点长,我们最需要关心的就是 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(); }
复制代码


做了个静态的 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);
复制代码


找到了对应的 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>
复制代码


也就是说此时的 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; }}
复制代码


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


    @Override    public void removeViewImmediate(View view) {        mGlobal.removeView(view, true);    }
复制代码


会调用 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); } }
复制代码


然后调用 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); } } }
复制代码


所以也就是说在 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); }
复制代码


但是看到这段代码,又有疑问了,我并没有在 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;            }        }    }
复制代码


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



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

总结

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


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


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


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


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



发布于: 刚刚阅读数: 4
用户头像

实时交互,万物互联! 2020.08.10 加入

实时交互,万物互联,全球实时互动云服务商领跑者!

评论

发布
暂无评论
Android技术分享|【Android踩坑】怀疑人生,主线程修改UI也会崩溃?_android_anyRTC开发者_InfoQ写作社区