Решил я однажды таки попробовать дико популярный нынче Rx. А заодно и Retrofit. И посмотреть, как с их помощью реализовать стандартную задачу: получить с сервера набор данных, отобразить их и при этом ничего не терять при поворотах экрана и не делать лишних запросов. Первый вариант у меня получился сразу почти — просто взял и вызвал cache() на Observable, получаемый из синглтона, но он меня не устраивал — для принудительного обновления приходилось, по какой-то причине, пересоздавать экземпляры классов Retrofit и его же реализации моего интерфейса для API. Пересоздание же самого Observable эффекта не давало — всегда возвращались старые данные вместо запуска нового сетевого запроса и получения новых данных.
После долгих мучений с новой для себя технологией выяснил, что во всём был виновен cache() (точнее, наверное, моё неправильное оного понимание). В итоге сделал так: фрагмент запускает метод, подписывающий Subscriber синглтона на Observable retrofit-a, коий запускает onNext и onError BehaviorSubject-a, на который подписывается уже Subscriber фрагмента. Код на GitHub тут, подробности — под катом.
Итак, приступим. Сначала напишем простейший php код, коий будет отдавать JSON. Чтобы успевать повернуть экран сделаем так чтобы перед отдачей данных была задержка секунд в 5.
<?php $string = '[ { "title": "Some awesome title 1", "text": "Lorem ipsum dolor sit amet..." }, { "title": "Some awesome title 2", "text": "Lorem ipsum dolor sit amet..." } ]'; $seconds = 5; sleep($seconds); $json = json_decode($string); print json_encode($json, JSON_PRETTY_PRINT);
Теперь зависимости в gradle:
compile 'com.android.support:appcompat-v7:23.3.0' compile 'com.android.support:design:23.3.0' compile 'com.android.support:cardview-v7:23.3.0' compile 'com.android.support:recyclerview-v7:23.3.0' compile 'io.reactivex:rxjava:1.1.3' compile 'io.reactivex:rxandroid:1.1.0' compile 'com.squareup.retrofit2:adapter-rxjava:2.0.2' compile 'com.squareup.retrofit2:retrofit:2.1.0' compile 'com.squareup.retrofit2:converter-gson:2.1.0' compile 'com.google.code.gson:gson:2.6.2'
Использовать более свежие версии либ от гугла не будем — уж столько раз обжигался на их бездумном обновлении у себя в проектах… То атрибуты какие-нибудь в стилях виджетов поменяют, то баг, уже однажды поправленный вернут, то новый придумают. Версия 23.3.0 работает относительно стабильно, засим берём её.
Переходим к коду. Вот какая структура проекта у меня получилась:

