GCX-HCI/ThirtyInch

[Question] Where is the best place to start loading data and persist it?

Closed this issue · 8 comments

I am currently investigating different MVxx libraries and approaches on Android and stumbled upon this one. As a show case for each library I am creating simple list+detail (image gallery) app using REST services as data provider.

Lets assume we are using one Activity (MainActivity) and two fragments (GalleryFragment, DetailsFragment) in our app. Implementation of activity is quite straightforward as it requires no view or presenter - is simply starts and manages fragments.

Now, we need to implement GalleryFragment which needs to display a list of images.

So we need a view:

public interface GalleryView extends TiView {
    void startLoading(boolean pullToRefresh);
    void stopLoading();
    void showError(Throwable error);
    void showGallery(List<Image> images);
}

We need layout for fragment:

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:layout_marginLeft="16dp"
    android:layout_marginRight="16dp">

    <ProgressBar
        android:id="@+id/progress"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:indeterminate="true"/>

    <android.support.v4.widget.SwipeRefreshLayout
        android:id="@+id/contentView"
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <android.support.v7.widget.RecyclerView
            android:id="@+id/recyclerView"
            android:name="pl.fzymek.tiimagegallery.gallery.GalleryFragment"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            tools:context="pl.fzymek.tiimagegallery.gallery.GalleryFragment"
            tools:listitem="@layout/fragment_gallery_item"/>


    </android.support.v4.widget.SwipeRefreshLayout>

    <TextView
        tools:text="Error happened! :("
        android:id="@+id/error"
        android:layout_gravity="center"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"/>

</FrameLayout>

And we need a Fragment:

public class GalleryFragment extends TiFragment<GalleryPresenter, GalleryView> implements GalleryView, SwipeRefreshLayout.OnRefreshListener {

    @BindView(R.id.recyclerView)
    RecyclerView recyclerView;
    @BindView(R.id.progress)
    ProgressBar progressBar;
    @BindView(R.id.error)
    TextView error;
    @BindView(R.id.contentView)
    SwipeRefreshLayout contentView;

    GalleryAdapter adapter;
    Unbinder unbinder;

    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container,
                             Bundle savedInstanceState) {
        return inflater.inflate(R.layout.fragment_gallery, container, false);
    }

    @Override
    public void onViewCreated(View view, Bundle savedInstanceState) {
        unbinder = ButterKnife.bind(this, view);
        contentView.setOnRefreshListener(this);

        Context context = view.getContext();
        if (getResources().getConfiguration().orientation == Configuration.ORIENTATION_PORTRAIT) {
            recyclerView.setLayoutManager(new LinearLayoutManager(context));
        } else {
            recyclerView.setLayoutManager(new GridLayoutManager(context, 3));
        }
        adapter = new GalleryAdapter();
        recyclerView.addItemDecoration(new SpaceDecoration());
        recyclerView.setAdapter(adapter);
    }

    @Override
    public void onDestroyView() {
        super.onDestroyView();
        unbinder.unbind();
    }

    @NonNull
    @Override
    public GalleryPresenter providePresenter() {
        Timber.d("providePresenter");
        return new GalleryPresenter();
    }

    @Override
    public void startLoading(boolean pullToRefresh) {
        progressBar.setVisibility(View.VISIBLE);
        contentView.setVisibility(View.GONE);
        error.setVisibility(View.GONE);
        contentView.setRefreshing(pullToRefresh);
    }

    @Override
    public void stopLoading() {
        contentView.setVisibility(View.VISIBLE);
        progressBar.setVisibility(View.GONE);
        error.setVisibility(View.GONE);
        contentView.setRefreshing(false);
    }

    @Override
    public void showError(Throwable err) {
        error.setVisibility(View.VISIBLE);
        contentView.setVisibility(View.GONE);
        progressBar.setVisibility(View.GONE);
        contentView.setRefreshing(false);
    }

    @Override
    public void showGallery(List<Image> images) {
        adapter.setData(images);
    }

    @Override
    public void onRefresh() {
        loadData(true);
    }

    private void loadData(boolean pullToRefresh) {
        getPresenter().loadData(pullToRefresh);
    }
}

And finally, we also have our presenter:

class GalleryPresenter extends TiPresenter<GalleryView> {

