iOS & Swift Integration: Making Koin Feel Native

šŸ“š Dependency Injection in Kotlin Multiplatform with Koin Part 6 of 8

Imagine an iOS developer saying: "I don't want to learn your DI framework. I just want to call a function and get what I need."

Fair point. iOS developers shouldn't need to understand Koin's DSL, scopes, or qualifiers. They should get Swift APIs that feel native—factories that return objects, observable wrappers that work with SwiftUI, and no Kotlin generics leaking into their code.

This article is about building that abstraction layer for our fictional Budget Tracker app.

The Swift Interop Problem

Let's see what happens when you expose Koin directly to Swift:

// Kotlin - what we have
fun getRepository(): UserRepository = getKoin().get()
// Swift - what iOS devs see
let repo = KoinKt.getKoin().get(clazz: UserRepository.self, qualifier: nil, parameters: nil)

That's verbose and requires understanding Koin's API. Worse, Kotlin generics don't translate cleanly:

// Kotlin
inline fun <reified T: Any> get(): T = getKoin().get()

This reified generic becomes awkward in Swift because Swift doesn't have reified generics. You end up passing class references explicitly, which defeats the purpose.

The goal: iOS developers should never see Koin, get(), or qualifier in their code.

The Dependencies Factory Pattern

Instead of exposing Koin's API, we create a factory object with explicit methods for each dependency:

// commonMain/kotlin/com/budgettracker/di/Dependencies.kt

package com.budgettracker.di

import com.budgettracker.data.repository.TransactionRepository
import com.budgettracker.data.repository.UserRepository
import com.budgettracker.data.repository.BudgetRepository
import com.budgettracker.feature.transaction.list.TransactionListViewModel
import com.budgettracker.feature.profile.ProfileViewModel
import org.koin.core.component.KoinComponent
import org.koin.core.component.get

/**
 * Factory for accessing dependencies from platform code.
 *
 * This object provides a clean API that hides Koin internals.
 * iOS code calls these methods without knowing Koin exists.
 */
object Dependencies : KoinComponent {

    // ============ Repositories ============
    // These are singletons - safe to access directly

    fun getUserRepository(): UserRepository = get()

    fun getTransactionRepository(): TransactionRepository = get()

    fun getBudgetRepository(): BudgetRepository = get()

    // ============ Screen Components ============
    // These create scoped components - caller must manage lifecycle

    fun createTransactionListComponent(): TransactionListComponent {
        return TransactionListComponent()
    }

    fun createTransactionDetailComponent(transactionId: String): TransactionDetailComponent {
        return TransactionDetailComponent(transactionId)
    }

    fun createProfileComponent(): ProfileComponent {
        return ProfileComponent()
    }

    fun createOnboardingFlow(): OnboardingFlowCoordinator {
        return OnboardingFlowCoordinator()
    }
}

From Swift, this becomes:

// Clean Swift API
let userRepo = Dependencies.shared.getUserRepository()
let listComponent = Dependencies.shared.createTransactionListComponent()

No generics. No qualifiers. No Koin knowledge required.

Making Names Swift-Friendly

Kotlin's naming conventions don't always produce nice Swift names. Use @ObjCName to control the generated interface:

// commonMain/kotlin/com/budgettracker/di/Dependencies.kt

import kotlin.native.ObjCName

@ObjCName("AppDependencies")  // Renamed for Swift
object Dependencies : KoinComponent {

    @ObjCName("userRepository")
    fun getUserRepository(): UserRepository = get()

    @ObjCName("transactionRepository")
    fun getTransactionRepository(): TransactionRepository = get()

    @ObjCName("createTransactionList")
    fun createTransactionListComponent(): TransactionListComponent {
        return TransactionListComponent()
    }
}

Swift now sees:

// Natural Swift naming
let userRepo = AppDependencies.shared.userRepository
let listComponent = AppDependencies.shared.createTransactionList()

The @ObjCName annotation only affects the Objective-C/Swift interface—Kotlin code still uses the original names.

Component Lifecycle in Swift

Screen components need lifecycle management. Here's the pattern that works with SwiftUI:

