Kotlin/Native Memory, Performance, Binary Size and Debugging

This content was generated with the assistance of AI and should be verified against the official Kotlin Multiplatform documentation before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

A Kotlin/Native framework is not a thin wrapper around Swift. It carries its own runtime: a memory allocator, a garbage collector, a scheduler for background work and a type system, all linked into your iOS app next to the Swift runtime. Most days you never notice, because the integration is designed to be seamless. This page covers the days you do notice: a screen that stutters during a collection, a Swift object that is never freed, an .ipa that grew by several megabytes, a release build that takes twenty minutes, or a crash report full of unresolved addresses.

The baseline is the one used across this section: Kotlin 2.4.20 and Xcode 26.4, the pair that the Kotlin Gradle plugin is tested against (see Gradle Configuration, the Multiplatform DSL and Compatibility). Kotlin/Native targets and their tiers are described on How Kotlin Multiplatform Compiles: Targets and Tiers. Several features below are Experimental or Beta; each is labelled where it appears. Every example is a slice of TrailMate Everywhere, the multiplatform version of the Android section’s TrailMate app (package com.example.trailmate). Xcode, LLDB and Instruments themselves are not re-taught: see Debugging with Xcode and LLDB and Performance and Instruments.

The Memory Manager

Kotlin/Native uses a modern memory manager that is similar to the JVM and Go. Two properties define it. First, objects live in one shared heap that any thread can access. Second, a tracing garbage collector (GC) runs periodically and reclaims every object that is no longer reachable from the roots, such as local and global variables.

For anybody who learned Kotlin/Native before 2021, this is a different world. The old model forbade sharing mutable objects between threads, and required freeze() to make an object shareable. That model is gone, and the history is worth knowing only so that you can recognize and delete old code.

Aspect Legacy memory manager (removed) Current memory manager

Status

Default until 1.7.20; the legacy manager and freezing were removed in 1.9.20.

Default since 1.7.20; the only manager since 1.9.20.

Object sharing between threads

Only immutable, frozen graphs, or objects transferred between workers.

Any object, from any thread, with no special API.

freeze() and InvalidMutabilityException

Required, and a frequent cause of crashes.

Not needed; the freezing API was removed in 1.9.20.

Coroutines

The native-mt build of kotlinx-coroutines was required for multithreading.

Plain kotlinx-coroutines-core works on all threads.

Global state

object and top-level val were frozen automatically.

Ordinary shared state; you protect it like on the JVM.

The coroutine consequences of this change (dispatchers, thread confinement, atomics) are covered on Coroutines, Flow and Concurrency in Shared Code. == The Garbage Collector

The Kotlin/Native GC is still evolving. Today it is a concurrent mark and sweep (CMS) collector that does not split the heap into generations. It runs on its own thread and starts either from memory-pressure heuristics or from a timer. During marking, application threads, the GC thread and optional marker threads all help drain the mark queue, and by default the mark phase runs concurrently with the application, which shortens pauses. Weak references are also processed concurrently by default.

CMS is the default since Kotlin 2.4.0

Kotlin 2.0.20 introduced the concurrent collector as an experiment. After user feedback and regression fixes, CMS became the default in Kotlin 2.4.0. Before that, the default was parallel mark concurrent sweep (PMCS), which marks in parallel but stops the application while marking. The practical effect for an iOS app is shorter GC pauses and fewer dropped frames, which matters most for Compose Multiplatform screens (see Compose Multiplatform on iOS).

The collector is selected with the kotlin.native.binary.gc binary option:

Value Behavior When to use it

cms (default since 2.4.0)

Concurrent mark and sweep; marking runs alongside application threads.

Normal apps. Leave it alone.

pmcs

Parallel mark, concurrent sweep.

Fallback if you hit a problem that appears only with CMS.

stwms

Simple stop-the-world mark and sweep.

Diagnosing whether concurrency is involved in a bug.

noop

No collection at all; memory only grows.

Tests and very short-lived programs. Never ship it.

# gradle.properties (project root of TrailMate Everywhere)
# The default since Kotlin 2.4.0 is already cms; this line is only needed to switch back.
kotlin.native.binary.gc=pmcs

Forcing and observing collections

You can start a collection by hand with kotlin.native.internal.GC.collect(). The call blocks until the collection completes. Production code should not call it; its value is in tests, which can check that an object graph really is released.

// sharedLogic/src/iosTest/kotlin/com/example/trailmate/MemoryLeakTest.kt
package com.example.trailmate

