
Важно!
Изначально в статье была реализация с ошибкой. Ошибку исправил, статью немного поправил.
Предисловие
Истинное понимание проблем каждой платформы приходит после того, как попробуешь писать под другую платформу / на другом языке. И вот как раз после того, как я познакомился с разработкой под 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.
Готовый и исправленный код тут
