AsyncTask & runtime config change: what approaches, with concise code samples, does the Android team endorse?
Asked Answered
G

1

5

Since AsyncTask was introduced in Cupcake (API 3, Android 1.5) in 2009, it has been consistently promoted by the Android team as simple:

The code samples which they provide reinforce this message of simplicity, especially to those of us who have had to work with threads in more painful ways. AsyncTask is very attractive.

Yet in the many years since, crashes, memory leaks, and other problems have plagued most developers who have chosen to use AsyncTask in their production applications. This is often due to Activity destruction and recreation on runtime configuration change (especially orientation/rotation) while the AsyncTask is running doInBackground(Params...) ; when onPostExecute(Result) is called, the Activity has already been destroyed, leaving UI references in an unusable state (or even null).

And the lack of obvious, clear, and concise guidance and code samples from the Android team on this issue has only made things worse, leading to confusion as well as various workarounds and hacks, some decent, some terrible:

Obviously, since AsyncTask can be used in many situations, there is no One Way to accommodate this issue. My question, however, is about options.

What are the canonical (endorsed by the Android team) best practices, with concise code samples, for integrating AsyncTask with the Activity/Fragment lifecycle and automatic restarts on runtime configuration change?

Getraer answered 13/3, 2016 at 13:51 Comment(0)
G
11

Provided recommendation (for any approach)

Don't hold references to UI-specific objects

From Memory & Threading. (Android Performance Patterns Season 5, Ep. 3):

You've got some threading object that's declared as an inner class of an Activity. The problem here is that the AsyncTask object now has an implicit reference to the enclosing Activity, and will keep that reference until the work object has been destroyed... Until this work completes, the Activity stays around in memory... This type of pattern also leads to common types of crashes seen in Android apps...

The takeaway here is that you shouldn't hold references to any types of UI-specific objects in any of your threading scenarios.

Provided approaches

Although the documentation is sparse and scattered, the Android team have provided at least three distinct approaches to dealing with restarts on config change using AsyncTask:

  1. Cancel/save/restart the running task in lifecycle methods
  2. Use WeakReferences to UI objects
  3. Manage in the top-level Activity or Fragment using "work records"

1. Cancel/save/restart the running task in lifecycle methods

From Using AsyncTask | Processes and Threads | Android Developers

To see how you can persist your task during one of these restarts and how to properly cancel the task when the activity is destroyed, see the source code for the Shelves sample application.

In the Shelves app, references to the tasks are maintained as fields in an Activity, so that they can be managed in the Activity's lifecycle methods. Before taking a look at the code, however, there are a couple of important things to note.

First, this app was written before AsyncTask was added to the platform. A class that strongly resembles what was later released as AsyncTask is included in the source, called UserTask. For our discussion here, UserTask is functionally equivalent to AsyncTask.

