Повороты экрана в Android без боли

image

Важно!
Изначально в статье была реализация с ошибкой. Ошибку исправил, статью немного поправил.

Предисловие


Истинное понимание проблем каждой платформы приходит после того, как попробуешь писать под другую платформу / на другом языке. И вот как раз после того, как я познакомился с разработкой под iOS, я задумался над тем, насколько ужасна реализация поворотов экрана в Android. С того момента я думал над решением данной проблемы. Попутно я начал использовать реактивное программирование везде, где только можно и уже даже не представляю как писать приложения по-другому.



И вот я узнал про последнюю недостающую деталь — Data Binding. Как-то эта библиотека прошла мимо меня в свое время, да и все статьи, что я читал (что на русском, что на английском) рассказывали не совсем про то, что мне было необходимо. И вот сейчас я хочу рассказать про реализацию приложения, когда можно будет забыть про повороты экранов вообще, все данные будут сохраняться без нашего прямого вмешательства для каждого активити.

Когда начались проблемы?


По настоящему остро я почувствовал проблему, когда в одном проекте у меня получился экран на 1500 строк xml, по дизайну и ТЗ там было целая куча различных полей, которые становились видимыми при разных условиях. Получилось 15 различных layout’ов, каждый из которых мог быть видимым или нет. Плюс к этому была еще куча различных объектов, значения которых влияют на вьюху. Можете представить уровень проблем в момент поворота экрана.

Возможное решение


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

Я назову это реактивным MVVM. Абсолютно любой экран можно представить в виде объекта: TextView — параметр String, видимость объекта или ProgressBar’а — параметр Boolean и т.д… А так же абсолютно любое действие можно представить в виде Observable: нажатие кнопки, ввод текста в EditText и т.п…

Вот тут я советую остановиться и прочитать несколько статей про Data Binding, если еще не знакомы с этой библиотекой, благо, на хабре их полно.

Да начнется магия


Перед тем как начать создавать нашу активити, создадим базовые классы для активити и ViewModel'ли, где и будет происходить вся магия.

Update!
После общения в комментариях, осознал свою ошибку. Суть в том, что в моей первой реализации ничего не сериализуется, но все работает при поворотах экрана, да даже при сворачивании, разворачивании экрана. В комментариях ниже обязательно почитайте почему так происходит. Ну а я исправлю код и поправлю комментарии к нему.

Для начала, напишем базовую ViewModel:

public abstract class BaseViewModel extends BaseObservable {

  
    private CompositeDisposable disposables; //Для удобного управления подписками
    private Activity activity;


    protected BaseViewModel() {
        disposables = new CompositeDisposable();
    }


    /**
     * Метод добавления новых подписчиков
     */
    protected void newDisposable(Disposable disposable) {
        disposables.add(disposable);
    }

    /**
     * Метод для отписки всех подписок разом
     */
    public void globalDispose() {
        disposables.dispose();
    }


    protected Activity getActivity() {
        return activity;
    }

    public void setActivity(Activity activity) {
        this.activity = activity;
    }

    public boolean isSetActivity() {
        return (activity != null);
    }

}

Я уже говорил, что все что угодно можно представить как Observable? И библиотека RxBinding отлично это делает, но вот беда, мы работает не напрямую с объектами, типа EditText, а с параметрами типа ObservableField. Что бы радоваться жизни и дальше, нам необходимо написать функцию, которая будет делать из ObservableField необходимый нам Observable RxJava2:

protected static <T> Observable<T> toObservable(@NonNull final ObservableField<T> observableField) {

        return Observable.fromPublisher(asyncEmitter -> {
            final OnPropertyChangedCallback callback = new OnPropertyChangedCallback() {
                @Override
                public void onPropertyChanged(android.databinding.Observable dataBindingObservable, int propertyId) {
                    if (dataBindingObservable == observableField) {
                        asyncEmitter.onNext(observableField.get());
                    }
                }
            };
            observableField.addOnPropertyChangedCallback(callback);
        });
    }

Тут все просто, передаем на вход ObservableField и получаем Observable RxJava2. Именно для этого мы наследуем базовый класс от BaseObservable. Добавим этот метод в наш базовый класс.

Теперь напишем базовый класс для активити:

public abstract class BaseActivity<T extends BaseViewModel> extends AppCompatActivity {

    private static final String DATA = "data"; //Для сохранения данных
    private T data; //Дженерик, ибо для каждого активити используется своя ViewModel


    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        if (savedInstanceState != null)
            data = savedInstanceState.getParcelable(DATA); //Восстанавливаем данные если они есть
        else
            connectData(); //Если нету - подключаем новые


