DriveScore KMM Journey: Episode III — Issues and Solutions

William da Silva
ClearScore
Published in
16 min readNov 8, 2021

Anyone who comes to our office notices it’s full of Star Wars references, with film posters on the walls and meeting rooms named Jakku, Alderaan and Bespin. Embracing ClearScore’s intergalactic obsession, I’ll start releasing a series of blog posts by the third episode!

  • Episode I — Idea, Spikes and Proposal;
  • Episode II — Architecture and Definition of Shared;
  • Episode III — Issues and Solutions;
  • Episode IV — What so far and what to expect for the future.

I recently gave a talk that covered these 4 points at Droidcon London 2021. From the questions I received, I found most people were interested in the Issues and Solutions section. I have decided to write about this first, so people having similar issues can use my insights and perhaps even solve their issues based on our solutions!

Context in a nutshell: As this comes after the first two episodes that explain the whole context of the project, I’ll give you the brief version: First, we released a normal Android project and then we wanted to iterate quickly on iOS. We found that using KMM, we could reuse Android code and share between both platforms but with the constraint of not being able to refactor existing Android code 👀. As you read about some of the issues and why we went with those solutions, you’ll see why this was important.

Note: This is content that focuses heavily on the issues in using KMM in production. If you don’t know what is KMM, or want to know more about it, please read KMM — Getting Started for more information.

Below you can find an easy to navigate list of the points discussed to help you navigate to key points — you can fav ⭐️ for easy future access.

Umbrella modules

Okay, this one isn’t an “issue” by itself, but actually an approach we used to leverage and help us in other situations which will come up later.

When making the setup of the shared module, the approach we chose was to share the modules on Android and iOS by using an “umbrella” module. We decided to use this approach for both platforms.

We set up an all-common module, having all the shared modules as an api dependency for commonMain. This module is used as a dependency by the android:app module, so this way, Android can easily have access to all shared modules just by depending on this single module.

We also have another module called iosframework which specifies the packForXcode task, and configures the shared code and what is accessed on the iOS side, for example the baseName for the framework, the modules to be export. Below, we have our config (I simplified the shared feature modules for visibility, as we have more than 20 already):

kotlin {
targets.getByName("ios") {
with(this as KotlinNativeTarget) {
binaries {
framework {
// Name of the generated .framework file.
baseName = "SharedDriveScore"
// Always embed bitcode to allow xcode archiving.
embedBitcode(BitcodeEmbeddingMode.BITCODE)
// Exports to prevent prefixing on the Obj-C side.
// Access to base classes.
export(project(":modules:core"))

// Access to DI Setup.
export(project(":modules:all-common"))

// Features.
export(project(":modules:feature1"))
export(project(":modules:feature2"))
// 3rd party libraries accessible from Swift.
export(Deps.mokoResources)
export(Deps.koinCore)
// Required for SQLDelight.
linkerOpts.add("-lsqlite3")
}
}
}
}
sourceSets {
commonMain {
dependencies {
// All dependencies accessible from the generated .framework.
// They need to be `api` in order to allow `export` in the framework config.
api(project(":modules:all-common"))
}
}
}
}
val packForXcode by tasks.creating(Sync::class) {
val mode = System.getenv("CONFIGURATION") ?: "DEBUG"
val framework = kotlin.targets.getByName<KotlinNativeTarget>("ios").binaries.getFramework(mode)
val targetDir = File(buildDir, "xcode-frameworks")
group = "build"
dependsOn(framework.linkTask)
inputs.property("mode", mode)
from({ framework.outputDirectory })
into(targetDir)
}
tasks.getByName("build").dependsOn(packForXcode)

In the iOS project, we have a script running in the build phase generating the .framework to be ready to be used on the iOS side:

DEADBEEF /* Run Script - Build KMM module */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 1234567890;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "Run Script - Build KMM module";
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "cd \"$SRCROOT/../..\"\n./gradlew :modules:iosframework:packForXCode -PXCODE_CONFIGURATION=${CONFIGURATION} -DkotlinVersion=\"1.5.31\"\n";
};

You may notice we are using a different Kotlin version there? 👀. The reason is because our Android app uses Compose, at this time we could not (yet) update to the latest Kotlin version, but we want to get the newest on the KMM interop available in latest releases for iOS 🙃

Configuring API keys

Build steps and API keys have to be set up since we use and share some API keys, while some are specific for each platform.

