СоХабр закрыт.

С 13.05.2019 изменения постов больше не отслеживаются, и новые посты не сохраняются.

| сохранено

H Как не потерять связь между фоновыми задачами и Activity в черновиках Из песочницы

Введение

При программирование для Android есть два основных подхода к управлению состоянием Activity, View или Fragment.

Первый подход — это игнорировать состояние и загружать содержимое заново при каждом создании (поворот экрана, переключение между приложениями).

Второй подход — сохранять и восстанавливать состояние компонентов в соответствии с шаблоном onSaveInstanceState/onRestoreInstanceState.

Первый подход легко реализовать, но пользовательский опыт оставляет желать лучшего.

Второй подход влечет за собой огромное количество проблем. Это реально трудно — поддерживать все компоненты в полной готовности к сохранению/восстановлению и одновременно с этим выполнять в фоне какие-то задачи. Давайте рассмотрим некоторые из проблем, которые при этой возникают.

1. Когда Fragment или Activity запускает фоновую задачу (например, Activity хочет подтянуть содержимое из интернета), результат такой задачи нельзя доставить обратно в Activity, если Activity было уничтожено или пересоздано из-за изменения ориентации экрана или перезапуска процесса.

2. Разрекламированная функция Fragment.setRetainInstanceState(true) тут не поможет, потому что такой Fragment сохраняется только для изменений конфигурации экрана. В добавок, хранение ссылок на Fragment может привести к утечкам памяти.

3. Не существует простого способа проверить, запущена ли уже фоновая задача. Обычный способ — это проверить запускалась ли задача ранее при помощи savedInstanceState, и если не запускалась — тогда запустить фоновую задачу. Но, если фоновая задача была потеряна при пересоздании процесса, она не запустится второй раз, и пользователь получит экран со значком вечного прогресса. Такой глюк — совершенно обычное дело и встречается даже в самих популярных приложениях.

4. Сохранение списка фоновых задач в статических переменных или в объекте Application — хорошая идея, но это на самом деле не спасет, если они будут сброшены при перезапуске процесса из-за нехватки памяти.

Состояние фоновых задач и состояния Activity должны быть согласованны.
Есть ли какое-нибудь решение для всех этих проблем?

Решение

С одной стороны, у нас есть Activity, которая сохраняет свое состояние, а с другой у нас есть статические переменные. Иногда Activity выживает, а иногда выживает процесс и статические переменные.

Решение — это хранить все фоновые задачи в статических переменных и доставлять результаты их выполнения в Activity. Если Activity не находится в активном состоянии (между onResume/onPause), то результаты нужно придержать, пока Activity не активизируется.

Параллельно, нужно сохранять/восстанавливать список фоновых задач для Activity, потому что статические переменные могут быть потеряны из-за перезапуска процесса. Тогда эти фоновые задачи нужно будет перезапустить.

Вот так и появился AsyncBean.

Как использовать AsyncBean

public class YourAsyncTaskBean extends AsyncBean {

    YourAsyncTaskBean(<аргументы на ваше усмотрение>) {
    }

    @Override
    protected void run(boolean restart) {

        // Запустить фоновую задачу. Когда завершена, фоновая задача
        // должна вызвать AsyncBean.deliver() в главном потоке.

        deliver();
    }
}

// где-то в объявлении activity/fragment/view

    YourAsyncTaskBean yourBean;

// где-то в activity/fragment/view onCreate/onRestoreInstanceState

    if (savedInstanceState != null)
        yourBean = AsyncBean.restoreInstance((AsyncBean)savedInstanceState.getSerializable("yourBean"));

// как запустить

    yourBean = new YourAsyncTaskBean(<аргументы на ваше усмотрение>);
    yourBean.execute(yourBeanListener);

// код сохранения/подключения/отключения к фоновой задаче

@Override
protected void onSaveInstanceState(Bundle outState) {
    super.onSaveInstanceState(outState);
    outState.putSerializable("yourBean", yourBean);
}

@Override
protected void onResume() {
    super.onResume();
    if (yourBean != null)
        yourBean.onResume(yourBeanListener);
}

@Override
protected void onPause() {
    super.onPause();
    if (yourBean != null)
        yourBean.onPause();
}

// для получения данных после выполнения фоновой задачи используйте

