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.
Perfect for: Toast/Snackbar, Navigation, Permissions, Haptics, Notifications - any one-way UI command from shared ViewModels.
Key insight: Integrating KMP libraries (Voyager, Moko, Peekaboo) into shared ViewModels traditionally requires complex patterns. KRelay provides a simple, type-safe bridge.
// Shared ViewModel - Zero platform dependencies
class LoginViewModel {
fun onLoginSuccess() {
KRelay.dispatch<ToastFeature> { it.show("Welcome!") }
KRelay.dispatch<NavigationFeature> { it.goToHome() }
}
}KRelay v1.1.0 focuses on production-grade hardening without adding new features or breaking changes:
- ✅ iOS Lock: Replaced
pthread_mutexwithNSRecursiveLock(ARC-managed, reentrant, zero memory leaks) - ✅ Stress Tested: Validated under 100k concurrent operations
- ✅ Platform Parity: Both Android and iOS use reentrant locks
- ✅ dump(): Visual debugging output of system state
- ✅ getDebugInfo(): Comprehensive diagnostic data
- ✅ getRegisteredFeaturesCount(): Track registered features
- ✅ getTotalPendingCount(): Monitor queue depth across all features
- ✅ @MemoryLeakWarning: New opt-in annotation warns about lambda capture risks
- ✅ Enhanced Documentation: Clear DO/DON'T examples for memory management
- ✅ Test Coverage: 99.2% success (262/264 tests across platforms)
Recommendation: All users should upgrade to v1.1.0 for improved stability and diagnostics.
See iOS Test Report for detailed validation results.
KRelay queues lambdas that may capture variables. Follow these rules to avoid leaks:
✅ DO: Capture primitives and data
val message = viewModel.successMessage
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()
class MyViewModel : ViewModel() {
override fun onCleared() {
super.onCleared()
KRelay.clearQueue<ToastFeature>()
}
}KRelay includes three passive safety mechanisms:
- actionExpiryMs (default: 5 min): Old actions auto-expire
- maxQueueSize (default: 100): Oldest actions dropped when full
- WeakReference: Platform implementations auto-released
For 99% of use cases (Toast, Navigation, Permissions), these are sufficient.
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 Voyager Navigator
class LoginViewModel(private val navigator: Navigator) {
fun onLoginSuccess() {
navigator.push(HomeScreen())
}
}
// - Hard to test (need Navigator mock)
// - Can't switch navigation librariesWith KRelay:
// ✅ ViewModel stays pure Kotlin
class LoginViewModel {
fun onLoginSuccess() {
KRelay.dispatch<NavigationFeature> { it.goToHome() }
}
}
// - Easy testing with simple mock
// - Switch Voyager → Decompose without touching ViewModelOption 1: Maven Central (Recommended for published library)
// In your shared module's build.gradle.kts
commonMain.dependencies {
implementation("dev.brewkits:krelay:1.1.0")
}Option 2: Local Project Reference
// In your shared module's build.gradle.kts
commonMain.dependencies {
implementation(project(":krelay"))
}Step 1: Define Feature Contract (commonMain)
interface ToastFeature : RelayFeature {
fun show(message: String)
}Step 2: Use from Shared Code
class LoginViewModel {
@OptIn(ProcessDeathUnsafe::class, SuperAppWarning::class)
fun onLoginSuccess() {
KRelay.dispatch<ToastFeature> { it.show("Welcome back!") }
}
}
⚠️ Important Warnings:
@ProcessDeathUnsafe: Queue is lost on process death (safe for UI feedback, NOT for payments)@SuperAppWarning: Global singleton (use feature namespacing in large apps)See Managing Warnings to suppress at module level.
Step 3: Implement on Android
class AndroidToast(private val context: Context) : ToastFeature {
override fun show(message: String) {
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
}
// In Activity
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
KRelay.register<ToastFeature>(AndroidToast(applicationContext))
}Step 4: Implement on iOS
class IOSToast: ToastFeature {
weak var viewController: UIViewController?
func show(message: String) {
let alert = UIAlertController(title: nil, message: message,
preferredStyle: .alert)
viewController?.present(alert, animated: true)
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
alert.dismiss(animated: true)
}
}
}
// Register
KRelay.shared.register(impl: IOSToast(viewController: controller))- Automatic WeakReference prevents Activity/ViewController leaks
- No manual cleanup needed
- Proven zero-leak in production apps
- Commands never lost during rotation/lifecycle changes
- Auto-replay when platform implementation registers
- Configurable queue size and expiry
- All commands execute on Main/UI thread automatically
- Reentrant lock on both platforms (NSRecursiveLock on iOS, ReentrantLock on Android)
- Stress-tested with 100k concurrent operations
- No
CalledFromWrongThreadException
- Works with Voyager, Decompose, Compose Navigation
- Integrates Moko Permissions, Peekaboo, Play Core
- Clean decoupling from platform libraries
- Simple mock implementations for tests
- No mocking library needed
- Easy to verify dispatched commands
- Zero overhead when on main thread
- Efficient queue management
- Minimal memory footprint
- dump() for visual debugging
- getDebugInfo() for programmatic inspection
- Real-time monitoring of registered features and queue depth
- Production-ready diagnostics without overhead
KRelay.register<ToastFeature>(AndroidToast(context))KRelay.dispatch<ToastFeature> { it.show("Hello") }KRelay.isRegistered<ToastFeature>() // Check if registered
KRelay.getPendingCount<ToastFeature>() // Count queued actions
KRelay.unregister<ToastFeature>() // Manual unregister (optional)
KRelay.clearQueue<ToastFeature>() // Clear pending actions
KRelay.reset() // Clear all (for testing)KRelay.dump() // Print system state to console
KRelay.getDebugInfo() // Get DebugInfo data class
KRelay.getRegisteredFeaturesCount() // Count registered features
KRelay.getTotalPendingCount() // Total pending actions across all features- Navigation:
KRelay.dispatch<NavFeature> { it.goToHome() } - Toast/Snackbar: Show user feedback
- Permissions: Request camera/location
- Haptics/Sound: Trigger vibration/audio
- Analytics: Fire-and-forget events
- Notifications: In-app banners
- Return Values: Use
expect/actualinstead - State Management: Use
StateFlow - Heavy Processing: Use
Dispatchers.IO - Database Ops: Use Room/SQLite directly
- Critical Transactions: Use WorkManager
- Network Requests: Use Repository pattern
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:
- ✅ Safe: Toast, Navigation, Haptics (UI feedback - acceptable to lose)
- ❌ Dangerous: Payments, Uploads, Critical Analytics (use WorkManager)
Why? Lambdas can't be serialized. When OS kills your app, the queue is cleared.
See @ProcessDeathUnsafe and Anti-Patterns Guide for details.
KRelay uses object KRelay singleton pattern.
Impact:
- ✅ Perfect for: Single-module apps, small-medium projects
⚠️ Caution: Super Apps (Grab/Gojek style) - use Feature Namespacing
Workaround for large apps:
// Namespace your features
interface ModuleAToastFeature : RelayFeature { ... }
interface ModuleBToastFeature : RelayFeature { ... }See @SuperAppWarning for guidance.
- Integration Guides - Voyager, Moko, Peekaboo, Decompose
- Anti-Patterns - What NOT to do (Super App examples)
- Testing Guide - How to test KRelay-based code
- Managing Warnings - Suppress
@OptInat module level
- Architecture - Deep dive into internals
- API Reference - Complete API documentation
- ADR: Singleton Trade-offs - Design decisions
- Positioning - Why KRelay exists (The Glue Code Standard)
- Roadmap - Future development plans (Desktop, Web, v2.0)
A: 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: DI is perfect for ApplicationContext-scoped helpers (file paths, toasts). KRelay complements DI, doesn't replace it.
The problem arises with Activity-scoped actions (Navigation, Dialogs, Permissions):
DI Approach:
// ❌ Can't inject Activity into ViewModel - memory leak!
class LoginViewModel(private val activity: Activity) // LEAK!
// ⚠️ Need manual WeakReference + attach/detach boilerplate
interface ViewAttached { fun attach(activity: Activity) }KRelay Approach:
// ✅ ViewModel stays pure - no Activity reference
class LoginViewModel {
fun onSuccess() {
KRelay.dispatch<NavFeature> { it.goToHome() }
}
}
// Activity registers itself automatically
override fun onCreate(savedInstanceState: Bundle?) {
KRelay.register<NavFeature>(AndroidNav(this))
}When to use what:
- DI: For stateless helpers, repositories, use cases (ApplicationContext-scoped)
- KRelay: For Activity-scoped UI actions (Navigation, Permissions, Dialogs)
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.
class LoginViewModelTest {
@BeforeTest
fun setup() {
KRelay.reset() // Clean state
}
@Test
fun `when login success should show toast and navigate`() {
// Arrange: Register mock implementations
val mockToast = MockToast()
val mockNav = MockNav()
KRelay.register<ToastFeature>(mockToast)
KRelay.register<NavigationFeature>(mockNav)
// Act: Trigger login
viewModel.onLoginSuccess(testUser)
// Assert: Verify commands dispatched
assertEquals("Welcome back!", mockToast.lastMessage)
assertTrue(mockNav.navigatedToHome)
}
}
class MockToast : ToastFeature {
var lastMessage: String? = null
override fun show(message: String) { lastMessage = message }
}Run tests:
./gradlew :krelay:testDebugUnitTest # Android
./gradlew :krelay:iosSimulatorArm64Test # iOS SimulatorThe project includes a demo app showcasing real integrations:
Android:
./gradlew :composeApp:installDebugFeatures:
- Basic Demo: Core KRelay features
- Voyager Integration: Real navigation library integration
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:
- ✅ A messenger for one-way UI commands
- ✅ Fire-and-forget pattern
- ✅ Lifecycle-aware bridge
What KRelay Is NOT:
- ❌ RPC framework (no request-response)
- ❌ State management (use StateFlow)
- ❌ Background worker (use WorkManager)
- ❌ DI framework (use Koin/Hilt)
By staying focused, KRelay remains simple, reliable, and maintainable.
Contributions are welcome! Please feel free to submit a Pull Request.
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - 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
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