// commonMain/kotlin/com/budgettracker/feature/transaction/list/TransactionListComponent.kt

package com.budgettracker.feature.transaction.list

import org.koin.core.component.KoinComponent
import org.koin.core.scope.Scope

class TransactionListComponent : KoinComponent {

    private val scope: Scope = getKoin().createScope(
        scopeId = "transaction_list_${System.currentTimeMillis()}",
        qualifier = TransactionListScope
    )

    val viewModel: TransactionListViewModel = scope.get()

    /**
     * Clean up resources. Call when the screen is dismissed.
     */
    fun destroy() {
        scope.close()
    }
}

Swift usage with SwiftUI:

// iosApp/Sources/Features/TransactionList/TransactionListView.swift

import SwiftUI
import Shared

struct TransactionListView: View {
    @StateObject private var holder = TransactionListHolder()

    var body: some View {
        TransactionListContent(viewModel: holder.viewModel)
            .onDisappear {
                holder.destroy()
            }
    }
}

// Wrapper that holds the component
class TransactionListHolder: ObservableObject {
    private let component: TransactionListComponent

    var viewModel: TransactionListViewModel {
        component.viewModel
    }

    init() {
        self.component = AppDependencies.shared.createTransactionList()
    }

    func destroy() {
        component.destroy()
    }

    deinit {
        destroy()
    }
}

The TransactionListHolder bridges Kotlin's component lifecycle with SwiftUI's view lifecycle.

Generic Component Holder

Rather than creating a holder for each screen, Budget Tracker uses a generic holder:

// iosApp/Sources/Core/ComponentHolder.swift

import SwiftUI
import Shared

/**
 * Generic holder for Kotlin components with lifecycle management.
 *
 * Usage:
 * ```swift
 * @StateObject private var holder = ComponentHolder {
 *     AppDependencies.shared.createTransactionList()
 * }
 * ```
 */
class ComponentHolder<T: AnyObject>: ObservableObject {
    let component: T
    private let onDestroy: ((T) -> Void)?
    private var isDestroyed = false

    init(
        create: () -> T,
        onDestroy: ((T) -> Void)? = nil
    ) {
        self.component = create()
        self.onDestroy = onDestroy
    }

    func destroy() {
        guard !isDestroyed else { return }
        isDestroyed = true

        // Call custom destroy logic if provided
        onDestroy?(component)

        // Try standard destroy method if component has one
        if let destroyable = component as? Destroyable {
            destroyable.destroy()
        }
    }

    deinit {
        destroy()
    }
}

// Protocol for components with destroy()
@objc protocol Destroyable {
    func destroy()
}

Make Kotlin components conform:

// commonMain - mark component as Destroyable for Swift
interface Destroyable {
    fun destroy()
}

class TransactionListComponent : KoinComponent, Destroyable {
    // ... implementation
    override fun destroy() {
        scope.close()
    }
}

SwiftUI usage becomes cleaner:

struct TransactionListView: View {
    @StateObject private var holder = ComponentHolder {
        AppDependencies.shared.createTransactionList()
    }

    var body: some View {
        TransactionListContent(viewModel: holder.component.viewModel)
    }
    // destroy() called automatically on deinit
}

Observing Kotlin State in SwiftUI

Kotlin ViewModels typically expose state via StateFlow. SwiftUI needs @Published properties. We need to bridge the gap.

The State Wrapper Pattern

First, add an observation method to Kotlin ViewModels:

// commonMain/kotlin/com/budgettracker/feature/transaction/list/TransactionListViewModel.kt

class TransactionListViewModel(
    private val getTransactions: GetTransactionsUseCase
) {
    private val _state = MutableStateFlow<TransactionListState>(TransactionListState.Loading)
    val state: StateFlow<TransactionListState> = _state.asStateFlow()

    /**
     * Observe state changes. Returns a cancellable handle.
     * Designed for platform consumption (Swift/ObjC).
     */
    fun observeState(onChange: (TransactionListState) -> Unit): Cancellable {
        val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
        scope.launch {
            state.collect { onChange(it) }
        }
        return object : Cancellable {
            override fun cancel() {
                scope.cancel()
            }
        }
    }

    fun loadTransactions() {
        // ... implementation
    }
}