AsyncBeanListener yourBeanListener = new AsyncBeanListener() {
    @Override
    public void onAsyncBeanStateChanged(AsyncBean bean, AsyncBeanState state) {
        if (bean instanceof YourAsyncTaskBean && state == AsyncBeanState.COMPLETED)
            ...
    }
};


Здесь много вспомогательного кода, так что сделайте базовый класс, который будет управлять всем этим вместо вас, как я сделал это в BaseActivity в демонстрационном приложении.

Демонстрационное приложение

github.com/konmik/AsyncBeanDemo

Присутствует apk, можно сразу запускать. Разрешений не требует.

Этот пример поддерживает две фоновые задачи при поворотах экрана и пересоздании Activity, одновременно показывая индикатор прогресса. Когда процесс уничтожается и Activity пересоздается из сохраненного состояния, фоновые задачи автоматически перезапускаются. Состояние фоновых задач и состояние Activity поддерживаются в согласованном состоянии.

Демонстрационное приложение должно корректно работать при:

1. Пересоздании Activity — а) поверните экран или б) откройте настройки разработчика и включите галочку «Do not keep activities» («Не сохранять операции» на русском). Переключайтесь между приложениями во время выполнения фоновых задач. Выполнение фоновых задач не должно прерываться.
2. Пересоздание процесса — откройте диспетчер задач и нажмите «Очистить память». Переключитесь в демонстрационное приложение, вы увидите, как задачи будут перезапущены.

Заключение

AsyncBean заполняет большую брешь в архитектуре Android-приложения.

Комментарии и предложения — приветствуются!

комментарии (42)

0
Mikhail_dev ,   * (был изменён)
Но, если фоновая задача была потеряна при пересоздании процесса, она не запустится второй раз, и пользователь получит экран со значком вечного прогресса.

Когда процесс уничтожается и Activity пересоздаётся из сохраненного состояния, фоновые задачи автоматически перезапускаются.

Процесс — это не поток, а уж тем более не объект (ну если говорить абстрактно). Пересоздается именно объект в рамках того же самого потока. А процесс в большинстве случаев — это всё ваше приложение. Если Ваш процесс умрёт — умрёт и всё приложение со всеми вашими статическими ссылками и прочим.

1. Вы имели дело с Loaders, к примеру AsyncTaskLoader? Он грамотно обрабатывает фоновые задачи при поворотах. Единственный минус — это пересоздание фоновой задачи при повороте. На эту проблему приходит пункт два
2. IntentService — запустил и биндись к нему в onResume для получения статуса задачи, после чего делать вывод, нужны нам данные или нет.

2. Разрекламированная функция Fragment.setRetainInstanceState(true) тут не поможет, потому что такой Fragment сохраняется только для изменений конфигурации экрана.

Вы серьёзно? (метод называется setRetainInstance(boolean state) )
public void setRetainInstance (boolean retain)

Control whether a fragment instance is retained across Activity re-creation (such as from a configuration change). This can only be used with fragments not in the back stack. If set, the fragment lifecycle will be slightly different when an activity is recreated:

onDestroy() will not be called (but onDetach() still will be, because the fragment is being detached from its current activity).
onCreate(Bundle) will not be called since the fragment is not being re-created.
onAttach(Activity) and onActivityCreated(Bundle) will still be called.

Сохраняется как раз таки объект, а вьюшка умирает. Более подробно — здесь
+3
JackHexen ,  
Да, в документации все очень красиво расписано. Но.

Когда андроиду мало памяти, он грохает ваш процесс. Т.е. целиком — со всеми фоновыми процессами, фрагментами, и т.п… А когда пользователь переключается на ваше приложение — Activity восстанавливаются из сохраненного состояния. Статические переменные, фоновые процессы — никто для вас не восстанавливает. Все фрагменты с SetRetainInstance(true) тоже пересоздаются из сохраненного состояния.

Проверить это очень просто — нажмите «очистить память» и узрите. Именно из-за этого у нас так много странных глюков при переключении между задачами. Попробуйте завалить мое демо-приложение. А классические демки «как надо программировать» с setRetainInstance(true) даже стыдно заваливать — это как избиение детей. Ни одно из них не продолжает выполнение фонового процесса после очистки памяти, из-за этого реальные приложения глючат.