As for security concerns, we don’t commit API keys into the codebase and load from environment variables while building the app. Gradle in Android was already set up for this, but on KMM it wasn’t possible to reuse the same code.

Luckily, we found the library BuildKonfig. This enables us to do something similar to the BuildConfig in Android but in the KMM.

We can have common keys and platform-specific keys. Generating a expect object BuildKonfig and the actual implementation with each platform is pretty simple to config and their docs cover this step:

buildkonfig {
packageName = "your.package.buildconfig"
defaultConfigs {
buildConfigField(STRING, "SOME_API_KEY", rootDir.getEnvironmentVariable("SOME_API_KEY"))
// Platform-dependant fields - leave empty values here.
buildConfigField(STRING, "PLATFORM_API_KEY", "")
}
targetConfigs {
create("android") {
buildConfigField(STRING, "PLATFORM_API_KEY", rootDir.getEnvironmentVariable("ANDROID_PLATFORM_API_KEY"))
}
create("ios") {
buildConfigField(STRING, "PLATFORM_API_KEY", rootDir.getEnvironmentVariable("IOS_PLATFORM_API_KEY"))
}
}
}

You may be wondering about the getEnvironmentVariable...

It is our custom extension to make it easier for developers to build on the local machine without adding variables to their environment. We look first into the environment variables, if not there, we look into the file local.properties, which isn't committed to the codebase.

fun File.getEnvironmentVariable(key: String): String {
val value = System.getenv(key) ?: getPropertiesFromFile("local.properties")[key] ?: throw GradleException("The key $key is missing from local.properties.")
return value as String
}
private fun File.getPropertiesFromFile(fileName: String): Properties {
return Properties().apply {
load(FileInputStream(File(this@getPropertiesFromFile, fileName)))
}
}

Interop Koin and Dagger/Hilt

The initial Android project was done using for Dependency Injection (DI) Hilt and Dagger.

When we started with KMM we could not use Dagger/Hilt, so one of the options that was ready for KMM was Koin.

The challenge was: We don’t want to refactor the whole Android app to use Koin at this moment, so how can we use Koin for the Shared module and still use Dagger/Hilt for the Android app?

Our approach was to make Dagger create a Koin instance as Singleton, which is created from the KMM shared module and contains all required modules for DI (which we configure Koin in the all-common module).

This way, each platform can provide its own implementations and set up Koin according to their requirements.

Once this is done, Dagger can request Koin and simply do a koin.get() to get any dependency coming from Koin.

In all-common inside the common source set, we define our Koin DI, as shown below. As the all-common holds the dependency for all shared modules, it has access to all koin modules created in each separated shared module. It helps to keep each module responsible for creating their own instances, and we can have implementations as internal and expose just the interfaces.

internal fun initKoin(appModule: Module) = startKoin {
modules(
coreModule,
// Platform-specific modules.
appModule,
platformModule,
allCommonModule,
)
}
fun completeKoinSetup(
sdk1Adapter: Sdk1Adapter,
sdk2Adapter: Sdk2Adapter,
) {
KoinPlatformTools.defaultContext().loadKoinModules(
listOf(
module {
// SDK Adapters.
single { sdk1Adapter }
single { sdk2Adapter }
},
// Utility modules
utility1Module,
utility2Module,
// Feature modules.
feature1Module,
feature2Module,
)
)
}
val allCommonModule = module {
// Some classes required to be common
}
internal expect val platformModule: Module

Then each platform makes its own platformModule, for example, Android:

internal actual val platformModule = module {
single<MultiplatformResourceProvider> { MultiplatformResourceProviderAndroidImpl(get()) }
}

While iOS gives their specific implementations:

internal actual val platformModule = module {
single<MultiplatformResourceProvider> { MultiplatformResourceProviderIosImpl() }
}

Inside each platform source set, we also have a method where each platform specify their required dependencies to make it available on Koin, for example, this one for Android (simplified, as we indeed send more Android specifics as parameters there, such SharedPreferences, etc.):

fun initKoinAndroid(
appContext: Context,
): KoinApplication = initKoin(
module {
single { appContext }
}
)

Once we did these methods, we can actually create from Dagger:

@Provides
@Singleton
fun provideKoin(
@ApplicationContext context: Context,
): Koin {
return initKoinAndroid(
appContext = context,
).koin
.apply {
completeKoinSetup(
sdk1Adapter = Sdk1AdapterImpl(context),
sdk2Adapter = Sdk2AdapterImpl(context),
)
}
}

