KRelay
Dispatch Toasts, Navigation & Permissions from KMP shared ViewModels to Android/iOS — zero memory leaks, survives screen rotation. Works with Voyager, Decompose, Moko, Peekaboo & Compose Multiplatform.
Install / Use
/learn @brewkits/KRelayREADME
⚡ KRelay

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.
🛑 Sound familiar?
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.
✅ KRelay solves it in 3 steps
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.
Why developers choose KRelay
🛡️ Zero memory leaks — by design
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.
🔄 Events survive screen rotation
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.
🧵 Always runs on the Main Thread
Dispatch from any background coroutine. KRelay guarantees UI code always executes on the Main Thread — Android Looper and iOS GCD both handled.
Works with your stack
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.
Quick Start
Installation
// shared module build.gradle.kts
commonMain.dependencies {
implementation("dev.brewkits:krelay:2.1.0")
}
Option A — Singleton (simple apps)
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))
}
Option B — Instance API (DI & multi-module)
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.
❌ When NOT to use KRelay
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.
Core API
The API is identical on the singleton and on any instance.
Singleton
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 state
Instance API
val 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()
Scope Token API — fine-grained cleanup
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
}
}
Memory Management
Lambda capture rules
// ✅ 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!
Built-in protections (passive — always active)
| 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().
Testing
Singleton API
@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)
}
Instance API (recommended — explicit, no global state)
@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:connect
Related Skills
node-connect
347.6kDiagnose OpenClaw node connection and pairing failures for Android, iOS, and macOS companion apps
frontend-design
108.4kCreate distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, or applications. Generates creative, polished code that avoids generic AI aesthetics.
openai-whisper-api
347.6kTranscribe audio via OpenAI Audio Transcriptions API (Whisper).
qqbot-media
347.6kQQBot 富媒体收发能力。使用 <qqmedia> 标签,系统根据文件扩展名自动识别类型(图片/语音/视频/文件)。
