Повороты экрана в 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.

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

Подробнее
Реклама
Комментарии 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, но на практике мне пока не приходилось такое делать даже на довольно нагруженных экранах.

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