Write once, run anywhere?

What a migration to Kotlin Multiplatform taught Quizlet about the nuances of cross-platform development.
Part of
Issue 18 August 2021

Mobile

When a company ships the same product on several different platforms, engineering teams invariably begin to wonder whether they could be sharing code between the client applications. It’s easy to be overwhelmed by the number of tools that purport to be best for the job, and evaluating cross-platform frameworks can be a time-consuming (and possibly contentious) undertaking. Quizlet’s team dealt with this conundrum a bit earlier than most. 

Quizlet leverages state machines, rule engines, and algorithms to power the learning journey for over 60 million monthly users of our educational tools. When we started writing our native apps in 2012, our one-engineer iOS and Android “teams” manually translated this logic from JavaScript to Java and Objective-C. We had a shared product vision, but inconsistent architecture and planning across platforms led to implementation variations that increased development and maintenance costs. When we ran into user-facing errors or inconsistencies across platforms, the culprit was almost always a discrepancy in translation between languages.

We initially started sharing code across our web, iOS, and Android clients through internal JavaScript libraries in 2016, when our web engineering team was still over twice the size of our native teams. While this approach allowed us to scale our mobile apps quickly and overcome the challenges of platform-specific implementations, shared JavaScript had its downsides. Complex JavaScript code didn’t roll into standard native error monitoring tools, making production errors almost impossible to debug. Shared JavaScript was also much slower than native code on iOS and Android, and JavaScript had a hefty impact on the size of our Android app. 

As we searched for ways to overcome these obstacles, we evaluated (and decided against) React Native before migrating to Kotlin Multiplatform (KMP) in 2018, becoming one of its largest and earliest adopters. Along the way, we learned a lot about what to look out for when selecting cross-platform tools and how to make cross-platform code succeed at scale. Hopefully, you’ll find these learnings helpful for your own code sharing undertakings.

Evaluating frameworks

In a cross-platform code sharing utopia, programming would be a “write once, run anywhere” experience, and no engineer would need to concern themselves with platform-specific nuances. Mobile engineers, however, know this isn’t the reality, and we view shared code frameworks with a healthy degree of skepticism. In our experience, the nuances, complexities, and edge cases that go into developing a best-in-class app are impossible to cleanly abstract away.

Stories of glamorous successes and explosive failures told on company blogs, at conferences, and on Twitter often make it difficult to determine objective evaluation criteria. But each code-sharing tool provides different capabilities, so it’s important to evaluate them within the unique context of your stack, business, and team. Does the tool allow for flexible integration with your existing code? Does it complement your team’s strengths? Does it blend with your existing processes? Will you need to rethink one or more of these aspects?

Understand the scope

Many engineers assume that every cross-platform shared code framework aims (with varying degrees of success) to handle all possible scenarios one might encounter when building an app. While this is the vision of tools like Flutter and React Native, tools such as KMP or J2ObjC place user interfaces outside the scope of the core framework. When evaluating a tool that purports to cover a wider surface area, consider that this breadth is frequently supported by a more shallow handling of use cases—for example, Shopify found that React Native’s threading model couldn’t handle background tasks at the scale it needed without the app becoming unresponsive. When you inevitably need to go deeper than the shared framework allows, prepare to write the native components for each platform, as well as the bridging code to the framework.

KMP allows you to write cross-platform libraries to share business logic across your codebases, and because it assumes you’ll need to write platform-specific code (for example, to implement native UIs), it tries to make the process as easy as possible. This same interoperability allows engineers to incrementally evaluate KMP versus native code in existing projects without having to commit to drastic architecture changes or a large rewrite.

Again, it’s critical to evaluate these nuances within your specific context. The Quizlet team, for instance, revels in building native UIs; it was our complex state machines and rule engines that were tripping us up. KMP allowed us to focus our shared code efforts on the challenges that constrained us without also forcing a shared UI.

Think of the user

Sharing business logic for features via JavaScript enabled our smaller mobile teams to keep up with the web, but it came at a cost to our mobile users. Younger learners and international users are often on older devices, so they need lightweight apps that run fast. Using JavaScriptCore had a huge performance hit on iOS: Grading and other interaction-blocking flows incurred up to a 50x performance penalty compared to a native-only approach, resulting in a laggy experience. Things were even worse on Android: We researched several runtime engines, and J2V8, the only option with tolerable performance, increased our app’s size by 50 percent. 

Of course, performance and app size aren’t the only user-facing costs of shared code. Mobile users expect their apps to have consistent patterns and gestures, such as those outlined in Apple’s Human Interface Guidelines and Google’s Material Design. Building shared UIs without accounting for platform-specific differences can result in apps that seem off to users. 

