Architecture / LiveData

Maintainable Architecture – Five Day Forecast – UI Layer

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.

In the previous article we added a data layer to provide a five day weather forecast. With that in place hooking up the UI is actually pretty straightforward. Part of the reason for this straightforwardness is because of the work that we have done within the data layer to provide the data in an easy to consume format. The FiveDayForecast object contains a list of DailyItem objects, each of which contains a summarised daily forecast. We can consume this as-is without having to do any further transformation. Presenting the data to the UI is an easily digestible form is crucial to keeping our UI code simply about the UI and not the data itself.

To consume this data, we first need to make it available to the UI, and we can do this through our Dagger 2 dependency injection. First we need to create a provider for our WeatherForecastDao:

@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
    @Singleton
    fun providesWeatherForecastDao(database: WeatherStationDatabase) =
            database.weatherForecastDao()

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

Dagger now has the additional provider it needs for the constructor injection of the ForecastRepository, so we can now add a binding for this:

@Module
abstract class ViewModelModule {

    @Binds
    abstract fun bindCurrentWeatherRepository(currentWeatherRepository: CurrentWeatherRepository):
            WeatherRepository<CurrentWeather>

    @Binds
    abstract fun bindForecastRepository(forecastRepository: ForecastRepository):
            WeatherRepository<WeatherForecast>

    @Binds
    @IntoMap
    @ViewModelKey(WeatherViewModel::class)
    abstract fun bindCurrentWeatherViewModel(viewModel: WeatherViewModel): ViewModel

    @Binds
    abstract fun bindViewModelFactory(factory: ViewModelFactory): ViewModelProvider.Factory
}

Those with a good memory may notice that we have also changed the name of the ViewModel from CurrentWeatherViewModel to the more generic WeatherViewModel. This reflects that the ViewModel now provides more than just the current forecast, it also provides five-day forecast data:

class WeatherViewModel @Inject constructor(
        val currentWeather: LiveData<CurrentWeather>,
        val fiveDayForecast: LiveData<FiveDayForecast>
) : ViewModel()

There is one more small thing that we need to do because we separated the Repository and LiveData from being the same class. Previously we could just use the same instance wherever we needed either object type, but that will no longer work. Instead we need a provides method which will provide an instance of the relevant LiveData type from the property of the repository, and these will be used for the constructor injection of WeatherViewModel:

@Module
class ViewModelProviderModule {

    @Provides
    fun providesCurrentWeather(currentWeatherRepository: CurrentWeatherRepository): LiveData<CurrentWeather> =
            currentWeatherRepository.currentWeather

    @Provides
    fun providesFiveDayForecast(forecastRepository: ForecastRepository): LiveData<FiveDayForecast> =
            forecastRepository.fiveDayForecast

}

It may seem odd that we haven’t just added these two provider functions to the existing ViewModelModule, but Dagger 2 does not permit both @Binds and @Provides to be used in the same module, so we no option but to separate them.

If we add this to the WeatherStationComponent then the Dagger object graph now contains all of that is necessary to be able to inject new and improved WeatherViewModel:

@Singleton
@Component(modules = [
    AndroidInjectionModule::class,
    AndroidBuilder::class,
    WeatherStationModule::class,
    LocationModule::class,
    WeatherModule::class,
    ViewModelModule::class,
    ViewModelProviderModule::class,
    DatabaseModule::class
])
interface WeatherStationComponent {

    @Component.Builder
    interface Builder {

        @BindsInstance
        fun application(application: Application): Builder

        fun build(): WeatherStationComponent
    }

    fun inject(application: WeatherStationApplication)
}

In CurrentWeatherFragment, we can now obtain a FiveDayForecast LiveData instance and begin observing it:

override fun onCreate(savedInstanceState: Bundle?) {
    AndroidSupportInjection.inject(this)
    super.onCreate(savedInstanceState)

    weatherViewModel = ViewModelProviders.of(this, viewModelFactory)
            .get(WeatherViewModel::class.java)

    weatherViewModel.currentWeather.observe(this, Observer<CurrentWeather> { current ->
        current?.apply {
            bindCurrent(current)
        }
    })

    weatherViewModel.fiveDayForecast.observe(this, Observer<FiveDayForecast> { forecast ->
        forecast?.apply {
            bindForecast(forecast)
        }
    })
}

We haven’t yet written that bindForecast() function which gets invoked when the live data changes, but we’ll get to that shortly.

We’re going to display the forecast data in a RecyclerView named forecasts which appears below the current weather data that we are already displaying.

