Architecture / Architecture Components / Room

Maintainable Architecture – Repository

Creating a maintainable, flexible codebase is not easy but is an essential part of software engineering. In this series we’ll take a look at a simple, functional weather app and look at some of the issues in its design. We shall then refactor and re-design it to create a codebase which will be easier to maintain, less prone to bugs, and easier to add features to. This series is not going to be a deep dive in to the techniques and technologies that we’re going to use, but will be more an exploration of what benefits they give us. In this article we’ll look at fixing a bug which isn’t immediately apparent.

So far we’ve done a fair bit of tidying up of our Weather Station app, but there is still a problem with it, albeit not an immediately obvious one. The problem lies back in something that was mentioned in the first article, whereby we use an OkHttp cache to try and throttle the number of HTTP transactions that we make, to limit it to one call every 10 minutes. While this would appear to be a sound strategy, there is a flaw in it. The problem lies in how the URI is constructed for our call to the Open Weather Maps API – it is a base URI with a query string containing a few parameters. Of these parameters, two are the latitude and longitude of the current location.

When we register for location updates, we will get an update when the user’s location changes, even if it only by a small amount. If the latitude and / or longitude changes even by a very small amount, then the URI will change and we’ll get a miss on the cache lookup, and make the network call. If the user is actually moving around, then we may get these updates quite frequently, and end up making lots of backend calls. The issue here is that the weather data that get from the backend is not going to change for small deltas in the location, but our caching strategy does not allow for this.

One solution would be to change how we get location updates, and only get updated for courser-grained location changes. But rather than that will apply our own logic, which we can build in quite easily thanks to the separation of concerns that we did earlier.

We’ve already looked in to hiding the logic for retrieving weather data behind our CurrentWeatherProvider, and we can build this logic behind this façade in the component responsible for supplying the current weather data.

This will comprise of two distinct parts: the first will be a local cache of the data which we are in control of (as opposed to the Retrofit cache for which we don’t control the logic of when to return cached data); the second is the business logic for deciding when we should return the cached data, and when we should make a network call.

If we put all of this behind a façade, then we are implementing what is commonly referred to as the ‘repository’ pattern. A consumer of the data is completely agnostic of whether the data came from the cache, a network call, or some other source – how and where to obtain the data is the responsibility of the repository.

Let’s first look at how we will cache the data. We’ll store water data in a SQLite database, and simplify the task by using Room to manage the persistence for us. Back in the article on separating our concerns we saw how we could simplify the structure of the data that we retrieved from Open Weather Map, flatten the object hierarchy, and only pass around the specific data that we are interested in. Transforming data in this way to structure it to our problem domain is a very useful thing and we’ll now trap the benefits of this because storing a much simpler, flattened object hierarchy using Room will require much less effort.

Let’s begin by looking at how we can prepare our CurrentWeather class to be persisted using Room:

@Entity(
        primaryKeys = ["retrievalLatitude", "retrievalLongitude"]
)
data class CurrentWeather(
        val latitude: Float,
        val longitude: Float,
        val placeName: String,
        val temperature: Float,
        val windSpeed: Float,
        val windDirection: Float,
        val weatherType: String,
        val weatherDescription: String,
        val icon: String,
        val timestamp: Instant,
        @ColumnInfo(index = true)
        val retrievalTime: Instant = Instant.now(),
        var retrievalLatitude: Float,
        var retrievalLongitude: Float
)

I’ve added three additional fields here named retrievalTime, which will hold the time we actually retrieved this data from the backend, and retrievalLatitude and retrievalLongitude which represent the location the device was at when we retrieved a particular weather reading. This is because the existing timestamp field holds the value that the weather data was taken, and not necessarily the time that we retrieved it; and the latitude and longitude fields hold the location of the weather station from which the data was obtained, and not the device location. This will be important later on when we implement our own cache expiry logic. The other changes are to add a couple of annotations. The first declares the class as a room Entity with a compound primary key of the retrievalLatitude and retrievalLongitude fields, and also an index on the retrievalTime field to make searches based on that much more performant – again this will be important later on.

Next we need a Data Access Object (DAO) which defines how we can interact with the database itself:

@Dao
interface CurrentWeatherDao {

    @Insert
    fun insertCurrentWeather(currentWeather: CurrentWeather): Long

