Architecture / Architecture Components / Room

Maintainable Architecture – Daily Forecast

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 data layer makes it easier to add further features.

Previously we added a five-day forecast to our Weather Station app and saw how, by separating the data layer from the UI layer we can keep each focused on its own responsibilities. However, we can take this further still. We actually have the necessary data already stored in the repository to enable the user to be able to see a more detailed view of each day. The data that came from Open Weather Maps actually consisted of a series of forecasts for each 3-hour time slot over the next five days. We persist that data in the repository, and only convert it to the daily format when we present the data to the UI layer. Converting this data to a different schema is actually pretty straightforward, and makes providing the additional forecast information to the user much easier.

One important difference between the five-day forecast and the more detailed daily forecast is how it will be accessed. The five-day forecast will be presented on the main page of the app, and is surfaced to the UI layer as LiveData so that the UI will automatically refresh whenever the forecast data changes. However the detailed forecast will be a little different. It will be displayed only when the user taps on one of the days in the five-day forecast to display detailed information for that day. The data itself is actually very similar to the data we have stored, and the new data class for this data is really simple:

data class DailyForecast(
        val date: LocalDate,
        val city: String,
        val forecasts: List<WeatherForecastItem>
)

We’re actually using the WeatherForecastItem data that we defined previously, and is what is getting persisted within the Repository. However, we need to filter it so that we only include the forecasts for the specific day requested which we do by adding by adding a function to ForecastRepository:

override fun getDailyForecast(forecastId: Long, city: String, date: LocalDate): DailyForecast =
        weatherForecastDao.getWeatherForecastItemsForDateRange(
                forecastId,
                date.atStartOfDay().toInstant(ZoneOffset.UTC).toEpochMilli(),
                date.atTime(23, 59).toInstant(ZoneOffset.UTC).toEpochMilli()
        ).let { items ->
            DailyForecast(date, city, items)
        }

This will be called from the UI layer in response to the user tapping on a day for a given 5-day forecast so the UI layer will already have the forecast ID and City name from the 5-day forecast data. This uses a new query which we add to the Doa so that Room is able to query this data:

@Query("SELECT * FROM WeatherForecastItem WHERE forecastId = :forecastId AND timestamp BETWEEN :start AND :end")
fun getWeatherForecastItemsForDateRange(forecastId: Long, start: Long, end: Long): List<WeatherForecastItem>

We do not want the UI layer to have any direct knowledge of WeatherForecastRepository, so we create a new interface named DailyForecastProvider which WeatherForecastRepository will implement:

interface DailyForecastProvider {

    fun getDailyForecast(forecastId: Long, city: String, date: LocalDate): DailyForecast
}

The single function in this interface matches the one that we’ve already created in WeatherForecastRepository, so we need to add a provider to our Dagger 2 module so that it is able to inject a DailyForecastProvider instance:

@Module
class ViewModelProviderModule {

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

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

    @Provides
    fun providesDailyForecastProvider(forecastRepository: ForecastRepository): DailyForecastProvider =
            forecastRepository
}

That is the data layer complete, so let’s take a look at the UI layer changes. First we need to tweak our ViewModel to implement DailyForecastProvider:

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

This will be injected by Dagger 2 with a DailyForecastProvider instance, and we use Kotlin delegation to make our life easier. The DailyForecastProvider that gets injected will implement the getDailyForecast() method, and DailyForecastProvider by dailyForecastProvider means that WeatherViewModel now also implements the DailyForecastProvider interface but calls to its methods will be delegated to dailyForecastProvider. This is a really useful trick because it enables us to add logic without exposing collaborators.

Next we need to add a new Fragment which will display the detailed daily forecast:

fun createDailyForecastFragment(forecastId: Long, city: String, date: LocalDate) =
        DailyForecastFragment().apply {
            arguments = Bundle().apply {
                putLong(EXTRA_FORECAST_ID, forecastId)
                putString(EXTRA_CITY, city)
                putSerializable(EXTRA_DATE, date)
            }
        }

private const val EXTRA_FORECAST_ID = "EXTRA_FORECAST_ID"
private const val EXTRA_CITY = "EXTRA_CITY"
private const val EXTRA_DATE = "EXTRA_DATE"

class DailyForecastFragment : Fragment() {

    @Inject lateinit var viewModelFactory: ViewModelProvider.Factory

    private lateinit var weatherViewModel: WeatherViewModel

    private lateinit var converter: Converter

