Rust shared core, part 4: delivering it to iOS as an XCFramework

Jaemyeong Jin···7 min read
한국어

Using a shared Rust core in an iOS app takes more than passing tests on the Rust side. That is not a guarantee that the app links. Results can differ between the simulator and a device, or between a development machine and CI, and the binary checksum of a Swift package can fail to match.

Part 4 goes through delivering the Rust binary and the Swift calling layer to an Apple app, based on the Apple Developer, Rust Reference, rustc, and UniFFI documentation. I reopened and checked these documents on October 4, 2026.

Bundle the static libraries into an XCFramework

The crate types for Rust output that another language links against include staticlib and cdylib (linkage in the Rust Reference). On iOS, the approach is to package a static library as an XCFramework.

The device build and the simulator build are made separately, the headers and the module map are prepared, and xcodebuild -create-xcframework combines them. Written out conceptually, the flow is this.

cargo build --target aarch64-apple-ios
cargo build --target aarch64-apple-ios-sim

libdomain_core.a
headers/
module.modulemap

-> DomainCore.xcframework

The supported targets are listed in rustc’s Apple iOS platform notes. The block above is not commands that were run with their output. It is a picture of how the inputs relate to the artifact.

An XCFramework can hold a variant per platform and architecture in one bundle. iOS devices, the simulator, macOS, and visionOS are examples. When it is used as a binary dependency of an Xcode project or a Swift package, Xcode can choose the slice that matches the build destination.

Reproducing the build needs matching conditions. I recommend making the installed targets, the toolchain version, the profile, the output location, and the place where headers are generated the same on development machines and in CI. I also recommend putting target installation and packaging into a script. The values of the environment in which I built the example are in the section “An XCFramework bundled from an example.”

The headers, the module map, and the generated Swift bindings have to match each other. If the code UniFFI produced and the Rust library are from different versions, linking can fail or symbol problems can appear at run time. So I suggest managing the two generation steps as one release unit.

An XCFramework bundled from an example

I ran the flow above for real with a small example. The example is deliberately small. Its job is to check the structure, and it is not a real app. The run was on October 4, 2026, on macOS 27.0.1 with Xcode 27.0, cargo 1.96.0, and uniffi 0.29.5. The Rust targets aarch64-apple-ios and aarch64-apple-ios-sim were already installed. The crate-type of the binding crate includes staticlib.

For the header and the module map, I used what UniFFI generated together with the Swift bindings. The generated sudoku_uniffiFFI.modulemap was copied under the name module.modulemap. Below are the lines of the script that was run which belong to this step. The sed at the end shortens the absolute path printed in the output, and out/host is the directory used for the Swift call in part 3.

mkdir -p out/headers out/host
cp out/swift/sudoku_uniffiFFI.h out/headers/
cp out/swift/sudoku_uniffiFFI.modulemap out/headers/module.modulemap
# …
cargo build -q -p sudoku-uniffi --release --target aarch64-apple-ios
cargo build -q -p sudoku-uniffi --release --target aarch64-apple-ios-sim
xcodebuild -create-xcframework \
  -library target/aarch64-apple-ios/release/libsudoku_uniffi.a -headers out/headers \
  -library target/aarch64-apple-ios-sim/release/libsudoku_uniffi.a -headers out/headers \
  -output out/SudokuCore.xcframework | sed 's#: .*/out/#: out/#'

This is the output and the directory that was produced.

xcframework successfully written out to: out/SudokuCore.xcframework
SudokuCore.xcframework
SudokuCore.xcframework/Info.plist
SudokuCore.xcframework/ios-arm64
SudokuCore.xcframework/ios-arm64-simulator
SudokuCore.xcframework/ios-arm64-simulator/Headers
SudokuCore.xcframework/ios-arm64-simulator/libsudoku_uniffi.a
SudokuCore.xcframework/ios-arm64/Headers
SudokuCore.xcframework/ios-arm64/libsudoku_uniffi.a

