Однажды я решил попробовать ставший безумно популярным Rx. И в то же время модернизировать.
И посмотрите, как с их помощью реализовать стандартную задачу: получить набор данных с сервера, отобразить его и при этом ничего не потерять при повороте экрана и не делать лишних запросов.
Первый вариант у меня получился почти сразу — я просто взял и вызвал кэш() на Observable, полученном из синглтона, но он меня не устроил — для принудительного обновления почему-то пришлось заново создавать экземпляры классов Retrofit и это реализация моего интерфейса для API. Пересоздание самого Observable не дало никакого эффекта — всегда возвращались старые данные вместо запуска нового сетевого запроса и получения новых данных.
После долгих мучений с новой технологией я выяснил, что во всем виноват кэш() (точнее, наверное, мое неправильное его понимание).
В итоге я сделал так: фрагмент запускает метод, подписывающий Подписчика синглтона на модификацию Observable, которая запускает onNext и onError BehaviorSubject, на который уже подписан Подписчик фрагмента.
Код на GitHub здесь , подробности под катом.
Итак, давайте начнем.
Во-первых, давайте напишем простейший PHP-код, который будет выводить JSON. Чтобы успеть повернуть экран, сделаем так, чтобы перед передачей данных была задержка в 5 секунд.
Теперь зависимости в gradle:<Э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);
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'
Более новые версии библиотек от Google использовать не будем - я столько раз обжигался, бездумно обновляя их в своих проектах.
То какие-то атрибуты в стилях виджетов изменятся, то баг, который уже один раз исправили вернут или придумают новый.
Версия 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);
}
}
Основа готова, теперь поговорим о том, как должно вести себя приложение:
- При запуске приложения должен начаться запрос к сети.
- Ответом должны быть либо данные, либо ошибка.
- Когда мы поворачиваем экран и воссоздаем активность/фрагмент, мы должны отображать уже загруженные данные, если они есть.
Если их нет или запущен ранее неполный запрос новых данных, мы должны вывести индикатор загрузки и подписаться на получение данных.
- Естественно, мы не хотим ни терять данные, ни повторно отправлять новый запрос в сеть.
- Нам также нужна возможность принудительного обновления данных.
Сначала я не мог понять, что делать.
Повозившись с кодом так и сяк пару часов, я решил пойти на крайние меры — спросил вопрос по stackoverflow .
Прямо мне не ответили, но дали 2 подсказки - по поводу уже упомянутого поведения кэша() и по поводу того, что можно попробовать использовать BehaviorSubject, который может как получать, так и отправлять данные, а также хранит самые последние данные сами по себе.
С последним сразу возникла небольшая проблема — я не долго думая подписал BehaviorSubject на Observable-ретрофит, а фрагмент — на 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. Но с ним есть большие проблемы, а именно с установкой его статуса обновления, т.е.
показа вращающегося круга.
Иногда оно либо не появляется вообще, либо не исчезает, когда должно.
Также после появления координатораLayout в разных версиях библиотек поддержки этот виджет начинает некорректно работать с AppBarLayout (Pull-to-refresh срабатывает еще до полного раскрытия AppBarLayout и мешает его прокрутке вниз).
Более того, однажды Google исправил этот баг, а потом… вернул его обратно.
И опять же.
В общем, в нашем примере мы не будем трогать этот виджет, а создадим кнопку в меню и свой простой 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 загружаем разметку фрагмента, восстанавливаем состояние (т.е.
список в данных и статус загрузки/не загрузки) и, проверив, что наш список с данными пуст или выводим индикатор в процессе загрузки и подписываемся на ПоведениеСубъект.
- В методе 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 Теги: #Android #java #rxjava #rxandroid #Retrofit #github #java #Разработка мобильных приложений #Разработка Android
-
О Защите Безопасности Удаленного Доступа
19 Oct, 24 -
Баш Мертв, Детка*. Вива Зш
19 Oct, 24 -
Теплая Лампа Кпк Palm M105
19 Oct, 24 -
Новый Формат Kindle 8 С Поддержкой Html5.
19 Oct, 24 -
Колл-Центр Мегафон В Кисловодске
19 Oct, 24 -
Эффект Искажения На Скрипке
19 Oct, 24