        setActivity(); //Привязываем активити для ViewModel (если не используем Dagger)
        super.onCreate(savedInstanceState);
    }

    @Override
    public void onSaveInstanceState(Bundle outState) {
        super.onSaveInstanceState(outState);
        if (data != null) {
            Log.d("my", "Данные сохранены");
            outState.putParcelable(DATA, (Parcelable) data);
        }

    }


    /**
     * Метод onDestroy будет вызываться при любом повороте экрана, так что нам нужно знать
     * что мы сами закрываем активити, что бы уничтожить данные.
     */
    @Override
    public void onDestroy() {
        super.onDestroy();
        Log.d("my", "onDestroy");
        if (isFinishing())
            destroyData();
    }


    /**
     * Этот метод нужен только если вы не используете DI.
     * А так, это простой способ передать активити для каких-то действий с preferences или DB
     */
    private void setActivity() {
        if (data != null) {
            if (!data.isSetActivity())
                data.setActivity(this);
        }
    }


    /**
     * Возврощаем данные
     *
     * @return возврощает ViewModel, которая прикреплена за конкретной активити
     */
    public T getData() {
        Log.d("my", "Отдаем данные");
        return data;
    }

    /**
     * Прикрепляем ViewModel к активити
     *
     * @param data
     */
    public void setData(T data) {
        this.data = data;
    }


    /**
     * Уничтожаем данные, предварительно отписываемся от всех подписок Rx
     */
    public void destroyData() {
        if (data != null) {
            data.globalDispose();
            data = null;
            Log.d("my", "Данные уничтожены");
        }
    }


    /**
     * Абстрактный метод, который вызывается, если у нас еще нет сохраненных данных
     */
    protected abstract void connectData();


}

Я постарался подробно прокомментировать код, но заострю внимание на нескольких вещах.
Активити, при повороте экрана всегда уничтожается. Тогда, при восстановлении снова вызывается метод onCreate. Вот как раз в методе onCreate нам и нужно восстанавливать данные, предварительно проверив, сохраняли ли мы какие-либо данные. Сохранение данных происходит в методе onSaveInstanceState.

При повороте экрана нас интересует порядок вызовов методов, а он такой (то, что интересует нас):

1) onDestroy
2) onSaveInstanceState

Что бы не сохранять уже не нужные данные мы добавили проверку:

 if (isFinishing())

Дело в том, что метод isFinishing вернет true только если мы явно вызвали метод finish() в активити, либо же ОС сама уничтожила активити из-за нехватки памяти. В этих случаях нам нет необходимости сохранять данные.

Реализация приложения


Представим условную задачу: нам необходимо сделать экран, где будет 1 EditText, 1 TextView и 1 кнопка. Кнопка не должна быть кликабельной до тех пор, пока пользователь не введет в EditText цифру 7. Сама же кнопка будет считать количество нажатий на нее, отображая их через TextView.

Update!
Пишем нашу ViewModel:

public class ViewModel extends BaseViewModel implements Parcelable {
 

    public static final Creator<ViewModel> CREATOR = new Creator<ViewModel>() {
        @Override
        public ViewModel createFromParcel(Parcel in) {
            return new ViewModel(in);
        }

        @Override
        public ViewModel[] newArray(int size) {
            return new ViewModel[size];
        }
    };
    private ObservableBoolean isButtonEnabled = new ObservableBoolean(false);
    private ObservableField<String> count = new ObservableField<>();
    private ObservableField<String> inputText = new ObservableField<>();

    public ViewModel() {
        count.set("0"); //Что бы не делать проверку на ноль при плюсе
        setInputText();
    }

    protected ViewModel(Parcel in) {
        isButtonEnabled = in.readParcelable(ObservableBoolean.class.getClassLoader());
        inputText = (ObservableField<String>) in.readSerializable();
        count = (ObservableField<String>) in.readSerializable();
        setInputText();
    }

    private void setInputText() {
        newDisposable(toObservable(inputText)
                .debounce(2000, TimeUnit.MILLISECONDS) //Для имитации ответа от сервера
                .subscribeOn(Schedulers.newThread()) //Работаем не в основном потоке
                .subscribe(s -> {
                            if (s.contains("7"))
                                isButtonEnabled.set(true);
                            else
                                isButtonEnabled.set(false);
                        },
                        Throwable::printStackTrace));
    }


    /**
     * Добавляем значение в счетчик
     */
    public void addCount() {
        count.set(String.valueOf(Integer.valueOf(count.get()) + 1));
    }


    public ObservableField<String> getInputText() {
        return inputText;
    }

    public ObservableField<String> getCount() {
        return count;
    }

    public ObservableBoolean getIsButtonEnabled() {
        return isButtonEnabled;
    }

    @Override
    public int describeContents() {
        return 0;
    }

    @Override
    public void writeToParcel(Parcel dest, int flags) {
        dest.writeParcelable(isButtonEnabled, flags);
        dest.writeSerializable(inputText);
        dest.writeSerializable(count);
    }


}

