Асинхронные операции и пересоздание Activity в Android
В одной статье на хабре (274635) было продемонстрировано любопытное решение для передачи объекта из onSaveInstanceState в onRestoreInstanceState без сериализации. Там используется метод writeStrongBinder(IBInder) класса android.os.Parcel.
Такое решение корректно функционирует до тех пор, пока Android не выгрузит ваше приложение. А он вправе это сделать.
…system may safely kill its process to reclaim memory for other foreground or visible processes…
(http://developer.android.com/intl/ru/reference/android/app/Activity.html)
Однако это не главное. (Если приложению не нужно восстанавливать свое состояние после такого рестарта, то подойдет и это решение).
А вот цель, для чего там используются такие «несериализуемые» объекты мне показалась странной. Там через них передаются вызовы из асинхронных операций в Activity, чтобы обновить отображаемое состояние приложения.
Я всегда думал, что со времен Smalltalk, любой разработчик распознает эту типовую задачу проектирования. Но кажется я оказался не прав.
Задача
- По команде от пользователя (
onClick()) запустить асинхронную операцию - По завершению операции отобразить результат в
Activity
Особенности
Activity, отображаемая в момент завершения операции, может оказаться- та же, из которой поступила команда
- другим экземпляром того же класса (поворот экрана)
- экземпляром другого класса (пользователь перешел на другой экран в приложении)
- На момент завершения операции может оказаться, что ни одна
Activtyиз приложения не отображается
В последнем случае результаты должны отображаться при следующем открытии Activity.
MVC (с активной моделью) и Layers.
Вся остальная часть статьи — это объяснение что такое MVC и Layers.
Поясню на конкретном примере. Пусть нам необходимо построить приложение «Электронный билет в электронную очередь».
- Пользователь входит в отделение банка, нажимает в приложении кнопку «Взять билет». Приложение посылает запрос на сервер и получает билет.
- Когда подходит очередь в приложении отображается номер окошка в которое нужно обратиться.
Получение билета от сервера я сделаю с помощью асинхронной операции. Также асинхронными операциями будут считывание билета из файла (после перезапуска) и удаление файла.
Построить такое приложение можно из несложных компонентов. Например:
- Компонент где будет находиться билет (
TicketSubsystem) TicketActivityгде будет отображаться билет и кнопка «Взять билет»- Класс для Билета (номер билета, позиция в очереди, номер окошка)
- Класс для Асинхронной операции получения билета
Самое интересное то, как эти компоненты взаимодействуют.
Приложение вовсе не обязано содержать компонент TicketSubsystem. Билет мог бы находиться
в статическом поле Ticket.currentTicket, или в поле в классе-наследнике android.app.Application.
Однако очень важно, чтобы состояние есть/нет билета исходило из объекта способного выполнять рольМодель из MVC — т. е. генерировать уведомления при появлении (или замене) билета.
Если сделать TicketSubsystem моделью в терминах MVC, то Activity сможет подписаться на события и обновить отображение билета когда тот будет загружен. В этом случае Activity будет выполнять роль View (Представление) в терминах MVC.
Тогда асинхронная операция «Получение нового билета» сможет просто записать полученный билет в TicketSubsystem и больше ни о чем не заботиться.
Модель
Очевидно, что моделью должен являться билет. Однако в приложении билет не может «висеть» в воздухе. Кроме того, билет изначально не существует, он появляется только по завершению асинхронной операции. Из этого следует, что в приложении должно быть еще что-то где будет находиться билет. Пусть это будет TicketSubsystem. Сам билет также должен быть как-то представлен, пусть это будет класс Ticket. Оба этих класса должны быть способны выполнять роль активной модели.
Способы построения активной модели
Активная модель — модель оповещает представление о том, что в ней произошли изменения. wikipedia
В java есть несколько вспомогательных классов для создания активной модели. Вот например:
PropertyChangeSupportиPropertyChangeListenerиз пакетаjava.beansObservableиObserverиз пакетаjava.utilBaseObservableиObservable.OnPropertyChangedCallbackизandroid.databinding
Мне лично нравится третий способ. Он поддерживает строгое именование наблюдаемых полей, благодаря аннотации android.databinding.Bindable. Но есть и другие способы, и все они подходят.
А в Groovy есть замечательная аннотация groovy.beans.Bindable. Вместе с возможностью краткого объявления свойств объекта получается очень лаконичный код (который опирается на PropertyChangeSupport из java.beans).
@groovy.beans.Bindable
class TicketSubsystem {
Ticket ticket
}
@groovy.beans.Bindable
class Ticket {
String number
int positionInQueue
String tellerNumber
}
Представление
TicketActivity (как практически все объекты относящиеся к представлению) появляется и исчезает по воле пользователя. Приложение всего лишь должно корректно отображать данные в момент появления Activity и при изменении данных пока отображается Activity.
Итак в TicketActivity нужно:
- Обновлять UI виджеты при изменении данных в
ticket - Подключать слушателя к Ticket когда он появится
- Подключать слушателя к
TicketSubsytem(чтобы обновить вид, когда появитсяticket)
1. Обновление UI виджетов.
В примерах в статье я буду использовать PropertyChangeListener из java.beans ради демонстрации
подробностей. А в исходном коде по ссылке внизу статьи будет использоваться библиотека android.databinding,
как обеспечивающая самый лаконичный код.
PropertyChangeListener ticketListener = new PropertyChangeListener() {
@Override
public void propertyChange(PropertyChangeEvent event) {
updateTicketView();
}
};
void updateTicketView() {
TextView queuePositionView = (TextView) findViewById(R.id.textQueuePosition);
queuePositionView.setText(ticket != null ? "" + ticket.getQueuePosition() : "");
...
}
2. Подключение слушателя к ticket
PropertyChangeListener ticketSubsystemListener = new PropertyChangeListener() {
@Override
public void propertyChange(PropertyChangeEvent event) {
setTicket(ticketSubsystem.getTicket());
}
};
void setTicket(Ticket newTicket) {
if(ticket != null) {
ticket.removePropertyChangeListener(ticketListener);
}
ticket = newTicket;
if(ticket != null) {
ticket.addPropertyChangeListener(ticketListener);
}
updateTicketView();
}
Метод setTicket при замене билета удаляет подписку на события от старого билета и подписывается на события от нового билета. Если вызывать setTicket(null), то TicketActivity отпишется от событий ticket.
3. Подключение слушателя к TicketSubsystem
void setTicketSubsystem(TicketSubsystem newTicketSubsystem) {
if(ticketSubsystem != null) {
ticketSubsystem.removePropertyChangeListener(ticketSubsystemListener);
setTicket(null);
}
ticketSubsystem = newTicketSubsystem;
if(ticketSubsystem != null) {
ticketSubsystem.addPropertyChangeListener(ticketSubsystemListener);
setTicket(ticketSubsystem.getTicket());
}
}
@Override
protected void onPostCreate(@Nullable Bundle savedInstanceState) {
super.onPostCreate(savedInstanceState);
setTicketSubsystem(globalTicketSubsystem);
}
@Override
protected void onStop() {
super.onStop();
setTicketSubsystem(null);
}
Код получается довольно простым и прямолинейным. Но без использования специальных инструментов приходится писать довольно много однотипных операций. Для каждого элемента в иерархии модели приходится заводить поле и создавать отдельный слушатель.
Асинхронная операция «Взять билет»
Код асинхронной операции тоже довольно простой. Основная идея в том, чтобы по завершению асинхронной операции записывать результаты в Модель. А Представление обновится по уведомлению из Модели.
public class GetNewTicket extends AsyncTask {
private int queuePosition;
private String ticketNumber;
@Override
protected Void doInBackground(Void... params) {
SystemClock.sleep(TimeUnit.SECONDS.toMillis(2));
Random random = new Random();
queuePosition = random.nextInt(100);
ticketNumber = "A" + queuePosition;
// TODO записать данные билета в файл, чтобы можно было
// его загрузить после перезапуска приложения.
return null;
}
@Override
protected void onPostExecute(Void aVoid) {
Ticket ticket = new Ticket();
ticket.setNumber(ticketNumber);
ticket.setQueuePosition(queuePosition);
globalTicketSubsystem.setTicket(ticket);
}
}
Здесь ссылка globalTicketSubsystem (она также упоминалась в TicketActivity) зависит от способа компоновки подсистем в вашем приложении.
Восстановление состояния при рестарте
Допустим, что пользователь нажал кнопку «Взять билет», приложение послало запрос на сервер, а в это время случился входящий звонок. Пока пользователь отвечал на звонок, пришел ответ от сервера, но пользователь об этом не знает. Мало того, пользователь нажал «Home» и запустил какое-нибудь приложение, которое сожрало всю память и системе пришлось выгрузить наше приложение.
И вот наше приложение должно отобразить билет полученный до рестарта.
Чтобы обеспечить эту функциональность я буду записывать билет в файл и считывать его после старта приложения.
public class ReadTicketFromFileextends AsyncTask {
...
@Override
protected Void doInBackground(File... files) {
// Считываем из файла в number, positionInQueue, tellerNumber
}
@Override
protected void onPostExecute(Void aVoid) {
Ticket ticket = new Ticket();
ticket.setNumber(number);
ticket.setPositionInQueue(positionInQueue);
ticket.setTellerNumber(tellerNumber);
globalTicketSubsystem.setTicket(ticket);
}
}
Layers
Этот шаблон определяет правила по которым одним классам позволяется зависеть от других классов, так чтобы не возникало чрезмерной запутанности кода. Вообще это семейство шаблонов, а я ориентируюсь на вариант Крейга Лармана из книги «Применение UML и шаблонов проектирования». Вот здесь есть диаграмма.
Основная идея в том, что классам с нижних уровней нельзя зависеть от классов с верхних уровней. Если разместить наши классы по уровням Layers, то получится примерно такая диаграмма: 
Обратите внимание, что все стрелочки, что пересекают границы уровней, направлены строго вниз! TicketActivity создает GetNewTicket — стрелка вниз. GetNewTicket создает Ticket — стрелка вниз. Анонимный ticketListener реализует интерфейс PropertyChangeListener — стрелка вниз. Ticket оповещает слушателей PropertyChangeListener — стрелка вниз. И т. д.
То есть любые зависимости (наследование, использование в качестве типа члена класса, использование в качестве типа параметра или типа возвращаемого значения, использование в качестве типа локальной переменной) допустимы только к классам на том же уровне или на уровнях ниже.
Еще капельку теории, и перейдем к коду.
Назначение уровней
Объекты на уровне Domains отражают бизнес-сущности с которыми работает приложение. Они должны быть независимы от того как устроено наше приложение. Например наличие поля positionInQueue у Ticket обусловлено бизнес требованиями (а не тем, как мы написали наше приложение).
Уровень Application — это граница того, где может располагаться логика приложения (кроме формирования внешнего вида). Если нужно сделать какую-то полезную работу, то код должен оказаться здесь (или ниже).
Если нужно сделать что-то обладающее внешним видом, то это класс для уровня Presentation. А значит этот класс может содержать только код отображения, и никакой логики. За логикой ему придется обращаться к классам с уровня Application.
Принадлежность класса к определенному уровню Layers — условна. Класс находится на заданном уровне до тех пор пока выполняет его требования. То есть в результате правки класс может перейти на другой уровень, или стать непригодным ни для одного уровня.
Как определить на каком уровне должен находиться заданный класс? Я поделюсь скромной эвристикой, а вообще рекомендую изучить доступную теорию. Начинайте хоть здесь.
Эвристика
- Если приложению удалить Уровень Представления, то оно должно быть в состоянии выполнить все свои функции (кроме демонстрации результатов). Наше приложение без Уровня Представления всё ещё будет содержать и код для запроса билета, и сам билет, и доступ к нему.
- Если объект какого-то класса что-то отображает, или реагирует на действия пользователя, то его место на Уровне Представления.
- В случае противоречий — разделяйте класс на несколько.
В репозитории https://github.com/SamSoldatenko/habr3 находится описанное здесь приложение, построенное с применением android.databinding и roboguice. Посмотрите код, а здесь я кратко объясню какой выбор я делал и по каким причинам.
- Зависимость
com.android.support:appcompat-v7добавлена потому что коммерческие разработки опираются на эту библиотеку для поддержки старых версий android. - Зависимость
com.android.support:support-annotationsдобавлена для использования аннотации@UiThread(там много других полезных аннотаций). - Зависимость
org.roboguice:roboguice— библиотека для внедрения зависимостей. Используется чтобы компоновать приложение из частей с помощью аннотаций Inject. Также эта библиотека позволяет внедрять ресурсы, ссылки на виджеты и содержит механизм пересылки сообщений похожий на CDI Events из JSR-299.TicketActivityc помощью аннотации@Injectполучает ссылку наTicketSubsystem.- Асинхронная задача
ReadTicketFromFileс помощью аннотации@InjectResourceполучает имя файла из ресурсов, из которого нужно загрузить билет. TicketSubsystemс помощью@InjectполучаетProviderкоторый использует чтобы создатьReadTicketFromFile.- и др.
- Зависимость
org.roboguice:roboblenderсоздает базу данных всех аннотаций дляorg.roboguice:roboguiceво время компиляции, которая затем используется во время выполнения. - Добавлен файл
app/lint.xmlс настройками для подавления предупреждений от библиотекиroboguice. - Опция
dataBindingвapp/build.gradleразрешает специальный синтаксис в layout файлах похожий наExpression Language(EL) и подключает пакетandroid.databinding, который используется чтобы сделатьTicketиTicketSubsystemактивной моделью. В результате код представлений сильно упрощается и заменяется на декларации в layout файле. Например: - Папка
.ideaвнесена в.gitignoreчтобы использовать любые версииAndroid StudioилиIDEA. Проект отлично импортируется и синхронизируется через файлыbuild.gradle. - Конфигурация gradle wrapper оставлена без изменений (файлы
gradlew,gradlew.batи папкаgradle). Это очень эффективный и удобный механизм. - Настройка
unitTests.returnDefaultValues = trueвapp/build.gradle. Это компромисс между защищенностью от случайных ошибок в модульных тестах и краткостью модульных тестов. Здесь я отдал предпочтение краткости модульных тестов. - Библиотека
org.mockito:mockito-coreиспользуется для создания заглушек в модульных тестах. Кроме того эта библиотека позволяет описать «System Under Test» с помощью аннотаций@Mockи@InjectMocks. При использовании Dependency Injection компоненты «ожидают» что перед их использованием им будут внедрены зависимости. Перед тестами также требуется внедрить все зависимости.Mockitoумеет создавать и внедрять заглушки в тестируемый класс. Это очень упрощает код тестов, особенно если внедряемые поля имеют ограниченную видимость. См. GetNewTicketTest. - Почему
Mockito, а неRobolectric?- Разработчики Android рекомендуют таким способом писать локальные модульные тесты.
- Так получается самый быстрый проход цикла «правка» — «прогон тестов» — «результат» (важно для TDD).
- Robolectric больше подходит для интеграционного тестирования, чем для модульного.
- Библиотека
org.powermock:powermock-module-junitиorg.powermock:powermock-api-mockito. Некоторые вещи не удается заменить заглушками. Например подменить статический метод или подавить вызов метода базового класса. Для этих целейPowerMockподменяет загрузчик классов и правит байт-код. ВTicketActivityTestс помощьюPowerMockподавляется вызовRoboActionBarActivity.onCreate(Bundle)и задается возвращаемое значение из вызова статического методаDataBindingUtil.setContentView - Почему многие поля классов имеют область видимости package local?
- Это прикладной код, а не библиотека. То есть мы контролируем весь код который использует наши классы. Следовательно нет необходимости скрывать поля.
- Видимость полей из тестов упрощает написание модульных тестов.
- Почему тогда все поля не public?
Public член класса — это обязательство взятое на себя классом перед всеми другими классами, существующими и теми, что появятся в будущем. А package local — обязательство только перед теми, кто находится в том же пакете в то же время. Таким образом менять package local поле (переименовать, удалять, добавлять новое) можно, если при этом обновить все классы в пакете. - Почему переменная
LogInterface logне статическая?- Незачем писать код инициализации самому. DI справляется с этой задачей лучше.
- Чтобы легче было подменять логгер заглушкой. Вывод в лог в определенных случаях «специфицирован» и проверяется в тестах.
- Зачем нужны
LogInterfaceиLogImplкоторые всего лишь потомки похожих классов из RoboGuice?
Чтобы прописать конфигурацию Roboguice аннотацией@ImplementedBy(LogImpl.class). - Зачем аннотация
@UiThreadу классовTicketиTicketSubsystem?
Эти классы являются источниками событийonPropertyChangedкоторые используются в UI компонентах чтобы обновить отображение. Необходимо гарантировать что вызовы будут производиться в UI потоке. - Что происходит в конструкторе
TicketSubsystem?
После старта приложения нужно загрузить данные из файла. В Android приложении это событие Application.onCreate. Но в этом примере такой класс не был добавлен. Поэтому момент когда нужно прочитать файл определяется по тому, когда создаетсяTicketSubsystem(создается всего одна копия, т. к. он помечен аннотацией@Singleton). Однако в конструктореTicketSubsystemнельзя создатьReadTicketFromFile, так как ему нужна ссылка на еще не созданныйTicketSubsystem. Поэтому созданиеReadTicketFromFileоткладывается на следующий цикл UI потока. - Чтобы проверить, как работает приложение после перезапуска:
- Нажать «Взять билет»
- Не дожидаясь когда он появится, нажать «Home»
- В консоли выполнить
adb shell am kill ru.soldatenko.habr3 - Запустить приложение
Спасибо