На устройствах с малым количеством памяти эти глюки случаются чаще, и если приложение долго висит в фоне, то тоже есть большой шанс что андроид его пересоздаст. Поэтому этот глюк часто проходит мимо разработчиков, и все удовольствие получают конечные пользователи, а по данным крашлитикса приходится писать кучу корявок и костылей.
0
Mikhail_dev ,  
С чего это вдруг очистка памяти должна чистить работающие вещи приложения? Системе хватает памяти и она не будет чистить работающие составляющие.

Опять же, если у вас стоит задача железно выполнить операцию, даже когда выключился телефон, то создайте класс «Задача» и пишите его в базу, после чего база пусть работает с сервисом, который будет проверять наличие неотправленных задача.
Если задача не стоит обязательно доставить задачу, если телефон выключился, то задача отправляется в лодере/сервисе обычным образом.

Более того, а почему вы просто не взяли класс унаследованный от Application и не сделали там ваши статические переменные и прочее? Класс Application последним покидает судно корабля выгружается.
+2
bimeg ,  
С того что так написано в документации. Только foreground приложение не чистится. Но если оно хочет памяти, то остальные начинают прибивать. Совсем.
0
Mikhail_dev ,  
Так сделайте foreground! Только используйте его разумно. У нас за 3 года приложение не умирало никогда в данном режиме начиная с телефонов 2.1, а сейчас и подавно, а обработка задач там идет довольно немалая.
+1
bimeg ,   * (был изменён)
Гарантированно не умрет только активити которую сейчас видит юзер и/или другие компоненты с приоритетом foreground. Остальные компоненты могут быть убиты в порядке приоритета (даже активити из текущего видимого приложения). Managing Your App's Memory
+1
Mikhail_dev ,   * (был изменён)
Вы читали мой пост вообще?

Опять же, если у вас стоит задача железно выполнить операцию, даже когда выключился телефон, то создайте класс «Задача» и пишите его в базу, после чего база пусть работает с сервисом, который будет проверять наличие неотправленных задача.
Если задача не стоит обязательно доставить задачу, если телефон выключился, то задача отправляется в лодере/сервисе обычным образом.


Вы пишете «много писанины», я пишу что пишите меньше и говорю как. Вы начинаете говорить что есть вариант получить проблемы, я говорю что пишите больше и правильней.
+2
Alexey_Bespaly ,  
Использование локальной базы данных и Service — всегда беспроигрышный вариант!
0
JackHexen ,   * (был изменён)
С загрузчиками дело имел, но поверхностно. Слишком много кодить для выполнения элементарных вещей, приложение усложняется в разы. И они снимают только часть проблем, к примеру если моя фоновая задача должна обработать изображение, загрузчик тут можно конечно приспособить, но это будет использование не по назначению. :)
0
Mikhail_dev ,  
С чего это вдруг? Также в бекграунде и обрабатывайте свою картинку, кто же вам мешает. Разберитесь получше с сервисами и лодерами, ибо первые для долгоиграющих задач, вторые для коротких.
Писанины? Ну это Java, тут везде писанина. Хотите правильно — пишите отдельный Loader в отдельном классе, а если не хотите — то создайте поток в пару строк.
–1
JackHexen ,   * (был изменён)
Ну, вам могу посоветовать разобраться с жизненным циклом приложения тогда :)
0
Mikhail_dev ,  
Вы аргументируйте свои слова как-нибудь и пишите в чем со мной не согласны. Я что-то неправильно написал?
–1
JackHexen ,  
Ну, мне тоже от вас аргументации не хватает. :)
Я добавил табличку в текст, посмотрите — может, прояснится.
Вы с ней согласны? Если нет — запустите какое-нибудь приложение, с логированием жизненного цикла, и выполните шаги из раздела «Демонстрационное приложение должно корректно работать при:...» Если у вас приложение будет вести себя как-то по другому, с интересом об этом почитаю.
0
Mikhail_dev ,   * (был изменён)
Дык вы используете механизмы сериализации, в чем тут преимущество перед записью в базу задачи?? Причем тут вообще жизненный цикл, когда речь идёт уже о сохранении задачи на внешний носитель?
Это просто шедеврально сравнивать вещи, где используется ОЗУ с тем, где используется еще и сохранение на внешний носитель
0
bimeg ,  
То что кладется в onSaveInstanceState тоже оказывается на ПЗУ. Только за тем андроид лично следит.
–2
JackHexen ,  
Сохранять задачу в базу — тоже вариант, ничего против не имею. Сериализация тут просто потому что она удобней.
0
Mikhail_dev ,  
Давайте так рассуждать: есть специальные вещи, которые работают с SQLite, такие как ContentProvider. Они работают быстрее и позволяют удобно делать CRUD операции с БД. Чем тут выигрывает механизм сериализации? Ну кроме того, что оно родное. А что если у нас будет стек однотипных задач? Для каждой делать статическую переменную?
Сериализация удобная штука, но тут я не вижу в ней пользы. Более того, человек, который будет читать ваш код, вполне вероятно будет знать как работать с базой данных, а ваш код воспримет как непонятный и в который надо будет вникать.
–1
JackHexen ,  
Механизм сериализации выигрывает совершенно очевидно. Вы просто наследуете AsyncBean, и все. Однотипные задачи, да и любые другие задачи Activity, просто сохраняются в ArrayList, который точно так же сериализируется, а то и вообще достаточно поставить Icicle перед ним, если используете Icepick (я обычно его использую, но в примере решил избежать чтобы не нагружать тех, кто не в курсе).