    private final static TiConfiguration PRESENTER_CONFIGURATION = new TiConfiguration.Builder()
            .setRetainPresenterEnabled(true)
            .build();

    private GettyImagesService service;

    GalleryPresenter() {
        super(PRESENTER_CONFIGURATION);
        Retrofit retrofit = new Retrofit.Builder()
                .addCallAdapterFactory(RxJavaCallAdapterFactory.create())
                .addConverterFactory(GsonConverterFactory.create())
                .baseUrl(Config.GETTYIMAGES_API_URL)
                .build();


        service = retrofit.create(GettyImagesService.class);
    }

    @Override
    protected void onCreate() {
        super.onCreate();
        Timber.d("onCreate");
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        Timber.d("onDestroy");
    }

    @Override
    protected void onSleep() {
        super.onSleep();
        Timber.d("onSleep");
    }

    @Override
    protected void onWakeUp() {
        super.onWakeUp();
        Timber.d("onWakeUp");
    }

    public void loadData(String phrase, boolean pullToRefresh) {
        getView().startLoading(pullToRefresh);

        Timber.d("loadData %s", phrase);

        service.getImages(phrase)
                .subscribeOn(Schedulers.newThread())
                .observeOn(AndroidSchedulers.mainThread())
                .subscribe(
                        result -> {
                            getView().showGallery(result.getImages());
                            getView().stopLoading();
                        },
                         error -> {
                             getView().showError(error);
                         }
                );
    }
}

Simple as that.

What I do not understand, is when should I call loadData() to trigger a network request, and how and when to persist retrieved data between screen orientation changes.

Usually we'd start network request somewhere in onViewCreated() or onCreateView method of fragment as in that moment we have our view ready. Unfortunately, when using ThirtyInch, this in not true, as view is binded to presenter in onStart() method and calling GalleryPresenter#loadData() throws NPE as getView() returns null.

Solution to this would be to defer it a little and load data in onStart() method of fragment or onWakeUp() method of presenter (as presented in sample, but when using onWakeUp() I cannot pass my constructed query to the presenter). This works like a charm, but...

Since I am retaining a presenter, I see a problem with this solution when rotating a screen while network request is pending. Flow is in this case:

  1. Fragment/View and Presenter gets created
  2. Fragment is started (onStart) and Presenter is woken up onWakeUp -> trigger request 1
  3. Screen gets rotated
  4. Fragment/View is recreated but Presenter is retained (ok)
  5. Fragment is started (onStart) and Presenter is woken up onWakeUp -> trigger request 2
  6. Request 1 finishes -> view shows data 1
  7. Rotate screen again
  8. We go back here to recreating view, retaining presenter and triggering another request(no 3)
  9. Now we have view displaying progress, although data was fetched (request 1) and displayed for a while and 2 pending requests which will trigger new view updates

What I don't understand here is, how to correctly request data and persist it using ThirtyInch - is the persistence handled by framework (setRetainPresenterEnabled(true) ?) or should I rely on androids onSaveInstanceState(). Not retaining a presenter would leave me nothing more that Mosby (ad described here). So what should I do in order to use all features of TI?

I've been able to find a solution to this problem, but is still feels like I am not using the library in a good way. I am calling loadData() from GalleryFragment#onResume() and I needed to change my presenter so it caches data/error and looks like this:

class GalleryPresenter extends TiPresenter<GalleryView> {

    private final static TiConfiguration PRESENTER_CONFIGURATION = new TiConfiguration.Builder()
            .setRetainPresenterEnabled(true)
            .build();

    private GettyImagesService service;
    private GettySearchResult result;
    private Throwable error;
    private boolean isRequestPending = false;

    GalleryPresenter() {
        super(PRESENTER_CONFIGURATION);
        Retrofit retrofit = new Retrofit.Builder()
                .addCallAdapterFactory(RxJavaCallAdapterFactory.create())
                .addConverterFactory(GsonConverterFactory.create())
                .baseUrl(Config.GETTYIMAGES_API_URL)
                .build();


        service = retrofit.create(GettyImagesService.class);
    }

    @Override
    protected void onCreate() {
        super.onCreate();
        Timber.d("onCreate");
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        Timber.d("onDestroy");
    }

    @Override
    protected void onSleep() {
        super.onSleep();
        Timber.d("onSleep");
    }

    @Override
    protected void onWakeUp() {
        super.onWakeUp();
        Timber.d("onWakeUp");
        updateView();
    }