import kotlin.native.internal.GC
import kotlin.test.Test
import kotlin.test.assertEquals

class Resource

// A deliberately leaking global, to show what the test detects.
val leakyCache = mutableListOf<Resource>()

@OptIn(ExperimentalStdlibApi::class)
fun heapUsage(): Long {
    GC.collect()
    return GC.lastGCInfo!!.memoryUsageAfter["heap"]!!.totalObjectsSizeBytes
}

private fun allocateAndDrop() {
    leakyCache.add(Resource())
    // The test fails if you remove the next line, because the object is still reachable.
    leakyCache.clear()
}

class MemoryLeakTest {
    @Test
    fun cacheDoesNotRetainResources() {
        val before = heapUsage()
        allocateAndDrop()          // a separate function, so temporaries are out of scope
        val after = heapUsage()
        assertEquals(before, after)
    }
}

To read the collector’s own log, add the compiler option -Xruntime-logs=gc=info. At the moment the logs are printed to stderr. They report, among other things, the number of stable references in the root set, which is the number to watch when you suspect the interop leaks described below.

The allocator

Kotlin/Native has its own allocator. It divides memory into pages tuned to allocation sizes, so that sweeping can proceed page by page, and it protects against allocation spikes: if the application allocates garbage faster than the GC thread can reclaim it, the collector forces a stop-the-world phase until the iteration completes. Two Experimental binary options relate to it: pagedAllocator (default true; turning it off disables the Xcode memory tracking described below) and latin1Strings (default false; stores strings in Latin-1 when every character fits, which cuts string memory and binary size but makes String.pin, String.usePinned and String.refTo less efficient).

Integration with Swift and Objective-C ARC

Kotlin uses tracing GC. Swift and Objective-C use automatic reference counting (ARC). Kotlin objects handed to Swift are wrapped in Objective-C objects that retain them; Swift objects handed to Kotlin are held through a stable reference that retains them until the Kotlin side lets go. Usually that is invisible. The edge cases are all consequences of one fact: a Swift object that crossed into Kotlin is released only after a GC cycle has collected the Kotlin side that referred to it.

sequenceDiagram participant S as Swift (ARC) participant K as Kotlin/Native (GC) S->>K: pass SwiftExample object Note over K: stable reference retains it S-->>S: local variable goes out of scope Note over S: reference count still above zero K-->>K: Kotlin holder becomes unreachable K->>K: GC cycle collects the holder K->>S: release, so deinit runs

Deinitializers: when and where deinit runs

Because a Swift object is released only after a collection, its deinit does not run when the last Swift variable disappears. And because deinit can run arbitrary code, including Kotlin code, it cannot execute during the GC pause itself. It runs afterwards, and the thread is predictable:

  • If the Swift object was passed to Kotlin on the main thread, deinit runs on the main thread.

  • If it was passed on another thread, or the main dispatch queue is not being processed, deinit runs on a special GC thread. That thread is Objective-C-compatible: it has a run loop and drains autorelease pools.

The binary option objcDisposeOnMain (default true, available since 1.9.0) controls this. Setting kotlin.native.binary.objcDisposeOnMain=false forces deinitialization onto the GC thread even for objects passed on the main thread, which avoids main-thread work at the price of deinit code that must now be thread-safe.

// iosApp/iosApp/Diagnostics/ReleaseProbe.swift
import Foundation
import SharedLogic   // the Kotlin framework (baseName = "SharedLogic")

final class ReleaseProbe: LocationCallback {
    init() { print("init on \(Thread.current)") }

    // Runs only after Kotlin releases it, never at the end of the scope that created it.
    deinit { print("deinit on \(Thread.current)") }

    func onLocation(latitude: Double, longitude: Double) {}
}

func probeOnce() {
    let registry = LocationRegistry()          // Kotlin class holding a callback
    registry.register(callback: ReleaseProbe())
    // "deinit" is not printed here; it appears after a later GC cycle collects `registry`.
}

The consequence for your Swift code is simple: never use deinit for anything that must happen promptly, such as stopping a CLLocationManager, ending a Live Activity or cancelling a timer. Release those resources explicitly from a .task or onDisappear, and treat deinit as a last-resort cleanup.

Retain cycles across the boundary

Tracing GC can reclaim cycles; ARC cannot. This asymmetry decides what happens when a cycle spans both worlds:

Cycle contains Can it be reclaimed?

Only Kotlin objects

Yes, the tracing GC collects the whole cycle once it is unreachable.

