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

от автора

image

Предисловие

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



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

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

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

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

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

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

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

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

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

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

public class BaseViewModel extends BaseObservable implements Parcelable {      public static final Creator<BaseViewModel> CREATOR = new Creator<BaseViewModel>() {         @Override         public BaseViewModel createFromParcel(Parcel in) {             return new BaseViewModel(in);         }          @Override         public BaseViewModel[] newArray(int size) {             return new BaseViewModel[size];         }     };     private CompositeDisposable disposables; //Для удобного управления подписками     private Activity activity;       protected BaseViewModel() {         disposables = new CompositeDisposable();     }      private BaseViewModel(Parcel in) {      }       /**      * Метод добавления новых подписчиков      */     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);     }       @Override     public int describeContents() {         return 0;     }      @Override     public void writeToParcel(Parcel dest, int flags) {      } }

Я уже говорил, что все что угодно можно представить как 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. Добавим этот метод в наш базовый класс. А реализация интерфейса Parcelable необходима для сохранения объекта в методе onSaveInstanceState.

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

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)             outState.putParcelable(DATA, data);     }      /**      * Метод onDestroy будет вызываться при любом повороте экрана, так что нам нужно знать      * что мы сами закрываем активити, что бы уничтожить данные.      */     @Override     public void onDestroy() {         super.onDestroy();         if (isFinishing())             destroyData();     }      /**      * Этот метод нужен только если вы не используете DI.      * А так, это простой способ передать активити для каких-то действий с preferences или DB      */     private void setActivity() {         if (data != null) {             if (!data.isSetActivity())                 data.setActivity(this);         }     }      /**      * Возвращаем данные      *      * @return возвращает ViewModel, которая прикреплена за конкретной активити      */     public T getData() {         return data;     }      /**      * Прикрепляем ViewModel к активити      */     public void setData(T data) {         this.data = data;     }      /**      * Уничтожаем данные, предварительно отписываемся от всех подписок Rx      */     public void destroyData() {         if (data != null) {             data.globalDispose();             data = null;         }     }      /**      * Абстрактный метод, который вызывается, если у нас еще нет сохраненных данных      */     protected abstract void connectData(); }

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

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

1) onDestroy
2) onSaveInstanceState

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

 if (isFinishing())

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

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

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

Пишем нашу ViewModel:

public class ViewModel extends BaseViewModel {       private ObservableBoolean isButtonEnabled = new ObservableBoolean(false);     private ObservableField<String> count = new ObservableField<>();     private ObservableField<String> inputText = new ObservableField<>();      public ViewModel() {         count.set("0"); //Чтобы не делать проверку на ноль при плюсе         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 ObservableField<String> getInputText() {         return inputText;     }      public ObservableField<String> getCount() {         return count;     }      public ObservableBoolean getIsButtonEnabled() {         return isButtonEnabled;     }      /**      * Добавляем значение в счетчик      */     public void addCount() {         count.set(String.valueOf(Integer.valueOf(count.get()) + 1));     }   }

Теперь напишем для этой модели 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.

Готовый код тут

Неожиданные проблемы

Интересная проблема выискивается после того, как мы откроем другую активити и вернемся назад, а все введенные данные останутся, ибо при открытии и возврате не вызывается метод onCreate. Но о моих возможных решениях этой проблемы я напишу в следующей статье.
ссылка на оригинал статьи https://habrahabr.ru/post/328512/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *