
Lightweight, leak‑free bridge delivering one‑way UI commands from shared ViewModels to native implementations; automatic weak references, sticky queued actions, main‑thread execution, easy testing.
The Glue Code Standard for Kotlin Multiplatform
Safe, leak-free bridge between shared code and platform-specific APIs
KRelay is a lightweight bridge that connects your shared Kotlin code to platform-specific implementations (Android/iOS) without memory leaks or lifecycle complexity. It offers a simple, type-safe API for one-way, fire-and-forget UI commands.
v2.0 introduces a powerful instance-based API, perfect for dependency injection and large-scale "Super Apps," while remaining fully backward-compatible with the original singleton.
Use Cases:
// ✅ Singleton (Existing projects)
class LoginViewModel {
fun onLoginSuccess() {
KRelay.dispatch<ToastFeature> { it.show("Welcome!") }
}
}
// ✅ Instance-based (DI / Super Apps)
class RideViewModel(private val krelay: KRelayInstance) {
fun onBookingConfirmed() {
krelay.dispatch<ToastFeature> { it.show("Ride booked!") }
}
}KRelay v2.0 introduces a powerful instance-based API, designed for scalability, dependency injection, and large-scale applications ("Super Apps"), while preserving 100% backward compatibility with the simple singleton API.
KRelay.create("MyModuleScope")
KRelayInstance into your ViewModels, UseCases, and repositories.// Before (v1.x): Global singleton could cause conflicts
// ⚠️ Ride module and Food module might conflict on `ToastFeature`
KRelay.register<ToastFeature>(RideToastImpl())
KRelay.register<ToastFeature>(FoodToastImpl()) // Overwrites the first one!
// After (v2.0): Fully isolated instances
val rideKRelay = KRelay.create("Rides")
val foodKRelay = KRelay.create("Food")
rideKRelay.register<ToastFeature>(RideToastImpl()) // No conflict
foodKRelay.register<ToastFeature>(FoodToastImpl()) // No conflictKRelay.builder("MyScope").maxQueueSize(50).build()
KRelay.dispatch works exactly as before.KRelay object now transparently uses a default instance.Recommendation: All new projects, especially those using DI (Koin/Hilt) or with a multi-module architecture, should use the new instance-based API. Existing projects can upgrade without any changes.
KRelay queues lambdas that may capture variables. Follow these rules to avoid leaks:
✅ DO: Capture primitives and data
// Singleton
val message = viewModel.successMessage
KRelay.dispatch<ToastFeature> { it.show(message) }
// Instance
val krelay: KRelayInstance = get() // from DI
krelay.dispatch<ToastFeature> { it.show(message) }❌ DON'T: Capture ViewModels or Contexts
// BAD: Captures entire viewModel
KRelay.dispatch<ToastFeature> { it.show(viewModel.data) }🔧 CLEANUP: Use clearQueue() in onCleared()
// Singleton Usage
class MyViewModel : ViewModel() {
override fun onCleared() {
super.onCleared()
KRelay.clearQueue<ToastFeature>()
}
}
// Instance Usage (with DI)
class MyViewModel(private val krelay: KRelayInstance) : ViewModel() {
override fun onCleared() {
super.onCleared()
krelay.clearQueue<ToastFeature>()
}
}Each KRelay instance includes three passive safety mechanisms:
For 99% of use cases (Toast, Navigation, Permissions), these are sufficient. These settings can be configured per-instance using the KRelay.builder().
Without KRelay:
// ❌ DIY approach - Memory leak!
object MyBridge {
var activity: Activity? = null // Forgot to clear → LEAK
}With KRelay:
// ✅ Automatic WeakReference - Zero leaks
override fun onCreate(savedInstanceState: Bundle?) {
KRelay.register<ToastFeature>(AndroidToast(this))
// Auto-cleanup when Activity destroyed
}Without KRelay:
// ❌ Command missed if Activity not ready
viewModelScope.launch {
val data = load()
nativeBridge.showToast("Done") // Activity not created yet - event lost!
}With KRelay:
// ✅ Sticky Queue - Commands preserved
viewModelScope.launch {
val data = load()
KRelay.dispatch<ToastFeature> { it.show("Done") }
// Queued if Activity not ready → Auto-replays when ready
}Without KRelay:
// ❌ ViewModel coupled to a specific Navigator
class LoginViewModel(private val navigator: Navigator) {
fun onLoginSuccess() {
navigator.push(HomeScreen())
}
}
// - Hard to test (requires a Navigator mock)
// - Can't switch navigation libraries easilyWith KRelay (v2.0):
// ✅ ViewModel is pure, depends only on the KRelay contract
class LoginViewModel(private val krelay: KRelayInstance) {
fun onLoginSuccess() {
krelay.dispatch<NavigationFeature> { it.goToHome() }
}
}
// - Easy testing: pass in a mock instance
// - DI-friendly: inject the correct instance
// - Switch Voyager → Decompose without touching the ViewModel// In your shared module's build.gradle.kts
commonMain.dependencies {
implementation("dev.brewkits:krelay:2.0.0")
}Step 1: Define Feature Contract (commonMain) This is the shared contract between your business logic and platform UI.
interface ToastFeature : RelayFeature {
fun show(message: String)
}Perfect for single-module apps or maintaining backward compatibility.
Step 2A: Use from Shared Code
// ViewModel uses the global KRelay object
class LoginViewModel {
fun onLoginSuccess() {
// The @SuperAppWarning reminds you that this is a global singleton
KRelay.dispatch<ToastFeature> { it.show("Welcome back!") }
}
}Step 3A: Implement and Register on Platform
// Android (in Activity)
class AndroidToast(private val context: Context) : ToastFeature { /*...*/ }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
KRelay.register<ToastFeature>(AndroidToast(applicationContext))
}
// iOS (in UIViewController)
class IOSToast: ToastFeature { /*...*/ }
override func viewDidLoad() {
super.viewDidLoad()
KRelay.shared.register(impl: IOSToast(viewController: self))
}The recommended approach for new, multi-module, or DI-based projects.
Step 2B: Create & Inject Instance Create a shared instance for your module or screen. Here, we use Koin as an example.
// In a Koin module (e.g., RideModule.kt)
val rideModule = module {
single { KRelay.create("Rides") } // Create a scoped instance
viewModel { RideViewModel(krelay = get()) }
}
// ViewModel receives the instance via constructor
class RideViewModel(private val krelay: KRelayInstance) : ViewModel() {
fun onBookingConfirmed() {
krelay.dispatch<ToastFeature> { it.show("Ride booked!") }
}
}Step 3B: Implement and Register on Platform The implementation is the same, but you register it with the specific instance.
// Android (in Activity)
val rideKRelay: KRelayInstance by inject() // from Koin
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
rideKRelay.register<ToastFeature>(AndroidToast(applicationContext))
}
// iOS (in UIViewController)
let rideKRelay: KRelayInstance = koin.get() // from Koin
override func viewDidLoad() {
super.viewDidLoad()
rideKRelay.register(impl: IOSToast(viewController: self))
}
⚠️ Important Warnings:
@ProcessDeathUnsafe: The queue is in-memory and lost on process death. This is safe for UI feedback (Toasts, Navigation), but not for critical data (payments).@SuperAppWarning: This reminds you that the globalKRelayobject is a singleton. For modular apps, use the instance-based API (Option B) to prevent conflicts.See Managing Warnings to suppress at the module level.
KRelayInstances for each module, preventing conflicts.KRelay.reset() provides a clean state for each test.KRelayInstance directly to your ViewModel for even easier and more explicit testing.dump(): A visual printout of the current state (registered features, queue depth).getDebugInfo(): Programmatic access to all diagnostic data.The Core API is consistent across the singleton and instances.
For quick setup or existing projects. All calls are delegated to a default instance.
// Register a feature on the default instance
KRelay.register<ToastFeature>(AndroidToast(context))
// Dispatch an action on the default instance
KRelay.dispatch<ToastFeature> { it.show("Hello from singleton!") }For dependency injection, multi-module apps, and testability.
// Create a new, isolated instance
val rideKRelay = KRelay.create("Rides")
// Or, create a configured instance
val foodKRelay = KRelay.builder("Food")
.maxQueueSize(20)
.build()
// Register a feature on a specific instance
rideKRelay.register<ToastFeature>(RideToastImpl())
// Dispatch an action on that instance
rideKRelay.dispatch<ToastFeature> { it.show("Your ride is here!") }These functions are available on both the KRelay singleton and any KRelayInstance.
Utility Functions:
// On singleton
KRelay.isRegistered<ToastFeature>()
KRelay.getPendingCount<ToastFeature>()
KRelay.clearQueue<ToastFeature>()
KRelay.reset() // Resets the default instance
// On instance
val myRelay: KRelayInstance = get()
myRelay.isRegistered<ToastFeature>()
myRelay.getPendingCount<ToastFeature>()
myRelay.clearQueue<ToastFeature>()
myRelay.reset() // Resets only this instanceDiagnostic Functions:
// On singleton
KRelay.dump()
KRelay.getDebugInfo()
// On instance
val myRelay: KRelayInstance = get()
myRelay.dump()
myRelay.getDebugInfo()KRelay.dispatch<NavFeature> { it.goToHome() }
expect/actual insteadStateFlow
Dispatchers.IO
Golden Rule: KRelay is for one-way, fire-and-forget UI commands. If you need a return value or guaranteed execution after process death, use different tools.
Lambda functions cannot survive process death (OS kills app).
Impact:
Why? Lambdas can't be serialized. When OS kills your app, the queue is cleared.
See @ProcessDeathUnsafe and Anti-Patterns Guide for details.
KRelay provides two APIs, and choosing the right one is important.
Singleton API (KRelay.dispatch)
Instance API (KRelay.create(...))
See the @SuperAppWarning annotation and the "Quick Start" guide for examples of each.
@OptIn at module levelA: We understand the PTSD! 😅 But KRelay is fundamentally different:
| Aspect | Old EventBus | KRelay |
|---|---|---|
| Scope | Global pub/sub across all components | Strictly Shared ViewModel → Platform (one direction) |
| Memory Safety | Manual lifecycle management → leaks everywhere | Automatic WeakReference - leak-free by design |
| Direction | Any-to-Any (spaghetti) | Unidirectional (ViewModel → View only) |
| Discovery | Events hidden in random places | Type-safe interfaces - clear contracts |
| Use Case | General messaging (wrong tool) | KMP "Last Mile" problem (right tool) |
Key difference: EventBus was used for component-to-component communication (wrong pattern). KRelay is for ViewModel-to-Platform bridge only (the missing piece in KMP).
A: KRelay v2.0 is designed to integrate seamlessly with Dependency Injection frameworks. The new instance-based API allows you to register KRelayInstances as providers in your DI graph and inject them where needed.
KRelay complements DI by solving the specific problem of bridging to lifecycle-aware, Activity/UIViewController-scoped UI actions (like navigation, dialogs, permissions) without leaking platform contexts into your ViewModels.
Modern DI Approach (with KRelay v2.0):
// 1. Provide a KRelay instance in your Koin/Hilt module
val appModule = module {
single { KRelay.create("AppScope") } // Create an instance
viewModel { LoginViewModel(krelay = get()) }
}
// 2. Inject the instance into your ViewModel
class LoginViewModel(private val krelay: KRelayInstance) : ViewModel() {
fun onLoginSuccess() {
// ViewModel is pure and easily testable
krelay.dispatch<NavigationFeature> { it.goToHome() }
}
}
// 3. Register the implementation at the UI layer
class MyActivity : AppCompatActivity() {
private val krelay: KRelayInstance by inject() // Inject the same instance
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
krelay.register<NavigationFeature>(AndroidNavigation(this))
}
}When to use what:
KRelayInstances.A: Absolutely! LaunchedEffect is lifecycle-aware and doesn't leak. KRelay solves two different problems:
1. Boilerplate Reduction
Without KRelay:
// ViewModel
class LoginViewModel {
private val _navEvents = MutableSharedFlow<NavEvent>()
val navEvents = _navEvents.asSharedFlow()
fun onSuccess() {
viewModelScope.launch {
_navEvents.emit(NavEvent.GoHome)
}
}
}
// Every screen needs this collector
@Composable
fun LoginScreen(viewModel: LoginViewModel) {
val navigator = LocalNavigator.current
LaunchedEffect(Unit) {
viewModel.navEvents.collect { event ->
when (event) {
is NavEvent.GoHome -> navigator.push(HomeScreen())
// ... handle all events
}
}
}
}With KRelay:
// ViewModel
class LoginViewModel {
fun onSuccess() {
KRelay.dispatch<NavFeature> { it.goToHome() }
}
}
// One-time registration in MainActivity
override fun onCreate(savedInstanceState: Bundle?) {
KRelay.register<NavFeature>(VoyagerNav(navigator))
}2. Missed Events During Rotation
If you dispatch an event during rotation (between old Activity destroy → new Activity create), LaunchedEffect isn't running yet → event lost.
KRelay's Sticky Queue catches these events and replays them when the new Activity is ready.
Trade-off: If you only have 1-2 features and prefer explicit Flow collectors, stick with LaunchedEffect. If you have many platform actions (Toast, Nav, Permissions, Haptics), KRelay reduces boilerplate significantly.
KRelay is designed for testability. The v2.0 instance API makes testing even cleaner.
If you use the KRelay singleton, you can use KRelay.reset() to ensure a clean state between tests.
class LoginViewModelTest {
@BeforeTest
fun setup() {
KRelay.reset() // Clears the default instance's registry and queue
}
@Test
fun `when login success, dispatches toast and nav commands`() {
// Arrange: Register mock implementations on the global object
val mockToast = MockToast()
val mockNav = MockNav()
KRelay.register<ToastFeature>(mockToast)
KRelay.register<NavigationFeature>(mockNav)
val viewModel = LoginViewModel() // Assumes ViewModel uses KRelay singleton
// Act
viewModel.onLoginSuccess()
// Assert
assertEquals("Welcome back!", mockToast.lastMessage)
assertTrue(mockNav.navigatedToHome)
}
}This is the modern, recommended approach. It avoids global state and makes dependencies explicit.
class RideViewModelTest {
private lateinit var mockRelay: KRelayInstance
private lateinit var viewModel: RideViewModel
@BeforeTest
fun setup() {
// Create a fresh instance for each test
mockRelay = KRelay.create("TestScope")
viewModel = RideViewModel(krelay = mockRelay)
}
@Test
fun `when booking confirmed, dispatches confirmation toast`() {
// Arrange: Register a mock feature on the instance
val mockToast = MockToast()
mockRelay.register<ToastFeature>(mockToast)
// Act
viewModel.onBookingConfirmed()
// Assert
assertEquals("Ride booked!", mockToast.lastMessage)
}
}Shared Mock Implementations:
// A simple mock used in the tests above
class MockToast : ToastFeature {
var lastMessage: String? = null
override fun show(message: String) {
lastMessage = message
}
}
class MockNav : NavigationFeature {
var navigatedToHome: Boolean = false
override fun goToHome() {
navigatedToHome = true
}
}Run tests:
./gradlew :krelay:testDebugUnitTest # Android
./gradlew :krelay:iosSimulatorArm64Test # iOS SimulatorThe project includes a demo app showcasing real integrations:
Android:
./gradlew :composeApp:installDebugFeatures:
See composeApp/src/commonMain/kotlin/dev/brewkits/krelay/ for complete examples.
KRelay follows Unix philosophy - it has one responsibility:
Guarantee safe, leak-free dispatch of UI commands from shared code to platform.
What KRelay Is:
What KRelay Is NOT:
By staying focused, KRelay remains simple, reliable, and maintainable.
Contributions are welcome! Please feel free to submit a Pull Request.
git checkout -b feature/amazing-feature)git commit -m 'Add amazing feature')git push origin feature/amazing-feature)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
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
If KRelay saves you time, please give us a star!
It helps other developers discover this project.
Made with ❤️ by Nguyễn Tuấn Việt at Brewkits
Support: datacenter111@gmail.com • Community: GitHub Issues
The Glue Code Standard for Kotlin Multiplatform
Safe, leak-free bridge between shared code and platform-specific APIs
KRelay is a lightweight bridge that connects your shared Kotlin code to platform-specific implementations (Android/iOS) without memory leaks or lifecycle complexity. It offers a simple, type-safe API for one-way, fire-and-forget UI commands.
v2.0 introduces a powerful instance-based API, perfect for dependency injection and large-scale "Super Apps," while remaining fully backward-compatible with the original singleton.
Use Cases:
// ✅ Singleton (Existing projects)
class LoginViewModel {
fun onLoginSuccess() {
KRelay.dispatch<ToastFeature> { it.show("Welcome!") }
}
}
// ✅ Instance-based (DI / Super Apps)
class RideViewModel(private val krelay: KRelayInstance) {
fun onBookingConfirmed() {
krelay.dispatch<ToastFeature> { it.show("Ride booked!") }
}
}KRelay v2.0 introduces a powerful instance-based API, designed for scalability, dependency injection, and large-scale applications ("Super Apps"), while preserving 100% backward compatibility with the simple singleton API.
KRelay.create("MyModuleScope")
KRelayInstance into your ViewModels, UseCases, and repositories.// Before (v1.x): Global singleton could cause conflicts
// ⚠️ Ride module and Food module might conflict on `ToastFeature`
KRelay.register<ToastFeature>(RideToastImpl())
KRelay.register<ToastFeature>(FoodToastImpl()) // Overwrites the first one!
// After (v2.0): Fully isolated instances
val rideKRelay = KRelay.create("Rides")
val foodKRelay = KRelay.create("Food")
rideKRelay.register<ToastFeature>(RideToastImpl()) // No conflict
foodKRelay.register<ToastFeature>(FoodToastImpl()) // No conflictKRelay.builder("MyScope").maxQueueSize(50).build()
KRelay.dispatch works exactly as before.KRelay object now transparently uses a default instance.Recommendation: All new projects, especially those using DI (Koin/Hilt) or with a multi-module architecture, should use the new instance-based API. Existing projects can upgrade without any changes.
KRelay queues lambdas that may capture variables. Follow these rules to avoid leaks:
✅ DO: Capture primitives and data
// Singleton
val message = viewModel.successMessage
KRelay.dispatch<ToastFeature> { it.show(message) }
// Instance
val krelay: KRelayInstance = get() // from DI
krelay.dispatch<ToastFeature> { it.show(message) }❌ DON'T: Capture ViewModels or Contexts
// BAD: Captures entire viewModel
KRelay.dispatch<ToastFeature> { it.show(viewModel.data) }🔧 CLEANUP: Use clearQueue() in onCleared()
// Singleton Usage
class MyViewModel : ViewModel() {
override fun onCleared() {
super.onCleared()
KRelay.clearQueue<ToastFeature>()
}
}
// Instance Usage (with DI)
class MyViewModel(private val krelay: KRelayInstance) : ViewModel() {
override fun onCleared() {
super.onCleared()
krelay.clearQueue<ToastFeature>()
}
}Each KRelay instance includes three passive safety mechanisms:
For 99% of use cases (Toast, Navigation, Permissions), these are sufficient. These settings can be configured per-instance using the KRelay.builder().
Without KRelay:
// ❌ DIY approach - Memory leak!
object MyBridge {
var activity: Activity? = null // Forgot to clear → LEAK
}With KRelay:
// ✅ Automatic WeakReference - Zero leaks
override fun onCreate(savedInstanceState: Bundle?) {
KRelay.register<ToastFeature>(AndroidToast(this))
// Auto-cleanup when Activity destroyed
}Without KRelay:
// ❌ Command missed if Activity not ready
viewModelScope.launch {
val data = load()
nativeBridge.showToast("Done") // Activity not created yet - event lost!
}With KRelay:
// ✅ Sticky Queue - Commands preserved
viewModelScope.launch {
val data = load()
KRelay.dispatch<ToastFeature> { it.show("Done") }
// Queued if Activity not ready → Auto-replays when ready
}Without KRelay:
// ❌ ViewModel coupled to a specific Navigator
class LoginViewModel(private val navigator: Navigator) {
fun onLoginSuccess() {
navigator.push(HomeScreen())
}
}
// - Hard to test (requires a Navigator mock)
// - Can't switch navigation libraries easilyWith KRelay (v2.0):
// ✅ ViewModel is pure, depends only on the KRelay contract
class LoginViewModel(private val krelay: KRelayInstance) {
fun onLoginSuccess() {
krelay.dispatch<NavigationFeature> { it.goToHome() }
}
}
// - Easy testing: pass in a mock instance
// - DI-friendly: inject the correct instance
// - Switch Voyager → Decompose without touching the ViewModel// In your shared module's build.gradle.kts
commonMain.dependencies {
implementation("dev.brewkits:krelay:2.0.0")
}Step 1: Define Feature Contract (commonMain) This is the shared contract between your business logic and platform UI.
interface ToastFeature : RelayFeature {
fun show(message: String)
}Perfect for single-module apps or maintaining backward compatibility.
Step 2A: Use from Shared Code
// ViewModel uses the global KRelay object
class LoginViewModel {
fun onLoginSuccess() {
// The @SuperAppWarning reminds you that this is a global singleton
KRelay.dispatch<ToastFeature> { it.show("Welcome back!") }
}
}Step 3A: Implement and Register on Platform
// Android (in Activity)
class AndroidToast(private val context: Context) : ToastFeature { /*...*/ }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
KRelay.register<ToastFeature>(AndroidToast(applicationContext))
}
// iOS (in UIViewController)
class IOSToast: ToastFeature { /*...*/ }
override func viewDidLoad() {
super.viewDidLoad()
KRelay.shared.register(impl: IOSToast(viewController: self))
}The recommended approach for new, multi-module, or DI-based projects.
Step 2B: Create & Inject Instance Create a shared instance for your module or screen. Here, we use Koin as an example.
// In a Koin module (e.g., RideModule.kt)
val rideModule = module {
single { KRelay.create("Rides") } // Create a scoped instance
viewModel { RideViewModel(krelay = get()) }
}
// ViewModel receives the instance via constructor
class RideViewModel(private val krelay: KRelayInstance) : ViewModel() {
fun onBookingConfirmed() {
krelay.dispatch<ToastFeature> { it.show("Ride booked!") }
}
}Step 3B: Implement and Register on Platform The implementation is the same, but you register it with the specific instance.
// Android (in Activity)
val rideKRelay: KRelayInstance by inject() // from Koin
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
rideKRelay.register<ToastFeature>(AndroidToast(applicationContext))
}
// iOS (in UIViewController)
let rideKRelay: KRelayInstance = koin.get() // from Koin
override func viewDidLoad() {
super.viewDidLoad()
rideKRelay.register(impl: IOSToast(viewController: self))
}
⚠️ Important Warnings:
@ProcessDeathUnsafe: The queue is in-memory and lost on process death. This is safe for UI feedback (Toasts, Navigation), but not for critical data (payments).@SuperAppWarning: This reminds you that the globalKRelayobject is a singleton. For modular apps, use the instance-based API (Option B) to prevent conflicts.See Managing Warnings to suppress at the module level.
KRelayInstances for each module, preventing conflicts.KRelay.reset() provides a clean state for each test.KRelayInstance directly to your ViewModel for even easier and more explicit testing.dump(): A visual printout of the current state (registered features, queue depth).getDebugInfo(): Programmatic access to all diagnostic data.The Core API is consistent across the singleton and instances.
For quick setup or existing projects. All calls are delegated to a default instance.
// Register a feature on the default instance
KRelay.register<ToastFeature>(AndroidToast(context))
// Dispatch an action on the default instance
KRelay.dispatch<ToastFeature> { it.show("Hello from singleton!") }For dependency injection, multi-module apps, and testability.
// Create a new, isolated instance
val rideKRelay = KRelay.create("Rides")
// Or, create a configured instance
val foodKRelay = KRelay.builder("Food")
.maxQueueSize(20)
.build()
// Register a feature on a specific instance
rideKRelay.register<ToastFeature>(RideToastImpl())
// Dispatch an action on that instance
rideKRelay.dispatch<ToastFeature> { it.show("Your ride is here!") }These functions are available on both the KRelay singleton and any KRelayInstance.
Utility Functions:
// On singleton
KRelay.isRegistered<ToastFeature>()
KRelay.getPendingCount<ToastFeature>()
KRelay.clearQueue<ToastFeature>()
KRelay.reset() // Resets the default instance
// On instance
val myRelay: KRelayInstance = get()
myRelay.isRegistered<ToastFeature>()
myRelay.getPendingCount<ToastFeature>()
myRelay.clearQueue<ToastFeature>()
myRelay.reset() // Resets only this instanceDiagnostic Functions:
// On singleton
KRelay.dump()
KRelay.getDebugInfo()
// On instance
val myRelay: KRelayInstance = get()
myRelay.dump()
myRelay.getDebugInfo()KRelay.dispatch<NavFeature> { it.goToHome() }
expect/actual insteadStateFlow
Dispatchers.IO
Golden Rule: KRelay is for one-way, fire-and-forget UI commands. If you need a return value or guaranteed execution after process death, use different tools.
Lambda functions cannot survive process death (OS kills app).
Impact:
Why? Lambdas can't be serialized. When OS kills your app, the queue is cleared.
See @ProcessDeathUnsafe and Anti-Patterns Guide for details.
KRelay provides two APIs, and choosing the right one is important.
Singleton API (KRelay.dispatch)
Instance API (KRelay.create(...))
See the @SuperAppWarning annotation and the "Quick Start" guide for examples of each.
@OptIn at module levelA: We understand the PTSD! 😅 But KRelay is fundamentally different:
| Aspect | Old EventBus | KRelay |
|---|---|---|
| Scope | Global pub/sub across all components | Strictly Shared ViewModel → Platform (one direction) |
| Memory Safety | Manual lifecycle management → leaks everywhere | Automatic WeakReference - leak-free by design |
| Direction | Any-to-Any (spaghetti) | Unidirectional (ViewModel → View only) |
| Discovery | Events hidden in random places | Type-safe interfaces - clear contracts |
| Use Case | General messaging (wrong tool) | KMP "Last Mile" problem (right tool) |
Key difference: EventBus was used for component-to-component communication (wrong pattern). KRelay is for ViewModel-to-Platform bridge only (the missing piece in KMP).
A: KRelay v2.0 is designed to integrate seamlessly with Dependency Injection frameworks. The new instance-based API allows you to register KRelayInstances as providers in your DI graph and inject them where needed.
KRelay complements DI by solving the specific problem of bridging to lifecycle-aware, Activity/UIViewController-scoped UI actions (like navigation, dialogs, permissions) without leaking platform contexts into your ViewModels.
Modern DI Approach (with KRelay v2.0):
// 1. Provide a KRelay instance in your Koin/Hilt module
val appModule = module {
single { KRelay.create("AppScope") } // Create an instance
viewModel { LoginViewModel(krelay = get()) }
}
// 2. Inject the instance into your ViewModel
class LoginViewModel(private val krelay: KRelayInstance) : ViewModel() {
fun onLoginSuccess() {
// ViewModel is pure and easily testable
krelay.dispatch<NavigationFeature> { it.goToHome() }
}
}
// 3. Register the implementation at the UI layer
class MyActivity : AppCompatActivity() {
private val krelay: KRelayInstance by inject() // Inject the same instance
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
krelay.register<NavigationFeature>(AndroidNavigation(this))
}
}When to use what:
KRelayInstances.A: Absolutely! LaunchedEffect is lifecycle-aware and doesn't leak. KRelay solves two different problems:
1. Boilerplate Reduction
Without KRelay:
// ViewModel
class LoginViewModel {
private val _navEvents = MutableSharedFlow<NavEvent>()
val navEvents = _navEvents.asSharedFlow()
fun onSuccess() {
viewModelScope.launch {
_navEvents.emit(NavEvent.GoHome)
}
}
}
// Every screen needs this collector
@Composable
fun LoginScreen(viewModel: LoginViewModel) {
val navigator = LocalNavigator.current
LaunchedEffect(Unit) {
viewModel.navEvents.collect { event ->
when (event) {
is NavEvent.GoHome -> navigator.push(HomeScreen())
// ... handle all events
}
}
}
}With KRelay:
// ViewModel
class LoginViewModel {
fun onSuccess() {
KRelay.dispatch<NavFeature> { it.goToHome() }
}
}
// One-time registration in MainActivity
override fun onCreate(savedInstanceState: Bundle?) {
KRelay.register<NavFeature>(VoyagerNav(navigator))
}2. Missed Events During Rotation
If you dispatch an event during rotation (between old Activity destroy → new Activity create), LaunchedEffect isn't running yet → event lost.
KRelay's Sticky Queue catches these events and replays them when the new Activity is ready.
Trade-off: If you only have 1-2 features and prefer explicit Flow collectors, stick with LaunchedEffect. If you have many platform actions (Toast, Nav, Permissions, Haptics), KRelay reduces boilerplate significantly.
KRelay is designed for testability. The v2.0 instance API makes testing even cleaner.
If you use the KRelay singleton, you can use KRelay.reset() to ensure a clean state between tests.
class LoginViewModelTest {
@BeforeTest
fun setup() {
KRelay.reset() // Clears the default instance's registry and queue
}
@Test
fun `when login success, dispatches toast and nav commands`() {
// Arrange: Register mock implementations on the global object
val mockToast = MockToast()
val mockNav = MockNav()
KRelay.register<ToastFeature>(mockToast)
KRelay.register<NavigationFeature>(mockNav)
val viewModel = LoginViewModel() // Assumes ViewModel uses KRelay singleton
// Act
viewModel.onLoginSuccess()
// Assert
assertEquals("Welcome back!", mockToast.lastMessage)
assertTrue(mockNav.navigatedToHome)
}
}This is the modern, recommended approach. It avoids global state and makes dependencies explicit.
class RideViewModelTest {
private lateinit var mockRelay: KRelayInstance
private lateinit var viewModel: RideViewModel
@BeforeTest
fun setup() {
// Create a fresh instance for each test
mockRelay = KRelay.create("TestScope")
viewModel = RideViewModel(krelay = mockRelay)
}
@Test
fun `when booking confirmed, dispatches confirmation toast`() {
// Arrange: Register a mock feature on the instance
val mockToast = MockToast()
mockRelay.register<ToastFeature>(mockToast)
// Act
viewModel.onBookingConfirmed()
// Assert
assertEquals("Ride booked!", mockToast.lastMessage)
}
}Shared Mock Implementations:
// A simple mock used in the tests above
class MockToast : ToastFeature {
var lastMessage: String? = null
override fun show(message: String) {
lastMessage = message
}
}
class MockNav : NavigationFeature {
var navigatedToHome: Boolean = false
override fun goToHome() {
navigatedToHome = true
}
}Run tests:
./gradlew :krelay:testDebugUnitTest # Android
./gradlew :krelay:iosSimulatorArm64Test # iOS SimulatorThe project includes a demo app showcasing real integrations:
Android:
./gradlew :composeApp:installDebugFeatures:
See composeApp/src/commonMain/kotlin/dev/brewkits/krelay/ for complete examples.
KRelay follows Unix philosophy - it has one responsibility:
Guarantee safe, leak-free dispatch of UI commands from shared code to platform.
What KRelay Is:
What KRelay Is NOT:
By staying focused, KRelay remains simple, reliable, and maintainable.
Contributions are welcome! Please feel free to submit a Pull Request.
git checkout -b feature/amazing-feature)git commit -m 'Add amazing feature')git push origin feature/amazing-feature)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
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
If KRelay saves you time, please give us a star!
It helps other developers discover this project.
Made with ❤️ by Nguyễn Tuấn Việt at Brewkits
Support: datacenter111@gmail.com • Community: GitHub Issues