Pull to refresh

Comments 31

Честно, не очень понял проблему, для которой нужно именно такое решение.
В чём отличие, от скажем: есть сущность Cache в которой хранится результат асинхронной задачи. Этот результат можно получать спрашивая его у кэша в onResume() в активити, или через подписку/отписку в onResume()/onPause(). Также можно сделать аналог sticky broadcast, т.е. если результат есть, то сообщить его при подписке на событие.
Из активности MiddleActivity посылаем запрос на сервер, получаем ответ, кладём ответ (который занимает в памяти 100 КБ) в Cache. В этот момент MiddleActivity может быть окончательно завершена (пользователь ушёл на BottomActivity) или лишь временно закрыта активностью TopActivity. В какой момент удалять ответ из Cache? Если держать его там вечно в надежде, что когда-нибудь понадобится, то получится утечка памяти.
Если результат нужен только в рамках жизни MiddleActivity можно удалять ответ/чистить кэш в onDestroy()
Да, что-то такое типа onFinalDestroy() вполне подошло бы. Но пара onDestroy()+isFinishing() слишком ненадёжна, во многих случаях не работает. Например, запускаем Bottom > Middle > Top. MiddleActivity#onDestroy() отрабатывает с isFinishing()==false. Из TopActivity запускаем BottomActivity с флагом Intent#FLAG_ACTIVITY_CLEAR_TOP (вылогиниваем пользователя). При этом метод MiddleActivity#onDestroy() повторно вызван не будет (соответственно, произойдёт утечка).
Кстати, может вашу задачу как раз решает AsyncTaskLoader? Я сам не пользовался, могу ошибаться, но это вроде что-то типа AsyncTask, но которая ещё следит за жизненным циклом активити.
Не, AsyncTaskLoader ничем не поможет. Отрабатывает AsyncTaskLoader#loadInBackground(), получаем результат, но активность не активна (в случае Loader — не видна). И встаёт тот же вопрос: «Доколе хранить результат?»
Loader-ы (и в частности AsyncTaskLoader) очень хорошо справляются с задачей асинхронной загрузки и повторной асинхронной загрузки данных. Только их нужно использовать совместно с LoaderManager.
Конечно, я и имел ввиду, в частности, LoaderManager#initLoader(). Если объясните, чем AsyncTaskLoader может помочь в деле определения статуса активности-получателя результата асинхронной задачи, то буду очень благодарен.
А чем плох вариант с ограничением размера кэша? Скажем 30 записей в кэше максимум, более старые удаляются при добавлении новых. Ну или 5 мегабайт, вариантов много.
Очевидные недостатки варианта с ограниченным кэшем:
  • может быть удалён элемент кэша, который ещё нужен,
  • расходуется память на элементы, которые уже не нужны.

Но для большинства реальных задач, думаю, вполне приемлемое решение. Другое дело, что всё равно инфраструктуру нужно городить: вводить кэш, назначать активностям UID. Получится не намного проще, чем добавить несколько строк в BasicActivity#onCreate() и BasicActivity#onSaveInstanceState(). Класс IdentityParcelable в любом случае может пригодиться, чтобы какой-нибудь объект по ссылке через parcel пробросить. Остальной код не является специфичным для решения с Binder-ом.

Можно всё хранить, но не в памяти, а где-нибудь в файле или БД. Очищать при старте процесса, или, в зависимости от требований, результат асинхронной задачи может пережить даже смерть процесса. Но с сериализацией/десериализацией не всегда удобно работать. Иногда хочется какой-нибудь Runnable передать.

Можно вручную кэш чистить. Допустим, если переходим на «корневые» активности (LoginActivity, MainMenuActivity), то очищаем кэш, так как гарантированно все вложенные activity record-ы умерли. Но вручную не хочется управлять, особенно когда активностей много, workflow сложный, включая случаи, когда одна активность может встречаться в back stack-е несколько раз.

Есть вариант забывать про асинхронную задачу, например, в Activity#onStop(). Пользователь поворачивает экран до того, как запрос завершился, — ответ игнорируется, и в следующий раз отправляется новый запрос.
Функциональный аналог IdentityParcelable может быть очень простым: статический HashMap<Serializable, Object> и несложный код доступа.
Например так:
class IdentityParcelable implements Serializable {
    private static HashMap<UUID, Object> cache = new HashMap<>();
    private static HashMap<Object, UUID> reverseCache = new HashMap<>();

