Skip to content

About

GrowthBook iOS (Swift) SDK

Resources

Contributing

Stars

26 stars

Watchers

13 watching

Forks

Repository files navigation

GrowthBook - SDK

  • Lightweight and fast
  • Supports native Apple platforms
    • macOS version 10.15 & Above
    • iOS version 12.0 & Above
    • Apple tvOS version 12.0 & Above
    • Apple watchOS version 5.0 & Above
    • Apple visionOS version 1.0 & Above
  • Adjust variation weights and targeting without deploying new code
  • Latest spec version: 0.7.0 View Changelog

Installation

CocoaPods

CocoaPods is a dependency manager for Cocoa projects. For usage and installation instructions, visit their website. To integrate GrowthBook into your Xcode project using CocoaPods, specify it in your Podfile:

  • If you're using version 1.0.46 or above, it could be necessary to set "User Script Sandboxing" to "No" in your build settings.

  • Add below line in your podfile, if not there

source 'https://github.com/CocoaPods/Specs.git'
  • Add below in podfile - in respective target block
pod 'GrowthBook-IOS'
  • Execute below command in terminal
pod install
Swift Package Manager - SPM

The Swift Package Manager is a tool for automating the distribution of Swift code and is integrated into the swiftcompiler.

Once you have your Swift package set up, adding GrowthBook as a dependency is as easy as adding it to the dependencies value of your Package.swift.

dependencies: [
    .package(url: "https://github.com/growthbook/growthbook-swift.git")
]

Integration

Integration is super easy:

  1. Create a Growth Book with a few methods: with the API key and host URL, only host URL, only JSON
  2. At the start of your app, do SDK Initialization as per below

Now you can start/stop tests, adjust coverage and variation weights, and apply a winning variation to 100% of traffic, all within the Growth Book App without deploying code changes to your site.

var sdkInstance: GrowthBookSDK = GrowthBookBuilder(apiHost: <GrowthBook/API_HOST>,
    clientKey: <GrowthBook/Client_KEY>,
    attributes: <[String: Any]>,
    trackingCallback: { experiment, experimentResult in 

    }, backgroundSync: Bool?).initializer()

You must also provide the encryption key if you intend to use data encryption.

var sdkInstance: GrowthBookSDK = GrowthBookBuilder(apiHost: <GrowthBook/API_HOST>,
    clientKey: <GrowthBook/Client_KEY>,
    attributes: <[String: Any]>,
    trackingCallback: { experiment, experimentResult in 

    }).initializer()
var sdkInstance: GrowthBookSDK = GrowthBookBuilder(apiHost: <GrowthBook/API_HOST>,
    clientKey: <GrowthBook/Client_KEY>,
    attributes: <[String: Any]>,
    trackingCallback: { experiment, experimentResult in 

    }).initializer()

There are additional properties which can be setup at the time of initialization

var sdkInstance: GrowthBookSDK = GrowthBookBuilder(apiHost: <GrowthBook/API_HOST>,
    clientKey: <GrowthBook/Client_KEY>,
    attributes: <[String: Any]>,
    trackingCallback: { experiment, experimentResult in 

    })
    .setRefreshHandler { isRefreshed in
        
    } // Get Callbacks when SDK refreshed its cache
    .setNetworkDispatcher(networkDispatcher: <Network Dispatcher>) // Pass Network client to be used for API Calls
    .setEnabled(isEnabled: true) // Enable / Disable experiments
    .setQAMode(isEnabled: true) // Enable / Disable QA Mode
    .setForcedVariations(forcedVariations: <[String: Int]>) // Pass Forced Variations
    .setLogLevel(<LoggerLevel>) // Set log level for SDK Logger, by default log level is set to `info`
    .setCacheDirectory(<CacheDirectory>) // This function configures the cache directory used by the application to the designated directory type. Subsequent cache-related operations will target this directory.
    .setStickyBucketService(stickyBucketService: StickyBucketService()) // This function creates a sticky bucket service.
    .initializer()

Usage

  • Initialization returns SDK instance - GrowthBookSDK

    Use sdkInstance to consume below features -
  • The feature method takes a single string argument, which is the unique identifier for the feature and returns a FeatureResult object.

func evalFeature(id: String) -> FeatureResult
  • The run method takes an Experiment object and returns an experiment result
func run(experiment: Experiment) -> ExperimentResult
  • Manually Refresh Cache
