Ditching LiveData: StateFlow and SharedFlow as the Kotlin-first replacement, with a migration playbook
Advanced: Flow/StateFlow to Replace LiveData + Best Practices
Hello Android developers, welcome to Part 11 of the Jetpack Kernel in Action series.
In the previous article, we completed the full implementation and performance optimization of the note-taking project, delivering a feature-complete app. But in modern Kotlin Android development, LiveData is gradually being replaced by the Flow ecosystem—Flow, as Kotlin's native reactive component, offers richer operators, more flexible thread control, and more comprehensive lifecycle safety, making it the mainstream direction for Jetpack architecture in the future.
This article systematically explains the Kotlin Flow core system, compares LiveData's limitations, details cold/hot flows, StateFlow/SharedFlow core concepts and use cases, provides a smooth migration plan from LiveData to StateFlow, and, combined with the note-taking project, completes a full replacement, building a reactive architecture more in line with modern Kotlin practices.
1. Core Limitations of LiveData: Why Migrate?
LiveData is a hallmark Jetpack component from the Java era, but as Kotlin became Android's first-class language, its design shortcomings have become increasingly apparent, and it can no longer meet the needs of modern reactive development.
1. Not Kotlin-native, limited coroutine adaptation
LiveData is essentially a product of the Java technology stack, originally designed to serve Java development, and its support for the Kotlin coroutine ecosystem is only at the extension level. Complex coroutine chains and structured concurrency require manual wrapping, making the code verbose and inconsistent with Kotlin development habits.
2. Sparse operators, weak data processing capabilities
LiveData only has a few built-in transformation operators like map and switchMap. Common stream processing scenarios such as debounce, distinct, filter, combine, and throttle require manual implementation, resulting in large amounts of code and frequent errors. Flow, on the other hand, has over a hundred standard operators, covering almost all data stream processing scenarios.
| Operation | LiveData Implementation | Flow Implementation |
|---|---|---|
| Debounce | Manual Handler + cancellation | debounce(300) |
| Distinct | Manual checking | distinctUntilChanged() |
| Switch data source | switchMap (limited) | flatMapLatest (flexible) |
| Combine multiple sources | MediatorLiveData (verbose) | combine() (concise) |
3. Limited thread switching capability
LiveData can only distribute data from background threads to the main thread via postValue; it cannot flexibly schedule execution threads. Complex asynchronous chains (IO thread query → computational thread processing → main thread update) are cumbersome to implement, and performance overhead is uncontrollable. Flow can flexibly switch dispatchers at any point using flowOn and withContext.
4. Inherent deficiency for event scenarios
LiveData is inherently a sticky "state holder" and is unsuitable for one-time events. The commonly used SingleLiveEvent in the industry is an official patch solution that only supports a single observer; for multiple observers, event distribution and consumption state management are difficult to implement. SharedFlow, by contrast, can flexibly configure stickiness via the replay parameter and natively supports multiple observers.
5. Callbacks only on the main thread, limited scenarios
LiveData forces observer callbacks to execute on the main thread, even for data preprocessing or log reporting that doesn't need UI updates, causing unnecessary thread switching overhead. Flow can collect and process data on any thread, switching to the main thread as needed, with higher resource utilization.
2. Flow Basics: Core Concepts and Capabilities
Flow is an asynchronous data stream standard provided by Kotlin coroutines. It can emit multiple values sequentially, comes with coroutine lifecycle management, and is the cornerstone of Kotlin reactive programming.
1. Cold flow vs. hot flow
This is the core concept of Flow and the foundation for understanding StateFlow/SharedFlow. The essential difference is whether data is tied to observers.
| Type | Core Characteristics | Execution Timing | Data Sharing | Typical Use Cases |
|---|---|---|---|---|
| Cold Flow | The stream itself does not hold data; it only defines execution logic | Starts executing only when collected | Each observer runs the stream independently; data is not shared | Room query streams, file reading, network requests |
| Hot Flow | The stream itself holds data/state and actively emits | Runs regardless of observers | All observers share the same data stream | StateFlow, SharedFlow, global event bus |
Simple understanding: Cold flow is an "execution script" that runs when called; hot flow is a "data source" that always exists, and observers can subscribe at any time.
2. Basic operations: collection, exception handling, thread switching
(1) collect terminal operation
collect is the terminal operation of a flow, starting the flow and receiving emitted values. It must be called in a coroutine.
val countFlow: Flow<Int> = flow {
for (i in 1..3) {
delay(1000)
emit(i) // emit data
}
}
viewModelScope.launch {
countFlow.collect { value ->
println("Received value: $value")
}
}
(2) Exception handling
Use the catch operator to catch exceptions in the flow without crashing the entire coroutine:
countFlow
.catch { e ->
Log.e("Flow", "Flow exception", e)
emit(-1) // emit fallback value on exception
}
.collect { ... }
(3) Thread switching: flowOn
flowOn only affects the execution thread of the upstream flow; the downstream collection thread is not affected. It is the core of Flow's thread control:
flow {
// perform time-consuming query on IO thread
emit(repository.queryData())
}.flowOn(Dispatchers.IO) // schedule upstream to IO thread
.collect {
// downstream is currently on the current coroutine thread (main thread)
binding.tvText.text = it.toString()
}
3. Two Core Hot Flows: StateFlow and SharedFlow
StateFlow and SharedFlow are the core components in the Flow ecosystem that replace LiveData, corresponding to the two scenarios of "state holding" and "event notification".
1. StateFlow: state stream, replaces regular LiveData
StateFlow is an observable state holder that always holds a current latest value. New observers immediately receive the latest state upon subscription. Its behavior is highly similar to LiveData, but with more powerful capabilities.
Core features:
- Must have an initial value: an initial state must be specified at creation, meaning "state always has a value" semantically
- Sticky: new subscribers immediately receive the latest value, consistent with LiveData
- Automatic deduplication: no callback is triggered when old and new values are equal, providing a built-in debounce effect
- Hot flow: multiple observers share the same state; all observers receive state changes simultaneously
Standard pattern:
Follow the "private mutable, public read-only" encapsulation principle, consistent with LiveData guidelines:
class HomeViewModel : ViewModel() {
private val _uiState = MutableStateFlow<NoteListUiState>(NoteListUiState.Loading)
val uiState: StateFlow<NoteListUiState> = _uiState.asStateFlow()
fun loadNotes() {
viewModelScope.launch {
_uiState.value = NoteListUiState.Loading
val result = repository.getNoteList()
_uiState.value = NoteListUiState.Success(result)
}
}
}
2. SharedFlow: shared stream, replaces SingleLiveEvent
SharedFlow is a configurable shared data stream that supports custom cache sizes and buffer sizes, specifically designed for event notification scenarios, perfectly solving the single-observer limitation of SingleLiveEvent.
Core features:
- No mandatory initial value: pure event-driven, no initial value needed
- Configurable stickiness: the number of cached events is specified via the replay parameter; replay=0 means completely non-sticky one-time events
- Multiple observer support: all observers share the event stream; an event can be received by multiple pages simultaneously
- Flexible configuration: buffer size and overflow strategy can be configured to suit different business scenarios
Standard pattern for one-time events:
class HomeViewModel : ViewModel() {
private val _toastEvent = MutableSharedFlow<String>(
replay = 0,
extraBufferCapacity = 1
)
val toastEvent: SharedFlow<String> = _toastEvent.asSharedFlow()
fun showToast(msg: String) {
viewModelScope.launch {
_toastEvent.emit(msg)
}
}
}
3. Core comparison and selection
| Feature | StateFlow | SharedFlow | LiveData | SingleLiveEvent |
|---|---|---|---|---|
| Initial value | Required | None | Nullable | None |
| Sticky | Yes | Configurable | Yes | No (single observer only) |
| Multiple observers | Supported | Supported | Supported | Not supported |
| Flow operators | Rich | Rich | Very few | None |
| Thread flexibility | High | High | Low | Low |
| Use cases | UI state holding | One-time events | Simple state holding | Single-observer events |
4. Lifecycle Safety: Proper use of repeatOnLifecycle
Flow itself does not have lifecycle awareness. Directly collecting can cause memory leaks. The official repeatOnLifecycle is the standard approach for lifecycle-safe collection.
1. Why does direct collect leak?
collect is a suspend function. If the coroutine is not canceled, the flow keeps running and holds external references. If the page is destroyed and the coroutine doesn't terminate, the Activity/Fragment cannot be garbage collected, causing memory leaks.
2. repeatOnLifecycle: the officially recommended safe approach
repeatOnLifecycle is an extension function from lifecycle-ktx. It launches the coroutine to collect the flow when the lifecycle enters a specified state, and automatically cancels the coroutine when leaving that state. Its behavior is identical to LiveData.observe().
Standard usage:
class MainActivity : AppCompatActivity() {
private val viewModel: HomeViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { state ->
// update UI (executed when the page is visible)
}
}
}
}
}
3. Essential difference from launchWhenStarted
Many developers mistakenly use launchWhenStarted. There is an essential difference:
| Approach | Behavior | Resource consumption | Safe |
|---|---|---|---|
| launchWhenStarted | Only suspends the coroutine, no callbacks, but the flow itself is still running in the background | Continues consuming resources | ❌ |
| repeatOnLifecycle | Cancels the coroutine when leaving the state; the flow fully terminates | Resources fully released | ✅ |
4. Best practice: extract a base class helper to simplify
You can encapsulate an extension function in a base Activity to reduce repetitive code:
fun <T> AppCompatActivity.collectFlow(
flow: Flow<T>,
state: Lifecycle.State = Lifecycle.State.STARTED,
collect: (T) -> Unit
) {
lifecycleScope.launch {
repeatOnLifecycle(state) {
flow.collect { collect(it) }
}
}
}
// Use it in one line
collectFlow(viewModel.uiState) { state ->
renderUi(state)
}
5. Three-Step Method for Migrating from LiveData to StateFlow
Existing projects do not need to replace everything at once. Following the three-step method enables a smooth migration with controllable risk.
Step 1: Replace in ViewModel layer
| Original | New |
|---|---|
| MutableLiveData() | MutableStateFlow(initialValue) |
| LiveData | StateFlow |
| SingleLiveEvent | MutableSharedFlow(replay = 0) |
| Transformations.map/switchMap | Flow corresponding operators (map/flatMapLatest) |
Step 2: Replace collection in UI layer
// Before migration
viewModel.data.observe(this) { data ->
updateUI(data)
}
// After migration
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.data.collect { data ->
updateUI(data)
}
}
}
Step 3: Gradual mixed-use transition
- Short-term mixing is fine: use Flow.asLiveData() to convert Flow to LiveData for UI consumption, while upgrading the ViewModel layer first
- Old LiveData can also be converted: use liveData.asFlow() to convert LiveData to Flow, reusing Flow operators
- Migrate pages with complex business logic that need stream processing first, then gradually replace everything
6. Practical Implementation: Full Replacement in the Note-Taking Project
We performed a complete replacement of the LiveData system with Flow/StateFlow/SharedFlow in the previous note-taking project.
1. Home ViewModel refactoring
Replaced the previous MediatorLiveData and SingleLiveEvent. Implemented debounce, search switching, and state encapsulation with Flow operators:
package com.example.jetpacknote.ui.home
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.example.jetpacknote.JetpackNoteApplication
import com.example.jetpacknote.data.entity.Note
import com.example.jetpacknote.data.repository.NoteRepository
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@OptIn(ExperimentalCoroutinesApi::class)
class HomeViewModel(application: Application) : AndroidViewModel(application) {
private val repository: NoteRepository
init {
val container = (application as JetpackNoteApplication).container
repository = container.noteRepository
}
// ========== Search keyword (UI state input) ==========
private val searchKeyword = MutableStateFlow("")
// ========== UI state (StateFlow) ==========
val uiState: StateFlow<NoteListUiState> = searchKeyword
.debounce(300) // input debounce 300ms
.distinctUntilChanged() // no repeated queries for same keyword
.flatMapLatest { keyword -> // automatically switch query stream on keyword change
if (keyword.isBlank()) {
repository.noteListFlow
} else {
repository.searchNotesFlow(keyword)
}
}
.map<List<Note>, NoteListUiState> { list ->
NoteListUiState.Success(list)
}
.catch { e ->
emit(NoteListUiState.Error(e.message ?: "Load failed"))
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = NoteListUiState.Loading
)
// ========== One-time events (SharedFlow) ==========
private val _toastEvent = MutableSharedFlow<String>(replay = 0)
val toastEvent: SharedFlow<String> = _toastEvent.asSharedFlow()
private val _navigationEvent = MutableSharedFlow<Long>(replay = 0)
val navigationEvent: SharedFlow<Long> = _navigationEvent.asSharedFlow()
// ========== Business methods ==========
fun updateSearchKeyword(keyword: String) {
searchKeyword.value = keyword
}
fun deleteNote(note: Note) {
viewModelScope.launch {
repository.deleteNote(note)
_toastEvent.emit("Note deleted")
}
}
fun navigateToDetail(noteId: Long) {
viewModelScope.launch {
_navigationEvent.emit(noteId)
}
}
}
2. Replacing collection in UI layer
Use the encapsulated collectFlow instead of observe; it is lifecycle-safe and concise:
private fun observeUiState() {
// Collect list state
collectFlow(viewModel.uiState) { state ->
when (state) {
NoteListUiState.Loading -> {
binding.progressBar.visibility = View.VISIBLE
binding.rvNotes.visibility = View.GONE
binding.tvEmpty.visibility = View.GONE
}
is NoteListUiState.Success -> {
binding.progressBar.visibility = View.GONE
if (state.noteList.isEmpty()) {
binding.rvNotes.visibility = View.GONE
binding.tvEmpty.visibility = View.VISIBLE
} else {
binding.tvEmpty.visibility = View.GONE
binding.rvNotes.visibility = View.VISIBLE
noteAdapter.submitList(state.noteList)
}
}
is NoteListUiState.Error -> {
binding.progressBar.visibility = View.GONE
Toast.makeText(this, state.message, Toast.LENGTH_SHORT).show()
}
}
}
// Collect Toast events
collectFlow(viewModel.toastEvent) { msg ->
Toast.makeText(this, msg, Toast.LENGTH_SHORT).show()
}
// Collect navigation events
collectFlow(viewModel.navigationEvent) { noteId ->
EditActivity.start(this, noteId)
}
}
3. Native Flow integration in data layer
- Room DAO: natively supports returning Flow<List>; automatically emits new values after data changes, no manual refresh needed
- DataStore: the data attribute is itself Flow, which can be converted to business state directly via operators
- End-to-end pipeline: the whole chain from data layer to UI layer is Flow throughout, without LiveData conversion overhead
// Room DAO native Flow support
@Dao
interface NoteDao {
@Query("SELECT * FROM notes ORDER BY updateTime DESC")
fun getAllNotes(): Flow<List<Note>> // automatically pushes on data change
}
// DataStore native Flow support
class SettingsRepository {
val darkModeFlow: Flow<Boolean> = dataStore.data
.map { it[SettingsKeys.DARK_MODE] ?: false }
}
4. Verifying migration results
| Verification point | Result |
|---|---|
| Functionality compatibility | List display, search, add/delete, state switching are identical to before |
| Performance improvement | Search debounce and data deduplication are implemented with native operators; code is more concise |
| Lifecycle safety | Flow collection automatically cancels when leaving the page; no memory leak risk |
| Better extensibility | Future logic like filtering, sorting, throttling can be added directly as operators |
7. FAQ / Pitfalls
Q1: Why must StateFlow have an initial value?
A: This is determined by StateFlow's positioning: it is a "state holder", not an "event emitter". Semantically, state must always have a definite value; there is no "no-state" situation, which is more rigorous than LiveData's null semantics.
If you truly need an "uninitialized" scenario, define an Uninitialized state using a sealed class as the initial value. Do not use null as a workaround.
sealed class UiState {
object Uninitialized : UiState()
object Loading : UiState()
data class Success(val data: List<Note>) : UiState()
data class Error(val message: String) : UiState()
}
Q2: How does StateFlow's deduplication work?
A: StateFlow has built-in equality checking: when setting the value, it first compares with the old value using equals. If equal, it will not emit and will not trigger observer callbacks, providing a basic debounce effect naturally.
- Kotlin data classes automatically generate equals methods, which can be deduplicated normally
- Custom classes must properly override equals and hashCode
- For more complex deduplication logic, use the distinctUntilChanged operator to customize rules
Q3: How to safely collect Flow to avoid memory leaks?
A: The core principle is that "collection coroutines must be bound to lifecycle":
| Principle | Explanation |
|---|---|
| Collect in lifecycle-aware scopes | Use lifecycleScope, viewModelScope; never use GlobalScope |
| Must use repeatOnLifecycle in UI layer | Automatically cancel coroutines when leaving the page, releasing flow resources |
| Use stateIn/shareIn to convert cold flows to hot | Specify WhileSubscribed policy; automatically release upstream resources when there are no observers |
| Do not collect UI flows in the background | Do not indefinitely collect UI-related flows in background services or static singletons |
Q4: Can LiveData and Flow be mixed? What are the options?
A: Yes, mixing is suitable for gradual migration in existing projects:
Conversion direction | Method | Use case Flow → LiveData | flow.asLiveData() | ViewModel uses Flow for logic, UI layer continues to use observe LiveData → Flow | liveData.asFlow() | Reuse Flow operators for existing LiveData
Note: Mixing increases technical stack complexity; in the long run it is recommended to unify on the Flow ecosystem.
Q5: How to choose the replay parameter for SharedFlow?
A:
| replay value | Behavior | Use case |
|---|---|---|
| 0 | Does not replay historical events (non-sticky) | Toast, navigation, dialogs (recommended) |
| 1 | Replays the last event (sticky) | Similar LiveData-like sticky behavior |
| > 1 | Replays multiple historical events | Scenarios where history needs to be retained |
8. Selection recommendations
| Scenario | Recommended approach | Reason |
|---|---|---|
| New projects | StateFlow + SharedFlow | Kotlin-native, richer ecosystem, officially recommended by Google |
| Existing LiveData projects | Gradual migration | Can start using Flow in new modules, then gradually replace old code |
| Simple state (1-2 values) | LiveData or StateFlow | Both work; LiveData is simpler |
| Complex data stream processing | StateFlow | Flow operators are rich and processing is more elegant |
| One-time events | SharedFlow | Official recommended solution, replacing SingleLiveEvent |
9. Architecture evolution summary
| Phase | UI state management | One-time events | Characteristics |
|---|---|---|---|
| Parts 1-3 | LiveData | SingleLiveEvent | Android-native, easy to get started |
| Parts 4-10 | LiveData + MediatorLiveData | SingleLiveEvent | Feature-complete but limited operators |
| This part | StateFlow | SharedFlow | Kotlin-native, rich operators, officially recommended |
At this point, our note-taking project has been upgraded from LiveData to StateFlow, giving it more powerful reactive data processing capabilities.
Next up in the series
This article systematically covered the core Kotlin Flow system and completed a migration plan from LiveData to StateFlow/SharedFlow with practical implementation. Flow, as Kotlin's native reactive standard, offers more powerful stream processing, more flexible thread control, and more complete lifecycle management, making it an essential skill for modern Android development.
The next article is the final summary of the column. We will make a systematic summary of the entire architecture and compile high-frequency interview questions with standard answers to help you quickly prepare for interviews and turn what you've learned into job-hunting competitiveness.
🌟 Like + Bookmark + Follow to get updates first 💬 Comment: In your project, do you use LiveData or Flow? Why? Feel free to share your thoughts!