Mobile SDKs

Swift SDK

Install and configure the Swift SDK, evaluate configs and feature flags, and keep SwiftUI views current as values change

Introduction

The Swift SDK is intended to be used by Apple platform applications. It evaluates your configs against a user context, keeps them current as they change in the dashboard, and hands SwiftUI values it can re-render from.

PlatformMinimum
iOS / iPadOS15.0
macOS12.0
tvOS15.0
watchOS8.0

Building it requires Swift 6.0 or newer. The package is built in Swift 6 language mode under strict concurrency and every public type is Sendable, so the client can be shared across tasks and actors without wrapping it.

Config evaluation is synchronous and reads config state the client already holds in memory, which is what makes it safe to call from a SwiftUI body. Telemetry about those evaluations is aggregated and reported on its own task, so evaluating a config never waits on the network.

Installation

The SDK is distributed through Swift Package Manager from its repository: https://github.com/ConfigDirector/configdirector-swift-sdk

In Xcode, go to File → Add Package Dependencies… and enter the package URL:

https://github.com/ConfigDirector/configdirector-swift-sdk

To add it to a Swift package instead, declare it in Package.swift:

Package.swift
dependencies: [
    .package(url: "https://github.com/ConfigDirector/configdirector-swift-sdk", from: "1.7.0"),
],
targets: [
    .target(
        name: "YourTarget",
        dependencies: [.product(name: "ConfigDirector", package: "configdirector-swift-sdk")]
    ),
]

Configure and initialize the client

  1. Create an instance of the client providing your client SDK key. You can retrieve a client SDK key for each environment under SDK keys in the dashboard's navigation panel.
  2. Initialize the client to initiate its connection lifecycle.
ConfigDirectorSetup.swift
import ConfigDirector

// Creating the client makes no network calls. It throws only when the SDK key is
// blank or a custom base URL is invalid.
let client = try ConfigDirectorClient(clientSDKKey: "YOUR-CLIENT-SDK-KEY")

// Connects and waits until config values are received, or until it times out.
// After a timeout the client keeps trying to connect in the background.
await client.initialize()

The client is thread safe. Create one and share it for the lifetime of the app.

Before the client is initialized, config values will evaluate to the default value provided in code.

The initializer throws a ConfigDirectorError in only two cases, both of which are programming errors rather than runtime conditions: missingClientSDKKey when the key is blank, and invalidBaseURL when a custom base URL is not absolute. Connection failures are not thrown — initialize returns normally whether or not config state arrived, and isReady tells you which happened.

Most applications create a single client instance, initialize it during startup, and let it live for the lifetime of the app. In a SwiftUI app that usually means owning it in the App:

MyApp.swift
import ConfigDirector
import SwiftUI

@main
struct MyApp: App {
    @State private var client = try? ConfigDirectorClient(clientSDKKey: "YOUR-CLIENT-SDK-KEY")

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(\.configDirectorClient, client)
                .task {
                    await client?.initialize(context: ConfigDirectorContext(id: "user-123"))
                }
        }
    }
}

extension EnvironmentValues {
    @Entry var configDirectorClient: ConfigDirectorClient?
}
The SDK follows the app lifecycle so it can release its network connection while the app is backgrounded. See connection below to manage that yourself instead.

Additional configuration options

These options can be passed in to ConfigDirectorClientOptions.

metadata

The metadata option allows you to provide your application's name and version. These values can be used in targeting rules conditionals. For example, if a certain feature should only be enabled starting with a certain version of your application.

Whichever of the two you leave unset is read from the running application's bundle — CFBundleDisplayName (falling back to CFBundleName) for the name, and CFBundleShortVersionString for the version — so most applications do not need to set either one.

ConfigDirectorSetup.swift
let client = try ConfigDirectorClient(
    clientSDKKey: "YOUR-CLIENT-SDK-KEY",
    options: ConfigDirectorClientOptions(
        metadata: ConfigDirectorMetaContext(
            appName: "YOUR-APP-NAME",
            appVersion: "1.0.2"
        )
    )
)

logger

By default, the SDK logs to the unified logging system under the com.configdirector.sdk subsystem, and it is set to log warnings and errors only. You can configure a logger by either creating a ConfigDirector console logger with a different log level, or by conforming to the ConfigDirectorLogger protocol to provide your own logger. The protocol can be used to create an adapter to another logging library.

