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 how the changes we’ve made so far make adding new features much easier.
For a weather app, just displaying the current weather is of limited use because the user can usually get a rough idea of the current weather with a quick check outside! So it would be nice to provide a forecast for the user. In addition to the Current Weather API that we’re already using, OpenWeatherMap also has a 5 day forecast API which we can use for this. The data that this provides is a set of 40 individual forecasts for each 3 hour period over the next five days. We want to show the user a daily forecast rather than a 3 hourly forecast, but we can generate that from this data and we’ll look at that in due course.
The data that we get is somewhat different to the current weather data, so we need to create a new series of data classes for Moshi to deserialise the data to:
data class Forecast( @Json(name = "city") val city: City, @Json(name = "list") val list: List<ForecastItem> ) { val weatherForecast = WeatherForecast( city.coordinates.latitude, city.coordinates.longitude, city.name ) } data class City( @Json(name = "id") val id: Long, @Json(name = "name") val name: String, @Json(name = "coord") val coordinates: Coordinates, @Json(name = "country") val country: String ) data class ForecastItem( @Json(name = "dt") val timestamp: Long, @Json(name = "weather") val weather: List<Weather>, @Json(name = "main") val temperaturePressure: TemperaturePressure, @Json(name = "wind") val wind: Wind, @Json(name = "clouds") val clouds: Clouds, @Json(name = "rain") val rain: Precipitation?, @Json(name = "snow") val snow: Precipitation? ) { val time: Instant by lazy { Instant.ofEpochSecond(timestamp) } fun weatherForecastItem(forecastId: Long) = WeatherForecastItem( forecastId, temperaturePressure.temperature, temperaturePressure.temperatureMax, temperaturePressure.temperatureMin, wind.speed ?: 0f, wind.direction ?: 0f, weather[0].main, weather[0].description, weather[0].icon, time ) } data class Clouds( @Json(name = "all") val percentage: Int ) data class Precipitation( @Json(name = "3h") val total: Float? )
We can now add the API call to our Retrofit interface:
interface OpenWeatherMap { @GET("/data/2.5/weather") fun currentWeather( @Query("lat") latitude: Double, @Query("lon") longitude: Double, @Query("appid") appId: String ): Call<Current> @GET("/data/2.5/forecast") fun forecast( @Query("lat") latitude: Double, @Query("lon") longitude: Double, @Query("appid") appId: String ): Call<Forecast> }
Now we can rename our CurrentWeatherProvider interface to a more generic WeatherProvider and extend it to permit retrieval of the forecast data:
interface WeatherProvider { fun requestCurrentWeather(latitude: Double, longitude: Double, callback: (CurrentWeather) -> Unit) fun requestWeatherForecast(latitude: Double, longitude: Double, callback: (Forecast) -> Unit) fun cancel() }
Then we extend our OpenWeatherMapProvider to match this:
class OpenWeatherMapProvider( private val service: OpenWeatherMap, private val appId: String, private val calls: MutableList<Call<*>> = mutableListOf() ) : WeatherProvider { override fun requestCurrentWeather(latitude: Double, longitude: Double, callback: (CurrentWeather) -> Unit) { calls += service.currentWeather(latitude, longitude, appId).apply { enqueue(object : Callback<Current> { override fun onFailure(call: Call<Current>, t: Throwable?) { println("Failure: $t") calls.remove(call) } override fun onResponse(call: Call<Current>, response: Response<Current>) { calls.remove(call) println("Response: $response") response.body()?.apply { callback(currentWeather) } } }) } } override fun requestWeatherForecast(latitude: Double, longitude: Double, callback: (Forecast) -> Unit) { calls += service.forecast(latitude, longitude, appId).apply { enqueue(object : Callback<Forecast> { override fun onFailure(call: Call<Forecast>, t: Throwable?) { println("Failure: $t") calls.remove(call) } override fun onResponse(call: Call<Forecast>, response: Response<Forecast>) { calls.remove(call) println("Response: $response") response.body()?.also { callback(it) } } }) } } override fun cancel() { calls.forEach { it.cancel() } calls.clear() } }
As we can now have multiple API calls in-flight at any one time, we store them in a list named calls
which we can use to properly cancel things, if necessary.
I’ve not gone in to any depth on these explanations because they should be fairly self-explanatory for those familiar with Retrofit, and we’re doing nothing particularly out of the ordinary. However, what we have done so far is enough for us to be able to retrieve the forecast data from OpenWeatherMap, and deserialise it in to our data objects.
The changes to our Repository and internal data model are rather more interesting so we’ll devote more explanation to those. Firstly we need to create an internal data model just as we did for the current weather data. This will be a simplified form of the data which will only contain the information that we’re interested in, and in a much flatter form which will make it easier to persist to a database using Room. To begin with we need to separate a fiew fileds from our CurrentWeather data class in to a base interface:
interface BaseWeather { var expiryTime: Instant var retrievalLatitude: Float var retrievalLongitude: Float }
These are common values to both current weather and forecast models. One important thing here is that we previously had a field named retrievalTime
in CurrentWeather which has been renamed to expiryTime
. This is because we are going to change the logic for how we detect stale data in a more generic way. We’ll explain this in a little while.
We can now implement our internal Room model for the forecast data:
@Entity( indices = [ Index(value = ["retrievalLatitude", "retrievalLongitude"]), Index(value = ["expiryTime"]) ] ) data class WeatherForecast( val latitude: Float, val longitude: Float, val placeName: String, override var expiryTime: Instant = Instant.now(), override var retrievalLatitude: Float = 0f, override var retrievalLongitude: Float = 0f ) : BaseWeather { @PrimaryKey(autoGenerate = true) var id: Long? = null } @Entity( foreignKeys = [ (ForeignKey( entity = WeatherForecast::class, parentColumns = ["id"], childColumns = ["forecastId"], onDelete = CASCADE )) ] ) data class WeatherForecastItem( @ColumnInfo(index = true) val forecastId: Long, val temperature: Float, val temperatureMax: Float, val temperatureMin: Float, val windSpeed: Float, val windDirection: Float, val weatherType: String, val weatherDescription: String, val icon: String, val timestamp: Instant ) { @PrimaryKey(autoGenerate = true) var id: Long? = null }
This is a little more complex than our current weather data because a single API call will return different forecasts for the next 40 3-hour periods, so we need to store these in a list of WeatherForecastItem instances within the WeatherForecast object. It is important to understand at this point that when we read and write a WeatherForecast record from the database, Room will not read or write this list because it does not map object references – we need to do it manually and we’ll look at how we do this shortly. However, we define a foreign key on the WeatherForecastItem
with onDelete = CASCADE
because this will cause any WeatherDataItem items associated to a WeatherForecast item to be deleted when we delete that WeatherForecast item.
Next we create the Data Access Object which defines the queries that Room will use to access the database objects:
@Dao interface WeatherForecastDao { @Insert fun insert(weather: WeatherForecast): Long @Insert fun insertWeatherForecastItems(items: List<WeatherForecastItem>) @Query("SELECT retrievalLatitude, retrievalLongitude FROM WeatherForecast") fun getAllLocations(): List<LocationTuple> @Query("SELECT * FROM WeatherForecast WHERE retrievalLatitude = :latitude AND retrievalLongitude = :longitude") fun getWeatherForecast(latitude: Double, longitude: Double): WeatherForecast @Query("SELECT * FROM WeatherForecastItem WHERE forecastId = :forecastId") fun getWeatherForecastItems(forecastId: Long): List<WeatherForecastItem> @Query("DELETE FROM WeatherForecast WHERE expiryTime < :cutoff") fun deleteOutdated(cutoff: Instant) }
Once again this is a little more complex than CurrentWeatherDao because we have additional queries to insert and retrieve both Entity types that we just defined.
The new Entities and Dao must be added to the WeatherStationDatabase:
@Database( entities = [ CurrentWeather::class, WeatherForecast::class, WeatherForecastItem::class ], version = 2, exportSchema = false ) @TypeConverters(WeatherStationDatabase.Converters::class) abstract class WeatherStationDatabase : RoomDatabase() { abstract fun currentWeatherDao(): CurrentWeatherDao abstract fun weatherForecastDao(): WeatherForecastDao class Converters { @TypeConverter fun instantFromTimestamp(timestamp: Long): Instant = Instant.ofEpochMilli(timestamp) @TypeConverter fun timestampFromInstant(instant: Instant): Long = instant.toEpochMilli() } }
Next we need to look at the Repository, and there’s a few things going on here. Firstly there will be two separate Repositories: One for the current weather data, and the other for the forecast data. I’m doing this because I want to use different content expiry logic for each, so it makes sense to encapsulate these differing persistence models in separate repositories. Also, as mentioned in the previous article, having the Repository and the LiveData instance as the same object will deny us some flexibility going forward, so we’re going to separate them at this point. The first thing that we’re going to do is move the common logic in to a base repository class:
abstract class WeatherRepository<T : BaseWeather>( private val distanceChecker: DistanceChecker ) { protected abstract val range: Double protected fun updateLocation(latitude: Double, longitude: Double) { launch(CommonPool) { deleteOutdated() getClosestInRange(latitude, longitude, range)?.also { updateWeather(it) } ?: run { requestWeather(latitude, longitude) { launch(CommonPool) { updateWeather(it) } } } } } private fun getClosestInRange(latitude: Double, longitude: Double, range: Double): T? { return 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 { getWeather(it.latitude, it.longitude) } } protected abstract fun getWeather(latitude: Double, longitude: Double): T protected abstract fun deleteOutdated(cutoff: Instant = Instant.now()) protected abstract fun requestWeather(latitude: Double, longitude: Double, func: (T) -> Unit) protected abstract fun updateWeather(value: T) protected abstract fun getAllLocations(): List<LocationTuple> private data class DistanceTuple(val latitude: Double, val longitude: Double, var distance: Double = 0.0) }
This logic is almost identical to the logic we used previously, with the single exception that now the deletion of outdated records is simply a matter of whether the expiryTime for any given record is in the past. By doing this we can have the concrete subclasses of this abstract base class responsible for setting the expiry of each record, and therefore implement differing expiry logic in each.
For CurrentWeatherRepository, the same 10 minute expiry can now be set by calculating the expiry time:
class CurrentWeatherRepository @Inject constructor( private val weatherProvider: WeatherProvider, private val currentWeatherDao: CurrentWeatherDao, locationProvider: LocationProvider, distanceChecker: DistanceChecker ) : WeatherRepository<CurrentWeather>(distanceChecker) { private val validity: Long = Duration.ofMinutes(10).toMillis() override val range: Double = 1000.0 val currentWeather: WeatherLiveData<CurrentWeather> = WeatherLiveData(locationProvider, weatherProvider, ::updateLocation) override fun requestWeather(latitude: Double, longitude: Double, func: (CurrentWeather) -> Unit) = weatherProvider.requestCurrentWeather(latitude, longitude) { launch(CommonPool) { it.retrievalLatitude = latitude.toFloat() it.retrievalLongitude = longitude.toFloat() it.expiryTime = Instant.now().plusMillis(validity) currentWeatherDao.insert(it) func(it) } } override fun getWeather(latitude: Double, longitude: Double): CurrentWeather = currentWeatherDao.getWeather(latitude, longitude) override fun updateWeather(value: CurrentWeather) = currentWeather.postValue(value) override fun deleteOutdated(cutoff: Instant) = currentWeatherDao.deleteOutdated(cutoff) override fun getAllLocations(): List<LocationTuple> = currentWeatherDao.getAllLocations() }
The other important thing here is that the LiveData is now a separate object which is exposed via the currentWeather
property. The third argument that we pass to the constructor is a callback which will be invoked in response to location updates, and we pass in a function reference to the updateLocation function of the base class. We have a WeatherLiveData object which handles the lifecycle registration is fairly straightforward:
class WeatherLiveData<T>( private val locationProvider: LocationProvider, private val weatherProvider: WeatherProvider, private val callback: (Double, Double) -> Unit ) : MutableLiveData<T>() { override fun onActive() { super.onActive() locationProvider.requestUpdates(callback) } override fun onInactive() { weatherProvider.cancel() locationProvider.cancelUpdates(callback) super.onInactive() } }
The ForecastRepository is similar to CurrentForecastRepository but, as before, is a little more complex:
class ForecastRepository @Inject constructor( locationProvider: LocationProvider, private val weatherProvider: WeatherProvider, private val weatherForecastDao: WeatherForecastDao, distanceChecker: DistanceChecker ) : WeatherRepository<WeatherForecast>(distanceChecker) { override val range: Double = 1000.0 val fiveDayForecast: WeatherLiveData<FiveDayForecast> = WeatherLiveData(locationProvider, weatherProvider, ::updateLocation) override fun requestWeather(latitude: Double, longitude: Double, func: (WeatherForecast) -> Unit) = weatherProvider.requestWeatherForecast(latitude, longitude) { forecast -> launch(CommonPool) { func(store(latitude, longitude, forecast)) } } private fun store(latitude: Double, longitude: Double, forecast: Forecast): WeatherForecast = forecast.weatherForecast.apply { retrievalLatitude = latitude.toFloat() retrievalLongitude = longitude.toFloat() expiryTime = forecast.expiry() weatherForecastDao.insert(this).also { rowId -> id = rowId forecast.list.map { it.weatherForecastItem(rowId) }.also { weatherForecastDao.insertWeatherForecastItems(it) } } } private fun Forecast.expiry(): Instant = list.sortedBy { it.time }.first().time override fun getWeather(latitude: Double, longitude: Double): WeatherForecast = weatherForecastDao.getWeatherForecast(latitude, longitude) override fun updateWeather(value: WeatherForecast) { fiveDayForecast.postValue(fiveDayTransformer(value)) } override fun getAllLocations(): List<LocationTuple> = weatherForecastDao.getAllLocations() override fun deleteOutdated(cutoff: Instant) = weatherForecastDao.deleteOutdated(cutoff) . . . }
The first thing is the expiry logic. Whereas the current weather readings are likely to change more frequently, the forecast data will change less frequelty, so we’ll only bother to update it as we enter the next three-hour period, which we can determine from the time stamp of the first forecast item. This is determined in the Forecast.expiry()
extension function (lines 33-34), and set on the WeatherForecast object (line 24). The common logic for deleting expired data in WeatherRepository will handle the rest.
The next important thing is that we need to convert the Forecast object that we got from the API call needs to be converted to WeatherForecast which is our Room Entity (line 21), and the ForecastItem objects in the Forecast need converting to WeatherForecastItem (line 27). Also, when we insert the new forecast data in to the database (line 25) we must also manually insert all of the WeatherForecastItems (line 28).
One departure from how CurrentWeatherRepository works is with the LiveData implementation. Rather than wrapping the Room Entity object, this uses a new data type named FiveDayForecast:
data class FiveDayForecast( val expiryTime: Instant, val days: List<DailyItem> ) { data class DailyItem( val date: LocalDate, val type: String, val icon: String, val windSpeed: Float, val windDirection: Float, val temperatureMax: Float, val temperatureMin: Float ) }
Although this appears quite similar to the Room Entity model, it actually represents the a list of daily forecasts rather than the hourly forecasts in the Entity model. This conversion is done in ForecastRepository:
private fun fiveDayTransformer(value: WeatherForecast): FiveDayForecast? = value.id?.let { weatherForecastDao.getWeatherForecastItems(it).let { items -> FiveDayForecast(value.expiryTime, dailyItems(items)) } } private fun dailyItems(items: List<WeatherForecastItem>): List<FiveDayForecast.DailyItem> = items.groupBy { it.timestamp.atZone(ZoneId.systemDefault()).toLocalDate() } .filter { it.value.size == 8 } .map { FiveDayForecast.DailyItem( it.key, it.value .groupBy { it.weatherType } .entries .sortedByDescending { it.value.size } .first() .value .first() .weatherType, it.value .groupBy { it.icon.dropLast(1) } .entries .sortedByDescending { it.value.size } .first() .let { "${it.key}d" }, it.value.sumByDouble { it.windSpeed.toDouble() } .toFloat() / 8f, (it.value.sumByDouble { it.windDirection + 360.0 } .toFloat() / 8f) - 360f, it.value.maxBy { it.temperatureMax }?.temperatureMax ?: 0f, it.value.minBy { it.temperatureMin }?.temperatureMin ?: 0f ) }
The fiveDayTransformer()
function retrieves the forecast items for the current forecast, and then builds the FiveDayForecast instance, but it is the dailyItems()
function where the real magic happens. It may look quite complex, but once we break it down, we can see that we are doing an awful lot in a few lines of code. It relies heavily on Kotlin functional programming. It takes a list of WeatherForecastItem objects, and will combine these in to a set of daily forecasts.
We start by creating a set of groups, where each group contains all of the 3-hour forecasts for any given day (line 55), the grouping is a Map<LocalDate, List<WeatherForecastItem>>
where the key is the date, and the list is all of the 3-hour forecasts for that date. We then filter out any groups which do not contain 8 forecasts as we’re only interested in the days for which we have all 8 three-hour forecasts (line 56). We then use a map to transform each group to a FiveDayForecast.DailyItem (line 57). The constructor for the DailyItem takes 7 arguments as we saw from the data class, and we obtain each of these from the group.
The first argument is a LocalDate, so we use the key of the group Map (line 59).
Second is the weather type and we find the most common throughout the 3-hourly forecasts to get the predominant weather type for that day. We start by grouping the WeatherForecastItems for that day (line 60) according to the weatherType
field (line 61) which will create a Map<String, List<WeatherForecastItem>>
which is keyed on the weatherType, then getting the list of entries (line 62) which is the List<WeatherForecastItem>
. Next we sort in descending order these depending on the number of entries (line 63), so that the first item contains the most entries. We can then get the weather type from this, and that is the most common weather type for all of the three-hourly forecasts for that day (lines 64-67).
The third argument is the icon representing the weather type. OpenWeatherMap represents these as a two digit number followed by a letter – either ‘d’ or ‘n’. The number represents the weather type, and the letter specifies different icons for day and night. So first we group the WeatherForecastItems by the icon name with letter omitted (line 69), we then sort them in to descending size order as we did with the weather type argument (lines 70-71). Finally we get the icon name and append a ‘d’ so that we always get the day icon (lines 72-75).
The fourth argument is the wind speed, and we take an average of the wind speeds in each three-hourly forecast (lines 76-77).
The fifth argument is the wind direction and, once again, we take an average of the wind directions in each of the three-hourly forecasts (lines 78-79).
The sixth argument is the maximum temperature, and we get the maximum value of the temperatureMax fields from three-hourlies (line 80).
The seventh and final argument is the minimum temperature, and we take the minimum value of the temperatureMin fields from the three-hourlies (line 81).
So with this one block we have transformed a set of three-hourly forecasts in to a much smaller set of daily forecasts.
In some cases it may make sense to do this kind of transformation on the data coming from the AAPI call, and then persisting the FiveDayForecast using Room. However, in this case we can re-use the data that we currently persist using Room in a variety of ways as well see in a later article, so I have decided to perform this transformation here.
We’ve covered quite a lot in this article with in terms of how we obtain the new three-hourly data from OpenWeatherMap, how we persist it and manage expiry using our repository, and how we transform it to a daily forecast. An important thing to note about this is that we’ve gone nowhere near any UI code (there’s not a View in sight, if you’ll forgive the pun!), and it is largely independent for the Android framework (with the exception of the LiveData which is lifecycle-aware, and both it and Room are Android Jetpack libraries). As such this represents a clean data layer within our app, and has been much easier to implement because of the earlier work that we did to separate our concerns. In the next article we’ll look at how we can hook this up to the UI, and find that it’s surprisingly straightforward!
Although we’ve added a chunk of code, there are no actual behavioural differences to the app because it isn’t yet hooked up. Nonetheless the source code that we’ve added in 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.