Although Quizlet’s UI is intentionally free of complex interactions, the intricacy of the business logic that powers features such as our Learning Assistant (which provides personalized study paths, progress insights, and smart grading) was an obstacle to iterating quickly across platforms. Quizlet’s decision to go with UI-agnostic shared code platforms allowed us to specify what information users should see in the shared code while using native components and design language to render that information. 

Making it usable

Take it one step at a time

Cross-platform tools tend to promise the world, and it’s tempting to try to use a new tool in multiple architectural components or user-facing features at the same time. But it’s best to start simple. Rather than attempting several sweeping changes at once, pick a single, strategic problem to solve and make incremental progress. Celebrate your successes, learn from the shortcomings, and evaluate how to move forward.

We chose our grading rule engine as the first candidate for KMP adoption. Given that we already had a functioning JavaScript grading engine, focusing on porting just the engine into KMP allowed us to generate valuable data while restricting the blast radius and maintaining our ability to revert to the status quo if anything failed. Once we saw performance and bundle size results, we moved on to our Learning Assistant, a more complicated interface. By addressing our constraints one at a time, we were able to validate the framework, boost productivity, and wait for KMP’s tooling to mature instead of committing to tackling several problems at once.

Recognize it’s okay to bail

You did your research, picked a code sharing framework, and started to build with it. That doesn’t mean you’re stuck with it forever. Airbnb, for example, famously stopped using React Native for a variety of technical and organizational reasons, while Dropbox dropped its shared C++ libraries because of the hiring and maintenance overhead.

At Quizlet, we bailed on shared JavaScript and switched to KMP. While sharing our business logic had many positives, we could no longer justify the costs of using JavaScript, including performance and bundle size penalties on native clients, unacceptably high error rates with illegible messages, an unworkable debugging flow, and mobile developers’ discomfort with writing and consuming JavaScript. Switching to KMP not only improved all of these dimensions, but also allowed us to continue reaping the benefits of shared business logic.

Stress the API

When working with complex code, misunderstandings about the meanings or valid values of parameters or outputs can easily arise, even within a single programming language. Imagine the issues that can crop up when you’re working across languages with differing type systems! 

When we were duplicating similar logic across platforms, we initially found ourselves dealing with shaky assumptions about data nullability, handling backward compatibility for poorly thought-out decisions, and probing the guts of our implementation details because the top-level methods didn’t provide the information we needed. Investing in a well-defined, thoroughly validated interface for our initial platform-specific implementations made these concerns much easier to handle, even without directly sharing code. It also paid dividends once shared code came into the picture. An interface-centric approach allowed us to smoothly extract the code into shared JavaScript components, and later KMP libraries, and run side-by-side comparison tests to validate behavior and understand the performance benefits along the way.

Don’t skip the tests

Making changes to shared client-side libraries comes with extra overhead with regard to releasing, debugging, and communicating, just as it does when making changes to backend APIs. Without solid testing practices, small bugs, no matter how trivial, can bring your productivity to a crawl.

The logic our team extracted into shared code was frequently the easiest code to test. Shared code components often have very little state, and their most complex dependencies are abstracted away. Using KMP has forced us to create a clear separation between UI and business logic concerns—plus, KMP provides a cross-platform test framework that’s well-suited to testing business logic. 

Making it successful

At the start of your journey with a shared code framework, it might not seem like every engineer needs to know the ins and outs. But not sharing knowledge and resources early on can have serious consequences down the road. Before you know it, folks are either going out of their way to avoid using the framework, or they’re using it on several projects without enough people who can critique engineering plans, review pull requests, and get the team unstuck if they run up against a limitation of the platform (or their understanding of it).

When rolling out any new technology, it’s vital to support teams as they learn. Put together a concise curriculum for people new to the language and framework; we started with the Kotlin resources Learn by Example, Hands-On, and Koans. Ensure newcomers feel comfortable leaning on peers for support, and celebrate wins as a team. Most importantly, turn those newcomers into advocates for the platform. Once you’ve selected the right cross-platform tool for your needs and empowered your team to work with and advocate for it, you’ll be well on your way to success with shared code.

About the author

Ankush Gupta is a staff software engineer at Quizlet with a passion for learning and teaching. He wrote the first lines of Quizlet’s Android app as an intern, and now focuses on improving quality and efficiency by sharing critical code across all of Quizlet’s apps.

@ankushg

Buy the print edition

Visit the Increment Store to purchase print issues.

Store

Continue Reading

Explore Topics

All Issues