    private lateinit var threeHourlyForecastAdapter: ThreeHourlyForecastAdapter

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

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

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

        converter = Converter(context)
        threeHourlyForecastAdapter = ThreeHourlyForecastAdapter(converter)

        if (context is AppCompatActivity) {
            context.supportActionBar?.apply {
                setDisplayHomeAsUpEnabled(true)
                setTitle(R.string.daily_forecast)
                setHasOptionsMenu(true)
            }
        }
    }

    override fun onOptionsItemSelected(item: MenuItem): Boolean {
        return when(item.itemId) {
            android.R.id.home -> {
                fragmentManager?.popBackStack()
                true
            }
            else -> super.onOptionsItemSelected(item)
        }
    }

    override fun onCreateView(
            inflater: LayoutInflater,
            container: ViewGroup?,
            savedInstanceState: Bundle?
    ): View? {
        val forecastId = savedInstanceState?.getLong(EXTRA_FORECAST_ID) ?: arguments?.getLong(EXTRA_FORECAST_ID)
        val city = savedInstanceState?.getString(EXTRA_CITY) ?: arguments?.getString(EXTRA_CITY)
        val date = (savedInstanceState?.getSerializable(EXTRA_DATE) ?: arguments?.getSerializable(EXTRA_DATE)) as LocalDate?

        if (forecastId == null || city == null || date == null)
            throw IllegalArgumentException("Missing either forecastId, city or date")

        return inflater.inflate(R.layout.fragment_daily_forecast, container, false).apply {
            findViewById<RecyclerView>(R.id.three_hourly_forecasts).apply {
                layoutManager = LinearLayoutManager(inflater.context, RecyclerView.VERTICAL, false)
                adapter = threeHourlyForecastAdapter
            }
            loadDailyForecast(forecastId, city, date)
        }
    }

    private fun loadDailyForecast(forecastId: Long, city: String, date: LocalDate) = async(UI) {
        bind(withContext(CommonPool) {
            weatherViewModel.getDailyForecast(forecastId, city, date)
        })
    }

    private fun bind(forecast: DailyForecast) {
        city.text = forecast.city
        date.text = forecast.date.format(DateTimeFormatter.ofPattern("EEEE"))
        threeHourlyForecastAdapter.apply {
            items.clear()
            items.addAll(forecast.forecasts)
            notifyDataSetChanged()
        }
    }
}

I won’t give an in-depth description of this class because it’s not that much different from the existing UI components in the project. It uses a RecyclerView to display the list of 3-hourly forecasts for a given day. One important area is the loadDailyForecast() method which calls the getDailyForecast() method on the WeatherViewModel – which is what we just looked at. Also we have a constructor method named createDailyForecastFragment() which creates a Fragment instance with the correct arguments in a Bundle. Having a constructor method such as this means that another UI component can create an instance of this Fragment without needing any knowledge of the key names used in the Bundle, so the EXTRA_FORECAST_ID, EXTRA_CITY, and EXTRA_DATE constants can all be private.

This needs to be wired up to a click handler in the CurrentWeatherFragment to load this Fragment:

override fun onClick(view: View) {
    forecasts.getChildAdapterPosition(view).also { position ->
        showDailyForecast(dailyForecastAdapter.items[position].date)
    }
}

private fun showDailyForecast(date: LocalDate) {
    currentFiveDayForecast?.also {
        fragmentManager?.transaction {
            replace(R.id.activity_main, createDailyForecastFragment(it.forecastId, it.city, date))
            addToBackStack(DailyForecastFragment::class.java.simpleName)
        }
    }
}

The only thing remaining is to add a Dagger 2 injector definition so that it is able to inject DailyForecastFragment:

@Module
abstract class AndroidBuilder {

    @ContributesAndroidInjector
    abstract fun bindCurrentWeatherFragment(): CurrentWeatherFragment

    @ContributesAndroidInjector
    abstract fun bindDailyForecastFragment(): DailyForecastFragment
}

The important things to note in this is that we have been able to use data that we already have in the Repository thanks to the fact that we persisted the data in a form that we could re-use. We then convert this data in to a form that is easy for the UI layer to consume with separate UI components having their own domain model. This really simplifies our UI logic because it is much more focused on just binding easy to consume data to the Views.

Once again we’ve seen how having a decoupled architecture can make adding features much easier than for the code that we started with. However, there is an area of this new feature code that we’ve added over the last few articles which could cause us problems with maintainability later on, and in the next article we’ll take a look at what the problem is and how we can fix it.

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.