Consuming REST on Android with Retrofit, ViewModel and LiveData

Introduction

The first functionality we want to implement in the Ersa Android client is some basic dashboarding. We’ll display the latest temperature and humidity readings by origin (sensor node) as well as a calculated dew point value. A pager allows us to swipe between different origins. Data will be retrieved from our Ersa REST API and bound to a ViewModel that updates the fragments on display.

All code is available on GitHub: https://github.com/Pygmalion69/ErsaAndroid

Dependencies

We’ll use Retrofit to consume the API and a ViewModel from the Android Architecture Components to hold the data and update the UI. Values are displayed in Gauge views. The Ersa library is used for the needed calculations.

dependencies {
    // ...
    implementation 'com.squareup.retrofit2:retrofit:2.3.0'
    implementation 'com.squareup.retrofit2:converter-gson:2.1.0'
    implementation 'com.google.code.gson:gson:2.8.0'
    implementation 'android.arch.lifecycle:extensions:1.1.0'
    implementation 'com.github.Pygmalion69:Gauge:1.2.3'
    implementation 'com.github.Pygmalion69:Ersa:0.2'
    // ...
}

Retrofit

Retrofit makes it extremely easy to consume REST. Let’s start with a POJO reflecting the entity from the API.

Reading.java

public class Reading {
 
    private Long id;
 
    private String origin;
 
    private Long timestamp;
 
    private Double temperature;
 
    private Double humidity;
 
    public Long getId() {
        return id;
    }
 
    public void setId(Long id) {
        this.id = id;
    }
 
    public String getOrigin() {
        return origin;
    }
 
    public void setOrigin(String origin) {
        this.origin = origin;
    }
 
    public Long getTimestamp() {
        return timestamp;
    }
 
    public void setTimestamp(Long timestamp) {
        this.timestamp = timestamp;
    }
 
    public Double getTemperature() {
        return temperature;
    }
 
    public void setTemperature(Double temperature) {
        this.temperature = temperature;
    }
 
    public Double getHumidity() {
        return humidity;
    }
 
    public void setHumidity(Double humidity) {
        this.humidity = humidity;
    }
}

We need to define an interface for our endpoint:

MainActivity.java

    public interface ApiEndpointInterface {
 
        @GET("latest")
        Call<List<Reading>> getLatestReadings();
    }

Now we can build a Retrofit instance and create an API Service:

MainActivity.java

        String url = "http://services.yourdomain.tld:8080/ersa/";
 
        Retrofit retrofit = new Retrofit.Builder()
                .baseUrl(url)
                .addConverterFactory(GsonConverterFactory.create())
                .build();
 
        final ApiEndpointInterface apiService =
                retrofit.create(ApiEndpointInterface.class);

Next we can create a Call and get our results in a Callback. In this case we’ll update a ViewModel from the Callback.

MainActivity.java

        Call<List<Reading>> call = apiService.getLatestReadings();
        call.enqueue(new Callback<List<Reading>>() {
            @Override
            public void onResponse(@NonNull Call<List<Reading>> call, @NonNull Response<List<Reading>> response) {
                Log.d(TAG, response.toString());
                if (response.body() != null && mViewModel != null) {
                    mViewModel.getReadings().postValue(response.body());
                    mViewModel.getNumberOfOrigins().postValue(response.body().size());
                }
            }
 
            @Override
            public void onFailure(@NonNull Call<List<Reading>> call, @NonNull Throwable t) {
                t.printStackTrace();
            }
        });

ViewModel

We use a ViewModel to decouple our data from out business logic. Furthermore, the LiveData in our ViewModel is lifecycle-aware. It only updates Observers that are in the STARTED or the RESUMED state.

Let’s create two observable properties:

ViewModel.java

public class DashboardViewModel extends ViewModel {
 
    private MutableLiveData<List<Reading>> mReadings;
    private MutableLiveData<Integer> mNumberOfOrigins;
 
    public MutableLiveData<List<Reading>> getReadings() {
        if (mReadings == null) {
            mReadings = new MutableLiveData<>();
        }
        return mReadings;
    }
 
    public MutableLiveData<Integer> getNumberOfOrigins() {
        if (mNumberOfOrigins == null) {
            mNumberOfOrigins = new MutableLiveData<>();
        }
        return mNumberOfOrigins;
    }
 
}

– When there is a change in the readings data we want to update the UI.
– When there is a change in the number of origins we need to notify the pager adapter (as the number of dashboard fragments needs to change accordingly).

Here’s the Observer in our activity that notifies the adapter. Note that we set the activity (‘this’) as the lifecycle owner, so that the ViewModel is aware of its state. We should also do this in our fragments when we access the same ViewModel.

        mAdapter = new DashboardAdapter(getSupportFragmentManager());
        ViewPager pager = findViewById(R.id.pager);
        pager.setAdapter(mAdapter);
 
        mViewModel = ViewModelProviders.of(this).get(DashboardViewModel.class);
 
        final Observer<Integer> originObserver = numberOfOrigins -> {
            if (mViewModel.getReadings() != null && mViewModel.getReadings().getValue() != null) {
                origins.clear(); // List<String>
                for (Reading reading : mViewModel.getReadings().getValue()) {
                    origins.add(reading.getOrigin());
                }
                mAdapter.notifyDataSetChanged();
            }
        };
 
        mViewModel.getNumberOfOrigins().observe(this, originObserver);

