Manage Project Environment in Kotlin Multiplatform Mobile
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 setupbuildFlavors
and buildConfig
to 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 Scheme
to 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!