Configure the ConfigDirector console logger to a different level:

ConfigDirectorSetup.swift
let client = try ConfigDirectorClient(
    clientSDKKey: "YOUR-CLIENT-SDK-KEY",
    options: ConfigDirectorClientOptions(logger: ConsoleLogger(level: .debug))
)

The available levels are .off, .error, .warn (the default), .info and .debug.

Implement your own logger adapter:

MyLogger.swift
import ConfigDirector

struct MyLogger: ConfigDirectorLogger {
    // Messages more verbose than this level are never passed to `log`.
    let level: ConfigDirectorLogLevel = .info

    func log(_ level: ConfigDirectorLogLevel, message: String, error: (any Error)?) {
        // your specific logging library implementation here
    }
}

let client = try ConfigDirectorClient(
    clientSDKKey: "YOUR-CLIENT-SDK-KEY",
    options: ConfigDirectorClientOptions(logger: MyLogger())
)

connection

ConnectionOptions accepts five optional values:

  • mode
    • The connection mode, which can be .streaming or .polling. It is recommended to use the default of .streaming unless you have a specific need to use .polling instead.
    • .streaming holds a connection open and receives changes as they happen, reconnecting on its own with a backoff.
    • Defaults to .streaming
  • pollingInterval
    • How often to re-fetch config state, in seconds, when mode is .polling. It has no effect in any other mode. A value below the minimum of 30 seconds is raised to 30 seconds and a warning is logged. See Polling intervals.
    • Defaults to 60 seconds
  • timeout
    • The timeout to be used in initialization and when updating the context. This is how long initialize will wait for data from ConfigDirector services before returning. If the timeout is reached, initialize will return but the client will still be in an unready status and returning default values. The client will continue to attempt to connect and retrieve config values in the background.
    • If your application is used in environments where the users frequently have poor or no connection, you may want to use a lower timeout, or simply don't await the initialize call.
    • Defaults to 3 seconds
  • pausesWhileBackgrounded
    • Whether to pause the connection while the app is in the background and resume it when the app returns to the foreground. Mobile operating systems terminate background connections on their own, so this is enabled by default. Set it to false to manage the connection yourself with pauseNetwork() and resumeNetwork().
    • It has no effect on macOS, where an app keeps running, and keeps its connections, once it leaves the foreground.
    • Defaults to true
  • baseURL
    • The base URL used to connect to ConfigDirector services.
    • This should only be provided if your environment requires you to configure a proxy server in order to connect to ConfigDirector services.
ConfigDirectorSetup.swift
let client = try ConfigDirectorClient(
    clientSDKKey: "YOUR-CLIENT-SDK-KEY",
    options: ConfigDirectorClientOptions(
        connection: ConnectionOptions(mode: .polling, pollingInterval: 600, timeout: 2)
    )
)

Events

Events notify you of some key actions from the client. events publishes the client's lifecycle changes, and evaluations publishes every individual config evaluation. Each is an AsyncStream you consume with a for await loop, and each access to the property returns an independent stream, so several parts of your app can observe them at once. Cancelling the consuming task ends the loop, and every stream is finished when the client is closed.

ConfigDirectorSetup.swift
for await event in client.events {
    switch event {
    case let .ready(reason):
        print("Client is ready after \(reason)")
    case let .configsUpdated(update):
        print("Configs updated: \(update.keys), removed: \(update.removedKeys)")
    case let .contextUpdated(context):
        print("Context updated: \(context?.id ?? "none")")
    }
}

The following events are published on client.events:

  • ready(ConnectReason)
    • Emitted when the client is initialized, or after reconnection due to a context update or a resumed network connection. When this event is emitted it means the client has received a payload from the ConfigDirector servers and is ready to evaluate configs.
    • The associated ConnectReason is the action that triggered the client to connect/reconnect, which can be .initialization, .contextUpdate, or .networkResume.
  • configsUpdated(ConfigsUpdate)
    • Emitted when a payload is received from the ConfigDirector servers with config data. It is emitted during initialization or context update when an entire config payload is received. After initialization or context update, it is emitted when updates are pushed from the server (or discovered via polling if that connection mode is used).
    • keys - A [String] listing the config keys that were included in the payload from the server. On a delta update, these are only the configs that changed.
    • removedKeys - A [String] listing the keys of configs the payload removed. A config is removed when a full payload no longer includes it. Empty when nothing was removed, and always empty on a delta update.
  • contextUpdated(ConfigDirectorContext?)
    • Emitted whenever the user context takes effect, including during initialization. It is emitted before ready, therefore should not be relied upon as a lifecycle event but rather as an informational event to track updates to the user context.
    • The associated value is the context the client is now evaluating against. It can be nil.

