Process Death Handling via Automatic StateFlow Persistence with Reanimator (Android & KMP)

Ioannis Anifantakis on 2025-04-21

Process Death Handling via Automatic State Persistence (Android & KMP)

Image Source — GPT 4o

Why Saving App Information is Very Important

When we build mobile apps, for Android phones or using Kotlin Multiplatform (KMP), users expect apps to remember where they were and what they were doing, even if they switch to another app for a moment or get a phone call.

Now a big challenge, especially on Android, is something called “process death”. To save resources, the Android system might completely stop your app if it’s running in the background (not visible on the screen). This isn’t a crash; it’s a normal Android behavior.

However, when the user comes back to your app, Android restarts it only with the navigation stack restored and the rest of the state not restored. If you haven’t saved the screen’s state properly you will end up with improperly restored UI.

The MVI Problem: One State Object, Two Kinds of Data

Many modern app developers like using patterns such as MVI (Model-View-Intent). A common idea in MVI is to define a data class to host a single-state object for each screen.

However, this single state object often contains two different kinds of information mixed together:

  1. Persistent State: Information that must be saved and restored after process death.
  2. Transient State: Temporary information that should NOT be saved. This state should reset to its default value when the app restarts.
@Serializable
data class MyScreenState(

    // --- Save This ---
    val userProfile: UserProfile? = null,
    val items: List<Item> = emptyList(),

    // --- Do Not Save This (Transient) ---
    val isLoading: Boolean = false,
    val isDownloading: Boolean = false
)

If we simply save the entire MyScreenState object using SavedStateHandle, we run into trouble. When the app restarts after process death, the isLoading flag might still be true (showing a spinner forever!) or an old status might still show that doesn’t make sense anymore.

To fix this manually, more code would need to be added for filtering what needs to be persisted and resetting would be needed for whatever should not be persisted, adding complexity and boilerplate code – exactly the kind of repetitive work MVI tries to help us avoid!

Approach 1 — @Transient + light snippet

If your state class is primarily used within a specific ViewModel and not serialized elsewhere with different needs, Kotlin's built-in @Transient annotation is often the simplest solution.

You mark the fields directly in the data class:

import androidx.lifecycle.SavedStateHandle
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.serialization.json.Json
import kotlin.properties.PropertyDelegateProvider
import kotlin.properties.ReadOnlyProperty

inline fun <reified T> SavedStateHandle.getMutableStateFlow(
    defaultValue: T,
    coroutineScope: CoroutineScope,
    key: String? = null
) = PropertyDelegateProvider<Any, ReadOnlyProperty<Any, MutableStateFlow<T>>> { thisRef, property ->
    val viewModelClassName = thisRef.let { it::class.simpleName } ?: "UnknownViewModel"
    val actualKey = key ?: "${viewModelClassName}_${property.name}"
    val json = Json { ignoreUnknownKeys = true },

    val stateFlow = MutableStateFlow(
        get<String>(actualKey)?.let { json.decodeFromString<T>(it) } ?: defaultValue
    ).also { flow ->
        flow.onEach { set(actualKey, json.encodeToString(it)) }.launchIn(coroutineScope)
    }

    ReadOnlyProperty { _, _ -> stateFlow }
}

And in your ViewModel:

// YOUR SERIALZABLE DATA CLASS WITH TRANSIENT FIELDS
@Serializable
data class MyScreenState(
    val userProfile: UserProfile? = null,
    val items: List<Item> = emptyList(),
    @Transient val isLoading: Boolean = false,
    @Transient val isDownloading: Boolean = false,
)

// YOUR STATE AT THE VIEWMODEL
private val _state by savedStateHandle.getMutableStateFlow(
    coroutineScope = viewModelScope,
    defaultValue = MyScreenState(),
)
val state = _state.asStateFlow()

This simple snippet wires up loading/decoding, saving/encoding, and collection efficiently.

If you control the model class and @Transient fits your needs perfectly, this solution is excellent and keeps dependencies minimal.

Where the Snippet Shines