On the iOS side, we do similar work. We initialise a Singleton in accordance with the app lifecycle.

func start(launchOptions: [UIApplication.LaunchOptionsKey: Any]) {
sharedDependencyList = KoinIOSKt.doInitKoinIOS().koin
settings = makeSettings()
sdk1AdapterProvider = makeSdk1AdapterProvider()
sdk2AdapterProvider = makeSdk2AdapterProvider()
KoinKt.completeKoinSetup(
sdk1Adapter: sdk1AdapterProvider.getAdapter(),
sdk2Adapter: sdk2AdapterProvider.getAdapter()
)
}

Now having Koin set, whenever a dependency comes from it but is actually required via Dagger, we can make Dagger ask Koin for it:

@Module
@InstallIn(SingletonComponent::class)
object SomeApiModule {
@Provides
fun provideSomething(koin: Koin): Something = koin.get()
}

Our goal is to get rid of Dagger/Hilt and use only Koin, as the downside of this approach is always having to check if a dependency is coming from Dagger or Koin. But to avoid a huge refactor, this approach is working pretty well and enabled us to progress without much disruption.

Sharing resources

While converting our first module from the Android project to a shared module, we found new challenges. The main one was how to share Resources across multiplatform.

We researched this topic and found the moko-resources, which helped us share Strings and Images from shared modules.

The setup was pretty simple, and the guide on the library page was enough. Our own particularity was to have a ResourceProvider with some simple extensions to abstract the .toString(context = this) on Android and .localized() on iOS - making our ViewData models directly as simple String, instead of StringDesc.

This made our ViewModel (which is shared between platforms) using the extensions simple to follow and use, while the View doesn't know about this abstraction or the use of the library for text. Here is an example of how it is to use on a ViewModel:

SomeViewData(
title = MR.strings.some_view_title.asString,
subtitle = MR.strings.some_view_subtitle.asString(someParam),
)

Using the moko-resources, we could also share images. PNG images were fine, as the library handled the proper device size, but more problematic were the SVG/PDFs images which we use a lot of and the library doesn’t support.

For the SVG/PDFs, our approach was to keep it simple and not try a generic and one-to-fit-all solution. We simply decided on these cases to either hardcode on the view the icon, when it didn’t need to react to anything or change, OR create an enum to represent the icon. The view can check the enum value and accordingly set the proper SVG on Android or PDF on iOS. Here is an example:

On the ViewData definitions:

enum class ItemIcon {
EMAIL,
LOG_OUT,
}

And in the Android View:

private val ItemIcon.drawableRes: Int
@DrawableRes get() = when (this) {
ItemIcon.EMAIL -> R.drawable.ic_email
ItemIcon.LOG_OUT -> R.drawable.ic_log_out
}

Similar on the iOS View:

private func makeIconName(from icon: ItemIcon) -> String {
switch icon {
case .email:
return "MyAccountMenuEmail"
case .logOut:
return "MyAccountMenuLogout"
default:
return ""
}
}

This approach has been working fine for us so far, and we used a similar approach with Lottie animation files. We would love to improve this in the future for some other approach that allows us to have less boilerplate.

Regarding moko-resources library, we are loving it as, so far, we have only once noticed an issue which was images (PNGs) were not being found on some iOS devices. It was so specific to one screen that we deferred to investigate later and just hardcoded the images on the iOS side.

Sharing ViewModel

Sorting the Resources pattern we would go forward, we then move our current ViewData and ViewEvent models to the shared module, along the ViewModel itself.

When sharing work, we had to explain how we interact with a View from the ViewModel which is slightly different from how the iOS team does it on their projects. Also, looking at how the iOS team gave us more ideas on how we could improve our approach in the future. BUT in the "now", where we couldn't afford a refactor of the Android side, we made the compromise where iOS would follow the way we already had in place.

To avoid further concerns and exposure of this approach, we decided to wrap our shared ViewModel in an iOS ViewModelAdapter, which would interop between the approach iOS uses (and to add some properties they need to use SwiftUI) with the current Android-ish approach.

Basically, we have a ViewData exposed in a StateFlow to which we push the View states and anything we need to be displayed, for example, Display the Title, Body and a Button. At the same time, we have the ViewEvent exposed in a SharedFlow to which we push anything the View needs to do, for example, Show a dialog, navigate to another screen, or open a link in the browser. And the View directly calls any functions exposed in the ViewModel. For example, when pressed, the Button on that screen would call a method viewModel.onButtonPressed().

