[Из песочницы] Cохранение состояний в android приложениях

Сегодня я хотел поделиться с вами еще одним подходом сохранения состояния при разработке android приложений. Не для кого не секрет, что наше приложение в фоне может быть убито в любой момент и эта проблема становится все актуальнее с вводом агрессивного энергосбережения — привет Oreo. Также никто не отменял смену конфигурации на телефоне: ориентация, смена языка и т.д. И чтобы открыть приложение из бэкграунда и отобразить интерфейс в последнем состоянии нам нужно позаботиться о его сохранении. Ох уж этот onSaveInstanceState.

onSaveInstanceState

Сколько боли он нам принес.
Далее я буду приводить примеры, ипользуя Clean Achitecture и Dagger2, так что будьте готовы к этому:)

Вопрос сохранения состояния в зависимости от задач можно решить несколькими способами:

  1. Сохранять первичные данные в onSaveInstanceState хоста (Activity, Fragment) — такие как айдишник страницы, пользователя, да что угодно. То, что нам требуется для первичного получения данных и отображения страницы.
  2. Сохранять полученные данные в интеракторе в репозитории (SharedPreference, Database.
  3. Использовать ретеин фрагменты для сохранения и восстановления данных при пересоздании активити.


Но что делать, если нам нужно восстановить состояние ui, а также текущую реакцию интерфейса на действие пользователя? Для большей простоты рассмотрим решение этой задачи на реальном примере. У нас есть страница логина — пользователь вводит свои данные, нажимает на кнопку и тут к нему поступает входящий звонок. Наше приложение уходит в бэкграунд. Его убивает система. Звучит страшновато, не правда ли?)

Пользователь возвращается к приложению и что он должен увидеть? Как минимум, продолжение операции логина и показ прогресса. Если приложение успело пройти логин до вызова метода onDestroy хоста, то тогда пользователь увидит навигацию на стартовый экран приложения. Данное поведение можно с легкостью решить, используя паттерн состояния (State machine). Очень хороший доклад от яндекс. В этой же статье постараюсь поделиться пережеванными мыслями по этому докладу.

Теперь немного кода:

BaseState

public interface BaseState extends Parcelable{

    /**
     * Get name
     *
     * @return name
     */
    @NonNull
    String getName();

    /**
     * Enter to state
     *
     * @param aView view
     */
    void onEnter(@NonNull VIEW aView);

    /**
     * Exit from state
     */
    void onExit();

    /**
     * Return to next state
     */
    void forward();

    /**
     * Return to previous state
     */
    void back();

    /**
     * Invalidate view
     *
     * @param aView view
     */
    void invalidateView(@NonNull VIEW aView);

    /**
     * Get owner
     *
     * @return owner
     */
    @NonNull
    OWNER getOwner();

    /**
     * Set owner
     *
     * @param aOwner owner
     */
    void setOwner(@NonNull OWNER aOwner);
}


BaseOwner

public interface BaseOwner extends BasePresenter{

    /**
     * Set state
     *
     * @param aState state
     */
    void setState(@NonNull STATE aState);
}


BaseStateImpl

public abstract class BaseStateImpl implements BaseState{

    private OWNER mOwner;

    @NonNull
    @Override
    public String getName(){
        return getClass().getName();
    }

    @Override
    public void onEnter(@NonNull final VIEW aView){
        Timber.d( getName()+" onEnter");
        //depend from realization
    }

    @Override
    public void onExit(){
        Timber.d(getName()+" onExit");
        //depend from realization
    }

    @Override
    public void forward(){
        Timber.d(getName()+" forward");
        onExit();
        //depend from realization
    }

    @Override
    public void back(){
        Timber.d(getName()+" back");
        onExit();
        //depend from realization
    }

    @Override
    public void invalidateView(@NonNull final VIEW aView){
        Timber.d(getName()+" invalidateView");
        //depend from realization
    }

    @NonNull
    @Override
    public OWNER getOwner(){
        return mOwner;
    }

    @Override
    public void setOwner(@NonNull final OWNER aOwner){
        mOwner = aOwner;
    }


В нашем случае state owner будет презентер.

Рассматривая страницу логина можно выделить три уникальных состояния:

LoginInitState, LoginProgressingState, LoginCompleteState.

Итак, рассмотрим теперь, что происходит в этих состояниях.

LoginInitState у нас происходит валидация полей и в случае успешной валидации кнопка login становится активной.

В LoginProgressingState делается запрос логина, сохраняется токен, делаются дополнительные запросы для старта главной активити приложения.

В LoginCompleteState осуществляется навигация на главный экран приложения.

Условно переход между состояниями можно отобразить на следующей диаграмме:

Диаграмма состояний логина

Выход из состояния LoginProgressingState происходит в случае успешной операции логина в состояние LoginCompleteState, а в случае сбоя в LoginInitState. Таким образом, когда у нас вьюха детачится, мы имеем вполне детерменированное состояние презентера. Это состояние мы должны сохранить, используя стандартный механизм андроида onSaveInstanceState. Для того, чтобы мы могли это сделать, все состояния логина должны имплементировать интерфейс Parcelable. Поэтому расширяем наш базовый интерфейс BaseState.

Далее у нас встает вопрос, как пробросить это состояние из презентера в наш хост? Самый простой способ — из хоста попросить данные у презентера, но с точки зрения архитектуры это выглядит не очень. И поэтому нам на помощь приходят retain фрагменты. Мы можем создать интерфейс для кэша и имплементировать его в таком фрагменте:

public interface Cache{

    /**
     * Save cache data
     *
     * @param aData data
     */
    void saveCacheData(@Nullable Parcelable aData);

    @Nullable
    Parcelable getCacheData();

    /**
     * Check that cache exist
     *
     * @return true if cache exist
     */
    boolean isCacheExist();
}


Далее мы инжектим кэш фрагмент в конструктор интерактора, как Cache. Добавляем методы в интеректоре для получения и сохранения состояния в кэше. Теперь, при каждом изменении состояния презентера, мы можем сохранить состояние в интеракторе, а интерактор сохраняет в свою очередь в кэше. Все становится весьма логично. При первичной загрузке хоста, презентер получает состояние у интерактора, который в свою очередь получает данные из кэша. Так выглядит метод изменения состояния в презентере:

@Override
    public void setState(@NonNull final LoginBaseState aState){
        mState.onExit();
        mState = aState;
        clearDisposables();
        mState.setOwner(this);
        mState.onEnter(getView());
        mInteractor.setState(mState);
    }


Хочется отметить такой момент — сохранение данных через кэш можно производить для любых данных, не только для состояния. Возможно, вам придется сделать свой уникальный кэш фрагмент для хранения текущих данных. В данной статье рассказан общий подход. Также хочется отметить, что рассматриваемая ситуация очень утрированная. В жизни приходится решать задачи намного сложнее. К примеру, у нас в приложении были совмещены три страницы: логин, регистрация, восстановления пароля. При этом диаграмма состояний выглядела следующим образом:

Диаграмма состояний в реальном проекте

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

Полный код можно посмотреть в репозитории.

© Habrahabr.ru