    private UUID id;

    public IdentityParcelable() {
    }

    public IdentityParcelable(Object o) {
        if (reverseCache.containsKey(o)) {
            id = reverseCache.get(o);
        } else {
            id = UUID.randomUUID();
            cache.put(id, o);
            reverseCache.put(o, id);
        }
    }

    public Object getObject() {
        return cache.get(id);
    }
}

В чём, по-вашему, этот простой аналог уступает предложенному вами коду?
Уступает тем, что объекты накапливаются в памяти (в cache и reverseCache), происходит утечка.
Рассмотрим пример

public class MiddleActivity extends Activity {

    private Object object;

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

        if (savedInstanceState == null) {
            object = new Object();
        } else {
            object = ((IdentitySerializable) savedInstanceState.getSerializable(OBJECT_KEY)).getObject();
        }
    }

    @Override
    protected void onSaveInstanceState(Bundle outState) {
        super.onSaveInstanceState(outState);
        outState.putSerializable(OBJECT_KEY, new IdentitySerializable(object));
    }
}

Если использовать не IdentitySerializable, а IdentityParcelable из статьи, то как только activity record Middle умирает (пользователь нажимает Back, вызывается Activity#finish(), используется флаг Intent#FLAG_ACTIVITY_CLEAR_TOP и т. д.), объект «object» становится доступным сборщику мусора. (Но пока activity record жив — поворот экрана, переключение на другие приложения — object не доступен для сборки).
Уверены, что в вашем коде нет утечки которая очевидна в моем коде?
В Андроиде вообще мало, в чём можно быть стопроцентно уверенным.
Допустим, что в Activity#onSaveInstanceState() мы сохранили какой-то экземпляр Binder. Идея в том, что пока activity record жив и, соответственно, может быть вызван метод Activity#onCreate(), сохранённый в onSaveInstanceState() объект Binder не может быть уничтожен, так как в параметре savedInstanceState метода onCreate() должен быть передан в точности тот экземпляр Binder, который был сохранён. Если же activity record умирает, то onCreate() больше не может быть вызван, и нет причин держать (от сборщика мусора) Binder. То есть, такой Binder по сути представляет собой activity record, имеет идентичный жизненный срок. Дальше можно к Binder-у подцеплять любые данные, например, через статический WeakHashMap, или как в статье, сделать отдельный класс ActivityRecord, который держится IdentityParcelable-ом, который держится ReferenceBinder-ом. Обёртка не принципиальна.
Конечно, мы проверяли и в DDMS, и программно (Runtime#totalMemory() — freeMemory(), отсутствие OOM, переопределение Object#finalize()). Утечек не обнаружили. Решение с Binder-ом используется два с половиной года на большом разнообразии устройств. С утечками также не сталкивались.
Мне удалось проверить, что:
Ссылка на ActivityRecord удерживается пока существует Bundle в который записали instance state.
Ссылка на ActivityRecord удаляется когда удаляется Bundle в который ее записали.

Но не удалось заставить систему восстановить ReferenceBinder из Parcel!
IdentityParcelable createFromParcel(Parcel source) — не вызывается чтобы я ни делал. Такое ощущение, что Bundle хранит ссылку на Parcelable, и игнорирует все что записали в Parcel.

Для проверки гипотезы я удалил из IdentityParcelable всё вплоть до ReferenceBinder и не нашел никаких изменений в работе приложения.
public class IdentityParcelable implements Parcelable {

    public static final Parcelable.Creator<IdentityParcelable> CREATOR = new Creator<IdentityParcelable>() {
        @Override
        public IdentityParcelable createFromParcel(Parcel source) {
            throw new RuntimeException("Not implemented");
        }

        @Override
        public IdentityParcelable[] newArray(int size) {
            throw new RuntimeException("Not implemented");
        }
    };

    public final Object content;

    public IdentityParcelable(Object content) {
        this.content = content;
    }

    @Override
    public int describeContents() {
        return 0;
    }

    @Override
    public void writeToParcel(Parcel dest, int flags) {
    }
}

Эта реализация работает так же как ваша (и не использует никаких Binder/IBinder).
Дело в какой-то недокументированной оптимизации в Андроиде. В некоторых случаях сериализация/десериализация объекта Parcelable может не проводиться. При повороте в onCreate() может передаваться тот же экземпляр Parcelable, который был записан в onSaveInstanceState(). При этом методы writeToParcel/createFromParcel не вызываются.
Сериализацию можно пронаблюдать так.
  1. Выставляем в Developer options опцию «Don't keep activities».
  2. Переходим на экран «Length».
  3. Нажимаем кнопку Home. LengthActivity благодаря опции гарантированно уничтожается.
  4. Возвращаемся в приложение (на экран «Length»).
В некоторых случаях сериализация/десериализация объекта Parcelable может не проводиться

Например, когда передача идет во фрагмент, а не Activity. Может, в этом дело?
В примере «Length» из статьи фрагменты не используются.
Возможно, что при повороте сериализация Parcelable не производится никогда. Возможно, производится в некоторых случаях, на некоторых устройствах, некоторых версиях операционной системы, или станет производиться в будущем. Это поведение ведь не специфицировано.
Вспомнил одну задачу. Как бы вы её разрулили в вашей системе:
1) Есть две активити A и B.
2) Внутри активити A запускается фоновая задача и в этот момент переходим в активити B.
3) Внутри активити B юзер делает что-то из-за чего результат фоновой задачи становится не актуальным.
В вашем случае по возвращении через Back в активити A у вас «выстрелит» результат уже не актуальный.
А прямого доступа к «хранилищу» чтобы инвалидировать результат у вас нет…
Ну код автора заточен под возвращение результата в любом случае. понятно, что если на одни и те же данные могут влиять разные компоненты, то нужно какое-то общее хранилище или возвращение результата из другого компонента. Просто ему это не нужно было, видимо.