class CurrentWeatherFragment : Fragment() {
    .
    .
    .
    private lateinit var adapter: DailyForecastAdapter
    .
    .
    .
    override fun onCreateView(
            inflater: LayoutInflater,
            container: ViewGroup?,
            savedInstanceState: Bundle?
    ): View? = inflater.inflate(R.layout.fragment_current_weather, container, false).apply {
        forecasts.apply {
            layoutManager = LinearLayoutManager(inflater.context, RecyclerView.VERTICAL, false)
            adapter = [email protected]
        }
    }

    override fun onAttach(context: Context) {
        super.onAttach(context)

        converter = Converter(context)
        adapter = DailyForecastAdapter(converter)
    }
    .
    .
    .
}

The only thing remaining is to implement the bindForecast() method that we saw earlier:

private fun bindForecast(forecast: FiveDayForecast) {
    adapter.items.clear()
    adapter.items.addAll(forecast.days)
    adapter.notifyDataSetChanged()
}

I’ve kept this simple to demonstrate how little additional code is required in the Fragment (I make it around 15 lines) which is really quite impressive! However, for a production app I would be inclined to be a bit smarter in the bindForecast() function and actually use DiffUtil to calculate the delta, rather than use the blunderbuss of notifyDataSetChanged().

With our Fragment updated, let’s take a look at DailyForecastAdapter:

class DailyForecastAdapter(private val converter: Converter) : RecyclerView.Adapter<DailyForecastViewHolder>() {
    
    var items: MutableList<FiveDayForecast.DailyItem> = mutableListOf()

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DailyForecastViewHolder =
            LayoutInflater.from(parent.context)
                    .inflate(R.layout.daily_forecast_item, parent, false).let {
                        DailyForecastViewHolder(converter, it)
                    }

    override fun getItemCount(): Int = items.count()

    override fun onBindViewHolder(holder: DailyForecastViewHolder, position: Int) {
        items[position].also { item ->
            holder.bind(item)
        }
    }
}

There’s nothing unusual happening here either – the individual item layouts are from R.layout.daily_forecast_item, and we use DailyForecastViewHolder to perform the binding of a DailyItem object to the individual Views:

class DailyForecastViewHolder(
        private val converter: Converter,
        private val itemView: View,
        private val context: Context = itemView.context,
        private val resources: Resources = itemView.resources,
        private val temperatureMax: TextView = itemView.findViewById(R.id.temperature_max),
        private val temperatureMin: TextView = itemView.findViewById(R.id.temperature_min),
        private val windSpeed: TextView = itemView.findViewById(R.id.wind_speed),
        private val windDirection: ImageView = itemView.findViewById(R.id.wind_direction),
        private val date: TextView = itemView.findViewById(R.id.date),
        private val type: TextView = itemView.findViewById(R.id.type),
        private val typeIcon: ImageView = itemView.findViewById(R.id.type_image)
) : RecyclerView.ViewHolder(itemView) {

    fun bind(dailyForecast: FiveDayForecast.DailyItem) {
        temperatureMax.text = converter.temperature(dailyForecast.temperatureMax)
        temperatureMin.text = converter.temperature(dailyForecast.temperatureMin)
        windSpeed.text = converter.speed(dailyForecast.windSpeed)
        windDirection.rotation = dailyForecast.windDirection
        date.text = dailyForecast.date.format(DateTimeFormatter.ofPattern("EEEE"))
        type.text = dailyForecast.type
        typeIcon.setImageResource(
                resources.getIdentifier(
                        "ic_${dailyForecast.icon}",
                        "drawable",
                        context.packageName
                )
        )
    }
}

Yet again, this is all pretty standard stuff and we’ve already looked at what the Converter does for us in a previous article.

The UI changes have all be really quite straightforward and that is because the only thing that we now have to do in the UI is actually display the data which is provided in a really easy to consume form. There is virtually no business logic in the UI. Furthermore, the only additional data item that we have visibility of is FiveDayForecast (and its inner DataItem class), so if we look back at the Fragment implementation that we had at the start of this series, try to imaging how much more bloated an complex our Fragment would have become if we had added this additional functionality on top of that directly!

Although the transformations that we did in the data layer may have appeared complex when we looked at them, the benefits that we get should be quite apparent now that we see how much simpler it makes our UI code. But we will not stop there, in the next article we’ll take a look at how we can add yet more functionality very easily because of how we have implemented our data layer.

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.

3 Comments

  1. @Mark Allison
    This article series are super awesome. got lot of knowledge around architecting Android applications step by step and also good use of Kotlin.
    Thank you very much.
    Please do a article around event event handling with ViewModel -> View
    like showing message when network fails or showing progress bar.

    1. Thanks for you comments. Unfortunately I don’t think I’ll get to your suggestion. Not for a while, at least. I still have another three articles to go in this series, and I feel that after that I need to move the focus elsewhere for a while – this series has turned in to something much bigger than I planned when I started it.

      I’ll certainly think about your suggestion as and when I feel it’s time to revisit these topics.

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.