Skip to content

OCNYang/ViewModelInCompose

Repository files navigation

Note

If you have more practical use cases, please let me know or create a pull request

ViewModelInCompose

中文文档

A Kotlin Multiplatform library providing enhanced ViewModel utilities for Compose Multiplatform.

Platforms

  • Android
  • iOS
  • Desktop (JVM)
  • Web (JS/Wasm)

Features

  • LaunchedEffectOnce: Execute side effects only once per page lifecycle
  • DisposableEffectOnce: Execute side effects with cleanup, both only once per page lifecycle
  • EventEffect: Lifecycle-aware one-time event handling from ViewModel to UI
  • StateBus: Cross-screen state sharing with automatic lifecycle management
  • SharedViewModel: Share ViewModels across multiple screens with automatic cleanup

Installation

Add the JitPack repository to your settings.gradle.kts:

dependencyResolutionManagement {
    repositories {
        // ...
        maven("https://jitpack.io")
    }
}

Add the dependency to your module's build.gradle.kts:

dependencies {
    implementation("com.github.OCNYang:ViewModelInCompose:<version>")
}

Replace <version> with the latest version shown in the badge above, or check releases.


LaunchedEffectOnce

A LaunchedEffect that executes only once per page lifecycle, surviving recomposition and configuration changes.When jumping to the next page and returning, it will not be re-executed. It will only be executed when reopening after exiting the current page.

When to Use

  • One-time initialization (data loading, analytics tracking)
  • Side effects that should not repeat on recomposition or screen rotation

Comparison with Standard LaunchedEffect

Behavior LaunchedEffect LaunchedEffectOnce
Recomposition May re-execute Does not re-execute
When returning from the next page Re-execute Does not re-execute
Configuration change Re-executes Does not re-execute
Page recreation Re-executes Re-executes

Usage

@Composable
fun MyScreen() {
    // Executes only once per page lifecycle
    LaunchedEffectOnce {
        viewModel.loadData()
        analytics.trackPageView()
    }
}

With Keys

@Composable
fun UserScreen(userId: String) {
    // Re-executes only when userId changes
    LaunchedEffectOnce(userId) {
        viewModel.loadUser(userId)
    }
}

Lifecycle Execution Comparison

The following diagrams show when each effect type executes during the Composable lifecycle:

LaunchedEffect

flowchart LR
    A[First Composition] --> B[State Recomposition] --> C[Key Changed] --> D[Navigate Away] --> E[Return] --> F[Config Change] --> G[Page Exit]

    A -.- A1[✅ effect executes]
    B -.- B1[❌ skipped]
    C -.- C1[✅ effect executes]
    D -.- D1[cancelled]
    E -.- E1[✅ effect executes]
    F -.- F1[✅ effect executes]
    G -.- G1[cancelled]
Loading

LaunchedEffectOnce

flowchart LR
    A[First Composition] --> B[State Recomposition] --> C[Key Changed] --> D[Navigate Away] --> E[Return] --> F[Config Change] --> G[Page Exit]

    A -.- A1[✅ effect executes]
    B -.- B1[❌ skipped]
    C -.- C1[✅ effect executes]
    D -.- D1[paused]
    E -.- E1[❌ skipped]
    F -.- F1[❌ skipped]
    G -.- G1[ViewModel cleared]
Loading

DisposableEffect

flowchart LR
    A[First Composition] --> B[State Recomposition] --> C[Key Changed] --> D[Navigate Away] --> E[Return] --> F[Config Change] --> G[Page Exit]

    A -.- A1[✅ effect executes]
    B -.- B1[❌ skipped]
    C -.- C1[✅ onDispose + effect]
    D -.- D1[✅ onDispose]
    E -.- E1[✅ effect executes]
    F -.- F1[✅ onDispose + effect]
    G -.- G1[✅ onDispose]
Loading

DisposableEffectOnce

flowchart LR
    A[First Composition] --> B[State Recomposition] --> C[Key Changed] --> D[Navigate Away] --> E[Return] --> F[Config Change] --> G[Page Exit]

    A -.- A1[✅ effect executes]
    B -.- B1[❌ skipped]
    C -.- C1[- no key]
    D -.- D1[paused]
    E -.- E1[❌ skipped]
    F -.- F1[❌ skipped]
    G -.- G1[✅ onDispose]
