Асинхронні операції і нарощування 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
. Обидва ці класу повинні бути здатні виконувати роль активної моделі.

Способи побудови активної моделі
Активна модель — модель сповіщає уявлення про те, що в ній відбулися зміни. вікіпедія

У java є декілька допоміжних класів для створення активної моделі. Ось наприклад:
  1. PropertyChangeSupport
    та
    PropertyChangeListener
    з пакету
    java.beans
  2. Вами
    та
    Observer
    з пакету
    java.util
  3. BaseObservable
    та
    Вами.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<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
— умовна. Клас знаходиться на заданому рівні до тих пір, поки виконує його вимоги. Тобто в результаті правки клас може перейти на інший рівень, або стати непридатним ні для одного рівня.

Як визначити, на якому рівні повинен знаходитися заданий клас? Я поділюся скромною евристикою, а взагалі рекомендую вивчити доступну теорію. Починайте хоч тут.

Евристика
  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 файлі. Наприклад:

    <TextView
    ...
    android:text="@{ts.ticket.number}"
    />
    

  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. Запустити додаток

Дякую

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.