“好记性不如烂笔头”,感觉有必要总结一下工作一年以来遇到的典型问题,就从Android子线程更新UI界面引起的crash开始吧。
项目中的Gallery在显示照片详细信息中使用了Google Map来标记照片的拍摄地点,测试工程师反馈了一个严重的bug,点击照片中的详细信息查看地图上照片拍摄地点,如果旋转屏幕,Gallery有时会崩溃,查看一下crash log:
05-12 14:44:11.992500 25232 25344 E AndroidRuntime: Caused by: java.lang.IllegalStateException: Not on the main thread
05-12 14:44:11.992500 25232 25344 E AndroidRuntime: at com.google.maps.api.android.lib6.common.k.a(:com.google.android.gms.DynamiteModulesB:131)
05-12 14:44:11.992500 25232 25344 E AndroidRuntime: at com.google.maps.api.android.lib6.common.r.a(:com.google.android.gms.DynamiteModulesB:30)
05-12 14:44:11.992500 25232 25344 E AndroidRuntime: at com.google.maps.api.android.lib6.impl.az.a(:com.google.android.gms.DynamiteModulesB:814)
05-12 14:44:11.992500 25232 25344 E AndroidRuntime: at com.google.android.gms.maps.internal.l.onTransact(:com.google.android.gms.DynamiteModulesB:83)
05-12 14:44:11.992500 25232 25344 E AndroidRuntime: at android.os.Binder.transact(Binder.java:504)
05-12 14:44:11.992500 25232 25344 E AndroidRuntime: at com.google.android.gms.maps.internal.IGoogleMapDelegate$zza$zza.moveCamera(Unknown Source)
05-12 14:44:11.992500 25232 25344 E AndroidRuntime: at com.google.android.gms.maps.GoogleMap.moveCamera(Unknown Source)
05-12 14:44:11.992500 25232 25344 E AndroidRuntime: at com.tct.gallery3d.app.fragment.DetailsFragment.initLatLng(DetailsFragment.java:79)
05-12 14:44:11.992500 25232 25344 E AndroidRuntime: at com.tct.gallery3d.app.fragment.DetailsFragment.onDetailsReady(DetailsFragment.java:67)
05-12 14:44:11.992500 25232 25344 E AndroidRuntime: at com.tct.gallery3d.app.adapter.DetailsAdapter.initDetails(DetailsAdapter.java:433)
05-12 14:44:11.992500 25232 25344 E AndroidRuntime: at com.tct.gallery3d.app.adapter.DetailsAdapter.access$000(DetailsAdapter.java:37)
05-12 14:44:11.992500 25232 25344 E AndroidRuntime: at com.tct.gallery3d.app.adapter.DetailsAdapter$DetailsTask.doInBackground(DetailsAdapter.java:198)
05-12 14:44:11.992500 25232 25344 E AndroidRuntime: at com.tct.gallery3d.app.adapter.DetailsAdapter$DetailsTask.doInBackground(DetailsAdapter.java:191)
05-12 14:44:11.992500 25232 25344 E AndroidRuntime: at com.tct.gallery3d.image.AsyncTask$2.call(AsyncTask.java:313)
05-12 14:44:11.992500 25232 25344 E AndroidRuntime: at java.util.concurrent.FutureTask.run(FutureTask.java:237)
05-12 14:44:11.992500 25232 25344 E AndroidRuntime:
... 4 more
把问题定位到DetailsTask这类的doInBackground方法中:
protected CustomArrayList doInBackground(MediaObject... params) {
......
initLatLng();
......
}
/**
* Init the latLng for the google map
*/
private synchronized void initLatLng() {
if (mGoogleMap == null || mLatLng == null) {
return;
}
// Move to the position.
updateMapPosition();
}
private void updateMapPosition(){
CameraUpdate update = CameraUpdateFactory.newLatLngZoom(mLatLng, 10f);
mGoogleMap.moveCamera(update);
mGoogleMap.setMapType(GoogleMap.MAP_TYPE_NORMAL);
mGoogleMap.addMarker(newMarkerOptions().position(mLatLng)
.title("Marker").flat(true));
mContext.onLatLngReady();
}
DetailTask继承至AsyncTask,那么doInBackground()这个方法就是在子线程中运行的,这里居然在子线程中更新UI界面,那么问题来了,作为一个Android工程师都应该知道——在子线程中更新UI肯定会抛出异常,为什么测试反馈说这个crash并不是必现的呢?估计非必现的原因,所以这位同事没有意识到他在子线程中修改了UI界面。非UI线程居然能更新UI界面,颠覆了我的Android世界观。最后百度发现,Android非UI线程真的能更新UI界面,这是为什么呢?自己写了一个demo来学习一下。
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
textView = findViewById(R.id.text_view);
button = findViewById(R.id.button);
button.setOnClickListener(this);
new Thread(new Runnable() {
@Override
public void run() {
textView.setText("setText on worker thread");
}
}).start();
}
@Override
public void onClick(View v) {
new Thread(new Runnable() {
@Override
public void run() {
textView.setText("onClick on worker thread");
}
}).start();
}
把demo推入手机运行一下,“this is in worker thread”能正常显示,果真在子线程中可以修改UI界面:
然后再点一下BUTTON按钮,程序崩溃了,以下是程序崩溃时的log:
android.view.ViewRootImpl$CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views.
at android.view.ViewRootImpl.checkThread(ViewRootImpl.java:7687)
at android.view.ViewRootImpl.requestLayout(ViewRootImpl.java:1203)
at android.view.View.requestLayout(View.java:19996)
at android.view.View.requestLayout(View.java:19996)
at android.view.View.requestLayout(View.java:19996)
at android.view.View.requestLayout(View.java:19996)
at android.view.View.requestLayout(View.java:19996)
at android.widget.TextView.checkForRelayout(TextView.java:7793)
at android.widget.TextView.setText(TextView.java:4547)
at android.widget.TextView.setText(TextView.java:4404)
at android.widget.TextView.setText(TextView.java:4379)
at com.fengbangquan.myapplication.MainActivity$2.run(MainActivity.java)
同样的代码,为什么在OnCreate()中开启工作线程去更新TextView没有问题,但是点击button主动起一个线程却会导致crash?我们跟进TextView.setText()去看一下,这个方法中首先调用checkForRelayout(),在这个方法中又调用View.requestLayout(),最终View.requestLayout()调用了ViewRootImpl.requestLayout()去重新绘制view,在requestLayout()中发现了一个很关键的方法checkThread():
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这个类在我们setText()后首先检测当前代码执行在哪个Thread中,如果不是在mThread中就不会执行scheduleTraversals()去重新绘制界面,mThread又是哪一个线程呢?(十有八九就是UI线程)。我们看一下ViewRootImpl中的mThread是在哪里赋值:
public ViewRootImpl(Context context, Display display) {
......
mContext = context;
mThread = Thread.currentThread();
......
}
原来mThread是在ViewRootImpl在初始化的时候赋值的,那ViewRootImpl又是在什么时候初始化的?找到了ViewRootImpl初始化的时间,也就找到了问题的答案。可以猜想一下,我们在Activity的OnCreate()中开启新线程时,ViewRootImpl是不是还没有初始化,所以checkThread()这个方法没有被调用去检测当前环境处在哪一个Thread。Activity的生命周期是在ActivityThread中调用的,查看ActivityThread的源码,最后发现ViewRootImpl是ActivityThread.handleResumeActivity()中得到初始化的:
final void handleResumeActivity(IBinder token, boolean clearHide,boolean isForward, boolean reallyResume, int seq, String reason) {
......
View decor = r.window.getDecorView();
......
ViewRootImpl impl = decor.getViewRootImpl();
......
}
原来ViewRootImpl是Activity进入OnResume()的时候才得到初始化,所以我们可以在Oncreate()开启一个线程去更新UI界面的时候,checkThread()这个方法并没有执行, 进而能够执行scheduleTraversals()去重新绘制view更新UI界面。虽然可以在子线程中更新UI界面,但是这个操作不是线程安全的。