The Pursuit of Consistency on Mobile

Yev Kanivets
360Learning Engineering
7 min readJul 25, 2020

Developing breakthrough products is hard. Be it web or mobile, native or cross-platform, proof of concept, or production-ready app. By supporting several platforms, you multiply the difficulty by a factor of 2 or 3.

In this article, we will cover the following questions:

  • what is difficult about developing for multiple operating systems
  • why consistency is important
  • how to decrease complexity and increase the conformity of codebase

I’m going to share our experience of making native mobile applications more consistent, increasing our team’s coherence and bandwidth, and finally making our end-users happier 🤗

Inconsistent by design

As a mobile engineer with experience in both Android and iOS, I can assure you that even though these platforms look and work in a pretty similar way, development experience differs considerably: SDKs, APIs, IDEs, best practices, and even developer habits.

When I arrived at 360Learning more than two years ago, we had apps, which were not only architectured differently, they worked and looked different even in the most user-facing features.

You may think now that the main problem is an inconsistent UX between different OSs. But how many users do switch from Apple to Android and vice versa? Not many. So it's not an issue here.

The real problem with such inconsistency is much more about the team’s experience, including designers, PMs, and devs. It was complicated to communicate about changes (caching mechanics, API requests, etc.) since we were never sure what the expected behavior is, and everybody had their own vision and constraints.

The long way to unity

The first step in resolving the issue was getting the dedicated PM and designer (both are super cool professionals, by the way). It allowed us to unify all functional and design requirements.

We started to write detailed specs, crafted the design system, and conducted the whole app redesign. We hoped it magically resolves all inconsistencies in the apps. Well, there is no magic in this world 😥

The second step was to merge iOS and Android teams and make everyone able to perform on both OSs. At the moment, we have 4 out of 7 “ambidextrous” developers, which are often implementing the same feature twice. It allowed us to increase bandwidth and consistency in the long run.

But there is still some space for improvement:

  • tools and libraries aren’t the same on Android and iOS, so even for business-logic there are many differences to take into account
  • existing codebases differ, so solutions need to be adapted
  • later changes by other devs don’t make the codebase more consistent
  • and most importantly, these are still two different codebases, which evolve on their own

And here comes the third step, which we considered about six months ago — Kotlin Multiplatform. This technology allows writing and sharing the business-logic between many different platforms, but we are mostly interested in iOS and Android at the moment.

Proof of concept

After a quick POC (2–3 days of work), we made sure that KMP is:

  • viable and production-ready technology
  • can be integrated with our current codebase and architecture
  • can help us share everything up to the presentation level

Even though the POC wasn’t pushed to the production or fully integrated with an existing app for two reasons:

  • missing official multithreading support for coroutines
  • no bandwidth for rewriting existing module to 100% KMP

But finally, the time has come, and I was lucky to get the new feature, sufficiently separated from the existing codebase and complicated enough to justify the use of KMP for sharing the business-logic.

To give you some context, the feature is all about displaying user achievements, which includes the dedicated component in the user's profile, animated toasts, and popups to engage users to learn and collaborate more.

Initial setup

The are many great resources for starting the KMP project from scratch (KaMPKit, for example) and even ones describing the full migration process. But what about projects with many existing modules?

Setup is pretty much the same, with the only difference that we need to put a shared KMP module as a submodule of the top-level feature module. So your Android app isn’t even aware you are using KMP 😉

Top-level feature module structure

Since our iOS and Android projects didn’t have any shared code before, they are obviously hosted as separate GitHub repositories. But all KMP examples I’ve found were organized as a monorepo with the iOS project being a sub-folder of the Android’s one 🤔

To enable code sharing with two separate repositories, I’ve crafted a small Bash script embedded into XCode Build Phases, which clones a particular tag of the Android repo, builds a FAT framework, and links it.

Alternatively, it’s possible to build and add a FAT framework manually, but then you should include it into git, which isn’t the best practice. In our case, the size of the framework is about 50 MB.

Sharing is caring

Kotlin Multiplatform is not supposed to allow 100% code sharing between platforms. So you need to decide what exactly you are going to share and how it integrates with the rest of the app.

We are using the mix of MVP (Model — View — Presenter) and Clean Architecture in both our apps (with some legacy code). It was decided to share Model, Presenter, and Interactor levels in the KMP module.

AchievementContract from KMP shared module

The conjunction point is located between Presenter, which is 100% shared, and View, which is 100% platform-specific. But even View and UiModel have consistent interfaces thanks to shared Contracts.

Developer experience

Working on the Android app was almost the same as usual if not taking into account new libraries for networking (Ktor), database management (SQLDelight), and dependency injection (Koin). The most difficult part was the integration with the existing code.

To give the concrete example: fetching user achievements from the server requires a user token, which is stored in platform-specific storage and wrapped by a platform-specific repository. There is no way to access it from the shared code, so it was injected on the app launch.

On the iOS side, it was more complicated. I’ve spent a few days trying to understand why all my calls to shared code (in the form of the FAT framework) do exactly nothing. I ended up with this simple class, which returned empty string and zero (not 5 and "String" as expected) when called from the iOS app.

I’m not an expert in the iOS build system but looks like the problem was related to the linking step, which was missing without the CocoaPods Gradle plugin. If it’s not the case, correct me in the comments. There were a few more issues and limitations, which I describe in the “Tips” section.

Tips for KMP beginners

  • Start small and make sure it’s working before going big.
  • Minimize the number of Kotlin objects and global properties, because Kotlin/Native handles them differently.
  • If you absolutely need Kotlin objects (Singletons), check this blog post and make sure that you add @ThreadLocal annotation and use them on a single thread.
  • If you absolutely need global properties, make sure they are initialized in the correct order, since Kotlin/Native seems to do it alphabetically.
  • Limit the use of coroutines, since they aren’t production-ready in Kotlin/Native yet and work from the PR branch: 1.3.5-native-mt.
  • Limit the use of suspend functions in public APIs, since they aren’t supported by Kotlin/Native yet (coming in Kotlin 1.4).
  • If you absolutely need suspend function in the interface, always provide an alternative “sync” method for iOS.
  • Provide minimalistic overloads for functions with default arguments, since Kotlin/Native omits those defaults.
  • You can always see println’s from shared code in the iOS console 😉

Numbers

Engineers like numbers. Here they are 🔢

  • Lines of code: iOS — 1418, Android — 1519, shared — 687 (~30%).
  • Android APK size has increased by ~10% (from 26.4 Mb to 29 MB).
  • iOS bundle size has increased by ~15% (from 54 Mb to 64 Mb).
  • CI builds: zero impact for Android, +10 minutes for iOS.
  • The shared code saved me about one week of work.

Conclusion

Developing and supporting applications for multiple operating systems is a real challenge. If you don't pay enough attention to consistency, you risk ending up with miscommunication and frustration inside your team.

But consistency is tough, especially for big complicated projects. There is no silver bullet, no magical solution. Even though you will never eradicate inconsistency, you can use some tools to reduce it and keep it under control.

Kotlin Multiplatform is one of such tools, which, in my opinion, is the best option for sharing business-logic between native mobile applications, which allows us to decrease complexity and increase the conformity of the codebase.

Sources

--

--

Yev Kanivets
360Learning Engineering

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