    public void loadData(String phrase, boolean pullToRefresh) {

        if (pullToRefresh) {
            clearData();
        }

        if (!hasData() && !isRequestPending) {
            Timber.d("loadData %s", phrase);
            getView().startLoading(pullToRefresh);
            isRequestPending = true;
            service.getImages(phrase)
                    .subscribeOn(Schedulers.newThread())
                    .observeOn(AndroidSchedulers.mainThread())
                    .subscribe(
                            result -> {
                                isRequestPending = false;
                                GalleryPresenter.this.result = result;
                                updateView();
                            },
                            error -> {
                                isRequestPending = false;
                                GalleryPresenter.this.error = error;
                                updateView();
                            }
                    );
        }
    }

    private void clearData() {
        error = null;
        result = null;
    }

    private boolean hasData() {
        boolean hasData = error != null || result != null;
        Timber.d("hasData %b", hasData);
        return hasData;
    }

    private void updateView() {
        Timber.d("updateView");
        if (isAwake()) {
            if (error != null) {
                Timber.d("showError");
                getView().showError(error);
            }
            if (result != null) {
                Timber.d("showGallery");
                getView().showGallery(result.getImages());
                getView().stopLoading();
            }
        }
    }
}

Please push this sample project somewhere so I'm able to pivot your code and reference parts of my solution in my answer

Hey Filip, finally found the time to update your sample project the ThirtyInch-way. Here is the updated code: https://github.com/passsy/MVP-Sample/blob/master/app/src/main/java/pl/fzymek/simplegallery/gallery/GalleryPresenter.java

Let me explain what I did:

First of all you shouldn't trigger loading from the Fragment (View). Never call something like loadData() from the View. The view should send events, and not tell the presenter what to do. Basically in your case there are two events when the data should be loaded. Initially and after a pull to refresh. The initial loading can be triggered by TiPresenter#onCreate() and the pull to refresh event can be simply forwarded to the presenter (presenter.onRefresh()).

Both events trigger the loadData() method and udpate the loading state as well as the error and result information.

You already implemented a updateView() method which generally updates the view based on the data known in the presenter. I modified it a bit but the idea was correct. Additionally I added @DistinctUntilChanged to the showGallery(list) and showLoadingIndicator(bool) method which prevents too many similar calls to the UI.

Cancelling the running request is the last missing piece. I cancel it it onDestroy() in case the request isn't finished when the user exits the Activity. It continues loading the data over a configuration change. When the request returns while the view isn't attached all state is known by the presenter. Although updateView() was called it never updates the view due to the null check. When the Activity resumes the view gets attached and onWakeUp() gets called triggering updateView(). This updates the view with the new data from the request.

Never call something like loadData() from the View. The view should send events, and not tell the presenter what to do.

Can you elaborate on the reasons why we should follow this rule ? Because I can read the exact opposite of what you've said on several blog posts including this valuable one.

I don't see the relationship to PRESENTERS DON'T NEED LIFECYCLE EVENTS.
Let's say it this way: There are good and bad events which can be forwarded to the presenter. Lifecycle events are the bad ones. They don't express a user interaction. Don't tell the presenter what to do, tell what happened. presenter.onButtonClicked(): Unit instead of presenter.loadData(): SomeData

Here is the good data flow which works in 99.8% of all cases:

View -----(User event)---> Presenter
View <--(Data, Actions)--- Presenter

Keep in mind that calling the Presenter from the View should not return data. The presenter should decide when and if view should get data or when actions should be executed.

The relationship with the article is that you seem to recommand to have lifecycle event in the presenter

The initial loading can be triggered by TiPresenter#onCreate()

And something like

Never call something like loadData() from the View.

Seem to be the opposite of what the article says. I think the best way is to have a loadData() method in the presenter and to trigger it on any Android lifecycle event we would like to (and which makes sense of course).
I'm ok with you when you say that the presenter must no return data and should decide when to forward it to view.

TiPresenter#onCreate is not the same as Activity#onCreate. TiPresenter#onCreate is only called once when the Presenter is created. After a configuration change, when Activity#onCreate with savedInstanceState != null is called the TiPresenter#onCreate will not be trigger. Same for TiPresenter#onDestroy which is only called once when the Activity will be finished, not always when Activity#onDestroy will be called.