As we worked, we could see the pros and cons of this approach and some things that were controversial, for example, using the ViewEvent to show a dialog. We had to cut some corners and simplify a few things on the architecture. This approach allowed us to iterate quickly, while still having a reasonable architecture, and to react even faster. With requirements changing every week it was useful that we could manage a few changes on the screen (for example, to add the Dialog, or to add a new state in the view) with a simple refactor and add to the project without much effort. We know we can do better and improve the approach, but this is currently out of the scope until the team grows and we have greater capacity to tackle tech debt and make refactors on initial patterns.

Once this pattern was understood and agreed between Android and iOS devs, and everyone aware of this huge tech debt for later, we exposed both Flows and ViewModel methods. While the Android code was happily working, when iOS tried to consume the Flow it wasn't so straightforward. Methods were translated with callbacks on iOS and to observe, at the same time caring about the coroutines, was not an easy task. We had issues with lifecycles too. Our approach was to make all shared ViewModel to extend a BaseViewModel, which would require each platform to have its own implementation on how it should handle coroutines scopes:

// common source set
expect abstract class BaseViewModel<ViewData : BaseViewData, ViewEvent : BaseViewEvent>() {
abstract val viewData: StateFlow<ViewData>?
abstract val viewEvent: SharedFlow<Event<ViewEvent>>?
val scope: CoroutineScope
val backgroundScope: CoroutineScope
protected open fun onCleared()
}

As you can see above, we have both Flows exposed, along with expecting implementations for the scope and backgroundScope for coroutines. The method onCleared was added specially to handle iOS lifecycles and cancellations.

The Android implementation for this doesn’t require much, as Android works well with Coroutines and lifecycle for ViewModel:

// android source set
actual abstract class BaseViewModel<ViewData : BaseViewData, ViewEvent : BaseViewEvent> : ViewModel() {
actual val scope: CoroutineScope = viewModelScope
actual val backgroundScope: CoroutineScope
get() = scope + Dispatchers.Default
actual abstract val viewData: StateFlow<ViewData>?
actual abstract val viewEvent: SharedFlow<Event<ViewEvent>>?
actual override fun onCleared() {
super.onCleared()
}
}

Android made this class even extend androidx.lifecycle.ViewModel to get all the benefits "for free", and just expose the proper coroutines scopes.

For iOS, we had to add some manual handling for coroutines:

// ios source set
actual abstract class BaseViewModel<ViewData : BaseViewData, ViewEvent : BaseViewEvent> {
private val viewModelJob = SupervisorJob()
private val viewModelScope: CoroutineScope = CoroutineScope(
Dispatchers.Main + viewModelJob
)
actual val scope: CoroutineScope = viewModelScope
actual val backgroundScope: CoroutineScope
get() = scope + Dispatchers.Default
actual abstract val viewData: StateFlow<ViewData>?
actual abstract val viewEvent: SharedFlow<Event<ViewEvent>>?
protected actual open fun onCleared() {
viewModelJob.cancelChildren()
}
fun observeViewData(onViewData: (ViewData) -> Unit) {
viewData
?.onEach { onViewData(it) }
?.launchIn(viewModelScope)
}
fun observeViewEvent(onViewEvent: (ViewEvent) -> Unit) {
viewEvent?.map { it.consume() }
?.filterNotNull()
?.onEach { onViewEvent(it) }
?.launchIn(viewModelScope)
}
}

Besides adding some util methods, to make it simpler and easier for iOS to observe ViewData and ViewEvent, we found that by removing the coroutine suspend exposure and making a simple function with iOS code, they could observe in a much "nicer" way:

// Swift code consuming Flows from ViewModel
shared.observeViewData { [weak self] viewData in
guard let self = self else {
return
}
self.makeViewData(from: viewData)
}
shared.observeViewEvent { [weak self] viewEvent in
guard let self = self else {
return
}
self.handleKMMViewEvent(event: viewEvent)
}

To enable proper coroutines cancellation tied to the lifecycle in iOS, we had to create the onCleared method, which is called on deinit:

deinit {
shared.onCleared()
}

Multithreading

Moving code from Android to KMM was very straightforward, requiring minimal changes or refactors on the Android side. We usually tested, and found all seemed good.

