Docs › Mobile builds: Xcode, Gradle, Flutter and React Native
Mobile builds: Xcode, Gradle, Flutter and React Native
Xcode and Gradle builds are the heaviest jobs a mobile developer runs all day. sparehand run sends them to a worker that has the toolchain, keeps the build folders there so the second build is incremental, and brings the app, the APK and the test reports back:
sparehand run xcodebuild -scheme MyApp -sdk iphonesimulator build # on a Mac worker with Xcode
sparehand run ./gradlew assembleDebug testDebugUnitTest # on a worker with a JDK and the Android SDK
sparehand emulator test # instrumented tests on an emulator, on a worker
Nothing is added to the command. Sparehand reads from it, and from the checkout, what the job needs.
Which worker gets the job
| The command has | The job needs | So it goes to |
|---|---|---|
xcodebuild, xcrun, xcodegen | Xcode | a Mac whose Xcode has finished its first launch |
swift build, swift test, swift run, swift package | Swift | a machine with Swift (a Mac with Xcode, or Linux with a Swift toolchain) |
./gradlew, gradle | a JDK, and the Android SDK when the project has an AndroidManifest.xml | a machine with both |
flutter … | Flutter | a machine with Flutter |
flutter build apk, appbundle, aar | Flutter, a JDK, the Android SDK | |
flutter build ios, ipa, macos | Flutter, Xcode | a Mac |
react-native run-android, build-android, expo run:android | a JDK, the Android SDK | |
react-native run-ios, build-ios, expo run:ios | Xcode | a Mac |
pod install, pod update | Xcode, CocoaPods | a Mac with CocoaPods |
The command is read as text, so sh -c 'xcodebuild … | xcbeautify' counts too. A script of your own (npm run ios, make release, fastlane …) can't be read: say what it needs with --needs:
sparehand run --needs xcode npm run build:ios
sparehand run --needs jdk,android ./scripts/build-release.sh
sparehand run --needs - ./gradlew help # "-": place it like any other job
The words are xcode, swift, jdk, android, flutter and pods. SPAREHAND_NEEDS does the same for every run of a shell.
A worker that lacks what the job needs is left out, and Sparehand says why:
[sparehand] → MacBook (Xcode 27.2; CPU 8% busy of 12 cores, 78% memory free, 0 other jobs)
[sparehand] not chosen: Laptop has no Xcode (it isn't a Mac)
When no worker has it, the answer is the usual "run it locally" (exit 255, or the command runs on the hub at a terminal), with each worker's reason.
The Xcode version. A project that needs one says so in a .xcode-version file at its top (27.2), or you set SPAREHAND_XCODE=27. A Mac with another version is left out. 27 is met by any 27.x, 27.2 by 27.2 and 27.2.1. Without either, any Xcode will do.
What each worker has is in sparehand doctor:
MacBook: ok, …; for mobile builds: Xcode 27.2, Swift 6.4, JDK 21, the Android SDK (API 37); no signing identity: simulator and unsigned builds only; …
Sparehand asks a worker once and keeps the answer for ten minutes (SPAREHAND_TOOLCHAIN_TTL). A "no" is asked again after a minute, and doctor always asks.
Setting a worker up
- Xcode: install it on the Mac and finish its first launch, which needs an administrator once:
sudo xcodebuild -runFirstLaunch. Until thenxcodebuildfails there anddoctorsays "Xcode isn't set up yet". For simulator tests, alsoxcodebuild -downloadPlatform iOS. - A JDK and the Android SDK:
sparehand emulator setup WORKERinstalls both in the user's home without sudo (see Android emulators). A JDK or SDK that is already there is found too:JAVA_HOME, thejavaon the PATH,~/.local/jdk;ANDROID_HOME,~/Library/Android/sdk,~/Android/Sdk. The job getsJAVA_HOME,ANDROID_HOMEandANDROID_SDK_ROOTset, so./gradlewneeds nolocal.properties. An SDK without the platform the project compiles against works as long as its licences are accepted: the Android Gradle plugin downloads the platform and build tools on the first build. - Flutter: on the PATH, or in
~/flutter,~/development/flutteror~/fvm/default. - CocoaPods:
podon the PATH. WhenPodfile.lock(at the top, inios/or inmacos/) isn't whatPods/was installed from, the job runspod installfirst. Flutter does that itself.
Anything else a project needs on the worker goes in its hook (~/.config/sparehand/hooks/REPO.sh, see Configuration); the hook runs after all of this and can override it.
Build caches
A second build is fast only when the first one's work is still there. On the worker it is:
- In the checkout's folder: Gradle's
build/and.gradle/, SwiftPM's.build/,Pods/, Flutter's.dart_tool/andbuild/. The sync leaves them alone as long as git ignores them, which every project template does. (Before, an ignore rule written without a trailing slash, as Android Studio writes/build, wasn't recognised, and each run built from nothing.) - Xcode's DerivedData for the checkout, in its usual place (
~/Library/Developer/Xcode/DerivedData). Each checkout has its own folder on the worker, so it has its own DerivedData too. - Shared by every checkout on that worker: Gradle's downloads and build cache (
~/.gradle), SwiftPM's and CocoaPods' caches, Flutter's packages. A new git worktree downloads nothing again. - Gradle's build cache is switched on for jobs (
org.gradle.caching), so a new checkout or worktree of a project reuses what another one built instead of compiling it again.SPAREHAND_GRADLE_CACHE=0leaves it as the project has it. Gradle's workers are also held to the CPUs the worker gives a job (sparehand set NAME cpus N).
sparehand clean says how much they take, and removes a checkout's caches with its run folder (DerivedData included) once the checkout is gone or hasn't been used for SPAREHAND_KEEP_DAYS:
MacBook: build caches of 9 checkouts take 2.8 GB (the largest: my-app-1a2b3c/my-app 563 MB, …); each goes with its run folder
MacBook: caches every checkout shares: Gradle 730 MB (~/.gradle), SwiftPM 63 MB (~/Library/Caches/org.swift.swiftpm)
Sparehand never removes the shared ones; they are the tools' own.
What comes back
After a mobile job, whatever its exit code, the standard outputs come back by themselves:
| Job | What | Where on the hub |
|---|---|---|
| Gradle (Android, Flutter, React Native) | build/outputs/apk, bundle, aar, androidTest-results; build/reports (tests, lint); build/test-results (JUnit XML), of every module | the same paths |
| Flutter | build/app/outputs/flutter-apk, apk, bundle; build/ios/ipa | the same paths |
| Xcode | the .app bundles and .xcresult test results the job made | build/sparehand/, where the command ran: build/sparehand/Debug-iphonesimulator/MyApp.app, build/sparehand/Test-MyApp-….xcresult |
[sparehand] got app/build/outputs/apk, app/build/reports, app/build/test-results from Laptop
Only these folders are taken, each replaced whole. Anything else (an .xcarchive, a -exportPath, a coverage folder) comes back with --get, as for any job:
sparehand run --get build/MyApp.xcarchive xcodebuild -scheme MyApp -archivePath build/MyApp.xcarchive archive CODE_SIGNING_ALLOWED=NO
SPAREHAND_OUT_DIR moves build/sparehand; add it to .gitignore if build/ isn't there already. SPAREHAND_OUTPUTS=0 switches the whole thing off.
Instrumented tests on an emulator
sparehand emulator test # ./gradlew connectedDebugAndroidTest
sparehand emulator test Pixel_9 --api 37 -- ./gradlew :app:connectedDebugAndroidTest
sparehand emulator test -- flutter test integration_test
One command boots an emulator on a free worker (the rules and options of sparehand emulator start: AVD, --on, --ram, --api), runs the test command on that same worker with ANDROID_SERIAL set to that emulator, brings the reports back (app/build/reports/androidTests, app/build/outputs/androidTest-results) and stops the emulator, also when the tests fail or you press Ctrl-C. Its exit code is the tests' own; 255 means no worker could boot the emulator or take the job.
The tests talk to the emulator on the worker itself, so nothing travels through the hub. To drive an emulator from the hub instead (your own adb, an agent that takes screenshots), use sparehand emulator start.
Limits
- Code signing. A worker has no signing identity: its keychain is locked for an SSH login, and your certificates and profiles should stay on your own Mac. So on a worker: simulator builds, unsigned builds (
CODE_SIGNING_ALLOWED=NO), tests, and Android debug builds (the debug key is made on the spot). Sparehand says so before anarchive,-exportArchiveorflutter build ipastarts on such a worker. Sign, export and upload on the hub: bring the unsigned archive back with--get, or run the release command withoutsparehand run. Android release signing works when the keystore is in the checkout and its passwords reach the job (SPAREHAND_PASS_ENV); a keystore git ignores stays on the hub unless you send it with--with. - Real devices. A phone plugged into the hub is not visible to a worker. Build on the worker, then install from the hub:
adb install app/build/outputs/apk/debug/app-debug.apk, orxcrun devicectl device install app.react-native run-ios,run-androidandflutter runwant a device or a simulator where they run, so run those on the hub and send only the build. - iOS simulator tests (
xcodebuild test -destination 'platform=iOS Simulator,…') run headless on a Mac worker that has a simulator runtime. Sparehand doesn't boot or share simulators the way it does Android emulators;xcodebuildboots its own. A Mac takes one job at a time by default (sparehand set NAME jobs N), which keeps two test runs off the same simulator. - One Xcode per Mac. Sparehand uses the Xcode that
xcode-selectpoints to and doesn't switch between several. - Windows. A Windows worker runs jobs in WSL, so Gradle and Flutter's Android builds work there and Windows-only tools don't.
Settings
| Setting | Default | Meaning |
|---|---|---|
SPAREHAND_NEEDS | auto | What a job needs: read from the command, a list (xcode,jdk), or - for nothing. run --needs sets it for one run. |
SPAREHAND_XCODE | The Xcode version an Xcode job needs (27, 27.2). Empty: the checkout's .xcode-version, else any. | |
SPAREHAND_OUTPUTS | 1 | 0: nothing comes back by itself. |
SPAREHAND_OUT_DIR | build/sparehand | Where an Xcode job's apps and test results land, relative to where the command ran. |
SPAREHAND_GRADLE_CACHE | 1 | 0: Gradle's build cache isn't switched on for jobs. |
SPAREHAND_TOOLCHAIN_TTL | 600 | Seconds a worker's answer about its toolchains is kept. |
For a coding agent, one line in its instructions does it: "Builds and tests go through sparehand run (xcodebuild, ./gradlew, flutter); instrumented Android tests through sparehand emulator test; on exit 255 run the same command here."