Second, subclasses of UserTask are declared as inner classes of an Activity. This approach is now regarded as an anti-pattern, as noted earlier (see Don't hold references to UI specific objects above). Fortunately, this implementation detail doesn't impact the overall approach of managing running tasks in lifecycle methods; however, if you choose to use this sample code for your own app, declare your subclasses of AsyncTask elsewhere.

Canceling the task

AddBookActivity.java
public class AddBookActivity extends Activity implements View.OnClickListener,
        AdapterView.OnItemClickListener {

    // ...

    private SearchTask mSearchTask;
    private AddTask mAddTask;

    // Tasks are initialized and executed when needed
    // ...

    @Override
    protected void onDestroy() {
        super.onDestroy();

        onCancelAdd();
        onCancelSearch();
    }

    // ...

    private void onCancelSearch() {
        if (mSearchTask != null && mSearchTask.getStatus() == UserTask.Status.RUNNING) {
            mSearchTask.cancel(true);
            mSearchTask = null;
        }
    }

    private void onCancelAdd() {
        if (mAddTask != null && mAddTask.getStatus() == UserTask.Status.RUNNING) {
            mAddTask.cancel(true);
            mAddTask = null;
        }
    }

    // ...

    // DO NOT DECLARE YOUR TASK AS AN INNER CLASS OF AN ACTIVITY
    // Instances of this class will hold an implicit reference to the enclosing
    // Activity as long as the task is running, even if the Activity has been
    // otherwise destroyed by the system.  Declare your task where you can be
    // sure it holds no implicit references to UI-specific objects (Views,
    // etc.), and do not hold explicit references to them in your own
    // implementation.
    private class AddTask extends UserTask<String, Void, BooksStore.Book> {
        // ...

        @Override
        public void onCancelled() {
            enableSearchPanel();
            hidePanel(mAddPanel, false);
        }

        // ...
    }

    // DO NOT DECLARE YOUR TASK AS AN INNER CLASS OF AN ACTIVITY
    // Instances of this class will hold an implicit reference to the enclosing
    // Activity as long as the task is running, even if the Activity has been
    // otherwise destroyed by the system.  Declare your task where you can be
    // sure it holds no implicit references to UI-specific objects (Views,
    // etc.), and do not hold explicit references to them in your own
    // implementation.
    private class SearchTask extends UserTask<String, ResultBook, Void>
            implements BooksStore.BookSearchListener {

        // ...

        @Override
        public void onCancelled() {
            enableSearchPanel();

            hidePanel(mSearchPanel, true);
        }

        // ...
    }

Saving and restarting the task

AddBookActivity.java
public class AddBookActivity extends Activity implements View.OnClickListener,
        AdapterView.OnItemClickListener {

    // ...

    private static final String STATE_ADD_IN_PROGRESS = "shelves.add.inprogress";
    private static final String STATE_ADD_BOOK = "shelves.add.book";

    private static final String STATE_SEARCH_IN_PROGRESS = "shelves.search.inprogress";
    private static final String STATE_SEARCH_QUERY = "shelves.search.book";

    // ...

    @Override
    protected void onRestoreInstanceState(Bundle savedInstanceState) {
        super.onRestoreInstanceState(savedInstanceState);
        // ...
        restoreAddTask(savedInstanceState);
        restoreSearchTask(savedInstanceState);
    }

    @Override
    protected void onSaveInstanceState(Bundle outState) {
        super.onSaveInstanceState(outState);
        if (isFinishing()) {
            // ...
            saveAddTask(outState);
            saveSearchTask(outState);
        }
    }

    // ...

    private void saveAddTask(Bundle outState) {
        final AddTask task = mAddTask;
        if (task != null && task.getStatus() != UserTask.Status.FINISHED) {
            final String bookId = task.getBookId();
            task.cancel(true);

            if (bookId != null) {
                outState.putBoolean(STATE_ADD_IN_PROGRESS, true);
                outState.putString(STATE_ADD_BOOK, bookId);
            }

            mAddTask = null;
        }
    }

    private void restoreAddTask(Bundle savedInstanceState) {
        if (savedInstanceState.getBoolean(STATE_ADD_IN_PROGRESS)) {
            final String id = savedInstanceState.getString(STATE_ADD_BOOK);
            if (!BooksManager.bookExists(getContentResolver(), id)) {
                mAddTask = (AddTask) new AddTask().execute(id);
            }
        }
    }

    private void saveSearchTask(Bundle outState) {
        final SearchTask task = mSearchTask;
        if (task != null && task.getStatus() != UserTask.Status.FINISHED) {
            final String bookId = task.getQuery();
            task.cancel(true);

            if (bookId != null) {
                outState.putBoolean(STATE_SEARCH_IN_PROGRESS, true);
                outState.putString(STATE_SEARCH_QUERY, bookId);
            }

            mSearchTask = null;
        }
    }

    private void restoreSearchTask(Bundle savedInstanceState) {
        if (savedInstanceState.getBoolean(STATE_SEARCH_IN_PROGRESS)) {
            final String query = savedInstanceState.getString(STATE_SEARCH_QUERY);
            if (!TextUtils.isEmpty(query)) {
                mSearchTask = (SearchTask) new SearchTask().execute(query);
            }
        }
    }

This is a straightforward approach, and should make sense even to beginners who are just getting acquainted with the Activity lifecycle. It also has the advantage of not requiring mimimal code outside of the task class itself, touching one to three lifecycle methods, depending on needs. A simple, 7-line onDestroy() snippet in the Usage section of the AsyncTask javadoc could have saved us all a lot of grief. Perhaps some future generation may be spared.

2. Use WeakReferences to UI objects

  • Pass UI objects as parameters in the AsyncTask's constructor. Store weak references to these objects as WeakReference fields in the AsyncTask.

  • In onPostExecute(), check that the UI object WeakReferences are not null then update them directly.

From Use an AsyncTask | Processing Bitmaps Off the UI Thread | Android Developers

class BitmapWorkerTask extends AsyncTask<Integer, Void, Bitmap> {
    private final WeakReference<ImageView> imageViewReference;
    private int data = 0;

    public BitmapWorkerTask(ImageView imageView) {
        // Use a WeakReference to ensure the ImageView can be garbage collected
        imageViewReference = new WeakReference<ImageView>(imageView);
    }

    // Decode image in background.
    @Override
    protected Bitmap doInBackground(Integer... params) {
        data = params[0];
        return decodeSampledBitmapFromResource(getResources(), data, 100, 100));
    }

    // Once complete, see if ImageView is still around and set bitmap.
    @Override
    protected void onPostExecute(Bitmap bitmap) {
        if (imageViewReference != null && bitmap != null) {
            final ImageView imageView = imageViewReference.get();
            if (imageView != null) {
                imageView.setImageBitmap(bitmap);
            }
        }
    }
}

The WeakReference to the ImageView ensures that the AsyncTask does not prevent the ImageView and anything it references from being garbage collected. There’s no guarantee the ImageView is still around when the task finishes, so you must also check the reference in onPostExecute(). The ImageView may no longer exist, if for example, the user navigates away from the activity or if a configuration change happens before the task finishes.

This approach is simpler and tidier than the first, adding only a type change and a null check to the task class, and no additional code anywhere else.

There is a cost to this simplicity, however: the task will run to its end without being canceled on config change. If your task is expensive (CPU, memory, battery), has side effects, or needs to be automatically restarted on Activity restart, then the first approach is probably a better option.

3. Manage in the top-level Activity or Fragment using "work records"

From Memory & Threading. (Android Performance Patterns Season 5, Ep. 3)

...force the top-level Activity or Fragment to be the sole system responsible for updating the UI objects.

For example, when you'd like to kick off some work, create a "work record" that pairs a View with some update function. When that block of work is finished, it submits the results back to the Activity using an Intent or a runOnUiThread(Runnable) call.

The Activity can then call the update function with the new information, or if the View isn't there, just drop the work altogether. And, if the Activity that issued the work was destroyed, then the new Activity won't have a reference to any of this, and it will just drop the work, too.

Here is a screenshot of the accompanying diagram that describes this approach:

Work Records thread management approach diagram

Code samples were not provided in the video, so here is my take on a basic implementation:

WorkRecord.java

public class WorkRecord {
  public static final String ACTION_UPDATE_VIEW = "WorkRecord.ACTION_UPDATE_VIEW";
  public static final String EXTRA_WORK_RECORD_KEY = "WorkRecord.EXTRA_WORK_RECORD_KEY";
  public static final String EXTRA_RESULT = "WorkRecord.EXTRA_RESULT";
  public final int viewId;
  public final Callback callback;

  public WorkRecord(@IdRes int viewId, Callback callback) {
    this.viewId = viewId;
    this.callback = callback;
  }

  public interface Callback {
    boolean update(View view, Object result);
  }

  public interface Store {
    long addWorkRecord(WorkRecord workRecord);
  }
}

MainActivity.java

public class MainActivity extends AppCompatActivity implements WorkRecord.Store {
  // ...

  private final Map<Long, WorkRecord> workRecords = new HashMap<>();
  private BroadcastReceiver workResultReceiver;

  // ...

  @Override protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    // ...

    initWorkResultReceiver();
    registerWorkResultReceiver();
  }

  @Override protected void onDestroy() {
    super.onDestroy();
    // ...

    unregisterWorkResultReceiver();
  }

  // Initializations

  private void initWorkResultReceiver() {
      workResultReceiver = new BroadcastReceiver() {
        @Override public void onReceive(Context context, Intent intent) {
          doWorkWithResult(intent);
        }
      };
    }

  // Result receiver

  private void registerWorkResultReceiver() {
    final IntentFilter workResultFilter = new IntentFilter(WorkRecord.ACTION_UPDATE_VIEW);
    LocalBroadcastManager.getInstance(this).registerReceiver(workResultReceiver, workResultFilter);
  }

  private void unregisterWorkResultReceiver() {
    if (workResultReceiver != null) {
      LocalBroadcastManager.getInstance(this).unregisterReceiver(workResultReceiver);
    }
  }

  private void doWorkWithResult(Intent resultIntent) {
    final long key = resultIntent.getLongExtra(WorkRecord.EXTRA_WORK_RECORD_KEY, -1);
    if (key <= 0) {
      Log.w(TAG, "doWorkWithResult: WorkRecord key not found, exiting:"
          + " intent=" + resultIntent);
      return;
    }

    final Object result = resultIntent.getExtras().get(WorkRecord.EXTRA_RESULT);
    if (result == null) {
      Log.w(TAG, "doWorkWithResult: Result not found, exiting:"
          + " key=" + key
          + ", intent=" + resultIntent);
      return;
    }

    final WorkRecord workRecord = workRecords.get(key);
    if (workRecord == null) {
      Log.w(TAG, "doWorkWithResult: matching WorkRecord not found, exiting:"
          + " key=" + key
          + ", workRecords=" + workRecords
          + ", result=" + result);
      return;
    }

    final View viewToUpdate = findViewById(workRecord.viewId);
    if (viewToUpdate == null) {
      Log.w(TAG, "doWorkWithResult: viewToUpdate not found, exiting:"
          + " key=" + key
          + ", workRecord.viewId=" + workRecord.viewId
          + ", result=" + result);
      return;
    }

    final boolean updated = workRecord.callback.update(viewToUpdate, result);
    if (updated) workRecords.remove(key);
  }

  // WorkRecord.Store implementation

  @Override public long addWorkRecord(WorkRecord workRecord) {
    final long key = new Date().getTime();
    workRecords.put(key, workRecord);
    return key;
  }
}

MyTask.java

public class MyTask extends AsyncTask<Void, Void, Object> {
  // ...
  private final Context appContext;
  private final long workRecordKey;
  private final Object otherNeededValues;

  public MyTask(Context appContext, long workRecordKey, Object otherNeededValues) {
    this.appContext = appContext;
    this.workRecordKey = workRecordKey;
    this.otherNeededValues = otherNeededValues;
  }

  // ...

  @Override protected void onPostExecute(Object result) {
    final Intent resultIntent = new Intent(WorkRecord.ACTION_UPDATE_VIEW);
    resultIntent.putExtra(WorkRecord.EXTRA_WORK_RECORD_KEY, workRecordKey);
    resultIntent.putExtra(WorkRecord.EXTRA_RESULT, result);
    LocalBroadcastManager.getInstance(appContext).sendBroadcast(resultIntent);
  }
}

(the class where you initiate the task)

  // ...
  private WorkRecord.Store workRecordStore;
  private MyTask myTask;

  // ...

  private void initWorkRecordStore() {
    // TODO: get a reference to MainActivity and check instanceof WorkRecord.Store
    workRecordStore = (WorkRecord.Store) activity;
  }

  private void startMyTask() {
    final long key = workRecordStore.addWorkRecord(key, createWorkRecord());
    myTask = new MyTask(getApplicationContext(), key, otherNeededValues).execute()
  }

  private WorkRecord createWorkRecord() {
    return new WorkRecord(R.id.view_to_update, new WorkRecord.Callback() {
      @Override public void update(View view, Object result) {
        // TODO: update view using result
      }
    });
  }

Obviously, this approach is a huge effort compared to the other two, and overkill for many implementations. For larger apps that do a lot of threading work, however, this can serve as a suitable base architecture.

Implementing this approach exactly as described in the video, the task will run to its end without being canceled on config change, like the second approach above. If your task is expensive (CPU, memory, battery), has side effects, or needs to be automatically restarted on Activity restart, then you would need to modify this approach to accommodate canceling, optionally saving and restarting, the task. Or just stick with the first approach; Romain had a clear vision for this and implemented it well.

Corrections

This is a big answer, and it's likely that I have made errors and omissions. If you find any, please comment and I'll update the answer. Thanks!

Getraer answered 13/3, 2016 at 13:51 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.