When you don’t have any @Transient properties in your state, otherwise a data class that doesn’t fall short in the “Falls Short” list!

Where the Snippet Falls Short

The @Transient approach works beautifully when the data class definition aligns perfectly with your persistence needs. However, you might encounter situations where it's less ideal:

A larger snippet that promotes the idea that the ViewModel should control which parts of its state survive process death. It focuses on giving the ViewModel this control over transient fields, especially for the purpose of saving state via SavedStateHandle.

It uses the familiar getMutableStateFlow extension function but adds the optional transientProperties parameter:

The Reanimator Snippet

import androidx.lifecycle.SavedStateHandle
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.serializer
import kotlin.properties.PropertyDelegateProvider
import kotlin.properties.ReadOnlyProperty


@OptIn(ExperimentalSerializationApi::class)
inline fun <reified T : Any> SavedStateHandle.getMutableStateFlow(
    defaultValue: T,
    key: String? = null,
    coroutineScope: CoroutineScope,
    transientProperties: Set<String> = emptySet(),
): PropertyDelegateProvider<Any, ReadOnlyProperty<Any, MutableStateFlow<T>>> {
    return PropertyDelegateProvider { owner, property ->

        // build a stable key for SavedStateHandle,
        val viewModelClassName = owner.let { it::class.simpleName } ?: "UnknownViewModel"
        val actualKey = key ?: "${viewModelClassName}_${property.name}"

        val serializer = serializer<T>()
        val json = Json { ignoreUnknownKeys = true }

        // ---------- initial value ----------
        val initial: T = get<String>(actualKey)?.let { stored ->
            if (transientProperties.isEmpty()) {
                // no transients in list, just decode the json
                runCatching { json.decodeFromString(serializer, stored) }
                    .getOrElse { defaultValue }
            } else {
                // transients in the list, merge with defaults
                val saved   = json.parseToJsonElement(stored).jsonObject
                val fresh   = json.encodeToJsonElement(serializer, defaultValue).jsonObject

                // rebuild JSON: keep everything except the transient keys,
                // then add the default values for those keys
                val patched = buildJsonObject {
                    saved.forEach { (k, v) -> if (k !in transientProperties) put(k, v) }
                    transientProperties.forEach { k -> fresh[k]?.let { put(k, it) } }
                }

                runCatching { json.decodeFromJsonElement(serializer, patched) }
                    .getOrElse { defaultValue }
            }
        } ?: defaultValue

        // ---------- Persist each StateFlow emission into SavedStateHandle ----------
        MutableStateFlow(initial).also { flow ->
            flow.onEach { value ->
                // initially encode all properties
                val element = json.encodeToJsonElement(serializer, value)
                //  then remove transient ones if "transientProperties" is not empty
                val cleanedObj = (element as? JsonObject)
                    ?.takeIf { transientProperties.isNotEmpty() }
                    ?.let { JsonObject(it.filterKeys { k -> k !in transientProperties }) }
                    ?: element

                // return the encoded value as SavedStateHandle key/value
                set(actualKey, cleanedObj.toString())
            }.launchIn(coroutineScope)
        }.let { ReadOnlyProperty { _, _ -> it } }
    }
}

Here is how you use it:

// YOUR SERIALZABLE DATA CLASS WITH TRANSIENT FIELDS
@Serializable
data class MyScreenState(
    val userProfile: UserProfile? = null,
    val items: List<Item> = emptyList(),
    val isLoading: Boolean = false,    // no need to mark it @Transient
    val isDownloading: Boolean = false // no need to mark it @Transient
)


// In ViewModel:
private val _state by savedStateHandle.getMutableStateFlow(
    defaultValue = MyScreenState(),  // Your starting state object
    coroutineScope = viewModelScope, // Scope for background saving (usually viewModelScope)
    
    // Define transient fields HERE, not necessarily in MyScreenState
    transientProperties = setOf("isLoading", "isDownloading")
)
val state: StateFlow<MyScreenState> = _state.asStateFlow()

Reanimator uses the same fast, direct serialization path as the simple snippet and will respect any @Transient annotations already present in your data class, so you can use it in place of the simpler snippet as well if you wish to make use of @Transient annotation.