Update
Вот тут и и были самые большие проблемы. Все работало и при старой реализации, ровно до того момента, пока в настройках разработчика не включить параметр «Don't keep activities».

Что бы все работало как надо, необходимо реализовывать интерфейс Parcelable для ViewModel. По поводу реализации ничего писать не буду, только уточню еще 1 момент:
private void setInputText() {
        newDisposable(toObservable(inputText)
                .debounce(2000, TimeUnit.MILLISECONDS) //Для имитации ответа от сервера
                .subscribeOn(Schedulers.newThread()) //Работаем не в основном потоке
                .subscribe(s -> {
                            if (s.contains("7"))
                                isButtonEnabled.set(true);
                            else
                                isButtonEnabled.set(false);
                        },
                        Throwable::printStackTrace));
    }

Данные-то мы возвращаем, а вот Observable мы теряем. Поэтому пришлось выводить в отдельный метод и вызывать его во всех конструкторах. Это очень быстрое решение проблемы, не было времени подумать лучше, нужно было было указать на ошибку. Если у кого-то есть идеи как реализовать это лучше, пожалуйста, поделитесь.

Теперь напишем для этой модели view:

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

    <data>

        <variable
            name="viewModel"
            type="com.quinque.aether.reactivemvvm.ViewModel"/>

    </data>

    <RelativeLayout
        xmlns:tools="http://schemas.android.com/tools"

        android:id="@+id/activity_main"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:paddingBottom="@dimen/activity_vertical_margin"
        android:paddingLeft="@dimen/activity_horizontal_margin"
        android:paddingRight="@dimen/activity_horizontal_margin"
        android:paddingTop="@dimen/activity_vertical_margin"
        tools:context="com.quinque.aether.reactivemvvm.MainActivity">

        <EditText
            android:id="@+id/edit_text"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:hint="Тект сюда"
            android:text="@={viewModel.inputText}"/>

        <Button
            android:id="@+id/add_count_button"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_below="@+id/edit_text"
            android:enabled="@{viewModel.isButtonEnabled}"
            android:onClick="@{() -> viewModel.addCount()}"
            android:text="+"/>

        <TextView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_below="@+id/add_count_button"
            android:layout_centerHorizontal="true"
            android:layout_marginTop="7dp"
            android:text="@={viewModel.count}"/>

    </RelativeLayout>
</layout>

Ну и теперь, мы пишем нашу активити:

public class MainActivity extends BaseActivity<ViewModel> {

    ActivityMainBinding binding;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        binding = DataBindingUtil.setContentView(this, R.layout.activity_main); //Биндим view
        binding.setViewModel(getData()); //Устанавливаем ViewModel, при этом методом getData, что бы вручную не сохронять данные
    }


    //Тут можно делать какие угодно предварительные шаги для создания ViewModel
    @Override
    protected void connectData() {
        setData(new ViewModel()); //Но данные устанавливаются только методом setData
    }
}

Запускаем приложение. Кнопка не кликабельна, счетчик показывает 0. Вводим цифру 7, вертим телефон как хотим, через 2 секунды, в любом случае кнопка становится активной, тыкаем на кнопку и счетчик растет. Стираем цифру, вертим телефоном снова — кнопка все равно через 2 секунды будет не кликабельна, а счетчик не сбросится.

Все, мы получили реализацию безболезненного поворота экрана без потери данных. При этом будут сохранены не только ObservableField и тому подобные, но так же и объекты, массивы и простые параметры, типа int.

Готовый и исправленный код тут
Поделиться публикацией
Похожие публикации
Ой, у вас баннер убежал!