Человеку, читающему мой код, небходимо будет разобраться с AsyncBean, конечно, также как и с JButterknife, Icepick, ORMLite, Dagger, etc.
+2
alexxxst ,  
Сервисы же… — в них все выполняется, BroadcastReceiver — шлет данны в Activity… зачем эти велосипеды?
0
+1 –1
JackHexen ,  
Сервис для того чтобы, к примеру, получить профиль пользователя с сервера? Многовато писанины.
0
alexxxst ,  
Это ж сколько по времени профиль пользователя с сервера качается у вас тогда? Быстрее и проще будет сделать новый тред и в нем все выполнить. Особенно, если это какая-то одноразовая сетевая операция, например.
Хотя… на любителя, конечно.
0
+1 –1
JackHexen ,   * (был изменён)
Так на этих одноразовых операциях все и спотыкаются. Мы запрашиваем профиль пользователя или feed, и тут пользователю звонят. Памяти мало, приложение закрывается, не получив ни ответа ни ошибки. Пользователь возвращается в приложение и в лучшем случае видит вечный прогрессбар. Глюк? Глюк.
0
Mikhail_dev ,  
Если приложение полностью умирает, то тут ничего не поможет
0
alexxxst ,  
Если дело только в памяти, то андроид убьёт приложение целиком, со всеми вашими тасками, сервисами и всем, чем только можно. Так что тут ничего не поможет. Скорее всего причина в чем-то другом.
–1
JackHexen ,  
В смысле в другом? Почему не поможет? :)
Я как раз и написал в чем причина, и решение этой проблемы описал. :)
Вы демку покрутите, попробуйте очистить память, мне тоже было трудно в это поверить поначалу.
0
WToll ,  
Стопать приложение советую через то что щас называется Android device monitor (ddms раньше). Бывали случае когда после очистки памяти отрабатывало хорошо, но в продакшне иногда крешилось, помочь определить в чем трабла помогла только остановка через ddms.
Ну и в догонку плюс один за robospice ;)
+2
anton9088 ,  
Обычно для таких вещей используются проверенные фреймворки, например, RoboSpice. Который выполняет задачи в сервисе и кэширует http запросы на диск. В onResume подсоединяются листенеры чтобы получить результат. В onPause отсоединяются чтобы не вернуть результат когда fragment уничтожен.
Если запрос выполнился в то время когда fragment скрыт, то запрашиваются данные из кэша.
Если же процесс будет убит, то данные опять же берутся из кэша.
Логика по подсоединению, отсоединению листенеров так же как взятие результатов из кэша делается в BaseFragment.
Все что остается — просто вызвать метод загружающий данные.
Все уже давно изобретено)
–1
JackHexen ,  
Ткните мне, пожалуйста, где робоспайс (или любой другой фреймворк) перезапускает задачи при пересоздании процесса или хотя бы уведомляет о том, что такая задача насильственно закрылась. Что-то найти не могу никак, уже пол года ищу.
0
bean ,  
AsyncTaskLoader все же проще, потому что он сам (точнее LoaderManager) следит за жизненным циклом фрагмента или активити к которому привязан (не надо будет писать, то что у вас в BaseActivity в методах onSaveInstanceState, onPause, onResume), не надо отдельно создавать AsyncTask, не нужно париться на счет сериализации.
0
+1 –1
JackHexen ,  
AsyncTaskLoader — хорошая штука, работает почти также. Мне проще передавать аргументы напрямую, рассчитывая на автоматическую сериализацию, а не через Bundle. Всегда упрощаю логику «сверху» приложения.

