Dispatch UI commands (Toast, Navigation, Permissions) from shared ViewModels to Android, iOS, Desktop JVM, and Web (WasmJs) — leak-free, rotation-safe, always on the Main Thread.
Most mobile apps treat everything (Toasts, Navigation, Alerts) as State. This is why you see "Ghost Toasts" popping up after rotation, or users stuck because a navigation event fired in the 300ms "blind spot" during an Activity restart.
| Approach | The Pain Point |
|---|---|
Pass Activity / UIViewController |
Memory leaks and onDestroy boilerplate |
SharedFlow(replay=0) |
Events are lost during screen rotation |
StateFlow as Event |
Double-execution / Side-effects that "stick" |
Channels |
Single-observer only (unsuitable for UI + Analytics) |
KRelay is a Buffered Multicasting bridge. Your shared ViewModel signals an intent, and the platform fulfills it — exactly once, always on the Main Thread, even if the UI wasn't ready when you called it.
"State is for seeing, Event is for running."
KRelay is designed for mission-critical systems (VoIP, Fintech, SOS) where event delivery is non-negotiable.
- Buffering: Holds events during the UI startup "blind spot."
- Multicasting: One dispatch, multiple listeners (UI, Analytics, Logging).
- No-Replay: Side-effects vanish immediately after they run.
Read the State vs. Event: Why your MVI/Redux app is probably leaking side-effects.
- Standard CRUD Applications: If your app consists of typical REST/GraphQL data fetching, list views, and straightforward state rendering, you do NOT need KRelay. Standard Kotlin Coroutines (
StateFlowfor state,Channelfor single-shot UI events) are completely sufficient and introduce zero external dependencies. - Synchronous Returns or Business Logic: KRelay is strictly for fire-and-forget UI intents. Never use it for queries, database operations, or operations needing return values.
| Scenario | Better Alternative | Why |
|---|---|---|
| Standard CRUD UI Events | Channel<UiEvent> / SharedFlow |
Simpler, standard Kotlin idioms, zero dependencies |
| Need a return value | suspend fun + expect/actual |
KRelay is fire-and-forget; cannot return results |
| Reactive UI state | StateFlow / MutableStateFlow |
State is for seeing; events are for running |
| Critical side-effects (payment, upload) | WorkManager / iOS Background Tasks |
KRelay queue is memory-based; lost on process death |
| Database / Network | Room / SQLDelight / Ktor | Core business architecture belongs in Repositories |
When shared business logic must trigger platform APIs tightly coupled to the active Activity or UIViewController lifecycle:
- Real-world Examples: In-App Review (
Google Play Core/ AppleStoreKit), Native Biometric Prompts (AndroidX Biometric/ iOSLocalAuthentication), Photo Picker intents, or Push notification system bridges. - Why not standard Flow? Passing callbacks or maintaining custom
expect/actualinterfaces through 4–5 architectural layers (ViewModel → UseCase → Repository → Activity) creates fragile boilerplate and lifecycle leaks. With KRelay, the platform Activity or Composable dynamically registers when resumed and unregisters when disposed. KRelay buffers commands in its sticky queue during rotations or screen transitions, dispatching them the moment the native UI is ready.
In large, enterprise-scale codebases where independent feature modules run under a single host shell:
- Real-world Examples: Team A (
Ride) and Team B (Food) running inside the same Super App (e.g., Grab, Uber, Gojek). Modules are strictly forbidden from having compile-time dependencies on each other. - Why KRelay? KRelay provides isolated, namespaced instances (
KRelay.create("Rides")vs.KRelay.create("Food")) withScopeTokenlifecycle guards. Feature teams dispatch cross-boundary intents dynamically without direct compile-time coupling or global event bus collision.
KRelay now provides a Bill of Materials (BOM) to automatically align versions across all artifacts.
// shared/build.gradle.kts
sourceSets {
commonMain.dependencies {
// 1. (Recommended) Import the BOM
api(platform("dev.brewkits:krelay-bom:2.2.0"))
// 2. Add dependencies without specifying versions
implementation("dev.brewkits:krelay")
implementation("dev.brewkits:krelay-compose") // Optional: Compose helpers
implementation("dev.brewkits:krelay-flow") // Optional: Flow operators (v2.2.0+)
}
commonTest.dependencies {
implementation("dev.brewkits:krelay-testing") // Optional: Test fakes and assertions
}
}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.
KRelay is designed for maximum testability. The krelay-testing artifact provides test fakes, JUnit rules, and type-safe assertions, completely removing the need for mocking frameworks.
import dev.brewkits.krelay.testing.KRelayTestRule
import kotlin.test.Test
import kotlin.test.AfterTest
class LoginViewModelTest {
// Automatically resets state after each test
private val relayRule = KRelayTestRule()
private val viewModel by lazy { LoginViewModel(krelay = relayRule.relay) }
@AfterTest
fun tearDown() = relayRule.after()
@Test
fun `login success shows toast and navigates`() {
// Act
viewModel.onLoginSuccess()
// Assert - type-safe and precise
relayRule.relay.assertDispatched<ToastFeature>()
relayRule.relay.assertDispatched<NavFeature>()
// Optional: execute the dispatch against a mock to verify parameters
var toastMessage: String? = null
relayRule.relay.executeLastDispatch(object : ToastFeature {
override fun show(msg: String) { toastMessage = msg }
})
assertEquals("Welcome back!", toastMessage)
}
}Run the test suite:
./gradlew :shared:test # JVM (fast)
./gradlew :shared:iosSimulatorArm64Test # iOS Simulator
./gradlew :shared: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 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 | Desktop (JVM) | WasmJs |
|---|---|---|---|---|---|---|
| 2.2.x | 2.1.x | 8.x | 24 | 14.0+ | ✅ | ✅ |
| 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) · JVM Desktop (macOS, Windows, Linux) · WasmJs Browser
v2.1.1 — QA Hardening & Ecosystem Infrastructure (Sep 2026)
krelay-testingartifact —FakeKRelayInstancewith a full assertion API (assertDispatched,assertNotDispatched,executeLastDispatch) for clean, mock-free unit testing.krelay-bom(Bill of Materials) — automatically align versions across all KRelay artifacts.- Public reified
dispatchWithPriorityAPI — simplified prioritization on both the singleton andKRelayInstance. - Binary Compatibility Validator (BCV) — integrated JetBrains BCV (
apiCheck) to mathematically guarantee zero breaking API changes in minor/patch releases. - Enterprise-grade CI/CD — automated multi-platform test matrices and release pipelines.
- Atomic dispatch & bug fixes — zero TOCTOU race conditions.
v2.1.0 — Compose Integration & Scope Tokens (Mar 2026)
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 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