A year ago, we announced our decision to place our bets, and our developer resources, on React Native. As you could expect, the move was met internally with a mix of excitement and apprehension, but a year later, we are still convinced that we made the right decision.
But making the right decision doesn’t come for free. We’ve had to rethink our development best practices in the mobile arena, re-standardize patterns, and choose and reject which open source projects to adopt (or, in the case of Software Mansion’s Reanimated and Gesture Handler frameworks, which projects to support financially). More importantly, we had to determine how to leverage React Native while still using iOS and Android native features. This article focuses on one topic in particular: when and how we write native code. I’ll be referring to our “Point of Sale” app—it’s influenced how we write React Native code at Shopify.
What I Mean by “Native Code”
If you are an Android or iOS developer, you are always writing native code. In React Native-land, though, this is something that you take on only with a sigh and after rereading the Native Modules documentation. At Shopify, we started our foray into React Native with the attitude of “only when absolutely necessary” towards writing native code. This meant that if you were writing a view, we strongly discouraged devs from making a Native UI component or Native Module.
For the most part, this attitude has been a good North Star for our developers—especially as we began the process of learning the dark and dusty corners of the React Native runtime. If you only ever make “the best” decisions, you never truly realize why some decisions are better than others. But…
There’s Always a But…
We eventually ran into limitations with this approach. First, when writing UI, it is easy to churn on components that combine scrolling, dragging & dropping, and high performance. One particularly difficult component we maintain is a grid of buttons that can be long-pressed (like the iOS home screen), making the items draggable and reorderable. There could potentially be hundreds of buttons in the scroll view, with different designs depending on the tile type. This view has a lot of complex animation and gesture interactions. We wondered whether we were asking too much of React Native—should we instead be writing these views using Native UI Components?
Another frustrating problem was with background processes. To support the app in offline mode, we need to routinely synchronize the local database with the server. We found that as the number of background jobs increased, we experienced poor responsiveness due to the combination of single-threaded JavaScript (JS) and single-threaded UI threads. Most of the heavy lifting in the jobs (network and database access) were handled with native libraries, and we assumed that these libraries were “well behaved” (not blocking the JS or UI threads). But we found that whenever the jobs were running, the app became almost entirely unresponsive.
The JS implementation of our job handler was a list of services that enqueues small job functions with the whole job system running once a minute on a timer. A small delay between jobs freed the main thread for interactions, or so we thought. But the reality of working in React Native, especially in the case of heavy network and native-module interactions, going over the bridge is, for now, an expensive operation.
We decided that “going all in” on React Native meant that we needed to reach for native code solutions when they were applicable.
When We Write Native Code
You may be surprised to hear that we stuck with our original UI approach! In fact, we leaned into it even more. We decided that having our UI layer in React Native – even for complicated views – was still a better tradeoff than maintaining separate UI components for Android and iOS. Using why-did-you-render, the React devtools profiler, and our own Application Performance Index measuring tool, we got rendering performance up and leveraged Reanimated 2 to handle our drag and drop code. The biggest improvements to performance and user experience were due to a combination of reducing re-renders and lazy renders. If you are undertaking a similar refactor, consider what you can memoize at runtime and what calculations you can perform and store ahead of time. We got our FPSs way up, and now that problem is solved on all platforms.
Background jobs, on the other hand, would benefit from threading frameworks only available to native code. We decided to begin an experiment to explore a job manager written natively.
How We Wrote It
We had two options: write a new job manager in Kotlin for Android and port it to Swift/iOS
or try a new platform altogether, Kotlin Multiplatform Module.
With Kotlin Multiplatform Modules (usually abbreviated KMM) you have one code base that runs on Android and iOS(and possibly web as well!). The caveat is that any platform-specific code, things like file I/O, network I/O, accessing hardware, or OS features like Bluetooth and shared preferences, is put into expect
and actual
classes or injected as a dependency that conforms to a protocol.
Our use case was perfectly shaped for this platform. The background jobs formed a well-defined graph, from fetching to persisting (and removing stale data) that could be written using the Kotlin standard library. The parts of our project that needed OS-specific code could either be handled using open source libraries that came with Kotlin Multiplatform support, like SQLDelight for the SQL database, and Apollo for GraphQL requests, or we simply wrote our own wrappers around File I/O, SharedPreferences/NSUserDefaults, and background thread handling (in conjunction with Kotlin’s coroutine library).
Most of the code lives inside common/
as shared, OS-agnostic code. At the time of writing, there are 3,447 LOC in common/
and 170 and 178 LOC in iOS/
and android/
folders, respectively—approximately 95% code sharing is fantastic! Also the code was a significant improvement to the original JS code in terms of performance. Previously the initial sync operation for a medium sized store took at least 30 seconds, but the new sync manager completed the same task in only 2-3 seconds. Having existing code to learn from improved code organization and the overall architecture.
Adding Kotlin Multiplatform to Your Project
There are two ways to add KMM to your project:
- Add KMM build steps directly to the Android project that’s part of your existing React Native project.
- Create a new KMM project and import it as a library into your app.
I recommend the second approach. As a separate library, you’re forced to have well-defined boundaries between module and application. As a bonus, you can create small sandbox Android and iOS applications that test your library in isolation from the rest of your application. Here’s how we set that up. In general, I recommend following the documented setup instructions, which use IntelliJ to generate the KMM library, but here are some more “down to the metal” instructions. They worked for us, but like all build tool setups, your mileage may vary. This covers the Android project setup and generating the iOS framework and assumes the starting point of a React Native application. For more in-depth instructions, I recommend reading these articles on Better Programming.
1. Organize Project Folders
We organized our “native-sync” project as a subfolder of multiplatform/ off the root project folder.
2. Add Library Folder to Project
Add the library folder to your project in android/settings.gradle
and add it as a dependency in build.gradle
:
3. Create New KMM Library
In themultiplatform/native-sync/
project, we need to do a little work to make the sandbox app and the exported library. There are too many nuances in gradle files to show all the build options. Hopefully, this setup will get your library compiling, but if these instructions don’t work for you, using the IntelliJ project wizard is the recommended way to create a new KMM library.In our case we decided to create the library by manually creating and editing the gradle files to give us the most control and understanding of the build settings:
4. Build The Framework
To build the iOS framework, you can use the command:
/gradlew :common:packForXcode -Pkn.iOS.target="arm"
.
The output of this command is a framework (in the common/build/
folder) that you can include in your Xcode project. This command should be run as part of building your Xcode project. Open the Build Phases tab of your project, and add a New Run Script Phase.
From here, we created native modules in the React Native Android and iOS apps, and those act as intermediaries between the React Native app and the KMM library/framework.
Downsides
For the most part, writing Kotlin Multiplatform Modules feels just like writing Kotlin code for Android, but only having access to the standard library. For developers coming from Swift, they have a steep learning curve: first, they need to learn React Native and TypeScript and that ecosystem, and then they need to learn Kotlin plus a specific subset that’s supported in Kotlin Multiplatform. Android devs have a leg up since they can bring to bear their existing knowledge of Kotlin. Less so if their background is in Java, of course.
Having completed most of the background sync project, we’re confident in the viability of Kotlin Multiplatform for most of the situations where we’ll need to reach for Native Code solutions. Our mobile app developers come from either a Kotlin or Swift background, and while Swift devs are at a disadvantage to their Kotlin counterparts, most people agree that Kotlin and Swift have a very similar “feel” in terms of language design and capabilities.
This article touches on many open source projects, and any time you are relying on open source you should ask yourself: will this be maintained for the foreseeable future, or could we maintain it ourselves? Facebook’s React and React Native projects are incredibly popular, and JetBrains has had nothing but success with Kotlin. We think that neither company is likely to ever walk away from these projects. Even Kotlin Multiplatform, a relatively new tool, is being adopted by many companies and projects.
Learning React Native often touches on rapid development and cross platform features. Hopefully this article will help you decide when and how you will reach for native code solutions.
Colin Gray is a Principal Developer of Mobile working on Shopify’s Point of Sale application. He has been writing mobile applications since 2010, in Objective-C, RubyMotion, Swift, Kotlin, and now React Native. He focuses on stability, performance, and making witty rejoinders in engineering meetings. Reach out on LinkedInto discuss mobile opportunities at Shopify!
We're planning to DOUBLE our engineering team in 2021 by hiring 2,021 new technical roles (see what we did there?). Our platform handled record-breaking sales over BFCM and commerce isn't slowing down. Help us scale & make commerce better for everyone.