    @Query("SELECT retrievalLatitude, retrievalLongitude FROM CurrentWeather")
    fun getAllLocations(): List

    @Query("SELECT * FROM CurrentWeather WHERE retrievalLatitude = :latitude AND retrievalLongitude = :longitude")
    fun getCurrentWeather(latitude: Double, longitude: Double): CurrentWeather

    @Query("DELETE FROM CurrentWeather WHERE retrievalTime < :cutoff")
    fun deleteOutdated(cutoff: Instant)
}

data class LocationTuple(val latitude: Double, val longitude: Double)

This contains four methods. Two of them are straightforward enough – they allow a CurrentWeather object to be either inserted or retrieved from the database. There is another method which will delete any records which have a retrieval time before a given value. This will enable us to purge any stale results, and will perform well because of the index that we set up on the retrievalTime field which will make searches based upon that field much faster. The final method will retrieve all of the records as LocationTuple instances which only contain the latitude and longitude of the device location when the weather data was obtained. This will enable us to implement some custom caching logic really efficiently, and we’ll look at this shortly. Both the getAllLocations() and getCurrentWeather() methods will perform well because the primary key fields will automatically be indexed, and so searches will be much faster.

Next we need to define the Room Database instance which will be the main entry point for our Room data:

@Database(entities = [CurrentWeather::class], version = 1, exportSchema = false)
@TypeConverters(Converters::class)
abstract class WeatherStationDatabase : RoomDatabase() {
    abstract fun currentWeatherDao(): CurrentWeatherDao
}

class Converters {
    @TypeConverter
    fun instantFromTimestamp(timestamp: Long): Instant =
            Instant.ofEpochMilli(timestamp)

    @TypeConverter
    fun timestampFromInstant(instant: Instant): Long =
            instant.toEpochMilli()
}

The WeatherStationDatabase class is pretty straightforward. It declares the Database instance, the entities that can be stored within the database, and a method for retrieving an instance of CurrentWeatherDao. It also declares a TypeConverter class which is a way that we can provide helper methods to enable Room to persist object types that it may not know about. In this case we provide helper methods to convert an Instant to and from a Long. By doing this, we can use Instant in our Entity class, but Room does not implicitly know how to persist one of these, but it does know how to persist a Long, so by providing these converter functions we enable Room to store an Instant as a Long, and then convert it back again when we read a record from the database.

That’s everything that we need for Room to do it’s stuff, but we now need to implement the caching logic. We do this in CurrentWeatherLiveData but this is no longer a simple LiveData object because it contains some logic so now actually represents our Repository pattern so it makes sense to rename it to CurrentWeatherRepository. This name is irrelevant to the consumers of it as they will still interact with it as a LiveData instance, but the naming makes its functionality more obvious:

private val validity: Long = Duration.ofMinutes(10).toMillis()
private const val range: Double = 1000.0

class CurrentWeatherRepository @Inject constructor(
        private val locationProvider: LocationProvider,
        private val currentWeatherProvider: CurrentWeatherProvider,
        private val currentWeatherDao: CurrentWeatherDao,
        private val distanceChecker: DistanceChecker
) : LiveData<CurrentWeather>() {

    override fun onActive() {
        super.onActive()
        locationProvider.requestUpdates(::updateLocation)
    }

    private fun updateLocation(latitude: Double, longitude: Double) {
        launch(CommonPool) {
            currentWeatherDao.deleteOutdated(Instant.now().minusMillis(validity))
            getClosestInRange(latitude, longitude)?.also {
                postValue(it)
            } ?: run {
                currentWeatherProvider.request(latitude, longitude) {
                    launch(CommonPool) {
                        it.retrievalLatitude = latitude.toFloat()
                        it.retrievalLongitude = longitude.toFloat()
                        currentWeatherDao.insertCurrentWeather(it)
                        postValue(it)
                    }
                }
            }
        }
    }

    private fun getClosestInRange(latitude: Double, longitude: Double): CurrentWeather? {
        return currentWeatherDao.getAllLocations().map {
            distanceChecker.distanceBetween(
                    latitude,
                    longitude,
                    it.retrievalLatitude,
                    it.retrievalLongitude
            ).let { distance ->
                DistanceTuple(it.retrievalLatitude, it.retrievalLongitude, distance)
            }
        }.sortedBy { it.distance }
                .firstOrNull { it.distance < range }
                ?.let {
                    currentWeatherDao.getCurrentWeather(it.latitude, it.longitude)
                }
    }

    override fun onInactive() {
        currentWeatherProvider.cancel()
        locationProvider.cancelUpdates(::updateLocation)
        super.onInactive()
    }
}