Only Swift or Objective-C objects

No, ARC leaks it unless you use weak or unowned.

Kotlin and Swift objects

No. If even one Swift object is in the cycle, the whole graph leaks, and it is impossible to break the cycle from the Kotlin side.

There is currently no tool that detects retain cycles in Kotlin/Native code automatically. The prevention rules are the usual ARC rules, applied at the boundary. TrailMate’s SwiftUI screen observes a shared ViewModel and registers a callback; the closure must not capture self strongly if the ViewModel holds the closure.

// iosApp/iosApp/Trails/TrailListObserver.swift
import Foundation
import SharedLogic

@MainActor
final class TrailListObserver: ObservableObject {
    @Published private(set) var trails: [Trail] = []
    private let viewModel: TrailListViewModel     // Kotlin object; holds the callback below

    init(viewModel: TrailListViewModel) {
        self.viewModel = viewModel
        // Wrong: `self.trails = $0` would capture self strongly, and the Kotlin ViewModel retains
        // the closure, which forms a Swift -> Kotlin -> Swift cycle that nothing can reclaim.
        viewModel.setOnTrails { [weak self] list in
            self?.trails = list
        }
    }

    func stop() {
        viewModel.setOnTrails { _ in }            // explicit teardown, do not rely on deinit
    }
}

The higher-level patterns that avoid callbacks (SKIE, KMP-NativeCoroutines) are on Consuming Coroutines and Flow from Swift and SwiftUI.

Long-lived Objective-C objects and autoreleasepool

Objective-C objects that cross into Kotlin can outlive the iteration that created them, particularly in a long loop. A growing "stable refs" count in the GC log is the sign. Wrapping the loop body in autoreleasepool from Kotlin lets each iteration’s autoreleased objects drain at once.

// sharedLogic/src/iosMain/kotlin/com/example/trailmate/data/PhotoThumbnailer.kt
package com.example.trailmate.data

import kotlinx.cinterop.autoreleasepool
import platform.Foundation.NSData

// Each thumbnail touches several temporary Objective-C objects. Without the pool, a 2,000-photo
// import keeps all of them alive until the outer pool drains.
fun thumbnailAll(photos: List<NSData>, makeThumbnail: (NSData) -> NSData): List<NSData> {
    val result = ArrayList<NSData>(photos.size)
    for (photo in photos) {
        autoreleasepool {
            result += makeThumbnail(photo)
        }
    }
    return result
}

Background state and App Extensions

By default the memory manager does not track whether the app is in the background, and it does not integrate with App Extensions. Its timer-based collections therefore keep firing while the app is suspended or running as, say, a widget. The Experimental binary option appStateTracking changes this: when enabled, timer-based GC is turned off in the background and a collection runs only when memory consumption becomes too high.

# gradle.properties
# Experimental since Kotlin 1.7.20.
kotlin.native.binary.appStateTracking=enabled

Binary Options

Binary options configure the Kotlin/Native runtime and compiler output. They are not Kotlin compiler flags in the usual sense; they are -Xbinary=name=value pairs. There are three ways to set them, from broadest to narrowest.

In gradle.properties the form is kotlin.native.binary.smallBinary=true, which applies to every Kotlin/Native binary in the build.

// sharedLogic/build.gradle.kts -- applies to one binary
kotlin {
    listOf(iosArm64(), iosSimulatorArm64()).forEach { target ->
        target.binaries.framework {
            baseName = "SharedLogic"
            isStatic = true
            binaryOption("smallBinary", "true")
            binaryOption("bundleId", "com.example.trailmate.shared")
        }
    }
}

The documentation lists only the notable options, not all of them. The table below covers the ones relevant to an iOS app, with the status stated by the documentation.

Option Values Effect Status

gc

cms (default), pmcs, stwms, noop

Selects the garbage collector.

cms default since 2.4.0

smallBinary

true, false (default)

Reduces release binary size; effectively sets -Oz for the LLVM phase.

Experimental since 2.2.20

stackProtector

yes, strong, all, no (default)

Emits stack canaries. yes protects vulnerable functions, strong applies a stronger heuristic, all protects every function.

Available since 2.2.20

enableReleaseBinaryCache

true, false (default)

Uses caches in release mode: faster builds, fewer optimizations.

Experimental since 2.4.20

objcDisposeOnMain

true (default), false

Where Swift deinit runs for objects handed to Kotlin.

Available since 1.9.0

appStateTracking

enabled, disabled (default)

