Swift Android Gradle Plugin Sample App

In my previous article, I introduced the Swift Android Gradle Plugin that simplifies integrating Swift code into Android projects. To demonstrate the plugin in action, I built a sample Android app that generates fractal images using Swift and renders them in Jetpack Compose.
What the App Does ๐ฑ
The app showcases a complete integration between Swift and Android by:
Computing fractal images using Swift code
Mapping fractal data to colors for each pixel
Passing coloring information across the native boundary via Java Native Interface (JNI)
Creating bitmap images and rendering them in Compose
Continuously animating a zoom into the fractal image

Project Structure ๐๏ธ
Here's an overview of the project layout:
App
โโโ build.gradle.kts
โโโ src
โโโ main
โโโ AndroidManifest.xml
โโโ java
โ โโโ Activity
โ โโโ StateHolder
โ โโโ Compose UI
โ โโโ Native lib definitions
โโโ res
โโโ swift
โโโ Package.swift
โโโ Sources
โโโ fractals.swift
โโโ lib.swift
The project follows the conventional Android app structure with a key addition: the Swift source set. Let me highlight the important components.
Key Components ๐
1. Native Library Interface ๐ช
I created a SwiftLibrary class that serves as the bridge between Kotlin and Swift. This class loads the native library and defines the methods we can call from our app:
class SwiftLibrary {
init {
System.loadLibrary("native-lib")
}
external fun captionFromSwift(): String
external fun generateFractal(
width: Int,
height: Int,
scale: Double,
cx: Double,
cy: Double
): DoubleArray
}
2. The Swift Source Set โก
The Swift source set contains all native Swift code and should be treated as a pure Swift Package Manager (SPM) package.
Package.swift
This manifest file defines what to build from which sources. Here we're building a dynamic library (.so) called native-lib:
// swift-tools-version: 6.3
import PackageDescription
let package = Package(
name: "native-lib",
products: [
.library(name: "Fractals", targets: ["Fractals"]),
.library(name: "native-lib", type: .dynamic, targets: ["Lib"])
],
targets: [
.target(name: "Lib", dependencies: ["Fractals"]),
.target(name: "Fractals")
]
)
Note: The package name
native-libmatches the name used to load the native library in our Kotlin code.
lib.swift
This file contains the JNI definitions that link to the external methods in SwiftLibrary:
import Android
@_cdecl("Java_com_charlesmuchene_sample_domain_SwiftLibrary_captionFromSwift")
public func captionFromSwift(
env: UnsafeMutablePointer<JNIEnv?>,
clazz: jclass
) -> jstring { ... }
@_cdecl("Java_com_charlesmuchene_sample_domain_SwiftLibrary_generateFractal")
public func generateFractal(
env: UnsafeMutablePointer<JNIEnv?>,
clazz: jclass,
...
) -> jdoubleArray { ... }
The Android module from the Swift SDK for Android provides the JNIEnv.
fractals.swift
This file contains pure Swift code for fractal generation, exposing the entry point function:
public func generateFractal(
width: Int,
height: Int,
scale: Double,
cx: Double,
cy: Double
) -> [Double] { ... }
With limited Swift support in Android Studio, I wrote the Swift code using Xcode, which offers better language support. Tooling is one area that will likely impact Swift for Android adoption. ๐