client.evaluations publishes a ConfigEvaluation every time a config is evaluated, which can be useful for debugging what a screen actually read, and for capturing additional context for analytics events. This includes calls to value(for:default:) and evaluations delivered by a values(for:default:) stream. A ConfigEvaluation carries:

  • key: String - The config key that was evaluated.
  • value: ConfigEvaluationValue - The value the config evaluated to. It can be the default value provided to value(for:default:) or values(for:default:), for example if the config was evaluated before the client was initialized. Read it back as the type the config was evaluated as with evaluation.value.as(Bool.self), which returns nil when the config was evaluated as some other type. Its description is a textual form of the value, suitable for logging.
  • valueID: String? - The value ID, which is a stable hash of the value that can be used for analytics or other third parties rather than sending the value. This can be useful for large values, like a JSON config, or to avoid disclosing the values themselves to third parties. It is nil if the evaluation had to fall back to the default value.
  • isDefaultValue: Bool - Whether or not the evaluation fell back to the default value provided in code.
  • context: ConfigDirectorContext? - The context the config was evaluated against, if one was set.
  • reason: EvaluationReason - The reason for the evaluation resolution. In the case the config successfully evaluated based on server-provided targeting rules, it will be .foundMatch. If the evaluation had to fall back to the default value, the reason will encode why the fallback was required:
    • .clientNotReady - The evaluation happened before the client finished initialization.
    • .configStateMissing - The requested config key was not present in the payload received from the server. This could be due to an incorrect config key, or a config that was not enabled to be available to client SDKs.
    • .valueMissing - The config had no value for the current context.
    • .invalidNumber - The config was requested as a number but the value received from the server could not be converted to a number.
    • .invalidBoolean - The config was requested as a boolean but the value received from the server could not be converted to a boolean.
    • .invalidJSON - The config was requested as JSON but the value received from the server could not be decoded into the requested type.
    • .typeMismatch - The config was requested with a data type that did not match the type of the config and no reasonable type conversion was possible.

ConfigEvaluation is Equatable, so evaluations can be compared or de-duplicated before they are forwarded on.

ConfigDirectorSetup.swift
for await evaluation in client.evaluations where evaluation.isDefaultValue {
    print("'\(evaluation.key)' fell back to its default: \(evaluation.reason.rawValue)")
}
ConfigDirectorSetup.swift
for await evaluation in client.evaluations {
    guard let isOn = evaluation.value.as(Bool.self) else { continue }

    print("'\(evaluation.key)' evaluated to \(isOn), value ID \(evaluation.valueID ?? "none")")
}
Do not update view state straight from the evaluations stream. The state change re-renders the view, which reads the config again, which publishes another evaluation.

Retrieve config values

To synchronously retrieve config values, use the client's value(for:default:) method. It requires two arguments. The first argument is the config key, and the second is the default value to be returned if the client has not yet received config values from ConfigDirector services.

You can also subscribe to config value changes via the values(for:default:) method. It takes the same two arguments and returns an AsyncStream that emits the config's current value on subscription and then every time the evaluated value changes, whether because it was edited in the ConfigDirector dashboard or because the context was updated. Consecutive identical values are not re-emitted. When a full payload no longer includes the watched config, the stream emits the default value, as value(for:default:) would now return it. Cancelling the task consuming the stream stops watching.

ContentView.swift
// Retrieve the current value. The default value decides the type the config
// is read as, and is returned until the client is ready.
let darkMode = client.value(for: "my-config-key", default: false)

// Subscribe to value updates. The stream yields the current value right away
// and then every time the evaluated value changes.
for await darkMode in client.values(for: "my-config-key", default: false) {
    print("Value updated: \(darkMode)")
}

