-
Lightweight and fast
-
Kotlin Multiplatform (Android, iOS, macOS (Apple Silicon), JVM, JS, Wasm)
- Android version 21 & above
- JDK 17 & Above
- iOS (iosX64, iosArm64, iosSimulatorArm64)
-
Use your existing event tracking (GA, Segment, Mixpanel, custom)
-
Adjust variation weights and targeting without deploying new code
See CHANGELOG.md for version history.
App level build.gradle (Groovy DSL):
repositories {
mavenCentral()
}
dependencies {
// Add GrowthBook module:
implementation 'io.growthbook.sdk:GrowthBook:7.6.0'
// Add Network Dispatcher you prefer:
// 1) NetworkDispatcherKtor — supports Android, iOS, JVM, JS, Wasm
implementation 'io.growthbook.sdk:NetworkDispatcherKtor:1.1.0'
// 2) NetworkDispatcherOkHttp — supports Android and JVM only
implementation 'io.growthbook.sdk:NetworkDispatcherOkHttp:1.0.9'
}If you are not sure which dispatcher to choose we recommend to use network dispatcher based on Ktor.
The main class of NetworkDispatcherKtor artifact is GBNetworkDispatcherKtor while the main class of NetworkDispatcherOkHttp artifact is GBNetworkDispatcherOkHttp.
If you are using other network client for example android-lite-http and don't want to have any other network client in your application,
you can provide your own implementation of NetworkDispatcher based on your network client.
Add Internet Permission to your AndroidManifest.xml, if not already added
<uses-permission android:name="android.permission.INTERNET" />Integration is super easy:
- Create a Growth Book API key
- 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.
initialize() method should be called in order to obtain SDK instance:
var sdkInstance: GrowthBookSDK = GBSDKBuilder(
apiKey = <API_KEY>,
hostURL = <GrowthBook_URL>,
attributes = < Hashmap >,
trackingCallback = { gbExperiment, gbExperimentResult -> },
encryptionKey = <String?>,
networkDispatcher = <NetworkDispatcher>, // you can use GBNetworkDispatcherKtor() or GBNetworkDispatcherOkHttp()
).initialize()If you are accessing features the first time there will be no features right after initialize() method call because features are not got from Backend yet. If you need to access features as soon as possible, you need to use GBCacheRefreshHandler. You can pass your implementation of GBCacheRefreshHandler through setRefreshHandler() method.
Threading: the fetched payload is processed on a background dispatcher, so
GBCacheRefreshHandleris invoked off the main thread. Marshal back to your UI thread yourself if the callback touches UI state.feature()/run()are safe to call from any thread and always evaluate against a single consistent snapshot of the loaded state.
.setEnabled(true) // Enable / Disable experiments
.setQAMode(true) // Enable / Disable QA Mode
.setForcedVariations(<HashMap>) // Pass Forced Variations
.setInitialFeatures(<GBFeatures>) // Seed bundled fallback features (see below)
.setCacheMaxAge(<Long>) // Cache freshness window in ms (see below)
.initialize()For a robust offline-first setup, you can bundle a known-good features payload (snapshotted from the API at build time) and seed the SDK with it at init. This gives flags a valid value from the first millisecond — on first installs, offline launches, and empty/corrupt cache — instead of falling back to hardcoded code defaults.
val bundledFeatures: GBFeatures = mapOf(
"dark-mode" to GBFeature(defaultValue = GBBoolean(false)),
"new-checkout" to GBFeature(defaultValue = GBBoolean(true)),
)
var sdkInstance: GrowthBookSDK = GBSDKBuilder(
apiKey = <API_KEY>,
hostURL = <GrowthBook_URL>,
attributes = hashMapOf(),
trackingCallback = { _, _ -> },
networkDispatcher = GBNetworkDispatcherKtor(),
)
.setInitialFeatures(bundledFeatures)
.initialize()The seeded features are applied immediately. The normal cache/network refresh still runs on top and overwrites the seed as fresher data arrives. Effective precedence: network > disk cache > seed > code defaults.
Upgrading from 6.x: Persistent caching is now implemented on every target — Android, Apple (iOS/macOS) and the JVM (on disk), and JS and wasmJs (browser
localStorage). The legacyFeatureCache.txt→FeatureCache_<clientKey>.txtmigration applies to Android only, so this upgrade note does not apply to the other targets.
By default the SDK caches feature definitions in the built-in per-platform storage described above. To make GrowthBook persist through your own storage instead — a shared KMP key/value store, encrypted storage, or one place to clear/reset all cached state — provide a GBCachingLayer:
class MyCachingLayer : GBCachingLayer {
override fun saveContent(fileName: String, content: String) = myKvStore.put(fileName, content)
override fun getContent(fileName: String): String? = myKvStore.get(fileName)
}
var sdkInstance: GrowthBookSDK = GBSDKBuilder(
apiKey = <API_KEY>,
hostURL = <GrowthBook_URL>,
attributes = hashMapOf(),
trackingCallback = { _, _ -> },
networkDispatcher = GBNetworkDispatcherKtor(),
)
.setCachingLayer(MyCachingLayer()) // routes both feature and sticky-bucket storage
.initialize()Values are opaque JSON strings keyed by filename — persist and return them verbatim. When set, the custom layer replaces the built-in cache for both feature definitions and sticky-bucket storage. It may be called in any order relative to the sticky-bucket setters.
By default the SDK refetches features from the network on every initialize(). Pass setCacheMaxAge(<ms>) to define a freshness window: while the cached features are younger than that window, the network call on the next fetch is skipped and the cache is served as the authoritative result. Once the cache is older, the SDK refetches. This is a staleness gate evaluated on the next fetch, not a background polling mechanism.
var sdkInstance: GrowthBookSDK = GBSDKBuilder(
apiKey = <API_KEY>,
hostURL = <GrowthBook_URL>,
attributes = hashMapOf(),
trackingCallback = { _, _ -> },
networkDispatcher = GBNetworkDispatcherKtor(),
)
.setCacheMaxAge(60 * 60 * 1000) // serve cache for up to 1 hour, then refetch
.initialize()An explicit refreshCache() call always bypasses this window and hits the network regardless of how fresh the cache is.
-
Initialization returns SDK instance - GrowthBookSDK
-
The feature method takes a single string argument, which is the unique identifier for the feature and returns a FeatureResult object.
fun feature(id: String) : GBFeatureResult
-
The featureValue method takes a string argument, which is the unique identifier, and the type of the accessed feature. Booleans, strings and numbers are returned unwrapped; JSON objects and arrays are returned as
GBJsonandGBArray(GBArrayalso satisfiesList<GBValue>). It returnsnullif the feature has no value or the value is not of the requested type.inline fun <reified V>featureValue(id: String): V?
The same function is available as an extension on
IGrowthBookSDK, with identical behavior, so code written against the interface reads values the same way. -
The
decodeAsextension (in theGrowthBookKotlinxSerializationmodule) decodes aGBValue— for example aGBJsonfeature value — into your own@Serializablemodel via kotlinx.serialization. It returnsnullif the value cannot be decoded into the requested type.inline fun <reified T> GBValue.decodeAs(json: Json = defaultDecodeJson): T?
import com.sdk.growthbook.kotlinx.serialization.decodeAs import kotlinx.serialization.Serializable @Serializable data class CheckoutConfig(val title: String, val maxItems: Int) // Decode a GBJson feature value into a typed model val config: CheckoutConfig? = sdkInstance.featureValue<GBJson>("checkout-config")?.decodeAs<CheckoutConfig>()
By default
decodeAsuses aJsonthat ignores unknown keys, so a feature config that gains new fields on the backend still decodes into older app models (forward compatibility). Pass your ownJsonto change this — for example, to fail on unknown fields instead:import kotlinx.serialization.json.Json val strictJson = Json { ignoreUnknownKeys = false } val config = featureValue.decodeAs<CheckoutConfig>(strictJson) // null if the JSON has unmodeled fields
-
If you changed, added or removed any features, you can call the refreshCache method to fetch the latest feature definitions from the network. It always bypasses the
setCacheMaxAgefreshness window, so it refetches even when the cache is still fresh.fun refreshCache()
-
use setRefreshHandler to set a callback that will be called whenever the cache is refreshed.
fun setRefreshHandler(handler: () -> Unit)
-
method for set prefix of filename in cache directory GrowthBook-KMM.
fun setCacheDirectory(prefix: String = "gbStickyBuckets__"): GBSDKBuilder {}
-
The run method takes an Experiment object and returns an ExperimentResult
fun run(experiment: GBExperiment) : GBExperimentResult
-
Get Context
fun getGBContext() : GBContext
-
Get Features
fun getFeatures() : GBFeatures
-
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
fun setEncryptedFeatures(encryptedString: String, encryptionKey: String, subtleCrypto: Crypto?){
-
start receiving Features automatically when updated SSE
fun startAutoRefreshFeatures(): Flow<Resource<GBFeatures?>>{}
-
stop receiving Features automatically during SSE connection
fun stopAutoRefreshFeatures() {}
-
set a handler to be notified about only the feature flags that changed on a refresh (SSE / network), instead of reacting to the whole feature set — useful to avoid invalidating an external cache for unrelated flags. The handler fires after features are applied, only on an authoritative result and only when something changed; it does not fire for the non-authoritative cached payload served before a network refresh. When no features were applied yet, the first call reports the whole set as
added.fun setFeaturesChangeHandler(handler: GBFeaturesChangeHandler): GBSDKBuilder // GBFeaturesChangeHandler = (GBFeaturesDiff) -> Unit // GBFeaturesDiff(added, removed, changed) + hasChanges / changedKeys
-
The isOn method takes a single string argument, which is the unique identifier for the feature and returns the feature state on/off
fun isOn(featureDd: String): Boolean {}
-
The setForcedFeatures method setup the Map of user's (forced) features
fun setForcedFeatures(forcedFeatures: Map<String, GBValue>) {}
-
The getForcedFeatures method returns the Map of currently set forced features
fun getForcedFeatures(): Map<String, GBValue> {}
-
The setAttributes method replaces the Map of user attributes that are used to assign variations.
fun setAttributes(attributes: Map<String, GBValue>) {}
-
The updateAttributes method shallow-merges into the current attributes instead of replacing them (parity with the TypeScript SDK's
updateAttributes): new keys are added, existing keys are overwritten, and untouched keys are preserved. The merge is one level deep — nestedGBJson/GBArrayvalues are replaced wholesale. A key mapped toGBNullkeeps the key with a null value (it is not removed); to remove a key, rebuild the map withsetAttributes.fun updateAttributes(attributes: Map<String, GBValue>) {}
Example:
sdk.setAttributes(mapOf("id" to GBString("1"))) sdk.updateAttributes(mapOf("plan" to GBString("pro"))) // evaluation now sees both "id" and "plan"
-
The setAttributeOverrides method replaces the Map of attribute overrides used for Sticky Bucketing.
fun setAttributeOverrides(overrides: Map<String, GBValue>) {}
-
If you use Sticky Bucketing and need to guarantee that assignments are loaded before evaluating experiments (e.g. after login or user switch), use the coroutine versions:
suspend fun setAttributesSync(attributes: Map<String, GBValue>) {} suspend fun updateAttributesSync(attributes: Map<String, GBValue>) {} suspend fun setAttributeOverridesSync(overrides: Map<String, GBValue>) {}
Example:
lifecycleScope.launch { sdk.setAttributesSync(loginAttributes) val result = sdk.feature("my-experiment") // sticky buckets guaranteed } -
The setForcedVariations method setup the Map of user's (forced) variations to assign a specific variation (used for QA)
fun setForcedVariations(forcedVariations: Map<String, Any>) {}
GrowthBookExt is a pure-Kotlin companion module with quality-of-life helpers
over the core SDK — no extra runtime dependencies, all Kotlin Multiplatform
targets. It adds typed feature accessors, fallback strategies, a typed Flag<T>
API, and DSLs for attributes and SDK configuration.
implementation 'io.growthbook.sdk:GrowthBookExt:1.0.0'Read a feature value with a type and a default instead of unwrapping GBValue:
val theme: String = sdk.getString("theme", default = "light")
val maxItems: Int = sdk.getInt("max-items", default = 10)
val ratio: Double? = sdk.getDoubleOrNull("ratio")
val payload: GBJson? = sdk.getJson("payload")Each type (String/Boolean/Int/Long/Float/Double) has three variants:
getX(id, default)— value or a constant defaultgetXOrNull(id)— value ornullgetXOrElse(id) { ... }— value or a lazily computed default
Boolean helpers: isEnabled(id), isDisabled(id), and isFeatureKnown(id)
(distinguishes "missing" from "present but off").
When a feature is unknown — i.e. absent from the loaded configuration — choose fail-open vs fail-closed explicitly at the call site:
if (sdk.isEnabled("new-checkout", FallbackStrategy.FAIL_CLOSED)) { ... }The strategy applies only to an unknown feature. A known-but-off feature still
returns its real evaluated value, and so does a loaded feature whose evaluation
fails (malformed rule, failed prerequisite) — an evaluation error is never mistaken
for a missing feature, so FAIL_OPEN cannot flip a kill switch on.
Startup window. Feature definitions are fetched asynchronously, so until the first payload (or cached payload) is applied every feature is unknown, and
FAIL_OPENreports all of them as enabled — permanently so if the fetch fails and no cache exists. UsesuspendFeature, or seed a bundled payload withinitialFeatures, when a flag must not be read before the SDK is ready.
Declare flags once (key + type + per-feature default) to remove magic strings:
object Flags {
val DARK_MODE = Flag("dark-mode", default = false) // Flag<Boolean>
val MAX_ITEMS = Flag("max-items", default = 10) // Flag<Int>
}
val dark = sdk.isOn(Flags.DARK_MODE) // Boolean
val items = sdk.value(Flags.MAX_ITEMS) // Int, falls back to 10Flag.default covers both a missing feature and a present-but-wrong-typed value.
Supported types: Boolean/String/Int/Long/Float/Double (decode custom
@Serializable types via the GrowthBookKotlinxSerialization module instead).
Read a flag as a Kotlin property with by. The flag is re-evaluated on every
read, so the property always reflects the current config — a refreshed payload is
picked up without re-declaring the property:
val newHome by sdk.featureFlag("new-home") // Boolean, via isOn
val betaCheckout by sdk.featureFlag("beta-checkout", FallbackStrategy.FAIL_CLOSED)
val maxItems by sdk.featureFlag(Flag("max-items", default = 10)) // Int, falls back to 10
if (newHome) renderNewHome() else renderOldHome()Pure sugar over isOn / isEnabled(id, fallback) / value(flag) — same semantics,
just a delegate form. Handy when a flag is read in several places or grouped as
screen/ViewModel config. In a hot loop, snapshot it into a local val to avoid
re-evaluating on each read.
Set targeting attributes with plain Kotlin values, hiding the GBValue wrappers:
sdk.setAttributes {
"id" to "user-123"
"premium" to true
"age" to 42
"tags" to listOf("a", "b")
"address" to obj {
"city" to "Kyiv"
}
}Or build a reusable map: val attrs = buildAttributes { "id" to "user-123" }.
Inside the block, to on a String is the DSL's own entry function and shadows
kotlin.to, so nest objects with obj { } rather than an inline
mapOf("city" to "Kyiv") (a map built outside the block works as a value).
Assemble and initialize the SDK declaratively:
val sdk = growthBook {
apiKey = "sdk-abc"
apiHost = "https://cdn.growthbook.io"
networkDispatcher = GBNetworkDispatcherKtor() // from NetworkDispatcherKtor
enableLogging = true
attributes {
"id" to "user-123"
"premium" to true
}
}apiKey, apiHost and networkDispatcher are required (missing →
IllegalArgumentException); every other field falls back to the SDK default.
The DSL covers the whole of GBSDKBuilder, so nothing forces you back to the
builder: streamingHost, encryptionKey, enableLogging, remoteEval, qaMode,
enabled, forceVariations, trackingCallback, refreshHandler,
featuresChangeHandler, featureUsageCallback, initialFeatures, plugins,
cachingEnabled, cacheMaxAge, cachingLayer, and sticky bucketing via either
stickyBucketService or stickyBucketScope (+ optional stickyBucketPrefix).
val sdk = growthBook {
apiKey = "sdk-abc"
apiHost = "https://cdn.growthbook.io"
networkDispatcher = GBNetworkDispatcherKtor()
plugins = listOf(
GrowthBookTrackingPlugin(TrackingPluginConfig(clientKey = "sdk-abc"))
)
cacheMaxAge = 60_000
stickyBucketScope = viewModelScope
}This SDK operates with such models as GBContext, GBFeature, GBFeatureRule, GBFeatureSource, GBFeatureResult, GBExperiment, GBExperimentResult, etc.
These models can be found in model package. Some entities were put in utils/Constants.kt file. In JS SDK there is only one entity "Result" while in this SDK GBFeatureResult, GBExperimentResult are present.
You can specify attributes about the current user and request. These are used for two things:
- Feature targeting (e.g. paid users get one value, free users get another)
- Assigning persistent variations in A/B tests (e.g. user id "123" always gets variation B)
Attributes can be any JSON data type - boolean, integer, float, string, list, or dict.
If you're using ProGuard, you may need to add rules to your configuration file to make it compatible with Obfuscation & Shriniking tools. These rules are guidelines only and some projects require more to work. You can modify those rules and adapt them to your project, but be aware that we do not support custom rules.
# Core SDK
-keep class com.sdk.growthbook.** { *; }
-keep class kotlinx.serialization.json.** { *; }
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.SerializationKt
-keep,includedescriptorclasses class com.sdk.growthbook.**$$serializer { *; }
-keepclassmembers class com.sdk.growthbook.** {
*** Companion;
}
-keepclasseswithmembers class com.sdk.growthbook.** {
kotlinx.serialization.KSerializer serializer(...);
}
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, set 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 — specifically on setAttributes / setAttributesSync /
updateAttributes / updateAttributesSync, setAttributeOverrides, setForcedFeatures, and setForcedVariations.
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.
By default, GrowthBook does not persist assigned experiment variations for a user. We rely on deterministic hashing to ensure that the same user attributes always map to the same experiment variation. However, there are cases where this isn't good enough. For example, if you change targeting conditions in the middle of an experiment, users may stop being shown a variation even if they were previously bucketed into it. Sticky Bucketing is a solution to these issues. You can provide a Sticky Bucket Service to the GrowthBook instance to persist previously seen variations and ensure that the user experience remains consistent for your users.
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 can implement an instance of
the StickyBucketService to use Sticky Bucketing. For simple bucket persistence using the CachingLayer.
Sticky Bucket documents contain three fields:
- attributeName - The name of the attribute used to identify the user (e.g. id, cookie_id, etc.)
- attributeValue - The value of the attribute (e.g. 123)
- assignments - A dictionary of persisted experiment assignments. For example: {"exp1__0":"control"}
The attributeName/attributeValue combo is the primary key.
Here's an example implementation using a theoretical db object:
class GBStickyBucketServiceImp(
override val coroutineScope: CoroutineScope,
private val prefix: String = "gbStickyBuckets__",
private val localStorage: CachingLayer? = null
) : GBStickyBucketService {
override suspend fun getAssignments(
attributeName: String,
attributeValue: String
): GBStickyAssignmentsDocument? {
val key = "$attributeName||$attributeValue"
localStorage?.let { localStorage ->
localStorage.getContent("$prefix$key")?.let { data ->
return try {
Json.decodeFromJsonElement<GBStickyAssignmentsDocument>(data)
} catch (e: Exception) {
null
}
}
}
return null
}
override suspend fun saveAssignments(doc: GBStickyAssignmentsDocument) {
val key = "${doc.attributeName}||${doc.attributeValue}"
localStorage?.let { localStorage ->
try {
val docDataString = Json.encodeToString(doc)
val jsonElement: JsonElement = Json.parseToJsonElement(docDataString)
localStorage.saveContent("$prefix$key", jsonElement)
} catch (e: Exception) {
// Handle JSON serialization error
}
}
}
override suspend fun getAllAssignments(attributes: Map<String, String>): Map<String, GBStickyAssignmentsDocument> {
val docs = mutableMapOf<String, GBStickyAssignmentsDocument>()
attributes.forEach { (key, value) ->
getAssignments(key, value)?.let { doc ->
val docKey = "${doc.attributeName}||${doc.attributeValue}"
docs[docKey] = doc
}
}
return docs
}
}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.