A few days later, when iOS started to implement the migrated feature, sometimes we would get crashes regarding multithreading — when we try to access a variable created in one thread on another one.

In a few places, we could fix these threading issues by not switching threads but in some other cases we could not — or the effort to refactor the code would be too much.

Some places inside ViewModel, where we had to hold a var for some state, OR inside a Repository where we want to cache values in-memory, were common in throwing the exception regarding threads.

As I said before, some places we could manage to refactor, but when the work would be too much, we found a library to handle atomic operations: AtomicFU, which seems a “lazy” option, but so far has been working wonders, and helped us avoid refactoring code and consequentially Android implementation. At the same time, multithreading on Kotlin Native is hard to follow and understand. For new joiners, using this approach was simpler and easier to “understand” without having to go on the steep curve to properly understand the problem and how to solve it.

A scenario where we can’t change the code around was a callback on a 3rd party library inside the iOS project, in which the completion handler given to the function calling the SDK was created in one thread, and inside the SDK callback, was failing to be able to call completion due being executed in a different thread.

Our particular solution for this case — and again, we didn’t spend loads of time understanding and drilling into the problem — was simple: wrap the call on coroutines main thread on the KMM side and on the iOS side before calling the completion, and make sure to also be on the main thread with DispatchQueue.main. We knew this potentially wasn't the best approach, but it solved our immediate problem, on tests it seemed to be working as expected and unblocked an important release to the app.

This doesn’t mean we never track back this issue. We have a ticket to look into the case when the team has the capacity and also mentions to try out the new memory management for Kotlin Native.

Sealed classes and exhaustiveness on Swift

One of the complaints from iOS devs was regarding sealed class and how the nested classes translate for them to use it.

This recent case is an example (simplified), where we have to show sometimes a static resource but sometimes we will load a Lottie animation, using the approach in having which Lottie file to load as an enum as it was explained earlier:

sealed class Item {
data class SomeItem(
val icon: ImageViewData,
) : Item() {
sealed class ImageViewData {
data class StaticImageResource(val resource: ImageResource) : ImageViewData()
data class LottieResource(val resource: LottieResourceItem, val shouldLoop: Boolean) : ImageViewData() {
enum class LottieResourceItem {
SOMETHING,
}
}
}
}
}

Android consuming this is reasonably okay and could even be reduced via some imports:

when (icon) {
is SomeItem.ImageViewData.LottieResource -> {
// some code here to handle the loop
when (lottieResource.resource) {
SomeItem.ImageViewData.LottieResource.LottieResourceItem.SOMETHING -> {
view.setAnimation(R.raw.something)
}
}.exhaustive
view.playAnimation()
}
is SomeItem.ImageViewData.StaticImageResource -> {
// load static image
}
}

But on the iOS side, it isn’t!

switch model.icon {
case let data as SharedDriveScore.Item.SomeItemImageViewDataStaticImageResource:
icon = .static(data.resource)
case let lottieResource as SharedDriveScore.Item.SomeItemImageViewDataLottieResource:
switch lottieResource.resource {
case .something:
icon = .lottie(.something)
default:
fatalError("Unsupported lottie type: \(lottieResource.resource)")
}
default:
fatalError("Unsupported icon type: \(model.icon)")
}

It merges all the classes names together which is sometimes confusing for the iOS devs to understand which one you are trying to reference.

We know of moko-kswift trying to fill this gap and make it nicer, but we have not tried yet.

Another issue is the exhaustive, where iOS will not get it, hence us adding the default with a fatalError, for us to get as early as possible if we missed any case.

Not everything is shared

We have been using shared modules across most of our features and parts of the app. One place we had issues would be trying to make something generic enough and at the same time enable platform specifics when the feature is heavy on native logic/interactions.

One use case was regarding device Permissions, requesting Location and other permissions from the user.

Both Android and iOS have specific flows and requirements for this task. We would have spent so much time figuring out the particular nuances to share modules, we quickly just decided each platform would create and manage this outside the shared folder.

This enabled us to progress quickly on this part, as we would not be able to share a lot anyway.

Everything around this feature is still shared, and shared parts moving to not-shared and then back to the shared parts works great! Since in the end everything is one codebase, for instance, iOS can simply ask Koin for the Analytics abstraction and track their events as we would have done in the shared code. This means iOS can leverage the whole setup done for KMM for DI, strings, images and still write code specific to their own platform!

Crashlytics

We made significant progress in the iOS app using KMM, but when we released our initial versions we noticed a problem. Crashes coming on Crashlytics, when from KMM, the stacktrace was useless. Not helping in anything, loads of __hidden, not pointing to proper places. This makes debugging really hard.

We found the library CrashKiOS, which was amazing to help us find more information on errors and actually fix a couple of issues.

The last remaining problem is when the crash comes from a Coroutines, when the stacktrace is still not helpful:

Non-fatal Exception: Throwable
0 SharedDriveScore 0x118388 kfun:kotlinx.coroutines#handleCoroutineExceptionImpl(kotlin.coroutines.CoroutineContext;kotlin.Throwable){} + 18 (CoroutineExceptionHandlerImpl.kt:18)
1 SharedDriveScore 0xc0a1c kfun:kotlinx.coroutines#handleCoroutineException(kotlin.coroutines.CoroutineContext;kotlin.Throwable){} + 34 (CoroutineExceptionHandler.kt:34)
2 SharedDriveScore 0xb81f4 kfun:kotlinx.coroutines.StandaloneCoroutine.handleJobException#internal + 243 (Builders.common.kt:243)
3 SharedDriveScore 0xc8f1c kfun:kotlinx.coroutines.JobSupport.finalizeFinishingState#internal + 231 (JobSupport.kt:231)
4 SharedDriveScore 0xceccc kfun:kotlinx.coroutines.JobSupport.tryMakeCompleting#internal + 925 (JobSupport.kt:925)
5 SharedDriveScore 0xce320 kfun:kotlinx.coroutines.JobSupport#makeCompletingOnce(kotlin.Any?){}kotlin.Any? + 845 (JobSupport.kt:845)
6 SharedDriveScore 0xb5648 kfun:kotlinx.coroutines.AbstractCoroutine#resumeWith(kotlin.Result<1:0>){} + 104 (AbstractCoroutine.kt:104)
7 SharedDriveScore 0x1edac kfun:kotlin.coroutines.native.internal.BaseContinuationImpl#resumeWith(kotlin.Result<kotlin.Any?>){} + 48 (ContinuationImpl.kt:48)
8 SharedDriveScore 0x1075cc kfun:kotlinx.coroutines.DispatchedTask#run(){} + 115 (DispatchedTask.kt:115)
9 SharedDriveScore 0xc3fec kfun:kotlinx.coroutines.EventLoopImplBase#processNextEvent(){}kotlin.Long + 24 (ObjectiveCUtils.kt:24)
10 SharedDriveScore 0x116dcc kfun:kotlinx.coroutines#runEventLoop(kotlinx.coroutines.EventLoop?;kotlin.Function0<kotlin.Boolean>){} + 80 (Builders.kt:80)
11 SharedDriveScore 0x11c8f4 kfun:kotlinx.coroutines.WorkerCoroutineDispatcherImpl.$start$lambda-0$FUNCTION_REFERENCE$649.$<bridge-UNN>invoke(){}#internal + 24 (ObjectiveCUtils.kt:24)
12 SharedDriveScore 0x24e60 WorkerLaunchpad + 86 (Internal.kt:86)
13 SharedDriveScore 0x7ca678 Worker::processQueueElement(bool)
14 SharedDriveScore 0x7ca3c8 (anonymous namespace)::workerRoutine(void*)
15 libsystem_pthread.dylib 0x1bfc _pthread_start
16 libsystem_pthread.dylib 0xa758 thread_start

We are still looking into solutions for this, so we are open to suggestions in this case! And if you also have this issue without a solution, please come and speak with us to share what we have found so far.

Conclusion

These are the main issues we have found, of course we had loads of other small ones. There were many others where, facing the issue one week, we found libraries updated the next week with the fix.

We are navigating in these new solar systems and not many people have gone this far. We are hoping that by sharing our mistakes and learnings, we can help more people come forward and use this new tech, without having to figure out patterns or solutions for the same problems others already had.

Sorry for the long post, but the whole point is to share more, not less on these topics, and — we could expand even more on each of the points! So, please give us your feedback over the content, and we can always improve for the next time — and I want it to have a lot of “next time”!

If you have questions please reach me out (@wbertan) on Kotlin Slack! (Request your invite here)

Don’t forget to help us to create more contents, so please share with your friends and give us some 👏 if you found this content helpful.

--

--