Introduction
In the previous article, we explored manual memory management and garbage collection in Kotlin Multiplatform (KMP) with a focus on shared libraries. KMP's ability to generate shared libraries for C++ projects and frameworks for Apple platforms makes it highly versatile, but each output format brings unique challenges and advantages in memory management. This article dives into using an Apple XCFramework from Kotlin Multiplatform, focusing on memory management and garbage collection (GC) in a Swift-based environment. We’ll highlight key differences from the shared library implementation and observe how automatic memory management in Swift’s ARC simplifies the process.
Building and Using an XCFramework in Swift
Building an XCFramework
Creating an XCFramework with Kotlin Multiplatform is straightforward, especially with Gradle's built-in support for Apple frameworks. The process is defined in the build script, as seen in the Gradle configuration example.
Key steps for building the XCFramework include:
-
Setting Up Target Platforms: In the
kotlin
block ofbuild.gradle.kts
, specify the Apple targets (iosArm64
,iosX64
, andiosSimulatorArm64
). -
Defining XCFramework Output: Use the
framework
directive to configure the XCFramework output, specifying necessary build parameters likebaseName
and the output directory. -
Building with Gradle: Use the command
./gradlew assembleKmpSampleXCFramework
to generate the framework, which can then be imported into an Xcode project. The task name will depend on theXCFramework(name)
passed in thebuild.gradle.kts
. In our case it isKmpSample
and the task name isassembleKmpSampleXCFramework
respectively.
Using the XCFramework in a Swift Project
The XCFramework can be added directly to an Xcode project, allowing seamless use of Kotlin code in Swift. The Swift sample project demonstrates this.
I won't spend much in this article on how do you exactly include the framework into the Swift project. But essentially it just requires creating a Swift project (like a CLI app, programming language: Swift), and drag-n-dropping the KmpSample.xcframework
folder from build/XCFrameworks/[debug,release]/
directly into Xcode project.
Now let's focus on the example of invoking Kotlin methods from Swift:
import Foundation
import KmpSample
let clazzInstance = KmpClazz()
// Call interfaceMethod
let resultString = clazzInstance.interfaceMethod()
print("Result from interfaceMethod: \(resultString)")
// Call returnInt
if let intResult = clazzInstance.returnInt() {
print("Result from returnInt: \(intResult)")
}
// Call returnLong
if let longResult = clazzInstance.returnLong() {
print("Result from returnLong: \(longResult)")
}
// Passing a byte array to a Kotlin function
var byteArray: [UInt8] = [0xCA, 0xFE, 0xCA, 0xFE, 0xCA, 0xFE, 0xCA, 0xFE]
let size = byteArray.count
byteArray.withUnsafeMutableBytes { rawBufferPointer in
KmpSampleKt.readNativeByteArray(byteArray: rawBufferPointer.baseAddress!, size: Int32(size))
}
In contrast to the shared library setup, using the XCFramework in Swift reduces the need for manual disposal of resources. Swift's ARC automatically manages object references, freeing developers from the memory management steps required in C++.
Memory Management in Kotlin Multiplatform with XCFramework
Kotlin Native provides a garbage collector (GC) that manages memory for objects created within the Kotlin framework. Although Swift’s ARC handles the lifespan of objects, the GC in Kotlin remains active, especially for objects created internally within Kotlin’s runtime. To monitor GC behavior, pass the -Xruntime-logs=gc=info
flag during compilation, as configured in the Gradle build here.
With GC logging enabled, you’ll see output like this when running the framework in a Swift environment:
[INFO][gc][tid#1494899][0.000s] Adaptive GC scheduler initialized
[INFO][gc][tid#1494899][0.001s] Set up parallel mark with maxParallelism = 8 and cooperative mutators
[INFO][gc][tid#1494899][0.001s] Parallel Mark & Concurrent Sweep GC initialized
Observing GC Behaviour
To test how Kotlin GC performs under heavy load, we can simulate high-frequency object creation and disposal in Swift. The sample code in IntegrationGcTest creates millions of KmpClazz
instances, calling methods and disposing of objects in a loop. The following code demonstrates this scenario:
import Foundation
import KmpSample
for i in 1...10_000_000 {
let clazzInstance = KmpClazz()
// Call interfaceMethod
let resultString = clazzInstance.interfaceMethod()
// Call returnInt
if let intResult = clazzInstance.returnInt() {}
// Call returnLong
if let longResult = clazzInstance.returnLong() {}
if i % 1_000_000 == 0 {
print("Created \(i) objects")
}
}
GC Log Analysis for XCFramework in Swift
The full GC log for the example above
The resulting GC log from this loop is extensive. Here’s a segment of the log:
[INFO][gc][tid#1796721][16.128s] Epoch #157: Started. Time since last GC 95913 microseconds.
[INFO][gc][tid#1796721][16.138s] Epoch #157: Root set: 0 thread local references, 0 stack references, 0 global references, 1 stable references. In total 1 roots.
[INFO][gc][tid#1796721][16.138s] Epoch #157: Mark: 2728 objects.
[INFO][gc][tid#1796721][16.138s] Epoch #157: Sweep extra objects: swept 58915 objects, kept 68256 objects
[INFO][gc][tid#1796721][16.138s] Epoch #157: Sweep: swept 65528 objects, kept 2728 objects
[INFO][gc][tid#1796721][16.138s] Epoch #157: Heap memory usage: before 6160384 bytes, after 6422528 bytes
[INFO][gc][tid#1796721][16.138s] Epoch #157: Time to pause #1: 32 microseconds.
[INFO][gc][tid#1796721][16.138s] Epoch #157: Mutators pause time #1: 2884 microseconds.
[INFO][gc][tid#1796721][16.138s] Epoch #157: Time to pause #2: 2 microseconds.
[INFO][gc][tid#1796721][16.138s] Epoch #157: Mutators pause time #2: 10 microseconds.
[INFO][gc][tid#1796721][16.138s] Epoch #157: Finished. Total GC epoch time is 10008 microseconds.
[INFO][gc][tid#1796725][16.145s] Epoch #157: Finalization is done in 6885 microseconds after epoch end.
Key Observations from the GC Logs
- GC Epoch Frequency: The XCFramework GC runs frequent epochs due to high object turnover. Compared to shared libraries, there are significantly more GC epochs (157 vs 31), suggesting a more responsive GC handling objects in short-lived scopes. See GC log for the shared library for comparison.
- Stable References and Roots: Since objects in Swift go out of scope naturally, stable references are kept to a minimum, which prevents the Kotlin GC from holding on to unreferenced objects, enhancing efficiency.
- Performance Impact: Frequent GC epochs show that although ARC handles disposals, Kotlin’s GC is still active and essential in memory management for stable references within the framework.
Summary
Using Kotlin Multiplatform’s XCFramework simplifies memory management in Apple projects by offloading much of the work to Swift’s ARC. This setup reduces the need for explicit disposal, enhancing code readability and reducing potential memory leaks. However, Kotlin Native’s garbage collector still plays an active role, particularly for stable references within the Kotlin framework.
In the next article, we’ll further investigate performance optimization for KMP GC handling in large-scale Swift applications.
Top comments (0)