// commonMain - Cancellable interface
interface Cancellable {
    fun cancel()
}

Create a Swift wrapper that observes:

// iosApp/Sources/Features/TransactionList/TransactionListViewModelWrapper.swift

import SwiftUI
import Shared
import Combine

class TransactionListViewModelWrapper: ObservableObject {
    private let viewModel: TransactionListViewModel
    private var cancellable: Cancellable?

    @Published var state: TransactionListState = TransactionListStateLoading()

    init(viewModel: TransactionListViewModel) {
        self.viewModel = viewModel

        // Observe Kotlin StateFlow, publish to SwiftUI
        cancellable = viewModel.observeState { [weak self] newState in
            DispatchQueue.main.async {
                self?.state = newState
            }
        }
    }

    func loadTransactions() {
        viewModel.loadTransactions()
    }

    deinit {
        cancellable?.cancel()
    }
}

SwiftUI view:

struct TransactionListView: View {
    @StateObject private var holder = ComponentHolder {
        AppDependencies.shared.createTransactionList()
    }
    @StateObject private var viewModel: TransactionListViewModelWrapper

    init() {
        let component = AppDependencies.shared.createTransactionList()
        _holder = StateObject(wrappedValue: ComponentHolder { component })
        _viewModel = StateObject(wrappedValue: TransactionListViewModelWrapper(
            viewModel: component.viewModel
        ))
    }

    var body: some View {
        Group {
            switch viewModel.state {
            case is TransactionListStateLoading:
                ProgressView()

            case let success as TransactionListStateSuccess:
                TransactionList(transactions: success.transactions)

            case let error as TransactionListStateError:
                ErrorView(message: error.message)

            default:
                EmptyView()
            }
        }
        .onAppear {
            viewModel.loadTransactions()
        }
    }
}

The Combined Holder + Wrapper Pattern

For a cleaner API, combine component holding and state observation:

// iosApp/Sources/Core/ScreenHolder.swift

import SwiftUI
import Shared

/**
 * Combines component lifecycle and state observation in one object.
 */
class ScreenHolder<Component: Destroyable, State: AnyObject>: ObservableObject {
    let component: Component
    private var cancellable: Cancellable?

    @Published var state: State

    init(
        createComponent: () -> Component,
        getViewModel: (Component) -> Any,
        initialState: State,
        observeState: (Any, @escaping (State) -> Void) -> Cancellable
    ) {
        self.component = createComponent()
        self.state = initialState

        let viewModel = getViewModel(component)
        self.cancellable = observeState(viewModel) { [weak self] newState in
            DispatchQueue.main.async {
                self?.state = newState
            }
        }
    }

    deinit {
        cancellable?.cancel()
        component.destroy()
    }
}

Usage:

struct TransactionListView: View {
    @StateObject private var screen = ScreenHolder(
        createComponent: { AppDependencies.shared.createTransactionList() },
        getViewModel: { $0.viewModel },
        initialState: TransactionListStateLoading() as TransactionListState,
        observeState: { vm, onChange in
            (vm as! TransactionListViewModel).observeState(onChange: onChange)
        }
    )

    var body: some View {
        // Use screen.state directly
    }
}

Alternative: SKIE for Automatic Flow Bridging

If you're using Touchlab's SKIE, Flow bridging becomes automatic. SKIE converts Kotlin Flows to Swift's AsyncSequence:

// Kotlin - with SKIE
class TransactionListViewModel {
    val state: StateFlow<TransactionListState>  // SKIE handles this
}
// Swift - SKIE generates async sequence
Task {
    for await state in viewModel.state {
        self.state = state
    }
}

SKIE also improves sealed class handling, making pattern matching natural:

// With SKIE's enhanced sealed class support
switch viewModel.state {
case .loading:
    ProgressView()
case .success(let data):
    TransactionList(transactions: data.transactions)
case .error(let error):
    ErrorView(message: error.message)
}

Budget Tracker evaluated SKIE and found it valuable for teams with heavy Swift investment. The trade-off is adding another build tool dependency.

Environment Object Integration

