As a modern Android developer, I love Jetpack ViewModel. It's the standard, safe way to handle UI state and survive configuration changes like screen rotation. But what if I tried to build an app without it, just as an experiment? This question came up during a deep technical discussion with Google's AI, Gemini, and it led to a fascinating exploration. This question is also becoming more relevant as we look towards a future with Kotlin Multiplatform (KMP), where Android-specific libraries can't be used in shared code.
This article documents our experiment: an exploration into building a robust, lifecycle-aware, and ViewModel-less architecture using Circuit, Koin, and the power of Kotlin Flow.
We'll use a simple Barometer app as our example. The goal was simple:
- Read sensor data.
- Display it on the screen.
- Survive screen rotation without losing data.
- Stop using the sensor when the screen is not visible to save battery.
- Crucially, avoid re-registering the sensor listener on every screen rotation.
The final code for this project can be found on GitHub.
The Challenge: Replacing ViewModel's Magic
ViewModel gives us two key features for free:
- Instance Survival: It stays alive during configuration changes.
- viewModelScope: A CoroutineScope that is automatically canceled when the ViewModel is no longer needed.
If we don't use ViewModel, we have to find replacements for these two features. This is where our experiment with Circuit and smart Flow operators begins.
Step 1: The Data Source - Making the Repository Smart
First, let's look at the data layer. The BarometricPressureRepository is responsible for getting data from the device's sensor. The key to our solution was how to expose this data.
Instead of a simple callbackFlow, we tried making it a "smart" hot stream using shareIn.
app/src/main/java/dev/yuyuyuyuyu/barometer/data/repository/impl/BarometricPressureRepositoryImpl.kt
class BarometricPressureRepositoryImpl(
private val sensorManager: SensorManager,
// We inject an application-level CoroutineScope from Koin
coroutineScope: CoroutineScope,
) : BarometricPressureRepository {
// ...
override val pressure: Result<Flow<Float>, BarometricPressureRepositoryError> =
if (barometricPressureSensor == null) {
// ... handle error
} else {
Ok(
callbackFlow {
val listener = object : SensorEventListener {
// ...
}
// Register the listener when the Flow starts
sensorManager.registerListener(...)
// Unregister when the Flow is cancelled
awaitClose {
Timber.d("awaitClose in Repository")
sensorManager.unregisterListener(listener)
}
}
// This is the magic part!
.shareIn(
scope = coroutineScope, // The "host" for the sharing mechanism
started = SharingStarted.WhileSubscribed(5000), // The "rules" for starting/stopping
)
)
}
}
The Magic of shareIn and WhileSubscribed
This small piece of code is the heart of the solution.
-
shareIn
: This operator turns a "cold" Flow (that starts for every collector) into a "hot" SharedFlow (that is shared among all collectors). -
scope = coroutineScope
: We provide a CoroutineScope that lives as long as the application. This scope doesn't run the sensor forever. Instead, it acts as a long-living "manager" for the sharing mechanism. -
started = SharingStarted.WhileSubscribed(5000)
: This tells the manager the rules.- Start the callbackFlow (and register the sensor listener) only when the first UI component starts listening.
- When the last UI component stops listening, wait for 5000 milliseconds (5 seconds).
- If no one starts listening again within 5 seconds, then and only then, stop the callbackFlow and call awaitClose to unregister the listener.
This 5-second timeout is the key to surviving screen rotation. The old UI stops listening, but the new UI starts listening again almost instantly, well within the timeout. The result? The sensor listener is never re-registered!
We set up this application scope in our Koin module.
app/src/main/java/dev/yuyuyuyuyu/barometer/di/BarometerAppModule.kt
val barometerAppModule = module {
single {
// This scope will live as long as the application process.
CoroutineScope(SupervisorJob() + Dispatchers.Default)
}
// ...
}
Step 2: The UI Logic - Circuit's Presenter
Now let's look at the Presenter. This is Circuit's version of a ViewModel. Its job is to prepare the state for the UI.
Here, we hit another interesting problem: How to survive screen rotation and also avoid a flicker of a "Loading" screen?
app/src/main/java/dev/yuyuyuyuyu/barometer/ui/barometer/BarometerPresenter.kt
class BarometerPresenter(
private val getFormattedBarometricPressureFlowUseCase: GetFormattedBarometricPressureFlowUseCase,
) : Presenter<BarometerScreen.State> {
@Composable
override fun present(): BarometerScreen.State {
// 1. Retain the last known state across screen rotations.
var barometerState: BarometerState by rememberRetained {
mutableStateOf(BarometerState.Loading)
}
// 2. Use the result of the Flow to update our retained state.
getFormattedBarometricPressureFlowUseCase().fold(
success = { pressureFlow ->
// This is the Flow coming from our smart Repository
pressureFlow.map { pressureString ->
BarometerState.SuccessToGetPressure(pressureString)
}
},
failure = { error ->
// ... handle error
},
)
.onEach { state ->
// Update the retained state with the latest value from the Flow
barometerState = state
}
// 3. Collect the flow only when the UI is visible.
.collectAsStateWithLifecycle(
// IMPORTANT: Use the retained state as the initial value!
initialValue = barometerState,
)
return BarometerScreen.State(barometerState)
}
}
The Key: rememberRetained + collectAsStateWithLifecycle
This Presenter code is subtle but very powerful.
-
rememberRetained
: This is Circuit's version of rememberSaveable for complex objects. It keeps our barometerState variable alive during screen rotation. This is our replacement for ViewModel's instance survival. -
collectAsStateWithLifecycle
: This is the standard, safe way to collect a Flow in a Composable. It automatically starts collecting when the screen is visible and stops when it's not. This is our replacement for viewModelScope's lifecycle awareness. -
initialValue = barometerState
: This is the clever trick! When the screen rotates, a new collectAsStateWithLifecycle is created. But instead of starting with a Loading state, we give it the last known state that we saved with rememberRetained. This prevents any flickering on the screen during rotation. The UI instantly shows the last pressure value while waiting for the next update from the Flow.
Conclusion: A New Way of Thinking
Through this experiment, we were able to build an architecture that is:
- ViewModel-less: Free from Android-specific architecture components, making it more portable (ready for KMP!).
- Lifecycle-Aware: It smartly uses and releases resources like sensors only when the UI is visible, saving battery.
- Rotation-Proof: It survives screen rotation without re-initializing data streams or showing annoying "Loading" flickers.
This wasn't just about finding an alternative to ViewModel; for us, it was about discovering a different way to think about state and lifecycles. By giving more responsibility to the Repository (with shareIn) and using the right tools in our Presenter (with rememberRetained), we were able to achieve a very clean and powerful architecture.
This pattern shows the true power of declarative UI and structured concurrency. While it might be an experimental approach and seem complex at first, it's an interesting solution for this difficult problem. We hope this exploration provides some useful insights for your own projects!
Top comments (0)