Skip to content

2. Скелет Android-клієнта, що взаємодіє з веб-сервісом / jSON

Тепер ми пропонуємо каркас додатка для Android, що взаємодіє з одним або кількома веб-сервісами / jSON. Це проект [client-android-skel], який можна знайти в папці [architecture] із прикладами:

  

Вивчення цього шаблону додатка дасть нам можливість ще раз розглянути деякі моменти, з якими ми стикалися в попередніх прикладах. Цей додаток слугуватиме основою для всіх майбутніх додатків. Він був створений після численних ітерацій. Його мета — винести в абстрактні класи якомога більше елементів додатків, які ми незабаром будемо створювати, щоб уникнути необхідності постійно писати один і той самий тип коду, що відрізняється лише дрібницями. Його характеристики такі:

  • асинхронна взаємодія з веб-сервером / jSON здійснюється за допомогою бібліотеки RxJava;
  • життєвий цикл фрагмента (update, save, restore) керується його батьківським класом [AbstractFragment], який у певні моменти викликає певні методи своїх дочірніх класів. Таким чином, дочірньому класу не потрібно турбуватися про етапи життєвого циклу, а лише реалізувати певні методи, визначені його батьківським класом;
  • життєвий цикл активності (save / restore) керується абстрактним класом [AbstractActivity], який також вимагає від дочірньої активності реалізувати певні методи;
  • клас [AbstractActivity] здатний керувати додатком із вкладками або без них, із зображенням очікування або без нього, з базовою автентифікацією на веб-сервері / jSON або без неї. Наявність чи відсутність цих елементів визначається конфігурацією;

Цей шаблон використовувався для всіх наступних прикладів. Через їхню різноманітність те, що працювало для одного прикладу, могло не працювати для наступного. Оскільки шаблон використовувався загалом для семи прикладів, відбулося чимало ітерацій. Якби його використали для восьмого прикладу, цілком можливо, що специфіка цього нового прикладу знову спричинила б нові помилки. Проте використання цього шаблону значно спростить написання наступних прикладів. Адже управління життєвим циклом фрагмента (update, save, restore) у поєднанні з поняттям суміжності фрагментів є особливо складним. Тут це повністю приховано в класі [AbstractFragment].

2.1. Архітектура клієнта для Android

Запропонований клієнт для Android базується на такій архітектурі:

  • шар [DAO] реалізує інтерфейс [IDao]. Саме він здійснює зв’язок із веб-сервером / jSON;
  • існує лише одна активність, яка також реалізує інтерфейс [IDao]. Види звертаються до неї для зв’язку з сервером;
  • види реалізовані за допомогою фрагментів;

Проєкт Android відображає цю архітектуру:

  

Ми по черзі розглянемо різні елементи цього проєкту.

2.2. Конфігурація Gradle

 

buildscript {
  repositories {
    mavenCentral()
  }
  dependencies {
    // Починаючи з версії 0.11 плагіна Gradle для Android, потрібно використовувати android-apt версії 1.3 або вище
    classpath 'com.neenbedankt.gradle.plugins:android-apt:1.8'
  }
}

apply plugin: 'com.android.application'
apply plugin: 'android-apt'

android {
  compileSdkVersion 23
  buildToolsVersion "23.0.3"
  defaultConfig {
    minSdkVersion 15
    targetSdkVersion 23
    versionCode 1
    versionName "1.0"
  }

  buildTypes {
    release {
      minifyEnabled false
      proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
    }
  }

  // необхідні параметри пакування для створення APK
  packagingOptions {
    exclude 'META-INF/ASL2.0'
    exclude 'META-INF/NOTICE'
    exclude 'META-INF/LICENSE'
    exclude 'META-INF/notice.txt'
    exclude 'META-INF/license.txt'
  }
}

def AAVersion = '4.0.0'
dependencies {
  apt "org.androidannotations:androidannotations:$AAVersion"
  compile "org.androidannotations:androidannotations-api:$AAVersion"
  apt "org.androidannotations:rest-spring:$AAVersion"
  compile "org.androidannotations:rest-spring-api:$AAVersion"
  compile 'com.android.support:appcompat-v7:23.4.0'
  compile 'com.android.support:design:23.4.0'
  compile 'org.springframework.android:spring-android-rest-template:2.0.0.M3'
  compile 'com.fasterxml.jackson.core:jackson-databind:2.7.4'
  compile 'io.reactivex:rxandroid:1.2.0'
  compile fileTree(include: ['*.jar'], dir: 'libs')
  testCompile 'junit:junit:4.12'
}

repositories {
  maven {
    url 'https://repo.spring.io/libs-milestone'
  }
}
  • усі номери версій можуть змінюватися. Проте можна виходити з поточних номерів, якщо налаштувати Android Studio так, щоб ці версії інструментів Android (рядки 15–16, 47–48) були обов’язково присутні (див. параграф 6.11);

2.3. Маніфест додатка

 

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
          package="client.android">

  <uses-permission android:name="android.permission.INTERNET"/>

  <application
    android:allowBackup="true"
    android:icon="@mipmap/ic_launcher"
    android:label="@string/app_name"
    android:supportsRtl="true"
    android:theme="@style/AppTheme">
    <activity
      android:name=".activity.MainActivity_"
      android:label="@string/app_name"
      android:windowSoftInputMode="stateHidden"
      android:theme="@style/AppTheme.NoActionBar">
      <intent-filter>
        <action android:name="android.intent.action.MAIN"/>

        <category android:name="android.intent.category.LAUNCHER"/>
      </intent-filter>
    </activity>
  </application>

</manifest>
  • рядок 3: змінимо пакет додатка;
  • рядки 10, 15: встановимо значення елемента [app_name] у файлі [res / values / strings.xml]. Наразі воно має такий вигляд:

<?xml version="1.0" encoding="utf-8"?>
<resources>

  <!-- назва додатка -->
  <string name="app_name">[Donnez un nom à votre application]</string>
</resources>

2.4. Організація коду Java

  
  • [architecture] об’єднує основні елементи організації коду;
  • [activity] містить єдину активність додатка;
  • [fragments] об’єднує фрагменти або види додатка;
  • [dao] об’єднує елементи взаємодії з веб-сервером / jSON;

2.5. Елементи активності

 

Image

2.5.1. Вигляд, пов'язаний з діяльністю

Вигляд [activity_main.xml], пов'язаний з операцією, має такий вигляд:


<?xml version="1.0" encoding="utf-8"?>
<android.support.design.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
                                                 xmlns:tools="http://schemas.android.com/tools"
                                                 xmlns:app="http://schemas.android.com/apk/res-auto"
                                                 android:id="@+id/main_content"
                                                 android:layout_width="match_parent"
                                                 android:layout_height="match_parent"
                                                 android:fitsSystemWindows="true"
                                                 tools:context=".activity.MainActivity">

  <android.support.design.widget.AppBarLayout
    android:id="@+id/appbar"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:paddingTop="@dimen/appbar_padding_top"
    android:theme="@style/AppTheme.AppBarOverlay">

    <android.support.v7.widget.Toolbar
      android:id="@+id/toolbar"
      android:layout_width="match_parent"
      android:layout_height="?attr/actionBarSize"
      android:background="?attr/colorPrimary"
      app:popupTheme="@style/AppTheme.PopupOverlay"
      app:layout_scrollFlags="scroll|enterAlways">
    </android.support.v7.widget.Toolbar>
  </android.support.design.widget.AppBarLayout>

  <!-- контейнер фрагментів -->
  <client.android.architecture.core.MyPager
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/container"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:paddingLeft="20dp"
    android:background="@color/floral_white"/>
</android.support.design.widget.CoordinatorLayout>
  • рядок 29: використовується спеціальний контейнер фрагментів;

Активність також має меню [res / menu / menu_main.xml] для свого виду:


<menu xmlns:android="http://schemas.android.com/apk/res/android"
      xmlns:app="http://schemas.android.com/apk/res-auto"
      xmlns:tools="http://schemas.android.com/tools"
      tools:context=".activity.MainActivity">
</menu>

Наразі воно порожнє. Розробник заповнить його за потреби.

2.5.2. Контейнер фрагментів [MyPager]

  

package client.android.architecture;

import android.content.Context;
import android.support.v4.view.ViewPager;
import android.util.AttributeSet;
import android.view.MotionEvent;

public class MyPager extends ViewPager {

  // керує прокручуванням
  private boolean isSwipeEnabled;
  // керує прокруткою
  private boolean isScrollingEnabled;

  // конструктори
  public MyPager(Context context) {
    super(context);
  }

  public MyPager(Context context, AttributeSet attrs) {
    super(context, attrs);
  }

  // методи, які потрібно перевизначити для управління свайпом
  @Override
  public boolean onInterceptTouchEvent(MotionEvent event) {
    // прокрутка дозволена?
    if (isSwipeEnabled) {
      return super.onInterceptTouchEvent(event);
    } else {
      return false;
    }
  }

  @Override
  public boolean onTouchEvent(MotionEvent event) {
    // прокрутка дозволена?
    if (isSwipeEnabled) {
      return super.onTouchEvent(event);
    } else {
      return false;
    }
  }

  // контроль прокрутки
  @Override
  public void setCurrentItem(int position){
    super.setCurrentItem(position,isScrollingEnabled);
  }

  // сетери
  public void setSwipeEnabled(boolean isSwipeEnabled) {
    this.isSwipeEnabled = isSwipeEnabled;
  }

  public void setScrollingEnabled(boolean scrollingEnabled) {
    isScrollingEnabled = scrollingEnabled;
  }
}

Цей клас розширює стандартний клас Android [ViewPager] виключно для управління свайпом (рядок 11) та прокруткою (рядок 13) між видами.

  • рядки 26–43: методи, що блокують свайп, якщо його було вимкнено;
  • рядки 46–49: перевизначення методу [setCurrentItem], який слугує для зміни відображуваного виду. Якщо прокрутка була заблокована, зміна виду відбуватиметься без прокрутки. Слід зауважити, що розробник може обійти цей режим роботи, використовуючи метод [setCurrentItem(int position, boolean smoothScrolling)], який дозволяє йому вказати бажаний режим прокрутки;

2.5.3. Клас [CoreState]

  

Клас [CoreState] є батьківським класом станів різних фрагментів:


package client.android.architecture.custom;

import client.android.architecture.core.MenuItemState;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonTypeInfo;

@JsonIgnoreProperties(ignoreUnknown = true)
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY)
// до зробити: додати сюди підкласи [CoreState]
/*@JsonSubTypes({
  @JsonSubTypes.Type(value = Class1.class),
  @JsonSubTypes.Type(value = Class2.class)}
)*/
public class CoreState {
  // фрагмент відвідано чи ні
  protected boolean hasBeenVisited = false;
  // стан можливого меню фрагмента
  protected MenuItemState[] menuOptionsState;

  // методи getter та setter
...
}
  • рядок 16: кожен фрагмент має у своєму стані булеве значення [hasBeenVisited], яке вказує, чи був він уже відвіданий чи ні. Це необхідно, оскільки іноді під час першого відображення фрагмента потрібно виконати певні дії;
  • рядок 18: проект [client-android-skel] автоматично зберігає та відновлює меню фрагментів, якщо вони їх мають. У масиві MenuItemState[] menuOptionsState зберігається інформація про те, чи видимі всі опції меню;
  • рядки 10–13: як це було зроблено в [Exemple-22], стан активності та її фрагментів буде збережено в сесії, яка, у свою чергу, буде збережена у вигляді рядка jSON. Ми побачимо, що сесія зберігає масив елементів типу [CoreState]. Якщо нічого не робити, то збережеться рядок jSON типу [CoreState]. Однак ми хочемо зберегти стани фрагментів — стани, похідні від [CoreState]. Щоб було згенеровано рядок jSON похідного типу, а не рядок батьківського типу, потрібно оголосити похідні типи, як зазначено в рядках 10–13. Клас [CoreState] є одним із класів архітектури, який розробник повинен модифікувати для кожного нового додатка (рядки 10–13);

2.5.4. Інтерфейс [IMainActivity]

  

Інтерфейс [IMainActivity] визначає, що фрагменти можуть запитувати в активності в наступній архітектурі:

Image


package client.android.architecture.custom;

import client.android.architecture.core.ISession;
import client.android.dao.service.IDao;

public interface IMainActivity extends IDao {

  // доступ до сесії
  ISession getSession();

  // зміна подання
  void navigateToView(int position, ISession.Action action);

  // управління очікуванням
  void beginWaiting();

  void cancelWaiting();

  // константи додатка (для редагування) -------------------------------------

  // режим налагодження
  boolean IS_DEBUG_ENABLED = true;

  // максимальний час очікування відповіді сервера
  int TIMEOUT = 1000;

  // час очікування перед виконанням клієнтського запиту
  int DELAY = 0;

  // базова автентифікація
  boolean IS_BASIC_AUTHENTIFICATION_NEEDED = false;

  // суміжність фрагментів
  int OFF_SCREEN_PAGE_LIMIT = 1;

  // панель вкладок
  boolean ARE_TABS_NEEDED = false;

  // зображення очікування
  boolean IS_WAITING_ICON_NEEDED = false;

  // кількість фрагментів додатка
  int FRAGMENTS_COUNT = 0;

  // додайте сюди свої константи та інші методи
}
  • рядок 6: інтерфейс [IMainActivity] розширює інтерфейс [IDao] з рівня [DAO];
  • рядок 9: саме ця активність надає доступ до сесії у вигляді екземпляра інтерфейсу [ISession];
  • рядок 12: саме за допомогою цієї активності здійснюється зміна виду. Другий параметр — це дія, що викликає цю зміну виду, одне з значень SUBMIT, NAVIGATION, RESTORE;
  • рядки 15–17: це активність, яка керує зображенням очікування;
  • рядок 22: для налагодження програми;
  • рядок 25: щоб не чекати занадто довго, якщо сервер перестав відповідати;
  • рядок 28: під час налагодження слід встановити значення в кілька секунд, щоб мати час скасувати операцію з сервером і побачити, що відбувається;
  • рядок 31: на true, якщо служба jSON вимагає базової автентифікації;
  • рядок 34: суміжність фрагментів;
  • рядок 37: на vrai, якщо додаток має вкладки;
  • рядок 39: vrai, якщо додаток взаємодіє з веб-сервером / jSON і потрібно відображати зображення очікування під час обміну даними;
  • рядок 43: кількість фрагментів, що обробляються додатком;

Інтерфейс [IMainActivity] є другим елементом архітектури, який розробник повинен заповнити (рядок 45).

2.5.5. Інтерфейс [IDao]

Інтерфейс [IMainActivity] розширює наступний інтерфейс [IDao]:

  

package client.android.dao.service;

import rx.Observable;

public interface IDao {
  // URL веб-сервісу
  void setUrlServiceWebJson(String url);

  // користувач
  void setUser(String user, String mdp);

  // тайм-аут клієнта
  void setTimeout(int timeout);

  // базова автентифікація
  void setBasicAuthentification(boolean isBasicAuthentificationNeeded);

  // режим налагодження
  void setDebugMode(boolean isDebugEnabled);

  // Час очікування клієнта перед запитом у мілісекундах
  void setDelay(int delay);

  // завдання: оголосіть свій інтерфейс тут
}
  • рядок 24: розробник заповнить інтерфейс тут;

2.5.6. Сесія

  

Клас [Session] інкапсулює елементи, спільні для активності та фрагментів. Він реалізує наступний інтерфейс [ISession]:


package client.android.architecture.core;

import client.android.architecture.custom.CoreState;

public interface ISession {

  // номер останнього відображеного виду
  int getPreviousView();

  void setPreviousView(int numView);

  // останній стан сторінки
  CoreState getCoreState(int numView);

  void setCoreState(int numView, CoreState coreState);

  // поточна дія
  enum Action {
    SUBMIT, NAVIGATION, RESTORE, NONE
  }

  Action getAction();

  void setAction(Action action);

  // стани всіх переглядів —
  // не використовується кодом, але необхідний для серіалізації/десеріалізації jSON
  CoreState[] getCoreStates();

  void setCoreStates(CoreState[] coreStates);

  // номер останньої вибраної вкладки
  int getPreviousTab();

  void setPreviousTab(int position);

  // навігація при виборі вкладки
  boolean isNavigationOnTabSelectionNeeded();

  void setNavigationOnTabSelectionNeeded(boolean navigationOnTabSelection);
}

Ми вводимо інтерфейс [ISession], щоб забезпечити наявність певних методів у сесії:

  • рядки 7–10: номер останнього відображеного виду (фрагмента);
  • рядки 12–15: стан конкретного виду;
  • рядки 17–24: ми вводимо поняття поточної дії. Їх чотири (рядок 17):
    • RESTORE: триває збереження/відновлення. Зміни виду немає;
    • NAVIGATION: триває навігація. Під навігацією тут розуміється зміна перегляду, при якій новий перегляд можна відновити на основі його останнього стану, збереженого в сесії;
    • SUBMIT: тип [SUBMIT] присвоюється дії, що виконується, коли відбувається зміна виду, і новий вид залежить від загального стану активності, а не лише від свого власного стану. Іноді важко провести розмежування між NAVIGATION та SUBMIT. У такому випадку слід брати найзагальніший випадок — SUBMIT;
    • NONE: значення дії, коли вона ще не отримала свого першого значення;
  • рядки 26–30: стани активності та фрагментів будуть зберігатися в масиві типу CoreState[]. Щоб цей масив правильно оброблявся під час серіалізації/десеріалізації jSON, він повинен мати методи getter та setter;
  • рядки 32–35: номер останньої вибраної вкладки. Використовується під час циклу збереження/відновлення для повторного вибору вкладки, яка була вибрана до повороту пристрою;
  • рядки 37–40: управління булевим значенням, яке вказує, чи має вибір вкладки супроводжуватися зміною фрагмента;

Інтерфейс [ISession] реалізовано за допомогою наступного абстрактного класу [AbstractSession]:


package client.android.architecture.core;

import client.android.architecture.custom.CoreState;
import client.android.architecture.custom.IMainActivity;
import com.fasterxml.jackson.annotation.JsonIgnore;

public class AbstractSession implements ISession {
  // номер попереднього перегляду
  private int preViousView;

  // статус переглядів
  private CoreState[] coreStates = new CoreState[0];

  // поточна дія
  private Action action = Action.NONE;

  // попередньо вибрана вкладка
  private int previousTab;

  // навігація за вибором вкладки
  @JsonIgnore
  private boolean navigationOnTabSelectionNeeded = true;

  // конструктор
  public AbstractSession() {
    // ініціалізація масиву станів фрагментів
    coreStates = new CoreState[IMainActivity.FRAGMENTS_COUNT];
    for (int i = 0; i < coreStates.length; i++) {
      coreStates[i] = new CoreState();
    }
  }


  // інтерфейс ISession ---------------------------------------------------------
  @Override
  public int getPreviousView() {
    return preViousView;
  }

  @Override
  public void setPreviousView(int numView) {
    this.preViousView = numView;
  }

  @Override
  public CoreState getCoreState(int numView) {
    return coreStates[numView];
  }

  @Override
  public void setCoreState(int numView, CoreState coreState) {
    coreStates[numView] = coreState;
  }

  @Override
  public Action getAction() {
    return action;
  }

  @Override
  public void setAction(Action action) {
    this.action = action;
  }

  @Override
  public CoreState[] getCoreStates() {
    return coreStates;
  }

  @Override
  public void setCoreStates(CoreState[] coreStates) {
    this.coreStates = coreStates;
  }

  @Override
  public int getPreviousTab() {
    return previousTab;
  }

  @Override
  public void setPreviousTab(int position) {
    this.previousTab = position;
  }

  @Override
  public boolean isNavigationOnTabSelectionNeeded() {
    return navigationOnTabSelectionNeeded;
  }

  @Override
  public void setNavigationOnTabSelectionNeeded(boolean navigationOnTabSelectionNeeded) {
    this.navigationOnTabSelectionNeeded = navigationOnTabSelectionNeeded;
  }
}
  • рядок 9: номер вікна, яке відображалося перед тим, що відображається зараз. Ця інформація корисна, коли до вікна можна перейти з кількох місць. Це типово для навігації за допомогою вкладок. Тоді вікно, що відображається, може дізнатися, яке вікно було попереднім;
  • рядок 12: масив станів усіх фрагментів, що відображаються активною діяльністю;
  • рядок 18: номер вкладки, яка була обрана раніше. Відіграє роль, аналогічну до номера попереднього виду з рядка 9. Ця інформація корисна, коли відбувається поворот пристрою і потрібно повернутися до вкладки, яка була обрана до повороту;
  • рядок 22: логічне значення, що вказує, чи має вибір вкладки супроводжуватися зміною відображуваного фрагмента. Слід знати, що проект [client-android-skel] здійснює окреме управління вкладками та фрагментами, щоб його можна було використовувати у випадках, коли кількість вкладок менша за кількість фрагментів. Існує два види вибору:
    • вибір користувачем при натисканні на вкладку. У цьому випадку, як правило, відображуваний фрагмент має змінитися;
    • програмний вибір за допомогою методу [Tablayout.Tab.select()]. У цьому випадку зміна відображуваного фрагмента не завжди є бажаною. Ось два приклади:
      • під час повороту пристрою активність створюється заново, так само як і вкладки. Однак під час створення першої вкладки вона автоматично піддається програмній операції [select]. Тоді небажано змінювати фрагмент, що відображається, оскільки ми перебуваємо на етапі відтворення активності, де фрагмент, який зрештою відобразиться, не обов’язково буде тим, що пов’язаний із першою вкладкою;
      • оскільки управління вкладками відокремлене від управління фрагментами, може виникнути потреба оновлювати вкладки (видалення, додавання) без втручання у пов’язані з ними фрагменти. Однак деякі з цих операцій можуть знову ж таки запустити неявну програмну операцію [select] на одній із вкладок. Цей вибір не обов’язково має призвести до переходу до пов’язаного фрагмента;
  • рядок 21: поле [navigationOnTabSelectionNeeded] не призначене для збереження під час операцій збереження активності та її фрагментів. Анотація [@JsonIgnore] забезпечує ігнорування цього поля під час серіалізації/десеріалізації jSON;
  • рядки 25–31: конструктор ініціалізує масив станів фрагментів [FRAGMENTS_COUNT] додатка. Елементи цього масиву ініціалізуються за допомогою поля [hasBeeenVisited=false]. Ця інформація використовується для визначення, чи маємо ми справу з першим відвідуванням фрагмента;

Клас [Session] має такий вигляд:


package client.android.architecture.custom;

import client.android.architecture.core.AbstractSession;

public class Session extends AbstractSession {
  // дані, що мають бути спільними між самими фрагментами та між фрагментами й активністю
  // елементи, які не можна серіалізувати в jSON, повинні мати анотацію @JsonIgnore
  // не забувайте про геттери та сеттери, необхідні для серіалізації/десеріалізації у форматі jSON
}
  • рядок 5: клас [Session] розширює клас [AbstractSession], який ми щойно розглянули. Розробник розмістить у ньому елементи, які мають бути спільними між самими фрагментами та між фрагментами й активністю. Зауважимо, що клас [Session] більше не анотується анотацією AA [@EBean]. Він став звичайним класом;

2.5.7. Абстрактний клас [AbstractActivity]

  

2.5.7.1. Squelette

Клас [AbstractActivity] — це клас, що налічує понад 300 рядків. Ми розглянемо його поетапно. Його структура така:


package client.android.architecture;

import android.os.Bundle;
import android.support.design.widget.AppBarLayout;
import android.support.design.widget.TabLayout;
import android.support.v4.app.FragmentManager;
import android.support.v4.app.FragmentPagerAdapter;
import android.support.v7.app.AppCompatActivity;
import android.support.v7.widget.Toolbar;
import android.util.Log;
import android.view.View;
import android.widget.ProgressBar;
import client.android.R;
import client.android.dao.service.IDao;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;

import java.io.IOException;

public abstract class AbstractActivity extends AppCompatActivity implements IMainActivity {
  // шар [DAO]
  private IDao dao;
  // сесія
  protected Session session;

  // контейнер фрагментів
  protected MyPager mViewPager;
  // панель інструментів
  private Toolbar toolbar;
  // зображення очікування
  private ProgressBar loadingPanel;
  // панель вкладок
  protected TabLayout tabLayout;

  // менеджер фрагментів або розділів
  private FragmentPagerAdapter mSectionsPagerAdapter;
  // назва класу
  protected String className;
  // маппер jSON
  private ObjectMapper jsonMapper;

  // конструктор
  public AbstractActivity() {
    // ім'я класу
    className = getClass().getSimpleName();
    // журнал
    if (IS_DEBUG_ENABLED) {
      Log.d(className, "constructeur");
    }
    // jsonMapper
    jsonMapper = new ObjectMapper();
  }

  // реалізація IMainActivity --------------------------------------------------------------------
  ...

  // життєвий цикл — збереження/відновлення активності ------------------------------------
  ...

  // управління зображенням очікування ---------------------------------
  ...

  // інтерфейс IDao -----------------------------------------------------
  ...

  // менеджер фрагментів --------------------------------
  ...

  // дочірні класи
  protected abstract void onCreateActivity();

  protected abstract IDao getDao();

  protected abstract AbstractFragment[] getFragments();

  protected abstract CharSequence getFragmentTitle(int position);

  protected abstract void navigateOnTabSelected(int position);

  protected abstract int getFirstView();

}

Клас [AbstractActivity]:

  • реалізує інтерфейс [IMainActivity] (рядки 21, 55);
  • забезпечує збереження та відновлення активності та її фрагментів під час повороту пристрою (рядок 58);
  • управляє зображенням очікування під час обміну даними з веб-сервером / jSON (рядок 61);
  • реалізує інтерфейс IDao рівня [DAO] (рядок 64);
  • реалізує менеджер фрагментів (рядок 67);
  • вимагає від своїх дочірніх класів наявності шести методів (рядки 71–81);

2.5.7.2. Реалізація інтерфейсу [IMainActivity]

Реалізація інтерфейсу [IMainActivity] (див. розділ 2.5.4) виглядає наступним чином:


  // реалізація IMainActivity --------------------------------------------------------------------
  @Override
  public Session getSession() {
    return session;
  }

  @Override
  public void navigateToView(int position, ISession.Action action) {
    if (IS_DEBUG_ENABLED) {
      Log.d(className, String.format("navigation vers vue %s sur action %s", position, action));
    }
    // відображення нового фрагмента
    mViewPager.setCurrentItem(position);
    // відзначається поточна дія під час цієї зміни перегляду
    session.setAction(action);
}

2.5.7.3. Збереження стану активності та її фрагментів