Suspends timer-based GC while the app is in the background.

Experimental since 1.7.20

enableSafepointSignposts

true, false (default)

Reports GC pauses as signposts for Instruments.

Available since 2.0.20

bundleId, bundleShortVersionString, bundleVersion

strings

Values for CFBundleIdentifier, CFBundleShortVersionString and CFBundleVersion in the framework’s Info.plist.

Available since 1.7.20

sourceInfoType

libbacktrace, coresymbolication (Apple only), noop (default)

Adds file names and line numbers to exception stack traces.

Experimental since 1.6.20

bundleId and the framework Info.plist

The three bundle* options fill the version keys of the Info.plist that the compiler generates for a framework. Give the shared framework a unique reverse-DNS bundleId, distinct from the app’s own, so that App Store Connect validation and tools that match frameworks by identifier do not see a collision. See Kotlin docs — Build final native binaries.

sourceInfoType and readable exception traces

An uncaught Kotlin exception on iOS prints a stack trace. By default the frames carry addresses, not file names. Setting sourceInfoType adds file locations and line numbers. coresymbolication uses Apple’s CoreSymbolication and is only available on Apple targets; it is already enabled by default for macOS and Apple simulators in debug mode. The option is Experimental, so test the effect on your release configuration before relying on it.

Static versus Dynamic Frameworks

The framework binary kind produces an Objective-C/Swift framework. By default it is dynamic; with isStatic = true it is static. The choice affects launch time, size and integration, and it interacts with how Xcode links the framework (see Integrating Shared Code into iOS: Direct, SwiftPM and CocoaPods).

Concern Static framework (isStatic = true) Dynamic framework (default)

Linking

Merged into the app binary at link time.

A separate .framework bundle that dyld loads at launch.

Launch time

No extra dylib to load.

Each dynamic framework adds dyld work at launch.

Several Kotlin frameworks in one app

Risky: each static framework embeds its own copy of the Kotlin runtime, so duplicates accumulate.

Also duplicates the runtime per framework, but the duplicates are isolated.

Embedding

Nothing to embed or sign separately.

Must be embedded and signed by the app target.

Typical use

One umbrella framework shipped to the app.

Frameworks shared between an app and its extensions.

TrailMate Everywhere builds one umbrella framework, SharedLogic, and makes it static, so the app links the Kotlin runtime once. (If the widget extension also links it, the extension carries its own copy; that is a size cost to measure, not a bug.) The most important rule is this: ship one Kotlin framework per app, not one per feature. If two Kotlin modules each produce their own framework, each includes a copy of the runtime and of the shared libraries, and objects of the same Kotlin type become different Swift types. Combine modules into one umbrella and use export(…​) (on an api dependency) for the types Swift must see.

Prefer export for exactly the types Swift must see. The transitiveExport option exports every transitive dependency of the exported libraries; the documentation recommends against it, because it disables dead-code elimination in many cases and grows both compile time and binary size. Distribution as an XCFramework, which replaces the old universal (fat) framework, is covered on Publishing Apps.

Keeping the App Small

Kotlin/Native compiles the whole program with link-time optimization and removes code that cannot be reached, but three things work against it: a broad exported API, broad dependencies, and unoptimized builds.

Practice Why it helps

Export only what Swift needs

Every exported declaration, and everything it references, must survive dead-code elimination and appear in the generated Objective-C header.

Avoid transitiveExport = true

It pulls the transitive closure of the exported libraries into the binary.

Use implementation, not api, by default

api dependencies of an exported module end up in the framework API, as explained on Architecture, Shared ViewModels and State.

Enable smallBinary for release (Experimental)

Optimizes for size with -Oz; it may cost some run-time speed, so check hot paths.

Consider latin1Strings (Experimental)

Halves the size of ASCII-heavy string data; it makes the String.pin family of helpers less efficient.

Build with Xcode’s Release configuration and archive

App thinning, bitcode-free slicing and compression happen in the archive step, so compare the .ipa or the App Thinning Size Report, not the raw framework.

Compilation Time

Native release builds are the slowest part of a KMP pipeline. Release is highly optimized, and a release binary takes an order of magnitude longer to produce than a debug one. Most speed-ups come from not building things you do not need.

Tip Detail

Use the latest Kotlin

Each release improves the Native compiler. Move to 2.4.20 before tuning anything else.

Preserve ~/.konan in CI

The compiler downloads its toolchain and caches results there. In containers or CI, cache it or redirect it with the konan.data.dir Gradle property.

