iOS SDK
Feature flags for iOS and macOS in Swift, evaluated by Abrova Flags for the current user and cached on the device. Typed getters, anonymous id, offline cache. Public API key only.
Uses the public API key only. Safe to ship in an app
The app receives values for this user. Targeting rules never reach the device
getBoolean, getString, getNumber and getJson, each with a default
The last values are stored in UserDefaults and served at start and offline
Percentage rollouts also work before sign-in
iOS 14+ and macOS 12+
This page describes the AbrovaFlags Swift package 0.2.0. It is a breaking release: flags are evaluated by the service instead of on the device, and the getters changed. Coming from 0.1.x? See Upgrading from 0.1.x at the end of this page.
iOS 14 or macOS 12. Swift 5.9.
Installation#
Install with Swift Package Manager. In Xcode: File > Add Package Dependencies and enter:
https://github.com/Abrova-co/abrova-ios-flags-sdk.git
Or add it to your Package.swift:
dependencies: [
.package(url: "https://github.com/Abrova-co/abrova-ios-flags-sdk.git", from: "0.2.0")
]Initialization#
Create one client for the app and call start(). Use your public API key (ab_live_...).
import AbrovaFlags
let flags = AbrovaFlags(config: AbrovaFlagsConfig(
apiKey: "ab_live_xxx",
environment: "production" // nil uses the Default values
))
flags.start()start() returns immediately. It serves the values cached on the device and fetches fresh ones in the background. There is no shared instance in 0.2.0: keep the client you created.
To wait for the first values, for example on a launch screen:
let ready = await flags.waitUntilReady(timeout: 3) // false when the time ran out
Read flags#
Getters never block and never throw. Each one takes the default to use when the flag is unknown, when no values are available yet, or when the flag has another type.
if flags.isEnabled("new-checkout") {
// Show the new checkout
}
let dark = flags.getBoolean("dark-mode", default: false)
let theme = flags.getString("theme", default: "light")
let maxItems = flags.getNumber("max-items", default: 10)
let layout = flags.getJson("home-layout", default: ["columns": 2])A json flag can also be decoded into your own type:
struct HomeLayout: Decodable {
let columns: Int
}
let layout = flags.getJson("home-layout", as: HomeLayout.self, default: HomeLayout(columns: 2))| Method | Returns |
|---|---|
| isEnabled(_:default:) | true when the flag is served on for this user. The default is false |
| getBoolean(_:default:) | Value of a boolean flag |
| getString(_:default:) | Value of a string flag |
| getNumber(_:default:) | Value of a number flag, as Double |
| getJson(_:default:) | Value of a json flag, as AbrovaJSON |
| getJson(_:as:default:) | A json flag decoded into any Decodable type |
| getAll() | Every value held, as [String: FlagResult] |
Details and reasons
let d = flags.stringDetail("theme", default: "light")
print(d.value, d.enabled, d.variant ?? "-", d.reason, d.version ?? 0)booleanDetail, stringDetail, numberDetail and jsonDetail check the flag type and return a FlagDetail. getDetail(_:default:) returns the stored value without a type check.
| Reason | Meaning |
|---|---|
| ON | The flag is on and has no variants. The value is true |
| VARIANT | The flag is on and a variant was served |
| DISABLED | The flag is off in this environment. The flag off value is served |
| NOT_TARGETED | The targeting rules did not match this user. The off value is served |
| OUTSIDE_ROLLOUT | The user is outside the rollout percentage. The off value is served |
| PREREQUISITE_FAILED | A flag this one depends on did not give the required result. The off value is served |
| NO_BUCKETING_ID | A rollout or split needed a user id or anonymous id and none was sent |
| ERROR | The flag could not be evaluated. The off value is served |
| FLAG_NOT_FOUND | No such flag. Your default is returned |
| NOT_READY | No cached or fetched values yet. Your default is returned |
| TYPE_MISMATCH | For example getString on a boolean flag. Your default is returned |
Compare with the constants on FlagReason, for example d.reason == .notReady. isEnabled is true only for ON and VARIANT.
Identify the user#
Tell the SDK who the user is and what you know about them. Targeting rules and percentage rollouts are evaluated against this context.
// After sign-in
flags.identify("user-123", attributes: ["plan": "pro", "country": "IR", "beta": true, "age": 31])
// Replace all attributes
flags.setAttributes(["plan": "business"])
// Signed out, attributes stay
flags.setUserId(nil)
// Sign-out: clears the user id, the attributes and the cached values
flags.reset()- Each change fetches new values after a short pause (250 ms), so several calls in a row send one request. A call that changes nothing sends no request.
setAttributesand the attributes ofidentifyreplace the whole set. They do not merge.- When the user id changes, the previous user's values are dropped at once. Until the new values arrive, getters return your defaults (reason
NOT_READY). - Attributes are a string (at most 1,024 characters), a number, a
Bool,nil/NSNull, or an array of those. At most 100 attributes. Anything else is dropped with a log warning. - The user id and attributes are remembered on the device, so after a restart the client starts with the same user. Call
reset()on sign-out.
If you call identify before start(), the first request already carries the user.
Anonymous id
Every install gets a random anonymous id (a UUID). It is stored on the device and sent with every request, so a 10% rollout reaches about 10% of signed-out installs too. When a user id is set, rollouts use the user id instead.
The anonymous id survives reset(). flags.resetAnonymousId() creates a new one. Read it with flags.anonymousId.
Events#
let token = flags.addListener { event in
switch event {
case .ready:
break // First values are available (from the cache or the network). Fires once
case .updated(let changedKeys):
print("changed:", changedKeys)
case .error(let error):
print(error.kind, error.statusCode ?? 0, error.code ?? "-", error.message)
}
}
// later
token.cancel()Events always arrive on the main thread.
SwiftUI Example#
import SwiftUI
import AbrovaFlags
final class FlagStore: ObservableObject {
let flags: AbrovaFlags
@Published private(set) var revision = 0
init() {
flags = AbrovaFlags(config: AbrovaFlagsConfig(apiKey: "ab_live_xxx", environment: "production"))
flags.addListener { [weak self] event in
switch event {
case .ready, .updated:
self?.revision += 1 // redraw views that read flags
case .error:
break
}
}
flags.start()
}
}
@main
struct MyApp: App {
@StateObject private var store = FlagStore()
var body: some Scene {
WindowGroup {
CheckoutView().environmentObject(store)
}
}
}
struct CheckoutView: View {
@EnvironmentObject var store: FlagStore
var body: some View {
if store.flags.isEnabled("new-checkout") {
Text("New checkout")
} else {
Text("Checkout")
}
}
}Refresh, offline and errors#
The SDK fetches values:
- on
start(), - after a change of user or attributes,
reset()andresetAnonymousId(), - when the app returns to the foreground and the values are older than 15 minutes,
- on
refresh(), - on a timer, only if you set
refreshIntervalSeconds.
// true when the values are current
let ok = await flags.refresh()
// With a completion handler
flags.refresh { ok in /* ... */ }Every request for values is counted as one flag evaluation, whatever the number of flags it returns (100,000 evaluations are free every month; the price above that is set at launch). This is why polling is off by default.
The last values are stored in UserDefaults (never the API key) and served at the next start, before the network answers. A failed request never clears them.
- Offline or server error: the last values keep being served. The SDK retries with a growing delay (up to 5 minutes).
- Rate limited (429): the SDK waits the time the service asks for, then retries.
- 401, 403 or 404 (wrong key, Flags not enabled for the project, unknown environment): automatic fetches stop until you call
refresh(). - Before the first values:
flags.isReadyisfalseand getters return your defaults.
flags.close() stops all network work. The cached values stay on the device.
Configuration Options#
| Option | Type | Default | Description |
|---|---|---|---|
| apiKey | String | required | Your public API key (ab_live_xxx) |
| environment | String? | nil | Environment key (production, staging, development or your own). nil uses the Default values |
| flagKeys | [String]? | nil | Only fetch these flags. nil fetches every flag of the project |
| refreshIntervalSeconds | TimeInterval | 0 | Polling while the app is in the foreground. 0 is off; otherwise at least 60 |
| cacheEnabled | Bool | true | Store the last values and the user context on the device |
| requestTimeout | TimeInterval | 10 | Network timeout per request, in seconds |
| debug | Bool | false | Verbose logs. They never contain the API key |
Upgrading from 0.1.x#
0.2.0 is a new API, and 0.1.x clients stop receiving flags. Update the package and change these calls:
| 0.1.x | 0.2.0 |
|---|---|
| try await flags.initialize(callback:) | flags.start(), plus addListener or waitUntilReady() |
| setUserId + setUserAttributes, merged | identify(_:attributes:), setUserId, setAttributes. Attributes are replaced |
| getValue(_:defaultValue:) | getBoolean / getString / getNumber / getJson |
| evaluate(_:) returning FlagEvalResult | getDetail(_:default:) returning FlagDetail, with reason and version |
| getAll() returning [FeatureFlag] with rules | getAll() returning [String: FlagResult], values only |
| AbrovaFlags.shared | Keep your own instance |
| enableOfflineCache | cacheEnabled |
| reset() / dispose() | reset() for sign-out, close() to stop the client |
Rollouts use a new bucketing method, so users are assigned to rollouts and variants again once.
Best Practices#
Create the client at launch and reuse it
The default is what your users see before the first values and when a flag is removed
The user id and attributes are remembered on the device. Call reset() when the user signs out
Use addListener to redraw when values change instead of calling refresh() often
Values refresh on start, on context changes and on app resume. Turn polling on only if you need faster updates