java ui线程_怀疑人生,主线程修改UI也会崩溃?

原标题:怀疑人生,主线程修改UI也会崩溃?

本文作者

作者:CDF_cc7d

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

0

前言

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

java.lang.RuntimeException: Unable to resumeactivity {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以后报的错的?

1

Demo 验证

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

@Override

protectedvoidonResume{

super.onResume;

Thread thread = newThread( newRunnable {

@Override

publicvoidrun{

Toast.makeText(MainActivity. this, "子线程弹出Toast",Toast.LENGTH_SHORT).show;

}

});

thread.start;

}

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

java.lang.RuntimeException: Can't toast ona thread that has not called Looper.prepare

atandroid.widget.Toast$TN.(Toast.java:393)

atandroid.widget.Toast.(Toast.java:117)

atandroid.widget.Toast.makeText(Toast.java:280)

atandroid.widget.Toast.makeText(Toast.java:270)

atcom.netease.photodemo.MainActivity $1.run(MainActivity.java:22)

atjava.lang.Thread.run(Thread.java:764)

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

Thread thread = newThread( newRunnable {

@Override

publicvoidrun{

Looper.prepare;

Toast.makeText(MainActivity. this, "子线程弹出Toast",Toast.LENGTH_SHORT).show;

Looper.loop;

}

});

thread.start;

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

c9f634bd48e1b9179f00ef7bba42f49e.png

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

2

再探堆栈

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

atcom.youdao.youdaomath.view

.PayCourseVideoActivity.onResume( PayCourseVideoActivity.java:218)

android.widget.Toast.setText( Toast.java:332)

发现是在主线程报了Toast设置Text的时候的错误。

这就让我很纳闷了,子线程修改UI会报错, 主线程也会报错?

感觉这么多年Android白做了。

这不是最基本的知识么?

于是我只能硬着头皮往源码深处看了:

先来看看Toast是怎么setText的:

publicstatic Toast makeText( @NonNullContext context, @NullableLooper looper,

@NonNullCharSequence text, @Durationint 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;

returnresult;

}

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

至于setText在我之前的文章震惊!Android子线程也能修改UI?有详细说过,是在ViewRootImpl里面进行checkThread是否在主线程上面。

所以感觉似乎一点问题都没有。那么既然出现了这个错误,总得有原因吧,或许是自己源码看漏了?

那就重新再看一遍 ViewRootImpl#checkThread方法吧:

voidcheckThread{if(mThread != Thread.currentThread) {

thrownewCalledFromWrongThreadException(

"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.

*/

publicvoidshow{

if(mNextView == null) {

thrownewRuntimeException( "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

publicvoidshow(IBinder windowToken){

if(localLOGV) Log.v(TAG, "SHOW: "+ this);

mHandler.obtainMessage(SHOW, windowToken).sendToTarget;

}

发送一个Message,通知进行show的操作,在Handler的handleMessage方法中找到了SHOW的case,接下来就要进行真正show的操作了:

publicvoidhandleShow( IBinder windowToken){

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

// ...

mParams.token = windowToken;

if(mView.getParent != null) {

if(localLOGV) Log.v(TAG, "REMOVE! "+ mView + " in "+ this);

mWM.removeView(mView);

}

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,然后下一次在主线程弹出来就出错了吧?想想应该是这样的。

3

再探Demo

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

@Override

protectedvoidonResume{

super.onResume;

Thread thread = newThread( newRunnable {

@Override

publicvoidrun{

Looper.prepare;

sToast = Toast.makeText(MainActivity. this, "子线程弹出Toast",Toast.LENGTH_SHORT);

sToast.show;

Looper.loop;

}

});

thread.start;

}

publicvoidclick(View view){

sToast.setText( "主线程弹出Toast");

sToast.show;

}

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

8d107f65bbdeeb4ccfb86cf3483fa4c6.gif

发现竟然没问题,这时候又开始怀疑人生了,这到底怎么回事。

ViewRootImpl此时的mThread应该是子线程啊,没道理还能正常运行,怎么办呢?

debug一步一步调试吧,一步一步调试下来,发现在View的requestLayout里面parent竟然为空了:

34d8966d1bba5d6c553ea6676c70ce09.png

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

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

在Android源码社区中搜索"transient_notification"找到了对应的toast布局文件,打开一看,果然如此:

< LinearLayoutxmlns: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方法找原因了

4

深入源码

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

publicvoid 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

publicvoidremoveViewImmediate(View view){

mGlobal.removeView(view, true);

}

会调用WindowManagerGlobal的removeView方法:

publicvoidremoveView(View view, booleanimmediate){

if(view == null) {

thrownewIllegalArgumentException( "view must not be null");

}

synchronized(mLock) {

intindex = findViewLocked(view, true);

View curView = mRoots.get(index).getView;

removeViewLocked(index, immediate);

if(curView == view) {

return;

}

thrownewIllegalStateException( "Calling with view "+ view

+ " but the ViewAncestor is attached to "+ curView);

}

}

然后调用removeViewLocked方法:

privatevoidremoveViewLocked( intindex, 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关闭,再次点击:

c9620891dd828f00634e4ea926d9b19c.gif

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

5

发现原因

那么问题原因找到了:

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

此时内心有个困惑:

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

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

classJSInterface{

@JavaInterface

publicvoidhandleMessage(String msg)throwsJSONException{

LogHelper.e(TAG, "msg::"+ msg);

JSONObject jsonObject = newJSONObject(msg);

String callType = jsonObject.optString(JS_CALL_TYPE);

switch(callType) {

...代码省略..

caseJSCallType.SHOW_TOAST:

showToast(jsonObject);

break;

default:

break;

}

}

}

/**

* 弹出吐司

* @paramjsonObject

* @throwsJSONException

*/

publicvoidshowToast(JSONObject jsonObject)throwsJSONException{

JSONObject payDataObj = jsonObject.getJSONObject( "data");

String message = payDataObj.optString( "data");

CommonToast.showShortToast(message);

}

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

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

classJSInterface{

@JavaInterface

publicvoidhandleMessage(String msg)throwsJSONException{

LogHelper.e(TAG, "msg::"+ msg);

JSONObject jsonObject = newJSONObject(msg);

String callType = jsonObject.optString(JS_CALL_TYPE);

Thread currentThread = Thread.currentThread;

Looper looper = Looper.myLooper;

switch(callType) {

...代码省略..

caseJSCallType.SHOW_TOAST:

showToast(jsonObject);

break;

default:

break;

}

}

}

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

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

6

总结

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

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

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

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

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

责任编辑:

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值