Use this concept document to understand how the Optimization iOS SDK runs shared optimization behavior in a native app, how SwiftUI and UIKit integrations share the same client, and how consent, state, entry resolution, tracking, preview overrides, and offline delivery work.
This document applies to native apps on iOS 15 or later and macOS 12 or later. SwiftUI and UIKit sections name runtime-specific APIs where behavior differs.
For step-by-step setup, see Integrating the Optimization iOS SDK in a SwiftUI app and Integrating the Optimization iOS SDK in a UIKit app. For the full Contentful entry contract, see Entry optimization and variant resolution.
The iOS SDK is a native Swift Package named ContentfulOptimization. Swift owns native app concerns
such as persistence, networking, lifecycle handling, SwiftUI helpers, UIKit preview-panel
presentation, and app-facing public APIs.
Shared optimization behavior runs inside a local JavaScriptCore context. That bridge lets the iOS SDK use the same optimization, profile, consent, and event-delivery behavior as the JavaScript SDKs while exposing a Swift API to the application.
Applications do not call the JavaScript layer directly. The public boundary is Swift:
OptimizationClient is the main facade for initialization, state, optimization, tracking, and
preview controls.OptimizationRoot, OptimizedEntry, OptimizationScrollView, and .trackScreen(name:) provide
SwiftUI integration helpers.PreviewPanelViewController provides the UIKit preview-panel host.This split also defines what the SDK does not own. The application still fetches Contentful entries, manages consent UX, controls routing, decides identity policy, and renders the final UI.
Decide these policies before initialization because they shape the client state the bridge receives at startup and the events it can emit before runtime consent changes:
| Constraint | iOS behavior |
|---|---|
| Configuration | clientId is required. environment defaults to "main". locale configures the SDK Experience API and event locale; it does not choose the Contentful CDA locale for app-owned entry fetches. |
| Consent | state.consent starts as unset unless StorageDefaults.consent or persisted SDK consent provides a value. Until event consent is true, iOS/native allow-list behavior lets only identify and screen emit by default. |
| Persistence consent | Boolean client.consent(true) or client.consent(false) updates event consent and durable profile-continuity persistence consent together. Use split consent when event consent and durable profile continuity have separate policy decisions. |
| Allowed event types | OptimizationConfig.allowedEventTypes replaces the native default pre-consent allow-list. Pass allowedEventTypes: [] for strict opt-in before any Optimization event, or pass a narrow custom list when legal and privacy review permits specific pre-consent events. |
| Storage availability | iOS stores consent and, when persistence consent is true, profile-continuity values in UserDefaults. If storage has no usable value or is cleared, the SDK starts from configured defaults and does not restore profile-continuity state from a previous process. |
| Preview mode | The preview panel is an app opt-in surface. Mount it only in debug or internal flows. Opening the panel sets client.isPreviewPanelOpen; SwiftUI OptimizedEntry treats that state as a live-update override, while UIKit apps must subscribe and redraw to reflect preview changes. |
| Offline behavior | Event queues are in memory. Events queued while offline flush when connectivity returns. On iOS/UIKit, app backgrounding triggers an online best-effort flush of queued events; it does not make an offline queue durable or flush while the device remains offline. QueuePolicy can tune caps, retry, backoff, circuit behavior, and callbacks, but the SDK does not provide a durable outbox across process death. |
| Configured defaults | StorageDefaults are startup defaults and take precedence over persisted values. If the application persists user choices, leave consent and persistence defaults unset. Restore SDK-stored consent naturally, or call client.consent(...) from the resolved app policy instead of seeding every launch. |
OptimizationClient has two phases:
| Phase | Behavior |
|---|---|
| Uninitialized | The client exists, but the bridge is not loaded. Async event APIs and setLocale(_:) throw OptimizationError.notInitialized; many sync read, resolve, consent, reset, and online-state APIs return nil, baseline content, or no-op. |
| Initialized | The bridge is loaded, startup defaults and eligible persisted state have been resolved, SDK state is available, network observers are active, and iOS/UIKit app lifecycle observers are active when UIKit is available. |
SwiftUI apps usually let OptimizationRoot call initialize(config:). UIKit apps usually call
initialize(config:) from scene or app startup before passing the client into view controllers.
OptimizationClient is @MainActor. Call it from main-thread contexts such as SwiftUI view tasks,
SwiftUI event handlers, view-controller lifecycle methods, or Task { @MainActor in ... } blocks.
The compiler can flag background calls as concurrency errors.
Typical apps keep one OptimizationClient alive for the app or scene lifetime. Use destroy() for
test teardown or deliberate SDK reset flows.
Every iOS integration builds an OptimizationConfig:
OptimizationConfig(
clientId: "your-client-id",
environment: "main",
locale: "en-US",
logLevel: .debug
)
Only clientId is required. environment defaults to "main". Base URL overrides belong only in
integrations that need non-default Experience API or Insights API endpoints.
Use top-level locale for the SDK Experience/event locale. When the application renders localized
Contentful entries, choose an app-owned Contentful locale and pass it to the app's Contentful
Delivery API request before entries are passed to OptimizedEntry or resolveOptimizedEntry(...).
For the full locale model, see
Locale handling in the Optimization SDK Suite.
OptimizationClient is an ObservableObject. It publishes runtime state that SwiftUI and UIKit
code can observe:
| Surface | Description |
|---|---|
state |
Snapshot of profile, consent, optimization readiness, and pending changes. |
isInitialized |
true after initialization completes. |
selectedOptimizations |
The selected optimization variants for the visitor. |
state.canOptimize |
Whether selected optimization data is available for entry resolution. |
optimizationPossible |
Whether the current consent and allow-list configuration can produce optimization data. |
experienceRequestState |
Outcome of the most recent Experience API request. |
locale |
Current SDK locale for Experience API requests and event context. |
isPreviewPanelOpen |
true while the in-app preview panel is visible. |
previewState |
Preview override state used by the in-app preview panel. |
eventStream |
Public passthrough event stream for debug surfaces and tests. It does not replay prior events to late subscribers. |
blockedEventStream |
Public passthrough stream for events blocked by consent or allow-list gating. |
SwiftUI code reads these values through @EnvironmentObject. UIKit code can subscribe through
Combine publishers such as client.$state and client.$selectedOptimizations.
Use state.canOptimize when rendering depends on variant data being available. Use
optimizationPossible when the app needs to know whether current consent and allow-list settings
can produce optimization data at all. For the cross-SDK distinction, see
Core state management.
Custom Flags use the same Core-backed model as the Web SDKs: client.getFlag(_:) returns the
current JSON value, and client.flagPublisher(_:) returns an AnyPublisher<JSONValue?, Never> that
updates on distinct value changes. Reading or subscribing to a flag can emit deduplicated flag-view
events when consent and profile state allow it, so treat flag delivery as an analytics exposure.
For the lower-level state model shared through the native bridge, see Core state management.
The SDK persists consent with UserDefaults. When persistence consent is true, it also persists
profile-continuity values such as profile, selected changes, selected optimizations, and the
anonymous ID. StorageDefaults are startup defaults, not one-time seeds. During initialization,
configured defaults take precedence over persisted SDK values, so a configured consent or
persistence default can replace a visitor's stored choice on each launch.
Use StorageDefaults(consent: true) only when the application's policy is truly default-on at
startup. If the application persists user choices in a CMP, account setting, or app preference,
leave StorageDefaults.consent and StorageDefaults.persistenceConsent unset. Resolve the policy
at startup and call client.consent(...) after the client is available instead of seeding a choice
every launch. If the application relies on SDK consent storage, also leave those defaults unset so
the persisted SDK consent can restore naturally.
When durable profile-continuity persistence is allowed, the client writes profile-continuity values
to UserDefaults before publishing the corresponding state snapshot and selected optimizations.
Application code and XCUITest flows can wait for SDK-derived state rather than adding storage-timing
delays before relaunching.
Consent is a three-state value: true, false, or unset. Until consent is granted, the SDK blocks
event types that are not allow-listed. iOS uses the native default allow-list when
OptimizationConfig.allowedEventTypes is unset:
| Consent state | Event behavior |
|---|---|
| Unset | identify and screen can emit; other events are blocked. |
true |
All SDK event types can emit. |
false |
identify and screen can emit; other events are blocked. |
This is the default iOS/native behavior, not a universal SDK rule. allowedEventTypes replaces the
default allow-list. Use allowedEventTypes: [] when no Optimization event can emit before explicit
consent, or pass a custom list when policy permits specific pre-consent events. Allow-listed
pre-consent events still mark context.gdpr.isConsentGiven as false until event consent is
explicitly true.
Call client.consent(true) when the visitor grants consent and client.consent(false) when the
visitor rejects it. Boolean consent controls both event emission and durable profile-continuity
persistence by default. client.consent(false) clears event consent and persistence consent, purges
SDK queues, and clears SDK-managed durable profile-continuity storage while leaving active in-memory
state available until the app resets or tears down the client.
Use client.consent(events:persistence:) when event emission and durable profile continuity need
separate policy decisions. For example, this call allows events while keeping profile continuity
session-only:
client.consent(events: true, persistence: false)
client.consent(events: false) withdraws event consent and purges SDK queues, but it does not clear
persistence consent unless persistence: false is also passed.
Read client.state.consent for event consent and client.state.persistenceConsent for durable
profile-continuity persistence consent. For cross-SDK consent guidance, see
Consent management in the Optimization SDK Suite.
Entry optimization is a local, synchronous decision once the app has both Contentful entry data and selected optimizations.
The application provides:
selectedOptimizations snapshot only when the caller needs one.The SDK returns either the baseline entry or the resolved variant entry:
let result = client.resolveOptimizedEntry(
baseline: entry,
selectedOptimizations: client.selectedOptimizations
)
let resolvedEntry = result.entry
let selectedOptimization = result.selectedOptimization
Omit selectedOptimizations when you want direct resolution to use the current bridge and client
state. Pass an explicit snapshot for locked UIKit screens or custom abstractions that must keep the
same variant until the app deliberately redraws or reloads that view.
resolveOptimizedEntry does not fetch Contentful entries, evaluate audiences, call the Experience
API, or mutate state. SwiftUI OptimizedEntry wraps the same boundary and adds component-level
behavior such as variant locking, live updates, and interaction tracking.
For the full data model and fallback behavior, see Entry optimization and variant resolution.
The iOS SDK emits mobile screen events, custom business events, and Contentful entry interaction events:
| Event type | SwiftUI path | UIKit path |
|---|---|---|
| Screen | .trackScreen(name:) |
client.trackCurrentScreen(...) |
| Event | App-owned event handlers | client.track(event:properties:) |
| Entry view | OptimizedEntry view tracking |
ViewTrackingController or client.trackView(TrackViewPayload(...)) |
| Entry tap | OptimizedEntry tap tracking |
client.trackClick(TrackClickPayload(...)) |
SDK-managed entry interaction tracking uses these defaults:
pause() emits a final duration update; foreground resume re-evaluates visibility and
starts a fresh cycle when the entry is still eligible.SwiftUI OptimizedEntry and UIKit ViewTrackingController can tune minVisibleRatio,
dwellTimeMs, and viewDurationUpdateIntervalMs per entry. Wrap scrollable SwiftUI content in
OptimizationScrollView when view timing needs an accurate viewport.
UIKit does not have automatic component visibility tracking. UIKit apps compute visibility and
duration through their own table, collection, or view-controller callbacks. Use
ViewTrackingController to apply the SDK visibility timing model, or call
client.trackView(TrackViewPayload(...)) directly when an app-owned abstraction already computes
view duration. Call client.trackClick(TrackClickPayload(...)) from UIKit control actions or
gesture recognizers.
SwiftUI OptimizedEntry locks to the first resolved variant by default. Locking prevents content
from changing while a visitor is reading it. Enable live updates when a component needs to react to
profile changes or preview overrides without a reload.
SwiftUI live-update precedence is:
| Preview panel | Global default | Per-entry override | Result |
|---|---|---|---|
| Open | Any | Any | Live |
| Closed | true |
nil |
Live |
| Closed | false |
true |
Live |
| Closed | true |
false |
Locked |
| Closed | false |
nil |
Locked |
Opening the preview panel sets client.isPreviewPanelOpen. SwiftUI OptimizedEntry treats that
state as a live-update override, so audience and variant overrides apply immediately in those
components. When the panel closes, entries that return to locked mode snapshot the current
client.selectedOptimizations value and keep the previewed variant. Entries whose global default or
per-entry override remains live continue to follow client.selectedOptimizations.
UIKit apps choose their own live-update policy. Subscribe to client.$selectedOptimizations,
client.$isPreviewPanelOpen, or client.$previewState and redraw views for live behavior, or keep
a selected-optimizations snapshot for locked behavior. Treat client.isPreviewPanelOpen as a reason
to redraw in live mode while previewing; the SDK does not automatically rebuild UIKit views.
The SDK monitors network reachability. On iOS/UIKit, it also observes app lifecycle events. No configuration is required for the default offline path, and queueing and flushing use the same event-delivery model for SwiftUI and UIKit integrations.
Default delivery behavior:
OptimizationClient alive for the app or scene
lifetime to preserve queued events across transient connectivity changes.Use OptimizationConfig(queuePolicy:) when production behavior needs non-default delivery
constraints. QueuePolicy.offlineMaxEvents changes the Experience offline cap, QueueFlushPolicy
configures retry timing, backoff, jitter, failure thresholds, and circuit-open timing, and queue
callbacks report offline drops, flush failures, circuit-open events, and recovery. Call
client.flush() only for deliberate app-owned checkpoints; reconnect flushing and iOS/UIKit
background flushing already perform best-effort delivery.
OptimizationRoot, OptimizedEntry, screen tracking, and preview panel
mounting.OptimizationClient usage, manual entry resolution, tracking, and
preview panel mounting.