Info.plist held two entries, ios-arm64 and ios-arm64-simulator. Each static library was about 31 MB. That is the size of a release build that was not stripped.

That is as far as the check went. I went one step further to see whether this XCFramework can really be consumed. I made a Swift package that points at the XCFramework with a binaryTarget and puts the generated Swift bindings in a target, and built it with xcodebuild build for the iOS simulator and for a device. Both succeeded. The module map generated by UniFFI was used unchanged. That is the result on Xcode 27.0, and other Xcode versions need their own check. Running an app in the simulator and xcodebuild test were not tried. Calling Rust through the generated Swift bindings was confirmed on macOS only, and that record is in part 3.

Delivering through a Swift package

What an XCFramework holds, how xcodebuild -create-xcframework is used, and Rust’s crate types and targets are documented. Preparing a module map, Xcode choosing a slice, and the symptoms of a version mismatch were not found in the documents checked this time. The scripting and the single release unit recommended in the previous section, and the package layout and CI practice from this section on, are not decided by the documentation. They are my own design choices.

With a single app, the XCFramework can be added to the project directly. With several apps, or with samples and test targets, I suggest using a Swift package. The binary target and the target for the Swift adapter can be separated.

DomainEnginePackage/
  Package.swift
  Sources/
    DomainEngine/
      DomainEngineClient.swift
      UniFFIAdapter.swift
      GeneratedBindings.swift
  Artifacts/
    DomainCore.xcframework

The point of contact exposed to the app is DomainEngineClient. The generated code and the binary are treated as internal implementation. That reduces what the app has to know about the Rust connection, and the dependencies are clearer than piling up scripts in the project’s build phases.

Distribution has decisions of its own: where to keep the artifact, how to manage the checksum of a remote ZIP, and how to match the version of the source package to the version of the binary. For internal use only, one way is to start with the artifact inside the repository and move it to release files once things are stable.

Making local builds and CI follow the same order

For integration on the Apple side, I think managing the Swift layer, the package, the build phases, and CI takes more of the work than calling Rust functions does.

A local machine can still have targets that were installed earlier and artifacts that were built before. CI is assumed to start from a fresh clone. Different paths cause problems as well. So I recommend managing the inputs and the order in one place.

install/check rust targets
cargo build for ios device
cargo build for ios simulator
generate UniFFI Swift bindings
generate headers/module map
create XCFramework
run xcodebuild test

This list names the steps to put into automation, not a shell script that runs as written.

Compiling Rust in a build phase every time can lengthen the edit-and-run cycle during development. I recommend setting inputs, outputs, and a cache locally, and running CI mainly as clean builds. A measured time for how much longer it gets is not in this post.

A setup that does not commit generated files adds one more condition. The required artifacts have to be generated automatically in the process of cloning fresh, opening the Xcode project, and building. Otherwise the first build of a newcomer fails.

Put the UniFFI implementation behind a Swift protocol

Using the API generated by UniFFI directly in the app is an easy start. Over time, the range the app depends on can widen. So the UniFFI implementation goes behind a Swift protocol. This is the shape of the protocol I propose.

protocol SudokuEngineClient {
    func startGame(request: StartGameRequest) throws -> GameSnapshot
    func apply(_ action: GameAction, to snapshot: GameSnapshot) throws -> Transition
}

The ViewModel depends on this protocol, and the generated API is used only inside the adapter. Heavy Rust work can be wrapped in an async API in the adapter, with UI updates handled on the MainActor. With a fake client, the ViewModel can be tested by itself. Delivering the same structure to Android as a .so (ABI, Gradle, the NDK, and the 16 KB page size) is in part 5.

Three things are worth confirming. Build for both a device and the simulator. See that local builds and CI use the same commands. See that an Xcode build works from a fresh clone. Of these, the example covered building for a device and for the simulator. I also copied only the sources to a fresh directory and ran the same script from the start, and it passed to the end. Running in CI and building an Xcode project were not checked.

광고Coupang Partners

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