3. Project Configuration ๐ฝ๏ธ
The project is a standard Android app with one important additionโthe Swift Android Gradle Plugin:
// build.gradle.kts
plugins {
// android app/lib plugin must be applied first
id("com.charlesmuchene.swift-android-gradle-plugin") version "0.1.0-alpha"
}
// settings.gradle.kts
pluginManagement {
repositories {
// ...
}
// NOTE: Plugin is not published yet!!
// Clone and add plugin as an included build
includeBuild("../swift-android-gradle-plugin")
}
Generating the Fractal ๐งฎ
The Swift code generates the Mandelbrot Set, a set of complex numbers C for which the following does not diverge.
$$z_{n+1} = z_n^2 + c$$
This video provides an excellent introduction to the mathematics behind the set.
The algorithm works as follows:
For a given grid (width ร height), map every pixel to a complex number C
For each complex number C, calculate its escape count in the Mandelbrot set
Complex Number Representation ๐
The Mandelbrot set lives in the complex plane, so I created a struct to represent complex numbers:
internal struct Complex: Sendable {
var real: Double
var imag: Double
func squared() -> Complex {
let newReal = real * real - imag * imag
let newImag = 2.0 * real * imag
return Complex(real: newReal, imag: newImag)
}
var magnitudeSquared: Double {
return real * real + imag * imag
}
}
The Generation Algorithm ๐ฟ
The core algorithm iterates through each pixel, mapping it to a complex number and calculating its escape count:
func generateMandelbrotGrid(...) async -> [[Double]] {
var hueGrid: [[Double]] = ...
for y in 0..<height {
for x in 0..<width {
let c = mapPixelToComplex(...)
let count = mandelbrotEscapeCount(c: c, maxIterations: maxIterations)
let colorHue = strategy.colorIndex(forCount: count)
hueGrid[y][x] = colorHue
}
}
return hueGrid
}
Coloring Strategies ๐จ
To visualize the fractal, I map each escape count to a color hue. I created a protocol-based system that allows different coloring strategies to be plugged in for various visual effects:
protocol MandelbrotColoringStrategy: Sendable {
var maxIterations: Int { get }
/// Maps the escape count (Int) to a color index (e.g., Hue: 0.0 to 1.0)
func colorIndex(forCount count: Int) -> Double
}
struct DiscreteColoringStrategy: MandelbrotColoringStrategy { ... }
struct ContinuousColoringStrategy: MandelbrotColoringStrategy { ... }
struct InsideColoringStrategy: MandelbrotColoringStrategy { ... }
The demo video uses DiscreteColoringStrategy with 50 iterations. This lower iteration count enables faster rendering during zoom animations, though it results in lower fractal resolution.
Data Transfer via JNI ๐
Data serialization from native code to the Android runtime is the biggest bottleneck in our app. The Mandelbrot generation produces a 2D array of doubles representing escape counts mapped to colors. Passing 2D arrays across JNI is complex and slow.
To improve efficiency, I flatten the array into a single contiguous double array in row-major order:
fileprivate func prepareDataForJNI(grid: [[Double]]) -> [Double] {
grid.flatMap { $0 }
}
The Kotlin code receives this flat array, converts from HSV to RGB, and uses the colors to construct the image.
Future Optimization: JEP 454: Foreign Function & Memory API was introduced in Java 22. This API enables Java programs to call native libraries and process native data without JNI's brittleness. When Android catches up to Java 22, we could access Mandelbrot data in off-heap memory directly from the app, eliminating this optimization dance.
Swift Concurrency and a 'Recursive Code Fractal' ๐ซจ
The generateMandelbrotGrid function uses Swift's structured concurrency to perform CPU-intensive tasks efficiently. It divides the large problem into smaller, independent tasks (calculating each row), then uses a TaskGroup to solve them in parallel:
func generateMandelbrotGrid(...) async -> [[Double]] {
var hueGrid: [[Double]] = ...
await withTaskGroup(of: (Int, [Double]).self) { group in
for y in 0..<height {
group.addTask { [capture list] in
var hueRow = Array(repeating: 0.0, count: width)
for x in 0..<width {
let c = mapPixelToComplex(...)
let count = mandelbrotEscapeCount(c: c, maxIterations: maxIterations)
hueRow[x] = strategy.colorIndex(forCount: count)
}
return (y, hueRow)
}
}
for await (y, row) in group {
hueGrid[y] = row
}
}
return hueGrid
}
The async keyword signals that the function performs asynchronous work and can pause without blocking threads. The await withTaskGroup(...) pauses execution until all child tasks complete. Each task produces a tuple (Int, [Double]) containing the row index and calculated pixel data.
The Swift runtime schedules tasks to run concurrently across available CPU cores, significantly speeding up the process compared to sequential loops. The for await (y, row) in group loop receives results as tasks finish, and structured concurrency guarantees all tasks complete before proceeding.
The Concurrency Challenge ๐งฉ
Everything worked smoothly until implementing the caller function, generateFractal. This function receives JNI input, creates a coloring strategy, generates the Mandelbrot grid, and returns data to the JNI call.
My initial implementation launched the generator in a Task and used a semaphore to block the thread:
var result: [Double] = []
let semaphore = DispatchSemaphore(value: 0)
Task { [capture list] in
let renderedGrid = await generateMandelbrotGrid(...)
result = prepareDataForJNI(grid: renderedGrid)
semaphore.signal()
}
semaphore.wait()
return result
This failed with a compiler error:
error: sending value of non-Sendable type '() async -> ()' risks causing data races
The problem? A data race. The Task's closure and the generateFractal function can access the result array concurrently, which is unsafe in Swift's concurrency model. The DispatchSemaphore waits for the Task, but doesn't protect the shared result variable from concurrent access.
The Actor Solution ๐ญ
To protect shared mutable state, I used an Actor to safely manage state and bridge the concurrent and synchronous worlds. Actors serialize access to internal state, guaranteeing only one piece of code can modify the result at a time:
actor ResultHolder<T: Sendable> {
var value: T?
func setResult(_ newValue: T) {
self.value = newValue
}
func getResultOrDefault(_ defaultValue: T) -> T {
self.value ?? defaultValue
}
}
Refactoring to use the Actor:
let resultHolder = ResultHolder<[Double]>()
let semaphore = DispatchSemaphore(value: 0)
Task { [capture list] in
let renderedGrid = await generateMandelbrotGrid(...)
let data = prepareDataForJNI(grid: renderedGrid)
await resultHolder.setResult(data)
semaphore.signal()
}
semaphore.wait()
return resultHolder.getResultOrDefault([])
This resolved the data race but introduced a new issue: we must await the call to getResultOrDefault too. The async issue had just moved further down! ๐ฅบ
To resolve this, I created a runBlocking function that allows retrieving the result from the actor synchronously. Interestingly, this function has a similar structure to the logic in generateFractalโa recursive code fractal! ๐ซจ
func runBlocking<T: Sendable>(operation: @escaping @Sendable () async -> T) -> T {
var result: T?
let semaphore = DispatchSemaphore(value: 0)
Task {
result = await operation()
semaphore.signal()
}
semaphore.wait()
return result! // Safe to force-unwrap: semaphore guarantees result is set
}
The final generateFractal implementation:
let resultHolder = ResultHolder<[Double]>()
let semaphore = DispatchSemaphore(value: 0)
Task { [capture list] in
let renderedGrid = await generateMandelbrotGrid(...)
let data = prepareDataForJNI(grid: renderedGrid)
await resultHolder.setResult(data)
semaphore.signal()
}
semaphore.wait()
return runBlocking {
await resultHolder.getResultOrDefault([])
}
Kotlin Concurrency: Choosing the Right Dispatcher ๐ซ
At first glance, Dispatchers.Default seems like the right choice since we're performing CPU-bound work. However, from the Kotlin perspective, we're invoking native methods (JNI calls) that block the calling JVM thread while native code executes.
Dispatchers.IO is designed specifically for blocking operations and has ideal behavior for our use case:
Uses an on-demand, uncapped (or very large) pool of worker threads
When a thread is blocked by a native call, a new thread can be created to handle other IO tasks, preventing starvation of the Default pool
Ensures native Swift code execution doesn't unnecessarily delay other parts of the application
Animating the Fractal ๐ซ
After the first UI composition, we request the initial fractal image at the default scale. This calls through SwiftLibrary, suspends until the native code responds with the flattened hue array, then maps hue values to RGB and creates a bitmap:
fun createImage(hueArray: DoubleArray, width: Int, height: Int): ImageBitmap {
val bitmap = createBitmap(width, height)
for (y in 0 until height) {
for (x in 0 until width) {
val index = y * width + x
val hue = hueArray[index].toFloat()
val color = Color.hsv(hue * 360f, 1.0f, 1.0f)
bitmap[x, y] = color.toArgb()
}
}
return bitmap.asImageBitmap()
}
When the image updates in Compose, we animate a float value that updates the image scale, creating a zoom effect. When the animation finishes, we request the next fractal image from the native layer at the new scale. The native layer generates the hue array at the given scale and returns the image, creating a continuous loop.
Conclusion ๐ฎโ๐จ
This project was both fun and challenging to create. Juggling Compose, app logic, and Swift code is certainly demanding. However, the project showcases an architecture close to production-ready, using modern Android components driven by logic living in native Swift code.
I'm curious to see how adoption of Swift SDK for Android evolves. You now have the SDK, the Swift Android Gradle Plugin, and this sample project to learn from. Go build something amazing.
Happy coding! ๐