Build one target and one build type while developing

Run linkDebugFrameworkIosSimulatorArm64, or let Xcode run embedAndSignAppleFrameworkForXcode, which builds only the architecture you are running.

Do not use release tasks in the edit loop

Replace linkRelease* with linkDebug*; replace assembleXCFramework with assembleSharedDebugXCFramework.

Enable Gradle caches

org.gradle.caching=true for the build cache and org.gradle.configuration-cache=true for the configuration cache.

Try release caches (Experimental)

kotlin.native.binary.enableReleaseBinaryCache=true with kotlin.internal.native.enableReleaseBinaryCache=true trades some optimization for faster release builds.

Try incremental klib compilation (Beta)

kotlin.incremental.native=true.

Do not export too much

Every exported module costs compile time and size; avoid huge classes as well.

In CI the biggest win is caching ~/.konan and the Gradle caches between runs; the workflow is shown on CI/CD for Kotlin Multiplatform with GitHub Actions. Time at least the second build, since the first is always slow. Release caches are a trade: you choose build time over a fraction of the optimization, so use them on pull-request builds and turn them off for the build you submit to the App Store.

Debugging Kotlin in Xcode and LLDB

The Kotlin/Native compiler emits DWARF 2 debug information, which LLDB understands, so you can set breakpoints, step, and inspect variables. Because DWARF 2 has no identifier for Kotlin, the debugger treats Kotlin as C89; that explains why names such as kfun:…​ and ObjHeader * appear in LLDB output.

Breakpoints and stepping

Xcode can set a breakpoint in a Kotlin source file only when the Kotlin sources are visible to it. The Kotlin Multiplatform IDE plugin can debug an iOS run configuration from IntelliJ IDEA or Android Studio, with Kotlin frames and Swift frames in one stack; use Xcode when you need Instruments, view debugging, or on-device testing (see Getting Started and Tooling). In an LLDB console you can set Kotlin breakpoints several ways.

# LLDB console, attached to the TrailMate iOS app
(lldb) b -n kfun:com.example.trailmate.data.OfflineFirstTrailRepository#refresh(){}
(lldb) b -f OfflineFirstTrailRepository.kt -l 42
(lldb) b -a 0x00000001000012e4
(lldb) b -r OfflineFirstTrailRepository
(lldb) fr var
(lldb) v trail->name

By name uses the compiled symbol (-n is the default); by location uses file and line; by address is useful in disassembly; and a regular expression is handy for generated lambdas, whose names contain #. Variables declared with var and val can be inspected with fr var or v; Kotlin objects appear as ObjHeader * with the fields reachable by dot or arrow notation.

The documented limits: expression evaluation in the debugger is not supported, and there are no plans to implement it. Python bindings are also slow. Expect to log values or use watch expressions on plain variables instead of calling Kotlin functions from the console.

Crash Reports and dSYM Symbolication

A crash report from a user lists memory addresses. Symbolication translates them back to function names and line numbers, using a .dSYM file. For Kotlin code that file comes from the Kotlin/Native compiler, which generates .dSYM bundles for release (optimized) binaries on Apple platforms by default. Xcode looks for them in the standard locations and uses them automatically, including in projects created from the IntelliJ IDEA templates.

On non-Apple platforms, or if you need lighter debug information in a binary, the -Xadd-light-debug compiler option adds it to the binary and increases its size.

The workflow for a release is:

  1. Archive in Xcode. The archive contains the app and its .dSYM bundles, including the Kotlin framework’s.

  2. Keep the archive (or at least the dSYMs) for every build you ship. A crash report can only be symbolicated with the dSYM of that exact build.

  3. When uploading to App Store Connect, leave "Upload your app’s symbols" enabled so that Xcode Organizer and crash services can symbolicate. Third-party crash reporters need the dSYMs uploaded separately.

  4. To symbolicate manually, drag the crash log into Xcode’s Devices and Simulators window, or use atos with the dSYM. Xcode Organizer’s Crashes tab does it automatically for TestFlight and App Store crashes.

The Apple-side tooling is described on Logging, Metrics and Crash Reports. An unhandled Kotlin exception that propagates into Swift terminates the process, because Swift cannot catch it unless the Kotlin function declares @Throws; the crash log then shows a Kotlin stack, and symbolication is the difference between 0x1032f4 and OfflineFirstTrailRepository.refresh.

Profiling with Instruments

