跪拜 Guibai
← Back to the summary

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:

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:

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

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 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.

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!