Cохранение состояний в android приложениях +11


Сегодня я хотел поделиться с вами еще одним подходом сохранения состояния при разработке 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);
    }

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

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

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

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

Вы можете помочь и перевести немного средств на развитие сайта



Комментарии (12):

  1. PainMain
    /#18988707

    Будут ли примеры на Kotlin?

    • veretelnikov
      /#18988717

      Пока что смысла не вижу, если не появится идея как расширить эту статью

  2. smirnov_sergey
    /#18988927

    Репозиторий на GitHub пустой

    • veretelnikov
      /#18988953

      Не правда) в develop весь код, а master на которую смотрит репозиторий пустая. Ссылку изменил, чтобы удобнее было смотреть.

  3. Dimezis
    /#18990171 / +1

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

    Во-первых, не стоит пытаться сохранить в SavedInstanceState все на свете.
    В большинстве случаев, все уже сделано за вас. Стек активити и фрагментов сохраняется, состояния EditText, включая Error — тоже.

    Какой смысл заморачиваться с сохранением состояния а-ля «нажал на кнопку Login»? Какова вероятность, что вас процесс резко убъется посреди реквеста? Вместо этого стоило бы лучше подумать о том, чтобы этот реквест не делался заново при перевороте экрана, например.

    То же самое с состоянием LoginComplete. В какой ситуации вам нужно восстановить это состояние из бандла? Вы либо уже открыли следующий скрин, и соотвественно стэк активити восстановится после смерти процесса, либо вы отменили реквест и не перешли в это состояние вообще.

    Фрагмент с кэшем для одного типа данных, еще и не типобезопасным — тоже такое себе удовольствие.

    А еще обилие бесполезных пустых и Base классов не делает вашу архитектуру Clean.

  4. veretelnikov
    /#18990703

    Спасибо за комментарий)

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

    Поэтому в статье и рассматривался такой сценарий, в реальности решали задачу сложнее, как видно в конце статьи.
    Какой смысл заморачиваться с сохранением состояния а-ля «нажал на кнопку Login»? Какова вероятность, что вас процесс резко убъется посреди реквеста? Вместо этого стоило бы лучше подумать о том, чтобы этот реквест не делался заново при перевороте экрана, например.

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

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

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

    • Dimezis
      /#18991549

      Поэтому в статье и рассматривался такой сценарий, в реальности решали задачу сложнее, как видно в конце статьи.

      Я имею в виду, что в сложных задачах писанины еще больше.

      Вьюха может отдетачится, и метод перехода не вызовится

      Если вьюха отдетачится, и переход не вызовется, то у вас и состояние это теряется.
      При детаче вы отписываетесь от чейна, значит onComplete/onNext не вызовется, и соответственно этот код — тоже.
      setState(new LoginCompleteState());


      При этом вся прелесть в том что мы не храним флаги в презентере, а решается на уровне объектов наших состояний.

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

      • veretelnikov
        /#18993223

        Если вьюха отдетачится, и переход не вызовется, то у вас и состояние это теряется.
        При детаче вы отписываетесь от чейна, значит onComplete/onNext не вызовется, и соответственно этот код — тоже.
        setState(new LoginCompleteState());

        Не совсем так, кто сказал, что вьюха отдетачилась именно на моменте когда у нас запрос еще делается, она может отдетачиться когда, вызвался метод subscribe и выставилось состояние, но при этом метод navigateToMainView еще не вызвался. Кейс с очень низкой вероятностью, но такое может быть.
        Это хорошо, но мой посыл не то, что состояния — это плохо, а то что не нужно каждое состояние делать Parcelable и извращаться с их сохранением. Особенно когда часть этих состояний и так сохранится без вашего участия.

        Визуально состояния элементов сохранятся, с этим никто не спорит, даже прогресс диалог будет показываться, если комит в фрагмент менеджер произошел до вызова onSavedInstanceState. Но сохранится ли текущая реакция приложения на действия пользователя? Теперь, давайте вернемся к последней схеме статьи.
        Три кейса логин, восстановление пароля, регистрация. Элементы в разметке: email, password, first name, last name, btn. Каждый элемент должен себя вести в разных состояниях по разному: разные правила валидации поля, разные действия при нажатии на кнопку. Без сохранения текущего состояния мы получим полностью не рабочий интерфейс.

        • Dimezis
          /#18995551

          Не совсем так, кто сказал, что вьюха отдетачилась именно на моменте когда у нас запрос еще делается, она может отдетачиться когда, вызвался метод subscribe и выставилось состояние, но при этом метод navigateToMainView еще не вызвался

          Нет, это невозможно.
          Детач и вызов setState происходит в одном и том же треде. Последовательно. В setState вы сразу делаете переход navigateToMainView. Либо вы отдетачитесь и теряете стейт, либо вы на этот стейт переходите и потом детачитесь. Никакого рейс кондишина здесь не может быть.

      • veretelnikov
        /#18993241

        Плюс ко всему в котлине parcelable идет практически из коробки.

  5. Starksoft
    /#18990745

    Меня одного смутило, что слой View возвращает значения? Как же Void в обе стороны для слоев в MVP?

    • veretelnikov
      /#18990753

      Вы про actions у view? В таком рассмотрении, в отличии от привычного нам, не вьюха дергает презентер, что в плане архитектуры не правильно, а наоборот презентер реагирует на изменения вьюхи. А так да, в классическом рассмотрении методы должны быть глухими.