Dispatch UI commands (Toast, Navigation, Permissions) from shared ViewModels to Android and iOS — leak-free, rotation-safe, always on the Main Thread.
Shared ViewModels can't safely touch platform APIs. Every approach has a catch:
| Approach | Problem |
|---|---|
Pass Activity / UIViewController |
Memory leak |
SharedFlow + collect {} |
Events lost on rotation |
expect/actual |
Wires up a whole file for a one-liner |
LiveData / StateFlow as event |
Complex, miss-able |
KRelay is none of the above. It is a typed bridge: the ViewModel signals an intent, the platform fulfills it.
// shared/build.gradle.kts
commonMain.dependencies {
implementation("dev.brewkits:krelay:2.1.1")
implementation("dev.brewkits:krelay-compose:2.1.1") // Compose helpers (optional)
}1. Define a contract in commonMain
interface ToastFeature : RelayFeature {
fun show(message: String)
}2. Dispatch from your ViewModel
class LoginViewModel : ViewModel() {
fun onLoginSuccess() {
KRelay.dispatch<ToastFeature> { it.show("Welcome back!") }
// Zero platform imports. Zero leaks. Queued if the UI isn't ready yet.
}
}3. Register the platform implementation
// Android — Activity or Composable
KRelay.register<ToastFeature>(object : ToastFeature {
override fun show(message: String) =
Toast.makeText(this@MainActivity, message, Toast.LENGTH_SHORT).show()
})// iOS — Swift
let toastClass = KRelayKClassHelpersKt.toastFeatureKClass()
KRelayIosHelperKt.registerFeature(
instance: KRelay.shared.instance,
kClass: toastClass,
impl: IOSToast(viewController: self)
)That's all the wiring needed. KRelay routes the call to the Main Thread, replays it if the UI wasn't ready, and releases the implementation when it's GC'd.
ViewModel KRelay Platform
─────────────────────────────────────────────────────────────
dispatch<Toast> { ... } ──► impl registered?
├── yes: runOnMain { block(impl) }
└── no: sticky queue ──► replay on register()
Three guarantees, always active:
- WeakReference registry — implementations are never strongly held; no
onDestroycleanup needed for 99% of cases. - Sticky queue — actions dispatched before registration are held and replayed automatically. Screen rotation, async init, cold start — all covered.
- Main Thread dispatch — regardless of which thread
dispatchis called from, the block executes on Android'sLooper.mainLooper()/ iOS's GCD main queue.
The API is identical on the global singleton and on any isolated instance.
// Registration
KRelay.register<ToastFeature>(impl)
KRelay.unregister<ToastFeature>() // unconditional
KRelay.unregister<ToastFeature>(impl) // identity-safe (won't clear a newer registration)
KRelay.isRegistered<ToastFeature>()
// Dispatch
KRelay.dispatch<ToastFeature> { it.show("Hello") }
KRelay.dispatchWithPriority<ToastFeature>(ActionPriority.CRITICAL) { it.show("Error!") }
// Queue management
KRelay.getPendingCount<ToastFeature>()
KRelay.clearQueue<ToastFeature>()
// Scope tokens — cancel queued actions by caller identity
val token = scopedToken()
KRelay.dispatch<ToastFeature>(token) { it.show("...") }
KRelay.cancelScope(token) // in ViewModel.onCleared()
// Debug
KRelay.dump()
KRelay.debugMode = trueWhen multiple actions queue up before an implementation registers, higher-priority actions replay first. On overflow, the lowest-priority action is evicted (not just the oldest).
KRelay.dispatchWithPriority<NavFeature>(ActionPriority.HIGH) { it.goToHome() }
KRelay.dispatchWithPriority<NavFeature>(ActionPriority.CRITICAL) { it.showError("Timeout") }
// ActionPriority: LOW(0) NORMAL(50) HIGH(100) CRITICAL(1000)Survives process death. The action is saved to SharedPreferences (Android) or NSUserDefaults (iOS) and restored on next launch.
// Register a factory to reconstruct the action from its payload
instance.registerActionFactory<ToastFeature>("toast", "show") { payload ->
{ feature -> feature.show(payload) }
}
// Dispatch — persisted to disk if no impl is available
instance.dispatchPersisted<ToastFeature>("toast", "show", "Payment received")
// On app restart — restores actions into the in-memory queue
instance.restorePersistedActions()Use an explicit string
featureKey(not the class name) — class names can be obfuscated by ProGuard/R8.
The singleton is fine for small apps. For multi-module projects or Koin/Hilt injection, create isolated instances:
// Each module owns its registry — no cross-module interference
val rideKRelay = KRelay.create("Rides")
val foodKRelay = KRelay.create("Food")
// Or with custom settings via builder
val krelay = KRelay.builder("Payment")
.maxQueueSize(50)
.actionExpiry(60_000L)
.debugMode(BuildConfig.DEBUG)
.build()Inject into ViewModels via Koin:
val appModule = module {
single { KRelay.create("AppScope") }
viewModel { LoginViewModel(krelay = get()) }
}
class LoginViewModel(private val krelay: KRelayInstance) : ViewModel() {
fun onSuccess() { krelay.dispatch<NavFeature> { it.goToHome() } }
}Add krelay-compose and use the built-in helpers:
// Registers when composition enters, unregisters when it leaves
@Composable
fun HomeScreen() {
val context = LocalContext.current
KRelayEffect<ToastFeature> {
object : ToastFeature {
override fun show(message: String) =
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
}
// ...
}// When you need to use the implementation in the same composable
@Composable
fun HomeScreen() {
val snackbarState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
rememberKRelayImpl<ToastFeature> {
object : ToastFeature {
override fun show(message: String) {
scope.launch { snackbarState.showSnackbar(message) }
}
}
}
Scaffold(snackbarHost = { SnackbarHost(snackbarState) }) { ... }
}Both helpers accept an optional instance parameter for the Instance API:
KRelayEffect<ToastFeature>(instance = myKRelayInstance) { ... }Manual
DisposableEffect? Always hoist the implementation intoremember {}. Without it, Kotlin/Native's GC can collect the object before the first dispatch.See Compose Integration Guide for full patterns including Navigation Compose and Voyager.
No mocking library required. Inject a real KRelayInstance and register plain Kotlin objects.
private lateinit var krelay: KRelayInstance
private lateinit var viewModel: LoginViewModel
@BeforeTest
fun setup() {
krelay = KRelay.create("TestScope")
viewModel = LoginViewModel(krelay = krelay)
}
@AfterTest
fun tearDown() {
krelay.reset()
}
@Test
fun `login success shows toast and navigates`() {
val toast = MockToast()
val nav = MockNav()
krelay.register<ToastFeature>(toast)
krelay.register<NavFeature>(nav)
viewModel.onLoginSuccess()
assertEquals("Welcome back!", toast.lastMessage)
assertEquals("home", nav.lastDestination)
}
class MockToast : ToastFeature {
var lastMessage: String? = null
override fun show(message: String) { lastMessage = message }
}
class MockNav : NavFeature {
var lastDestination: String? = null
override fun navigateTo(screen: String) { lastDestination = screen }
}Run the test suite:
./gradlew :krelay:test # JVM (fast)
./gradlew :krelay:iosSimulatorArm64Test # iOS Simulator
./gradlew :krelay:connectedDebugAndroidTest # Real Android deviceBy default, three passive protections apply to every queued action:
| Protection | Default | Behaviour |
|---|---|---|
WeakReference |
Always on | Platform impls released when GC'd — no onDestroy cleanup needed |
actionExpiryMs |
5 min | Queued actions expire and are dropped automatically |
maxQueueSize |
100 | When full, lowest-priority (or oldest) action is evicted |
For granular control, use scope tokens to cancel only the actions queued by a specific ViewModel:
class MyViewModel : ViewModel() {
private val token = scopedToken()
fun doWork() = KRelay.dispatch<WorkFeature>(token) { it.run() }
override fun onCleared() = KRelay.cancelScope(token)
}KRelay is for one-way, fire-and-forget UI commands. For anything else, use the right tool:
| Scenario | Better alternative |
|---|---|
| Need a return value | suspend fun + expect/actual |
| Reactive UI state | StateFlow / MutableStateFlow |
| Critical side-effects (payment, upload) | WorkManager / background service |
| Database | Room / SQLDelight |
| Network | Ktor + Repository |
KRelay is framework-agnostic. It connects to whatever navigation, media, or permission library you already use — ViewModels stay clean of all framework imports.
| Category | Library |
|---|---|
| Navigation | Voyager · Decompose · Navigation Compose |
| Media | Peekaboo (image/camera picker) |
| Permissions | Moko Permissions |
| Biometrics | Moko Biometry |
| Reviews | Play Core · StoreKit |
| DI | Koin · Hilt |
See Integration Guides for step-by-step examples.
| KRelay | Kotlin | AGP | Android minSdk | iOS |
|---|---|---|---|---|
| 2.1.x | 2.1.x | 8.x | 24 | 14.0+ |
| 2.0.x | 2.1.x | 8.x | 24 | 14.0+ |
| 1.1.x | 2.0.x | 8.x | 23 | 13.0+ |
| 1.0.x | 1.9.x | 7.x | 21 | 13.0+ |
Platforms: Android arm64 · Android x86_64 · iOS arm64 (device) · iOS arm64 (simulator) · iOS x64 (simulator)
v2.1.1 — Hardened & Standardized
- Atomic dispatch — the impl lookup, queue insertion, and persistence decision happen inside a single lock, closing the TOCTOU window that could strand an action indefinitely.
krelay-composeartifact —KRelayEffect<T>andrememberKRelayImpl<T>published asdev.brewkits:krelay-compose:2.1.1, separate from the zero-dependency core.- ProGuard/R8-safe persistence —
registerActionFactoryanddispatchPersistednow require an explicit stablefeatureKeystring. Old overloads deprecated withreplaceWithguidance. - Identity-aware
unregister—unregister(impl)only removes the registration if the stored reference matches, preventing a recomposing Compose component from clearing a newer registration. - Thread-safe metrics — all
KRelayMetricsoperations are now lock-protected. - iOS registration validation —
registerFeaturevalidates interface conformance at runtime; crashes in debug, warns in release. - Priority eviction — queue overflow now evicts the lowest-priority action, not the oldest FIFO item.
v2.1.0 — Compose, Persistence & Scope Tokens
KRelayEffect<T>andrememberKRelayImpl<T>Compose helpers- Persistent dispatch with
dispatchPersisted<T>()— survives process death SharedPreferencesPersistenceAdapter(Android) andNSUserDefaultsPersistenceAdapter(iOS)- Scope Token API:
scopedToken()+cancelScope(token)for fine-grained ViewModel cleanup dispatchWithPriorityavailable on instances (was singleton-only)resetConfiguration()without clearing the registry or queue
v2.0.0 — Instance API for Super Apps
KRelay.create("ScopeName")— isolated instances per moduleKRelay.builder(...)— configure queue, expiry, and debug mode per instance- DI-friendly:
KRelayInstanceis an interface, injectable via Koin or Hilt - 100% backward compatible with v1.x
| Guide | Description |
|---|---|
| Compose Integration | KRelayEffect, rememberKRelayImpl, Navigation Compose, Voyager |
| SwiftUI Integration | iOS-specific patterns, XCTest |
| Integration Guides | Voyager, Decompose, Moko, Peekaboo, DI |
| Lifecycle Guide | Activity · Fragment · UIViewController · SwiftUI |
| Testing Guide | Patterns, mocks, instrumented tests |
| Anti-Patterns | What not to do and why |
| Architecture | Internals deep dive |
| API Reference | Full API cheat sheet |
| Managing Warnings | Suppress @OptIn at module level |
| Migration to v2.0 | Upgrading from v1.x |
Copyright 2026 Brewkits
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Made with care by Nguyễn Tuấn Việt · Brewkits