Сравните количество кода в MainActivity для AsyncBean:

execute(new PercentageBean(10000))

и если бы я написал для AsyncTaskLoader:

— onCreate:

Bundle b = new Bundle();
b.putInt("delay", 10000);
getLoaderManager().initLoader(0, b, this);


— calling loader:

Bundle b = new Bundle();
b.putInt("delay", 10000);
getLoaderManager().initLoader(0, b, this).forceLoad();

MainActivity implements LoaderCallbacks {
 public Loader onCreateLoader(int id, Bundle args) { 
      return new PercentageLoader(this, args); 
 } 

+ распаковка Bundle внутри Loader


Да, логика самого BercentageBean немного сложнее чем PercentageLoader, но в итоге использовать его проще. Что, если нужно запустить несколько Loader параллельно с разными параметрами? AsyncBean это позволит сделать, а для Loader нужно как-то хранить id, параметры, сериализировать их вручную, и в итоге вы захотите как-то все это объединить и сделать универсальным не париться, а потом подумаете нафига тут вообще Loader, и получите AsyncBean. ;) По крайней мере у меня произошло нечто похожее.
0
bean ,  
На счет Bundle'ов согласен с вами, действительно многобукоф, неудобно.
Не вижу в чем проблема с AsyncTaskLoader. Id будет хранится как обычная константа, этот идентификатор как раз определяет какая из задач сейчас выполняется. Сериализовывать тоже ничего не надо, зачем? Результат будет хранится в самом лоадере.
Честно говоря не понял зачем нужная такая сложная логика внутри UidGenerator для генерации айдишников AsyncBean. Но там кстати используются интерфейс Application.ActivityLifecycleCallbacks доступный только с 14 версии, а те же Loaders доступны в support-library v4.
0
bean ,  
Кажется понял, для AsyncBean идентификаторы должны быть уникальны для всего приложения, поскольку они хранятся как статическое поле, а для лоадеров они уникальны только в пределах LoaderManager'a, который привязан к фрагменту или активити. Поэтому там все проще.
0
JackHexen ,  
Да, разница именно в этом — идентификатор должен быть уникальным. View.generateViewId() по этой же причине я считаю глючным. Эти Id разу не уникальные, и использовать эту функцию нельзя, т.к. при пересоздании процесса пойдет дубляж, особенно это будет заметно если откроется несколько экземпляров одного Activity.

Для низких версий вместо идентификаторов можно использовать строковый UUID, но это немного медленнее — примерно миллисекунда-другая будет сжираться. Есть еще вариант встраивать вызов UidGenerator.init() в MainActivity, и вызывать когда savedInstanceState == null, я так раньше делал. Потом выделил, потому что новые проекты пишу только начиная с апи 15.
0
JackHexen ,  
Если вы хотите одновременно выполнить несколько одинаковых AsyncTaskLoader, вам придется вызывать initLoader для каждого в onCreate. Чтобы вызывать initLoader придется хранить Id каждого запущенного AsyncTaskLoader, даже если Bundle будет сохраняться сам.
0
bean ,  
Ну и что? execute у AsyncBean тоже 2 раза нужно будет вызывать. С id проблему не вижу, ну есть он и есть, внутри отдельного фрагмента коллизия не возникнет :)
getLoaderManager().initLoader(0, ProgressLoader.buildArgs(1000) this);
getLoaderManager().initLoader(1, ProgressLoader.buildArgs(999), this);

или
YourAsyncTaskBean yourBean0 = new YourAsyncTaskBean(1000);
yourBean.execute(yourBeanListener);
YourAsyncTaskBean yourBean1= new YourAsyncTaskBean(999);
yourBean.execute(yourBeanListener);

