Pull to refresh

Использование android.os.Binder для организации асинхронного взаимодействия в Андроиде

Reading time 7 min
Views 20K
Одна из естественных и первых задач при разработке под Андроид – организация асинхронного взаимодействия. Например, обращение к серверу из некоторой активности и отображение на ней результата. Трудность состоит в том, что за время обращения к серверу поверх может быть открыта другая активность или другое приложение, исходная активность может быть безвозвратно завершена (пользователь нажал Back) и т. д. Вот получили мы результат от сервера, но активность «неактивна». Под «активна», в зависимости от обстоятельств, можно понимать, например, что находится между onStart и onStop, onResume и onPause (или, как у нас в проекте, между onPostResume и первым из onSaveInstanceState и onStop). Как понять, завершена активность окончательно (и результат нужно отдать сборщику мусора) или лишь временно неактивна (результат нужно хранить, и отобразить, как только активность станет активной)?

Удивительно, но в документации, интернетах, при личном общении я ни разу не встречал корректного и приемлемо универсального способа. Хочу безвозмездно поделиться решением, которое мы применяем два с половиной года в мобильном интернет-банкинге. Приложение установлено (как часть более крупной системы) у нескольких сотен банков, на данный момент имеет около миллиона пользователей.

Уточним понятия активность и activity record. Активность – это экземпляр класса, короткоживущий объект. Activity record – логическое понятие, экран с точки зрения пользователя, более долгоживущий.
Рассмотрим схему Bottom > Middle > Top.
  1. Запускаем активность BottomActivity, поверх неё MiddleActivity. При повороте экрана, временном переключении на другое приложение и т. п. активность (экземпляр класса MiddleActivity) может уничтожаться и создаваться новая, но activity record Middle остаётся неизменным. Запускаем TopActivity поверх MiddleActivity, нажимаем кнопку Back. Активность MiddleActivity снова наверху стека, могла быть пересоздана, но activity record Middle всё так же сохраняется неизменным.
  2. Нажимаем 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». Оно состоит из двух активностей и четырёх инфраструктурных классов.

Project files

MenuActivity состоит из одной кнопки, которая запускает LengthActivity.

Main menu

Работать с 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<IdentityParcelable> CREATOR = new Creator<IdentityParcelable>() {
        @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<Runnable> 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. При нажатии на кнопку асинхронно вычисляется длина введённой строки. Заметим, что при повороте экрана вычисление не начинается заново, а продолжается.

Length activity

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.

UPD: deej подсказал класс android.support.v4.app.BundleCompat, который умеет записывать IBinder в Bundle. Когда разрабатывали решение, этого класса ещё не было. BundleCompat немного упрощает код, позволяя обойтись без IdentityParcelable, одним Binder-ом наподобие
public class ValueBinder extends Binder {

    public Object value;

    public ValueBinder() {
    }

    public ValueBinder(Object value) {
        this.value = value;
    }

    public <V> V value() {
        //noinspection unchecked
        return (V) value;
    }
}

Возможно, IdentityParcelable всё равно может быть полезен, например, для передачи произвольных объектов в Intent в качестве extra, хотя можно обойтись ValueBinder-ом, передавая через Bundle.
Tags:
Hubs:
+11
Comments 31
Comments Comments 31

Articles