Ну, и что?
Реклама
Комментарии 15
  • –2
    А что насчёт?
    setRetainInstance(true);
    

    Биндинг модели во вьюшку — это хорошо, но иногда может быть избыточно.
    • +1
      setRetainInstance(true) не избавляет вас от обязанности сохранять состояние, так как на не помогает в случаях когда Activity была уничтожена и затем восстановлена. Я вообще советую никогда эту опцию не использовать. Разве только в редких случаях, когда восстановление View крайне дорогая операция.
    • +4
      Интересная проблема выискивается после того, как мы откроем другую активити и вернемся назад, а все введенные данные останутся, ибо при открытии и возврате не вызывается метод onCreate.
      Не вижу ни какой проблемы. При возвращении в предыдущую Activity она, в большинстве случаев, должна сохранять свое состояние.
      • 0
        Тут дело в том, что даже введенный текст в EditText сохранится, если оставить так как есть.
        • 0
          Если что, в EditText текст сохраняется и без чьей либо помощи. Это уже по умолчанию зашито в самой Activity.
          • 0

            Большинство стандартные компоненты сохраняют свое состояние, если у них есть уникальный ID

        • +1

          Вы пишете, что при убийстве из-за ннхватки памяти нам не нужно сохранять данные.


          имхо, правильное поведение, когда пользовиель не знает, что происходило с процессом пока он был в фоне и все его данные сохраняются если он явно не пожелал обратного (например, закрыв активити)

          • 0
            Сейчас пересмотрел код еще раз и заметил, что при наследовании от BaseViewModel вы не определяете новый CREATOR. В таком случае, получается, у вас из Parselable должен восстановиться объект BaseViewModel а не ViewModel. Да и не видно работы с Parcel в самой ViewModel. Это так задумано или ошибка по недосмотру?
            • 0
              Вот тут-то и скрыта самая сильная магия. Так и задумано, это не ошибка. Я, честно скажу, я не знаю почему, но даже в таком виде ViewModel сохраняется полностью. Я пробовал записывать туда массивы, другие объекты, простые типы, все сохраняется даже при пустом CREATOR. Попробуйте запустить этот код и убедитесь. Кто может объяснить почему такое происходит?
              • 0

                Осмелюсь предположить, что на самом деле ничего-то толком и не сохраняется, сохраняется только ссылка на ViewModel, которая потом восстанавливается. В javadoc-ах к Parcel упоминается интересная особенность:


                An unusual feature of Parcel is the ability to read and write active
                • objects. For these objects the actual contents of the object is not
                • written, rather a special token referencing the object is written. When
                • reading the object back from the Parcel, you do not get a new instance of
                • the object, but rather a handle that operates on the exact same object that
                • was originally written.

                То есть, фактически, ничего не сериализуется, просто если не надо передавать данные меджу процессами (а Parcelable в том числе было сделано для IPC), в целях оптимизации производительности где-то магически держится ссылка на объект ViewModel, который в свою очередь держит ссылки на все объекты внутри себя. Это можно проверить, убив активити (например, свернув его и нажав в Android Studio на панели Android Monitor кнопку "Terminate Application"). Если данные не сериализовались, а просто где-то держалась ссылка на ViewModel, данные не восстановлятся.
                Это скорее предположение + немного смутных воспоминаний из своего опыта. Стоит проверить.

                • 0
                  Да, действительно. Если включить Don't keep activities, то все летит в нехорошие места. Получается на самом деле нужно реализовывать Creator в классах наследниках. А в таком случае реализовывать Parceble в базовом классе вообще нету смысла.
                • 0
                  Никакой тут магии нет. Вы просто эксплуатируете незадокументированное поведение Android API: cистема не сериализует вашу ViewModel при поворотах, а сохраняет во временой переменой. Вся магия начнется когда вы пойдете в настройки, включите Don't keep activities, перейдете на новую Activity, вернетесь назад:

                  Caused by: java.lang.ClassCastException: com.quinque.aether.reactivemvvm.base.BaseViewModel cannot be cast to com.quinque.aether.reactivemvvm.ViewModel

                  На самом деле ваше решение ничем не отличается от того чтоб создать статическое поле и туда записывать вашу ViewModel на момент пересоздания Activity. Вся прелесть Parcelable что модель должна выживать даже когда Android убивает вашу Activity.
                  • 0
                    Спасибо на указание ошибки, поправил код и статью. Стало не так все красиво и уже не без боли, но основная идея все еще видна. Еще буду думать над более изящным решением.
                    • 0

                      Думаю вам стоит посмотреть в сторону Loaders. Насколько мне известно это сейчас стандратное решение для данной проблемы.

                      • +1

                        Да нет, что вы, лоадеры помогают в некоторой степени бороться с поворотами, но от нехватки ресурсов все так же не спасают. Я в последнее время решил не бороться с платформой, а подружиться с ней. Фактически, Теперь у меня при убийстве презентеры (у меня MVP, а не MVVM на текущем проекте, но сути это не меняет) умирают и пересоздаются вместе с активити/фрагментами. Все, что надо, сохраняю в Bundle. Как бонус, теперь не нужно проверять, прикреплена ли View к презентеру, потому что они не существуют раздельно, плюс поддерживается восстановление не только при смене ориентации, но и при смерти от нехватки ресурсов.

                        Скрытый текст
                        За этим должны следить все приложения, но очень многие об этом либо забывают, либо не заморачиваются. Очень разозлило, когда писал комментарий в Redmine, потом пошел что-то погуглить, и по возвращению увидел, что мой комментарий исчез.


                        Теоретически может возникнуть ситуация, когда при пересоздании активити восстанавливается слишком много всего, тогда можно отключить пересоздание этой activity и обрабатывать ситуацию вручную (при восстановлении после нехватки памяти время не так критично, потому что пользователь покидал экран/приложение и не ждет моментального отклика) или сделать retain fragment, но на практике мне пока не приходилось такое делать даже на довольно нагруженных экранах.

              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

              Самое читаемое