В целом, подход интересный, можно подтюнить под свои нужды
По нашему опыту могу сказать, что идея введения сущности для activity record оказалась плодотворной. Класс ActivityRecord — очень удобный, гибкий и мощный. Рекомендую. Конечно, в реальном проекте он немного сложнее, чем в статье. Кстати, в андроидных внутренностях есть нечто подобное: com.android.server.am.ActivityRecord, android.app.ActivityThread$ActivityClientRecord, android.app.BackStackRecord.

Затрудняюсь дать наилучшее решение задачи, так как оно зависит от существующей архитектуры и от деталей требований. Например, активность A может в качестве extra передавать активности B weak reference на свой activity record.
Intent intent = new Intent(this, BActivity.class);
intent.putExtra("AActivityRecord", new IdentityParcelable(
        new WeakReference<?>(getActivityRecord())
));

Теперь у активности B есть «прямой доступ к хранилищу».
WeakReference<AActivityRecord> aActivityRecordRef = (WeakReference<AActivityRecord>) ((IdentityParcelable) getIntent().getParcelableExtra("AActivityRecord")).content;

. . .

AActivityRecord aActivityRecord = aActivityRecordRef.get();
if (aActivityRecord != null) {
    aActivityRecord.invalidateTaskResult();
}
Ну в целом это решение. Хотя можно прикопаться:)
— активити B может запускаться из разных активити, т.е. за этим надо следить и знать откуда были запущены
— мы привязываем B к неким сущностям A и/или предыдущих активити. Вместо того, чтобы знать о сущности общего кэша. Т.е. удаление/изменение A, потребует изменения в B.

Но тем не менее, как многие пишут — вам надо всё таки показать/рассказать о ваших реальных задачах, может быть составить таблицу сравнения(по кол-ву коду, удобство) вашего решения, со многими другими, тем же RoboSpice/Кэшем. Даже Гугл вроде нигде не рассказывает о таком способе хранения «состояний».
Неуниверсален.
Можно как угодно запускать асинхронные задачи: через Thread, Executor, AsyncTask, AsyncTaskLoader, сервисы и т. д. Можно сколь угодно удобно/безопасно организовывать взаимодействие с жизненным циклом активностей, и вообще со всей андроидной кухней. Вопрос в другом: «Что делать с асинхронно полученным результатом?»
RoboSpice ничем не помогает. Предлагается метод SpiceManager#shouldStop(). А в какой момент его дёргать? В картинках и презентациях на сайте правильно критикуются AsyncTask и Loader-ы. Но сам RoboSpice, мне кажется, от них недалеко ушёл.
Присоединяюсь к вопросам о том, какая именно проблема решается :)

