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

onSaveInstanceState

Сколько боли он нам принес.

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

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

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

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

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

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

BaseState

public interface BaseState<VIEW extends BaseView, OWNER extends BaseOwner> 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<VIEW extends BaseView, STATE extends BaseState> extends BasePresenter<VIEW>{

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

BaseStateImpl

public abstract class BaseStateImpl<VIEW extends BaseView, OWNER extends BaseOwner> implements BaseState<VIEW, OWNER>{

    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);
    }

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

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

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

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