func refreshCache()
  • Get Context
func getGBContext() -> Context
  • Get Features
func getFeatures() -> Features
  • Get the value of the feature with a fallback
func getFeatureValue(feature id: String, defaultValue: JSON) -> JSON
  • The isOn method takes a single string argument, which is the unique identifier for the feature and returns the feature state on/off
func isOn(feature id: String) -> Bool
  • The setEncryptedFeatures method takes an encrypted string with an encryption key and then decrypts it with the default method of decrypting or with a method of decrypting from the user.
func setEncryptedFeatures(encryptedString: String, encryptionKey: String, subtle: CryptoProtocol? = nil)

Models

/// Defines the GrowthBook context.
class Context {
    /// api host
    public let apiHost: String?
    /// unique client key
    public let clientKey: String?
    /// Encryption key for encrypted features.
    let encryptionKey: String?
    /// Switch to globally disable all experiments. Default true.
    let isEnabled: Bool
    /// Map of user attributes that are used to assign variations
    var attributes: JSON
    /// Force specific experiments to always assign a specific variation (used for QA)
    let forcedVariations: JSON?
    /// If true, random assignment is disabled and only explicitly forced variations are used.
    let isQaMode: Bool
    /// A function that takes experiment and result as arguments.
    let trackingCallback: (Experiment, ExperimentResult) -> Void

    // Keys are unique identifiers for the features and the values are Feature objects.
    // Feature definitions - To be pulled from API / Cache
    var features: Features
}
/// A Feature object consists of possible values plus rules for how to assign values to users.
class Feature {
    /// The default value (should use null if not specified)
    let defaultValue: JSON?
    /// Array of Rule objects that determine when and how the defaultValue gets overridden
    let rules: [FeatureRule]?
}

/// Rule object consists of various definitions to apply to calculate feature value
struct FeatureRule {
    /// Optional targeting condition
    let condition: JSON?
    /// What percent of users should be included in the experiment (between 0 and 1, inclusive)
    let coverage: Float?
    /// Immediately force a specific value (ignore every other option besides condition and coverage)
    let force: JSON?
    /// Run an experiment (A/B test) and randomly choose between these variations
    let variations: [JSON]?
    /// The globally unique tracking key for the experiment (default to the feature key)
    let key: String?
    /// How to weight traffic between variations. Must add to 1.
    let weights: [Float]?
    /// A tuple that contains the namespace identifier, plus a range of coverage for the experiment.
    let namespace: [JSON]?
    /// What user attribute should be used to assign variations (defaults to id)
    let hashAttribute: String?
    /// Hash version of hash function
    let hashVersion: Float?
    /// A more precise version of `coverage`
    let range: BucketRange?
    /// Ranges for experiment variations
    let ranges: [BucketRange]?
    /// Meta info about the experiment variations
    let meta: [VariationMeta]?
    /// Array of filters to apply to the rule
    let filters: [Filter]?
    /// Seed to use for hashing
    let seed: String?
    /// Human-readable name for the experiment
    let name: String?
    /// The phase id of the experiment
    let phase: String?
    /// Array of tracking calls to fire
    let tracks: [TrackData]?
}

/// Enum For defining feature value source
enum FeatureSource: String {
    /// Queried Feature doesn't exist in GrowthBook
    case unknownFeature
    /// Default Value for the Feature is being processed
    case defaultValue
    /// Forced Value for the Feature is being processed
    case force
    /// Experiment Value for the Feature is being processed
    case experiment
}

 /// Result for Feature