Loading

Summary Table

Effect First Composition State Recomposition Key Changed Navigate Away Return Config Change Page Exit
LaunchedEffect ✅ execute ❌ skip ✅ execute cancel ✅ execute ✅ execute cancel
LaunchedEffectOnce ✅ execute ❌ skip ✅ execute pause ❌ skip ❌ skip -
DisposableEffect ✅ execute ❌ skip ✅ dispose+execute ✅ dispose ✅ execute ✅ dispose+execute ✅ dispose
DisposableEffectOnce ✅ execute ❌ skip - (no key) pause ❌ skip ❌ skip ✅ dispose

DisposableEffectOnce

A DisposableEffect that executes only once per page lifecycle. Both the effect and the onDispose callback execute only once - effect runs on first composition, onDispose runs when the page is destroyed (ViewModel cleared).

When to Use

  • Subscribing to resources that need cleanup (listeners, observers)
  • Acquiring resources on screen enter and releasing on screen exit
  • One-time setup with guaranteed cleanup

Comparison with Standard DisposableEffect

Behavior DisposableEffect DisposableEffectOnce
Effect execution On every key change Only once
onDispose trigger On key change or leave composition Only when page is destroyed
Configuration change Re-executes effect + onDispose Neither re-executes
Recomposition May re-execute Does not re-execute

Usage

@Composable
fun MyScreen() {
    DisposableEffectOnce {
        // Effect code runs once when page is created
        val listener = registerListener()

        onDispose {
            // Cleanup runs once when page is destroyed
            listener.unregister()
        }
    }
}

Practical Example

@Composable
fun SensorScreen() {
    DisposableEffectOnce {
        // Subscribe to sensor updates - runs once
        sensorManager.registerListener(sensorListener)

        onDispose {
            // Unsubscribe - runs once when leaving the screen
            sensorManager.unregisterListener(sensorListener)
        }
    }
}

EventEffect

Lifecycle-aware event collection for handling one-time UI events from ViewModel.

When to Use

  • Navigation events
  • Showing toasts or snackbars
  • One-time UI actions triggered by ViewModel