private data class DistanceTuple(val latitude: Double, val longitude: Double, var distance: Double = 0.0)

The only real change here is what is happening within updateLocation(). Previously this would just request the current weather data from CurrentWeatherProvider, and then update any LiveData observers with the latest data, but now it implements our caching logic. First we use a coroutine to ensure that we are not going to perform database operations on the main thread (line 17). Next we call deleteOutdated() (line 18) to purge any stale data from the database before we look for cached candidates.

Now we call getClosestInRange() (line 19) which will return the closest cached weather data to the current location. It does this my first calling getAllLocations() (line 35) to obtain a list of all cached locations as those LocationTuple objects we saw earlier. It then converts each of these to a DistanceTuple (lines 36-41) which is the same as LocationTuple but with an additional distance field (which represents the distance of the location from which the weather data was retrieved to the current location). Then it sorts this list of DistanceTuples so that the closest will be first (line 44). Next it will retrieve the first item which is closer than the range value (line 45) which will filter out any candidates which were taken more than 100m away from the current location. If an item was found which meets the necessary criteria then the full weather data is retrieved from the database (line 47) and returned.

In a nutshell, getClosestInRange() will determine the distances of each currently cached weather data objects, and return the closest one, but only if it was taken within 1000m of the current location, or null if no appropriate readings were found.

Back in updateLocation(), if an appropriate reading was found, then it is posted to the LiveData observers (line 20), Otherwise we request new weather data for the current location (line 22), store it to the database (lines 24-26), and then post that to the LiveData observers (line 27).

It is worth mentioning that combining the Repository and the LiveData in to a single object is not great when we come to extend the functionality (as we'll see in the next article). However, we'll leave these two components ocmbined for now, and address this later on.

Our caching logic is that we will return a cached value if we have cached weather data which was obtained within 1000m of the current location within the last 10 minutes. This caching logic is the responsibility of the Repository, and nothing else has any visibility of this. Whenever it receives a location update it decides whether to update with cached data or obtain fresh data based upon the cache rules.

The only thing remaining is to hook this up through a Dagger 2 Module:

@Module
class DatabaseModule {

    @Provides
    @Singleton
    fun providesWeatherStationDatabase(context: Context): WeatherStationDatabase =
            Room.databaseBuilder(context, WeatherStationDatabase::class.java, "WeatherStationDatabase").build()

    @Provides
    @Singleton
    fun providesCurrentWeatherDao(database: WeatherStationDatabase) =
            database.currentWeatherDao()

    @Provides fun providesDistanceChecker(): DistanceChecker =
            LocationDistanceChecker()
}

This includes provider functions to satisfy the new constructor signature of CurrentWeatherRepository:

class CurrentWeatherRepository @Inject constructor(
        private val locationProvider: LocationProvider,
        private val currentWeatherProvider: CurrentWeatherProvider,
        private val currentWeatherDao: CurrentWeatherDao,
        private val distanceChecker: DistanceChecker
) : LiveData<CurrentWeather>() {
    ...
}

By doing this we do not need to update our Fragment at all. It gets injected with a ViewModelProvider.Factory instance, which can instantiate a CurrentWeatherViewModel which, in turn, gets injected with a LiveData instance. Because of the changes that we have made in this article, this will actually be a CurrentWeatherRepository instance, and so this updated behaviour automatically gets propagated to any consumers without them even knowing.

This shows the real advantage of re-structuring our app in the way that we have. We can fix and update broken behaviours easily because we have small, focused components which have their own domains of responsibility.

This series has been something of a departure from normal Styling Android content as it has been much more focused on the internals of the app, and an average user will not notice any difference whatsoever in the behaviour of the app. The app will actually be more robust and use data more efficiently as a result of the changes, but there have been no significant changes to the user experience. In the next article we'll turn our attention to actually adding new features and explore how the re-structuring that we've done so far makes it much easier to do so.

The source code for this article is available here.

© 2018, Mark Allison. All rights reserved.

Copyright © 2018 Styling Android. All Rights Reserved.
Information about how to reuse or republish this work may be available at http://blog.stylingandroid.com/license-information.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.