Manage Project Environment in Kotlin Multiplatform Mobile

Uwais Alqadri
5 min readFeb 15, 2023

When working on a project, we need to specify an Environment for each stage of development, some companies have specified more than 3 stages for a project, but most commonly, a project will have 3 stages Development Staging Production

For Android, we can setupbuildFlavorsand buildConfigto specify the environment, for iOS, we can create an .entitlements file target to represent each environment, or by adding some setup on Build Scheme and Build Settings, but how about a KMM project? should we specify the Environment for each platform? How we can specify the API base URL or anything else that came out from the Kotlin Module? is there a simple way to achieve such a thing?

Let me introduce you to https://github.com/yshrsmz/BuildKonfig a library made by an amazing developer called Yasuhiro, this library made us easier to create a specified BuildKonfig similar to how we configure Environment in Android, here’s how you use it

Configure BuildKonfig

The first thing to do is to make a BuildKonfig configuration inbuild.gradle.kts inside the shared module, something like:

buildkonfig {
packageName = "com.yourapp.shared"
objectName = "YourConfig"
exposeObjectWithName = "YourPublicConfig"

// default config is required
defaultConfigs {
buildConfigField(STRING, "BASE_URL", "base_url.dev")
buildConfigField(STRING, "BASE_URL_IMAGE", "image.base_url")
}

targetConfigs { // dev target to pass BuildConfig to iOS
create("ios") {
buildConfigField(STRING, "BASE_URL", "base_url.dev")
buildConfigField(STRING, "BASE_URL_IMAGE", "image.base_url")
}
}

targetConfigs("staging") {
create("android") {
buildConfigField(STRING, "BASE_URL", "s.base_url")
buildConfigField(STRING, "BASE_URL_IMAGE", "s_image.base_url")
}

create("ios") {
buildConfigField(STRING, "BASE_URL", "s.base_url")
buildConfigField(STRING, "BASE_URL_IMAGE", "s_image.base_url")
}
}

// .. and so on ~
}

Then, to configure the environment, just add one line to gradle.properties:

buildkonfig.flavor=staging // "staging" from targetConfigs

BuildKonfig is fun, but something bothers me is that it’s hard to switch between environments, you need to specify what the KMM module build for, and it is a pain especially when Android project and iOS project are in different stages, imagine the KMM module is specified to build in “staging” but iOS project need the KMM module to build in “dev”, case like this happens.

Configure the Environment

Our objective in this article is to create an environment configuration that follows each platform configuration, so Android Developers can specify buildVariants and iOS Developers can specify .entitlements or Build Schemeto set the environment, and the shared module will follow

How it actually works

From what we’ve known so far, the BuildKonfig library helps us to specify the environment by manually writing the flavor inside gradle.properties.

Apart from what BuildKonfig have done, to achieve this, I’m using a resource reader helper that read the .yaml configuration file that represents each environment every time the buildVariants or .entitlements or Build Scheme changes

For the resource reader, I’m using ResouceReader from Touchlab’s Droidcon KMM App public repo, with nicely touched logic to read assets for Android, and file resources for iOS

// Android
class AssetResourceReader(
private val context: Context
) : ResourceReader {
override fun readResource(name: String): String {
// TODO: Catch Android-only exceptions and map them to common ones.
return context.assets.open(name).use { stream ->
InputStreamReader(stream).use { reader ->
reader.readText()
}
}
}
}
// iOS
class BundleResourceReader(
private val bundle: NSBundle = NSBundle.bundleForClass(BundleMarker)
) : ResourceReader {
override fun readResource(name: String): String {
// TODO: Catch iOS-only exceptions and map them to common ones.
val (filename, type) = when (val lastPeriodIndex = name.lastIndexOf('.')) {
0 -> {
null to name.drop(1)
}
in 1..Int.MAX_VALUE -> {
name.take(lastPeriodIndex) to name.drop(lastPeriodIndex + 1)
}
else -> {
name to null
}
}
val path = bundle.pathForResource(filename, type) ?: error("Couldn't get path of $name (parsed as: ${listOfNotNull(filename, type).joinToString(".")})")

return memScoped {
val errorPtr = alloc<ObjCObjectVar<NSError?>>()

NSString.stringWithContentsOfFile(path, encoding = NSUTF8StringEncoding, error = errorPtr.ptr) ?: run {
// TODO: Check the NSError and throw common exception.
error("Couldn't load resource: $name. Error: ${errorPtr.value?.localizedDescription} - ${errorPtr.value}")
}
}
}

private class BundleMarker : NSObject() {
companion object : NSObjectMeta()
}
}

Then, create your file configurations, for example, I’m creating 2 configuration files, dev.yaml and release.yaml :

And because those files are compiled as part of the Binary Framework which means it’s not part of your iOS project, you need to add them somewhere inside of the iOS project (just drag and drop them somewhere you like)

How do we read the value from .yaml file? we need to make an implementation for that by adding a helper called YamlResourceReader that helps to serialize the yaml file into a data class object

import kotlinx.serialization.DeserializationStrategy
import net.mamoe.yamlkt.Yaml

class YamlResourceReader(
private val resourceReader: ResourceReader
) {
internal fun <T> readAndDecodeResource(name: String, strategy: DeserializationStrategy<T>): T {
val text = resourceReader.readResource(name)
return Yaml.decodeFromString(strategy, text)
}
}

// object
@Serializable
data class Configs(
val baseUrl: String
)

// implementation
val config = resourceReader.readAndDecodeResource(getStage().file, Configs.serializer())

Now, to make it seamlessly integrated with each platform project, add an expect-actual function called getStage(): EnvStage

commonMain

enum class EnvStage(val file: String) {
DEV("dev.yaml"),
RELEASE("release.yaml")
}

expect fun getStage(): EnvStage

androidMain

actual fun getStage(): EnvStage {
if (BuildConfig.DEBUG) {
return EnvStage.DEV
}

return EnvStage.RELEASE
}

Checked whether the BuildVariants is Debug or not by inspecting value from Android’s BuildConfig

iosMain

actual fun getStage(): EnvStage {
return stageIos ?: EnvStage.DEV
}

var stageIos: EnvStage? = null
func setupEnvirontment() {
#if DEBUG
ConfigsKt.stageIos = .dev
#else
ConfigsKt.stageIos = .release_
#endif
}

Define a global mutable variable called stageIos , and define the environment based on current environment flags direcyly from swift code

Android Build Variants

Now the moment of truth, configure the environment by changing the Build Variants

Xcode Entitlements / Scheme Target

For iOS, change the Build Scheme or Build Target

Closing

By using this approach, we can achieve a better implementation of Environment workflow in Kotlin Multiplatform Mobile especially when the project is in a different stage, without tinkering around with the shared module

it’s me Uwais, Peace Out!

--

--

Uwais Alqadri

A person who's curious about Mobile Technology and very passionate about it. specialize in Swift (Apple Platforms) and Kotlin (Android, Kotlin Multiplatform).