Both methods infer their type from the default value. Bool, String, Int and Double are supported out of the box. If there is a type mismatch at runtime, the SDK will attempt to convert the served value to the requested type. If the conversion fails, it will return the default value rather than trapping. Mismatched types at runtime are captured by the telemetry collector and will surface as warnings in the ConfigDirector dashboard.

ContentView.swift
client.value(for: "my-string-config-key", default: "Default")

client.value(for: "my-integer-config-key", default: 100)

client.value(for: "my-boolean-config-key", default: false)

JSON configs

A config holding a JSON document is read with value(for:as:default:), which decodes it into any Decodable type. It returns the default value when the config is not a JSON config, or when its document cannot be decoded into the type you asked for:

ContentView.swift
struct Theme: Decodable {
    let primary: String
    let cornerRadius: Double
}

let theme = client.value(
    for: "theme",
    as: Theme.self,
    default: Theme(primary: "#101010", cornerRadius: 8)
)

values(for:as:default:) is the streaming counterpart. Its type has to be Equatable as well, since that is how the stream knows a decoded value did not change:

ContentView.swift
for await theme in client.values(for: "theme", as: Theme.self, default: .fallback) {
    self.theme = theme
}

Custom value types

To read a config as one of your own types, conform it to ConfigValue. A config value arrives as the string the server served, and configValueKind decides which configs the type can be read from — .boolean, .string, or .number. Returning nil from the initializer evaluates the config to the caller's default value:

Locale+ConfigValue.swift
import ConfigDirector

extension Locale: ConfigValue {
    public static var configValueKind: ConfigValueKind { .string }

    public init?(configValue: String) {
        self.init(identifier: configValue)
    }
}

let locale = client.value(for: "default-locale", default: Locale(identifier: "en_US"))

Reading configs in SwiftUI

value(for:default:) is synchronous and makes no network calls, so it is safe to call directly from a body. That gives you the current value, but the view will not re-render on its own when the value changes.

To have the view follow a config, drive a @State from values(for:default:) inside a .task. The stream yields the current value immediately, so there is no frame where the view shows something other than the default before the first value arrives:

DarkModeToggle.swift
import ConfigDirector
import SwiftUI

struct DarkModeToggle: View {
    let client: ConfigDirectorClient

    @State private var isOn = false

    var body: some View {
        Toggle("Dark mode", isOn: $isOn)
            .task {
                for await value in client.values(for: "dark-mode", default: false) {
                    isOn = value
                }
            }
    }
}
.task cancels its work when the view disappears and starts it again when the view returns, which ends and re-subscribes the stream for you. Use .task(id:) when the config key or the default value can change, so the stream is replaced when they do.

Update the user context

A user context can be provided when initializing the client:

ConfigDirectorSetup.swift
import ConfigDirector

let client = try ConfigDirectorClient(clientSDKKey: "YOUR-CLIENT-SDK-KEY")

await client.initialize(
    context: ConfigDirectorContext(
        id: "12345",
        name: "Example User",
        traits: [
            // Any arbitrary traits which can be referenced in targeting rules
            "region": "North America",
        ]
    )
)

The user context can also be updated via updateContext:

ContentView.swift
await client.updateContext(
    ConfigDirectorContext(
        id: "654321",
        name: "Another User",
        traits: ["region": "Australia"]
    )
)

// Update it to an anonymous context when a user signs out. Leaving `id` unset
// makes the server derive a stable identifier from this SDK instance.
await client.updateContext(ConfigDirectorContext(isAnonymous: true))
In client SDKs (browser and mobile), updating the user context re-establishes a new connection to ConfigDirector servers with the new context. While the new connection is in flight, config values will continue to evaluate to the currently cached values from the prior user context.
Awaiting updateContext will wait until the new config values are downloaded or the connection times out. In the case of a timeout, the client will continue to attempt to connect with the new context in the background.

Traits are written as ordinary Swift literals. Strings, integers, doubles, booleans, arrays, dictionaries and nil are all accepted:

ContentView.swift
await client.updateContext(
    ConfigDirectorContext(
        id: "user-123",
        name: "Ada Lovelace",
        traits: [
            "plan": "pro",
            "seats": 12,
            "beta": true,
            "regions": ["us-east", "eu-west"],
        ]
    )
)

