讨论一个Android 异步开发中容易忽视的bug

时间:2020-11-30 03:25:33

异步编程是android开发中,为提高程序效率而不得不采取的设计模式,由于android UI 线程对即时反应的高要求,任何耗时长久的操作都不能在主线程中执行,如果主线程堵塞,app 将引发异常退出,因此,像与服务器交互,下载文件等网络操作,一般要将任务放入子线程中执行。Android 移动编程模型与PC 类似,一般有线程池,AsyncTask 等方式。异步编程,有一难点,也是异步编程往往导致难以复现的诡异bug的,往往是数据状态的一致性维护不周,而导致线程异步读写数据,导致数据污染,致使程序崩溃。一般而言,我们都会通过加锁来处理共享数据读写,但有这么一种情况,却是加锁也难以解决的。


情况如下:在UI线程中,用户发起一个下载任务,主线程的Activity 生成一个AsyncTask ,并将自己的实例传入到AysncTask 类中,AysncTask 在自己的线程执行下载任务,下载完成后,onPostExecute 在UI 主线程执行,在该函数中,AysncTask 通过传进来的Activity 实例,调用Activity 提供的接口更新界面。但意外出现,也就是在onPostExecute调用前,用户将手机由竖屏切换为横屏,此时原有Activity的实例被销毁,新的Activity实例被创建,这意味着,原来传入AysncTask的Acitivity 实例是一个已经被系统销毁的对象,这时在onPostExecute中,调用原有传入的Activity实例接口,就会导致程序奔溃,而且这种奔溃是难以重现的。


为了处理这种情况,我们将处理流程总结为以下流程:

1. 我们需要在AsyncTask中保存对Activity实例的引用,当onPostExecute执行时,我们可以方便的通过Activity实例更新界面。

2. 我们知道,Activity实例有可能会被系统销毁,因此我们需要把握销毁事件,在引用的实例被销毁后,能及时的将旧引用转换为新生成的Activity实例。

3. 如果新的Activity生成时,AsyncTask仍然在后台执行,此时需要通知AysncTask更新它原来引用的实例。


根据以上所诉流程,我们给出解决该问题的示例代码如下:


public MainActivity extends Activity {
    private Worker worker;
    @Override
    public void onCreate(Bundle savedInstanceState) {
             super.onCreate(savedInstanceState);
             setContentView(R.layout.main);
             worker = (Worker)getLastNonConfigurationInstance();
             if (worker == null) {
                 worker = new Worker();
                 worker.execute();
             }

             worker.connectContext(this);
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        worker.disconnectContext();
    }

    @Override
    public Object onRetainNonConfigurationInstance() {
            return worker;
     }
}

Worker是一个继承与AsyncTask 的类,我们现检测一个Worker 对象在onCreate 执行前是否已经生成,如果onCreate是因为系统原因,例如横竖屏切换导致的,那么worker曾经被上一个Activity对象生成过,因此getLastNonConfigurationInstance()返回一个非空实例,如果返回空,那意味着当前Activity是程序首次生成,于是就顺理成章的new 一个Worker对象, 然后调用Worker的connectContext 接口将当前的Activity 对象传入。


如果当前Activity实例要被系统销毁时,onDestroy() 调用,在里面我们通过 disconnectContext() 通知worker 原来传入的Activity失效了。

再看看Woker 的代码:

public class Worker extends AsyncTask<Void, Void, String> {
    private Activity context;
    public void connectContext(Activity context) {
        this.context = context;
    }

    public void disconnectContext() {
        this.context = null;
    }
 
    @Override
    protected String doInBackground(Void... params) {
        //下载文件或耗时的操作
    }

    @Override
    protected void onPostExecute(String result) {
        if (context != null) {
            //更新Activity 界面
         }
    }
}

在Worker 中,保存一个对Activity的引用,并通过connectContext, disconnectContext 确保引用的对象是有效的,在onPostExecute执行中,确保context不为空,如果是空,就意味着,原来的Activity被销毁了,不能通过Activity 引用来更新界面。如果context 为空,表明Activity 刚被系统销毁,而新创建的Activity没有被传入到Worker中,或者是Worker创建时没有将Activity实例传入。无论何总情况,当前做法能保证onPostExecute执行时,不会引用被销毁的Activity实例。


该方法目前仍存一个疑问,那就是当onPostExecute执行前,Activity的onDestroy()调用,Worker里的Activity被设置为空,当onDestroy() 调用后,onPostExecute在主线程中执行,但由于context == null, 因此程序界面无法更新,当新的Activity生成后,那就只能显示原有界面,这不是一个bug吗。其实Android系统能保证onRetainNonConfigurationInstance执行后,onPostExecute不会执行,只有新的Activity实例构建完成后,既onCreate调用后,onPostExecute才会执行,这也就是说当onPostExecute执行时,context不会为空,程序主界面必定会得到更新。