The missing piece in Kotlin Multiplatform. Call Toasts, navigate screens, request permissions — anything native — directly from your shared ViewModel. No leaks. No crashes. No boilerplate.
You've written a clean, shared ViewModel. Then you need to show a permission dialog, navigate to the next screen, or open an image picker. And you hit the wall:
class ProfileViewModel : ViewModel() {
fun updateAvatar() {
// ❌ Can't pass Activity — memory leak waiting to happen
// ❌ Can't pass UIViewController — platform dependency in shared code
// ❌ SharedFlow loses events during screen rotation
// ❌ expect/actual is overkill for a one-liner
// 😤 So... what do you do?
}
}This is the "Last Mile" problem of KMP. Your business logic is clean and shared — but the moment you need to trigger something native, you're stuck choosing between leaks, boilerplate, or coupling.
Step 1 — Define a shared contract (commonMain)
interface MediaFeature : RelayFeature {
fun pickImage()
}Step 2 — Dispatch from your ViewModel
class ProfileViewModel : ViewModel() {
fun updateAvatar() {
KRelay.dispatch<MediaFeature> { it.pickImage() }
// ✅ Zero platform deps ✅ Zero leaks ✅ Queued if UI isn't ready yet
}
}Step 3 — Register the real implementation on each platform
// Android
KRelay.register<MediaFeature>(PeekabooMediaImpl(activity))
// iOS (Swift)
KRelay.shared.register(impl: IOSMediaImpl())That's it. KRelay handles lifecycle safety, main-thread dispatch, queue management, and cleanup automatically.
Implementations are held as WeakReference. When your Activity or UIViewController is destroyed, KRelay releases it automatically. No null checks. No onDestroy cleanup for 99% of use cases.
Commands dispatched while the UI isn't ready are queued and automatically replayed when a new implementation registers. Your user rotated the screen mid-API-call? The navigation event still arrives.
Dispatch from any background coroutine. KRelay guarantees UI code always executes on the Main Thread — Android Looper and iOS GCD both handled.
KRelay is the glue layer — it integrates with whatever libraries you already use, keeping your ViewModels free of framework dependencies:
| Category | Works with |
|---|---|
| 🧭 Navigation | Voyager, Decompose, Navigation Compose |
| 📷 Media | Peekaboo image/camera picker |
| 🔐 Permissions | Moko Permissions |
| 🔒 Biometrics | Moko Biometry |
| ⭐ Reviews | Play Core (Android), StoreKit (iOS) |
| 💉 DI | Koin, Hilt — inject KRelayInstance into ViewModels |
| 🎨 Compose | Built-in KRelayEffect<T> and rememberKRelayImpl<T> helpers |
Your ViewModels stay pure — zero direct dependencies on Voyager, Decompose, Moko, or any platform library.
→ See Integration Guides for step-by-step examples.
// shared module build.gradle.kts
commonMain.dependencies {
implementation("dev.brewkits:krelay:2.1.0")
}Perfect for single-module apps or getting started fast.
// 1. Define the contract (commonMain)
interface ToastFeature : RelayFeature {
fun show(message: String)
}
// 2. Dispatch from shared ViewModel
class LoginViewModel {
fun onLoginSuccess() {
KRelay.dispatch<ToastFeature> { it.show("Welcome back!") }
}
}
// 3A. Register on Android
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
KRelay.register<ToastFeature>(object : ToastFeature {
override fun show(message: String) =
Toast.makeText(this@MainActivity, message, Toast.LENGTH_SHORT).show()
})
}
// 3B. Register on iOS (Swift)
override func viewDidLoad() {
super.viewDidLoad()
KRelay.shared.register(impl: IOSToast(viewController: self))
}The recommended approach for new projects, Koin/Hilt, and modular "Super Apps." Each module gets its own isolated instance — no conflicts between modules.
// Koin module setup
val rideModule = module {
single { KRelay.create("Rides") } // isolated instance
viewModel { RideViewModel(krelay = get()) }
}
// ViewModel — pure, no framework deps
class RideViewModel(private val krelay: KRelayInstance) : ViewModel() {
fun onBookingConfirmed() {
krelay.dispatch<ToastFeature> { it.show("Ride booked!") }
}
}
// Android Activity
val rideKRelay: KRelayInstance by inject()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
rideKRelay.register<ToastFeature>(AndroidToast(applicationContext))
}Compose Multiplatform users: Use the built-in
KRelayEffect<T>helper for zero-boilerplate, lifecycle-scoped registration:KRelayEffect<ToastFeature> { AndroidToastImpl(context) } // auto-unregisters when the composable leaves
⚠️ Warnings:@ProcessDeathUnsafeand@SuperAppWarningare compile-time reminders. See Managing Warnings to suppress them at module level.
KRelay is for one-way, fire-and-forget UI commands. Be honest with yourself:
| Use Case | Better Tool |
|---|---|
| Need a return value | expect/actual or suspend fun |
| State management | StateFlow / MutableStateFlow |
| Critical data — payments, uploads | WorkManager / background services |
| Database operations | Room / SQLDelight |
| Network requests | Repository + Ktor |
| Heavy background work | Dispatchers.IO |
Golden Rule: If you need a return value or guaranteed persistence across process death, use a different tool.
The API is identical on the singleton and on any instance.
KRelay.register<ToastFeature>(AndroidToast(context))
KRelay.dispatch<ToastFeature> { it.show("Hello!") }
KRelay.unregister<ToastFeature>()
KRelay.isRegistered<ToastFeature>()
KRelay.getPendingCount<ToastFeature>()
KRelay.clearQueue<ToastFeature>()
KRelay.reset() // clear registry + queue
KRelay.dump() // print debug stateval krelay = KRelay.create("MyScope") // isolated instance
// or
val krelay = KRelay.builder("MyScope")
.maxQueueSize(50)
.actionExpiryMs(30_000)
.build()
krelay.register<ToastFeature>(impl)
krelay.dispatch<ToastFeature> { it.show("Hello!") }
krelay.reset()
krelay.dump()class MyViewModel : ViewModel() {
private val token = KRelay.scopedToken()
fun doWork() {
KRelay.dispatch<WorkFeature>(token) { it.run("task") }
}
override fun onCleared() {
KRelay.cancelScope(token) // removes only this ViewModel's queued actions
}
}// ✅ DO: capture primitives and data
val message = viewModel.successMessage
KRelay.dispatch<ToastFeature> { it.show(message) }
// ❌ DON'T: capture ViewModels or Contexts
KRelay.dispatch<ToastFeature> { it.show(viewModel.data) } // captures viewModel!| Protection | Default | Effect |
|---|---|---|
actionExpiryMs |
5 min | Old queued actions auto-expire |
maxQueueSize |
100 | Oldest actions dropped when queue fills |
WeakReference |
Always | Platform impls released on GC automatically |
These are sufficient for 99% of use cases. Customize per-instance with KRelay.builder().
@BeforeTest
fun setup() {
KRelay.reset() // clean state for each test
}
@Test
fun `login success dispatches toast and navigation`() {
val mockToast = MockToast()
KRelay.register<ToastFeature>(mockToast)
LoginViewModel().onLoginSuccess()
assertEquals("Welcome back!", mockToast.lastMessage)
}@BeforeTest
fun setup() {
mockRelay = KRelay.create("TestScope")
viewModel = RideViewModel(krelay = mockRelay)
}
@Test
fun `booking confirmed dispatches toast`() {
val mockToast = MockToast()
mockRelay.register<ToastFeature>(mockToast)
viewModel.onBookingConfirmed()
assertEquals("Ride booked!", mockToast.lastMessage)
}// Simple mocks — no mocking libraries needed
class MockToast : ToastFeature {
var lastMessage: String? = null
override fun show(message: String) { lastMessage = message }
}Run tests:
./gradlew :krelay:testDebugUnitTest # JVM (fast)
./gradlew :krelay:iosSimulatorArm64Test # iOS Simulator
./gradlew :krelay:connectedDebugAndroidTest # Real Android device237 unit tests · 19 instrumented tests · Tested on JVM, iOS Simulator (arm64), and real Android device (Pixel 6 Pro, Android 16).
A: KRelay is fundamentally different:
| Aspect | Old EventBus | KRelay |
|---|---|---|
| Direction | Any-to-Any (spaghetti) | Unidirectional: ViewModel → Platform only |
| Memory | Manual lifecycle → leaks everywhere | Automatic WeakReference — leak-free by design |
| Contracts | Stringly-typed events hidden anywhere | Type-safe interfaces — explicit, discoverable |
| Scope | Global pub/sub | Strictly ViewModel → UI layer |
| Purpose | General messaging (wrong tool) | KMP "Last Mile" bridge (right tool) |
A: Yes, and for 1–2 simple cases that's fine. KRelay shines when you have many platform actions and need:
- Less boilerplate — no
MutableSharedFlowper feature, nocollect {}per screen - Rotation safety —
LaunchedEffectstops collecting betweenonDestroyandonCreate; KRelay's sticky queue covers the gap
// Without KRelay: boilerplate per feature, per screen
class LoginViewModel {
private val _navEvents = MutableSharedFlow<NavEvent>()
val navEvents = _navEvents.asSharedFlow()
fun onSuccess() { viewModelScope.launch { _navEvents.emit(NavEvent.GoHome) } }
}
@Composable
fun LoginScreen(vm: LoginViewModel) {
LaunchedEffect(Unit) { vm.navEvents.collect { when(it) { ... } } }
}
// With KRelay: register once, dispatch anywhere
class LoginViewModel {
fun onSuccess() { KRelay.dispatch<NavFeature> { it.goToHome() } }
}A: Create a KRelayInstance as a scoped singleton in your DI module and inject it into both the ViewModel (dispatch) and the UI layer (register):
// Koin
val appModule = module {
single { KRelay.create("AppScope") }
viewModel { LoginViewModel(krelay = get()) }
}
// ViewModel
class LoginViewModel(private val krelay: KRelayInstance) : ViewModel() {
fun onLoginSuccess() { krelay.dispatch<NavigationFeature> { it.goToHome() } }
}
// Activity
class MyActivity : AppCompatActivity() {
private val krelay: KRelayInstance by inject()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
krelay.register<NavigationFeature>(AndroidNavigation(this))
}
}The repo includes a runnable demo covering all major features:
./gradlew :composeApp:installDebug| Demo | What it shows |
|---|---|
| Basic | Core dispatch, queue, WeakRef behavior |
| Voyager Integration | Navigation across screens without Voyager in ViewModel |
| Decompose Integration | Component-based navigation, same pattern |
| Library Integrations | Moko Permissions, Biometry, Peekaboo, In-app Review |
| Super App Demo | Multiple isolated KRelayInstances, no conflicts |
| KRelay | Kotlin | KMP | AGP | Android minSdk | iOS min |
|---|---|---|---|---|---|
| 2.1.0 | 2.3.x | 2.3.x | 8.x | 24 | 14.0 |
| 2.0.0 | 2.3.x | 2.3.x | 8.x | 24 | 14.0 |
| 1.1.0 | 2.0.x | 2.0.x | 8.x | 23 | 13.0 |
| 1.0.0 | 1.9.x | 1.9.x | 7.x | 21 | 13.0 |
| KRelay | Singleton | Instance API | Priority Dispatch | Compose Helpers | Persistent Dispatch |
|---|---|---|---|---|---|
| 2.1.x | ✅ | ✅ | ✅ Both | ✅ KRelayEffect, rememberKRelayImpl |
✅ |
| 2.0.x | ✅ | ✅ | ✅ Both | ❌ | ✅ |
| 1.1.x | ✅ | ❌ | ✅ Singleton | ❌ | ❌ |
| 1.0.x | ✅ | ❌ | ❌ | ❌ | ❌ |
| Platform | v1.0 | v1.1 | v2.0 | v2.1 |
|---|---|---|---|---|
| Android (arm64, x86_64) | ✅ | ✅ | ✅ | ✅ |
| iOS arm64 (device) | ✅ | ✅ | ✅ | ✅ |
| iOS arm64 (simulator) | ✅ | ✅ | ✅ | ✅ |
| iOS x64 (simulator) | ✅ | ✅ | ✅ | ✅ |
| JVM (unit tests) | ✅ | ✅ | ✅ | ✅ |
v2.1.0 — Compose Integration & Hardening
- Built-in
KRelayEffect<T>andrememberKRelayImpl<T>Compose helpers KRelay.instancepublic property for cross-module access- Persistent dispatch with
SharedPreferencesPersistenceAdapter(Android) andNSUserDefaultsPersistenceAdapter(iOS) - Scope Token API:
scopedToken()/cancelScope(token) - 237 unit tests + 19 instrumented tests — all passing
- Voyager demo fixed (Voyager 1.1.0-beta03, no more lifecycle crashes)
- Android 15+ 16KB page alignment compatibility
KRelayMetricswiring fixed; iOS KClass bridging fixed
See CHANGELOG.md and RELEASE_NOTES_2.1.0.md for full details.
v2.0.0 — Instance API for Super Apps
KRelay.create("ScopeName")— create isolated instances per moduleKRelay.builder(...)— configure queue size, expiry, debug mode per instance- DI-friendly: inject
KRelayInstanceinto ViewModels - 100% backward compatible with v1.x
See CHANGELOG.md for full details.
- Integration Guides — Voyager, Decompose, Moko, Peekaboo
- Compose Integration —
KRelayEffect,rememberKRelayImpl, Navigation patterns - SwiftUI Integration — iOS-specific patterns, XCTest
- Lifecycle Guide — Android (Activity/Fragment/Compose) and iOS (UIViewController/SwiftUI)
- DI Integration — Koin and Hilt setup
- Testing Guide — Best practices for testing KRelay-based code
- Anti-Patterns — What NOT to do
- Managing Warnings — Suppress
@OptInat module level
- Architecture — Internals deep dive
- API Reference — Full API cheat sheet
- Migration to v2.0 — From v1.x
KRelay does one thing:
Guarantee safe, leak-free dispatch of UI commands from shared code to platform — on any thread, across any lifecycle.
It is not a state manager, not an RPC framework, not a DI framework. By staying focused, it stays simple, reliable, and easy to delete if you ever outgrow it.
Contributions welcome! Please submit a Pull Request.
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature) - Commit your changes
- Push to the branch
- Open a Pull Request
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 ❤️ by Nguyễn Tuấn Việt at Brewkits
Support: datacenter111@gmail.com · Issues: GitHub Issues
