Mobile Development 24 min read

Using Kotlin Flow and Channel to Solve Android Development Pain Points and Implement an MVI Architecture

This article demonstrates how Kotlin Flow and Channel can address common Android development challenges such as ViewModel‑View communication and event handling, compares alternatives like LiveData and RxJava, and presents a practical MVI architecture implementation with code examples.

ByteDance Dali Intelligent Technology Team
ByteDance Dali Intelligent Technology Team
ByteDance Dali Intelligent Technology Team
Using Kotlin Flow and Channel to Solve Android Development Pain Points and Implement an MVI Architecture

Preface

This article explains how to use Kotlin Flow to solve pain points in Android development, introduces a Flow‑based MVI architecture, and points out typical misuse of LiveData. For basic Flow concepts see the official Kotlin documentation.

Background

The Dali Smart client team refactored a tablet app from an RxJava‑based MVP architecture to a LiveData + ViewModel + Kotlin‑coroutine MVVM architecture. As business complexity grew, LiveData could no longer clearly separate "state" from "event", and its sticky behavior caused side‑effects.

Kotlin Flow, introduced with Kotlin 1.4, provides asynchronous streams that can emit multiple values. StateFlow and SharedFlow bring many Channel features to the forefront, making Flow a strong candidate for the team’s needs.

Pain Point 1 – Awkward ViewModel ↔ View Communication

Problem Discovery

LiveData breaks after screen rotation?

When the screen rotates, the Activity is recreated and the observer is re‑registered, causing the same Toast to be shown on every rotation because LiveData re‑emits the latest value to each new observer.

LiveData guarantees that every observer receives the latest value, which is perfect for UI state but unsuitable for one‑time events.

Developers often wrap MutableLiveData with a SingleLiveEvent to force single emission, but this violates LiveData’s design and can introduce other issues.

Is LiveData‑only communication enough?

LiveData merges events and discards older ones, which works for state but not for events that must be processed individually (e.g., multiple praise dialogs).

Problem Analysis

LiveData is great for state, but events need a different mechanism. The requirements are:

Prevent memory leaks and respect lifecycle.

Support thread switching.

Avoid consuming events when the observer is inactive.

Solution 1 – Blocking Queue

Using a blocking queue in the ViewModel and a busy‑wait loop in the View is cumbersome and does not work with coroutines.

Solution 2 – Kotlin Channel

Channels replace the blocking put / take with suspendable send / receive . They do not hold lifecycle references, support coroutine context switching, and can be paused with launchWhenX or repeatOnLifecycle to avoid consumption when the UI is inactive.

Will consuming a Channel in a lifecycle component cause memory leaks? No, because Channel does not retain lifecycle component references.
Does it support thread switching? Yes, by launching the collector in a coroutine with the desired dispatcher.
Will it consume events when the observer is inactive? Using launchWhenX or repeatOnLifecycle suspends the collector until the lifecycle reaches the required state.

Creating a Channel with default parameters (capacity = RENDEZVOUS, overflow = SUSPEND) satisfies the requirements.

Solution 3 – Cold Flow

Cold flows start execution only when collected, which makes them unsuitable for emitting events from outside the collector. However, channelFlow or converting a Channel with receiveAsFlow yields a "cold‑outside, hot‑inside" flow that behaves like a Channel.

private val testChannel: Channel
= Channel()
private val testChannelFlow = testChannel.receiveAsFlow()

Solution 4 – SharedFlow / StateFlow

Both are hot flows. SharedFlow supports multiple collectors, which is unnecessary for one‑to‑one event delivery, and can drop data when there are no subscribers. StateFlow behaves similarly, so they are not ideal for the described one‑time events.

Solution for Pain Point 1 – Use Channel

class RoomViewModel : ViewModel() {
    private val _effect = Channel
()
    val effect = _effect.receiveAsFlow()

    private fun setEffect(builder: () -> Effect) {
        val newEffect = builder()
        viewModelScope.launch { _effect.send(newEffect) }
    }

    fun showToast(text: String) {
        setEffect { Effect.ShowToastEffect(text) }
    }
}

sealed class Effect {
    data class ShowToastEffect(val text: String) : Effect()
}
class RoomActivity : BaseActivity() {
    override fun initObserver() {
        lifecycleScope.launchWhenStarted {
            viewModel.effect.collect { effect ->
                when (effect) {
                    is Effect.ShowToastEffect -> showToast(effect.text)
                }
            }
        }
    }
}

Pain Point 2 – Activity/Fragment Communication via Shared ViewModel