разницы никакой, только в случае AsyncBean нужно еще обработать onSaveInstanceState/onPause/onResume, проверить savedInstanceState.
0
JackHexen ,  
В случае AsyncBean не придется обрабатывать save/restore/pause/resume, это за тебя сделает BaseActivity. ;)

В случае с Loader нужно будет хранить его Id, например если пользователь нажал «отправить сообщение», потом написал еще одно и нажал еще раз. Сколько раз сможет пользователь нажать «отправить сообщение» до того, как они закончат отправляться и синхронизироваться? Массив Id придется хранить по любому. Ну или писать сервис, как тут советовали, да еще чтобы индикатор в статусбаре крутил. :))

AsyncBean:

execute(new PostMessage(textView.getText.toString()));


AsyncTaskLoader:

В дефиниции класса:

ArrayList ids = new ArrayList();
int idCounter;

Запуск Loader:

Bundle b = new Bundle();
b.putString("text", textView.getText.toString());
getLoaderManager().initLoader(1, b, this).forceLoad();
ids.add(idCounter++);

... onCreate:

if (savedInstanceState != null) {
    idCounter = savedInstanceState.getInteger("idCounter");
    ids = savedInstanceState.getSerializable("ids");
    for (int id : ids)
        getLoaderManager().initLoader(id, null, this)
}

... onSaveInstanceState:

state.putSerializable("ids", ids);
state.putInt("idCounter", idCounter);



Как-то так. Конечно, какую-то часть можно вынести в базовый класс, какую-то можно оставить на откуп Icepick.

В общем, с моей точки зрения, оба подхода заполняют одну брешь, и имеют преимущество в разных случаях. AsyncBean использовать проще, если написать базовый класс для своих задач, как сделано у меня в деме. Если Loader выполняют задачи, простые как молоток, то удобнее использовать иго.

Я использую AsyncBean, так как он предельно прост, можно встраивать любую логику и любые сообщения. Я даже не могу назвать это библиотекой, по сути там работает два маленьких класса — AsyncBean и помогает UidGenerator, все остальное — демонстрация применения.
0
bean ,  
Не хочу с вами спорить, но судя по примеру вы не поняли как работают Loader'ы.
0
JackHexen ,   * (был изменён)
Да с этими Loader'ами вообще косяк. Похоже, они не продолжают работу при пересоздании процесса. Сейчас кручу демку — переписал ее под Loader'ы. Не работают они, рассинхрон идет. Приложение запустило Loader, было убито при нехватке памяти, Activity воссоздано, а Loader — нет.

То есть получается что эти Loader — то же что и Fragment.setRetainInstance(true) — только создают видимость надежности и облегчают программирование, но не делают программу безглючной на самом деле. Только запутывают все.
0
bean ,  
Ну да, в случае смены конфигурации лоадер тот же, а если все уходит в background то прибивается вместе с ui, если нужно чтобы такого не было то IntentService в помощь (ну или обычный сервис). Если же речь идет о запросах то тут volley классный врапер. В любом случае они используют статические переменные для хранения результата, так что чем-то похоже на вашу реализацию.
0
JackHexen ,  
Да, чем-то похожи, но не главным.
0
lNevermore ,  
Непонятно, зачем такие велосипеды.

И все таки ASyncTask использовать уже практически моветон. Как минимум есть альтернативы в лице RxJava с ее Observable, где поверх retrofit таскание картинок из интернетов ложится как нельзя лучше, да еще и не надо ничего пересоздавать. Просто отписывайтесь от обсервабла и подписывайтесь еще раз.

Если не хочется тянуть библиотеки или еще что, то можно стандартно, но все равно без ASyncTask. Насчет сервисов — не так уж много писанины. Особенно, если все равно надо кэшировать или хочется еще в каких то местах уведомлять пользователя (трэй, нотификации, тосты). Старая добрая архитектура Service (запросы) + ContentProvider(кэш) + BroadcastReceiverO(result delivery) отлично ложится на любой(почти) RESTful бэкэнд.

Стоит только унифицировать общение между сервисом и ui (роутинг запросиков + нормальные броадкасты), как все становится не так уж сложно.
0
lNevermore ,  
И да, андроид приложения тем лучше, чем меньше у него прогресс баров. Логика «тут у меня асинхронная операция, нужны прогресс бары» далеко не всегда хорошая. Ну, может это дело вкуса, конечно.