I decided one day to try the now wildly popular Rx. And at the same time Retrofit. And see how to use them to implement the standard task: get a set of data from the server, display them and at the same time lose nothing when the screen rotates and not make unnecessary requests. The first option I got almost immediately - I just picked up and called cache () on Observable, obtained from Singleton, but it didn’t work for me - for a forced update I had to re-create instances of the Retrofit classes and its implementation of my interface for API. Rebuilding the Observable itself did not have the effect - old data was always returned instead of launching a new network request and receiving new data.
After much torment with a new technology for myself, I found out that cache () was guilty of everything (more precisely, probably, my wrong understanding thereof). As a result, I did this: the fragment runs the method that signs the Subscriber of the singleton on the Observable retrofit, which runs onNext and onError BehaviorSubject, which is already subscribed to the Subscriber fragment. The code on GitHub is here , the details are under the cut.
So let's get started. First, we write the simplest php code, which will give JSON. In order to have time to rotate the screen, we will do so that before sending the data there was a delay of 5 seconds.
<?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);
Now the dependencies in the 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'
We will not use more recent versions of Google from Google - it has been burned so many times on their mindless update in their projects ... Some attributes in widget styles will be changed, then a bug, already corrected once again, will be returned, then a new one will be invented. Version 23.3.0 works relatively stable, we take it, we take it.
Go to the code. Here is the structure of the project I got:
Activation markup will be simple, here it is:
<?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>
The code in the activit is no less laconic:
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); } }
The basis is ready, now how the application should behave:
As mentioned at the beginning, I had high hopes for cache (), but, as far as I understood, it caches the request to the network itself and even the re-creation of Observable does not allow making a new request to the network without re-creating Retrofita objects, which is obviously the wrong way . At first, I could not figure out what to do. Picked up the code this way and that for a couple of hours I decided on extreme measures - I asked a question on stackoverflow . They didn’t answer me directly, but they gave me 2 hints - about the already mentioned cache () behavior and about the fact that you can try to use the BehaviorSubject, which can both receive and send data, and also keeps the latest data in itself.
With the latter, a small problem arose at once - without hesitation, I signed the BehaviorSubject on the Observable retrofit, and the fragment on the BehaviorSubject. It seems everything is correct, only if during the rotation of the screen the task is completed, then the fragment will receive as the last data ... correctly - the onComplete event, and not the data itself. Then I froze for a while, trying to google how to prevent the Observable from emitting a shutdown event or how to ignore it from subscribers. Google was silent and in every way it hinted that I was dripping in the wrong direction. And yes - a similar idea could only occur to a newcomer to the technology) The solution turned out to be simple - instead of trying to change the Observable behavior, I simply didn’t sign up for the BehaviorSubject, but just in the first callback (onNext and onError) called the appropriate methods of the second. And onComplete - ignored.
In the end, this one turned out to be a singleton:
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; } }
Now the actual fragment. Since we need a forced update method and a loading indicator, then the seemingly obvious solution would be to use SwipeRefreshLayout. But there are big problems with him, namely, in setting refreshing status to him, i.e. showing a rotating circle. He sometimes either does not show at all, or does not disappear when it should. Also, after the appearance of the CoordinatorLayout in different versions of the support libraries, this widget starts to work incorrectly with AppBarLayout (Pull-update-it works even before the full disclosure of AppBarLayout and prevents it from scrolling down). With that once in Google this bug was corrected, and then ... returned back. And then again ... In general, in our example we will not touch this widget, but we will make a button in the menu and our simple ImageView with rotation animation, which we will hide / show at the right moments. Simple and no problems with SwipeRefreshLayout.
Here is the fragment markup:
<?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>
So simple that you can not lead. The Java code for the fragment is a bit more complicated, so we’ll give it exactly.
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(); } } }
That's what it is, by points:
I'm not sure about when to subscribe and unsubscribe. I saw tips on the Internet to do this in onResume / onPause and thought to do the same ... But I liked too much that if you unsubscribe to onDestroy, even after the application was minimized, before the data arrived, the data will eventually appear in the fragment and after switching back to applications . Yes, if you do it differently, then when you deploy the application, onResume will be called, we will re-subscribe to the BehaviorSubject and the data will not disappear and will come ... But my method also works - if you have objections and / or any thoughts on this matter - write in comments.
And lastly - the data model. It was necessary, perhaps, to place it closer to the beginning, but everything is so simple that I decided to place it at the end. The only thing worth paying attention to is the implementation of the Parcelable interface class, which allows you to write a model in the Bundle for recovery after the screen turns. Well, remember that for the correct operation of parsing JSON-strings from the API to the model, it is necessary for the class fields to contain both setters and getters. Well, so that in the annotations to the fields were correct values.
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; } }
That's all. We tried Retrofit + RxJava / RxAndroid in battle and got a working prototype of the application, some do not eat extra traffic, do not fall when the screen is turned and has fancy libraries in dependencies. Thank you for reading to the end!
PS Once again links:
Question on stackoverflow: http://ru.stackoverflow.com/q/541099/17609
GitHub repository: https://github.com/mohaxspb/RxRetrofitAndScreenOrientation
Source: https://habr.com/ru/post/305478/
All Articles