Skip to content

The Glue Code Standard for Kotlin Multiplatform. A type-safe, leak-free bridge to dispatch UI commands from shared code to native platforms (Android & iOS). Supports Sticky Queue & Lifecycle management.

License

Notifications You must be signed in to change notification settings

brewkits/KRelay

Repository files navigation

KRelay

KRelay Cover

The Glue Code Standard for Kotlin Multiplatform

Safe, leak-free bridge between shared code and platform-specific APIs

Kotlin Multiplatform Maven Central Zero Dependencies License


What is KRelay?

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: Simple, zero-config for small to medium apps.
  • Instances: DI-friendly, isolated for large modular apps.
// βœ… 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!") }
    }
}

What's New in v2.0.0 - Instance API for Super Apps πŸš€

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.

1. Instance-Based API

  • βœ… Create Isolated Instances: KRelay.create("MyModuleScope")
  • βœ… Solves Super App Problem: No more feature name conflicts between independent modules.
  • βœ… DI-Friendly: Inject KRelayInstance into your ViewModels, UseCases, and repositories.
  • βœ… Full Isolation: Each instance has its own registry, queue, and configuration.
// 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 conflict

2. Configurable Instances

  • βœ… Builder Pattern: KRelay.builder("MyScope").maxQueueSize(50).build()
  • βœ… Per-Instance Settings: Customize queue size, action expiry, and debug mode for each module.

3. Full Backward Compatibility

  • βœ… No Breaking Changes: All existing code using KRelay.dispatch works exactly as before.
  • βœ… Easy Migration: Adopt the new instance API incrementally, where it makes sense.
  • βœ… The global 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.


Memory Management Best Practices

Lambda Capture Warning

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>()
    }
}

Built-in Protections

Each KRelay instance includes three passive safety mechanisms:

  1. actionExpiryMs (default: 5 min): Old actions auto-expire.
  2. maxQueueSize (default: 100): Oldest actions are dropped when the queue is full.
  3. WeakReference: Platform implementations are weakly referenced and auto-released.

For 99% of use cases (Toast, Navigation, Permissions), these are sufficient. These settings can be configured per-instance using the KRelay.builder().


Why KRelay?

Problem 1: Memory Leaks from Strong References

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
}

Problem 2: Missed Commands During Lifecycle Changes

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
}

Problem 3: Poor Testability & DI

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 easily

With 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

Quick Start

Installation

// In your shared module's build.gradle.kts
commonMain.dependencies {
    implementation("dev.brewkits:krelay:2.0.0")
}

Basic Usage

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)
}

Option A: Singleton Usage (Simple)

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))
}

Option B: Instance Usage (DI & Super Apps)

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 global KRelay object 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.


Key Features

πŸ“¦ Instance-Based API (New in v2.0)

  • Super App Ready: Create isolated KRelayInstances for each module, preventing conflicts.
  • DI Friendly: Inject instances into ViewModels and services.
  • Configurable: Each instance can have its own queue size, expiry, and debug settings.

πŸ›‘οΈ Memory Safety

  • Automatic WeakReference prevents Activity/ViewController leaks.
  • No manual cleanup needed for 99% of use cases.

πŸ”„ Sticky Queue

  • Commands are never lost during configuration changes (e.g., screen rotation).
  • Auto-replays queued commands when a platform implementation becomes available.

🧡 Thread Safety

  • All commands execute on the Main/UI thread automatically.
  • Reentrant locks on both platforms (Android & iOS) ensure safe concurrent access.
  • Stress-tested with 100k+ concurrent operations.

πŸ”Œ Library Integration

  • Decouples ViewModels from navigation libraries like Voyager, Decompose, and Compose Navigation.
  • Integrates cleanly with permission handlers (Moko Permissions), image pickers (Peekaboo), and more.

πŸ§ͺ Testability

  • Singleton: KRelay.reset() provides a clean state for each test.
  • Instances: Pass a mock KRelayInstance directly to your ViewModel for even easier and more explicit testing.
  • No complex mocking libraries needed.

⚑ Performance

  • Zero overhead when dispatching from the main thread.
  • Efficient queue management and minimal memory footprint.

πŸ” Diagnostic Tools

  • dump(): A visual printout of the current state (registered features, queue depth).
  • getDebugInfo(): Programmatic access to all diagnostic data.
  • Real-time monitoring of registered features and queue depth.

Core API

The Core API is consistent across the singleton and instances.

Singleton API (Backward Compatible)

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!") }

Instance API (New in v2.0)

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!") }

Common Functions

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 instance

Diagnostic Functions:

// On singleton
KRelay.dump()
KRelay.getDebugInfo()

// On instance
val myRelay: KRelayInstance = get()
myRelay.dump()
myRelay.getDebugInfo()

When to Use KRelay

βœ… Perfect For (Recommended)

  • 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

❌ Do NOT Use For

  • Return Values: Use expect/actual instead
  • 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.


Important Limitations

1. Queue NOT Persistent (Process Death)

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.

2. Singleton vs. Instance API

KRelay provides two APIs, and choosing the right one is important.

Singleton API (KRelay.dispatch)

  • Pros: Zero setup, easy to use, great for simple apps.
  • Cons: Can cause feature conflicts in large, multi-module "Super Apps" if two modules use the same feature interface.
  • Use When: Your app is a single module, or you are certain feature names will not conflict.

Instance API (KRelay.create(...))

  • Pros: Full isolation between modules, DI-friendly, configurable per-instance. This is the solution for Super Apps.
  • Cons: Requires a small amount of setup (creating and providing the instance).
  • Use When: Building a multi-module app, using dependency injection, or needing different configurations for different parts of your app.

See the @SuperAppWarning annotation and the "Quick Start" guide for examples of each.


Documentation

πŸ“š Guides

πŸ—οΈ Technical

🎯 Understanding KRelay

  • Positioning - Why KRelay exists (The Glue Code Standard)
  • Roadmap - Future development plans (Desktop, Web, v2.0)

FAQ

Q: Isn't this just EventBus? I remember the nightmare on Android...

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).


Q: How does KRelay v2.0 work with DI (Koin/Hilt)?

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:

  • DI (Koin/Hilt): For managing the lifecycle of your dependencies, including repositories, use cases, and KRelayInstances.
  • KRelay: As the clean, lifecycle-safe bridge for dispatching commands from your DI-managed components to the UI layer.

Q: Can't I just use LaunchedEffect + SharedFlow? Why add another library?

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.


Testing

KRelay is designed for testability. The v2.0 instance API makes testing even cleaner.

Testing with the Singleton API

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)
    }
}

Testing with the Instance API (Recommended)

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 Simulator

Demo App

The project includes a demo app showcasing real integrations:

Android:

./gradlew :composeApp:installDebug

Features:

  • Basic Demo: Core KRelay features
  • Voyager Integration: Real navigation library integration

See composeApp/src/commonMain/kotlin/dev/brewkits/krelay/ for complete examples.


Philosophy: Do One Thing Well

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.


Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

  1. Fork the repository
  2. Create your feature branch (git checkout -b feature/amazing-feature)
  3. Commit your changes (git commit -m 'Add amazing feature')
  4. Push to the branch (git push origin feature/amazing-feature)
  5. Open a Pull Request

License

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.

⭐ Star Us on GitHub!

If KRelay saves you time, please give us a star!

It helps other developers discover this project.


⬆️ Back to Top


Made with ❀️ by Nguyα»…n TuαΊ₯n Việt at Brewkits

Support: datacenter111@gmail.com β€’ Community: GitHub Issues