For a signed-out user, set isAnonymous to true. The values are still used to evaluate targeting rules, but the context is not persisted and does not appear in the dashboard. Leaving id unset makes the ConfigDirector server derive an identifier from the SDK instance, so the user keeps the same place in percentage rollouts for as long as that client instance lives; a new client instance gets a new identifier, which can move the user into a different percentile. Pass your own id when a rollout must stay stable across app launches.

Other useful client features

The client provides additional properties and methods.

context

Returns the current user context (which may be nil). When calling updateContext, the context is not immediately updated. The context is updated after the new connection for the new context succeeds (or times out and goes on retry).

isReady

Returns a Bool indicating if the client has been successfully initialized and is ready to evaluate configs for the given user context. It is initially false and becomes true after initialize succeeds. Upon calling updateContext it becomes false again and it is set to true once updateContext succeeds.

isInitializing

Returns a Bool indicating whether the client is trying to get its very first config state from the server. It is false on creation, becomes true when initialize is called on a client that has never received config state, stays true through timeouts and retries, and becomes false when the first config state arrives, when an unrecoverable connection error stops the retries, or on close(). updateContext and resumeNetwork never set it. This is useful to show a loading state instead of transitioning from the in-code default value to the evaluated value on a slow connection.

pauseNetwork() and resumeNetwork()

pauseNetwork() releases the network connection without discarding config state, event streams, or watch streams, and resumeNetwork() re-establishes it using the last context given to initialize or updateContext.

The client already does this on its own while the app is backgrounded, so these are only needed if you set pausesWhileBackgrounded to false in order to manage the connection yourself.

close()

Closes the connection, every watch stream, and every event stream, and reports whatever telemetry is left. The client cannot be used afterwards.

The client closes itself when it is released, so calling close() is only necessary to shut it down while a reference to it is still held.

Test your code

Use the SDK's testing library, the ConfigDirectorTesting product of the same package, to test the code that reads your configs and flags. It creates a test client: the SDK's real client connected to an in-memory server that your test controls. Only the connection to ConfigDirector, the telemetry, and the app lifecycle observer are replaced. Everything else is the same code that runs in production, so the code under test behaves exactly as it does against ConfigDirector, and no network connection is opened and no telemetry is sent. The product ships in the package you already depend on, so there is nothing else to add; link it into your test target only.

Package.swift
.testTarget(
    name: "YourAppTests",
    dependencies: [
        "YourApp",
        .product(name: "ConfigDirectorTesting", package: "configdirector-swift-sdk"),
    ]
),

In an Xcode project, add the ConfigDirectorTesting library to your test target under Frameworks and Libraries; the package itself is already added for the app.

CheckoutTests.swift
import ConfigDirectorTesting
import Testing

struct CheckoutTests {
    @Test func enablesTheNewCheckoutWhenTheFlagIsOn() async {
        let testClient = makeTestClient(values: ["new-checkout": true, "max-items": 20])
        defer { testClient.client.close() }
        await testClient.client.initialize()

        let checkout = Checkout(client: testClient.client)
        #expect(checkout.isNewCheckoutEnabled)

        testClient.setValue(false, for: "new-checkout")
        #expect(!checkout.isNewCheckoutEnabled)
    }
}

testClient.client is a ConfigDirectorClient, so it goes anywhere your code accepts one. It starts uninitialized, like a production client, because the code under test usually owns the call to initialize; with nothing held or failed, initialize completes at once with the client ready. Close it when the test is done, as your production code would.

Watching values

values(for:default:) streams work as they do in production: a stream yields the current value when it is created and then every time the evaluated value changes, so a test reads the next element after each change. A SwiftUI view driven by such a stream, as in Reading configs in SwiftUI, follows the test client the same way.

GreetingTests.swift
let testClient = makeTestClient(values: ["greeting": "hello"])
defer { testClient.client.close() }
var greetings = testClient.client.values(for: "greeting", default: "none").makeAsyncIterator()
#expect(await greetings.next() == "none")

await testClient.client.initialize()
#expect(await greetings.next() == "hello")

testClient.setValue("hi", for: "greeting")
#expect(await greetings.next() == "hi")