In the dashboard fragment, we create an Observer to update the UI.

DashboardFragment.java

        mViewModel = ViewModelProviders.of(getActivity()).get(DashboardViewModel.class);
 
        final Observer<List<Reading>> readingsObserver = readings -> {
            if (readings != null) {
                for (Reading reading : readings) {
                    if (mOrigin.equals(reading.getOrigin())) {
                        mReading = reading;
                        break;
                    }
                }
                update();
            }
        };
 
        mViewModel.getReadings().observe(getActivity(), readingsObserver);

Adapter

As we all know, Android can be a bitch. You might be familiar with the issue that UI fragments in a pager do not update when the dataset changes. So we create an adapter that assures UI updates in this case and an interface to be implemented by updatable fragments. See https://stackoverflow.com/a/32639894/959505.

Updatable.java

public interface Updatable {
 
    void update();
}

UpdatableFragmentStatePagerAdapter.java

public abstract class UpdatableFragmentStatePagerAdapter extends FragmentStatePagerAdapter {
    private WeakHashMap<Integer, Fragment> mFragments;
 
    public UpdatableFragmentStatePagerAdapter(FragmentManager fm) {
        super(fm);
        mFragments = new WeakHashMap<>();
    }
 
    @Override
    public Fragment getItem(int position) {
        Fragment item = getFragmentItem(position);
        mFragments.put(position, item);
        return item;
    }
 
    @Override
    public void destroyItem(ViewGroup container, int position, Object object) {
        super.destroyItem(container, position, object);
        Integer key = position;
        if (mFragments.containsKey(key)) {
            mFragments.remove(key);
        }
    }
 
    @Override
    public void notifyDataSetChanged() {
        super.notifyDataSetChanged();
        for (Integer position : mFragments.keySet()) {
            //Make sure we only update fragments that should be seen
            if (position != null && mFragments.get(position) != null && position < getCount()) {
                updateFragmentItem(position, mFragments.get(position));
            }
        }
    }
 
    @Override
    public int getItemPosition(Object object) {
        //If the object is a fragment, check to see if we have it in the hashmap
        if (object instanceof Fragment) {
            int position = findFragmentPositionHashMap((Fragment) object);
            //If fragment found in the hashmap check if it should be shown
            if (position >= 0) {
                //Return POSITION_NONE if it shouldn't be displayed
                return (position >= getCount() ? POSITION_NONE : position);
            }
        }
 
        return super.getItemPosition(object);
    }
 
    /**
     * Find the position of a fragment in the hashmap if it is being viewed
     *
     * @param object the Fragment we want to check for
     * @return the position if found else -1
     */
    private int findFragmentPositionHashMap(Fragment object) {
        for (Integer position : mFragments.keySet()) {
            if (position != null &&
                    mFragments.get(position) != null &&
                    mFragments.get(position) == object) {
                return position;
            }
        }
 
        return -1;
    }
 
    public abstract Fragment getFragmentItem(int position);
 
    public abstract void updateFragmentItem(int position, Fragment fragment);
}

In our DashboardAdapter we override getFragmentItem() instead of getItem():

MainActivity.java:

public static class DashboardAdapter extends UpdatableFragmentStatePagerAdapter {
        DashboardAdapter(FragmentManager fm) {
            super(fm);
        }
 
        @Override
        public int getCount() {
            return origins.size();
        }
 
 
        @Override
        public Fragment getFragmentItem(int position) {
            return DashboardFragment.newInstance(origins.get(position));
 
        }
 
        @Override
        public void updateFragmentItem(int position, Fragment fragment) {
            if (fragment instanceof Updatable) {
                ((Updatable) fragment).update();
            }
        }
    }

And finally we can update the fragments.

DashboardFragment.java

public class DashboardFragment extends Fragment implements Updatable {
 
    // ...
 
    @Override
    public void update() {
 
        if (mViewCreated) {
            mTemperature.setTemperature(mReading.getTemperature());
            double relativeHumidity = mReading.getHumidity();
            double kelvin = mTemperature.getKelvin();
            try {
                mDewPoint.setKelvin(mDew.dewPoint(relativeHumidity, kelvin));
            } catch (SolverException e) {
                e.printStackTrace();
            }
            mTvOrigin.setText(mOrigin);
            mTvDateTime.setText((new Date(1000 * mReading.getTimestamp()).toString()));
            mGaugeTemperature.moveToValue(mReading.getTemperature().floatValue());
            mGaugeHumidity.moveToValue(mReading.getHumidity().floatValue());
            mGaugeDewPoint.moveToValue((float) mDewPoint.getTemperature());
        }
 
    }
 
}