class FeatureResult {
    /// The assigned value of the feature
    let value: JSON?
    /// The assigned value cast to a boolean
    public var isOn: Bool = false
    /// The assigned value cast to a boolean and then negated
    public var isOff: Bool = true
    /// One of "unknownFeature", "defaultValue", "force", or "experiment"
    let source: String
    /// When source is "experiment", this will be the Experiment object used
    let experiment: Experiment?
    /// When source is "experiment", this will be an ExperimentResult object
    let experimentResult: ExperimentResult?
}
/// Defines a single experiment
class Experiment {
    /// The globally unique tracking key for the experiment
    let key: String
    /// The different variations to choose between
    let variations: [JSON]
    /// A tuple that contains the namespace identifier, plus a range of coverage for the experiment
    let namespace: [JSON]?
    /// All users included in the experiment will be forced into the specific variation index
    let hashAttribute: String?
    /// How to weight traffic between variations. Must add to 1.
    var weights: [Float]?
    /// If set to false, always return the control (first variation)
    var isActive: Bool
    /// What percent of users should be included in the experiment (between 0 and 1, inclusive)
    var coverage: Float?
    /// Optional targeting condition
    var condition: JSON?
    /// All users included in the experiment will be forced into the specific variation index
    var force: Int?
    /// Array of ranges, one per variation
    let ranges: [BucketRange]?
    /// Meta info about the variations
    let meta: [VariationMeta]?
    /// Array of filters to apply
    let filters: [Filter]?
    /// The hash seed to use
    let seed: String?
    /// Human-readable name for the experiment
    let name: String?
    /// Id of the current experiment phase
    let phase: String?
}

/// The result of running an Experiment given a specific Context
class ExperimentResult {
    /// Whether or not the user is part of the experiment
    let inExperiment: Bool
    /// The array index of the assigned variation
    let variationId: Int
    /// The array value of the assigned variation
    let value: JSON
    /// The user attribute used to assign a variation
    let hashAttribute: String?
    /// The value of that attribute
    let valueHash: String?
    /// The unique key for the assigned variation
    let key: String
    /// The human-readable name of the assigned variation
    let name: String?
    /// The hash value used to assign a variation (float from `0` to `1`)
    let bucket: Float?
    /// Used for holdout groups
    let passthrough: Bool?
}

/// Meta info about the variations
public struct VariationMeta {
    /// Used to implement holdout groups
    let passthrough: Bool?
    /// A unique key for this variation
    let key: String?
    /// A human-readable name for this variation
    let name: String?
}

///Used for remote feature evaluation to trigger the `TrackingCallback`
public struct TrackData {
    let experiment: Experiment
    let result: ExperimentResult
}

Offline Mode

When you supply a pre-fetched features payload directly to the builder, the SDK uses it immediately — no network call is made on initialization and no disk round-trip occurs. This is the right choice when your app controls when it fetches features (for example, as part of app launch sequencing) and you want to drive updates explicitly.

// Fetch your payload however you like — from your own cache, a bundled file,
// a prior API call, etc. — then hand it to the builder.
let featuresData: Data = ... // your pre-fetched payload

var sdkInstance = GrowthBookBuilder(
    apiHost: "https://cdn.growthbook.io",
    clientKey: "sdk-abc123",
    attributes: userAttributes,
    features: featuresData,           // supply pre-fetched payload
    trackingCallback: { _, _ in },
    backgroundSync: false             // no automatic background sync
)
.initializer()

// Features are immediately available — no async wait required.
let isEnabled = sdkInstance.isOn(feature: "my-feature")

// When you decide it's time to get the latest features, call refreshCache()
// explicitly. It will fetch, cache, and apply the new payload right away.
sdkInstance.refreshCache()

Stable Session Mode

In stable session mode (stableSession: true), features are locked in at the moment the SDK is initialized and will not change for the lifetime of that SDK instance — even if refreshCache() is called or a streaming update arrives. The updated payload is cached silently and applied the next time the SDK is initialized (i.e. the next app cold start).

This is the right choice for apps where mid-session UI changes are unacceptable — for example, when feature flags control which UI components are rendered on launch.

var sdkInstance = GrowthBookBuilder(
    apiHost: "https://cdn.growthbook.io",
    clientKey: "sdk-abc123",
    attributes: userAttributes,
    features: featuresData,           // optional: pre-fetched fallback payload
    trackingCallback: { _, _ in },
    backgroundSync: false
)
.setStableSession(true)               // lock features for this session
.setRefreshHandler { _ in
    // Called when a refresh completes. In stableSession mode the new payload
    // has been written to cache but is NOT yet active. It will become active
    // on the next SDK initialization (next app cold start).
    print("New features cached — will apply on next launch")
}
.initializer()

// Features are stable here. You can call refreshCache() at any safe moment
// (e.g. when the app goes to background) to warm the cache for the next session.
NotificationCenter.default.addObserver(
    forName: UIApplication.didEnterBackgroundNotification,
    object: nil, queue: .main
) { _ in
    sdkInstance.refreshCache()
    // fetches latest → writes to cache → does NOT change live features
}