Разметка активити будет простой, вот она:
<?xml version="1.0" encoding="utf-8"?> <android.support.design.widget.CoordinatorLayout android:id="@+id/root" xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" android:fitsSystemWindows="true"> <android.support.design.widget.AppBarLayout android:id="@+id/app_bar_layout" android:layout_width="match_parent" android:layout_height="?attr/actionBarSize" android:minHeight="?attr/actionBarSize"> <android.support.v7.widget.Toolbar android:id="@+id/toolbar" android:layout_width="match_parent" android:layout_height="?attr/actionBarSize" app:layout_scrollFlags="scroll|enterAlways"/> </android.support.design.widget.AppBarLayout> <FrameLayout android:id="@+id/container" android:layout_width="match_parent" android:layout_height="match_parent" app:layout_behavior="@string/appbar_scrolling_view_behavior" android:paddingEnd="@dimen/activity_horizontal_margin" android:paddingLeft="@dimen/activity_horizontal_margin" android:paddingRight="@dimen/activity_horizontal_margin" android:paddingStart="@dimen/activity_horizontal_margin"/> </android.support.design.widget.CoordinatorLayout>
Код в активити не менее лаконичен:
public class MainActivity extends AppCompatActivity { private Toolbar toolbar; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); initViews(); setSupportActionBar(toolbar); Fragment fragmentHotelsList = getSupportFragmentManager().findFragmentById(R.id.container); if (fragmentHotelsList == null) { fragmentHotelsList = new ModelsListFragment(); getSupportFragmentManager(). beginTransaction().add(R.id.container, fragmentHotelsList) .commit(); } } private void initViews() { toolbar = (Toolbar) findViewById(R.id.toolbar); } }
Основа готова, теперь о том, как приложени�� должно себя вести:
- При запуске приложения должен стартовать запрос в сеть.
- Ответом должны служить либо данные, либо ошибка.
- При повороте экрана и пересоздании активити/фрагмента мы должны отобразить уже загруженные данные, если они есть. Если же их нет или был запущен до этого ещё не завершённый запрос о новых данных, мы должны отобразить индикатор загрузки и подписаться на получение данных.
- Естественно, мы не хотим ни терять данные, ни повторно запускать новый запрос в сеть.
- Также нам нужна возможность принудительного обновления данных.
Как упоминалось в начале, я возлагал большие надежды на cache(), но, насколько я понял, он кэширует сам запрос в сеть и даже пересоздание Observable не позволяет делать новый запрос в сеть без пересоздания ещё и объектов Retrofita, что, очевидно, неправильный путь. Поначалу я никак не мог сообразить как же мне поступить. Поковыряв код и так и сяк пару часов решился на крайние меры — задал вопрос на stackoverflow. Там мне не ответили прямо, но дали 2 подсказки — про уже помянутое поведение cache() и про то, что можно попробовать использовать BehaviorSubject, который может как получать, так и отправлять данные, да ещё и хранящий последние данные в себе.
С последним возникла сразу небольшая проблема — не долго думая я подписал BehaviorSubject на Observable retrofit-a, а фрагмент на BehaviorSubject. Вроде всё верно, вот только если во время поворота экрана задача будет завершена, то фрагмент в качестве последних данных получит… правильно — событие onComplete, а не сами данные. Тут я ненадолго завис, пытаясь загуглить как помешать Observable излучать событие окончания работы или как его игнорировать у подписчиков. Гугл молчал и всячески этим намекал, что я не в ту сторону капаю. И да — подобная идея могла придти в голову только новичку в технологии) Решение оказалось простым — вместо попыток изменить поведение Observable я просто не стал подписывать на него BehaviorSubject, а просто в колбэках первого (onNext и onError) вызвал соответствующие методы второго. А onComplete — проигнорировал.
В итоге вот такой получился синглтон:
public class RetrofitSingleton { private static final String TAG = RetrofitSingleton.class.getSimpleName(); private static Observable<ArrayList<Model>> observableRetrofit; private static BehaviorSubject<ArrayList<Model>> observableModelsList; private static Subscription subscription; private RetrofitSingleton() { } public static void init() { Log.d(TAG, "init"); RxJavaCallAdapterFactory rxAdapter = RxJavaCallAdapterFactory.createWithScheduler(Schedulers.io()); Gson gson = new GsonBuilder().create(); Retrofit retrofit = new Retrofit.Builder() .baseUrl(Const.BASE_URL) .addConverterFactory(GsonConverterFactory.create(gson)) .addCallAdapterFactory(rxAdapter) .build(); GetModels apiService = retrofit.create(GetModels.class); observableRetrofit = apiService.getModelsList(); } public static void resetModelsObservable() { observableModelsList = BehaviorSubject.create(); if (subscription != null && !subscription.isUnsubscribed()) { subscription.unsubscribe(); } subscription = observableRetrofit.subscribe(new Subscriber<ArrayList<Model>>() { @Override public void onCompleted() { //do nothing } @Override public void onError(Throwable e) { observableModelsList.onError(e); } @Override public void onNext(ArrayList<Model> models) { observableModelsList.onNext(models); } }); } public static Observable<ArrayList<Model>> getModelsObservable() { if (observableModelsList == null) { resetModelsObservable(); } return observableModelsList; } }
Теперь собственно фрагмент. Т.к. нам нужен способ принудительного обновления и индикатор загрузки, то, казалось бы, самым очевидным решение будет использование SwipeRefreshLayout. Но с ним большие проблемы, а именно — в установке ему статуса refreshing, т.е. показа крутящегося кружочка. Он порой либо не показывается вовсе, либо не исчезает, когда должен. Также, после появления CoordinatorLayout в разных версиях библиотек поддержки этот виджет начинает неправильно работать с AppBarLayout (Потяни-чтоб-обновить срабатывает ещё до полного раскрытия AppBarLayout и мешает его скроллу вниз). При чём однажды в гугле этот баг поправили, а потом… вернули обратно. А потом опять… В общем, в нашем примере мы не будем трогать этот виджет, а сделаем кнопку в меню и свою простой ImageView с анимацией вращения, коий будем в нужные моменты скрывать/показывать. Просто и никаких проблем со SwipeRefreshLayout.
Вот разметка фрагмента:
<?xml version="1.0" encoding="utf-8"?> <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent"> <android.support.v7.widget.RecyclerView android:id="@+id/recycler" android:layout_width="match_parent" android:layout_height="match_parent"/> <ImageView android:id="@+id/loading_indicator" android:layout_width="50dp" android:layout_height="50dp" android:layout_gravity="center" android:contentDescription="@string/app_name" android:src="@drawable/ic_autorenew_indigo_500_48dp" android:visibility="gone"/> </FrameLayout>
Так просто, что можно и не приводить. Java-код же фрагмента сложнее немного, так что его точно приведём.
public class ModelsListFragment extends Fragment { private static final String TAG = ModelsListFragment.class.getSimpleName(); private Subscription subscription; private ImageView loadingIndicator; private RecyclerView recyclerView; private ArrayList<Model> models = new ArrayList<>(); private boolean isLoading; @Override public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); setHasOptionsMenu(true); } @Override public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { inflater.inflate(R.menu.menu_models_list, menu); super.onCreateOptionsMenu(menu, inflater); } @Override public boolean onOptionsItemSelected(MenuItem item) { int id = item.getItemId(); switch (id) { case R.id.refresh: Log.d(TAG, "refresh clicked"); RetrofitSingleton.resetModelsObservable(); showLoadingIndicator(true); getModelsList(); return true; } return super.onOptionsItemSelected(item); } @Nullable @Override public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { View v = inflater.inflate(R.layout.fragment_models_list, container, false); if (savedInstanceState != null) { models = savedInstanceState.getParcelableArrayList(Const.KEY_MODELS); isLoading = savedInstanceState.getBoolean(Const.KEY_IS_LOADING); } recyclerView = (RecyclerView) v.findViewById(R.id.recycler); loadingIndicator = (ImageView) v.findViewById(R.id.loading_indicator); recyclerView.setLayoutManager(new LinearLayoutManager(getActivity())); recyclerView.setAdapter(new ModelsListRecyclerAdapter(models)); if (models.size() == 0 || isLoading) { showLoadingIndicator(true); getModelsList(); } return v; } private void showLoadingIndicator(boolean show) { isLoading = show; if (isLoading) { loadingIndicator.setVisibility(View.VISIBLE); loadingIndicator.animate().setInterpolator(new AccelerateDecelerateInterpolator()).rotationBy(360).setDuration(500).setListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { loadingIndicator.animate().setInterpolator(new AccelerateDecelerateInterpolator()).rotationBy(360).setDuration(500).setListener(this); } }); } else { loadingIndicator.animate().cancel(); loadingIndicator.setVisibility(View.GONE); } } private void getModelsList() { if (subscription != null && !subscription.isUnsubscribed()) { subscription.unsubscribe(); } subscription = RetrofitSingleton.getModelsObservable(). subscribeOn(Schedulers.io()). observeOn(AndroidSchedulers.mainThread()). subscribe(new Subscriber<ArrayList<Model>>() { @Override public void onCompleted() { Log.d(TAG, "onCompleted"); } @Override public void onError(Throwable e) { Log.d(TAG, "onError", e); isLoading = false; if (isAdded()) { showLoadingIndicator(false); Snackbar.make(recyclerView, R.string.connection_error, Snackbar.LENGTH_SHORT) .setAction(R.string.try_again, new View.OnClickListener() { @Override public void onClick(View v) { RetrofitSingleton.resetModelsObservable(); showLoadingIndicator(true); getModelsList(); } }) .show(); } } @Override public void onNext(ArrayList<Model> newModels) { Log.d(TAG, "onNext: " + newModels.size()); int prevSize = models.size(); isLoading = false; if (isAdded()) { recyclerView.getAdapter().notifyItemRangeRemoved(0, prevSize); } models.clear(); models.addAll(newModels); if (isAdded()) { recyclerView.getAdapter().notifyItemRangeInserted(0, models.size()); showLoadingIndicator(false); } } }); } @Override public void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); outState.putParcelableArrayList(Const.KEY_MODELS, models); outState.putBoolean(Const.KEY_IS_LOADING, isLoading); } @Override public void onDestroy() { super.onDestroy(); if (subscription != null && !subscription.isUnsubscribed()) { subscription.unsubscribe(); } } }
Вот что в нём есть, по пунктам:
- При создании говорим, что в нём есть свои элементы в меню.
- Добавляем в меню активити новые элементы меню.
- Переопределяем метод нажатий на меню и в нём запускаем принудительное обновление данных (вызываем метод синглтона, запускаем анимацию индикатора загрузки, переподписываемся на BehaviorSubject)
- В onCreateView загружаем разметку фрагмента, восстанавливаем состояние (т.е. список в данными и статус загружаю/не загружаю) и, проверив, что у нас список с данными пуст или мы в процессе загрузки отображаем индикатор и подписываемся на BehaviorSubject.
- В методе getModelsList() мы сначала отписываемся от BehaviorSubject, если подписаны и подписывемся на него же. В onNext и onError соотве��ствующе реагируем: показываем SnackBar с текстом ошибки и кнопкой "повторить"; обновляем данные в списке данных фрагмента, уведомляем об этом адаптер. В обоих случаях останавливаем индикатор загрузки (если фрагмент добавлен (isAdded())) и обновляем статус загружаем/не загружаем.
- В onSaveInstanceState сохраняем состояние
- В onDestroy отписываемся от BehaviorSubject
На счёт того, когда надо подписываться и отписываться я не уверен. В Интернете видел советы делать это в onResume/onPause и думал сделать так же… Но мне слишком понравилось то, что если отписываться в onDestroy, то даже после сворачивания приложения до прихода данных данные в итоге во фрагмент поступят и после переключения обратно на приложения они отобразятся. Да, если сделать иначе, то при разворачивании приложения вызовется onResume, мы заново подпишемся на BehaviorSubject и данные никуда не денутся и придут… Но и мой способ работает — если у вас есть возражения и/или какие-то мысли на сей счёт — напишите в комментах.
Ну и на последок — модель данных. Надо было, пожалуй, ближе к началу её поместить, но так всё так просто, что я решил поместить её в конце. Единственное, на что там стоит обратить внимание — это на реализацию классом интерфейса Parcelable, позволяющего записыывать модель в Bundle для восстановления после поворотов экрана. Ну и помянуть, что для правильной работы парсинга JSON-строки из API в модель надо чтобы для полей класса присутствовали как сеттеры, так и геттеры. Ну и чтобы в аннотациях к полям были верные значения.
public class Model implements Parcelable { /** * Parcel implementation */ public static final Parcelable.Creator<Model> CREATOR = new Parcelable.Creator<Model>() { @Override public Model createFromParcel(Parcel source) { return new Model(source); } @Override public Model[] newArray(int size) { return new Model[size]; } }; @SerializedName("title") private String title; @SerializedName("text") private String text; /** * Parcel implementation */ private Model(Parcel in) { this.title = in.readString(); this.text = in.readString(); } /** * Parcel implementation */ @Override public int describeContents() { return 0; } /** * Parcel implementation */ @Override public void writeToParcel(Parcel dest, int flags) { dest.writeString(title); dest.writeString(text); } public String getTitle() { return title; } public void setTitle(String title) { this.title = title; } public String getText() { return text; } public void setText(String text) { this.text = text; } }
Вот и всё. Мы попробовали в бою Retrofit + RxJava/RxAndroid и получили рабочий прототип приложения, кое не ест лишний трафик, не падает при повороте экрана и имеет модные библиотеки в зависимостях. Спасибо, что дочитали до конца!
P.S. Ещё раз ссылки:
Вопрос на stackoverflow: http://ru.stackoverflow.com/q/541099/17609
Репозиторий на GitHub: https://github.com/mohaxspb/RxRetrofitAndScreenOrientation
