J.BLOG

Rust shared core, part 3: keep UniFFI generated code behind an adapter

Jaemyeong Jin···7 min read
한국어

Calling common functionality written in Rust from iOS and Android needs a connecting layer on the Swift side and on the Kotlin side. One way is to build a C ABI and a wrapper per platform by hand. That is a reasonable choice for a small experiment, but I expect the maintenance burden to grow as the app gets larger and the API grows.

UniFFI is a tool that generates this layer. The tool does the generating, but it does not decide where the generated code sits inside the app. Part 3 is about choosing that place.

What UniFFI does and does not do

What UniFFI generates, as described in this section, comes from its documentation. The judgment that the risk of drift goes down, the account of what the tool leaves undecided, and the adapter structure and handling of generated files in the later sections are my own reading and design choices.

UniFFI produces Swift and Kotlin calling code from the Rust functions and types chosen for exposure. The generated code reaches the Rust library through a C-compatible FFI. After the API is changed, the code can be generated again, so a wrapper per language does not have to be maintained by hand. Both platforms end up using the same domain functions, so I expect the risk of rules drifting apart to go down. The Swift output and the Kotlin output are separate artifacts that come from the same Rust API.

The tool does not decide everything. Which functions to expose, what shape the DTOs take, how errors are represented, and how far the generated types are visible in the app are decided by the people building the app. Exposing Rust’s internal model as it is can make it awkward to use on mobile.

Generated code goes behind an adapter

If a ViewModel or a ViewController calls the generated API directly and the generated types spread through the whole app, every change to the Rust API may require changes to the UI on both platforms. So each platform gets a thin adapter.

iOS
  SudokuEngineClient protocol
    -> UniFFISudokuEngineClient
      -> generated Swift binding

Android
  SudokuEngineClient interface
    -> UniFFISudokuEngineClient
      -> generated Kotlin binding

The app uses only the protocol on iOS and the interface on Android. Only UniFFISudokuEngineClient touches the generated code. Generated types are not passed to the ViewModel. Swift passes its own DTOs or domain types, and Kotlin passes data classes.

With this structure, a fake SudokuEngineClient can be put into the unit tests of a ViewModel. Integration tests that really call Rust are set up separately.

Call size and types

I recommend handling one call per user action over calling many times on every render. The current state and the action go in, and the changed state comes back in one piece.

The API below is an example written with JSON strings going in and out.

start_game(request_json: String) -> String
apply_action(snapshot_json: String, action_json: String) -> String
validate_snapshot(snapshot_json: String) -> String

JSON is not mandatory. UniFFI records, enums, and sequences are an option too. As the exposed types get more complex, though, keeping the meaning identical in the two languages can become hard.

I prefer to start with simple DTOs and fixtures. The types are split more finely after measurement shows a performance problem. Making the FFI design complex first does not help if the real bottleneck is somewhere else. Numbers comparing the performance of the two approaches are not in this post.

What to look after on each platform

Using UniFFI does not make the two apps structurally the same. How things run inside the adapter is chosen to fit the runtime of that platform.

On iOS, Swift concurrency, MainActor, and state updates in UIKit and SwiftUI are taken into account. Calls that use a lot of CPU are better handled off the main thread. The adapter manages a background queue or an async boundary, and screen updates happen back on the main actor. Packaging the Rust output as an XCFramework comes in part 4.

ViewModel scope, the coroutine dispatcher, state updates in Compose, and the order of loading native libraries are the things to check on Android. If the Rust .so fails to load, an error can occur at app start or on the first call. Whether it is packaged correctly for each ABI has to be confirmed too. For the concepts of connecting Kotlin and native code, see the Android JNI tips.

What I generated and called in an example

I built this structure as a small example, and on October 4, 2026 I generated the bindings and called them. The workspace is small and only checks the structure. No real app is behind it. The environment is macOS 27.0.1, cargo 1.96.0, uniffi 0.29.5, and Swift 6.4. The binding crate only wraps and exports the functions of the core. Keeping the functions that parse and serialize JSON in the core crate as well is where the example departs from the design in part 2, to stay small.

uniffi::setup_scaffolding!();

#[uniffi::export]
pub fn start_game(request_json: String) -> String {
    sudoku_core::start_game_json(&request_json)
}

#[uniffi::export]
pub fn apply_action(snapshot_json: String, action_json: String, environment_json: String) -> String {
    sudoku_core::apply_action_json(&snapshot_json, &action_json, &environment_json)
}
// … validate_snapshot and generate_daily_puzzle have the same shape.

Unlike the API example above, apply_action takes three arguments. environment_json was added, following the contract in part 2. The bindings are generated from the built library. uniffi-bindgen is not installed separately. It is a binary in the same crate.

cargo build -q -p sudoku-uniffi
# …
cargo run -q -p sudoku-uniffi --bin uniffi-bindgen -- generate \
  --library target/debug/libsudoku_uniffi.dylib --language swift --out-dir out/swift
cargo run -q -p sudoku-uniffi --bin uniffi-bindgen -- generate \
  --library target/debug/libsudoku_uniffi.dylib --language kotlin --no-format --out-dir out/kotlin

--no-format is on the Kotlin side because ktlint is not on this Mac. Generating without the option printed a warning that ktlint could not be found. -q is the cargo option that reduces output. Four files were generated.

kotlin/uniffi/sudoku_uniffi/sudoku_uniffi.kt
swift/sudoku_uniffi.swift
swift/sudoku_uniffiFFI.h
swift/sudoku_uniffiFFI.modulemap

The Swift file was 514 lines and the Kotlin file 1107 lines. In Swift the function names become startGame(requestJson:), applyAction(snapshotJson:actionJson:environmentJson:), and so on. I compiled the generated Swift file together with the calling code using swiftc, linked the Rust library, and ran it on macOS. This is part of the calling code.

let env = #"{"locale":"ko-KR"}"#
let snapshot = startGame(requestJson: #"{"seed":0}"#)
// …
print("validate:", validateSnapshot(snapshotJson: snapshot))
// …
let conflict = applyAction(snapshotJson: snapshot, actionJson: #"{"type":"set_value","row":0,"col":2,"value":5}"#, environmentJson: env)
print("conflict:", conflict)

This is the output of that part.

validate: {"valid":true,"errors":[]}
conflict: {"ok":false,"snapshot":null,"effects":[],"error":{"code":"conflict","message":"the value conflicts with a row, column, or box"}}

That confirms a function called from Swift returning the result from Rust as a string. The run was on the macOS host, not on iOS, and no adapter or protocol was built.

Whether to commit the generated files

There are two options.

  • Commit the generated Swift and Kotlin files. They are easy to browse in the IDE, and a PR shows how the API changed. The large diff of generated code can be a burden in review.
  • Generate at build time. Rust stays the single source. The generation steps have to be reproduced on a fresh clone and in CI, and the build can become unstable when the UniFFI CLI or Rust toolchain versions differ across development environments.

Early in an app project, especially while the connection between iOS and Xcode has not settled, committing is worth considering. In that case it is good to record the generation command and have CI check that regenerating produces no diff. For a project centered on a library, or when the amount of change is a burden, generating at build time is worth considering. Neither is a policy that fits every project. Whichever is chosen, I recommend reproducing binding generation in CI.

How to manage generated files, along with the API boundary, the types, and the error representation, has to be decided per project. The options in this post are recommendations. What the example confirmed is generation and a Swift call on macOS. Compiling the Kotlin bindings and calling them on Android, and running on an iOS device, were not checked.

광고Coupang Partners

이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.