// On the next cold start, the SDK reads the cache written above and the
// user sees fresh features consistently from the very first frame — with
// no mid-session surprises.

Note: .setStableSession(true) works with backgroundSync: true as well. SSE-pushed updates will keep the cache fresh in the background, and each new session starts with the latest payload.

Streaming updates

To enable streaming updates set backgroundSync variable to "true"

var sdkInstance: GrowthBookSDK = GrowthBookBuilder(apiHost: <GrowthBook/API_KEY>, clientKey: <GrowthBook/ClientKey>, attributes: <[String: Any]>, trackingCallback: { experiment, experimentResult in
    }, refreshHandler: { error in
    }, backgroundSync: true)
    .initializer()

Remote Evaluation

This mode brings the security benefits of a backend SDK to the front end by evaluating feature flags exclusively on a private server. Using Remote Evaluation ensures that any sensitive information within targeting rules or unused feature variations are never seen by the client. Note that Remote Evaluation should not be used in a backend context.

You must enable Remote Evaluation in your SDK Connection settings. Cloud customers are also required to self-host a GrowthBook Proxy Server or custom remote evaluation backend.

To use Remote Evaluation, add the remoteEval: true property to your SDK instance. A new evaluation API call will be made any time a user attribute or other dependency changes. You may optionally limit these API calls to specific attribute changes by setting the cacheKeyAttributes property (an array of attribute names that, when changed, trigger a new evaluation call).

var sdkInstance: GrowthBookSDK = GrowthBookBuilder(apiHost: <GrowthBook/API_KEY>, clientKey: <GrowthBook/ClientKey>, attributes: <[String: Any]>, trackingCallback: { experiment, experimentResult in 
    }, refreshHandler: { error in
    }, remoteEval: true)
    .initializer()

note

If you would like to implement Sticky Bucketing while using Remote Evaluation, you must configure your remote evaluation backend to support Sticky Bucketing. You will not need to provide a StickyBucketService instance to the client side SDK.

Contextual Bandits

Contextual bandits reallocate experiment traffic toward the best-performing variation per user segment. All of the optimisation happens on the GrowthBook backend — the SDK only consumes the ready weights, so no configuration is required. Support is automatic as soon as your payload contains contextual bandits.

The feature payload gains a contextualBandits map (or encryptedContextualBandits when the endpoint is encrypted), keyed by bandit ref:

{
  "features": { "...": {} },
  "contextualBandits": {
    "bandit_abc123": {
      "banditVersion": 4,
      "contexts": [
        { "leafId": 0, "condition": { "country": "US" }, "weights": [0.7, 0.3] },
        { "leafId": 1, "condition": { "country": "CA" }, "weights": [0.2, 0.8] }
      ]
    }
  }
}

A feature rule opts in with contextualBanditRef and carries its variations under contextualVariations. At evaluation time the SDK walks the bandit's contexts in order, picks the first leaf whose condition matches the user's attributes, applies that leaf's weights, and then buckets the user through the normal hashing mechanism.

The selection is reported on ExperimentResult so it can be attributed in your tracking callback:

var sdkInstance: GrowthBookSDK = GrowthBookBuilder(
    apiHost: <GrowthBook/API_KEY>,
    clientKey: <GrowthBook/ClientKey>,
    attributes: <[String: Any]>,
    trackingCallback: { experiment, experimentResult in
        // nil unless the experiment is a contextual bandit
        let leafId = experimentResult.leafId
        let weights = experimentResult.variationWeights
        let banditVersion = experimentResult.banditVersion
    },
    refreshHandler: { error in })
    .initializer()

leafId, variationWeights and banditVersion are only set when the user was actually hash-bucketed into the experiment — a forced variation, a QA-mode assignment or a user filtered out by coverage leaves them nil.

Fallback behaviour

  • No leaf condition matches the user → leafId is -1 and the rule's own (aggregate) weights are used, or equal weights if the rule has none.
  • contextualBanditRef points at a definition missing from the payload → the rule is evaluated as a plain experiment with its own weights, and no bandit metadata is reported.

Aggregate (non-contextual) bandits need nothing from this: they are delivered as ordinary experiments with server-computed weights and work through the existing bucketing and sticky bucketing paths.

Sticky Bucketing

