Introducing the Zenmanage iOS SDK
The Zenmanage iOS SDK is a native Swift package that evaluates flags on the device, caches rules in UserDefaults, and keeps percentage rollouts stable across launches.
UserDefaults, so a cold launch with no signal still evaluates against the last rules it saw.
Shipping a web feature is a deploy. Shipping an iOS feature is a submission. You build, you wait for App Store review, and then you wait again while people get around to updating. Some never do. The version you shipped eight months ago is still in someone's pocket, running whatever logic you compiled into it.
That makes a flag more than a convenience on iOS. Once the binary is out, it's the lever you have left. A checkout redesign that misbehaves can't be pulled back, but a flag can be turned off in the time it takes to open the dashboard. Two things make flags harder on a phone than on the web, though. The network is unreliable in ways a server never is, and anything you put in the binary is readable by anyone who downloads it. The iOS SDK is built around both.
What it is
zenmanage-swift is a Swift package with an async/await API. It fetches your rules once and evaluates them on the device, the same local-evaluation model as our JavaScript and Java SDKs, so reading a flag never waits on a per-flag network call. It supports iOS 13 and later and needs Xcode 15 and Swift 5.9. If part of your app is still Objective-C, the package includes a bridge (ZMObjCZenmanage and its companions), so a mixed-language app can import the same package.
Getting started
Add the package to your Package.swift, then depend on the Zenmanage product:
dependencies: [
.package(url: "https://github.com/zenmanage/zenmanage-swift.git", from: "1.0.0")
],
targets: [
.target(
name: "YourApp",
dependencies: [
.product(name: "Zenmanage", package: "zenmanage-swift")
]
)
]
In Xcode, choose File → Add Package Dependencies, paste https://github.com/zenmanage/zenmanage-swift.git, and add the Zenmanage library to your app target. Swift Package Manager is the only supported way to install the SDK. There's no CocoaPods podspec.
You'll use a mobile key (mob_) or a client key (cli_) from your environment. A server key (srv_) throws when you create the SDK. A key in an app bundle can be extracted by anyone with the binary, and a server key can do far more than read flags, so the SDK won't accept one.
Read a flag
Create one Zenmanage when the app launches and pass it down. It keeps the loaded rules in memory, so build it once instead of per screen. This view shows the new checkout when the flag is on:
import SwiftUI
import Zenmanage
@main
struct ShopApp: App {
private let zenmanage: Zenmanage
init() {
// A srv_ key throws here, and you'll see it on the first run.
zenmanage = try! Zenmanage(environmentToken: "mob_your_mobile_key_here")
}
var body: some Scene {
WindowGroup {
CheckoutScreen(zenmanage: zenmanage)
}
}
}
struct CheckoutScreen: View {
let zenmanage: Zenmanage
@State private var useNewCheckout = false
var body: some View {
Group {
if useNewCheckout {
NewCheckoutView()
} else {
LegacyCheckoutView()
}
}
.task {
let flag = try? await zenmanage.flags().single("new-checkout", defaultValue: false)
useNewCheckout = flag?.isEnabled() ?? false
}
}
}
The default you pass to single is what you get when the flag doesn't exist or the rules can't be loaded, such as an offline device with nothing cached. A flag outage shows your users the old behavior, not an error. The first frame draws with that default, and the real value lands as soon as the read returns, which with a warm cache doesn't wait on the network. .task needs iOS 15. The SDK itself goes back to iOS 13, so on older targets read the flag from wherever you start async work.
Phased rollouts that stay phased
On iOS, a rollout is risk management. You can't hotfix a bad release, so you want a small group to find the problem before everyone else does. You set the percentage on the flag in the dashboard, and the code that reads it doesn't change as you move from 1% to 5% to 50%. What the app needs to supply is an identifier:
let context = Context.single(type: "user", identifier: userID)
let flag = try await zenmanage.flags()
.withContext(context)
.single("new-checkout", defaultValue: false)
if flag.isEnabled() {
showNewCheckout()
}
The SDK hashes the rollout's salt and that identifier with CRC32B into a bucket from 0 to 99, and the user is in the rollout when their bucket falls below the percentage. The same identifier and salt always give the same bucket. A user stays in the rollout across launches, and raising the percentage only adds people. Our other SDKs hash the same way, so the user who got the new checkout on their phone gets it on your web app too. We wrote up how that stability works separately, and the progressive rollouts post covers the stages themselves.
Try a UI variant on some users
A flag doesn't have to be a boolean. Make it a string, give each layout a value, and let the value pick the screen. Attributes on the context let your rules target by plan, country, or anything else you pass:
let context = Context(
type: "user",
name: "Taylor",
identifier: userID,
attributes: [
Attribute("country", values: ["US"]),
Attribute("plan", values: ["pro"]),
]
)
let variant = try await zenmanage.flags()
.withContext(context)
.single("checkout-layout", defaultValue: "control")
switch variant.asString() {
case "one-page":
showOnePageCheckout()
default:
showMultiStepCheckout()
}
Zenmanage decides who sees which layout. It doesn't analyze the result. Percentage rollouts are a release control, not an experiment platform, so we don't compute significance for you. Send the variant to your analytics and judge the outcome there. Always give single a sensible default, because that is what a user sees when the app can't reach the rules.
Rules that survive a cold launch
A phone launches in places a server never runs: a subway platform, a hotel with a captive portal, airplane mode. The SDK keeps the rules in UserDefaults for an hour by default, so here's what happens:
- On the first read, the SDK fetches your rules, holds them in memory, and writes them to
UserDefaults. - On the next cold launch it reads the rules from
UserDefaultsand evaluates them with no rules fetch at all, until the cache expires. - If the cache has expired and the network is down, the read falls back to the default you passed.
Change the lifetime with cacheTTL, in seconds. UserDefaultsRuleCache() is the default. InMemoryRuleCache() caches only until the app quits, and NullRuleCache() turns caching off:
let zenmanage = try Zenmanage(
environmentToken: "mob_your_mobile_key_here",
cacheTTL: 900,
cache: UserDefaultsRuleCache(),
requestTimeout: 3
)
// Fetch fresh rules now instead of waiting for expiry.
try await zenmanage.flags().refreshRules()
The cache holds rules, and the SDK still reports flag usage over the network when it can. That report is fire-and-forget, so it never blocks a read.
Three things to know before you ship
A change reaches a running app when it refreshes
The SDK loads rules once per launch and then evaluates from memory. If you publish a change in the dashboard, a running app keeps its old rules until you call refreshRules(), and a freshly launched app picks the change up once its cache expires. If you need a kill switch to reach people who already have the app open, call refreshRules() when the app returns to the foreground.
Rollouts need an identifier
A context with no identifier is never in a percentage rollout, and that includes the anonymous one you get when you skip withContext. Pass something that stays the same for a person, like a user ID. A value that changes every launch, like a fresh UUID(), puts the same person in and out of the rollout from one session to the next.
A dead network can make a read wait
With no cached rules and a network that never answers, a read retries before it gives up and returns your default. At the default 10-second requestTimeout that can add up to about a minute. If you read flags on the launch path, set requestTimeout lower, as in the example above, and your users reach the default sooner.
What's next
zenmanage-swift 1.0.0 is out now. The source is on GitHub, with runnable examples for rollouts, A/B testing, caching, and JSON flags, and tagged versions are on the releases page. The iOS quickstart walks through setup end to end, and the SDK overview lists every language we support.
If something in the package gets in your way, tell us at hello@zenmanage.com or open an issue on GitHub.