Skip to main content

Command Palette

Search for a command to run...

Swift Android Gradle Plugin Sample App

Updated
โ€ข9 min read
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:

  1. Computing fractal images using Swift code

  2. Mapping fractal data to colors for each pixel

  3. Passing coloring information across the native boundary via Java Native Interface (JNI)

  4. Creating bitmap images and rendering them in Compose

  5. 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-lib matches 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:

  1. For a given grid (width ร— height), map every pixel to a complex number C

  2. 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! ๐Ÿ˜Ž

Resources ๐Ÿšš