Использование android.os.Binder для организации асинхронного взаимодействия в Андроиде
Одна из естественных и первых задач при разработке под Андроид — организация асинхронного взаимодействия. Например, обращение к серверу из некоторой активности и отображение на ней результата. Трудность состоит в том, что за время обращения к серверу поверх может быть открыта другая активность или другое приложение, исходная активность может быть безвозвратно завершена (пользователь нажал Back) и т. д. Вот получили мы результат от сервера, но активность «неактивна». Под «активна», в зависимости от обстоятельств, можно понимать, например, что находится между onStart и onStop, onResume и onPause (или, как у нас в проекте, между onPostResume и первым из onSaveInstanceState и onStop). Как понять, завершена активность окончательно (и результат нужно отдать сборщику мусора) или лишь временно неактивна (результат нужно хранить, и отобразить, как только активность станет активной)?
Удивительно, но в документации, интернетах, при личном общении я ни разу не встречал корректного и приемлемо универсального способа. Хочу безвозмездно поделиться решением, которое мы применяем два с половиной года в мобильном интернет-банкинге. Приложение установлено (как часть более крупной системы) у нескольких сотен банков, на данный момент имеет около миллиона пользователей.
Уточним понятия активность и activity record. Активность — это экземпляр класса, короткоживущий объект. Activity record — логическое понятие, экран с точки зрения пользователя, более долгоживущий.
Рассмотрим схему Bottom > Middle > Top.
- Запускаем активность BottomActivity, поверх неё MiddleActivity. При повороте экрана, временном переключении на другое приложение и т. п. активность (экземпляр класса MiddleActivity) может уничтожаться и создаваться новая, но activity record Middle остаётся неизменным. Запускаем TopActivity поверх MiddleActivity, нажимаем кнопку Back. Активность MiddleActivity снова наверху стека, могла быть пересоздана, но activity record Middle всё так же сохраняется неизменным.
- Нажимаем Back — BottomActivity наверху стека. Снова запускаем MiddleActivity. Опять наверху activity record Middle. Но это уже новый activity record, не имеющий отношения к activity record из пункта 1. Тот activity record безвозвратно умер.
Предлагаемое решение основывается на следующем замечательном свойстве android.os.Binder. Если записать Binder в android.os.Parcel, то при чтении в том же процессе (в той же виртуальной машине) гарантированно прочитаем тот же самый экземпляр объекта, который был записан. Соответственно, можно проассоциировать с активностью экземпляр объекта activity record, и сохранять этот объект неизменным с помощью механизма onSaveInstanceState. В асинхронную задачу передаётся объект activity record, в который возвращается результат. Если activity record умирает, то становится доступен сборщику мусора, вместе с результатами работы асинхронных задач.
Для иллюстрации создадим простое приложение «Length». Оно состоит из двух активностей и четырёх инфраструктурных классов.
MenuActivity состоит из одной кнопки, которая запускает LengthActivity.
Работать с Binder напрямую неудобно, так как его нельзя записать в android.os.Bundle. Поэтому обернём Binder в android.os.Parcelable.
public class IdentityParcelable implements Parcelable {
private final ReferenceBinder referenceBinder = new ReferenceBinder();
public final Object content;
public static final Parcelable.Creator CREATOR = new Creator() {
@Override
public IdentityParcelable createFromParcel(Parcel source) {
try {
return ((ReferenceBinder) source.readStrongBinder()).get();
} catch (ClassCastException e) {
// It must be application recover from crash.
return null;
}
}
@Override
public IdentityParcelable[] newArray(int size) {
return new IdentityParcelable[size];
}
};
public IdentityParcelable(Object content) {
this.content = content;
}
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeStrongBinder(referenceBinder);
}
private class ReferenceBinder extends Binder {
IdentityParcelable get() {
return IdentityParcelable.this;
}
}
}
Класс IdentityParcelable позволяет передавать через parcel-механизм «ссылки» на объекты. Например, передать в качестве extra (Intent#putExtra) объект, который не является ни Serializable, ни Parcelable, и позже получить (getExtra) тот же экземпляр в другой активности.
Классы ActivityRecord и BasicActivity действуют в связке. ActivityRecord умеет исполнять callback-и. Если активность видна (находится в состоянии между onStart и onStop), то callback исполняется сразу, иначе сохраняется для более позднего исполнения. Когда активность становится видимой, исполняются все отложенные callback-и. При создании activity record (первый вызов BasicActivity#onCreate) создаётся новый объект ActivityRecord, и дальше поддерживается в onSaveInstanceState/onCreate.
public class ActivityRecord {
private static final Handler UI_HANDLER = new Handler(Looper.getMainLooper());
private Activity visibleActivity;
private final Collection pendingVisibleActivityCallbacks = new LinkedList<>();
public void executeOnVisible(final Runnable callback) {
UI_HANDLER.post(new Runnable() {
@Override
public void run() {
if (visibleActivity == null) {
pendingVisibleActivityCallbacks.add(callback);
} else {
callback.run();
}
}
});
}
void setVisibleActivity(Activity visibleActivity) {
this.visibleActivity = visibleActivity;
if (visibleActivity != null) {
for (Runnable callback : pendingVisibleActivityCallbacks) {
callback.run();
}
pendingVisibleActivityCallbacks.clear();
}
}
public Activity getVisibleActivity() {
return visibleActivity;
}
}
public class BasicActivity extends Activity {
private static final String ACTIVITY_RECORD_KEY = "com.zzz.ACTIVITY_RECORD_KEY";
private ActivityRecord activityRecord;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if (savedInstanceState == null) {
activityRecord = new ActivityRecord();
} else {
activityRecord = (ActivityRecord) ((IdentityParcelable) savedInstanceState.getParcelable(ACTIVITY_RECORD_KEY)).content;
}
}
@Override
protected void onStart() {
super.onStart();
activityRecord.setVisibleActivity(this);
}
@Override
protected void onStop() {
activityRecord.setVisibleActivity(null);
super.onStop();
}
@Override
protected void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
outState.putParcelable(ACTIVITY_RECORD_KEY, new IdentityParcelable(activityRecord));
}
public ActivityRecord getActivityRecord() {
return activityRecord;
}
}
На основе ActivityRecord делаем для асинхронных задач базовый класс, похожий контрактом на android.os.AsyncTask.
public class BackgroundTask {
private final ActivityRecord activityRecord;
public BackgroundTask(ActivityRecord activityRecord) {
this.activityRecord = activityRecord;
}
public void execute() {
new Thread() {
@Override
public void run() {
doInBackground();
activityRecord.executeOnVisible(new Runnable() {
@Override
public void run() {
onPostExecute(activityRecord.getVisibleActivity());
}
});
}
}.start();
}
protected void publishProgress(final int progress) {
activityRecord.executeOnVisible(new Runnable() {
@Override
public void run() {
onProgressUpdate(activityRecord.getVisibleActivity(), progress);
}
});
}
protected void doInBackground() {
}
protected void onProgressUpdate(Activity activity, int progress) {
}
protected void onPostExecute(Activity activity) {
}
}
Теперь, наладив инфраструктуру, делаем LengthActivity. При нажатии на кнопку асинхронно вычисляется длина введённой строки. Заметим, что при повороте экрана вычисление не начинается заново, а продолжается.
public class LengthActivity extends BasicActivity {
private TextView statusText;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.length_activity);
statusText = (TextView) findViewById(R.id.statusText);
findViewById(R.id.calculateLengthButton).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
new LengthTask(
getActivityRecord(),
((TextView) findViewById(R.id.sampleField)).getText().toString()
).execute();
}
});
}
private void setCalculationResult(CharSequence sample, int length) {
statusText.setText("Length of " + sample + " is " + length);
}
private void setCalculationProgress(CharSequence sample, int progress) {
statusText.setText("Calculating length of " + sample + ". Step " + progress + " of 100.");
}
private static class LengthTask extends BackgroundTask {
final String sample;
int length;
LengthTask(ActivityRecord activityRecord, String sample) {
super(activityRecord);
this.sample = sample;
}
@Override
protected void doInBackground() {
for (int i = 0; i < 100; i++) {
publishProgress(i);
try {
Thread.sleep(50);
} catch (InterruptedException e) {
throw new IllegalStateException(e);
}
}
length = sample.length();
}
@Override
protected void onProgressUpdate(Activity activity, int progress) {
((LengthActivity) activity).setCalculationProgress(sample, progress);
}
@Override
protected void onPostExecute(Activity activity) {
((LengthActivity) activity).setCalculationResult(sample, length);
}
}
}
Прикладываю архив со всеми исходниками и собранным APK.
Спасибо за внимание! Буду рад услышать комментарии и поучаствовать в обсуждении. Буду счастлив узнать более простое решение, без заморочек с Binder.