Sharing a ViewModel between an Activity and its Fragments works for state synchronization but becomes problematic for one‑to‑many event broadcasting, especially with DialogFragments that need to notify the host Activity.

Problem Discovery

Using interfaces for DialogFragment callbacks leads to many interfaces in the Activity as the number of dialogs grows.

Problem Analysis

We need a broadcast‑like mechanism that supports multiple subscribers and discards stale events. SharedFlow fits this requirement because it can be configured with replay = 0 and capacity = 0 , ensuring new subscribers receive only fresh events.

Solution

class NoticeDialogFragment : DialogFragment() {
    private val activityVM: MyViewModel by activityViewModels()
    fun initListener() {
        posBtn.setOnClickListener {
            activityVM.sendEvent(NoticeDialogPosClickEvent(textField.text))
            dismiss()
        }
        negBtn.setOnClickListener {
            activityVM.sendEvent(NoticeDialogNegClickEvent)
            dismiss()
        }
    }
}

class MainActivity : FragmentActivity() {
    private val viewModel: MyViewModel by viewModels()
    fun showNoticeDialog() {
        val dialog = NoticeDialogFragment()
        dialog.show(supportFragmentManager, "NoticeDialogFragment")
    }
    fun initObserver() {
        lifecycleScope.launchWhenStarted {
            viewModel.event.collect { event ->
                when (event) {
                    is NoticeDialogPosClickEvent -> handleNoticePosClicked(event.text)
                    NoticeDialogNegClickEvent -> handleNoticeNegClicked()
                }
            }
        }
    }
}

class MyViewModel : ViewModel() {
    private val _event = MutableSharedFlow
()
    val event = _event.asSharedFlow()
    fun sendEvent(event: Event) {
        viewModelScope.launch { _event.emit(event) }
    }
}

Using repeatOnLifecycle instead of launchWhenStarted would be a better practice to automatically cancel the collector when the Activity is stopped.

Flow/Channel‑Based MVI Architecture

What is MVI?

MVI stands for Model, View, Intent. The Model is an immutable data class representing the UI state. The View renders the UI and emits user intents. The Intent is the sole source of state changes.

Implementation Details

abstract class BaseViewModel
: ViewModel() {
    private val initialState: State by lazy { createInitialState() }
    abstract fun createInitialState(): State

    private val _uiState = MutableStateFlow(initialState)
    val uiState = _uiState.asStateFlow()

    private val _event = MutableSharedFlow
()
    val event = _event.asSharedFlow()

    private val _effect = Channel
()
    val effect = _effect.receiveAsFlow()

    init { subscribeEvents() }

    private fun subscribeEvents() {
        viewModelScope.launch { event.collect { handleEvent(it) } }
    }

    protected abstract fun handleEvent(event: Event)
    fun sendEvent(event: Event) { viewModelScope.launch { _event.emit(event) } }
    protected fun setState(reduce: State.() -> State) { _uiState.value = _uiState.value.reduce() }
    protected fun setEffect(builder: () -> Effect) { viewModelScope.launch { _effect.send(builder()) } }
}

interface UiState
interface UiEvent
interface UiEffect

The UI layer collects uiState for rendering, effect for one‑time side effects (e.g., Toast), and sends user actions via sendEvent . A concrete contract (e.g., NoteContract ) defines the specific State, Event, and Effect types.

Benefits of MVI

Solves the two earlier pain points by cleanly separating state and events.

Enforces a unidirectional data flow, making debugging easier.

Improves decoupling between View and ViewModel.

Provides a clear contract for developers, leading to more maintainable code.

Practical Drawbacks

UiState classes can become large for complex screens, requiring modularization.

Defining events and routing them adds boilerplate compared to direct method calls.

Conclusion

Using SharedFlow and Channel for event handling is valuable even outside a full MVI setup. StateFlow (or LiveData) can aggregate UI state, while Channel‑based effects guarantee one‑time execution. Developers can adopt parts of this architecture to solve rotation‑related bugs and improve event handling.

What Else Flow Brings

Flow offers many operators not present in RxJava or plain coroutines, such as flowOn , buffer , conflate , debounce , combine , and callbackFlow for converting callback‑based APIs into suspendable streams.

Final Thoughts

New frameworks should be adopted only when they solve real problems. Kotlin Flow, when applied correctly, can simplify Android development and address common pitfalls like screen‑rotation issues.

androidKotlinCoroutineschannelMVIFlow
ByteDance Dali Intelligent Technology Team
Written by

ByteDance Dali Intelligent Technology Team

Technical practice sharing from the ByteDance Dali Intelligent Technology Team

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.