В одной статье на хабре (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.beans
Observable
иObserver
из пакетаjava.util
BaseObservable
и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<Void, Void, Void> {
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<File, Void, Void> {
...
@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.TicketActivity
c помощью аннотации@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 файле. Например:<TextView ... android:text="@{ts.ticket.number}" />
- Папка
.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
- Запустить приложение
Спасибо
Автор: SamSol