Асинхронные операции и пересоздание 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.

Поясню на конкретном примере. Пусть нам необходимо построить приложение «Электронный билет в электронную очередь».

  1. Пользователь входит в отделение банка, нажимает в приложении кнопку «Взять билет». Приложение посылает запрос на сервер и получает билет.
  2. Когда подходит очередь в приложении отображается номер окошка в которое нужно обратиться.


Получение билета от сервера я сделаю с помощью асинхронной операции. Также асинхронными операциями будут считывание билета из файла (после перезапуска) и удаление файла.

Построить такое приложение можно из несложных компонентов. Например:

  1. Компонент где будет находиться билет (TicketSubsystem)
  2. TicketActivity где будет отображаться билет и кнопка «Взять билет»
  3. Класс для Билета (номер билета, позиция в очереди, номер окошка)
  4. Класс для Асинхронной операции получения билета


Самое интересное то, как эти компоненты взаимодействуют.

Приложение вовсе не обязано содержать компонент TicketSubsystem. Билет мог бы находиться
в статическом поле Ticket.currentTicket, или в поле в классе-наследнике android.app.Application.
Однако очень важно, чтобы состояние есть/нет билета исходило из объекта способного выполнять роль
Модель из MVC — т. е. генерировать уведомления при появлении (или замене) билета.

Если сделать TicketSubsystem моделью в терминах MVC, то Activity сможет подписаться на события и обновить отображение билета когда тот будет загружен. В этом случае Activity будет выполнять роль View (Представление) в терминах MVC.

Тогда асинхронная операция «Получение нового билета» сможет просто записать полученный билет в TicketSubsystem и больше ни о чем не заботиться.

Модель


Очевидно, что моделью должен являться билет. Однако в приложении билет не может «висеть» в воздухе. Кроме того, билет изначально не существует, он появляется только по завершению асинхронной операции. Из этого следует, что в приложении должно быть еще что-то где будет находиться билет. Пусть это будет TicketSubsystem. Сам билет также должен быть как-то представлен, пусть это будет класс Ticket. Оба этих класса должны быть способны выполнять роль активной модели.

Способы построения активной модели


Активная модель — модель оповещает представление о том, что в ней произошли изменения. wikipedia

В java есть несколько вспомогательных классов для создания активной модели. Вот например:

  1. PropertyChangeSupport и PropertyChangeListener из пакета java.beans
  2. Observable и Observer из пакета java.util
  3. 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 нужно:

  1. Обновлять UI виджеты при изменении данных в ticket
  2. Подключать слушателя к Ticket когда он появится
  3. Подключать слушателя к 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, то получится примерно такая диаграмма:
97728c7159b54d77806475c76ab0a503.png
Обратите внимание, что все стрелочки, что пересекают границы уровней, направлены строго вниз! TicketActivity создает GetNewTicket — стрелка вниз. GetNewTicket создает Ticket — стрелка вниз. Анонимный ticketListener реализует интерфейс PropertyChangeListener — стрелка вниз. Ticket оповещает слушателей PropertyChangeListener — стрелка вниз. И т. д.

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

Еще капельку теории, и перейдем к коду.

Назначение уровней


Объекты на уровне Domains отражают бизнес-сущности с которыми работает приложение. Они должны быть независимы от того как устроено наше приложение. Например наличие поля positionInQueue у Ticket обусловлено бизнес требованиями (а не тем, как мы написали наше приложение).

Уровень Application — это граница того, где может располагаться логика приложения (кроме формирования внешнего вида). Если нужно сделать какую-то полезную работу, то код должен оказаться здесь (или ниже).

Если нужно сделать что-то обладающее внешним видом, то это класс для уровня Presentation. А значит этот класс может содержать только код отображения, и никакой логики. За логикой ему придется обращаться к классам с уровня Application.

Принадлежность класса к определенному уровню Layers — условна. Класс находится на заданном уровне до тех пор пока выполняет его требования. То есть в результате правки класс может перейти на другой уровень, или стать непригодным ни для одного уровня.

Как определить на каком уровне должен находиться заданный класс? Я поделюсь скромной эвристикой, а вообще рекомендую изучить доступную теорию. Начинайте хоть здесь.

Эвристика

  1. Если приложению удалить Уровень Представления, то оно должно быть в состоянии выполнить все свои функции (кроме демонстрации результатов). Наше приложение без Уровня Представления всё ещё будет содержать и код для запроса билета, и сам билет, и доступ к нему.
  2. Если объект какого-то класса что-то отображает, или реагирует на действия пользователя, то его место на Уровне Представления.
  3. В случае противоречий — разделяйте класс на несколько.


В репозитории https://github.com/SamSoldatenko/habr3 находится описанное здесь приложение, построенное с применением android.databinding и roboguice. Посмотрите код, а здесь я кратко объясню какой выбор я делал и по каким причинам.

