Based on the How to Build an MVI Clean Code Weather App tutorial by Philipp Lackner. The Weather domain model is heavily based on his original implementation.
This repository contains an implementation of a small weather forecast application using functional style, as described in Arrow's design section and the book Functional Ideas for the Curious Kotliner.
The application uses Open-Meteo to gather forecast data, following the original tutorial. GeoIP2 is used to map IPs to locations, since we don't use location services.
The application is implemented in Compose Multiplatform Desktop instead of Android. The main reason is being able to use experimental Kotlin features, which are only available in the JVM back-end. Furthermore, it makes it possible for everybody to check the application, even if they don't own an Android phone nor want to download a simulator.
The original tutorial uses a class with nullable fields to represent the different states of the application (loading, error, success).
data class WeatherState(
val isLoading: Boolean,
val weatherInfo: WeatherInfo?,
val error: String?
)
Our implementation uses sealed interfaces instead. Each state gets its own type, making it impossible to represent invalid states,
sealed interface WeatherState {
data object Loading : WeatherState
data class Error(val error: String) : WeatherState
data class Ok(val place: String?, val weatherInfo: WeatherInfo) : WeatherState
}
Our implementation doesn't use dependency injection framework, as opposed to most Android applications, which use Hilt. Instead, the dependencies are represented as context receivers,
context(WeatherRepository, LocationTracker)
class WeatherViewModel { /* implementation */ }
The actual injection of dependencies is performed manually in the entry point,
suspend fun <A> injectDependencies(
block: context(WeatherRepository, LocationTracker) () -> A
): A = resourceScope {
val weather: WeatherRepository = WeatherRepositoryImpl(autoCloseable { WeatherApi() })
val location: LocationTracker = autoCloseable { LocationTrackerImpl() }
block(weather, location)
}
Another advantage of this approach, apart from the speed gains at both compile and run time, is that resources
are managed correctly using Arrow's resourceScope
.
This is often a convoluted task when using dependency injection frameworks -- when are instances actually created
and disposed -- whereas here everything is explicit.
Jetpack Compose encourages to keep the activity state in a ViewModel. One of the main benefits of this approach is that ViewModels are lifecycle-aware. For example, if you launch a concurrent coroutine and the activity is then closed, the coroutine is automatically cancelled.
This ability comes in a great deal from the
structured concurrency
guarantees from Kotlin's coroutines. If you capture a CoroutineScope
, you can launch new coroutines tied to
the lifecycle of that scope. This is exactly what we do in
our ViewModel,
context(/* other contexts */, CoroutineScope)
class WeatherViewModel {
/* ... */
fun loadWeatherInfo() {
// 'launch' comes from the CoroutineScope
launch(Dispatchers.IO) {
/* ... */
}
}
}
In our case we want to tie the lifecycle of the ViewModel to that of the entire application. The CoroutineScope
comes from the outermost call to SuspendApp
.
Arrow DSLs
We've already mentioned that resourceScope
is used
to correctly manage resource acquisition and disposal. This is one of Arrow's DSLs, each of them providing
additional features within a certain scope. The other one used heavily within this application are
typed errors.
The implementation of LocationTracker
showcases how the DSLs can be used and combined.
Tests with Turbine
One of the advantages of having a Flow
as source of truth for our application is the availability of specialized
testing libraries. In particular, Turbine allows us to specify how
the flow should evolve over time.
For example, one of our tests simulates that our location tracking is failing by providing a LocationTracker
instance that always returns null
. In that case, we know that the expected turn of events is loading,
and then error.
"errors when location is down" {
// set up WeatherViewModel with a LocationTracker that always fails
model.state.test {
awaitItem().shouldBeInstanceOf<WeatherState.Loading>()
model.loadWeatherInfo()
awaitItem().shouldBeInstanceOf<WeatherState.Error>()
}
}
Another tool in our tests is property-based testing, brought by Kotest. Shortly, property- based testing executes the same tests several times with arbitrary data, ensuring that more complex conditions and corner cases are covered. By using their reflective generators, starting with a random location and weather data is quite simple.
checkAll(
Arb.bind<Location>(),
Arb.list(Arb.bind<WeatherData>(), 24..48)
) { location, weatherData -> /* test */ }