A stream keeps only the newest value, so two setValue calls before the next read arrive as one element, and events keeps the newest 256 events.

Values

values, setValue, and replaceValues take TestValues, usually written as literals, and the config type follows from the case: true is a boolean config, 20 an integer config, 2.5 a float config, "Ada" a string config, ["theme": "dark", "limit": 3] a JSON object, and ["x", "y"] a JSON array. A string is always a string config, so JSON text meant as a JSON config is given as an object or array literal. Every value is served to every context; a test that needs different values per context is written as one test per value. There is no null value; removeValue(for:) removes one. A blank key or a number that is not finite is a programming error and stops the test with a precondition failure.

Reads behave as they do in production: setValue(true, for: "k") read as a String returns the in-code default value and publishes a typeMismatch evaluation, setValue(2.5, for: "k") read as an Int returns 2, and setValue("", for: "k") serves the in-code default value with the valueMissing reason. A JSON object is encoded with its keys in sorted order, and value(for:as:default:) decodes it into your Decodable type.

Controls

ControlEffect
setValue(_:for:)Stores the value and, once the client is connected, delivers an update carrying only that key. Reads see it when setValue returns; values(for:) streams of the key and events yield it.
removeValue(for:)Removes the value and delivers a full update without it. Reads return the in-code default value with the configStateMissing reason, values(for:) streams of the key yield the default, and configsUpdated lists the key in removedKeys.
replaceValues(_:)Replaces every stored value, disarms any armed hold or failure, and delivers a full update. Use it to reset a test client shared across tests.
holdInitialization()The next initialize waits until completeInitialization() or failInitialization(), or until the client's timeout elapses.
completeInitialization()Delivers the stored values to the held initialize, so the client is ready when it returns, and lets initialize complete. Called while a hold is armed but not picked up, it disarms the hold.
failInitialization()Fails the held initialize the way an invalid SDK key does: it completes promptly, the client is not ready, and an error is logged. Called with no initialize held, it arms the next one to fail.
holdContextUpdate(), completeContextUpdate(), failContextUpdate()The same for the next updateContext.
contextUpdatesThe context of every initialize and updateContext call, in order. initialize without a context records an empty context, and resumeNetwork is not recorded.

The initialization controls act only on initialize and the context update controls only on updateContext; resumeNetwork is never held or failed. After a failed attempt, the next attempt succeeds and delivers the values stored in the meantime. A held initialize suspends its caller, so start it in a Task and drive it from the test:

LoadingTests.swift
let testClient = makeTestClient(values: ["new-checkout": true], timeout: 0.5)
defer { testClient.client.close() }
testClient.holdInitialization()

let initialization = Task { await testClient.client.initialize() }
try await Task.sleep(nanoseconds: 50_000_000)
#expect(testClient.client.isReady == false)
#expect(testClient.client.isInitializing)

testClient.completeInitialization()
await initialization.value
#expect(testClient.client.isReady)

Options

makeTestClient takes values, timeout (the client's connection timeout, which bounds how long a held attempt waits; defaults to the SDK's production timeout of 3 seconds), and logger (defaults to the SDK's ConsoleLogger, which writes to the unified logging system; pass a ConfigDirectorLogger of your own to capture what the client logs).

What to expect

  • Under the test client, initialize publishes contextUpdated, then ready, then configsUpdated, all before it returns.
  • A held attempt that times out, or that is interrupted by pauseNetwork or close, leaves the client not ready until the next attempt, applies the context it was given, and logs the SDK's timeout warning, which says the client will keep retrying, as production streaming would. A held initialize ended by close logs it too, and isInitializing stays true after a timeout, as it does in production until the first config state arrives.
  • The client runs initialize, updateContext, and resumeNetwork one at a time, so an attempt started while another is held waits for it to be completed, failed, or ended.
  • values(for:) streams yield on creation and then only when the evaluated value changes, as in production, so a setValue to the value already served yields nothing, while events still carries the configsUpdated.
  • pausesWhileBackgrounded has no effect: the test client does not observe the app lifecycle. Call pauseNetwork and resumeNetwork to test that path; values set while paused are delivered on resume.
  • After close, the test client's controls are silent no-ops, every stream finishes, and no task from the SDK is left running.