Xcode Instruments profiles a Kotlin framework like any other native code, because the Kotlin frames are ordinary native frames. Run Product, Profile (Cmd+I) on a release-like configuration, and use the standard templates: Time Profiler for CPU, Allocations and Leaks for Objective-C and Swift objects, and VM Tracker for virtual memory. The general workflow is on Performance and Instruments; here are the Kotlin-specific parts.

GC pauses as signposts

To see whether a visible hitch coincides with a collection, enable safepoint signposts. This binary option (enableSafepointSignposts, available since 2.0.20) makes the GC report each pause as a signpost event.

# gradle.properties
kotlin.native.binary.enableSafepointSignposts=true

Then, in Xcode, choose Product, Profile, pick the os_signpost template, set the subsystem to org.kotlinlang.native.runtime and the category to safepoint, and record. Every blue blob on the lowest graph is a GC pause; if one lines up with a hang in the Hangs instrument, the GC is involved, and you can reduce allocation rate in the hot path (for example, avoid building a new List<Trail> on every scroll event).

Tracking Kotlin memory in VM Tracker

On Apple platforms, Kotlin’s share of memory is tagged with an identifier (mmapTag, default 246), so VM Tracker can show how much is reserved by Kotlin. The feature is available only with the default allocator, with tagging on (mmapTag not 0) and with the paged allocator enabled (pagedAllocator not false); and it can be tuned if 246 collides with another tag in your app. This is the first place to look when the total footprint is much larger than your Kotlin heap statistics suggest.

C Interop Basics

Kotlin/Native can call C libraries. The cinterop tool reads C headers and generates Kotlin stubs for functions, structs and constants. C library import is Beta: every declaration that cinterop generates carries the @ExperimentalForeignApi annotation, so you must opt in. Apple’s own frameworks (Foundation, UIKit, POSIX) are different: they come as pre-built platform libraries that need no setup, and only some of their APIs require the opt-in. For TrailMate you almost always want the platform libraries, which are described on expect/actual and Calling Platform APIs.

The workflow

  1. Write a definition (.def) file that tells cinterop which headers to include.

  2. Add the cinterop block to the Kotlin target in Gradle.

  3. Call the generated bindings from iosMain (or nativeMain).

# sharedLogic/src/nativeInterop/cinterop/geohash.def
headers = geohash.h
headerFilter = geohash.h
package = com.example.trailmate.cinterop.geohash
compilerOpts = -I/Users/dev/trailmate/native/include
linkerOpts = -L/Users/dev/trailmate/native/lib -lgeohash
// sharedLogic/build.gradle.kts
kotlin {
    iosArm64 {
        compilations.getByName("main") {
            cinterops {
                val geohash by creating {
                    definitionFile.set(project.file("src/nativeInterop/cinterop/geohash.def"))
                }
            }
        }
    }
}

The paths in compilerOpts and linkerOpts above are placeholders; in a real project they point to your own vendored library, ideally relative to the project. Dependency management for such libraries, including CocoaPods and SwiftPM import, is covered on Dependencies, klibs.io, cinterop and SwiftPM Import.

Memory in C interop

Memory that C owns is outside the GC. The Kotlin API therefore makes ownership explicit: memScoped allocates native memory that is freed when the block ends, and usePinned pins a Kotlin array so that C can read it without a copy. A pointer must never be kept after the block that created it.

// sharedLogic/src/iosMain/kotlin/com/example/trailmate/geo/GeoHash.kt
package com.example.trailmate.geo

import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.allocArray
import kotlinx.cinterop.memScoped
import kotlinx.cinterop.toKString
import kotlinx.cinterop.ByteVar
import com.example.trailmate.cinterop.geohash.geohash_encode

@OptIn(ExperimentalForeignApi::class)
fun encodeGeoHash(latitude: Double, longitude: Double, precision: Int = 9): String =
    memScoped {
        val buffer = allocArray<ByteVar>(precision + 1)   // freed when memScoped ends
        geohash_encode(latitude, longitude, precision, buffer)
        buffer.toKString()                                // copy to a Kotlin String before returning
    }

geohash_encode is a function of the example C library; TrailMate does not ship it.

Common Mistakes

  • Copying legacy concurrency code. Remove freeze(), @SharedImmutable, @ThreadLocal workarounds and native-mt coroutine versions; none are needed.

  • Trusting deinit. It runs after a GC cycle, possibly on a GC thread. Release real resources explicitly.

  • Shipping several Kotlin frameworks. Each one duplicates the runtime; build one umbrella framework.