Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save RohitSurwase/f715dc42beb6f43ca473bff815aea642 to your computer and use it in GitHub Desktop.

Select an option

Save RohitSurwase/f715dc42beb6f43ca473bff815aea642 to your computer and use it in GitHub Desktop.
Evolving Android MVI: Kotlin Flows, Compose, and Lifecycle Safety 🚀
Prerequisites: This builds on our foundation: Best Architecture For Android : MVI + LiveData + ViewModel = ❤️. The core concepts of Unidirectional Data Flow, ViewState, ViewEffect, and ViewEvent remain intact.
The original architecture served us well, but the Android ecosystem has shifted. LiveData is tied to the Android SDK and struggles with one-shot events. SingleLiveEvent was a hack. Jetpack Compose completely changed the UI rendering contract.
Here is how we evolved our MVI base to use Kotlin Flows, support Jetpack Compose, and guarantee lifecycle safety.
1. State: LiveData ➡️ StateFlow
LiveData binds lifecycle-awareness into the ViewModel. StateFlow pushes that responsibility to the UI layer, keeping the ViewModel pure Kotlin. It always holds a value and replays it instantly to new collectors, making it the perfect replacement for ViewState.
// In AacMviViewModel.kt
private val _viewStates: MutableStateFlow<STATE> by lazy { MutableStateFlow(viewState) }
override val viewStates: StateFlow<STATE> get() = _viewStates.asStateFlow()
protected var viewState: STATE
get() = _viewState ?: throw UninitializedPropertyAccessException("Init viewState first")
set(value) {
_viewState = value
_viewStates.value = value
}
Note: We use by lazy so the flow is only instantiated after the subclass init {} block assigns the initial viewState.
2. Effects: SingleLiveEvent ➡️ Channel
SingleLiveEvent was notorious for dropping events or multi-delivery bugs during configuration changes. To handle fire-and-forget ViewEffects (like navigation or Toasts), we use a Channel exposed as a Flow.
A Channel guarantees exactly-once delivery. If the UI is rotating, the BUFFERED capacity holds the effect until the UI re-attaches.
private val _viewEffects: Channel<EFFECT> = Channel(
capacity = Channel.BUFFERED,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
onUndeliveredElement = { Log.w(TAG, "Dropped effect: $it") } // Hook for analytics
)
override val viewEffects: Flow<EFFECT> get() = _viewEffects.receiveAsFlow()
protected var viewEffect: EFFECT
get() = _viewEffect ?: throw UninitializedPropertyAccessException()
set(value) {
_viewEffect = value
viewModelScope.launch { _viewEffects.send(value) }
}
override fun onCleared() {
super.onCleared()
_viewEffects.close() // Crucial: prevents memory leaks of buffered effects
}
3. Safe UI Collection: repeatOnLifecycle
Raw Kotlin Flows don't stop collecting when the Activity goes to the background. To prevent crashes and wasted resources, we enforce repeatOnLifecycle(STARTED) in the Base Activity/Fragment.
STARTED is critical. If you use RESUMED, your flow pauses when a dialog or permission prompt appears on top of your app. Any effects emitted during that time would be permanently dropped by the Channel.
// Inside AacMviActivity.kt onCreate()
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
launch { viewModel.viewStates.collect { renderViewState(it) } }
launch {
// Main.immediate prevents dropping effects during rapid config changes
withContext(Dispatchers.Main.immediate) {
viewModel.viewEffects.collect { renderViewEffect(it) }
}
}
}
}
4. Bridging Jetpack Compose
To allow incremental migration, we introduce an isForPureCompose flag. When true, XML rendering is bypassed. State collection is handled naturally by Compose's collectAsStateWithLifecycle().
override fun onCreateView(...): View? {
return if (isForPureCompose) {
ComposeView(requireContext()).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
val viewState = viewModel.viewStates.collectAsStateWithLifecycle()
ComposeViewState(viewState.value)
}
}
} else {
super.onCreateView(inflater, container, savedInstanceState)
}
}
However, Effects require explicit collection. We force developers to call a ComposeViewEffect {} block inside their composable tree. This prevents the silent failures common in open XML methods.
@Composable
fun ComposeViewEffect(composeViewEffect: (EFFECT) -> Unit) {
val lifecycleOwner = LocalLifecycleOwner.current
LaunchedEffect(viewModel.viewEffects, lifecycleOwner.lifecycle) {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
withContext(Dispatchers.Main.immediate) {
viewModel.viewEffects.collect { composeViewEffect(it) }
}
}
}
}
Two details worth noting:
Dispatchers.Main.immediate: A regular withContext(Dispatchers.Main) posts the effect as a new task on the main thread event loop. If the composable is near a recomposition cycle boundary, the effect could be dispatched after the lifecycle moves below STARTED and get dropped. Main.immediate executes the block in the current event cycle if already on Main, eliminating that race condition.
Why not automatic?: Making effect collection opt-in forces the developer to consciously place the ComposeViewEffect{} call in their composable tree. Silent drop (forgetting to call it) is a compile-time-visible omission rather than a silent runtime bug, and it keeps state and effect concerns cleanly separated.
Example: A Compose Activity
class MainActivity : AacMviActivity<MainState, MainEffect, MainEvent, MainViewModel>(
isForPureCompose = true
) {
override val viewModel: MainViewModel by viewModels()
// Required by base contract, but safe to ignore in Compose mode
override fun renderViewEffect(viewEffect: MainEffect) {}
@Composable
override fun ComposeViewState(viewState: MainState) {
MainScreen(state = viewState)
// Explicitly handle effects
ComposeViewEffect { effect ->
when (effect) {
is MainEffect.ShowToast -> Toast.makeText(this, effect.message, Toast.LENGTH_SHORT).show()
}
}
}
}
The Full Base Classes:
private const val TAG = "AacMviActivity"
abstract class AacMviActivity<STATE, EFFECT, EVENT, VM : AacMviViewModel<STATE, EFFECT, EVENT>>(
val isForPureCompose: Boolean,
) : AppCompatActivity() {
abstract val viewModel: VM
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
if (isForPureCompose) {
setContent {
val viewState = viewModel.viewStates.collectAsStateWithLifecycle()
ComposeViewState(viewState.value)
}
} else {
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
launch {
viewModel.viewStates.collect {
if (logging) Log.d(MAIN_TAG, "$TAG -> observed viewState : $it")
renderViewState(it)
}
}
launch {
withContext(Dispatchers.Main.immediate) {
viewModel.viewEffects.collect {
if (logging) Log.d(MAIN_TAG, "$TAG -> observed viewEffect : $it")
renderViewEffect(it)
}
}
}
}
}
}
}
open fun renderViewState(viewState: STATE) {}
abstract fun renderViewEffect(viewEffect: EFFECT)
@CallSuper
@Composable
open fun ComposeViewState(viewState: STATE) {
if (logging) Log.d(MAIN_TAG, "$TAG -> ComposeViewState: $viewState")
}
@Composable
fun ComposeViewEffect(composeViewEffect: (EFFECT) -> Unit) {
val lifecycleOwner = LocalLifecycleOwner.current
LaunchedEffect(viewModel.viewEffects, lifecycleOwner.lifecycle) {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
withContext(Dispatchers.Main.immediate) {
viewModel.viewEffects.collect {
if (logging) Log.d(MAIN_TAG, "$TAG -> ComposeViewEffect: $it")
composeViewEffect(it)
}
}
}
}
}
}
private const val TAG = "AacMviFragment"
abstract class AacMviFragment<STATE, EFFECT, EVENT, VM : AacMviViewModel<STATE, EFFECT, EVENT>>(
val isForPureCompose: Boolean,
) : Fragment() {
abstract val viewModel: VM
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return if (isForPureCompose) {
ComposeView(requireContext()).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
val viewState = viewModel.viewStates.collectAsStateWithLifecycle()
ComposeViewState(viewState.value)
}
}
} else {
super.onCreateView(inflater, container, savedInstanceState)
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
if (isForPureCompose.not()) {
launch {
viewModel.viewStates.collect {
if (logging) Log.d(MAIN_TAG, "$TAG -> observed viewState : $it")
renderViewState(it)
}
}
launch {
withContext(Dispatchers.Main.immediate) {
viewModel.viewEffects.collect {
if (logging) Log.d(MAIN_TAG, "$TAG -> observed viewEffect : $it")
renderViewEffect(it)
}
}
}
}
}
}
}
open fun renderViewState(viewState: STATE) {}
abstract fun renderViewEffect(viewEffect: EFFECT)
@CallSuper
@Composable
open fun ComposeViewState(viewState: STATE) {
if (logging) Log.d(MAIN_TAG, "$TAG -> ComposeViewState: $viewState")
}
@Composable
fun ComposeViewEffect(composeViewEffect: (EFFECT) -> Unit) {
val lifecycleOwner = LocalLifecycleOwner.current
LaunchedEffect(viewModel.viewEffects, lifecycleOwner.lifecycle) {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
withContext(Dispatchers.Main.immediate) {
viewModel.viewEffects.collect {
if (logging) Log.d(MAIN_TAG, "$TAG -> ComposeViewEffect: $it")
composeViewEffect(it)
}
}
}
}
}
}
What Hasn't Changed
The conceptual contracts - ViewState, ViewEffect, ViewEvent  - are identical to the original article. The file-per-feature convention (keeping all three in one file per screen) is still the recommendation. The process() entry point and copy()-based state mutation are unchanged. This evolution is purely in the plumbing, not the pattern.
Thanks for reading!
Rohit Surwase
If you liked this article, clap clap clap 👏👏👏 as many times as you can.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment