Android非UI线程中更新UI界面

“好记性不如烂笔头”,感觉有必要总结一下工作一年以来遇到的典型问题,就从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界面:
Android非UI线程中更新UI界面_第1张图片
然后再点一下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界面,但是这个操作不是线程安全的。

你可能感兴趣的:(Android)