Стан активності та її фрагментів повністю зберігається у сесії. Отже, потрібно зберегти саме її. Тут ми повторюємо те, що було зроблено в проєкті [Exemple-22] (див. параграф 1.23):


  // управління збереженням/відновленням активності ------------------------------------
  @Override
  protected void onSaveInstanceState(Bundle outState) {
    // батьківський елемент
    super.onSaveInstanceState(outState);
    // збереження сеансу у вигляді рядка jSON
    try {
      outState.putString("session", jsonMapper.writeValueAsString(session));
    } catch (JsonProcessingException e) {
      e.printStackTrace();
    }
    // журнал
    if (IS_DEBUG_ENABLED) {
      try {
        Log.d(className, String.format("onSaveInstanceState session=%s", jsonMapper.writeValueAsString(session)));
      } catch (JsonProcessingException e) {
        e.printStackTrace();
      }
    }
}

2.5.7.4. Відновлення стану активності та її фрагментів

Необхідно відновити сесію. Ми діємо так, як було показано в [Exemple-22]:


@Override
  protected void onCreate(Bundle savedInstanceState) {
    // батьківський
    super.onCreate(savedInstanceState);
    // журнал
    if (IS_DEBUG_ENABLED) {
      Log.d(className, "onCreate");
    }
    // щось для відновлення?
    if (savedInstanceState != null) {
      // відновлення сеансу
      try {
        session = jsonMapper.readValue(savedInstanceState.getString("session"), new TypeReference<Session>() {
        });
      } catch (IOException e) {
        e.printStackTrace();
      }
      // журнал
      if (IS_DEBUG_ENABLED) {
        try {
          Log.d(className, String.format("onCreate session=%s", jsonMapper.writeValueAsString(session)));
        } catch (JsonProcessingException e) {
          e.printStackTrace();
        }
      }
    } else {
      // сесія
      session = new Session();
    }
...
  • рядки 10–26: якщо параметр [Bundle savedInstanceState] у рядку 2 не дорівнює null, то сесія відновлюється (рядки 12–17);
  • рядки 26–29: випадок, коли параметр [Bundle savedInstanceState] у рядку 2 дорівнює null, відповідає першому запуску активності. У цьому випадку створюється порожня сесія;

2.5.7.5. Ініціалізація шару [DAO]


@Override
  protected void onCreate(Bundle savedInstanceState) {
    // батьківський елемент
    super.onCreate(savedInstanceState);
    // журнал
    if (IS_DEBUG_ENABLED) {
      Log.d(className, "onCreate");
    }
    ...
    // шар [DAO]
    dao = getDao();
    if (dao != null) {
      // конфігурація шару [DAO]
      setDebugMode(IS_DEBUG_ENABLED);
      setTimeout(TIMEOUT);
      setDelay(DELAY);
      setBasicAuthentification(IS_BASIC_AUTHENTIFICATION_NEEDED);
    }
...
  // дочірні класи
  protected abstract IDao getDao();
....
}
  • рядок 11: до дочірньої активності (рядок 21) надсилається запит на посилання на шар [DAO];
  • рядки 14–17: якщо шар [DAO] існує, його налаштовують на основі інформації, що міститься в інтерфейсі [IMainActivity];

2.5.7.6. Ініціалізація подання, пов’язаного з діяльністю

Вигляд, пов'язаний з активністю, було представлено в розділі 2.5.1:


<?xml version="1.0" encoding="utf-8"?>
<android.support.design.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
                                                 xmlns:tools="http://schemas.android.com/tools"
                                                 xmlns:app="http://schemas.android.com/apk/res-auto"
                                                 android:id="@+id/main_content"
                                                 android:layout_width="match_parent"
                                                 android:layout_height="match_parent"
                                                 android:fitsSystemWindows="true"
                                                 tools:context=".activity.MainActivity">

  <android.support.design.widget.AppBarLayout
    android:id="@+id/appbar"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:paddingTop="@dimen/appbar_padding_top"
    android:theme="@style/AppTheme.AppBarOverlay">

    <android.support.v7.widget.Toolbar
      android:id="@+id/toolbar"
      android:layout_width="match_parent"
      android:layout_height="?attr/actionBarSize"
      android:background="?attr/colorPrimary"
      app:popupTheme="@style/AppTheme.PopupOverlay"
      app:layout_scrollFlags="scroll|enterAlways">
    </android.support.v7.widget.Toolbar>
  </android.support.design.widget.AppBarLayout>

  <!-- контейнер фрагментів -->
  <client.android.architecture.core.MyPager
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/container"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:paddingLeft="20dp"
    android:background="@color/floral_white"/>
</android.support.design.widget.CoordinatorLayout>

Цей вигляд ініціалізується за допомогою такого коду:


  @Override
  protected void onCreate(Bundle savedInstanceState) {
    // батьківський елемент
    super.onCreate(savedInstanceState);
    // журнал
    if (IS_DEBUG_ENABLED) {
      Log.d(className, "onCreate");
    }
  ...
    // пов'язаний вигляд
    setContentView(R.layout.activity_main);
    // компоненти подання ---------------------
    // панель інструментів
    Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
    setSupportActionBar(toolbar);
    // зображення очікування?
    if (IS_WAITING_ICON_NEEDED) {
      // додаємо зображення очікування
      if (IS_DEBUG_ENABLED) {
        Log.d(className, "adding loadingPanel");
      }
      // створення ProgressBar
      loadingPanel = new ProgressBar(this);
      loadingPanel.setVisibility(View.INVISIBLE);
      // додавання ProgressBar до панелі інструментів
      toolbar.addView(loadingPanel);
    }
...
  • рядок 11: представлення XML [activity_main] пов'язане з активністю;
  • рядки 14–15: панель інструментів інтегрована та підтримується;
  • рядки 17–27: можливе додавання зображення очікування: якщо логічна змінна [IS_WAITING_ICON_NEEDED] має значення «true» в інтерфейсі [IMainActivity];
  • рядок 23: створення зображення очікування типу [ProgressBar], на яке посилається поле [loadingPanel];
  • рядок 24: спочатку це зображення приховане;
  • рядок 26: його додано до панелі інструментів;

2.5.7.7. Управління вкладками

Інтерфейс [IMainActivity] може вимагати наявності панелі вкладок. Вона додається та управляється наступним чином:


// панель вкладок
  protected TabLayout tabLayout;
...

    // панель вкладок?
    if (ARE_TABS_NEEDED) {
      // додається панель вкладок
      if (IS_DEBUG_ENABLED) {
        Log.d(className, "adding tablayout");
      }
      // відсутність навігації за вибором до відображення фрагмента
      session.setNavigationOnTabSelectionNeeded(false);
      // створення панелі вкладок
      tabLayout = new CustomTabLayout(this);
      tabLayout.setTabTextColors(ContextCompat.getColorStateList(this, R.color.tab_text));
      // додавання панелі вкладок до панелі додатків
      AppBarLayout appBarLayout = (AppBarLayout) findViewById(R.id.appbar);
      appBarLayout.addView(tabLayout);
      // менеджер подій панелі вкладок
      tabLayout.setOnTabSelectedListener(new TabLayout.OnTabSelectedListener() {
        @Override
        public void onTabSelected(TabLayout.Tab tab) {
          // вкладка була вибрана
          if (IS_DEBUG_ENABLED) {
            Log.d(className, String.format("onTabSelected n° %s, action=%s, tabCount=%s isNavigationOnTabSelectionNeeded=%s",
              tab.getPosition(), session.getAction(), tabLayout.getTabCount(), session.isNavigationOnTabSelectionNeeded()));
          }
          if (session.isNavigationOnTabSelectionNeeded()) {
            // позиція вкладки
            int position = tab.getPosition();
            // пам'ять
            session.setPreviousTab(position);
            // чи відображається пов'язаний фрагмент?
            navigateOnTabSelected(position);
          }
        }

        @Override
        public void onTabUnselected(TabLayout.Tab tab) {

        }

        @Override
        public void onTabReselected(TabLayout.Tab tab) {

        }
      });
    }

...
  // дочірні класи
  protected abstract void navigateOnTabSelected(int position);
...
  • рядки 12–48: додавання та управління панеллю вкладок;
  • рядок 6: панель вкладок додається, якщо константа [ARE_TABS_NEEDED] має значення vrai в інтерфейсі [IMainActivity];
  • рядок 12: під час створення панелі вкладок можуть відбуватися неявні операції [Tablayout.Tab.select] (вони не ініціюються користувачем). Булеве значення [session.navigationOnTabSelectionNeeded] встановлюється на faux, щоб уникнути будь-якої навігації під час цих помилкових виборів. Розробник повинен вибрати фрагмент для відображення за допомогою методу [navigateToView]. Логічне значення [session.navigationOnTabSelectionNeeded] буде скинуто до vrai, коли цей фрагмент буде відображено (див. клас AbstractFragment);
  • рядок 14: створення панелі вкладок, на яку посилається поле [tabLayout]. Ми використовуємо налаштовану панель вкладок [CustomTabLayout], до якої ми ще повернемося;
  • рядок 15: ми задаємо кольори заголовків вкладок. Вони містяться у наступному файлі [res / color / tab_txt.xml]:

<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
  <item android:state_selected="true" android:color="#FFFF00" />
  <item android:state_selected="false" android:color="#FFFFFF" />
</selector>
    • рядок (c): колір заголовка вкладки, коли вона вибрана;
    • рядок (d): колір заголовка вкладки, коли вона не вибрана;

Цей файл, звісно, можна редагувати. Шістнадцяткові коди кольорів можна знайти, наприклад, тут.

  • рядки 17–18: додавання цієї панелі вкладок до панелі додатків, що міститься у вікні XML [activity_main];
  • рядки 20–47: обробник подій панелі вкладок;
  • рядки 22–36: обробляється лише подія [onTabSelected]. Вона відповідає кліку на вкладці [Tab tab], переданій як параметр методу, або програмній операції [TabLayout.Tab.select];
  • рядок 30: позиція вибраної вкладки;
  • рядок 32: ця позиція зберігається протягом сеансу;
  • рядок 34: тепер потрібно відобразити фрагмент, пов’язаний із цією вкладкою. Лише дочірній клас (рядок 52) може здійснити це пов’язування. Зазначимо, що панель вкладок не пов’язується з контейнером фрагментів [mViewPager], як це робилося в деяких розглянутих прикладах. Тут управління панеллю вкладок повністю відокремлено від управління фрагментами. Саме тому, коли натискають на вкладку, необхідно вказати, який вигляд потрібно відобразити;
  • рядок 28: розрізняємо вибір вкладки з навігацією та без неї. Як правило, коли користувач клацає на вкладку, потрібна навігація, а під час програмного вибору — ні. Саме розробник розрізняє ці два випадки за допомогою елемента [session.navigationOnTabSelectionNeeded]. Якщо навігація не здійснюється, номер останньої вибраної вкладки не зберігається у сесії. Це має зробити розробник;

2.5.7.8. Менеджер вкладок [CustomTabLayout]

  

Ми використовуємо власний менеджер вкладок, щоб мати змогу відображати заголовки вкладок різними шрифтами. Клас [CustomTabLayout] виглядає так:


package client.android.architecture.custom;

import android.content.Context;
import android.graphics.Typeface;
import android.support.design.widget.TabLayout;
import android.util.AttributeSet;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;

public class CustomTabLayout extends TabLayout {
  private Typeface mTypeface;

  public CustomTabLayout(Context context) {
    super(context);
    init();
  }

  public CustomTabLayout(Context context, AttributeSet attrs) {
    super(context, attrs);
    init();
  }

  public CustomTabLayout(Context context, AttributeSet attrs, int defStyleAttr) {
    super(context, attrs, defStyleAttr);
    init();
  }

  private void init() {
    mTypeface = Typeface.createFromAsset(getContext().getAssets(), "fonts/Roboto-Bold.ttf");
  }

  @Override
  public void addTab(Tab tab) {
    super.addTab(tab);

    ViewGroup mainView = (ViewGroup) getChildAt(0);
    ViewGroup tabView = (ViewGroup) mainView.getChildAt(tab.getPosition());

    int tabChildCount = tabView.getChildCount();
    for (int i = 0; i < tabChildCount; i++) {
      View tabViewChild = tabView.getChildAt(i);
      if (tabViewChild instanceof TextView) {
        ((TextView) tabViewChild).setTypeface(mTypeface, Typeface.NORMAL);
      }
    }
  }

}
  • налаштування шрифту заголовків вкладок здійснюється у рядках 30 та 44;

Файл [fonts] має такий вигляд:

  

Джерела:

  • код класу [CustomTabLayout] було знайдено у URL та [http://stackoverflow.com/questions/31067265/change-the-font-of-tab-text-in-android-design-support-tablayout];
  • шрифти були знайдені в URL та [https://www.fontsquirrel.com/fonts/roboto];

2.5.7.9. Останні ініціалізації


  @Override
  protected void onCreate(Bundle savedInstanceState) {
    // батьківський елемент
    super.onCreate(savedInstanceState);
    // журнал
    if (IS_DEBUG_ENABLED) {
      Log.d(className, "onCreate");
    }
  ...
    // інстанціювання менеджера фрагментів
    mSectionsPagerAdapter = new SectionsPagerAdapter(getSupportFragmentManager());
    // контейнер фрагментів пов'язаний з менеджером фрагментів
    // тобто фрагмент № i контейнера фрагментів є фрагментом № i, наданим менеджером фрагментів
    mViewPager = (MyPager) findViewById(R.id.container);
    mViewPager.setAdapter(mSectionsPagerAdapter);
    // блокується перехід між фрагментами
    mViewPager.setSwipeEnabled(false);
    // суміжність фрагментів
    mViewPager.setOffscreenPageLimit(OFF_SCREEN_PAGE_LIMIT);
    // відображається перший вигляд
    if (session.getAction() == ISession.Action.NONE) {
      navigateToView(getFirstView(), ISession.Action.NONE);
    }
    // передача управління дочірній активності
    onCreateActivity();
  }
...
  // дочірні класи
  protected abstract void onCreateActivity();
  protected abstract int getFirstView();
...
  • рядки 10–19: тут міститься код, який часто зустрічається в розглянутих прикладах;
  • рядки 21–23: відображення найпершого перегляду. Безсумнівно, існує кілька способів розрізнити цей випадок. Тут ми скористалися тим, що для найпершого перегляду значення дії, яка спричиняє зміну перегляду, дорівнює NONE;
  • рядок 22: ми не робимо припущень щодо першого фрагмента, який потрібно відобразити. У наших прикладах це часто був фрагмент № 0, але не завжди (див. Приклад-22). Тому ми попросимо дочірню активність (рядок 30) повідомити нам, який саме це перший вигляд;
  • рядок 25: тут ми винесли у загальний вираз усе, що можна було. Тепер дочірній клас має виконати власні ініціалізації (рядок 29);

2.5.7.10. Управління зображенням очікування

У класі [AbstractActivity] зображення очікування керується за допомогою двох таких методів:


  // управління зображенням очікування ---------------------------------
  public void cancelWaiting() {
    if (loadingPanel != null) {
      loadingPanel.setVisibility(View.INVISIBLE);
    }
  }

  public void beginWaiting() {
    if (loadingPanel != null) {
      loadingPanel.setVisibility(View.VISIBLE);
    }
}

2.5.7.11. Реалізація інтерфейсу [IDao]

У класі [AbstractActivity] інтерфейс [IDao] (див. розділ 2.5.5) реалізовано таким чином:


public abstract class AbstractActivity extends AppCompatActivity implements IMainActivity {
  // шар [DAO]
  private IDao dao;
...
  // інтерфейс IDao -----------------------------------------------------
  @Override
  public void setUrlServiceWebJson(String url) {
    dao.setUrlServiceWebJson(url);
  }

  @Override
  public void setUser(String user, String mdp) {
    dao.setUser(user, mdp);
  }

  @Override
  public void setTimeout(int timeout) {
    dao.setTimeout(timeout);
  }

  @Override
  public void setBasicAuthentification(boolean isBasicAuthentificationNeeded) {
    dao.setBasicAuthentification(isBasicAuthentificationNeeded);
  }

  @Override
  public void setDebugMode(boolean isDebugEnabled) {
    dao.setDebugMode(isDebugEnabled);
  }

  @Override
  public void setDelay(int delay) {
    dao.setDelay(delay);
}
  • рядок 3: нагадаємо, що значення цього поля було надано дочірньою активністю у методі [onCreate];

2.5.7.12. Реалізація менеджера фрагментів

У класі [AbstractActivity] обробник фрагментів реалізовано наступним чином:


...
  // менеджер фрагментів --------------------------------
  public class SectionsPagerAdapter extends FragmentPagerAdapter {

    private AbstractFragment[] fragments;

    // конструктор
    public SectionsPagerAdapter(FragmentManager fm) {
      super(fm);
      // фрагменти дочірнього класу
      fragments = getFragments();
    }

    // повинен відтворити фрагмент № позиції
    @Override
    public AbstractFragment getItem(int position) {
      // виводиться фрагмент
      return fragments[position];
    }

    // показує кількість фрагментів, які потрібно обробити
    @Override
    public int getCount() {
      return fragments.length;
    }

    // повертає заголовок фрагмента № позиції
    @Override
    public CharSequence getPageTitle(int position) {
      return getFragmentTitle(position);
    }
  }

  // дочірні класи
  protected abstract AbstractFragment[] getFragments();

  protected abstract CharSequence getFragmentTitle(int position);
...
}
  • рядок 5: масив фрагментів, пов’язаних із активністю. Усі фрагменти будуть похідними від класу [AbstractFragment];
  • рядки 8–12: це конструктор, який ініціалізує масив фрагментів. Він запитує їх у дочірньому класі активності (рядок 35);
  • рядки 28–31: заголовки фрагментів можна використовувати в додатку, де кількість вкладок дорівнює кількості фрагментів. У цьому випадку вкладці можна присвоїти заголовок фрагмента. Тут ці заголовки запитуються у дочірнього класу (рядок 37);

2.5.7.13. Метод [onResume]

Метод [onResume] виконується незадовго до того, як вікно, пов’язане з цією дією, стане видимим. Тут його використовують для вибору вкладки після збереження/відновлення:


  @Override
  public void onResume() {
    // батьківський
    super.onResume();
    if (IS_DEBUG_ENABLED) {
      Log.d(className, "onResume");
    }
    // якщо відбувається відновлення, то потрібно відновити останню вибрану вкладку
    if (ARE_TABS_NEEDED && session.getAction() == ISession.Action.RESTORE) {
      tabLayout.getTabAt(session.getPreviousTab()).select();
    }
}
  • рядок 10: вибір вкладки, яка була вибрана до процесу збереження/відновлення. Слід пам’ятати, що в методі [onCreate], який у життєвому циклі активності виконується перед методом [onResume], навігація при виборі вкладки була заблокована. Отже, тут відбувається вибір вкладки, але не відбувається зміна фрагмента;

2.5.7.14. Résumé

Абстрактний клас [AbstractActivity] буде батьківським класом єдиної активності додатка.

Дочірня активність повинна реалізувати шість наступних методів:


  // дочірні класи
  protected abstract void onCreateActivity();

  protected abstract IDao getDao();

  protected abstract AbstractFragment[] getFragments();

  protected abstract CharSequence getFragmentTitle(int position);

  protected abstract void navigateOnTabSelected(int position);

protected abstract int getFirstView();

Крім того, дочірня активність має доступ до таких захищених елементів свого батьківського класу:


  // сесія
  protected ISession session;
  // контейнер фрагментів
  protected MyPager mViewPager;
  // панель вкладок
  protected CustomTabLayout tabLayout;
  // ім'я класу
protected String className;

2.5.8. Активність [MainActivity]

  

Клас [MainActivity] може мати іншу назву. Єдина вимога до нього — реалізувати інтерфейс [IMainActivity]. Базовий клас надається у такому вигляді:


package client.android.activity;

import android.util.Log;
import client.android.R;
import client.android.architecture.AbstractActivity;
import client.android.architecture.AbstractFragment;
import client.android.architecture.Session;
import client.android.dao.service.Dao;
import client.android.dao.service.IDao;
import org.androidannotations.annotations.Bean;
import org.androidannotations.annotations.EActivity;
import org.androidannotations.annotations.OptionsMenu;

@EActivity
@OptionsMenu(R.menu.menu_main)
public class MainActivity extends AbstractActivity {

  // шар [DAO]
  @Bean(Dao.class)
  protected IDao dao;
  // сесія
  private Session session;

  // методи батьківського класу -----------------------
  @Override
  protected void onCreateActivity() {
    // журнал
    if (IS_DEBUG_ENABLED) {
      Log.d(className, "onCreateActivity");
    }
    // сесія
    this.session = (Session) super.session;
    // до зробити: продовжуємо ініціалізацію, розпочату батьківським класом
  }

  @Override
  protected IDao getDao() {
    return dao;
  }

  @Override
  protected AbstractFragment[] getFragments() {
    // до зробити: визначити фрагменти тут
    return new AbstractFragment[0];
  }


  @Override
  protected CharSequence getFragmentTitle(int position) {
    // до виконання: визначити тут заголовки фрагментів
    return null;
  }

  @Override
  protected void navigateOnTabSelected(int position) {
    // до виконання: навігація за вкладками — визначити, який вигляд відображати
  }

  @Override
  protected int getFirstView() {
    // до виконання: навігація за вкладками — визначити перший вигляд, який потрібно відобразити
    return 0;
  }
}
  • рядок 14: щоб нотація AA [@Bean] у рядку 19 була зрозумілою, активність повинна мати нотацію AA [@EActivity];
  • рядок 15: дія пов’язана з меню XML [menu_main]. Наразі це меню порожнє. Розробник повинен буде заповнити його, якщо це буде потрібно;
  • рядок 16: клас успадковує клас [AbstractActivity];
  • рядки 19–20: посилання на шар [DAO]. Він буде інстанційований бібліотекою AA до того, як це поле буде ініціалізовано. Це означає, що бін AA [Dao] повинен існувати. Це завжди так у випадку з шаблонним додатком, який ми надаємо. Навіть у додатку без шару [DAO] можна залишити пакет [dao]. Це не спричиняє ускладнень;
  • рядок 22: сесія як екземпляр типу [Session]. Сесія існує в батьківському класі [AbstractActivity], але як екземпляр інтерфейсу [ISession] (рядок 32);
  • рядки 24–63: шість методів, визначених батьківським класом [AbstractActivity];
  • рядки 36–39: метод [getDao] повертає посилання на рівень [DAO]. У цьому випадку це посилання ніколи не є null. Однак у батьківському класі [AbstractActivity] передбачено випадок, коли дочірній клас повертає посилання null, щоб вказати на відсутність шару [DAO]. Якщо ви хочете скористатися цією можливістю (на мою думку, не дуже корисною), саме тут потрібно повернути покажчик null;

2.6. Шар [DAO]

Image

  

2.6.1. Інтерфейс IDao

Він був представлений у розділі 2.5.5:


package client.android.dao.service;

import rx.Observable;

public interface IDao {
  // URL веб-сервісу
  void setUrlServiceWebJson(String url);

  // користувач
  void setUser(String user, String mdp);

  // час очікування клієнта
  void setTimeout(int timeout);

  // базова автентифікація
  void setBasicAuthentification(boolean isBasicAuthentificationNeeded);

  // режим налагодження
  void setDebugMode(boolean isDebugEnabled);

  // Час очікування клієнта перед запитом у мілісекундах
  void setDelay(int delay);

  // до зробити: оголосіть свій інтерфейс тут
}

Розробник додасть методи свого рівня [DAO], починаючи з рядка 24.

2.6.2. Інтерфейс [WebClient]

  

Інтерфейс [WebClient] має такий вигляд:


package client.android.dao.service;

import org.androidannotations.rest.spring.annotations.Get;
import org.androidannotations.rest.spring.annotations.Path;
import org.androidannotations.rest.spring.annotations.Rest;
import org.androidannotations.rest.spring.api.RestClientRootUrl;
import org.androidannotations.rest.spring.api.RestClientSupport;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.web.client.RestTemplate;

@Rest(converters = {MappingJackson2HttpMessageConverter.class})
public interface WebClient extends RestClientRootUrl, RestClientSupport {

  // RestTemplate
  void setRestTemplate(RestTemplate restTemplate);

  // до зробити: тут слід вказати URL, які потрібно досягти
}

Розробник додасть методи, що взаємодіють із URL, які надає сервер jSON, починаючи з рядка 17.

2.6.3. Перехоплювач автентифікації [MyAuthInterceptor]

  

Клас [MyAuthInterceptor] має такий вигляд:


package client.android.dao.service;

import org.androidannotations.annotations.EBean;
import org.springframework.http.HttpAuthentication;
import org.springframework.http.HttpBasicAuthentication;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpRequest;
import org.springframework.http.client.ClientHttpRequestExecution;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.ClientHttpResponse;

import java.io.IOException;

@EBean(scope = EBean.Scope.Singleton)
public class MyAuthInterceptor implements ClientHttpRequestInterceptor {

  // користувач
  private String user;
  // пароль
  private String mdp;

  public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException {
    // заголовки HTTP перехопленого запиту HTTP
    HttpHeaders headers = request.getHeaders();
    // заголовок HTTP базової автентифікації
    HttpAuthentication auth = new HttpBasicAuthentication(user, mdp);
    // додавання до заголовків HTTP
    headers.setAuthorization(auth);
    // продовження життєвого циклу запиту HTTP
    return execution.execute(request, body);
  }

  // елементи автентифікації
  public void setUser(String user, String mdp) {
    this.user = user;
    this.mdp = mdp;
  }
}

Цей клас генерує такий заголовок автентифікації HTTP:

Authorization: Basic code

де [code] — це код Base64 для рядка «user:mp». Цей клас використовується лише в тому випадку, якщо сервер jSON очікує саме таку форму автентифікації. Існують також інші класи.

Примітка: використання цього класу проілюстровано в розділі 3.6.3.1.

2.6.4. Клас [AbstractDao]

  

Клас [AbstractDao] має такий вигляд:


package client.android.dao.service;

import android.util.Log;
import client.android.architecture.core.Utils;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import rx.Observable;
import rx.Subscriber;

public abstract class AbstractDao {

  // маппер jSON
  private ObjectMapper mapper = new ObjectMapper();
  // режим налагодження
  protected boolean isDebugEnabled;
  // назва класу
  protected String className;
  // затримка перед виконанням запиту
  private int delay;

  // конструктор
  public AbstractDao() {
    // ім'я класу
    className = getClass().getName();
    Log.d("AbstractDao", String.format("constructeur, thread=%s", Thread.currentThread().getName()));
  }

  // захищені методи ----------------------------------------------------------
  // генеральний інтерфейс
  protected interface IRequest<T> {
    T getResponse();
  }

  // запит до веб-сервісу / jSON
  protected <T> Observable<T> getResponse(final IRequest<T> request) {
    // журнал
    if (isDebugEnabled) {
      Log.d(String.format("%s", className), String.format("delay=%s", delay));
    }
    // виконання сервісу — очікується одна відповідь
    return Observable.create(new Observable.OnSubscribe<T>() {
      @Override
      public void call(Subscriber<? super T> subscriber) {
        DaoException ex = null;
        // виконання сервісу
        try {
          // очікування?
          if (delay > 0) {
            Thread.sleep(delay);
          }
          // виконується синхронний запит
          T response = request.getResponse();
          // журнал
          if (isDebugEnabled) {
            String log;
            if (response instanceof String) {
              log = (String) response;
            } else {
              log = mapper.writeValueAsString(response);
            }
            Log.d(className, String.format("response=%s sur thread [%s]", log, Thread.currentThread().getName()));
          }
          // відправляється відповідь спостерігачеві
          subscriber.onNext(response);
          // повідомляється про завершення спостережуваного об’єкта
          subscriber.onCompleted();
        } catch (InterruptedException | JsonProcessingException | RuntimeException e) {
          // журнал
          if (isDebugEnabled) {
            try {
              Log.d(className, String.format("Thread [%s], Exception communication avec serveur : %s", Thread.currentThread().getName(), mapper.writeValueAsString(Utils.getMessagesFromException(e))));
            } catch (JsonProcessingException e1) {
              Log.d(className, String.format("Erreur jSON imprévue"));
            }
          }
          // генерується виняток
          subscriber.onError(new DaoException(e, 100));
        }
      }
    });
  }

  // режим налагодження
  public void setDebugMode(boolean isDebugEnabled) {
    this.isDebugEnabled = isDebugEnabled;
  }

  public void setDelay(int delay) {
    this.delay = delay;
  }
}
  • рядки 35–81: метод [getResponse] використовує бібліотеку RxAndroid для візуалізації типу [Observable<T>]. На відміну від деяких прикладів, розглянутих раніше, тут не відображається тип [Response<T>], який є пропрієтарним типом, а будь-який тип T;
  • рядок 35: метод [getResponse] отримує як параметр екземпляр типу [IRequest<T>] із рядків 30–32, тип T якого метод [IRequest.getReponse()] отримує за допомогою синхронної операції HTTP;
  • рядки 48–50: штучно очікуємо [delay] мілісекунд. У виробничому середовищі буде встановлено [delay=0]. На етапі налагодження буде встановлено значення [delay=qqs secondes], щоб дати користувачеві можливість скасувати асинхронну операцію і таким чином побачити, як поводиться код у цьому випадку;
  • рядок 52: очікувана відповідь запитується за допомогою синхронного запиту;
  • рядок 64: після отримання відповіді її передають спостерігачеві;
  • рядок 66: вказується, що подальших передач не буде. Тут ми маємо особливий випадок асинхронної дії, яка повертає лише один елемент;
  • рядки 67–78: у разі виникнення винятку виняток передається спостерігачеві (рядок 77);

2.6.5. Клас [Dao]

  

Клас [Dao] має такий вигляд:


package client.android.dao.service;

import android.util.Log;
import org.androidannotations.annotations.AfterInject;
import org.androidannotations.annotations.Bean;
import org.androidannotations.annotations.EBean;
import org.androidannotations.rest.spring.annotations.RestService;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.web.client.RestTemplate;
import rx.Observable;

import java.util.ArrayList;
import java.util.List;

@EBean(scope = EBean.Scope.Singleton)
public class Dao extends AbstractDao implements IDao {

  // клієнт веб-сервісу
  @RestService
  protected WebClient webClient;
  // безпека
  @Bean
  protected MyAuthInterceptor authInterceptor;
  // RestTemplate
  private RestTemplate restTemplate;
  // фабрика RestTemplate
  private SimpleClientHttpRequestFactory factory;

  @AfterInject
  public void afterInject() {
    // журнал
    Log.d(className, "afterInject");
    // виготовлення restTemplate
    factory = new SimpleClientHttpRequestFactory();
    restTemplate = new RestTemplate(factory);
    // встановлюємо перетворювач jSON
    restTemplate.getMessageConverters().add(new MappingJackson2HttpMessageConverter());
    // встановлюємо restTemplate веб-клієнта
    webClient.setRestTemplate(restTemplate);
  }

  @Override
  public void setUrlServiceWebJson(String url) {
    // встановлюємо URL веб-сервісу
    webClient.setRootUrl(url);
  }

  @Override
  public void setUser(String user, String mdp) {
    // реєструємо користувача в перехоплювачі
    authInterceptor.setUser(user, mdp);
  }

  @Override
  public void setTimeout(int timeout) {
    if (isDebugEnabled) {
      Log.d(className, String.format("setTimeout thread=%s, timeout=%s", Thread.currentThread().getName(), timeout));
    }
    // фабрика конфігурації
    factory.setReadTimeout(timeout);
    factory.setConnectTimeout(timeout);
  }

  @Override
  public void setBasicAuthentification(boolean isBasicAuthentificationNeeded) {
    if (isDebugEnabled) {
      Log.d(className, String.format("setBasicAuthentification thread=%s, isBasicAuthentificationNeeded=%s", Thread.currentThread().getName(), isBasicAuthentificationNeeded));
    }
    // інтерцептор автентифікації?
    if (isBasicAuthentificationNeeded) {
      // додається перехоплювач автентифікації
      List<ClientHttpRequestInterceptor> interceptors = new ArrayList<ClientHttpRequestInterceptor>();
      interceptors.add(authInterceptor);
      restTemplate.setInterceptors(interceptors);
    }
  }

  // приватні методи -------------------------------------------------
  private void log(String message) {
    if (isDebugEnabled) {
      Log.d(className, message);
    }
  }

  // до зробити: реалізація IDao
}
  • рядки 21–22: введення біна AA [WebClient], який забезпечить обмін даними з веб-сервером / jSON;
  • рядки 24–25: введення перехоплювача автентифікації;
  • рядки 31–42: метод, що виконується після введення полів з рядків 21–25;
  • рядок 37: об’єкт [RestTemplate], який забезпечує обмін даними між клієнтом і сервером, створюється на основі factory. Це не є обов’язковим, але саме через factory можна налаштувати тайм-аути обміну даними. Саме тому ми не використовуємо конструктор без параметрів [RestTemplate()];
  • рядок 39: додаємо конвертер jSON до конвертерів [RestTemplate]. Це буде єдиний конвертер. Також, коли метод клієнта [WebClient] отримає рядок jSON від сервера, цей рядок буде автоматично десеріалізовано в об’єкт, який метод повинен повернути;
  • рядок 41: об’єкт [RestTemplate], налаштований таким чином, передається веб-клієнту, який за його допомогою забезпечуватиме обмін даними між клієнтом і сервером;
  • рядки 44–48: встановлюється кореневий об’єкт веб-сервера URL / jSON. Усі URL, оголошені в класі [WebClient], є URL, пов’язаними з цим кореневим URL;
  • рядки 50–54: цей метод дозволяє вказати власника з’єднання, коли воно контролюється авторизацією базового типу (див. параграф 2.6.3);
  • рядки 56–64: визначають параметри обміну даними між клієнтом і сервером. Це здійснюється за допомогою factory об’єкта [RestTemplate], який керує обміном даними;
  • рядки 66–78: цей метод дозволяє вказати, що сервер захищений аутентифікацією базового типу;
  • рядки 72–77: якщо вимагається аутентифікація базового типу, перехоплювач аутентифікації, вставлений у рядку 25, додається до перехоплювачів об’єкта [RestTemplate]. Цей перехоплювач автоматично додаватиме до всіх запитів веб-клієнта рядок HTTP з базовою автентифікацією, яку очікує сервер;
  • розробник реалізує інтерфейс [IDao], починаючи з рядка 87;

2.7. Фрагменти

  

2.7.1. Клас [MenuItemState]

Клас [MenuItemState] інкапсулює стан опції меню:


package client.android.architecture;

public class MenuItemState {

  // ідентифікатор пункту меню
  private int menuItemId;
  // видимість опції
  private boolean isVisible;

  // конструктори
  public MenuItemState() {

  }

  public MenuItemState(int menuItemId, boolean isVisible) {
    this.menuItemId = menuItemId;
    this.isVisible = isVisible;
  }

  // методи getter та setter
...
}

2.7.2. Клас [Utils]

Клас [Utils] містить статичні допоміжні методи:


package client.android.architecture;

import java.util.ArrayList;
import java.util.List;

public class Utils {

  // список повідомлень про виняток — версія 1
  static public List<String> getMessagesFromException(Throwable ex) {
    // створення списку з повідомленнями про помилки зі стека винятків
    List<String> messages = new ArrayList<>();
    Throwable th = ex;
    while (th != null) {
      messages.add(th.getMessage());
      th = th.getCause();
    }
    return messages;
  }

  // список повідомлень про виняток — версія 2
  static public String getMessageForAlert(Throwable th) {
    // формування тексту для відображення
    StringBuilder texte = new StringBuilder();
    List<String> messages = getMessagesFromException(th);
    int n = messages.size();
    for (String message : messages) {
      texte.append(String.format("%s : %s\n", n, message));
      n--;
    }
    // результат
    return texte.toString();
  }

  // список повідомлень про виняток — версія 3
  static public String getMessageForAlert(List<String> messages) {
    // формується текст для відображення
    StringBuilder texte = new StringBuilder();
    int n = messages.size();
    for (String message : messages) {
      texte.append(String.format("%s : %s\n", n, message));
      n--;
    }
    // результат
    return texte.toString();
  }
}

2.7.3. Базовий клас [AbstractFragment]

Клас [AbstractFragment] об’єднує те, що є спільним для всіх фрагментів додатка. Як і в класі [AbstractActivity], його код є складним. Ми також проаналізуємо його поетапно.

2.7.3.1. Скелет


package client.android.architecture.core;

import android.app.Activity;
import android.os.Bundle;
import android.support.v4.app.Fragment;
import android.util.Log;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import client.android.architecture.custom.CoreState;
import client.android.architecture.custom.IMainActivity;
import client.android.architecture.custom.Session;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import rx.Observable;
import rx.Subscription;
import rx.android.schedulers.AndroidSchedulers;
import rx.functions.Action0;
import rx.functions.Action1;
import rx.schedulers.Schedulers;

import java.util.ArrayList;
import java.util.List;

public abstract class AbstractFragment extends Fragment {

  // приватні дані ------------------------------------------------------------
  // підписки на спостережувані об’єкти
  private List<Subscription> abonnements = new ArrayList<>();
  // меню фрагмента
  private Menu menu;
  private MenuItemState[] menuOptionsStates = new MenuItemState[0];
  // життєвий цикл фрагмента
  private boolean initDone = false;
  private boolean isVisibleToUser = false;
  private boolean saveFragmentDone = false;
  // стан фрагмента
  private CoreState previousState;
  // маппер jSON
  private ObjectMapper jsonMapper = new ObjectMapper();
  // життєвий цикл фрагмента
  private boolean fragmentHasToBeInitialized = false;
  private boolean viewHasToBeInitialized = false;
  // асинхронні завдання
  private boolean runningTasksHaveBeenCanceled;

  // дані,  доступні для дочірніх класів ---------------------------------------
  // режим налагодження
  final protected boolean isDebugEnabled = IMainActivity.IS_DEBUG_ENABLED;
  // назва класу
  protected String className;
  // асинхронні завдання
  protected int numberOfRunningTasks;
  // активність
  protected IMainActivity mainActivity;
  protected Activity activity;
  // сесія
  protected Session session;


  // Оновлення фрагмента ----------------------------------------------------------------------------------
 ...

  // управління меню ------------------------------------------
  ...

  // управління чергою -------------------------------------------------------------
...

  // управління асинхронними операціями --------------------------------------------------------------------
...

  // обробка винятків -------------------------------------------------------------------
....

  // управління життєвим циклом фрагмента --------------------------------------------------------
...

  // дочірні класи -----------------------------------------------------
  public abstract CoreState saveFragment();

  protected abstract int getNumView();

  protected abstract void initFragment(CoreState previousState);

  protected abstract void initView(CoreState previousState);

  protected abstract void updateOnSubmit(CoreState previousState);

  protected abstract void updateOnRestore(CoreState previousState);

  protected abstract void notifyEndOfUpdates();

  protected abstract void notifyEndOfTasks(boolean runningTasksHaveBeenCanceled);

}
  • рядки 28–45: приватні дані класу;
  • рядки 47–58: захищені дані, доступні для дочірніх класів;
  • рядки 61–62: код, що оновлює фрагмент, який буде відображатися;
  • рядки 64–65: допоміжний код для управління можливим меню;
  • рядки 67–68: допоміжний код для управління очікуванням під час асинхронної операції;
  • рядки 70–71: код для спрощення взаємодії фрагмента з рівнем [DAO];
  • рядки 73–74: допоміжний код для стандартного оброблення будь-яких винятків;
  • рядки 76–77: код, що керує життєвим циклом фрагмента;
  • рядки 80–94: батьківський клас накладає 8 методів на свої дочірні класи;

2.7.3.2. Конструктор

Конструктор класу має такий вигляд:


  // назва класу
  protected String className;
  // життєвий цикл фрагмента
  private boolean fragmentHasToBeInitialized = false;
...
  // конструктор ----------------------
  public AbstractFragment() {
    // ініціалізація
    className = getClass().getSimpleName();
    fragmentHasToBeInitialized = true;
    // журнал
    if (isDebugEnabled) {
      Log.d(className, "constructeur");
    }
}
  • рядок 9: зазначається ім’я дочірнього класу, який тут інстанціюється. Це ім’я використовується у всіх логах батьківського класу;
  • рядок 10: фіксується, що фрагмент перебуває в процесі створення. Ця інформація буде використана, коли дочірньому фрагменту буде наказано оновитися;

2.7.3.3. Управління меню

У нашій архітектурі кожен фрагмент повинен мати меню, навіть порожнє. Журнали показали, що коли виконується метод [onCreateOptionsMenu] (який викликається, коли фрагмент має меню), фрагмент уже пов’язаний зі своєю активністю, своїм видом та своїм меню і стане видимим. Отже, саме в цей момент можна оновити візуальний інтерфейс та меню. Саме в цьому методі [onCreateOptionsMenu] ми вимагаємо від дочірнього фрагмента оновитися.

Управління меню об’єднує допоміжні методи, які дозволяють дочірньому фрагменту відображати або приховувати елементи меню:


  // меню фрагмента
  private Menu menu;
  private MenuItemState[] menuOptionsStates;
...
  // управління меню ------------------------------------------
  private void getMenuOptions(Menu menu, List<Integer> menuOptionsIds) {
    // перегляд усіх пунктів меню
    for (int i = 0; i < menu.size(); i++) {
      // пункт № i
      MenuItem menuItem = menu.getItem(i);
      menuOptionsIds.add(menuItem.getItemId());
      // якщо пункт № i є підменю, то починаємо спочатку
      if (menuItem.hasSubMenu()) {
        // рекурсія
        getMenuOptions(menuItem.getSubMenu(), menuOptionsIds);
      }
    }
  }

  private void getMenuOptionsStates(Menu menu) {
    // результат
    if (isDebugEnabled) {
      Log.d(className, "getMenuOptionsStates(Menu)");
    }
    // отримуємо ідентифікатори опцій меню
    List<Integer> menuOptionsIds = new ArrayList<>();
    getMenuOptions(menu, menuOptionsIds);
    // переносимо опції меню в масив
    menuOptionsStates = new MenuItemState[menuOptionsIds.size()];
    for (int i = 0; i < menuOptionsStates.length; i++) {
      // ідентифікатор опції
      int id = menuOptionsIds.get(i);
      // стан опції
      menuOptionsStates[i] = new MenuItemState(id, menu.findItem(id).isVisible());
    }
    // результат
    if (isDebugEnabled) {
      Log.d(className, String.format("Nombre d'options de menu=%s", menuOptionsStates.length));
    }
  }

  // стани опцій меню
  private MenuItemState[] getMenuOptionsStates() {
    MenuItemState[] menuOptionsStates = new MenuItemState[this.menuOptionsStates.length];
    for (int i = 0; i < menuOptionsStates.length; i++) {
      // стан
      MenuItemState state = this.menuOptionsStates[i];
      // ідентифікатор меню
      int id = state.getMenuItemId();
      // ініціалізація стану
      menuOptionsStates[i] = new MenuItemState(id, menu.findItem(id).isVisible());
    }
    // результат
    return menuOptionsStates;
  }

  // відображення опцій меню -----------------------------------
  protected void setAllMenuOptionsStates(boolean isVisible) {
    // оновлюються всі опції меню
    for (MenuItemState menuItemState : menuOptionsStates) {
      menu.findItem(menuItemState.getMenuItemId()).setVisible(isVisible);
    }
  }

  protected void setMenuOptionsStates(MenuItemState[] menuItemStates) {
    // оновлюються деякі опції меню
    for (MenuItemState menuItemState : menuItemStates) {
      menu.findItem(menuItemState.getMenuItemId()).setVisible(menuItemState.isVisible());
    }
}
  • рядки 6–18: цей метод дозволяє отримати числові ідентифікатори всіх опцій меню;
  • рядок 6: метод [getMenuOptions] приймає два параметри:
    • [Menu menu]: меню фрагмента;
    • [List<Integer> menuOptionsIds]: список ідентифікаторів Android для пунктів меню. Спочатку цей список порожній. Потім він заповнюється за допомогою рекурсивного обходу (рядок 15) дерева меню;
  • рядки 20–40: на основі меню будується масив станів (ідентифікатор, видимість) пунктів меню. Цей масив зберігається у рядку 3. Клас [MenuItemState] було описано в розділі 2.7.1;
  • рядки 43–55: варіант попереднього методу. Він виконує те саме, але замість перерахунку ідентифікаторів усіх опцій меню, що вже було зроблено, використовує ідентифікатори з масиву станів із рядка 3;
  • рядки 58–63: метод [setAllMenuOptionsStates] дозволяє приховати або показати всі пункти меню фрагмента;
  • рядки 65–69: метод [setMenuOptionsStates] дозволяє вибірково відображати або приховувати певні пункти меню;
  • методи [getMenuOptions, getMenuOptionsStates] оголошені приватними, оскільки використовуються виключно в [AbstractFragment]. Методи [setAllMenuOptionsStates] (рядок 58) та [setMenuOptionsStates] (рядок 65) оголошені як захищені, щоб бути доступними для дочірніх класів;

2.7.3.4. Управління очікуванням завершення асинхронного завдання


   // підписки на об’єкти спостереження
  private List<Subscription> abonnements = new ArrayList<>();
// асинхронні завдання
  protected int numberOfRunningTasks;
  protected boolean tasksInBackgroundHaveBeenCanceled;
...

  // управління очікуванням завершення асинхронної операції -------------------------------------
  protected void beginRunningTasks(int numberOfRunningTasks) {
    // фіксується кількість завдань, які будуть виконані
    this.numberOfRunningTasks = numberOfRunningTasks;
    // встановлюємо зображення очікування
    mainActivity.beginWaiting();
    // очищаємо список підписок
    abonnements.clear();
    // скасування ще не відбулося
    runningTasksHaveBeenCanceled = false;
  }

  protected void cancelWaitingTasks() {
    // приховується зображення очікування
    mainActivity.cancelWaiting();
  }

  • рядки 9–18: щоб запустити одну або кілька асинхронних операцій, дочірній фрагмент викличе метод батьківського фрагмента [beginRunningTasks]. Параметром цього методу є кількість асинхронних завдань, які дочірній фрагмент запустить;
  • рядок 11: зберігається параметр методу;
  • рядок 13: зображення очікування стає видимим;
  • рядок 15: очищується список підписок на асинхронні операції. Вони ще не були створені дочірнім фрагментом;
  • рядок 17: ведеться булеве значення, яке вказує, що асинхронні завдання, запитані дочірнім фрагментом, були скасовані. Спочатку це булеве значення має значення false;
  • рядки 20–25: дочірній фрагмент викликає метод батьківського фрагмента [cancelWaitingTasks], щоб вказати, що він хоче скасувати завдання, які він запустив;
  • рядок 22: зображення очікування приховано;

2.7.3.5. Обробка винятків


  // обробка винятку -------------------------------------------------------------------

  // виведення попередження про виняток
  protected void showAlert(Throwable th) {
    // відображення повідомлень зі стека винятків класу Throwable th
    new android.app.AlertDialog.Builder(activity).setTitle("Des erreurs se sont produites").setMessage(Utils.getMessageForAlert(th)).setNeutralButton("Fermer", null).show();
  }

  // виведення списку повідомлень
  protected void showAlert(List<String> messages) {
    // відображення списку повідомлень
    new android.app.AlertDialog.Builder(activity).setTitle("Des erreurs se sont produites").setMessage(Utils.getMessageForAlert(messages)).setNeutralButton("Fermer", null).show();
}
  • рядки 4–7: метод [showAlert(Throwable)] дозволяє дочірньому фрагменту відображати у вікні повідомлення зі стека винятків методу Throwable, переданого як параметр;
  • рядки 10–13: метод [showAlert(List<String>] дозволяє дочірньому фрагменту відображати у вікні список повідомлень, переданий як параметр;
  • клас [Utils], що використовується у рядках 6 та 12, описано у розділі 2.7.2;

2.7.3.6. Управління асинхронними операціями


...
  // підписки на об’єкти спостереження
  private List<Subscription> abonnements = new ArrayList<>();
  // асинхронні завдання
  private boolean runningTasksHaveBeenCanceled;
  protected int numberOfRunningTasks;
...
  // виконання асинхронного завдання за допомогою RxAndroid
  protected <T> void executeInBackground(Observable<T> process, Action1<T> consumeResult) {
    // процес: об’єкт спостереження, який потрібно виконати / спостерігати
    // consumeResult: метод, що обробляє отриману відповідь
    // 
    // нові підписки створюються лише в тому випадку, якщо не відбулося скасування
    if (!runningTasksHaveBeenCanceled) {
      // виконання у потоці вводу-виводу та спостереження у потоці інтерфейсу користувача
      process = process.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread());
      // виконується спостережуваний об’єкт
      try {
        abonnements.add(process.subscribe(
          // споживання результату
          consumeResult,
          // обробка винятку
          new Action1<Throwable>() {
            @Override
            public void call(Throwable th) {
              consumeThrowable(th);
            }
          },
          // завершення завдання
          new Action0() {

            @Override
            public void call() {
              endOfTask();
            }
          }));
      } catch (Throwable th) {
        consumeThrowable(th);
      }
    }
  }

  private void endOfTask() {
...
  }

  // асинхронна операція викликала виняток
  // або під час виконання асинхронної операції стався виняток
  private void consumeThrowable(Throwable th) {
...
  }

  • рядки 9–41: виконують асинхронне завдання;
  • рядок 9: метод [executeInBackground] очікує два параметри:
    • [Observable<T> process]: асинхронний процес, який потрібно виконати;
    • [Action1<T> consumeResult]: метод дочірнього фрагмента, який потрібно викликати, щоб передати йому елементи, згенеровані процесом. У наших попередніх прикладах процеси завжди генерували лише один елемент. Тип T у [Action1<T>] — це тип T результату, поверненого спостережуваним процесом;
  • рядок 14: асинхронне завдання запускається лише в тому випадку, якщо ще не відбулося скасування з боку користувача або програми (через виняток);
  • рядок 16: процес налаштований на виконання у потоці вводу-виводу та спостерігається у потоці інтерфейсу користувача;
  • рядок 16: інструкція [process.subscribe] запускає виконання процесу в потоці вводу-виводу. Усередині цього потоку все виконується синхронно, оскільки ми використовуємо бібліотеку HTTP, яка є синхронною;
  • рядок 19: метод [process.subscribe] має три параметри:
    • рядок 21: [consumeResult] — метод дочірнього фрагмента, який буде обробляти елементи, що надходять від процесу;
    • рядки 22–28: метод, що виконується, коли під час обробки асинхронного завдання сталося виключення. Обробка делегується методу [consumeThrowable] із рядка 49;
    • рядки 29–36: метод, що виконується, коли завдання надсилає повідомлення про завершення передачі. Обробка делегується методу [endOfTask] у рядку 43;
  • рядок 19: щойно запущене асинхронне завдання записується в поле [abonnements], яке зберігає всі запущені асинхронні завдання. Це дозволить скасувати їх у разі потреби;
  • рядки 37–39: метод, що виконується у разі виникнення винятку під час обробки асинхронного завдання. Обробка делегується методу [consumeThrowable] у рядку 49;

Метод [endOfTask] має такий вигляд:


  // асинхронні завдання
  protected int numberOfRunningTasks;
...
  private void endOfTask() {
    // на одне завдання менше, на яке доводиться чекати
    numberOfRunningTasks--;
    // закінчено?
    if (numberOfRunningTasks == 0) {
      // завершено очікування
      cancelWaitingTasks();
      // повідомлення про завершення завдань до дочірнього класу
      notifyEndOfTasks(false);
    }
  }
...
  // дочірні класи -----------------------------------------------------
...
protected abstract void notifyEndOfTasks(boolean runningTasksHaveBeenCanceled);
  • рядок 6: асинхронне завдання щойно завершилося. Зменшується лічильник активних завдань;
  • рядок 8: якщо активних завдань більше немає, то дочірній фрагмент отримав усі свої відповіді;
  • рядок 10: очікування скасовується;
  • рядок 12: дочірньому фрагменту повідомляється, що всі запущені ним завдання завершені, шляхом виклику його методу [notifyEndOfTasks]. Параметр цього методу вказує, як завершилися завдання: нормально, чи внаслідок скасування користувачем або кодом через виникнення винятку. У рядку 12 повідомляється про нормальне завершення. Зазначимо, що дочірньому фрагменту не потрібно піклуватися про підрахунок завдань, що ще активні. Це робить за нього його батьківський клас;

Метод [consumeThrowable] має такий вигляд:


  // асинхронні завдання
  protected int numberOfRunningTasks;
  private boolean runningTasksHaveBeenCanceled;
...
    // асинхронна операція викликала виняток
  // або під час виконання асинхронної операції сталося виключення
  private void consumeThrowable(Throwable th) {
    // th: виняток, який потрібно обробити
    // 
    // журнал
    if (isDebugEnabled) {
      Log.d(className, "Exception reçue");
    }
    // вже запущені завдання скасовуються
    cancelRunningTasks();
    // відображаються повідомлення про помилки
    showAlert(th);
  }

  // скасування завдань
  protected void cancelRunningTasks() {
    // журнал
    if (isDebugEnabled) {
      Log.d(className, "Annulation des tâches lancées");
    }
    // скасовуються всі зареєстровані асинхронні завдання
    for (Subscription abonnement : abonnements) {
      abonnement.unsubscribe();
    }
    // фіксується скасування
    runningTasksHaveBeenCanceled = true;
    numberOfRunningTasks = 0;
    // завершення очікування
    cancelWaitingTasks();
    // повідомлення про скасування завдань до дочірнього фрагмента
    notifyEndOfTasks(true);
}

...
  // дочірні класи -----------------------------------------------------
...
protected abstract void notifyEndOfTasks(boolean runningTasksHaveBeenCanceled);
  • рядок 3: метод [consumeThrowable] отримує виняток, що виник;
  • рядок 15: усі завдання, що все ще активні, скасовуються;
  • рядок 17: виводиться текст винятку;
  • рядки 21–37: скасування всіх завдань;
  • рядки 27–29: скасовуються всі підписки;
  • рядок 31: фіксується факт скасування;
  • рядок 32: лічильник завдань обнуляється;
  • рядок 34: очікування скасовано;
  • рядок 36: дочірньому фрагменту повідомляється про завершення завдань у разі скасування;

2.7.3.7. Управління життєвим циклом фрагмента


  // життєвий цикл --------------------------------------------------------
  @Override
  public void onDestroyView() {
    // батьківський
    super.onDestroyView();
    // журнал
    if (isDebugEnabled) {
      Log.d(className, "onDestroyView");
    }
  }

  @Override
  public void onDestroy() {
    // батьківський
    super.onDestroy();
    // журнал
    if (isDebugEnabled) {
      Log.d(className, "onDestroy");
    }
  }

  @Override
  public void setUserVisibleHint(boolean isVisibleToUser) {
...
  }

  private void saveState() {
...
  }

  @Override
  public void onActivityCreated(Bundle savedInstanceState) {
...
  }


  @Override
  public void onSaveInstanceState(final Bundle outState) {
...
}
  • рядки 2–20: методи [onDestroyView, onDestroy] призначені виключно для ведення журналів. Вони дозволяють розробнику краще зрозуміти життєвий цикл фрагментів;

Збереження фрагмента під час повороту пристрою здійснюється за допомогою таких методів [setUserVisibleHint, onSaveInstanceState, saveState]:


  // життєвий цикл фрагмента
  private boolean isVisibleToUser = false;
  private boolean saveFragmentDone = false;
...

@Override
  public void setUserVisibleHint(boolean isVisibleToUser) {
    // батьківський
    super.setUserVisibleHint(isVisibleToUser);
    // збереження?
    if (this.isVisibleToUser && !isVisibleToUser) {
      // фрагмент буде приховано — його зберігаємо
      if (!saveFragmentDone) {
        saveState();
      }
    }
    // пам'ять
    this.isVisibleToUser = isVisibleToUser;
  }

  private void saveState() {
...
  }

  @Override
  public void onSaveInstanceState(final Bundle outState) {
    // журнал
    if (isDebugEnabled) {
      Log.d(className, String.format("onSaveInstanceState isVisibleToUser=%s, saveFragmentDone=%s", isVisibleToUser, saveFragmentDone));
    }
    // батьківський
    super.onSaveInstanceState(outState);
    // збереження фрагмента лише в тому випадку, якщо він видимий
    if (isVisibleToUser) {
      // можливо, збереження вже відбулося
      if (!saveFragmentDone) {
        saveState();
      }
      // відновлення потрібно виконати в будь-якому випадку
      session.setAction(ISession.Action.RESTORE);
    }
}
  • рядки 6–19: збереження фрагмента відбувається, якщо він переходить зі стану «відображено» у стан «приховано» (рядок 11). Цю інформацію надає метод [setUserVisibleHint];
  • рядок 14: збереження здійснюється за допомогою приватного методу, описаного в рядках 21–23;
  • рядки 25–41: під час повороту пристрою викликається метод [onSaveInstanceState]. Фрагмент зберігається за двох умов:
    • він є видимим (рядок 34);
    • він ще не був збережений (рядок 36). Можливо, що обидва методи [setUserVisibleHint, onSaveInstanceState] не зможуть виконатися одночасно, коли фрагмент є видимим, і тому управління булевим значенням [saveFragmentDone] є зайвим. На всяк випадок я вирішив використати саме його;
  • рядок 40: після збереження відбудеться відновлення. Зазначимо, що наступного разу, коли фрагмент повинен буде оновитися, це відбуватиметься за допомогою операції [RESTORE];

Звернемо увагу на два моменти, коли вимагається збереження фрагмента:

  1. коли фрагмент переходить зі стану «видимий» у стан «прихований»;
  2. коли відбувається поворот пристрою;

Приватний метод [saveState] має такий вигляд:


...
  private void saveState() {
    // завдання, які потрібно скасувати?
    if (numberOfRunningTasks != 0) {
      // завдання скасовуються
      cancelRunningTasks();
    }
    // зберігаємо стан фрагмента
    CoreState currentState = saveFragment();
    // фрагмент було переглянуто
    currentState.setHasBeenVisited(true);
    // збереження стану меню
    currentState.setMenuOptionsState(getMenuOptionsStates());
    // вхід у систему
    session.setCoreState(getNumView(), currentState);
    // збереження виконано
    saveFragmentDone = true;
    // журнал
    if (isDebugEnabled) {
      try {
        Log.d(className, String.format("saveFragment state=%s", jsonMapper.writeValueAsString(currentState)));
      } catch (JsonProcessingException e) {
        e.printStackTrace();
      }
    }
  }


...
  // дочірні класи -----------------------------------------------------
public abstract CoreState saveFragment();

protected abstract int getNumView();
  • рядки 4–7: обертання периферійного пристрою може відбуватися під час виконання асинхронних операцій. Тут приймається рішення скасувати їх усі. Це не найкраще рішення для користувача, якому доведеться повторно надсилати новий запит, що може зайняти багато часу, хоча він лише перемістив свій телефон чи планшет або отримав телефонний дзвінок. Зберегти мережеві з’єднання протягом циклу резервного копіювання/відновлення цілком можливо. Просто рішення не є очевидними, і я вирішив не розглядати їх у цьому курсі для початківців. Правильний підхід полягає в тому, щоб встановлювати ці мережеві з’єднання за допомогою фрагмента без прив’язаного візуального інтерфейсу, який не знищується під час циклу резервного копіювання/відновлення. Для цього достатньо використати інструкцію [Fragment.setRetainInstance(true)];
  • рядок 9: дочірньому фрагменту задається зберегти свій стан у типі, похідному від [CoreState] (рядок 31);
  • рядок 11: відзначається, що фрагмент було відвідано. Ця інформація є корисною. Коли фрагмент відвідується вперше, його оновлення може відрізнятися від наступних, оскільки на той момент він не має попереднього стану в сесії;
  • рядок 13: зберігається стан меню, що дозволить нам автоматично його відновити;
  • рядок 15: цей поточний стан зберігається у сесії. У ній стани згруповані за поданням / фрагментом, кожне з яких має свій стан. Номер подання надається дочірнім фрагментом (рядок 33);
  • рядок 17: фіксується, що фрагмент було збережено. Це пов’язано з тим, що метод [saveState] можуть викликати два методи, і немає сенсу виконувати збереження двічі;

Регенерація подання, пов’язаного з фрагментом, забезпечується наступним методом:


  @Override
  public void onActivityCreated(Bundle savedInstanceState) {
    // батьківський
    super.onActivityCreated(savedInstanceState);
    // журнал
    if (isDebugEnabled) {
      Log.d(className, "onActivityCreated");
    }
    // погляд потрібно відновити
    viewHasToBeInitialized = true;
}

У циклі життя метод [onActivityCreated] виконується одразу після методу [onCreateView]. Виклик останнього методу вказує на те, що представлення, пов’язане з фрагментом, має бути відновлене. Про це зазначено у рядку 10.

2.7.3.8. Оновлення фрагмента

Оновлення фрагмента — це остання операція, що виконується над фрагментом перед тим, як він стане видимим і перейде в режим очікування дій користувача. Це забезпечується таким кодом:


  // меню фрагмента
  private Menu menu;
  private MenuItemState[] menuOptionsStates;
  // життєвий цикл фрагмента
  private boolean initDone = false;
  private boolean isVisibleToUser = false;
  private boolean saveFragmentDone = false;
  // стани фрагмента
  private CoreState previousState;
  // маппер jSON
  private ObjectMapper jsonMapper = new ObjectMapper();
  // життєвий цикл фрагмента
  private boolean fragmentHasToBeInitialized = false;
  private boolean viewHasToBeInitialized = false;
...

  // Оновлення фрагмента ----------------------------------------------------------------------------------
  @Override
  public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
    // журнал
    if (isDebugEnabled) {
      Log.d(className, "onCreateOptionsMenu");
    }
    // пам'ять
    this.menu = menu;
    // отримуємо # опцій меню, якщо це ще не зроблено
    if (fragmentHasToBeInitialized) {
      // завантажуються # опції меню
      getMenuOptionsStates(menu);
      // дія
      this.activity = getActivity();
      this.mainActivity = (IMainActivity) activity;
      this.session = (Session) this.mainActivity.getSession();
    }
    // отримуємо попередній стан фрагмента (вперше, лише логічне значення hasBeenVisited має значення)
    previousState = session.getCoreState(getNumView());
    // оновлення дочірнього фрагмента в кілька етапів
    // етап 1 — чи це перший візит?
    if (!previousState.getHasBeenVisited()) {
      if (isDebugEnabled) {
        Log.d(className, "initFragment initView updateForFirstVisit");
      }
  ...
    } else {
      // це не перший візит
      // етап 2: чи потрібно ініціалізувати фрагмент?
      ...
      // етап 3: чи потрібно ініціалізувати представлення?
      ...
    }
    // крок 4: відправлення, перехід, відновлення?
    ...

    // етап 5: оновлення терміналів ----------------------
...
  }
...
  // дочірні класи -----------------------------------------------------
  protected abstract void initFragment(CoreState previousState);

  protected abstract void initView(CoreState previousState);

  protected abstract void updateOnSubmit(CoreState previousState);

  protected abstract void updateOnRestore(CoreState previousState);

  protected abstract void notifyEndOfUpdates();
  • рядок 19: для оновлення фрагмента використовується метод [onCreateOptionsMenu]. З цієї причини фрагмент повинен мати меню, яке, за потреби, може бути порожнім. Коли цей метод виконується, фрагмент уже пов’язаний зі своїм видом та активністю, а також є видимим;
  • рядок 25: зберігається меню, яке було передано як параметр (рядок 22) методу;
  • рядки 27–34: якщо фрагмент потрібно ініціалізувати:
    • рядок 29: стани опцій меню записуються в масив [menuOptionsStates] із рядка 3;
    • рядок 31: активність зберігається як екземпляр типу Android [Activity];
    • рядок 32: активність зберігається як екземпляр інтерфейсу [IMainActivity];
    • рядок 33: сесія зберігається. Зміна типу необхідна, оскільки метод [mainActivity.getSession()] повертає тип [ISession];
  • рядок 36: із сесії отримується попередній стан фрагмента. Якщо це перший візит до фрагмента, значення має лише логічне значення [previousState.hasBeenVisited];
  • рядки 39–44: код, що виконується, коли фрагмент відвідується вперше. У цьому випадку його попередній стан не має значення;
  • рядки 44–50: код, що виконується, коли це не перший візит до фрагмента;
  • рядки 46–47: код, що виконується, якщо було викликано конструктор фрагмента (fragmentHasToBeInitialized==true);
  • рядки 48–49: код виконується, якщо вікно, пов’язане з фрагментом, було відновлено (viewHasToBeInitialized==true);
  • рядки 51–52: код виконується залежно від поточної дії (SUBMIT, NAVIGATION, RESTORE);
  • рядки 54–55: код, що виконується постійно;

П’ять етапів оновлення такі:

етап 1


  // меню фрагмента
  private Menu menu;
  private MenuItemState[] menuOptionsStates;
  // життєвий цикл фрагмента
  private boolean initDone = false;
  private boolean isVisibleToUser = false;
  private boolean saveFragmentDone = false;
  // стани фрагмента
  private CoreState previousState;
  // маппер jSON
  private ObjectMapper jsonMapper = new ObjectMapper();
  // життєвий цикл фрагмента
  private boolean fragmentHasToBeInitialized = false;
  private boolean viewHasToBeInitialized = false;
...


    // відновлюється попередній стан фрагмента (вперше, лише логічне значення hasBeenVisited має значення)
    previousState = session.getCoreState(getNumView());
    // оновлення дочірнього фрагмента в кілька етапів
    // етап 1 — чи це перший візит?
    if (!previousState.getHasBeenVisited()) {
      if (isDebugEnabled) {
        Log.d(className, "initFragment initView updateForFirstVisit");
      }
      // ініціалізація фрагмента та подання
      initFragment(null);
      initView(null);
      // очищення previousState для подальших дій
      previousState = null;
    } else {
      // це не перший візит
...

  protected abstract void initFragment(CoreState previousState);

protected abstract void initView(CoreState previousState);
  • рядок 19: попередній стан фрагмента отримується із сесії;
  • рядки 22–31: код виконується, якщо фрагмент ще ніколи не відвідувався;
  • рядок 27: дочірньому класу надається запит на ініціалізацію фрагмента. Параметром методу [initFragment] у рядку 35 є попередній стан фрагмента. Тут передається null, щоб вказати дочірньому фрагменту, що це перший візит;
  • рядок 28: дочірньому класу доручається ініціалізувати вигляд, пов’язаний із фрагментом. Параметром методу [initView] у рядку 37 є попередній стан фрагмента. Тут передається значення null, щоб повідомити дочірньому фрагменту, що це перший візит;
  • рядок 30: попередній стан встановлюється на null для наступних етапів;

кроки 2 і 3


// меню фрагмента
  private Menu menu;
  private MenuItemState[] menuOptionsStates;
  // життєвий цикл фрагмента
  private boolean initDone = false;
  private boolean isVisibleToUser = false;
  private boolean saveFragmentDone = false;
  // стани фрагмента
  private CoreState previousState;
  // маппер jSON
  private ObjectMapper jsonMapper = new ObjectMapper();
  // життєвий цикл фрагмента
  private boolean fragmentHasToBeInitialized = false;
  private boolean viewHasToBeInitialized = false;
...


    // відновлюється попередній стан фрагмента (вперше, лише логічне значення hasBeenVisited має значення)
    previousState = session.getCoreState(getNumView());
    // оновлення дочірнього фрагмента в кілька етапів
    // етап 1 — чи це перший візит?
    if (!previousState.getHasBeenVisited()) {
...
    } else {
      // це не перший візит
      // етап 2: чи потрібно ініціалізувати фрагмент?
      if (fragmentHasToBeInitialized) {
        if (isDebugEnabled) {
          Log.d(className, "initialisation fragment");
        }
        // дочірній фрагмент
        initFragment(previousState);
      }
      // крок 3: чи потрібно ініціалізувати представлення?
      if (viewHasToBeInitialized) {
        if (isDebugEnabled) {
          Log.d(className, "initialisation vue");
        }
        // дочірній фрагмент
        initView(previousState);
      }
    }

...

  protected abstract void initFragment(CoreState previousState);

protected abstract void initView(CoreState previousState);
  • рядки 24–42: виконуються, коли це не перший візит до фрагмента;
  • рядки 27–33: якщо фрагмент щойно було відновлено, його ініціалізують, викликаючи метод [initFragment] дочірнього класу (рядки 32, 46). Йому передають попередній стан фрагмента;
  • рядки 35–51: якщо представлення, пов’язане з фрагментом, потрібно ініціалізувати або скинути, до дочірнього фрагмента надсилається запит на виконання цієї дії (рядки 40, 48). І тут також йому передається останній відомий стан фрагмента;

етап 4


// меню фрагмента
  private Menu menu;
  private MenuItemState[] menuOptionsStates;
  // життєвий цикл фрагмента
  private boolean initDone = false;
  private boolean isVisibleToUser = false;
  private boolean saveFragmentDone = false;
  // стани фрагмента
  private CoreState previousState;
  // маппер jSON
  private ObjectMapper jsonMapper = new ObjectMapper();
  // життєвий цикл фрагмента
  private boolean fragmentHasToBeInitialized = false;
  private boolean viewHasToBeInitialized = false;
...


    // відновлюється попередній стан фрагмента (вперше, лише логічне значення hasBeenVisited має значення)
    previousState = session.getCoreState(getNumView());
    // оновлення дочірнього фрагмента в кілька етапів
 ...

    // етап 4: відправка, перехід, відновлення?
    // журнал
    if (isDebugEnabled) {
      try {
        Log.d(className, String.format("session=%s", jsonMapper.writeValueAsString(session)));
        Log.d(className, String.format("état précédent=%s", jsonMapper.writeValueAsString(previousState)));
      } catch (JsonProcessingException e) {
        e.printStackTrace();
      }
    }
    // дія виконується
    ISession.Action action = session.getAction();
    switch (action) {
      case SUBMIT:
        if (isDebugEnabled) {
          Log.d(className, "updateOnSubmit");
        }
        // дочірній фрагмент
        updateOnSubmit(previousState);
        break;
      case NAVIGATION:
        if (isDebugEnabled) {
          Log.d(className, "updateForNavigation");
        }
        if (previousState != null) {
          // відновлення меню
          setMenuOptionsStates(previousState.getMenuOptionsState());
          // дочірній фрагмент
          updateOnRestore(previousState);
        } else {
          // це перший візит — нічого робити
        }
        break;
      case RESTORE:
        // відновлення
        if (isDebugEnabled) {
          Log.d(className, "updateOnRestore");
        }
        // відновлення меню (previousState не може бути нульовим)
        setMenuOptionsStates(previousState.getMenuOptionsState());
        // дочірній фрагмент
        updateOnRestore(previousState);
        break;
    }
....
  protected abstract void updateOnSubmit(CoreState previousState);

protected abstract void updateOnRestore(CoreState previousState);
  • рядки 34–66: обробляється поточна дія, яка може бути однією з трьох наступних:
    • RESTORE: виконується відновлення фрагмента після повороту пристрою;
    • NAVIGATION: повернення до фрагмента з метою відновлення його стану, в якому він перебував під час останнього використання;
    • SUBMIT: усі інші випадки;
  • рядок 34: відновлюємо поточну дію;
  • рядки 36–42: для дії типу SUBMIT викликається метод [updateOnSubmit] дочірнього фрагмента (рядки 41, 68) з передачею йому останнього відомого стану фрагмента;
  • рядки 43–55: для дії типу NAVIGATION;
  • рядки 47–54: ми хочемо повернути фрагмент до його останнього відомого стану. Операція NAVIGATION може супроводжуватися першим відвідуванням. Це може статися, наприклад, у додатку з вкладками: якщо я переходжу з вкладки 1 на вкладку 4:
    • я повинен ініціалізувати фрагмент вкладки 4, якщо це перший візит;
    • відновити фрагмент вкладки 4 до попереднього стану, якщо це не перший візит;
  • рядки 52–54: нічого не робимо, якщо це перший візит. Цю ініціалізацію має виконати дочірній метод [initView(CoreState previousState)]. Перший візит визначається умовою [previousState==null];
  • рядок 49: якщо це не перший візит до фрагмента, йому повертається його меню;
  • рядок 51: класу-дочірньому класу дається завдання оновити дані шляхом виклику методу з рядка 70. Йому передається попередній стан фрагмента, щоб він міг виконати свою роботу;
  • рядки 56–66: у разі операції відновлення фрагмента виконується те саме, що й у разі навігації, яка не є першим відвідуванням;

крок 5


// меню фрагмента
  private Menu menu;
  private MenuItemState[] menuOptionsStates;
  // життєвий цикл фрагмента
  private boolean initDone = false;
  private boolean isVisibleToUser = false;
  private boolean saveFragmentDone = false;
  // стани фрагмента
  private CoreState previousState;
  // маппер jSON
  private ObjectMapper jsonMapper = new ObjectMapper();
  // життєвий цикл фрагмента
  private boolean fragmentHasToBeInitialized = false;
  private boolean viewHasToBeInitialized = false;
...


    // етап 5: кінцеві оновлення ----------------------
    // змінено вид
    session.setPreviousView(getNumView());
    // більше немає активних дій
    session.setAction(ISession.Action.NONE);
    // коли ми вийдемо з цього фрагмента, його потрібно буде зберегти
    saveFragmentDone = false;
    // доки фрагмент не відновлено, його не потрібно ініціалізувати
    fragmentHasToBeInitialized = false;
    // доки вікно не відновлено, його не потрібно ініціалізувати
    viewHasToBeInitialized = false;
    // повертаємося до нормального функціонування вибору вкладок
    session.setNavigationOnTabSelectionNeeded(true);

    // фрагменту повідомляється, що представлення готове
    if (isDebugEnabled) {
      Log.d(className, "notifyEndOfUpdates");
    }
    notifyEndOfUpdates();
...
  protected abstract void notifyEndOfUpdates();
  • рядки 18–30: коли ми доходимо до цього місця, фрагмент вже ініціалізовано і він готовий до відображення. Тоді ми повертаємо всі індикатори, що використовуються в управлінні життєвим циклом фрагмента, у початковий стан;
  • рядок 20: відбулася зміна перегляду: це фіксується в сесії;
  • рядок 22: більше немає дій, що виконуються;
  • рядок 24: коли ми будемо виходити з фрагмента, що зараз відображається, його потрібно буде зберегти під час виходу;
  • рядок 26: фрагмент більше не потребує відновлення. Цей індикатор буде скинуто до значення vrai, коли конструктор фрагмента буде знову виконано;
  • рядок 28: вигляд, пов’язаний із фрагментом, більше не потребує ініціалізації. Цей індикатор буде скинуто до значення vrai, коли метод [onActivityCreated] буде знову виконано;
  • рядок 30: фрагмент, можливо, відображається в додатку з вкладками. У цьому випадку, коли користувач клацне на одну з них, має відбутися зміна фрагмента;
  • рядок 36: дочірньому класу повідомляється, що фрагмент готовий. Він може в методі [notifyEndOfUpdates] виконати оновлення, які слід зробити в будь-якому випадку, запустити асинхронну операцію для отримання нових даних тощо...

2.7.4. Приклад фрагмента

  

У проект [client-android-skel] було додано приклад фрагмента, щоб продемонструвати читачеві типову структуру фрагмента додатка, заснованого на цьому проекті.

Клас [DummyFragment] має такий вигляд:


package client.android.fragments.behavior;

import client.android.architecture.core.AbstractFragment;
import client.android.architecture.custom.CoreState;
import client.android.fragments.state.DummyFragmentState;

public class DummyFragment extends AbstractFragment {

  // поля, успадковані від батьківського класу -------------------------------------------------------

  // режим налагодження
  //-- final protected boolean isDebugEnabled = IMainActivity.IS_DEBUG_ENABLED;
  // ім'я класу
  //-- захищений рядок className;
  // асинхронні завдання
  //-- захищений int numberOfRunningTasks;
  // діяльність
  //-- protected IMainActivity mainActivity;
  //-- захищена активність;
  // сесія
  //-- захищений сеанс session;

  // методи, успадковані від батьківського класу -------------------------------------------------------

  // відображення опцій меню
  //-- protected void setAllMenuOptionsStates(boolean isVisible) {
  //-- protected void setMenuOptionsStates(MenuItemState[] menuItemStates) {
  // обробка очікування завершення серії асинхронних завдань
  //-- protected void beginRunningTasks(int numberOfRunningTasks) {
  //-- protected void cancelWaitingTasks() {
  // виконання асинхронного завдання за допомогою RxAndroid
  //-- protected <T> void executeInBackground(Observable<T> process, Action1<T> consumeResult) {
  // скасування завдань
  //-- protected void cancelRunningTasks() {
  // виведення попередження про виняток
  //-- protected void showAlert(Throwable th) {
  // виведення списку повідомлень
  //-- protected void showAlert(List<String> messages) {

  // методи, визначені батьківським класом -------------------------------------------------------

  @Override
  public CoreState saveFragment() {
    // необхідно зберегти фрагмент
    DummyFragmentState state=new DummyFragmentState();
    // ...
    return state;
    // якщонемає чого зберігати, виконати [return new CoreState();] та видалити клас [DummyFragmentState]
  }

  @Override
  protected int getNumView() {
    // необхідно повернути номер фрагмента в таблицю фрагментів, що управляються активністю (див. MainActivity)
    return 0;
  }

  @Override
  protected void initFragment(CoreState previousState) {
    // фрагмент стає видимим і пройшов побудову на цьому етапі або на попередньому
    // це відбувається під час запуску додатка та під час кожного повороту пристрою Android
    // обов’язково супроводжується виконанням [initView]
    // необхідно ініціалізувати поля фрагмента, який було відновлено
    // previousState — це останнє збереження фрагмента; приймає значення null, якщо це перший візит до фрагмента
  }

  @Override
  protected void initView(CoreState previousState) {
    // фрагмент стає видимим, і відповідний вигляд було відновлено на цьому етапі або на попередньому
    // це відбувається щоразу, коли виконується [initFragment], а також щоразу, коли фрагмент виходить із сусідства з відображеним фрагментом
    // необхідно ініціалізувати компоненти відновленого виду
    // previousState — це остання збережена версія фрагмента; має значення null, якщо це перший візит до фрагмента

  }

  @Override
  protected void updateOnSubmit(CoreState previousState) {
    // виконується після [initFragment, initView], якщо ці методи виконуються
    //— вигляд буде відображено після операції типу SUBMIT
    // зазвичай потрібно ініціалізувати фрагмент та пов'язаний з ним вигляд на основі сесії
    // previousState — це останнє збереження фрагмента; має значення null, якщо це перший візит до фрагмента
    // немає чого робити, якщо неможливо перейти до фрагмента за допомогою операції SUBMIT
    // якщо до фрагмента можна дістатися за допомогою операцій SUBMIT з різних фрагментів, попередній вигляд можна дізнатися за допомогою [session.getPreviousView]
    // якщо до фрагмента можна дістатися за допомогою кількох операцій SUBMIT, виходячи з одного й того самого фрагмента, то потрібно встановити сесійний індикатор для розрізнення різних типів SUBMIT, що виходять з цього фрагмента
  }

  @Override
  protected void updateOnRestore(CoreState previousState) {
    // виконується після [initFragment, initView], якщо ці методи виконуються
    //— вікно буде відображено після операції типу RESTORE або NAVIGATION
    // previousState — це останнє збереження фрагмента — ніколи не дорівнює null
    // необхідно повернути вікно до попереднього стану

  }

  @Override
  protected void notifyEndOfUpdates() {
    // виконується після методів [updateOnSubmit, updateOnRestore]
    // коли ми тут, вікно вже побудовано та ініціалізовано
    // часто тут нічого робити не потрібно, але тут також можна винести дії, які слід виконати незалежно від того, яким чином відбувся перехід до цього виду
  }

  @Override
  protected void notifyEndOfTasks(boolean runningTasksHaveBeenCanceled) {
    // викликається, коли асинхронні завдання, запущені фрагментом, або завершені, або скасовані
    // ці два випадки можна розрізнити за допомогою параметра runningTasksHaveBeenCanceled
    // як правило, потрібно перевести представлення в стан, відмінний від того, який він мав під час очікування відповідей від асинхронних завдань

  }
}

Клас [DummyFragment] може не мати стану. Тут ми додали один, щоб нагадати, що від нього очікується:


package client.android.fragments.state;

import client.android.architecture.custom.CoreState;

public class DummyFragmentState extends CoreState {
  // стан фрагмента [DummyFragment]
  // включати лише поля, що підлягають серіалізації, у jSON
  // додати анотацію @JsonIgnore до інших, але незрозуміло, для чого вони можуть знадобитися
  // не забувайте про методи getter/setter — вони використовуються для серіалізації/десеріалізації
}

Щоб проілюструвати використання проєкту [client-android-skel], спочатку розглянемо прості приклади, а потім перейдемо до більш детального аналізу конкретного випадку.

2.8. Ілюстративні вправи

Почнемо з рефакторингу вже написаних прикладів.

2.8.1. Приклад-17B

Повернемося до прикладу 17, розглянутого в розділі 1.18. Це додаток з одним фрагментом, без асинхронних завдань і без вкладок. Ми розглянемо його, щоб побачити, як він поводиться під час повороту пристрою. Вводимо такі дані:

Image

Потім у [1] ми двічі обертаємо пристрій. Новий вигляд виглядає так:

Image

Якщо порівняти ці зображення, то все збереглося, крім списку [2], який тепер порожній.

Крім того, якщо натиснути кнопку [Valider], з’явиться діалогове вікно з даними, введеними у форму. Якщо в цей момент повернути пристрій, діалогове вікно зникне.

Тому під час повороту нам доведеться перегенерувати:

  • випадаючий список та його вибраний елемент;
  • діалогове вікно, якщо воно було відкрите під час повороту;

2.8.1.1. Проєкт [Exemple-17B]

Ми дублюємо проект [client-android-skel] у папці exemples/Exemple-17B. Потім завантажуємо новий проект [1]:

  • у файл [2-3], що знаходиться в папці [behavior], вставляємо фрагмент [Vue1Fragment] з проекту [Exemple-17];
  • у [4-5], у папці [layout] проекту [Exemple-17B], вставляємо вигляд [vue1.xml] з [Exemple-17]. Це вигляд, пов'язаний із фрагментом;
  • у [6] папку [values] з [Exemple-17B] замінюють на папку [values] з [Exemple-17];

Верхнє поле подання [vue1.xml] буде змінено на 80 dp:


    <TextView
      android:id="@+id/textViewFormulaireTitre"
      android:layout_width="wrap_content"
      android:layout_height="wrap_content"
      android:layout_alignParentLeft="true"
      android:layout_alignParentTop="true"
      android:layout_marginLeft="10dp"
      android:layout_marginTop="80dp"
      android:text="@string/titre_vue1"
android:textSize="30sp"/>

На цьому етапі можна спробувати виконати першу компіляцію, щоб виявити помилки. Перші виявлені помилки пов’язані з imports через зміну розташування пакетів. Їх виправляємо (Ctrl-Shift-O). Інші помилки пов’язані з тим, що представлення [Vue1Fragment] не реалізує всіх методів, які вимагає його батьківський клас [AbstractParent]:

Image

Генеруємо відсутні методи (Alt-Enter).

Ще одна виявлена помилка компіляції така:

Image

Це виправляється у файлі [build.gradle] модуля (рядок 20 нижче):

 

На цьому етапі можна виконати перекомпіляцію, щоб перевірити, чи залишилися помилки. Єдина виявлена помилка стосується методу [Vue1Fragment.updateFragment]:

 

Необхідно видалити анотацію [@Override] із рядка 135. Тепер помилок більше немає. Ми будемо виходити з цього, щоб внести зміни до проєкту.

2.8.1.2. Стан фрагмента [Vue1Fragment]

Фрагмент [Vue1Fragment] потребує збереження інформації під час повороту пристрою, щоб його можна було повністю відновити. Для цього ми створюємо клас [Vue1FragmentState]:

  

Наразі цей клас порожній:


package client.android.fragments.state;

import client.android.architecture.custom.CoreState;

public class Vue1FragmentState extends CoreState {
  
}

2.8.1.3. Налаштування проекту

  

У папці [custom] містяться елементи архітектури, які розробник може налаштовувати на власний розсуд.

Константи інтерфейсу [IMainActivity] будуть такими:


package client.android.architecture.custom;

import client.android.architecture.core.ISession;
import client.android.dao.service.IDao;

public interface IMainActivity extends IDao {

  // доступ до сесії
  ISession getSession();

  // зміна подання
  void navigateToView(int position, ISession.Action action);

  // управління очікуванням
  void beginWaiting();

  void cancelWaiting();

  // константи додатка -------------------------------------

  // режим налагодження
  boolean IS_DEBUG_ENABLED = true;

  // максимальний час очікування відповіді сервера
  int TIMEOUT = 1000;

  // час очікування перед виконанням клієнтського запиту
  int DELAY = 0;

  // базова автентифікація
  boolean IS_BASIC_AUTHENTIFICATION_NEEDED = false;

  // суміжність фрагментів
  int OFF_SCREEN_PAGE_LIMIT = 1;

  // панель вкладок
  boolean ARE_TABS_NEEDED = false;

  // зображення очікування
  boolean IS_WAITING_ICON_NEEDED = false;

  // кількість фрагментів додатка
  int FRAGMENTS_COUNT = 1;

}
  • рядки 24–31: додаток тут не використовує свій рівень [DAO]. Ці константи не будуть використовуватися;
  • рядок 34: сусідність фрагментів, що дорівнює 1, — це значення за замовчуванням. Оскільки додаток має лише один фрагмент (рядок 43), це значення не має значення;
  • рядки 39–40: оскільки операцій із шаром [DAO] немає, зображення очікування не потрібне;
  • рядок 37: це не додаток із вкладками;
  • рядок 43: є лише один фрагмент;

Клас [Session] має такий вигляд:


package client.android.architecture.custom;

import client.android.architecture.core.AbstractSession;

public class Session extends AbstractSession {
  // елементи, які не можна серіалізувати у jSON, повинні мати анотацію @JsonIgnore

}

Він порожній. Дійсно, оскільки є лише один фрагмент, немає необхідності передбачати міжфрагментну комунікацію з сесією.

Нарешті, клас [CoreState] має такий вигляд:


package client.android.architecture.custom;

import client.android.architecture.core.MenuItemState;
import client.android.fragments.state.Vue1FragmentState;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;

@JsonIgnoreProperties(ignoreUnknown = true)
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY)
@JsonSubTypes({
  @JsonSubTypes.Type(value = Vue1FragmentState.class)}
)
public class CoreState {
  // відвіданий чи не відвіданий фрагмент
  protected boolean hasBeenVisited = false;
  // стан можливого меню фрагмента
  protected MenuItemState[] menuOptionsState;

  // гетери та сеттери
...
}
  • рядки 11–13: нам потрібно розмістити всі класи, похідні від [CoreState], які зберігають стан різних фрагментів. Тут є лише один (рядок 12);

2.8.1.4. Активність [MainActivity]

Активність [MainActivity] наразі виглядає так:


package client.android.activity;

import android.util.Log;
import client.android.R;
import client.android.architecture.core.AbstractActivity;
import client.android.architecture.core.AbstractFragment;
import client.android.architecture.custom.Session;
import client.android.dao.service.Dao;
import client.android.dao.service.IDao;
import org.androidannotations.annotations.Bean;
import org.androidannotations.annotations.EActivity;
import org.androidannotations.annotations.OptionsMenu;

@EActivity
@OptionsMenu(R.menu.menu_main)
public class MainActivity extends AbstractActivity {

  // шар [DAO]
  @Bean(Dao.class)
  protected IDao dao;
  // сесія
  private Session session;

  // методи батьківського класу -----------------------
  @Override
  protected void onCreateActivity() {
    // журнал
    if (IS_DEBUG_ENABLED) {
      Log.d(className, "onCreateActivity");
    }
    // сесія
    this.session = (Session) super.session;
    // до зробити: продовжуємо ініціалізацію, розпочату батьківським класом
  }

  @Override
  protected IDao getDao() {
    return dao;
  }

  @Override
  protected AbstractFragment[] getFragments() {
    // до виконання: визначити фрагменти тут
    return new AbstractFragment[0];
  }


  @Override
  protected CharSequence getFragmentTitle(int position) {
    // до зробити: визначити тут заголовки фрагментів
    return null;
  }

  @Override
  protected void navigateOnTabSelected(int position) {
    // завдання: навігація за вкладками — визначити, який вигляд відображати при виборі вкладки № [position]
  }

  @Override
  protected int getFirstView() {
    // до виконання: визначити номер першого виду (фрагмента), який потрібно відобразити
    return 0;
  }
}

Коментарі [//todo] вказують, що має зробити розробник. Клас [MainActivity] змінюється наступним чином:


package client.android.activity;

import android.util.Log;
import client.android.R;
import client.android.architecture.core.AbstractActivity;
import client.android.architecture.core.AbstractFragment;
import client.android.architecture.custom.Session;
import client.android.dao.service.Dao;
import client.android.dao.service.IDao;
import client.android.fragments.behavior.Vue1Fragment_;
import org.androidannotations.annotations.Bean;
import org.androidannotations.annotations.EActivity;
import org.androidannotations.annotations.OptionsMenu;

@EActivity
@OptionsMenu(R.menu.menu_main)
public class MainActivity extends AbstractActivity {

  // шар [DAO]
  @Bean(Dao.class)
  protected IDao dao;
  // сесія
  private Session session;

  // методи батьківського класу -----------------------
  @Override
  protected void onCreateActivity() {
    // журнал
    if (IS_DEBUG_ENABLED) {
      Log.d(className, "onCreateActivity");
    }
    // сесія
    this.session = (Session) super.session;
  }

  @Override
  protected IDao getDao() {
    return dao;
  }

  @Override
  protected AbstractFragment[] getFragments() {
    return new AbstractFragment[]{new Vue1Fragment_()};
  }

  @Override
  protected CharSequence getFragmentTitle(int position) {
    return null;
  }

  @Override
  protected void navigateOnTabSelected(int position) {

  }

  @Override
  protected int getFirstView() {
    return 0;
  }
}

Змінити потрібно лише метод у рядках 41–44. Він повинен повертати масив фрагментів додатка. У рядку 43 не забувайте ставити підкреслення після імені фрагмента.

2.8.1.5. Стан фрагмента [FragmentState]

За результатами тестів ротації, проведених на проєкті [Exemple-17], вирішено зберегти такі елементи фрагмента:

  • список значень випадаючого списку;
  • позицію вибраного елемента в цьому списку;
  • повідомлення, що відображається у діалоговому вікні, якщо воно присутнє на момент обертання;

Клас [Vue1FragmentState] матиме такий вигляд:

  

package client.android.fragments.state;

import client.android.architecture.custom.CoreState;

import java.util.List;

public class Vue1FragmentState extends CoreState {

  // значення зі списку, що розгортається
  private List<String> list;
  // вибраний елемент у випадаючому списку
  private int listSelectedPosition;
  // повідомлення, що відображається у діалоговому вікні
  private String message;

  // методи getter та setter
...
}

2.8.1.6. Фрагмент [AbstractFragment]

Наразі життєвий цикл фрагмента керується двома методами (рядки 6 та 32):


// випадаючий список
  private List<String> list;
  private ArrayAdapter<String> dataAdapter;

  @AfterViews
  void afterViews() {
    // позначено першу кнопку
    radioButton1.setChecked(true);
    // календар
    datePicker1.setCalendarViewShown(false);
    // seekBar
    seekBar.setMax(100);
    seekBar.setOnSeekBarChangeListener(new OnSeekBarChangeListener() {

      public void onStopTrackingTouch(SeekBar seekBar) {
      }

      public void onStartTrackingTouch(SeekBar seekBar) {
      }

      public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
        seekBarValue.setText(String.valueOf(progress));
      }
    });
    // випадаючий список
    list = new ArrayList<>();
    list.add("list 1");
    list.add("list 2");
    list.add("list 3");
  }
...
  protected void updateFragment() {
    // ініціалізація адаптера випадаючого списку
    dataAdapter = new ArrayAdapter<>(activity, android.R.layout.simple_spinner_item, list);
    dataAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
    dropDownList.setAdapter(dataAdapter);
  }

Код цих двох методів буде перенесено до методів, визначених класом [AbstractFragment], таким чином:


// управління життєвим циклом фрагмента ---------------------------------------------------------------------
  @Override
  public CoreState saveFragment() {
    Vue1FragmentState state = new Vue1FragmentState();
    state.setList(list);
    state.setListSelectedPosition(dropDownList.getSelectedItemPosition());
    state.setMessage(message);
    return state;
  }

  @Override
  protected int getNumView() {
    return 0;
  }

  @Override
  protected void initFragment(CoreState previousState) {
    // Перше відвідування?
    if (previousState == null) {
      // створюються значення випадаючого списку
      list = new ArrayList<>();
      list.add("list 1");
      list.add("list 2");
      list.add("list 3");
    } else {
      // відображення значень випадаючого списку
      Vue1FragmentState state = (Vue1FragmentState) previousState;
      list = state.getList();
      // та текст діалогового вікна
      message = state.getMessage();
    }
    // ініціалізація адаптера випадаючого списку
    dataAdapter = new ArrayAdapter<>(activity, android.R.layout.simple_spinner_item, list);
    dataAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
  }

  @Override
  protected void initView(CoreState previousState) {
    // календар
    datePicker1.setCalendarViewShown(false);
    // seekBar
    seekBar.setMax(100);
    seekBar.setOnSeekBarChangeListener(new OnSeekBarChangeListener() {

      public void onStopTrackingTouch(SeekBar seekBar) {
      }

      public void onStartTrackingTouch(SeekBar seekBar) {
      }

      public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
        seekBarValue.setText(String.valueOf(progress));
      }
    });
    // ініціалізація адаптера випадаючого списку
    dropDownList.setAdapter(dataAdapter);
    // Перший візит?
    if (previousState == null) {
      // позначити першу кнопку
      radioButton1.setChecked(true);
    }
  }

  @Override
  protected void updateOnSubmit(CoreState previousState) {

  }

  @Override
  protected void updateOnRestore(CoreState previousState) {
    // значення повзунка
    seekBarValue.setText(String.valueOf(seekBar.getProgress()));
    // вибраний елемент у випадаючому списку
    Vue1FragmentState state = (Vue1FragmentState) previousState;
    dropDownList.setSelection(state.getListSelectedPosition());
    // діалогове вікно видиме?
    if (message != null) {
      // відображається
      showMessage();
    }
  }

  @Override
  protected void notifyEndOfUpdates() {

  }

  @Override
  protected void notifyEndOfTasks(boolean runningTasksHaveBeenCanceled) {

}
  • рядки 2–9: метод [saveFragment] повинен помістити елементи фрагмента, що підлягають збереженню, у клас, похідний від [CoreState], і повернути екземпляр цього класу;
  • рядки 11–14: метод [getNumView] повинен повернути номер фрагмента. У даному випадку є лише один фрагмент із номером 0;
  • рядки 16–34: метод [initFragment] повинен ініціалізувати поля фрагмента. Він отримує попередній стан фрагмента. Якщо [previousState] дорівнює null, то це перший візит;
  • рядки 19–25: під час першого відвідування створюються значення списку, що розгортається;
  • рядки 26–30: якщо це не перший візит, поля [list, message] фрагмента відновлюються з попереднього стану;
  • рядки 33–34: ініціалізація поля [dataAdapter] фрагмента. Це джерело даних для випадаючого списку;
  • рядки 37–62: метод [initView] слугує для ініціалізації компонентів візуального інтерфейсу. Він отримує як параметр попередній стан [previousState]. Якщо [previousState==null], то це перший візит;
  • тут міститься те, що раніше було в методі [@AfterViews];
  • рядки 57–61: під час першого відвідування перевіряється, чи позначено першу перемикальну кнопку;
  • рядки 64–67: метод [updateOnSubmit] виконується, коли поточна дія — [SUBMIT]. Тут немає навігації між фрагментами, а отже, немає поточної дії;
  • рядки 69–81: метод [updateOnRestore] виконується, коли поточним дією є [NAVIGATION] або [RESTORE]. Тут немає навігації між фрагментами, а отже, дія [NAVIGATION] неможлива;
  • рядок 72: перераховується (а не відновлюється) значення TextView seekBarValue. Адже під час обертань його значення іноді втрачалося;
  • рядки 74–75: список позиціонується на елементі, який був вибраний до обертання. Без цього список позиціонувався на першому елементі;
  • рядки 76–80: знову відображається діалогове вікно, якщо повідомлення попереднього стану не є null. Ми ще повернемося до методу [showMessage] (рядок 79);
  • рядки 83–86: метод [notifyEndOfUpdates] є останнім методом, який викликає батьківський клас перед тим, як залишити дочірній фрагмент у спокої. Тут нічого робити не потрібно;
  • рядки 88–91: метод [notifyEndOfTasks] сигналізує про завершення асинхронних завдань, запущених фрагментом. Тут їх немає;

Відновлення діалогового вікна відбувається наступним чином:


  // повідомлення діалогового вікна
  private String message;
...
  @Click(R.id.formulaireButtonValider)
  protected void doValider() {
    // список повідомлень для відображення
    List<String> messages = new ArrayList<>();
    ...
    // відображення
    doAfficher(messages);
  }

  private void doAfficher(final List<String> messages) {
    // формується текст для відображення
    StringBuilder texte = new StringBuilder();
    for (String message : messages) {
      texte.append(String.format("%s\n", message));
    }
    // зберігання повідомлення
    message = texte.toString();
    // відображення
    showMessage();
  }

  private void showMessage() {
    // відображення
    new AlertDialog.Builder(activity).setTitle("Valeurs saisies").setMessage(message).setNeutralButton("Fermer", new DialogInterface.OnClickListener() {
      @Override
      public void onClick(DialogInterface dialog, int which) {
        // скидання повідомлення
        message = null;
      }
    }).show();
}

Коли користувач підтверджує форму, метод [doValider] (рядок 5) формує список повідомлень, який потім відображається (рядок 10) у діалоговому вікні.

  • рядки 14–20: список повідомлень об’єднується в одне повідомлення, яке зберігається в рядку 2;
  • рядки 25–33: саме це повідомлення відображається у діалоговому вікні, і саме це повідомлення метод [updateOnRestore] виводить на екран;
  • рядок 27: другим параметром методу [setNeutralButton] є метод, що виконується, коли користувач натискає кнопку [Fermer] у діалоговому вікні;
  • рядок 31: після закриття діалогового вікна повідомлення передається до null, щоб вказати, що діалогове вікно більше не існує;

2.8.1.7. Tests

Читачеві пропонується протестувати цей проект і перевірити, чи фрагмент зберігається після одного або декількох послідовних обертань.

2.8.2. Приклад-23: клієнт погоди

Деякі сайти надають інформацію про погоду у вигляді рядків у форматі jSON. Ось приклад:

Image

URL має такий вигляд: http://api.openweathermap.org/data/2.5/weather?q={city},{country}&APPID={APPID}, де:

  • city — місто, для якого потрібно отримати прогноз погоди, у даному випадку Анже;
  • country — країна, в якій розташоване місто, у даному випадку Франція (fr);
  • APPID — ключ, отриманий під час реєстрації на сайті [https://home.openweathermap.org/users/sign_up];

2.8.2.1. Проект

  

Проєкт створено на основі проєкту [client-android-skel]. Він має такі особливості:

  • він має лише один фрагмент, стан якого не потрібно зберігати;
  • він виконує асинхронні запити;

2.8.2.2. Налаштування проекту

  

Інтерфейс [IMainActivity] дозволяє вказати певні характеристики проекту:


package client.android.architecture.custom;

import client.android.architecture.core.ISession;
import client.android.dao.service.IDao;

public interface IMainActivity extends IDao {

  // вхід у сесію
  ISession getSession();

  // зміна виду
  void navigateToView(int position, ISession.Action action);

  // управління чергою
  void beginWaiting();

  void cancelWaiting();

  // константи програми -------------------------------------

  // режим налагодження
  boolean IS_DEBUG_ENABLED = true;

  // максимальний час очікування відповіді сервера
  int TIMEOUT = 1000;

  // час очікування перед виконанням клієнтського запиту
  int DELAY = 5000;

  // базова автентифікація
  boolean IS_BASIC_AUTHENTIFICATION_NEEDED = false;

  // суміжність фрагментів
  int OFF_SCREEN_PAGE_LIMIT = 1;

  // панель вкладок
  boolean ARE_TABS_NEEDED = false;

  // зображення очікування
  boolean IS_WAITING_ICON_NEEDED = true;

  // кількість фрагментів додатка
  int FRAGMENTS_COUNT = 1;

}
  • рядки 25, 28, 31, 40: характеристики шару [DAO]. У рядку 31 не потрібна базова автентифікація;
  • рядок 34: суміжність фрагментів. У даному випадку ця константа не має значення, оскільки є лише один фрагмент;
  • рядок 37: це не додаток із вкладками;
  • рядок 43: є лише один фрагмент;

Клас [CoreState], який зберігає стан фрагментів, матиме такий вигляд:


package client.android.architecture.custom;

import client.android.architecture.core.MenuItemState;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonTypeInfo;

@JsonIgnoreProperties(ignoreUnknown = true)
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY)
// завдання: додати сюди підкласи [CoreState]
/*@JsonSubTypes({
  @JsonSubTypes.Type(value = Class1.class),
  @JsonSubTypes.Type(value = Class2.class)}
)*/
public class CoreState {
  // фрагмент відвідано чи ні
  protected boolean hasBeenVisited = false;
  // стан можливого меню фрагмента
  protected MenuItemState[] menuOptionsState;

  // гетери та сеттери
...
}
  • рядки 10–13: нічого не потрібно оголошувати, оскільки в цьому додатку є лише один фрагмент, стан якого не зберігається;

Клас [Session] має такий вигляд:


package client.android.architecture.custom;

import client.android.architecture.core.AbstractSession;

public class Session extends AbstractSession {
  // елементи, які не можуть бути серіалізовані в jSON, повинні мати анотацію @JsonIgnore
}

Він порожній, оскільки в цьому додатку немає міжфрагментної комунікації.

2.8.2.3. Рівень [DAO]

  

У шарі [DAO] необхідно налаштувати три класи:

  • інтерфейс IDao;
  • його реалізація Dao;
  • інтерфейс WebClient для взаємодії з веб-сервером / jSON;

Інтерфейс [WebClient] матиме такий вигляд:


package client.android.dao.service;

import org.androidannotations.rest.spring.annotations.Get;
import org.androidannotations.rest.spring.annotations.Path;
import org.androidannotations.rest.spring.annotations.Rest;
import org.androidannotations.rest.spring.api.RestClientRootUrl;
import org.androidannotations.rest.spring.api.RestClientSupport;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.web.client.RestTemplate;

@Rest(converters = {MappingJackson2HttpMessageConverter.class})
public interface WebClient extends RestClientRootUrl, RestClientSupport {

  // RestTemplate
  void setRestTemplate(RestTemplate restTemplate);

  // метеорологічна служба
  @Get("/data/2.5/weather?q={city},{country}&APPID={APPID}")
  String getWeatherForecast(@Path String city, @Path String country, @Path String APPID);
}
  • рядки 18–19: URL служби погоди. Нагадаємо, що він пов'язаний з кореневим URL (RestClientRootUrl, рядок 12) клієнта. У даному випадку цим кореневим URL буде [http://api.openweathermap.org/];

Інтерфейс [IDao] матиме такий вигляд:


package client.android.dao.service;

import rx.Observable;

public interface IDao {
  // URL веб-сервісу
  void setUrlServiceWebJson(String url);

  // користувач
  void setUser(String user, String mdp);

  // час очікування клієнта
  void setTimeout(int timeout);

  // базова автентифікація
  void setBasicAuthentification(boolean isBasicAuthentificationNeeded);

  // режим налагодження
  void setDebugMode(boolean isDebugEnabled);

  // Час очікування клієнта перед надсиланням запиту (у мілісекундах)
  void setDelay(int delay);

  //  метеорологічна служба
  Observable<String> getWeatherForecast(String city, String country, String APPID);
}
  • нагадуємо, що методи з рядків 6–22 за замовчуванням присутні в інтерфейсі IDao проекту [client-android-skel];
  • рядок 25: метод [getWeatherForecast] дозволяє отримати рядок jSON з прогнозом погоди для міста [city] у країні [country]. Третім параметром є ключ, отриманий на сайті [https://home.openweathermap.org/users/sign_up];

Інтерфейс [IDao] реалізовано за допомогою наступного класу [Dao]:


package client.android.dao.service;

import android.util.Log;
import org.androidannotations.annotations.AfterInject;
import org.androidannotations.annotations.Bean;
import org.androidannotations.annotations.EBean;
import org.androidannotations.rest.spring.annotations.RestService;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.web.client.RestTemplate;
import rx.Observable;

import java.util.ArrayList;
import java.util.List;

@EBean(scope = EBean.Scope.Singleton)
public class Dao extends AbstractDao implements IDao {

  // клієнт веб-сервісу
  @RestService
  protected WebClient webClient;
  // безпека
  @Bean
  protected MyAuthInterceptor authInterceptor;
  // RestTemplate
  private RestTemplate restTemplate;
  // фабрика RestTemplate
  private SimpleClientHttpRequestFactory factory;
  // тайм-аут
  private int timeout;

  @AfterInject
  public void afterInject() {
    // журнал
    Log.d(className, "afterInject");
    // будується restTemplate
    factory = new SimpleClientHttpRequestFactory();
    restTemplate = new RestTemplate(factory);
    // задається конвертер jSON
    restTemplate.getMessageConverters().add(new MappingJackson2HttpMessageConverter());
    // встановлюємо restTemplate веб-клієнта
    webClient.setRestTemplate(restTemplate);
  }

  @Override
  public void setUrlServiceWebJson(String url) {
    // встановлюємо URL веб-сервісу
    webClient.setRootUrl(url);
  }

  @Override
  public void setUser(String user, String mdp) {
    // реєструємо користувача в перехоплювачі
    authInterceptor.setUser(user, mdp);
  }

  @Override
  public void setTimeout(int timeout) {
    if (isDebugEnabled) {
      Log.d(className, String.format("setTimeout thread=%s, timeout=%s", Thread.currentThread().getName(), timeout));
    }
    // пам'ять
    this.timeout = timeout;
    // фабрика конфігурації
    factory.setReadTimeout(timeout);
    factory.setConnectTimeout(timeout);
  }

  @Override
  public void setBasicAuthentification(boolean isBasicAuthentificationNeeded) {
    if (isDebugEnabled) {
      Log.d(className, String.format("setBasicAuthentification thread=%s, isBasicAuthentificationNeeded=%s", Thread.currentThread().getName(), isBasicAuthentificationNeeded));
    }
    // інтерцептор автентифікації?
    if (isBasicAuthentificationNeeded) {
      // додається перехоплювач автентифікації
      List<ClientHttpRequestInterceptor> interceptors = new ArrayList<ClientHttpRequestInterceptor>();
      interceptors.add(authInterceptor);
      restTemplate.setInterceptors(interceptors);
    }
  }


  // приватні методи -------------------------------------------------
  private void log(String message) {
    if (isDebugEnabled) {
      Log.d(className, message);
    }
  }

  // метеорологічна служба ---------------------------------------------------------
  @Override
  public Observable<String> getWeatherForecast(final String city, final String country, final String APPID) {
    // журнал
    if (isDebugEnabled) {
      Log.d(className, String.format("getWeatherForecast city=%s, country=%s, APIID=%s, thread=%s, timeout=%s", city, country, APPID, Thread.currentThread().getName(), timeout));
    }
    // результат
    return getResponse(new IRequest<String>() {
      @Override
      public String getResponse() {
        return webClient.getWeatherForecast(city, country, APPID);
      }
    });
  }
}
  • нагадаємо, що рядки 17–90 за замовчуванням присутні у класі [Dao] проекту [client-android-skel]. Потрібно лише додати методи реалізації інтерфейсу [IDao], специфічні для даного додатка (рядок 92);
  • рядки 93–105: реалізація методу [getWeatherForecast]. Вона дуже проста і займає 6 рядків, рядки 100–105;
  • рядок 100: метод [getResponse] є методом батьківського класу [AbstractDao]. Він очікує параметр типу [IRequest<T>], де T — тип очікуваної відповіді від сервера, у даному випадку String, оскільки очікується рядок типу jSON. Тип T для [IRequest<T>] повинен відповідати типу T методу [Observable<T> getWeatherForecast];
  • інтерфейс [IRequest<T>] має лише один метод: getResponse. Його роль полягає в наданні відповіді типу T, яку повинен повернути метод [Observable<T> getWeatherForecast];
  • рядок 103: саме інтерфейс [WebClient] надає цю відповідь. Йому передаються три параметри, отримані в рядку 94. З цієї причини вони повинні мати атрибут final;

2.8.2.4. Активність [MainActivity]

  

Активність [MainActivity] виглядає наступним чином:


package client.android.activity;

import android.util.Log;
import client.android.R;
import client.android.architecture.core.AbstractActivity;
import client.android.architecture.core.AbstractFragment;
import client.android.dao.service.Dao;
import client.android.dao.service.IDao;
import client.android.fragments.behavior.MeteoFragment_;
import org.androidannotations.annotations.Bean;
import org.androidannotations.annotations.EActivity;
import org.androidannotations.annotations.OptionsMenu;
import rx.Observable;

@EActivity
@OptionsMenu(R.menu.menu_main)
public class MainActivity extends AbstractActivity {

  // шар [DAO]
  @Bean(Dao.class)
  protected IDao dao;

  // методи батьківського класу -----------------------
  @Override
  protected void onCreateActivity() {
    // журнал
    if (IS_DEBUG_ENABLED) {
      Log.d(className, "onCreateActivity");
    }
  }

  @Override
  protected IDao getDao() {
    return dao;
  }

  @Override
  protected AbstractFragment[] getFragments() {
    return new AbstractFragment[]{new MeteoFragment_()};
  }


  @Override
  protected CharSequence getFragmentTitle(int position) {
    return null;
  }

  @Override
  protected void navigateOnTabSelected(int position) {
  }

  @Override
  protected int getFirstView() {
    return 0;
  }

  // інтерфейс IDao ---------------------------------------------------------------------
  @Override
  public Observable<String> getWeatherForecast(String city, String country, String APPID) {
    return dao.getWeatherForecast(city, country, APPID);
  }
}
  • нагадуємо, що рядки 15–55 за замовчуванням присутні у проєкті [client-android-skel]. Їх потрібно лише налаштувати;
  • рядки 37–40: таблиця фрагментів. Тут є лише один;
  • рядки 43–46: заголовки фрагментів не потрібні;
  • рядки 48–50: тут немає вкладок;
  • рядки 52–55: першим для відображення є вигляд № 0, а саме вигляд [MeteoFragment];
  • рядки 58–61: реалізація інтерфейсу [IDao]. Тут нічого іншого не потрібно робити, крім як делегувати роботу шару [DAO] із рядка 21;

2.8.2.5. Фрагмент [MeteoFragment]

  

Фрагмент [MeteoFragment] надсилає запит до веб-сервісу / jSON з прогнозом погоди. Його структура така:


package client.android.fragments;

import android.util.Log;
import android.widget.Toast;
import client.android.R;
import client.android.architecture.AbstractFragment;
import client.android.architecture.MenuItemState;
import org.androidannotations.annotations.EFragment;
import org.androidannotations.annotations.OptionsItem;
import org.androidannotations.annotations.OptionsMenu;
import rx.functions.Action0;
import rx.functions.Action1;

@EFragment(R.layout.meteo_fragment)
@OptionsMenu(R.menu.menu_meteo)
public class FirstFragment extends AbstractFragment {
...
}
  • рядок 14: вигляд [res / layout / meteo_fragment.xml] такий:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
                android:layout_width="match_parent"
                android:layout_height="match_parent">

  <TextView
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:textAppearance="?android:attr/textAppearanceLarge"
    android:text="Construisez votre interface visuelle"
    android:id="@+id/textView" android:layout_alignParentTop="true" android:layout_alignParentLeft="true"
    android:layout_alignParentStart="true" android:layout_marginLeft="64dp" android:layout_marginStart="64dp"
    android:layout_marginTop="120dp"/>
</RelativeLayout>

У цьому вигляді відображається лише текст рядка 10;

  • рядок 15: меню [res / menu / menu_meteo.xml] має такий вигляд:

<menu xmlns:android="http://schemas.android.com/apk/res/android"
      xmlns:app="http://schemas.android.com/apk/res-auto"
      xmlns:tools="http://schemas.android.com/tools"
      tools:context=".activity.MainActivity">
  <item
    android:id="@+id/menuActions"
    app:showAsAction="ifRoom"
    android:title="@string/menuActions">
    <menu>
      <item
        android:id="@+id/actionMeteo"
        android:title="@string/actionMeteo"/>
      <item
        android:id="@+id/actionAnnuler"
        android:title="@string/actionAnnuler"/>
      <item
        android:id="@+id/actionTerminer"
        android:title="@string/actionTerminer"/>
    </menu>
  </item>
</menu>
  • рядки 10–12: ця опція меню призначена для запиту про погоду в місті;
  • рядки 14–15: цей пункт меню призначений для скасування запиту, якщо він уже виконується;
  • рядки 16–18: цей пункт меню завершує роботу програми;

Повний код фрагмента виглядає так:


package client.android.fragments.behavior;

import android.util.Log;
import android.widget.Toast;
import client.android.R;
import client.android.architecture.core.AbstractFragment;
import client.android.architecture.core.MenuItemState;
import client.android.architecture.custom.CoreState;
import org.androidannotations.annotations.EFragment;
import org.androidannotations.annotations.OptionsItem;
import org.androidannotations.annotations.OptionsMenu;
import rx.functions.Action1;

@EFragment(R.layout.meteo_fragment)
@OptionsMenu(R.menu.menu_meteo)
public class MeteoFragment extends AbstractFragment {

  // локальні дані
  private int nbReponsesRecues;

  // управління подіями ---------------------------------------------------------------------------------------
  // міста, для яких потрібна інформація про погоду
  final String[] paysDeLoire = new String[]{"angers", "le mans", "nantes", "laval", "la roche sur yon"};

  @OptionsItem(R.id.actionMeteo)
  protected void doMeteo() {
    // свою країну
    String country = "fr";
    // отримайте ідентифікатор API, створивши обліковий запис [https://home.openweathermap.org/users/sign_up]
    String APPID = "xyz";
    // URL веб-сервісу / jSON
    mainActivity.setUrlServiceWebJson("http://api.openweathermap.org");
    // початок очікування асинхронних завдань [paysDeLoire.length]
    beginWaiting(paysDeLoire.length);
    // кількість отриманих відповідей
    nbReponsesRecues = 0;
    // асинхронні виклики виконуються паралельно
    for (String city : paysDeLoire) {
      // прогноз погоди
      executeInBackground(mainActivity.getWeatherForecast(city, country, APPID), new Action1<String>() {
        @Override
        public void call(String response) {
          // обробка відповіді
          consumeResponse(response);
          // позитивна відповідь
          nbReponsesRecues++;
        }
      });
    }
  }

  // обробка відповіді сервера
  private void consumeResponse(String response) {
    // журнал
    Log.d(className, String.format("thread=%s, response=%s", Thread.currentThread().getName(), response));
  }

  // початок очікування
  protected void beginWaiting(int numberOfRunningTasks) {
    // журнал
    if (isDebugEnabled) {
      Log.d(className, "beginWaiting");
    }
    // батьківський елемент
    beginRunningTasks(numberOfRunningTasks);
    // відображається опція [Annuler]
    setAllMenuOptionsStates(false);
    setMenuOptionsStates(new MenuItemState[]{
      new MenuItemState(R.id.menuActions, true),
      new MenuItemState(R.id.actionAnnuler, true)});

  }

  @Override
  protected void notifyEndOfTasks(boolean runningTasksHaveBeenCanceled) {
    // меню
    initMenu();
    // відображення результатів
    String message;
    switch (nbReponsesRecues) {
      case 0:
        message = "Aucune réponse n'a été reçue";
        break;
      case 1:
        message = "Une réponse a été reçue. Consultez vos logs...";
        break;
      default:
        message = String.format("%s réponses ont été reçues. Consultez vos logs...", nbReponsesRecues);
        break;
    }
    Toast.makeText(activity, message, Toast.LENGTH_SHORT).show();
  }

  // приватні методи -----------------------------------
  private void initMenu() {
    if (isDebugEnabled) {
      Log.d(className, "initMenu");
    }
    // меню
    setAllMenuOptionsStates(true);
    setMenuOptionsStates(new MenuItemState[]{new MenuItemState(R.id.actionAnnuler, false)});
  }

  // управління життєвим циклом ---------------------------------------------------------------------------------------
...
}
  • рядки 25–50: обробка кліка на пункт меню [Météo];
  • рядок 32: формування запиту URL до веб-сервісу / jSON до метеосервісу. Потім цей запит передається на рівень [DAO] через активність;
  • рядок 34: починаємо очікування. Передаємо кількість завдань, які будуть запущені, щоб батьківський клас міг повідомити нам про їх завершення. Тут є п’ять завдань, оскільки ми будемо запитувати прогноз погоди для п’яти міст із рядка 23;
  • рядок 16: підраховуємо кількість отриманих відповідей, щоб мати змогу їх відобразити;
  • рядки 38–50: виконуємо цикл по містах, для яких потрібно отримати прогноз погоди;
  • рядок 40: ми будемо паралельно виконувати 5 запитів HTTP;
  • рядок 40: ми просимо батьківський клас [AbstractParent] звернутися до веб-сервісу /jSON;
  • рядки 40–48: метод [executeInBackground] очікує два параметри:
    • рядок 40: процес, який потрібно спостерігати та виконати, надається методом [mainActivity.getWeatherForecast];
    • рядки 40–48: екземпляр [Action1], який має бути виконаний після отримання відповіді від асинхронного сервісу. Тип T для [Action1<T>] має відповідати типу T результату методу [getWeatherForecast];
  • рядок 44: отримано відповідь. Її передають до методу [consumeResponse] у рядку 53;
  • рядок 46: збільшується лічильник отриманих відповідей;
  • рядки 53–56: обробка відповіді jSON від метеорологічної служби;
  • рядок 55: просто записуємо рядок jSON у журнал;
  • рядки 59–72: код, що виконується перед запуском асинхронних завдань;
  • рядок 65: передаємо кількість завдань, що мають бути виконані, батьківському класу [AbstractParent]. Це дозволяє йому повідомити нас, коли всі завдання будуть завершені;
  • рядки 67–70: підготовка меню до очікування. Залишаємо лише опцію [Actions/Annuler], яка дозволить користувачеві скасувати запущені завдання;
  • рядки 74–92: код, що виконується, коли батьківський клас повідомляє, що всі запущені завдання завершені;
  • рядок 77: повертаємо меню до початкового стану. Метод [initMenu] (рядки 95–102) відображає меню з усіма його опціями, крім опції [Actions/Annuler], яка прихована;
  • рядки 80–91: відображається кількість отриманих відповідей;

Натискання на пункт меню [Annuler] обробляється за допомогою такого коду:


  @OptionsItem(R.id.actionAnnuler)
  protected void doAnnuler() {
    if (isDebugEnabled) {
      Log.d(className, "Annulation demandée");
    }
    // скасування асинхронних завдань
    cancelRunningTasks();
}
  • рядок 7: надсилається запит до батьківського класу на скасування завдань, що все ще активні;

Клік на пункт меню [Terminer] обробляється наступним кодом:


  @OptionsItem(R.id.actionTerminer)
  protected void doTerminer() {
    // зупинка всіх процесів
    System.exit(0);
}

Управління життєвим циклом фрагмента забезпечується такими методами:


  // управління життєвим циклом ---------------------------------------------------------------------------------------

  @Override
  public CoreState saveFragment() {
    return new CoreState();
  }

  @Override
  protected int getNumView() {
    return 0;
  }

  @Override
  protected void initFragment(CoreState previousState) {

  }

  @Override
  protected void initView(CoreState previousState) {
    // Перший візит?
    if (previousState == null) {
      initMenu();
    }
  }


  @Override
  protected void updateOnSubmit(CoreState previousState) {

  }

  @Override
  protected void updateOnRestore(CoreState previousState) {

  }

  @Override
  protected void notifyEndOfUpdates() {

}
  • рядки 3–6: слугують для збереження стану фрагмента у класі, похідному від [CoreState]. Якщо фрагмент не має стану, який потрібно зберегти, як у цьому випадку, достатньо просто повернути екземпляр класу [CoreState]. Не слід повертати null, оскільки це згодом призведе до збою;
  • рядки 8–11: повинні повертати номер подання. Тут фрагмент [MeteoFragment] має номер 0;
  • рядки 13–16: слугують для ініціалізації фрагмента після його побудови (previousState == null) або перебудови (previousState != null). Тут нічого робити не потрібно. Єдине поле, яке може потребувати ініціалізації, — це таке:

  // міста, для яких потрібна інформація про погоду
final String[] paysDeLoire = new String[]{"angers", "le mans", "nantes", "laval", "la roche sur yon"};

але воно ініціалізується самостійно;

  • рядки 18–24: слугують для ініціалізації подання, пов’язаного з фрагментом, після того, як воно було побудовано (previousState==null) або перебудовано (previousState!=null);
  • рядки 21–23: якщо це перший візит до фрагмента, ініціалізується його меню, щоб приховати опцію [Annuler];
  • рядки 27–30: викликаються, якщо для переходу до фрагмента відбувалася навігація за допомогою дії типу [SUBMIT]. У цьому випадку міжфрагментної навігації немає, оскільки є лише один фрагмент;
  • рядки 32–35: викликаються під час циклу збереження/відновлення через поворот пристрою або з іншої причини. Тут, оскільки стан не зберігався, нічого робити не потрібно;
  • рядки 37–40: викликаються після виконання всіх попередніх оновлень. Тут нічого робити не потрібно;

2.8.2.6. Tests

Тепер запустимо приклад:

Image

Image

Журнали мають такий вигляд:


07-23 13:24:30.899 2642-2642/client.android D/MainActivity_: constructeur
07-23 13:24:30.945 2642-2642/client.android D/AbstractDao: constructeur, thread=main
07-23 13:24:32.861 2642-2642/client.android D/client.android.dao.service.Dao_: afterInject
07-23 13:24:32.950 2642-2642/client.android D/MainActivity_: onCreate
07-23 13:24:32.951 2642-2642/client.android D/client.android.dao.service.Dao_: setTimeout thread=main, timeout=1000
07-23 13:24:32.952 2642-2642/client.android D/client.android.dao.service.Dao_: setBasicAuthentification thread=main, isBasicAuthentificationNeeded=false
07-23 13:24:33.041 2642-2642/client.android D/MainActivity_: adding loadingPanel
07-23 13:24:33.043 2642-2642/client.android D/MeteoFragment_: constructeur
07-23 13:24:33.044 2642-2642/client.android D/MainActivity_: navigation vers vue 0 sur action NONE
07-23 13:24:33.044 2642-2642/client.android D/MainActivity_: onCreateActivity
07-23 13:24:33.080 2642-2642/client.android D/MainActivity_: onResume
07-23 13:24:33.325 2642-2642/client.android D/MeteoFragment_: onActivityCreated
07-23 13:24:33.518 2642-2642/client.android D/MeteoFragment_: onCreateOptionsMenu
07-23 13:24:33.518 2642-2642/client.android D/MeteoFragment_: getMenuOptionsStates(Menu)
07-23 13:24:33.519 2642-2642/client.android D/MeteoFragment_: Nombre d'options de menu=4
07-23 13:24:33.519 2642-2642/client.android D/MeteoFragment_: initFragment initView updateForFirstVisit
07-23 13:24:33.519 2642-2642/client.android D/MeteoFragment_: initMenu
07-23 13:24:33.557 2642-2642/client.android D/MeteoFragment_: session={"action":"NONE","coreStates":[{"@type":"CoreState","hasBeenVisited":false,"menuOptionsState":null}],"previousTab":0,"previousView":0}
07-23 13:24:33.557 2642-2642/client.android D/MeteoFragment_: état précédent=null
07-23 13:24:33.558 2642-2642/client.android D/MeteoFragment_: notifyEndOfUpdates
07-23 13:24:39.766 2642-2642/client.android D/MeteoFragment_: beginWaiting
07-23 13:24:39.831 2642-2642/client.android D/client.android.dao.service.Dao_: getWeatherForecast city=angers, country=fr, APIID=aa6bb491c9a16810c4f0881f17e888c7, thread=main, timeout=1000
07-23 13:24:39.831 2642-2642/client.android D/client.android.dao.service.Dao_: delay=5000
07-23 13:24:39.882 2642-2642/client.android D/client.android.dao.service.Dao_: getWeatherForecast city=le mans, country=fr, APIID=aa6bb491c9a16810c4f0881f17e888c7, thread=main, timeout=1000
07-23 13:24:39.882 2642-2642/client.android D/client.android.dao.service.Dao_: delay=5000
07-23 13:24:39.885 2642-2642/client.android D/client.android.dao.service.Dao_: getWeatherForecast city=nantes, country=fr, APIID=aa6bb491c9a16810c4f0881f17e888c7, thread=main, timeout=1000
07-23 13:24:39.885 2642-2642/client.android D/client.android.dao.service.Dao_: delay=5000
07-23 13:24:39.886 2642-2642/client.android D/client.android.dao.service.Dao_: getWeatherForecast city=laval, country=fr, APIID=aa6bb491c9a16810c4f0881f17e888c7, thread=main, timeout=1000
07-23 13:24:39.886 2642-2642/client.android D/client.android.dao.service.Dao_: delay=5000
07-23 13:24:39.887 2642-2642/client.android D/client.android.dao.service.Dao_: getWeatherForecast city=la roche sur yon, country=fr, APIID=aa6bb491c9a16810c4f0881f17e888c7, thread=main, timeout=1000
07-23 13:24:39.887 2642-2642/client.android D/client.android.dao.service.Dao_: delay=5000
07-23 13:24:45.035 2642-2961/client.android D/client.android.dao.service.Dao_: response={"coord":{"lon":-1.55,"lat":47.22},"weather":[{"id":800,"main":"Clear","description":"clear sky","icon":"01d"}],"base":"cmc stations","main":{"temp":298.05,"pressure":1022,"humidity":47,"temp_min":297.15,"temp_max":299.15},"wind":{"speed":2.6,"deg":310},"clouds":{"all":0},"dt":1469277000,"sys":{"type":1,"id":5641,"message":0.0032,"country":"FR","sunrise":1469248505,"sunset":1469303378},"id":2990969,"name":"Nantes","cod":200} sur thread [RxIoScheduler-4]
07-23 13:24:45.035 2642-2963/client.android D/client.android.dao.service.Dao_: response={} sur thread [RxIoScheduler-6]
07-23 13:24:45.035 2642-2959/client.android D/client.android.dao.service.Dao_: response={} sur thread [RxIoScheduler-2]
07-23 13:24:45.035 2642-2962/client.android D/client.android.dao.service.Dao_: response={} sur thread [RxIoScheduler-5]
07-23 13:24:45.036 2642-2960/client.android D/client.android.dao.service.Dao_: response={} sur thread [RxIoScheduler-3]
07-23 13:24:45.039 2642-2642/client.android D/MeteoFragment_: thread=main, response={"coord":{"lon":-1.55,"lat":47.22},"weather":[{"id":800,"main":"Clear","description":"clear sky","icon":"01d"}],"base":"cmc stations","main":{"temp":298.05,"pressure":1022,"humidity":47,"temp_min":297.15,"temp_max":299.15},"wind":{"speed":2.6,"deg":310},"clouds":{"all":0},"dt":1469277000,"sys":{"type":1,"id":5641,"message":0.0032,"country":"FR","sunrise":1469248505,"sunset":1469303378},"id":2990969,"name":"Nantes","cod":200}
07-23 13:24:45.039 2642-2642/client.android D/MeteoFragment_: thread=main, response={}
07-23 13:24:45.039 2642-2642/client.android D/MeteoFragment_: thread=main, response={}
07-23 13:24:45.039 2642-2642/client.android D/MeteoFragment_: thread=main, response={}
07-23 13:24:45.039 2642-2642/client.android D/MeteoFragment_: thread=main, response={}
07-23 13:24:45.039 2642-2642/client.android D/MeteoFragment_: initMenu
  • рядки 32–36: відповіді jSON отримуються у потоках вводу-виводу
  • рядки 37–41: фрагмент отримує 5 відповідей у потоці інтерфейсу користувача;

Тепер ми надсилаємо запит із неправильним ідентифікатором API:


    String APIID = "";

Image

Логи виглядають наступним чином:


07-23 13:34:43.853 11240-11240/client.android D/MeteoFragment_: beginWaiting
...
07-23 13:34:49.121 11240-11464/client.android D/client.android.dao.service.Dao_: Thread [RxIoScheduler-2], Exception communication avec serveur : [org.springframework.web.client.HttpClientErrorException,["401 Unauthorized"]]
07-23 13:34:49.121 11240-11466/client.android D/client.android.dao.service.Dao_: Thread [RxIoScheduler-4], Exception communication avec serveur : [org.springframework.web.client.HttpClientErrorException,["401 Unauthorized"]]
07-23 13:34:49.162 11240-11468/client.android D/client.android.dao.service.Dao_: Thread [RxIoScheduler-6], Exception communication avec serveur : [org.springframework.web.client.HttpClientErrorException,["401 Unauthorized"]]
07-23 13:34:49.162 11240-11467/client.android D/client.android.dao.service.Dao_: Thread [RxIoScheduler-5], Exception communication avec serveur : [org.springframework.web.client.HttpClientErrorException,["401 Unauthorized"]]
07-23 13:34:49.163 11240-11240/client.android D/MeteoFragment_: Exception reçue
07-23 13:34:49.163 11240-11240/client.android D/MeteoFragment_: Annulation des tâches lancées
07-23 13:34:49.163 11240-11240/client.android D/MeteoFragment_: initMenu
07-23 13:34:49.167 11240-11465/client.android D/client.android.dao.service.Dao_: Thread [RxIoScheduler-3], Exception communication avec serveur : [org.springframework.web.client.HttpClientErrorException,["401 Unauthorized"]]
  • рядки 3–6, 10: 5 викликів HTTP спричинили 5 винятків;
  • рядок 7: фрагмент [MeteoFragment] отримує перше виключення. Після цього він скасує всі завдання;

Тепер встановимо час очікування 5 секунд [IMainActivity.DELAY] і скасуємо операцію. У цьому випадку журнали матимуть такий вигляд:


07-21 13:16:20.329 20390-20390/client.android D/MeteoFragment_: beginWaiting
...
07-21 13:16:23.635 20390-20390/client.android D/MeteoFragment_: Annulation demandée
07-21 13:16:23.635 20390-20390/client.android D/MeteoFragment_: Annulation des tâches lancées
07-21 13:16:23.635 20390-20390/client.android D/MeteoFragment_: initMenu
07-21 13:25:02.948 29965-30197/client.android D/client.android.dao.service.Dao_: Thread [RxIoScheduler-6], Exception communication avec serveur : [java.lang.InterruptedException,[null]]
07-21 13:25:02.948 29965-30195/client.android D/client.android.dao.service.Dao_: Thread [RxIoScheduler-4], Exception communication avec serveur : [java.lang.InterruptedException,[null]]
07-21 13:25:02.948 29965-30194/client.android D/client.android.dao.service.Dao_: Thread [RxIoScheduler-3], Exception communication avec serveur : [java.lang.InterruptedException,[null]]
07-21 13:25:02.951 29965-30193/client.android D/client.android.dao.service.Dao_: Thread [RxIoScheduler-2], Exception communication avec serveur : [java.lang.InterruptedException,[null]]
07-21 13:25:02.951 29965-30196/client.android D/client.android.dao.service.Dao_: Thread [RxIoScheduler-5], Exception communication avec serveur : [java.lang.InterruptedException,[null]]
  • рядок 3: запит на скасування;
  • рядок 4: очікування скасовано, оскільки відбулося скасування;
  • рядки 6–10: скасування завдань спричиняє виникнення винятку в кожному з потоків п’яти завдань. Тип винятку залежить від конкретних додатків. У даному випадку виняток має код [java.lang.InterruptedException], оскільки завдання були перервані під час виконання інструкції [Thread.sleep(delay)], яка штучно змушує їх очікувати [delay] мілісекунд;

2.8.3. Приклад-16B

Тут ми рефакторуємо приклад 16 з розділу 1.17. Він містить фрагмент, який виконує асинхронні виклики до сервера випадкових чисел. Подивимося, як він поводиться під час обертання периферійного пристрою:

Image

  • у [1] пристрій обертається двічі;

Image

Бачимо, що всі повідомлення про помилки загубилися. Спробуємо це виправити.

2.8.3.1. Проєкт «Приклад-16B»

Копіюємо проект [client-android-skel] у проект [exemples/Exemple-16B], а потім завантажуємо новий проект:

  

З початкового проєкту [Exemple-16] ми копіюємо до [Exemple-16B] такі елементи:

  • файл [res/layout/vue1.xml], папку [res/values]:
  

Змінимо верхнє поле вікна [vue1.xml] на 80 dp:


  <TextView
    android:id="@+id/txt_Titre2"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_marginTop="80dp"
    android:text="@string/aleas"
android:textAppearance="?android:attr/textAppearanceLarge" />
  • фрагмент [Vue1Fragment]:
  
  • клас [dao / service / Response]:
  

На цьому етапі можна спробувати виконати першу компіляцію:

  • перший тип помилок — це помилки типу imports. Під час міграції до [Exemple-16B] класи змінили пакет. Спочатку виправляємо цей тип помилок;
  • другий тип помилок виявлено у класі [Vue1Fragment], оскільки він не реалізує методи, обов’язкові для батьківського класу [AbstractParent]. Ми виконуємо автоматичне генерування цих методів;

Проводимо другу компіляцію:

  • усі залишилися помилки тепер зосереджені в класі [Vue1Fragment] — саме цей клас зазнає найбільших змін;

2.8.3.2. Створення стану для фрагмента [Vue1Fragment]

Ми побачили, що певну інформацію з фрагмента потрібно зберегти під час обертання, щоб відновити фрагмент у тому вигляді, в якому він був до обертання. Тому ми створюємо стан [Vue1FragmentState], який поки що є порожнім:

  

package client.android.fragments.state;

import client.android.architecture.custom.CoreState;

public class Vue1FragmentState extends CoreState {

}

2.8.3.3. Налаштування проекту

  

Інтерфейс [IMainActivity] дозволяє вказати певні характеристики проекту:


package client.android.architecture.custom;

import client.android.architecture.core.ISession;
import client.android.dao.service.IDao;

public interface IMainActivity extends IDao {

  // вхід у систему
  ISession getSession();

  // зміна виду
  void navigateToView(int position, ISession.Action action);

  // управління чергою
  void beginWaiting();

  void cancelWaiting();

  // константи програми -------------------------------------

  // режим налагодження
  boolean IS_DEBUG_ENABLED = true;

  // максимальний час очікування відповіді сервера
  int TIMEOUT = 1000;

  // час очікування перед виконанням клієнтського запиту
  int DELAY = 5000;

  // базова автентифікація
  boolean IS_BASIC_AUTHENTIFICATION_NEEDED = false;

  // суміжність фрагментів
  int OFF_SCREEN_PAGE_LIMIT = 1;

  // панель вкладок
  boolean ARE_TABS_NEEDED = false;

  // зображення очікування
  boolean IS_WAITING_ICON_NEEDED = true;

  // кількість фрагментів додатка
  int FRAGMENTS_COUNT = 1;

}
  • рядки 25, 28, 31, 40: характеристики шару [DAO]. Базова автентифікація не потрібна;
  • рядок 34: суміжність фрагментів. У даному випадку ця константа не має значення, оскільки є лише один фрагмент;
  • рядок 37: це не додаток із вкладками;
  • рядок 43: є лише один фрагмент;

Клас [CoreState], який зберігає стан фрагментів, матиме такий вигляд:


package client.android.architecture.custom;

import client.android.architecture.core.MenuItemState;
import client.android.fragments.state.Vue1FragmentState;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;

@JsonIgnoreProperties(ignoreUnknown = true)
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY)
@JsonSubTypes({
  @JsonSubTypes.Type(value = Vue1FragmentState.class)}
)
public class CoreState {
  // відвіданий чи не відвіданий фрагмент
  protected boolean hasBeenVisited = false;
  // стан можливого меню фрагмента
  protected MenuItemState[] menuOptionsState;

  // гетери та сеттери
...
}
  • рядок 12: оголошуємо клас стану фрагмента [Vue1Fragment];

Клас [Session] має такий вигляд:


package client.android.architecture.custom;

import client.android.architecture.core.AbstractSession;

public class Session extends AbstractSession {
  // елементи, які не можна серіалізувати в jSON, повинні мати анотацію @JsonIgnore
}

Він порожній, оскільки в цьому додатку немає міжфрагментної комунікації.

2.8.3.4. Рівень [DAO]

  

У шарі [DAO] необхідно налаштувати три класи:

  • інтерфейс IDao;
  • його реалізація Dao;
  • інтерфейс WebClient для взаємодії з веб-сервером / jSON;

Клас [Response] походить із проєкту [Exemple-16], який його використовує:


package client.android.dao.service;

import java.util.List;

public class Response<T> {

    // ----------------- властивості
    // статус операції
    private int status;
    // можливі повідомлення про помилки
    private List<String> messages;
    // тіло відповіді
    private T body;

    // конструктори
    public Response() {

    }

    public Response(int status, List<String> messages, T body) {
        this.status = status;
        this.messages = messages;
        this.body = body;
    }

    // методи getter та setter
...
}

Інтерфейс [WebClient] матиме такий вигляд:


package client.android.dao.service;

import org.androidannotations.rest.spring.annotations.Get;
import org.androidannotations.rest.spring.annotations.Path;
import org.androidannotations.rest.spring.annotations.Rest;
import org.androidannotations.rest.spring.api.RestClientRootUrl;
import org.androidannotations.rest.spring.api.RestClientSupport;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.web.client.RestTemplate;

@Rest(converters = {MappingJackson2HttpMessageConverter.class})
public interface WebClient extends RestClientRootUrl, RestClientSupport {

  // RestTemplate
  void setRestTemplate(RestTemplate restTemplate);

  // 1 випадкове число в інтервалі [a,b]
  @Get("/{a}/{b}")
  Response<Integer> getAlea(@Path("a") int a, @Path("b") int b);

}
  • рядки 18–19: URL служби випадкових чисел. Нагадаємо, що він пов'язаний з кореневим URL (RestClientRootUrl, рядок 12) клієнта. У цьому випадку цим кореневим URL буде [http://localhost:8080];

Інтерфейс [IDao] матиме такий вигляд:


package client.android.dao.service;

import rx.Observable;

public interface IDao {
  // URL веб-сервісу
  void setUrlServiceWebJson(String url);

  // користувач
  void setUser(String user, String mdp);

  // час очікування клієнта
  void setTimeout(int timeout);

  // базова автентифікація
  void setBasicAuthentification(boolean isBasicAuthentificationNeeded);

  // режим налагодження
  void setDebugMode(boolean isDebugEnabled);

  // Час очікування клієнта перед надсиланням запиту (у мілісекундах)
  void setDelay(int delay);

  // сервіс генерації випадкових чисел
  Observable<Response<Integer>> getAlea(int a, int b);

}
  • нагадаємо, що методи в рядках 6–22 за замовчуванням присутні в інтерфейсі IDao проекту [client-android-skel];
  • рядок 25: метод [getAlea] дозволяє отримати випадкове число в інтервалі [a,b]. Це число отримується у відповіді типу [Response<Integer>], де випадкове число міститься у полі [body] цього типу;

Інтерфейс [IDao] реалізовано наступним класом [Dao]:


package client.android.dao.service;

import android.util.Log;
import org.androidannotations.annotations.AfterInject;
import org.androidannotations.annotations.Bean;
import org.androidannotations.annotations.EBean;
import org.androidannotations.rest.spring.annotations.RestService;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.web.client.RestTemplate;
import rx.Observable;

import java.util.ArrayList;
import java.util.List;

@EBean(scope = EBean.Scope.Singleton)
public class Dao extends AbstractDao implements IDao {

  // клієнт веб-сервісу
  @RestService
  protected WebClient webClient;
  // безпека
  @Bean
  protected MyAuthInterceptor authInterceptor;
  // RestTemplate
  private RestTemplate restTemplate;
  // фабрика RestTemplate
  private SimpleClientHttpRequestFactory factory;

  @AfterInject
  public void afterInject() {
    // журнал
    Log.d(className, "afterInject");
    // виготовлення restTemplate
    factory = new SimpleClientHttpRequestFactory();
    restTemplate = new RestTemplate(factory);
    // встановлюємо перетворювач jSON
    restTemplate.getMessageConverters().add(new MappingJackson2HttpMessageConverter());
    // встановлюємо restTemplate веб-клієнта
    webClient.setRestTemplate(restTemplate);
  }

  @Override
  public void setUrlServiceWebJson(String url) {
    // встановлюємо URL веб-сервісу
    webClient.setRootUrl(url);
  }

  @Override
  public void setUser(String user, String mdp) {
    // реєструємо користувача в перехоплювачі
    authInterceptor.setUser(user, mdp);
  }

  @Override
  public void setTimeout(int timeout) {
    if (isDebugEnabled) {
      Log.d(className, String.format("setTimeout thread=%s, timeout=%s", Thread.currentThread().getName(), timeout));
    }
    // фабрика конфігурації
    factory.setReadTimeout(timeout);
    factory.setConnectTimeout(timeout);
  }

  @Override
  public void setBasicAuthentification(boolean isBasicAuthentificationNeeded) {
    if (isDebugEnabled) {
      Log.d(className, String.format("setBasicAuthentification thread=%s, isBasicAuthentificationNeeded=%s", Thread.currentThread().getName(), isBasicAuthentificationNeeded));
    }
    // інтерцептор автентифікації?
    if (isBasicAuthentificationNeeded) {
      // додано перехоплювач автентифікації
      List<ClientHttpRequestInterceptor> interceptors = new ArrayList<ClientHttpRequestInterceptor>();
      interceptors.add(authInterceptor);
      restTemplate.setInterceptors(interceptors);
    }
  }

  // приватні методи -------------------------------------------------
  private void log(String message) {
    if (isDebugEnabled) {
      Log.d(className, message);
    }
  }

  // сервіс випадкових чисел
  @Override
  public Observable<Response<Integer>> getAlea(final int a, final int b) {
    // виконання веб-клієнта
    return getResponse(new IRequest<Response<Integer>>() {
      @Override
      public Response<Integer> getResponse() {
        return webClient.getAlea(a, b);
      }
    });
  }

}
  • нагадуємо, що рядки 17–85 за замовчуванням присутні у класі [Dao] проекту [client-android-skel]. Потрібно лише додати методи реалізації інтерфейсу [IDao];
  • рядки 88–97: реалізація методу [getAlea]. Вона дуже проста і займає 6 рядків (рядки 91–96);
  • рядок 91: метод [getResponse] є методом батьківського класу [AbstractDao]. Він очікує параметр типу [IRequest<T>], де T — тип очікуваної відповіді, у даному випадку тип Response<Integer>. Тип T методу [IRequest<T>] (рядок 91) повинен збігатися з типом T методу [Observable<T> getAlea] (рядок 89);
  • інтерфейс [IRequest<T>] має лише один метод: getResponse. Його завданням є повернення відповіді типу T, яку повинен повернути метод [Observable<T> getAlea];
  • рядок 94: саме інтерфейс [WebClient] надає цю відповідь. Йому передаються два параметри, отримані в рядку 89. З цієї причини вони повинні мати атрибут final;

2.8.3.5. Активність [MainActivity]

  

Активність [MainActivity] виглядає наступним чином:


package client.android.activity;

import android.util.Log;
import client.android.R;
import client.android.architecture.core.AbstractActivity;
import client.android.architecture.core.AbstractFragment;
import client.android.architecture.core.ISession;
import client.android.dao.service.Dao;
import client.android.dao.service.IDao;
import client.android.dao.service.Response;
import client.android.fragments.behavior.Vue1Fragment_;
import org.androidannotations.annotations.Bean;
import org.androidannotations.annotations.EActivity;
import org.androidannotations.annotations.OptionsMenu;
import rx.Observable;

@EActivity
@OptionsMenu(R.menu.menu_main)
public class MainActivity extends AbstractActivity {

  // шар [DAO]
  @Bean(Dao.class)
  protected IDao dao;

  // методи батьківського класу -----------------------
  @Override
  protected void onCreateActivity() {
    // журнал
    if (IS_DEBUG_ENABLED) {
      Log.d(className, "onCreateActivity");
    }
    // продовжуємо ініціалізацію, розпочату батьківським класом
  }

  @Override
  protected IDao getDao() {
    return dao;
  }

  @Override
  protected AbstractFragment[] getFragments() {
    // визначити фрагменти тут
    return new AbstractFragment[]{new Vue1Fragment_()};
  }


  @Override
  protected CharSequence getFragmentTitle(int position) {
    // визначити тут заголовки фрагментів
    return null;
  }

  @Override
  protected void navigateOnTabSelected(int position) {
    // навігація за вкладками — визначити, який вигляд відображати
  }

  @Override
  protected int getFirstView() {
    return 0;
  }

  // інтерфейс IDao ------------------------------------------
  @Override
  public Observable<Response<Integer>> getAlea(int a, int b) {
    return dao.getAlea(a, b);
  }

}
  • нагадуємо, що рядки 15–61 за замовчуванням присутні у проєкті [client-android-skel]. Їх потрібно лише налаштувати;
  • рядки 40–44: таблиця фрагментів. Тут є лише один;
  • рядки 47–51: заголовки фрагментів не потрібні;
  • рядки 53–56: тут немає вкладок;
  • рядки 58–61: першим для відображення є вигляд № 0, а саме [Vue1Fragment];
  • рядки 64–67: реалізація інтерфейсу [IDao]. Тут нічого іншого не потрібно робити, крім як делегувати роботу шару [DAO] із рядка 23;

2.8.3.6. Стан фрагмента [Vue1Fragment]

  

Клас [Vue1FragmentState] матиме такий вигляд:


package client.android.fragments.state;

import client.android.architecture.custom.CoreState;

import java.util.ArrayList;
import java.util.List;

public class Vue1FragmentState extends CoreState {

  // стан фрагмента ------------------------
  // список відповідей
  private List<String> reponses = new ArrayList<>();
  // стан перегляду ------------------------
  // повідомлення про помилку щодо кількості запитаних випадкових чисел
  private boolean txtErrorAleasVisible = false;
  // повідомлення про помилку щодо інтервалу генерації [a,b]
  private boolean txtErrorIntervalleVisible = false;
  // повідомлення про помилку щодо URL веб-сервісу
  private boolean txtMsgErreurUrlServiceWebVisible = false;
  // повідомлення про помилку щодо часу очікування
  private boolean textViewErreurDelayVisible = false;
  // видимий чи невидимий стан кнопки «Виконати»
  private boolean btnExecuterVisible = true;

  // методи getter та setter
...
}

Щоб визначити, що саме потрібно зберегти у фрагменті, ми змусили пристрій обертатися в різних ситуаціях і спостерігали, що зникло під час відновлення. Ми дійшли висновку, що потрібно зберегти інформацію з рядків 10–23.

2.8.3.7. Фрагмент [Vue1Fragment]

  

Наразі вигляд [Vue1Fragment] містить різні помилки, пов’язані з тим, що батьківський клас [AbstractFragment], від якого він походить, змінився. Замість того, щоб описувати зміни по черзі, ми безпосередньо прокоментуємо кінцеву версію.

Структура фрагмента така:


package client.android.fragments.behavior;

import android.util.Log;
import android.view.View;
import android.widget.*;
import client.android.R;
import client.android.architecture.core.AbstractFragment;
import client.android.architecture.custom.CoreState;
import client.android.dao.service.Response;
import client.android.fragments.state.Vue1FragmentState;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.androidannotations.annotations.Click;
import org.androidannotations.annotations.EFragment;
import org.androidannotations.annotations.OptionsMenu;
import org.androidannotations.annotations.ViewById;
import rx.Observable;
import rx.functions.Action1;

import java.net.URI;
import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.List;

@EFragment(R.layout.vue1)
@OptionsMenu(R.menu.menu_vide)
public class Vue1Fragment extends AbstractFragment {

...
}
  • у рядку 26 нагадується, що кожен фрагмент повинен мати меню, навіть порожнє. У даному випадку це так.

2.8.3.7.1. Обробка кліка на кнопці [Exécuter]

@Click(R.id.btn_Executer)
  protected void doExecuter() {
    // перевірка введених даних
    if (!isPageValid()) {
      return;
    }
    // попередні відповіді видаляються
    reponses.clear();
    dataAdapterReponses.notifyDataSetChanged();
    // обнуляємо лічильник відповідей
    nbReponses = 0;
    infoReponses.setText("Liste des réponses (0)");
    // ініціалізація завдання
    mainActivity.setUrlServiceWebJson(urlServiceWebJson);
    mainActivity.setDelay(delay);
    // підготовка випадкового завдання
    beginWaiting(1);
    // запитуються випадкові числа
    getAleasInBackground(nbAleas, a, b);
  }

  void getAleasInBackground(int nbAleas, int a, int b) {
    // створюється процес, що спостерігається
    Observable<Response<Integer>> process = Observable.empty();
    for (int i = 0; i < nbAleas; i++) {
      process = process.mergeWith(mainActivity.getAlea(a, b));
    }
    // запит випадкових чисел
    executeInBackground(process, new Action1<Response<Integer>>() {

      @Override
      public void call(Response<Integer> response) {
        // обробка відповіді
        consumeAleaResponse(response);
      }
    });
  }

  protected void consumeAleaResponse(Response<Integer> response) {
    // журнал
    if (isDebugEnabled) {
      try {
        Log.d(String.format("%s", className), String.format("consumeAleaResponse(%s)", jsonMapper.writeValueAsString(response)));
      } catch (JsonProcessingException e) {
        e.printStackTrace();
      }
    }
    // відповідь +
    nbReponses++;
    infoReponses.setText(String.format("Liste des réponses (%s)", nbReponses));
    // аналізуємо відповідь
    // помилка?
    if (response.getStatus() != 0) {
      // відображення
      showAlert(response.getMessages());
      // скасування
      doAnnuler();
      // повернення до інтерфейсу
      return;
    }
    // додаємо інформацію до списку відповідей
    reponses.add(0, String.valueOf(response.getBody()));
    // оновлення відповідей
    dataAdapterReponses.notifyDataSetChanged();
  }

  // скасування ----------
  @Click(R.id.btn_Annuler)
  protected void doAnnuler() {
    if (isDebugEnabled) {
      Log.d(className, "Annulation demandée");
    }
    // скасування асинхронних завдань
    cancelRunningTasks();
}

  private void beginWaiting(int nbRunningTasks) {
    // вмикається пісочний годинник
    beginRunningTasks(nbRunningTasks);
    // кнопка [Annuler] замінює кнопку [Exécuter]
    btnExecuter.setVisibility(View.INVISIBLE);
    btnAnnuler.setVisibility(View.VISIBLE);
  }
  • рядки 4–6: спочатку перевіряється, чи введені дані є правильними. У цьому випадку можуть з’явитися повідомлення про помилки;
  • рядки 8–9: список відповідей очищується. Ця зміна передається до ListView, який їх відображає;
  • рядки 11–12: кількість отриманих відповідей обнуляється;
  • рядок 14: встановлюється URL служби випадкових чисел. Ця інформація буде передана на рівень [DAO];
  • рядок 15: встановлюється час очікування перед надсиланням запиту до служби випадкових чисел. Ця інформація буде передана на рівень [DAO];
  • рядок 17: готуємося до запуску 1 асинхронного завдання (а не N, пізніше з’ясуємо, чому);
  • рядки 24–27: з N асинхронних завдань кожне перетворюється на послідовність операцій [merge];
  • рядки 29–36: надсилається запит до батьківського класу [AbstractParent] щодо отримання випадкових чисел із веб-сервісу / jSON;
  • рядки 29–36: метод [executeInBackground] очікує два параметри:
    • рядок 29: процес, який потрібно спостерігати та виконати, — це той, що було обчислено в попередніх рядках;
    • рядки 29–36: екземпляр [Action1], який слід виконати після отримання відповіді від асинхронного сервісу. Тип T для [Action1<T>] має відповідати типу T результату методу [getAlea], тобто типу [Response<Integer>];
  • рядок 34: коли надходить відповідь (випадкове число), її обробляється в методі з рядка 39;
  • рядки 49–50: фіксується та повідомляється про отримання нової відповіді;
  • рядки 53–60: тип [Response<T>] має поле [status], яке є кодом помилки. Якщо цей код відмінний від нуля, то на сервері виникла проблема;
  • рядок 55: відображається повідомлення про помилку. Метод [showAlert] належить до батьківського класу;
  • рядок 57: викликається метод, описаний у рядках 68–75. Він скасує завдання, що все ще активні (рядок 74);
  • рядок 62: відповідь додається до списку відповідей, який є джерелом даних для ListView;
  • рядок 64: ListView оновлюється;
  • рядки 77–83: метод [beginWaiting(int nbRunningTasks)] готує представлення до очікування (рядки 81–82) і повідомляє батьківському класу, що завдання [nbRunningTasks] незабаром будуть виконані (рядок 79);

2.8.3.7.2. Життєвий цикл фрагмента

Життєвий цикл фрагмента забезпечується такими методами:


  // локальні дані
  private List<String> reponses;
  private ArrayAdapter<String> dataAdapterReponses;
  private int nbReponses = 0;
...
  // управління життєвим циклом ---------------------------------------------------------
  @Override
  public CoreState saveFragment() {
    // поточний стан перегляду
    Vue1FragmentState state = new Vue1FragmentState();
    state.setTextViewErreurDelayVisible(textViewErreurDelay.getVisibility() == View.VISIBLE);
    state.setTxtErrorAleasVisible(txtErrorAleas.getVisibility() == View.VISIBLE);
    state.setTxtMsgErreurUrlServiceWebVisible(txtMsgErreurUrlServiceWeb.getVisibility() == View.VISIBLE);
    state.setTxtErrorIntervalleVisible(txtErrorIntervalle.getVisibility() == View.VISIBLE);
    state.setBtnExecuterVisible(btnExecuter.getVisibility() == View.VISIBLE);
    state.setReponses(reponses);
    return state;
  }

  @Override
  protected int getNumView() {
    return 0;
  }

  @Override
  protected void initFragment(CoreState previousState) {
    // перший візит?
    if (previousState != null) {
      Vue1FragmentState state = (Vue1FragmentState) previousState;
      reponses = state.getReponses();
    } else {
      reponses = new ArrayList<>();
    }
    // джерело даних listView
    dataAdapterReponses = new ArrayAdapter<>(activity, android.R.layout.simple_list_item_1, android.R.id.text1, reponses);
    // кількість відповідей
    nbReponses = reponses.size();
  }

  @Override
  protected void initView(CoreState previousState) {
    // посилання на список / адаптер
    listReponses.setAdapter(dataAdapterReponses);
    // Перший візит?
    if (previousState == null) {
      // приховування повідомлень про помилки
      txtErrorAleas.setVisibility(View.INVISIBLE);
      txtErrorIntervalle.setVisibility(View.INVISIBLE);
      txtMsgErreurUrlServiceWeb.setVisibility(View.INVISIBLE);
      textViewErreurDelay.setVisibility(View.INVISIBLE);
      // кнопки
      btnAnnuler.setVisibility(View.INVISIBLE);
      btnExecuter.setVisibility(View.VISIBLE);
    }
  }

  @Override
  protected void updateOnSubmit(CoreState previousState) {

  }

  @Override
  protected void updateOnRestore(CoreState previousState) {
    // попередній стан вікна
    Vue1FragmentState state = (Vue1FragmentState) previousState;
    // показати / приховати повідомлення про помилки
    txtErrorAleas.setVisibility(state.isTxtErrorAleasVisible() ? View.VISIBLE : View.INVISIBLE);
    txtErrorIntervalle.setVisibility(state.isTxtErrorIntervalleVisible() ? View.VISIBLE : View.INVISIBLE);
    txtMsgErreurUrlServiceWeb.setVisibility(state.isTxtMsgErreurUrlServiceWebVisible() ? View.VISIBLE : View.INVISIBLE);
    textViewErreurDelay.setVisibility(state.isTextViewErreurDelayVisible() ? View.VISIBLE : View.INVISIBLE);
    // кнопки
    btnAnnuler.setVisibility(state.isBtnExecuterVisible() ? View.INVISIBLE : View.VISIBLE);
    btnExecuter.setVisibility(state.isBtnExecuterVisible() ? View.VISIBLE : View.INVISIBLE);
    // кількість відповідей
    infoReponses.setText(String.format("Liste des réponses (%s)", nbReponses));
  }

  @Override
  protected void notifyEndOfUpdates() {

  }

  @Override
  protected void notifyEndOfTasks(boolean runningTasksHaveBeenCanceled) {
    // кнопка [Exécuter] замінює кнопку [Annuler]
    btnAnnuler.setVisibility(View.INVISIBLE);
    btnExecuter.setVisibility(View.VISIBLE);

}
  • рядки 7–18: забезпечують збереження фрагмента, коли батьківський клас надсилає відповідний запит;
  • рядок 11: відображення повідомлення про помилку щодо часу очікування;
  • рядок 12: відображення повідомлення про помилку щодо кількості запитаних випадкових чисел;
  • рядок 13: відображення повідомлення про помилку щодо URL веб-сервісу / jSON;
  • рядок 14: відображення повідомлення про помилку щодо інтервалу [a,b] генерації випадкових чисел;
  • рядок 15: видимість кнопки [Exécuter];
  • рядок 16: список отриманих відповідей;
  • рядки 20–23: повинні повертати номер перегляду. Номер фрагмента тут дорівнює 0, оскільки він єдиний;
  • рядки 25–38: ініціалізація полів фрагмента, або під час першого відвідування (previousState==null), або під час наступного відвідування;
    • рядки 29–30: якщо це не перший візит, поле [reponses] відновлюється на основі попереднього стану фрагмента;
    • рядки 31–33: якщо це перший візит, то поле [reponses] ініціалізується порожнім списком;
    • рядки 34–37: на основі поля [reponses] можна побудувати джерело даних для поля ListView у фрагменті (рядок 35), а також визначити кількість відповідей (рядок 37);
  • рядки 40–55: виконуються для ініціалізації подання, пов’язаного з фрагментом, або під час першого відвідування (previousState==null), або під час наступного відвідування;
    • рядок 43: ListView фрагмента пов’язується з джерелом даних, яке щойно було створено в методі [initFragment];
    • рядки 45–54: якщо це перший візит, готується вікно для першого відображення;
  • рядки 57–60: виконуються під час навігації між фрагментами, пов’язаної з дією типу [SUBMIT]. Тут є лише один фрагмент, тому навігація між фрагментами відсутня;
  • рядки 63–76: виконуються під час навігації між фрагментами, пов’язаної з дією типу [NAVIGATION], або під час циклу збереження/відновлення внаслідок повороту пристрою чи з іншої причини. Тут може трапитися лише останній випадок. Слід пам’ятати, що в будь-якому разі [previousState] завжди не є null;
  • рядок 65: попередній стан приводяться до типу стану фрагмента;
  • рядки 66–75: використовується вміст попереднього стану для відновлення виду;
  • рядки 78–81: викликаються, коли всі попередні оновлення виконано. Тут нічого робити не потрібно;
  • рядки 83–89: виконуються, коли всі асинхронні завдання завершені. Тут приховується кнопка [Annuler], щоб замінити її на кнопку [Exécuter];

2.8.3.8. Тестування

Читачеві пропонується виконати такі тести:

  • створити помилки та запустити пристрій: повідомлення про помилки мають зберігатися;
  • отримати випадкові числа та запустити пристрій: отримані випадкові числа мають залишатися на екрані;
  • встановити паузу на кілька секунд і запустити пристрій під час очікування: завдання мають бути скасовані (це видно в журналах);

2.8.4. Приклад-22B

Тут ми повертаємося до прикладу 22, щоб рефакторувати його за зразком проєкту [client-android-skel]. Нагадаємо, що проект [Exemple-22] коректно обробляє цикл збереження/відновлення фрагментів під час обертання і саме він послужив основою для проекту [client-android-skel].

Ми дублюємо проект [client-android-skel] у [exemples/Exemple-22B] і завантажуємо останній проект:

  

Потім ми копіюємо різні елементи з проекту [Exemple-22] у проект [Exemple-22B].

Спочатку копіюємо елементи з папки [res]:

  • [layout/fragment_main.xml, layout/vue1.xml, menu/menu_fragment.xml, menu/menu_main.xml, папку [values];
  

Змінимо верхнє поле обох вікон на 120 dp:

[vue1.xml]:


  <TextView
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:textAppearance="?android:attr/textAppearanceLarge"
    android:text="@string/titre_vue1"
    android:id="@+id/textViewTitreVue1"
    android:layout_marginTop="120dp"
    android:textSize="50sp"
    android:layout_gravity="center|left"
    android:layout_alignParentTop="true"
android:layout_centerHorizontal="true"/>

[fragment_main]:


  <TextView
    android:id="@+id/section_label"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
android:layout_marginTop="120dp"/>

Далі скопіюємо елементи [Vue1Fragment, PlaceHolderFragment, PlaceHolderFragmentState]:

 

На цьому етапі ми можемо спробувати виконати першу компіляцію. З’являється перший тип помилок: це неправильні imports, оскільки класи змінили пакет. Виправляємо ці imports. Другий тип помилок пов’язаний з тим, що фрагменти не реалізують усі методи свого батьківського класу [AbstractFragment]. Виправлення виконується за допомогою (Alt+Enter).

Решта помилок пов’язані з існуючими відмінностями між старим і новим класом [AbstractFragment]. Наразі їх ігноруємо.

2.8.4.1. Налаштування проєкту

  

У папці [custom] містяться елементи архітектури, які розробник може налаштовувати.

Інтерфейс [IMainActivity] дозволяє вказати певні характеристики проекту:


package client.android.architecture.custom;

import client.android.architecture.core.ISession;
import client.android.dao.service.IDao;

public interface IMainActivity extends IDao {

  // вхід у сесію
  ISession getSession();

  // зміна виду
  void navigateToView(int position, ISession.Action action);

  // управління чергою
  void beginWaiting();

  void cancelWaiting();

  // режим налагодження
  boolean IS_DEBUG_ENABLED = true;

  // максимальний час очікування відповіді від сервера
  int TIMEOUT = 1000;

  // час очікування перед виконанням запиту клієнта
  int DELAY = 0;

  // базова автентифікація
  boolean IS_BASIC_AUTHENTIFICATION_NEEDED = false;

  // суміжність фрагментів
  int OFF_SCREEN_PAGE_LIMIT = 1;

  // панель вкладок
  boolean ARE_TABS_NEEDED = true;

  // зображення очікування
  boolean IS_WAITING_ICON_NEEDED = false;

  // кількість фрагментів
  int FRAGMENTS_COUNT = 5;

}
  • рядки 23, 26, 29, 38: характеристики шару [DAO]. Тут їх немає;
  • рядок 41: тут є п’ять фрагментів;
  • рядок 32: суміжність фрагментів. Ця константа може мати тут значення в [1,4]. Читачеві рекомендується змінювати це значення, щоб перевірити, чи програма продовжує працювати;
  • рядок 35: це додаток із вкладками;

Клас [CoreState], який зберігає стан фрагментів, матиме такий вигляд:


package client.android.architecture.custom;

import client.android.architecture.core.MenuItemState;
import client.android.fragments.state.PlaceHolderFragmentState;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;

@JsonIgnoreProperties(ignoreUnknown = true)
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY)
@JsonSubTypes({
  @JsonSubTypes.Type(value = PlaceHolderFragmentState.class)}
)
public class CoreState {
  // відвіданий чи не відвіданий фрагмент
  protected boolean hasBeenVisited = false;
  // стан можливого меню фрагмента
  protected MenuItemState[] menuOptionsState;

  // гетери та сеттери
...
}
  • рядок 12: ми оголошуємо клас стану фрагмента [PlaceHolderFragment]. Фрагмент [Vue1Fragment] не має стану;

Клас [Session] має такий вигляд:


package client.android.architecture.custom;

import client.android.architecture.core.AbstractSession;

public class Session extends AbstractSession {
  // дані, що мають бути спільними між самими фрагментами та між фрагментами й активністю
  // елементи, які не можна серіалізувати у jSON, повинні мати анотацію @JsonIgnore
  // не забувайте про геттери та сеттери, необхідні для серіалізації/десеріалізації у форматі jSON

  // кількість відвіданих фрагментів
  private int numVisit;
  // номер фрагмента типу [PlaceholderFragment], що відображається у другій вкладці
  private int numFragment = -1;

  // гетери та сеттери
...
}

Це сесія проєкту [Exemple-22].

2.8.4.2. Діяльність [MainActivity]

  

Операція [MainActivity] має такий вигляд:


package client.android.activity;

import android.os.Bundle;
import android.support.design.widget.TabLayout;
import android.util.Log;
import android.view.MenuItem;
import client.android.R;
import client.android.architecture.core.AbstractActivity;
import client.android.architecture.core.AbstractFragment;
import client.android.architecture.core.ISession;
import client.android.architecture.custom.IMainActivity;
import client.android.architecture.custom.Session;
import client.android.dao.service.Dao;
import client.android.dao.service.IDao;
import client.android.fragments.behavior.PlaceholderFragment_;
import client.android.fragments.behavior.Vue1Fragment_;
import org.androidannotations.annotations.Bean;
import org.androidannotations.annotations.EActivity;
import org.androidannotations.annotations.OptionsMenu;

@EActivity
@OptionsMenu(R.menu.menu_main)
public class MainActivity extends AbstractActivity {

  // шар [DAO]
  @Bean(Dao.class)
  protected IDao dao;
  // сесія
  private Session session;

  // управління меню-----------------------
  @Override
  public boolean onOptionsItemSelected(MenuItem item) {
...
  }

  private void showFragment(int i) {
...
  }

  // реалізація методів батьківського класу ---------------------------------------------------
  ...
}

Тут клас [MainActivity] є більш об’ємним, ніж у попередніх прикладах, з двох причин:

  • необхідно керувати вкладками;
  • необхідно керувати меню;

2.8.4.2.1. Реалізація методів батьківського класу

// методи батьківського класу -----------------------
  @Override
  protected void onCreateActivity() {
    // журнал
    if (IS_DEBUG_ENABLED) {
      Log.d(className, "onCreateActivity");
    }
    // продовжуємо ініціалізацію, розпочату батьківським класом
    // сесія
    this.session = (Session) super.session;
    ...
  }

  @Override
  protected IDao getDao() {
    return dao;
  }

  @Override
  protected AbstractFragment[] getFragments() {
    // номер фрагмента
    final String ARG_SECTION_NUMBER = "section_number";
    // ініціалізація масиву фрагментів
    AbstractFragment[] fragments = new AbstractFragment[FRAGMENTS_COUNT];
    int i;
    for (i = 0; i < fragments.length - 1; i++) {
      // створюється фрагмент
      fragments[i] = new PlaceholderFragment_();
      // можна передавати аргументи фрагменту
      Bundle args = new Bundle();
      args.putInt(ARG_SECTION_NUMBER, i + 1);
      fragments[i].setArguments(args);
    }
    // фрагмент +
    fragments[i] = new Vue1Fragment_();
    // результат
    return fragments;
  }


  @Override
  protected CharSequence getFragmentTitle(int position) {
    // тут немає заголовків
    return null;
  }

  @Override
  protected void navigateOnTabSelected(int position) {
...
  }

  @Override
  protected int getFirstView() {
    return IMainActivity.FRAGMENTS_COUNT - 1;
  }
  • рядки 2–12: метод [onCreateActivity] викликається батьківським класом [AbstractActivity] під час першого створення активності або її відновлення під час циклу збереження/відновлення. Коли цей метод викликається, батьківський клас уже відновив сесію;
  • рядок 10: отримується локальне посилання на сесію. Зміна типу пов’язана з тим, що сесія батьківського класу має тип [AbstractSession];
  • рядки 19–38: метод [getFragments] повинен повернути батьківському класу масив фрагментів, що керуються додатком. Тут їх [FRAGMENTS_COUNT], кількість яких визначена в [IMainActivity]. Перші фрагменти [FRAGMENTS_COUNT-1] мають тип [PlaceHolderFragment], а останній — тип [Vue1Fragment];
  • рядки 41–45: метод [getFragmentTitle] повинен повертати заголовки фрагментів, коли ця інформація може бути корисною. У даному випадку це не так;
  • рядки 47–50: цей метод викликається батьківським класом, коли користувач натискає на вкладку. Ми повернемося до цього в наступному абзаці;
  • рядки 52–55: повертає номер першого виду, який слід відобразити під час запуску програми. У цьому випадку фрагмент [Vue1Fragment] має відображатися першим. Метод [getFirstView] можна було б з користю замінити на константу в [IMainActivity];

2.8.4.2.2. Управління вкладками

Вкладки керуються за допомогою таких методів:


@Override
  protected void onCreateActivity() {
    // журнал
    if (IS_DEBUG_ENABLED) {
      Log.d(className, "onCreateActivity");
    }
    // продовжуємо ініціалізацію, розпочату батьківським класом
    // сесія
    this.session = (Session) super.session;
    // 1-ша вкладка
    TabLayout.Tab tab = tabLayout.newTab();
    tab.setText("Vue 1");
    tabLayout.addTab(tab);
    // 2-га вкладка?
    int numFragment = session.getNumFragment();
    if (numFragment != -1) {
      TabLayout.Tab tab2 = tabLayout.newTab();
      tab2.setText(String.format("Fragment n° %s", (numFragment + 1)));
      tabLayout.addTab(tab2);
    }
  }

  @Override
  protected void navigateOnTabSelected(int position) {
    // номер фрагмента для відображення
    int numFragment;
    switch (position) {
      case 0:
        // номер фрагмента [Vue1Fragment]
        numFragment = getFirstView();
        break;
      default:
        // номер фрагмента [PlaceholderFragment]
        numFragment = session.getNumFragment();
    }
    // відображення фрагмента
    if (numFragment != mViewPager.getCurrentItem()) {
      navigateToView(numFragment, ISession.Action.SUBMIT);
    }
  }
}
  • рядки 1–20: метод [onCreateActivity] викликається батьківським класом [AbstractActivity] під час першого створення активності або її відновлення під час циклу збереження/відновлення. Коли цей метод викликається, батьківський клас уже відновив сесію;
  • рядок 9: отримується локальне посилання на сесію. Зміна типу пов’язана з тим, що сесія батьківського класу має тип [AbstractSession];
  • рядки 11–13: створюється перша вкладка;
  • рядки 15–20: створюється друга вкладка, якщо в сесії зареєстровано номер фрагмента (рядок 15). Під час першого створення активності цей номер спочатку дорівнює -1;
  • рядки 23–39: цей метод викликається батьківським класом, коли користувач клацає на вкладці;
  • рядки 28–31: якщо натиснуто на вкладку 0, то потрібно відобразити [Vue1Fragment]. Відомо, що це перший вигляд, який було відображено під час запуску додатка;
  • рядки 32–35: якщо натиснуто вкладку 1, то потрібно відобразити фрагмент, номер якого збережено в сесії;
  • рядки 37–39: здійснюється перехід до обраного фрагмента. Відповідна дія — [SUBMIT]. Чи могло це бути [NAVIGATION]? У цьому документі [NAVIGATION] використовується лише тоді, коли для відображення нового фрагмента потрібно знати лише його попередній стан. У даному випадку це не так, оскільки відображення поточного фрагмента має змінитися порівняно з попереднім станом, щоб показати ще один візит;

2.8.4.2.3. Управління меню

Ця активність пов’язана з таким меню [menu_main.xml]:


<menu xmlns:android="http://schemas.android.com/apk/res/android"
      xmlns:app="http://schemas.android.com/apk/res-auto"
      xmlns:tools="http://schemas.android.com/tools"
      tools:context="exemples.android.MainActivity">
  <item android:id="@+id/action_settings"
        android:title="@string/action_settings"
        android:orderInCategory="100"
        app:showAsAction="never"/>
  <item android:id="@+id/fragment1"
        android:title="@string/fragment1"
        android:orderInCategory="100"
        app:showAsAction="never"/>
  <item android:id="@+id/fragment2"
        android:title="@string/fragment2"
        android:orderInCategory="100"
        app:showAsAction="never"/>
  <item android:id="@+id/fragment3"
        android:title="@string/fragment3"
        android:orderInCategory="100"
        app:showAsAction="never"/>
  <item android:id="@+id/fragment4"
        android:title="@string/fragment4"
        android:orderInCategory="100"
        app:showAsAction="never"/>
</menu>

яке відображає наступне:

  

Управління меню здійснюється за допомогою таких методів:


@Override
  public boolean onOptionsItemSelected(MenuItem item) {
    // журнал
    if (IS_DEBUG_ENABLED) {
      Log.d(className, "onOptionsItemSelected");
    }
    // обробка опцій меню
    int id = item.getItemId();
    switch (id) {
      case R.id.action_settings: {
        if (IS_DEBUG_ENABLED) {
          Log.d(className, "action_settings selected");
        }
        break;
      }
      case R.id.fragment1: {
        showFragment(0);
        break;
      }
      case R.id.fragment2: {
        showFragment(1);
        break;
      }
      case R.id.fragment3: {
        showFragment(2);
        break;
      }
      case R.id.fragment4: {
        showFragment(3);
        break;
      }
    }
    // елемент оброблено
    return true;
  }

  private void showFragment(int i) {
    if (i < FRAGMENTS_COUNT && mViewPager.getCurrentItem() != i) {
      // відсутність навігації при програмному виборі вкладки
      session.setNavigationOnTabSelectionNeeded(false);
      // відтворюємо обидві вкладки через проблеми зі шрифтом заголовків
      tabLayout.removeAllTabs();
      tabLayout.addTab(tabLayout.newTab().setText("Vue1"), false);
      tabLayout.addTab(tabLayout.newTab().setText(String.format("Fragment n° %s", (i + 1))), false);
      // номер фрагмента, який потрібно відобразити, задається в сеансі
      session.setNumFragment(i);
      // вибирається вкладка № 2 з навігацією
      session.setNavigationOnTabSelectionNeeded(true);
      tabLayout.getTabAt(1).select();
    }
  }
  • рядки 16–31: обробка кліка на пункт меню типу [Fragmenti];
  • рядки 37–50: відображення фрагмента № i (це фрагменти типу PlaceHolderFragment) у вкладці № 1 (2-га вкладка);
  • рядки 42–44: вирішено видалити існуючі вкладки, щоб створити дві нові. Це рішення було прийнято, щоб обійти таку проблему: коли фрагмент просто відображається в існуючій вкладці 1 (тобто без її видалення), дивно, але його заголовок має вигляд (шрифт, розмір), відмінний від заголовка вкладки 0;
  • рядки 43–44: обидві вкладки створено, але не виділено (останній параметр у false);
  • рядок 40: операції в рядках 42–44 можуть призвести до виконання операцій [select] над вкладками, що викличе менеджер [onTabSelected]. Якщо нічого не робити, відбудеться перехід до фрагмента. Цього можна уникнути, змінивши значення логічного параметра [navigationOnTabSelectionNeeded] на faux у сесії. Цей логічний параметр автоматично повертається до значення vrai класом [AbstractFragment], коли фрагмент стає видимим;
  • рядок 46: у сесії записується номер фрагмента, який потрібно відобразити;
  • рядки 48–50: вибирається вкладка № 2 з навігацією (рядок 48). Це запускає процедуру [onTabSelected], яка:
    • відобразить фрагмент, номер якого було занесено до сесії;
    • запам’ятає в сесії номер вибраної вкладки;

2.8.4.3. Фрагмент [Vue1Fragment]

Тут наводимо остаточну версію фрагмента:


package client.android.fragments.behavior;

import android.widget.EditText;
import android.widget.Toast;
import client.android.R;
import client.android.architecture.core.AbstractFragment;
import client.android.architecture.custom.CoreState;
import client.android.architecture.custom.IMainActivity;
import org.androidannotations.annotations.Click;
import org.androidannotations.annotations.EFragment;
import org.androidannotations.annotations.OptionsMenu;
import org.androidannotations.annotations.ViewById;

@EFragment(R.layout.vue1)
@OptionsMenu(R.menu.menu_fragment)
public class Vue1Fragment extends AbstractFragment {

  // елементи візуального інтерфейсу
  @ViewById(R.id.editTextNom)
  protected EditText editTextNom;

  // менеджер подій
  @Click(R.id.buttonValider)
  protected void doValider() {
    // відображається введене ім'я
    Toast.makeText(activity, String.format("Bonjour %s", editTextNom.getText().toString()), Toast.LENGTH_LONG).show();
  }

  // життєвий цикл фрагмента -----------------------------------------------
  private void initFragment() {
    // нічого не потрібно робити
  }

  // збереження стану фрагмента
  @Override
  public CoreState saveFragment() {
    // стан подання — нічого не потрібно зберігати
    return new CoreState();
  }

  @Override
  protected int getNumView() {
    return IMainActivity.FRAGMENTS_COUNT - 1;
  }

  @Override
  protected void initFragment(CoreState previousState) {
    // нічого робити
  }

  @Override
  protected void initView(CoreState previousState) {
    // перше відвідування?
    if (previousState == null) {
      // відображається номер візиту
      showNumVisit();
    }

  }

  @Override
  protected void updateOnSubmit(CoreState previousState) {
    // відображається номер візиту
    showNumVisit();

  }

  @Override
  protected void updateOnRestore(CoreState previousState) {

  }

  @Override
  protected void notifyEndOfUpdates() {

  }

  @Override
  protected void notifyEndOfTasks(boolean runningTasksHaveBeenCanceled) {

  }

  // приватні методи -------------------------------------
  // відображення номера відвідування
  private void showNumVisit() {
    // збільшення номера відвідування
    int numVisit = session.getNumVisit();
    numVisit++;
    session.setNumVisit(numVisit);
    // відображається номер відвідування
    Toast.makeText(activity, String.format("Visite n° %s", numVisit), Toast.LENGTH_SHORT).show();
  }
}

Клас майже порожній.

  • рядки 35–39: викликаються батьківським класом, коли фрагмент повинен зберегти свій стан. Фрагмент [Vue1Fragment] не має стану, який потрібно зберегти. Просто повертається екземпляр базового класу [CoreState] (нагадуємо: не слід повертати null);
  • рядки 41–44: повинні повертати номер фрагмента. Фрагмент [Vue1Fragment] за своєю структурою має номер [FRAGMENTS_COUNT-1];
  • рядки 51–59: викликаються батьківським класом, коли фрагмент створюється вперше (previousState == null) або в наступні рази (previousState != null);
    • рядки 54–57: якщо це перший візит, збільшується номер візиту та відображається (рядки 85–92);
  • рядки 61–65: викликаються, коли фрагмент буде відображено у зв’язку з дією [SUBMIT]. Збільшується номер відвідування та відображається. У цьому випадку неможливо, щоб номер відвідування збільшувався двічі протягом життєвого циклу. Адже перший візит до фрагмента [Vue1Fragment] відбувається під час запуску додатка, коли дія за замовчуванням у сесії дорівнює [NONE]. Це гарантує, що метод [updateOnSubmit] не буде викликаний. Потім це вже ніколи не буде першим зверненням, і метод [initView] нічого не робитиме;
  • рядки 68–71: викликаються в циклі збереження/відновлення. Оскільки фрагмент не має стану, тут немає чого відновлювати;
  • рядки 73–76: викликаються, коли всі попередні оновлення виконано. Тут більше нічого робити;
  • рядки 78–81: викликаються, коли всі запущені асинхронні завдання завершені. Тут немає асинхронних завдань;

2.8.4.4. Стан [PlaceHolderFragmentState]

Звіт про фрагмент [PlaceHolderFragment] матиме такий вигляд:


package client.android.fragments.state;

import client.android.architecture.custom.CoreState;

public class PlaceHolderFragmentState extends CoreState {
  // текст
  private String text;

  // конструктори
  public PlaceHolderFragmentState() {

  }

  public PlaceHolderFragmentState(String text) {
    super();
    this.text = text;
  }

  // методи getter та setter
 ...
}
  • коли потрібно буде зберегти стан фрагмента, буде збережено текст, який він відображав (рядок 7);

2.8.4.5. Фрагмент [PlaceHolderFragment]

Фрагмент [PlaceHolderFragment] матиме такий вигляд:


package client.android.fragments.behavior;

import android.util.Log;
import android.widget.TextView;
import client.android.R;
import client.android.architecture.core.AbstractFragment;
import client.android.architecture.custom.CoreState;
import client.android.fragments.state.PlaceHolderFragmentState;
import org.androidannotations.annotations.EFragment;
import org.androidannotations.annotations.OptionsMenu;
import org.androidannotations.annotations.ViewById;

@EFragment(R.layout.fragment_main)
@OptionsMenu(R.menu.menu_fragment)
public class PlaceholderFragment extends AbstractFragment {

  // компоненти візуального інтерфейсу
  @ViewById(R.id.section_label)
  protected TextView textViewInfo;
  @ViewById(R.id.textView1)
  protected TextView textView1;

  // дані
  private String text;

  // номер фрагмента
  private static final String ARG_SECTION_NUMBER = "section_number";

  // реалізація методів батьківського класу ----------------------------
  @Override
  public CoreState saveFragment() {
    // зберігаємо стан фрагмента
    PlaceHolderFragmentState placeHolderFragmentState = new PlaceHolderFragmentState();
    placeHolderFragmentState.setText(textViewInfo.getText().toString());
    return placeHolderFragmentState;
  }

  @Override
  protected int getNumView() {
    return getArguments().getInt(ARG_SECTION_NUMBER) - 1;
  }

  @Override
  protected void initFragment(CoreState previousState) {
    // оригінальний текст
    text = getString(R.string.section_format, getArguments().getInt(ARG_SECTION_NUMBER));
  }

  @Override
  protected void initView(CoreState previousState) {
  }

  @Override
  protected void updateOnSubmit(CoreState previousState) {
    // оновлення тексту, що відображається
    // збільшення номера відвідування
    int numVisit = session.getNumVisit();
    numVisit++;
    session.setNumVisit(numVisit);
    // змінений текст
    textViewInfo.setText(String.format("%s, visite %s", text, numVisit));
    // журнал
    if (isDebugEnabled) {
      Log.d(className, String.format("updateForSubmit, numvisit=%s, texte affiché=%s, visibility=%s", numVisit, textViewInfo.getText().toString(), textViewInfo.getVisibility()));
    }
  }

  @Override
  protected void updateOnRestore(CoreState previousState) {
    // відновлюється текст, що відображається
    PlaceHolderFragmentState state = (PlaceHolderFragmentState) previousState;
    textViewInfo.setText(state.getText());

  }

  @Override
  protected void notifyEndOfUpdates() {

  }

  @Override
  protected void notifyEndOfTasks(boolean runningTasksHaveBeenCanceled) {

  }

}
  • рядки 30–36: коли батьківський клас вимагає від фрагмента зберегти свій стан, зберігається текст, що відображається фрагментом (рядок 34);
  • рядки 38–41: повертають номер фрагмента. Він залежить від номера розділу, який було передано йому як аргумент під час його створення;
  • рядки 43–47: викликаються під час першого створення фрагмента (previousState==null) або під час наступних (previousState !=null);
    • рядок 46: тут попередній стан не використовується. Початковий текст [text] (рядок 24), що відображається під час першого відвідування, щоразу перераховується. Це викликає сумніви. Можна було б також включити цю інформацію до стану фрагмента;
  • рядки 49–51: викликаються під час першого формування подання, пов’язаного з фрагментом (previousState==null), або під час наступних (previousState!=null). Нічого робити не потрібно;
  • рядки 53–56: викликаються, коли фрагмент буде відображатися у зв’язку з дією [SUBMIT]. Це завжди так, за винятком циклу збереження/відновлення, де дією є [RESTORE]. Отже, ми збільшуємо номер відвідування та відображаємо його;
  • рядки 68–74: викликаються в циклі збереження/відновлення. Відновлюється текст, який було збережено у стані фрагмента;
  • рядки 76–79: викликаються, коли всі попередні оновлення виконано. Тут більше нічого робити не потрібно;
  • рядки 82–83: викликаються, коли всі запущені асинхронні завдання завершені. Тут асинхронних завдань немає;

2.8.4.6. Tests

Читачеві пропонується протестувати додаток, повертаючи пристрій, щоб переконатися, що відображуваний фрагмент не втрачає свого стану. Також слід переглянути журнали.

2.9. Conclusion

Наприкінці цього розділу ми маємо зразок проєкту [client-android-skel] — клієнт для Android, що взаємодіє з веб-сервісом / jSON, з такими характеристиками:

  • асинхронна взаємодія з веб-сервером / jSON здійснюється за допомогою бібліотеки RxJava;
  • життєвий цикл фрагмента (update, save, restore) керується його батьківським класом [AbstractFragment], який у певні моменти викликає певні методи своїх дочірніх класів. Таким чином, дочірній фрагмент не повинен піклуватися про етапи життєвого циклу, а лише реалізовувати певні методи, визначені його батьківським класом;
  • життєвий цикл активності (save / restore) керується абстрактним класом [AbstractActivity], який також вимагає від дочірньої активності реалізувати певні методи;
  • клас [AbstractActivity] здатний керувати додатком із вкладками або без них, із зображенням очікування або без нього, з базовою автентифікацією на веб-сервері / jSON або без неї. Наявність чи відсутність цих елементів визначається конфігурацією;

Тепер ми розглянемо приклад, складніший за попередні. Новий додаток базуватиметься на шаблоні проекту [client-android-skel].