However, the library's extra internal logic for filtering/merging only activates when you explicitly leverage the transientProperties feature to gain ViewModel-level control.

The Reanimator Library

Reanimator Library is a short library that just packs the reanimator snippet seen above.

I added it as a library to avoid pasting long snippets, but also to allow for more refined usage by improvements that could be added along the way.

All you need to do is add this library:

kotlin {
    sourceSets {
        commonMain.dependencies {
            implementation("eu.anifantakis:reanimator:1.0.5")
            // ... other libraries
        }
    }
}

or just copy the reanimator snippet from above :)

Reanimator in Action: A KMP Example Explained

Let’s look at the code example again, explaining it more. Remember, you can get the full working code from the demo project: github.com/ioannisa/ReanimatorUIDemo.

1. Add the Library (in build.gradle.kts): This line tells your project to include the Reanimator library.

2. Define State & Intents (in commonMain): This data class holds all info for the product screen. @Serializable allows Reanimator to convert it.

@Serializable // Needs to be Serializable
data class SimpleProductState(
    // --- Save This (Persistent) ---
    val products: List<String> = emptyList(),   // The list should be saved
    val selectedProduct: String? = null,        // The selection should be saved

    // --- Do Not Save This (Transient) ---
    val isLoading: Boolean = false,      // Loading is temporary, reset it
    val errorMessage: String? = null     // Errors are temporary, reset it
)

// User actions
sealed interface SimpleProductIntent { /* ... LoadProducts, SelectProduct, etc. ... */ }

We clearly see the mix: products and selectedProduct need saving, while isLoading and errorMessage are temporary.

3. Create the ViewModel (in commonMain or androidMain): The ViewModel manages the state and logic.

import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import eu.anifantakis.lib.reanimator.getMutableStateFlow

class SimpleProductViewModel(
    private val savedStateHandle: SavedStateHandle 
) : ViewModel() {

    // List the names of properties NOT to save
    private val transientProperties = setOf("isLoading", "errorMessage")

    // Reanimator makes saving state automatic here!
    private val _state: MutableStateFlow<SimpleProductState> by savedStateHandle.getMutableStateFlow(
        defaultValue = SimpleProductState(), // Initial state, also provides defaults for transient parts on restore
        coroutineScope = viewModelScope, // Scope for background saving work
        transientProperties = transientProperties // Pass the list of properties to ignore when saving
    )
    val state: StateFlow<SimpleProductState> = _state.asStateFlow()

    // --- Handle User Actions ---
    fun processIntent(intent: SimpleProductIntent) {
        if (intent is SimpleProductIntent.LoadProducts) {
            _state.update { it.copy(isLoading = true, errorMessage = null) }
        }
        // ... handle other intents ...
    }
    // ... other ViewModel functions ...
}

How to Get Started

Ready to make state saving easier?

  1. Add Library: Add the dependency implementation("eu.anifantakis:reanimator:1.0.2") to your build.gradle.kts file (usually in commonMain dependencies).
  2. Maven Central: You can find more library details here: https://central.sonatype.com/artifact/eu.anifantakis/reanimator.
  3. Source Code: See how it works on GitHub: https://github.com/ioannisa/reanimator.
  4. Demo App: See a working example on GitHub: https://github.com/ioannisa/ReanimatorUIDemo.
  5. How to Use: Just make sure your state data class is marked with @Serializable and use the savedStateHandle.getMutableStateFlow(...) delegate in your ViewModel.

Saving screen state correctly, especially when dealing with process death or complex state objects in MVI, is essential for creating good, professional apps. It used to involve a lot of difficult, repetitive code.

Reanimator offers a simple solution for Android and KMP developers. It automates the process of saving your StateFlow state using SavedStateHandle. Most importantly, it lets you easily tell it which parts of your state are temporary (transient properties) and should not be saved.

Enjoyed the article?

SUBSCRIBE and CLAP on Medium

Ioannis Anifantakis anifantakis.eu | LinkedIn | YouTube | X.com | Medium