Features

  • Lifecycle-aware: only collects events when UI is visible
  • Events are guaranteed to be delivered (won't be lost during config changes)
  • Each event is consumed exactly once

Usage

1. Define Events in ViewModel

class MyViewModel : ViewModel() {
    private val _events = EventChannel<UiEvent>()
    val events: Flow<UiEvent> = _events.flow

    fun onSubmit() {
        viewModelScope.launch {
            _events.send(UiEvent.ShowToast("Success!"))
            _events.send(UiEvent.NavigateBack)
        }
    }
}

sealed interface UiEvent {
    data class ShowToast(val message: String) : UiEvent
    data object NavigateBack : UiEvent
}

2. Collect Events in Composable

@Composable
fun MyScreen(viewModel: MyViewModel) {
    EventEffect(viewModel.events) { event ->
        when (event) {
            is UiEvent.ShowToast -> showToast(event.message)
            is UiEvent.NavigateBack -> navigator.navigateBack()
        }
    }
}

StateBus

Cross-screen state sharing solution with automatic lifecycle management.

When to Use

  • Pass data between screens (e.g., selection result from screen B back to screen A)
  • Share temporary state across multiple screens
  • Need automatic cleanup when no screens are observing

Features

  • Automatic Listener Tracking: Tracks observer count automatically
  • Automatic Cleanup: State is cleared when no observers remain
  • Thread-Safe: Uses synchronized locks and atomic counters
  • Configuration Change Survival: State persists across screen rotation
  • Process Death Recovery: Android platform supports state restoration via SavedStateHandle
  • Kotlin Multiplatform: Works on all supported platforms

Quick Start

1. Provide StateBus at Root

@Composable
fun App() {
    ProvideStateBus {
        MyNavHost()
    }
}

2. Set State (Sender Screen)

@Composable
fun ScreenB() {
    val stateBus = LocalStateBus.current

    Button(onClick = {
        stateBus.setState<Person>(selectedPerson)
        navigator.navigateBack()
    }) {
        Text("Confirm Selection")
    }
}

3. Observe State (Receiver Screen)

@Composable
fun ScreenA() {
    val stateBus = LocalStateBus.current
    val person = stateBus.observeState<Person?>()

    Text("Selected: ${person?.name ?: "None"}")
}

Using Custom Keys

When using generic types that may conflict (e.g., Result<String> vs Result<Int>), specify a custom key:

// Use explicit keys to avoid type erasure conflicts
val userResult = stateBus.observeState<Result<User>?>(stateKey = "userResult")
val orderResult = stateBus.observeState<Result<Order>?>(stateKey = "orderResult")

API Reference

API Description
ProvideStateBus Provides StateBus to the composition tree
LocalStateBus.current Gets the current StateBus instance
observeState<T>() Observes state with automatic listener tracking
setState<T>() Sets state value
removeState<T>() Manually removes state
hasState() Checks if state exists

Architecture

Activity/Fragment ViewModelStore
  └─ StateBus (ViewModel)
      └─ stateDataMap (thread-safe state cache)
      └─ persistence (platform-specific, SavedStateHandle on Android)

NavBackStackEntry (per screen)
  └─ StateBusListenerViewModel
      └─ Registers/unregisters listener on lifecycle

Platform Notes

Platform Process Death Recovery
Android ✅ Supported (SavedStateHandle)
iOS ❌ Not supported
Desktop ❌ Not supported
Web ❌ Not supported

SharedViewModel

Share ViewModels across multiple screens with automatic cleanup when all sharing screens are removed from the navigation stack.

When to Use

  • Multiple screens need to share the same ViewModel instance
  • Data should persist while navigating between related screens
  • ViewModel should be cleared when leaving the entire flow

Comparison with StateBus

Feature SharedViewModel StateBus
Purpose Share ViewModel instances Share state values
Scope Defined by SharedScope Global within StateBus
Cleanup When all includedRoutes leave stack When no observers remain
Use Case Complex business logic sharing Simple data passing

Quick Start

1. Define a Scope

object OrderFlowScope : SharedScope(
    includedRoutes = setOf(Route.Cart::class, Route.Checkout::class, Route.Payment::class)
)

2. Provide Registry at Root

@Composable
fun App() {
    ProvideSharedViewModelRegistry {
        MyNavHost()
    }
}

3. Register Scope in NavHost

@Composable
fun MyNavHost(navController: NavHostController) {
    val backStack by navController.currentBackStack.collectAsState()
    val routesInStack = remember(backStack) {
        backStack.mapNotNull { entry ->
            entry.destination.route?.let { getRouteClass(it) }
        }.toSet()
    }

    RegisterSharedScope(routesInStack, OrderFlowScope)

    NavHost(navController, startDestination = Route.Cart) {
        composable<Route.Cart> { CartScreen() }
        composable<Route.Checkout> { CheckoutScreen() }
        composable<Route.Payment> { PaymentScreen() }
    }
}

4. Use in Screens

@Composable
fun CartScreen() {
    val orderVm = sharedViewModel<OrderFlowScope, OrderViewModel> {
        OrderViewModel()
    }
}

@Composable
fun CheckoutScreen() {
    // Same ViewModel instance as CartScreen
    val orderVm = sharedViewModel<OrderFlowScope, OrderViewModel> {
        OrderViewModel()
    }
}

Usage Scenarios

Scenario 1: Sibling Screens Sharing ViewModel

object OrderFlowScope : SharedScope(
    includedRoutes = setOf(Route.Cart::class, Route.Checkout::class, Route.Payment::class)
)

Scenario 2: Parent Route with Nested Navigation

// IMPORTANT: Use PARENT route, not nested routes
object DashboardScope : SharedScope(
    includedRoutes = setOf(Route.Dashboard::class)  // Parent route only!
)

License

Apache License 2.0

About

KMP!!!ViewModel in Compose:👍提供多页面 ViewModel 的共享,👍提供 Compose 版的 EventBus(StateBus),👍提供打开 Compose 页面时真正只执行一次的 LaunchedEffectOnce 副作用

Topics

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages