Key takeaways from our Kotlin Multiplatform journey

Yev Kanivets
360Learning Engineering
9 min readMay 11, 2022

--

Kotlin Multiplatform is one of the hottest topics in native mobile development this year. KMP gives us an unprecedented opportunity to share business logic written in Kotlin between Android and iOS native mobile applications.

The technology has already proven itself a production-ready solution applied by many large, medium, and small companies, even though it’s only in the alpha stage.

The developer community is super excited about KMP, with dozens (or even hundreds) of articles and sample projects published monthly. Now, it’s almost certain that developing your next native mobile app using KMP is a great idea.

But what’s about existing projects? Can we use KMP in legacy mobile apps developed by separate Android and iOS teams? What are the cons and pros?

In this article, I’ll share key takeaways from using Kotlin Multiplatform in 360Learning native mobile apps for almost two years to improve consistency, reduce the number of bugs, and finally, increase dev productivity.

Context

I’ve previously shared our journey to adopting Kotlin Multiplatform at 360Learning in The Pursuit of Consistency on Mobile. It includes all the necessary context, leading us to the first feature done with KMP.

Our GitHub activity

In short, we are a team of seven mobile developers, one designer, and one product manager working on native mobile apps for Android and iOS. There are 25K+ commits, with the first contributions made in 2015.

Most of the developers in our team do both Android and iOS native development, so Kotlin Multiplatform came very naturally to us.

Source code structure

Kotlin Multiplatform enables sharing of the code between different platforms — iOS and Android, in our case. But where should this code be located?

As for most teams working on native mobile applications, it’s natural to write common (KMP) code as a part of the Android application (both are in Kotlin), hence in the Android app’s GitHub repository. iOS repository hosts iOS-only source code with a script to sync and build common Kotlin code.

This approach has several significant issues:

  • Every time we need to change common Kotlin code from the iOS side, we make that change in the Android repo, make a commit, then Pull Request, have it reviewed, merged, and only then does it become available on iOS.
  • All changes made in the common code from the Android side aren’t synced to the iOS side right away because we depend on a specific commit. It creates an alignment debt, which increases with time and is paid off by the first “lucky” person who needs an update of the common code from the iOS side.
  • At the start, the iOS app depended on the latest version of the Android’s dev branch. It allowed fetching common code changes right away. But it was breaking the iOS app unexpectedly producing a horrible dev experience, so we decided to depend on a specific commit instead.

This is why we’ve decided to migrate to the mobile monorepo, which hosts both applications. The great bonus is that now we can structure the whole source code by feature modules that contain common, Android and iOS code.

The third option preferred by (much) larger teams is having common Kotlin code in libraries written separately from both applications. Needless to say that changing common code from both sides is complicated.

Project structure

Modularization seems to be the industry’s standard for structuring from medium to large mobile applications. Such structure ensures proper separation of concerns, encapsulation, and scales along with a team.

At 360Learning, we have about a hundred modules total in both applications, with 10+ modules written to be shared between iOS and Android using Kotlin Multiplatform. Sounds good so far?

After adding the third or maybe fourth module, we discovered that while multiple KMP modules are working expectedly well on Android, they don’t work on iOS. At least, they don’t work the way everybody expects.

I’ve described this issue in the Three-framework problem with Kotlin Multiplatform Mobile in great detail. But in a nutshell, Kotlin/Native compiles every module and its dependencies into a single iOS framework to be included in the iOS application.

It means that all dependencies are duplicated for every module. So such simple things as an authentication module (to share the bearer token) don’t work because each module contains its own copy of it. To my knowledge, JetBrains doesn’t plan to fix this problem.

The recommended solution, which we use, is to create an umbrella module that depends on all KMP modules and include it as a single massive framework in the iOS application. It doesn’t affect the Android codebase. The proper architecture ensures no misuse of globally available APIs on iOS.

API structure

Kotlin Multiplatform isn’t a cross-platform framework but a multi-platform code-sharing solution. It means that we, as developers, have great flexibility to choose what and how to share between different platforms.

With great flexibility comes great responsibility. At 360Learning, we use standard Model-View-Presenter architecture with Navigators, Interactors (aka Use Cases), Repositories, Data Sources — powered by Clean Architecture.

For all new features, we apply Kotlin Multiplatform from the beginning. In our case, it means sharing everything but View. Yes, only the View is implemented in a platform-specific way on Android and iOS.

The View layer only interacts with the Presenter level and needs to know its interface without any implementation details. Hence, in the KMP modules, we declare Contracts that contain View, Presenter, Navigator, and UiModel.

Example of the Contract

So only the Contract layer is visible from the outside of the common module by default. Everything else is internal, even though we sometimes need to make exceptions with legacy codebases like ours.

By the way, any non-internal class, method, or variable in the KMP module is automatically exposed in the Objective-C header of the iOS framework compiled by Kotlin/Native. It clutters module API and increases app size.

Development Flow

We use GitHub to manage our work (projects, milestones, tickets). Since we had two separate repositories for Android and iOS apps, we also had separate tickets, and they were supposed to be done independently and shipped at the same time.

The issue with a common Kotlin code is that it needs to be written only once, usually while implementing the Android ticket. Hence iOS tickets depend on Android — something new for most mobile teams. So it requires more and better communication between devs from different platforms.

In our case, the two tickets of most of the features were done sequentially by the same people on both OSs, which is more effective in person-hours but takes longer to ship the feature since the work can’t be parallelized. iOS was always lagging behind.

With the migration to monorepo, we also decided to have a single ticket for both OSs, so they are implemented, reviewed, and shipped simultaneously (if done by the same dev). We use labels to mark platform-specific issues or bugs if needed.

Developer Experience

No technology can succeed if it offers an inferior developer experience (hello Xamarin). It includes everything from SDK and IDE to the standard library and community.

Our experience with Kotlin Multiplatform is built around native mobile IDEs such as Android Studio and XCode / AppCode.

We aren’t using KMM plugin for Android Studio due to two reasons:

  • it didn’t exist when we started with KMP
  • it doesn’t provide many benefits with two separate repositories

As mentioned above, a common KMP module is usually implemented while working on the Android ticket without much struggle. Then the integration is done on the iOS side, which is generally the most complicated part.

Our first attempted features required a lot of changes in the common code to make it work on iOS. Mainly due to the technical limitations described in the following section. Some syntax of Kotlin just can’t be translated into Swift.

We’ve found what’s working and what’s not during the last two years. Hence we limit the set of keywords/tools while writing the common KMP code, so it works out of the box on iOS without much struggle.

Still, it’s highly beneficial to the team to clearly communicate with iOS devs the contract and expected behavior of the common code. Plus making sure that initial integration is working (mainly Dependency Injection), and supporting them along the way (pair programming and debugging).

Debugging the common code from the iOS side is difficult with two separate repositories. In our case, it’s done by making changes in the Android repo, pushing it, and fetching it on the iOS side by commit hash. It takes way too long to debug an issue like that.

With a migration to monorepo, we will start using the KMM plugin to simplify the debugging and the overall developer experience. For example, we will include common KMP code in XCode directly.

Our goal is to make the iOS dev experience with Kotlin Multiplatform as great as Android’s one. There is still a long way to get there.

Technical Limitations

Kotlin Multiplatform is still in Alpha with Beta planned for this spring. We started to use this technology pre-Alpha and were aware of the risks. We didn’t use it for critical features to stay safe.

The list of technical limitations we’ve discovered is quite extensive and mainly related to Kotlin/Native interoperability with Objective-C/Swift. We highly recommend reading the complete doc before starting your first KMP module.

Here are a few most annoying limitations we’ve faced so far:

  • Multithreading and memory management model, which is the main problem targeted by JetBrains this year. It’s already fixed in Kotlin 1.6.+, but performance is to be improved. As a temporary workaround, we wrap all calls to mutable KMP objects to be executed on the UI thread.
  • Some Kotlin features, like inline or sealed classes, coroutines, and others, are not well supported in Obj-C/Swift. So we decided to not expose them in the common code APIs at all.
  • All primitive Kotlin types are wrapped in Kotlin... classes on the iOS side. It means that we need to convert them to and from Swift primitives.
  • We use interfaces for scoping other interfaces (to define a contract), which isn’t possible in Obj-C/Swift, so the resulting protocol (interface) names are just long strings and sometimes difficult to find and strange to use (e.x. MySessionsUiModel.ContentSession).
  • Matching method names from different interfaces, like setUiModel, clash even if they take different parameters. Kotlin/Native adds _ every time such a clash happens, which becomes annoying after a dozen cases (e.x. setUiModel(uiModel___ uiModel: SessionStatsContractUiModel)).
  • We are using Koin for the Dependency Injection in KMP, but the graph is only checked in runtime and only for those nodes we use. Combined with iOS lagging behind Android, it can be tricky to understand what’s to inject.
  • More low-level issues are described in this article

As a team, we simply acknowledge these limitations and find workarounds or adapt our practices to not face them anymore. There are no real roadblocks.

Conclusion

If I had just one word to characterize our experience with Kotlin Multiplatform, that would be “flexibility”. If I had one more word, I would add “great”. And for the third word, I’d say “too great flexibility” :)

We had all the options on the table when we started with Kotlin Multiplatform. It took us two years to find our ways of doing things efficiently. And discard those options that don’t work, at least for us.

Now when the KMP has almost reached the Beta stage, it’s vital to lower barriers for entry, provide the default ways of doing things, share experience and case studies. This is one of the reasons we share our experience in this article. We hope it will be helpful for other developers and companies.

--

--

Yev Kanivets
360Learning Engineering

Technical Lead Mobile @ 360Learning | KMP enthusiast | Husband & dad | Marathon finisher