Краткие объяснения
  1. Зависимость com.android.support:appcompat-v7 добавлена потому что коммерческие разработки опираются на эту библиотеку для поддержки старых версий android.
  2. Зависимость com.android.support:support-annotations добавлена для использования аннотации @UiThread (там много других полезных аннотаций).
  3. Зависимость org.roboguice:roboguice — библиотека для внедрения зависимостей. Используется чтобы компоновать приложение из частей с помощью аннотаций Inject. Также эта библиотека позволяет внедрять ресурсы, ссылки на виджеты и содержит механизм пересылки сообщений похожий на CDI Events из JSR-299.
    • TicketActivity c помощью аннотации @Inject получает ссылку на TicketSubsystem.
    • Асинхронная задача ReadTicketFromFile с помощью аннотации @InjectResource получает имя файла из ресурсов, из которого нужно загрузить билет.
    • TicketSubsystem с помощью @Inject получает Provider который использует чтобы создать ReadTicketFromFile.
    • и др.

  4. Зависимость org.roboguice:roboblender создает базу данных всех аннотаций для org.roboguice:roboguice во время компиляции, которая затем используется во время выполнения.
  5. Добавлен файл app/lint.xml с настройками для подавления предупреждений от библиотеки roboguice.
  6. Опция dataBinding в app/build.gradle разрешает специальный синтаксис в layout файлах похожий на Expression Language (EL) и подключает пакет android.databinding, который используется чтобы сделать Ticket и TicketSubsystem активной моделью. В результате код представлений сильно упрощается и заменяется на декларации в layout файле. Например:
    
    
    

  7. Папка .idea внесена в .gitignore чтобы использовать любые версии Android Studio или IDEA. Проект отлично импортируется и синхронизируется через файлы build.gradle.
  8. Конфигурация gradle wrapper оставлена без изменений (файлы gradlew, gradlew.bat и папка gradle). Это очень эффективный и удобный механизм.
  9. Настройка unitTests.returnDefaultValues = true в app/build.gradle. Это компромисс между защищенностью от случайных ошибок в модульных тестах и краткостью модульных тестов. Здесь я отдал предпочтение краткости модульных тестов.
  10. Библиотека org.mockito:mockito-core используется для создания заглушек в модульных тестах. Кроме того эта библиотека позволяет описать «System Under Test» с помощью аннотаций @Mock и @InjectMocks. При использовании Dependency Injection компоненты «ожидают» что перед их использованием им будут внедрены зависимости. Перед тестами также требуется внедрить все зависимости. Mockito умеет создавать и внедрять заглушки в тестируемый класс. Это очень упрощает код тестов, особенно если внедряемые поля имеют ограниченную видимость. См. GetNewTicketTest.
  11. Почему Mockito, а не Robolectric?
    1. Разработчики Android рекомендуют таким способом писать локальные модульные тесты.
    2. Так получается самый быстрый проход цикла «правка» — «прогон тестов» — «результат» (важно для TDD).
    3. Robolectric больше подходит для интеграционного тестирования, чем для модульного.

  12. Библиотека org.powermock:powermock-module-junit и org.powermock:powermock-api-mockito. Некоторые вещи не удается заменить заглушками. Например подменить статический метод или подавить вызов метода базового класса. Для этих целей PowerMock подменяет загрузчик классов и правит байт-код. В TicketActivityTest с помощью PowerMock подавляется вызов RoboActionBarActivity.onCreate(Bundle) и задается возвращаемое значение из вызова статического метода DataBindingUtil.setContentView
  13. Почему многие поля классов имеют область видимости package local?
    1. Это прикладной код, а не библиотека. То есть мы контролируем весь код который использует наши классы. Следовательно нет необходимости скрывать поля.
    2. Видимость полей из тестов упрощает написание модульных тестов.

  14. Почему тогда все поля не public?
    Public член класса — это обязательство взятое на себя классом перед всеми другими классами, существующими и теми, что появятся в будущем. А package local — обязательство только перед теми, кто находится в том же пакете в то же время. Таким образом менять package local поле (переименовать, удалять, добавлять новое) можно, если при этом обновить все классы в пакете.
  15. Почему переменная LogInterface log не статическая?
    1. Незачем писать код инициализации самому. DI справляется с этой задачей лучше.
    2. Чтобы легче было подменять логгер заглушкой. Вывод в лог в определенных случаях «специфицирован» и проверяется в тестах.

  16. Зачем нужны LogInterface и LogImpl которые всего лишь потомки похожих классов из RoboGuice?
    Чтобы прописать конфигурацию Roboguice аннотацией @ImplementedBy(LogImpl.class).
  17. Зачем аннотация @UiThread у классов Ticket и TicketSubsystem?
    Эти классы являются источниками событий onPropertyChanged которые используются в UI компонентах чтобы обновить отображение. Необходимо гарантировать что вызовы будут производиться в UI потоке.
  18. Что происходит в конструкторе TicketSubsystem?
    После старта приложения нужно загрузить данные из файла. В Android приложении это событие Application.onCreate. Но в этом примере такой класс не был добавлен. Поэтому момент когда нужно прочитать файл определяется по тому, когда создается TicketSubsystem (создается всего одна копия, т. к. он помечен аннотацией @Singleton). Однако в конструкторе TicketSubsystem нельзя создать ReadTicketFromFile, так как ему нужна ссылка на еще не созданный TicketSubsystem. Поэтому создание ReadTicketFromFile откладывается на следующий цикл UI потока.
  19. Чтобы проверить, как работает приложение после перезапуска:
    1. Нажать «Взять билет»
    2. Не дожидаясь когда он появится, нажать «Home»
    3. В консоли выполнить adb shell am kill ru.soldatenko.habr3
    4. Запустить приложение



Спасибо

© Habrahabr.ru