Похоже на то, что здесь повторён LoaderManager. В SDK он передаётся через метод onRetainNonConfigurationInstance, а здесь — через Binder в onSaveInstanceState.

Проблема стандартная, предложено ещё одно решение, за это спасибо.
В статье озвучена проблема: «Что делать с результатом работы асинхронной задачи в случае, если активность не готова в данный момент его принять?» Не хочется терять результат. Но и хранить без нужды тоже не хочется.
LoaderManager никак не помогает решить проблему. (Пожалуйста, поправьте, если я не прав.)

В том-то и дело, что проблема абсолютно стандартная, а вот предложенное в статье решение не «ещё одно», а единственное мне известное. (Несколько вариантов для неперфекционистов перечислены тут.) То есть в большинстве серьёзных андроидных приложений что-то такое binder-образное должно использоваться. Но оно не то, что не используется, а я вообще ни такой идеи, ни даже схожей постановки проблемы не встречал. Надеюсь получить от хабрасообщества помощь в разрешении парадокса :)
Пока анализировал нашел багу ;-(.
1. Ориентация портрет
2. Запуск приложения
3. Тап на кнопку Length
4. Тап на кнопку Calculate Length
5. Ориантация альбом
6. Нажать аппаратную кнопку Home
7. Ориентация портрет
8. Запуск приложения
Ожидаемое состояние: Текст «Lengh of is 0»
Действительное состояние: Текст «Calculating length of. Step 60 of 100.» (и не меняется).
Спасибо! Да, немного напортачил. Если выставлена опция «Don't keep activities», то вертеть экран не обязательно. Достаточно уйти в Home, и вернуться после того, как асинхронная задача завершится.

Бага чисто UI-ная, не связана с обсуждаемым вопросом. Для того, чтобы максимально упростить код, я выставил TextView полю атрибут freezesText, чтобы текст не исчезал при повороте.
<!-- res/layout/length_activity.xml -->

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
              android:layout_width="fill_parent"
              android:layout_height="fill_parent"
              android:orientation="vertical"
        >
    <TextView
            android:id="@+id/statusText"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:freezesText="true"/>
    <EditText
            android:id="@+id/sampleField"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"/>
    <Button android:id="@+id/calculateLengthButton"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="Calculate Length"/>
</LinearLayout>

Можно этот атрибут убрать, и баги не будет. Или, например, перенести activityRecord.setVisibleActivity(this) из BasicActivity#onStart() в onResume() или onPostResume(), как у нас в проекте. Кстати, в реальном проекте callback-и не вызываются в методах жизненного цикла активности, а делается Handler#post (в ActivityRecord#setVisibleActivity()). Можно более аккуратно вручную поддерживать значение TextView (в onSaveInstanceState()/onCreate()). В реальном проекте, возможно, лучше иметь два разных TextView: для прогресса и для последнего результата.

А сейчас получается, что после возврата в приложение в методе onStart() полю выставляется правильный текст, но затем в реализации по умолчанию onRestoreInstanceState() значение поля перетирается тем, что было сохранено до ухода в Home.
Очевидно, что бандл держит ссылку на внутренний нестатический класс биндер, который держит ссылку на активитирекорд. Интересно знать в какой момент происходит удаление ссылки и какой тип у этой ссылки, может быть это какой-нибудь softreference?
SamSol вплотную подошел к ответу и пропал, интрига сохраняется :)
Ой, извиняюсь.
При выключенном «Don't keep Activities» всё работает
— во время пересоздания Garbage Collector не уничтожает IdentityParcelable (на него ссылается Bundle)
— после создания и последующего удаления Activity ссылка на IdentityParcelable освобождается и утечка памяти не происходит (Bundle уничтожается)

Если включить «Don't keep Activities» тоже всё работает. Но я не проверил удерживает ли Parcel ссылку на ReferenceBinder во время пересоздания Activity. Или может быть Bundle как и раньше удерживает ссылку на IdentityParcelable…
Я только проверил, что после пересоздания и удаления Activity нет утечки памяти.
Sign up to leave a comment.

Articles