Sticky bucketing ensures that users see the same experiment variant, even when user session, user login status, or experiment parameters change. See the Sticky Bucketing docs for more information. If your organization and experiment supports sticky bucketing, you must implement an instance of the StickyBucketService to use Sticky Bucketing. For simple bucket persistence using the browser's LocalStorage (can be polyfilled for other environments).

Plugins

A plugin receives the SDK's lifecycle and evaluation events. Register as many as you like on the builder; each is called after your own trackingCallback has run.

let sdk = GrowthBookBuilder(url: url, attributes: attrs, trackingCallback: { _, _ in })
    .addPlugin(GrowthBookTrackingPlugin())
    .initializer()

Implement GrowthBookPlugin for your own. Every method has a default no-op, so override only what you need:

Method Called when
initialize(clientKey:) Once, at SDK init
onExperimentViewed(experiment:result:attributes:) A user is exposed to a variation
onFeatureEvaluated(featureKey:result:attributes:) A feature is evaluated
logEvent(eventName:properties:attributes:) The app called sdk.logEvent(...)
flush() Deliver buffered data, keep running
close() Shut down for good

GrowthBookTrackingPlugin

Batches experiment exposures and feature evaluations and POSTs them to the GrowthBook ingest endpoint.

let plugin = GrowthBookTrackingPlugin(config: .init(
    ingestorHost: "https://us1.gb-ingest.com",
    batchSize: 100,
    batchTimeout: 10,
    dedupeCacheSize: 1000,
    enableFeatureUsageEvents: true
))
Option Default Meaning
ingestorHost https://us1.gb-ingest.com Ingest endpoint
batchSize 100 Send once this many events are buffered
batchTimeout 10 Seconds between timed sends; also caps how long flush/close block
dedupeCacheSize 1000 Recent events remembered when suppressing duplicates; 0 disables deduping
enableFeatureUsageEvents true When false, stop reporting feature evaluations

Deduplication. A flag read on every screen produces the same event over and over, so identical exposures and evaluations are suppressed. The key is the event name plus the event's own fields — deliberately not the attributes, since a feature evaluation carries no unit identity and keying on attributes would defeat deduping for any app that updates them. Events from logEvent are never deduplicated: the app chose to send each one.

Switching users. Because attributes are outside the key, calling setAttributes to switch user does not reset deduplication: an evaluation already seen for the previous user is suppressed for the next one, for as long as it stays in the cache. This matches the JS SDK. If your app signs users in and out and you need every user's first evaluation recorded, set dedupeCacheSize: 0 to send everything.

Turning off usage events. enableFeatureUsageEvents: false drops feature evaluations only. Experiment exposures and custom events still send, so you keep your experiment data while shedding the much larger volume of usage events.

Custom events

sdk.logEvent("checkout_completed", properties: ["total": 42])

The event is buffered and batched like any other, and carries whatever attributes are set at the time it is logged.

Flushing

Events are buffered for up to batchTimeout seconds. deinit does not run when the system kills a suspended app — which is how most iOS apps end — so call flush() when your app goes to the background, or those events are lost:

func applicationDidEnterBackground(_ application: UIApplication) {
    sdk.flush()
}

flush() delivers the buffer and waits for it to leave the device, then leaves the plugins running so recording resumes when the app returns.

Returning is not proof of delivery. The wait is capped at batchTimeout, so a request slower than that is still in flight when flush() returns and is lost if the app is killed straight afterwards.

close() also delivers the buffer, but ends the plugins: nothing is recorded afterwards and later evaluations are dropped rather than buffered. Calling it more than once is harmless, and the SDK calls it on deallocation anyway — call it yourself when you want the timing to be yours.

Objective-C

GrowthBookPlugin carries JSON and the SDK's Swift model types, so the protocol itself cannot cross the bridge. The built-in plugin has its own entry point; a custom plugin still has to be written in Swift.

GrowthBookTrackingPlugin *plugin = [[GrowthBookTrackingPlugin alloc] init];
GrowthBookSDK *sdk = [[builder addTrackingPlugin:plugin] initializer];

[sdk logEvent:@"checkout_completed" properties:@{@"total": @42}];
[sdk flush];

License

This project uses the MIT license. The core GrowthBook app will always remain open and free, although we may add some commercial enterprise add-ons in the future.

About

GrowthBook iOS (Swift) SDK

Resources

Contributing

Stars

26 stars

Watchers

13 watching

Forks

Releases

Packages

Used by

Contributors

Languages