For app-wide dependencies that many views need, use SwiftUI's environment:

// iosApp/Sources/Core/AppEnvironment.swift

import SwiftUI
import Shared

class AppEnvironment: ObservableObject {
    let userRepository: UserRepository
    let transactionRepository: TransactionRepository
    let appInfo: AppInfo

    init() {
        let deps = AppDependencies.shared
        self.userRepository = deps.userRepository
        self.transactionRepository = deps.transactionRepository
        self.appInfo = deps.appInfo
    }
}

// In your App
@main
struct BudgetTrackerApp: App {
    @StateObject private var appEnvironment = AppEnvironment()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(appEnvironment)
        }
    }
}

// Child views access via environment
struct SettingsView: View {
    @EnvironmentObject var environment: AppEnvironment

    var body: some View {
        Text("Version: \(environment.appInfo.appVersion)")
    }
}

Common iOS Integration Mistakes

Mistake 1: Forgetting to Cancel Observations

// BAD: Cancellable lost, observation continues forever
class BadWrapper: ObservableObject {
    init(viewModel: SomeViewModel) {
        viewModel.observeState { [weak self] state in  // Never cancelled!
            self?.state = state
        }
    }
}

// GOOD: Store and cancel
class GoodWrapper: ObservableObject {
    private var cancellable: Cancellable?

    init(viewModel: SomeViewModel) {
        cancellable = viewModel.observeState { [weak self] state in
            self?.state = state
        }
    }

    deinit {
        cancellable?.cancel()
    }
}

Mistake 2: Dispatching to Main Thread

// BAD: Updates might come on background thread
cancellable = viewModel.observeState { [weak self] state in
    self?.state = state  // Might crash - not main thread!
}

// GOOD: Always dispatch to main
cancellable = viewModel.observeState { [weak self] state in
    DispatchQueue.main.async {
        self?.state = state
    }
}

Mistake 3: Strong Reference Cycles

// BAD: Strong reference to self
cancellable = viewModel.observeState { state in
    self.state = state  // Retains self forever!
}

// GOOD: Weak reference
cancellable = viewModel.observeState { [weak self] state in
    self?.state = state
}

Mistake 4: Not Destroying Components

// BAD: Component never destroyed
struct BadView: View {
    let component = AppDependencies.shared.createTransactionList()  // Leaked!

    var body: some View {
        // ...
    }
}

// GOOD: Use holder pattern
struct GoodView: View {
    @StateObject private var holder = ComponentHolder {
        AppDependencies.shared.createTransactionList()
    }

    var body: some View {
        // Component destroyed when holder deinits
    }
}

Mistake 5: Blocking Main Thread on Init

// BAD: Koin might do work on init
@main
struct BadApp: App {
    init() {
        KoinInitKt.doInitKoin(...)  // Might be slow!
    }
}

// GOOD: Show loading state
@main
struct GoodApp: App {
    @State private var isReady = false

    var body: some Scene {
        WindowGroup {
            if isReady {
                ContentView()
            } else {
                LaunchScreen()
                    .task {
                        await initializeApp()
                        isReady = true
                    }
            }
        }
    }

    private func initializeApp() async {
        // Run Koin init off main thread if needed
        await Task.detached {
            KoinInitKt.doInitKoin(...)
        }.value
    }
}

Summary: iOS Integration Checklist

When exposing Kotlin code to iOS:

  1. Create Dependencies factory object - Hide Koin behind explicit methods
  2. Use @ObjCName - Make Swift names idiomatic
  3. Create component holders - Manage lifecycle with SwiftUI
  4. Add observeState() methods - Bridge StateFlow to Swift
  5. Wrap in ObservableObject - Enable SwiftUI reactivity
  6. Store cancellables - Prevent memory leaks
  7. Dispatch to main thread - Avoid threading crashes
  8. Use [weak self] - Prevent retain cycles

What's Next

Platform integration is complete. But how do we know it all works correctly? Testing KMP code requires strategies that work across platforms.

In the next article, we'll explore testing patterns: unit tests with Koin fakes, integration tests with real dependencies, module verification, and CI/CD configuration for multiplatform test runs.