Building Cross-Platform Mobile Apps
Hi, my name is Jeff, and I work out of the Riot St. Louis office on the Always Available Friends team. We recently released the League Friends app for Android and iOS to give players a way to connect with their League friends outside of the game. During development, we quickly realized that we had two competing technical objectives: first, we wanted to tailor each app to its native platform to take advantage of platform-specific UI, data patterns, and hardware architecture. Second, we wanted to share as much business logic as possible to limit complexity, maintain a consistent experience, and lay a foundation we could use for future apps. In this article, I’ll dig into the decisions and development process that allowed us to share 80% of our code between Android and iOS as well as some tips we discovered along the way.
In order to deliver the best experience possible, we needed to build an app that felt like an Android app on Android, an iOS app on iOS, and a Riot app on any platform. We first looked at making use of shared C++ code. This would provide common code in a familiar language, but it would add complexity, additional tooling, lots of clumsy binding code, and painful debugging. We’d have to pipe large data sets from common code into each app’s specific code. It was a viable solution, but it didn’t quite pass our KISS test (keep it simple, stupid!). And we are huge fans of KISS.
Another option was to use the open-source tool J2ObjC, which translates plain Java code to plain Objective-C. Note that it’s not Android Java to iOS Objective-C - the common code is written as platform-agnostic Java and then translated. Using J2ObjC would allow us to build the Android app in Java and then translate to Objective-C with app-specific Swift for iOS. The tools and debugging would be standard for each platform, stack traces would make sense, and everything would compile together.
In the end, J2ObjC gave us the cleanest development process and the flexibility we wanted in our mobile architecture. Our development cycle looked something like this: write Java code, compile Java, translate the Java to Objective-C, compile Objective-C, run the Java unit tests, translate the Java unit tests to Objective-C, and then run the translated Objective-C unit tests. We can even run integration tests on the desktop.
It also made sense for us to build business logic that we could apply to potential future mobile apps, so we decided to structure our mobile core as a separate versionable and releasable product. This quickly evolved into a set of mobile modules, or “mobules” as we call them, that make up the majority of the business logic. Aside from the generated Objective-C, which can look a bit odd, it looks like we wrote our mobules specifically for each platform.
Putting it all Together
This method meant we were up against a pretty big tooling hole. It’s sorta like saying we decided to use clang, but there wasn’t really a make/cmake/scons/ninja type build tool to actually build it. So we built a Maven plugin to put everything together. Maven ended up working well when we tied the stages of a J2ObjC build to the Maven lifecycle. As described above, we didn’t bother doing things like translating Java to Objective-C if the Java didn’t compile. We also made sure that all tests happened during the test phases as these can take a long time.
We designated each Maven module as having multiple target artifacts. Besides the original Java JAR, we built a zip file of the full translated Objective-C source, a zip file of the translated Objective-C headers, compiled, fat-binary libraries (.a), and dynamic frameworks for both OSX and iPhone. These were all managed as normal Maven dependencies with unique classifiers, such as objc-sources, objc-headers, objc-resources, objc-library, or objc-iphone-framework. This gave us the flexibility to have a multi-module Maven project, where both the Java and Objective-C artifacts of a mobule could depend on other mobules. Since an Objective-C mobule could depend on compiled library artifacts, we didn’t need to fully compile all our Objective-C code for each module’s unit tests, which saved lots of time!
Handling dependencies is a pretty different problem on each platform. Since we used Maven, the Maven dependencies worked pretty much as is in Android Studio using Gradle.
For iOS and Xcode, things are a bit more complicated. Each module built with our Maven plugin produces multiple artifacts. We wanted each mobule’s pom to declare a dependency on other mobule projects, not on each specific artifact that project produced. Also, there is no single dependency tree for each of the plugin’s goals. For instance, when we build the unit test executable, we need to link against the OSX libraries. When linking the the iOS framework, we need the iOS libraries. When packaging a full source archive, we don’t need any headers or libraries. And finally, each mobule may produce only a subset of the Objective-C artifacts. Some mobules may not have any resources or some may be made up of only header files.
In order to handle these types of dependencies, we decided to add some metadata to each mobule’s primary JAR artifact describing its associated dependencies. This metadata gets written automatically into the JAR’s manifest by the plugin. It contains the version of J2ObjC that translated its associated Objective-C artifacts, as well as which artifacts were produced. The plugin is then able to build a primary dependency tree from the pom, resolve all the primary JAR artifacts, read the metadata from the JAR’s manifest, and then build appropriate dependency trees for each goal’s target artifact.
To get these Objective-C artifacts into our Xcode projects, we decided to use Cocoapods. The Maven plugin generates multiple podspecs with access to each target’s dependency tree. These podspecs allow for either source level dependencies or dynamic frameworks to be easily added to the Xcode project and kept up to date. We already had a private podspec repo used for other shared, in-house iOS code, so we added automatic commits of the generated podspecs to this repo whenever we do a release build.
A huge benefit of our approach is that it gave us access to the full scope of the mobile platform. This is not only all the awesome stuff in the Android and iOS SDKs, but all those awesome open source projects. Our business logic is really a level above things like database, networking, radio, and power states. Besides UI, these are key pieces of the apps that need to make use of the native facilities. In order to do that, we designed the core logic around standard interfaces.
These interfaces were very simple and constitute no actual business logic. For instance, the networking interface allows for HTTP requests with key-value headers, a body string, and few options. The actual app developers then wire up the modules, which use the interfaces, with implementation drivers. If you want to use OkHttp on Android and AFNetworking on iOS, no problem. Since the apps make use of dependency injection, we let the DI wire it all up.
When developing an API, it’s sometimes tempting to make calls asynchronous or provide sync and async versions of calls. In our case, it really just adds complexity and uncertainty. A well-written mobile app should make use of asynchronous operations to get long-running or pausable operations off of the main thread. It makes sense to have the apps dictate this. We make use of the reactive paradigm (via RxJava and ReactiveCocoa) to manage the observable executors and efficiently compose them.
Cycles and memory leaks
Java memory leaks are a thing, and there are tools to analyze cyclic references resulting in leaks. Lots of these are false positives and many are the result of non-static inner classes and anonymous types. It’s a tricky thing to avoid completely. Reference cycles are a very easy way to bleed memory leaks over to Objective-C. The J2ObjC authors bundle a cycle finder tool, which looks at Java code and provides some clues about the code that might be a problem. This is not foolproof, but we took the approach to deal with every reported issue. You can deal with real issues via a code change, or false positives with a handy set of Java annotations that the tool understands. Once the “noise” is removed, new warnings from code changes are more tangible.
Java, but not Android
The final tip is really a gotcha. Although you are writing Java code, you are targeting both the Android platform and the J2ObjC transpiler, which do not fully support any Java language version. If something is available on one platform and not another, move it to a driver. Also, limit all core dependencies to a known working set. The J2ObjC distro includes support for things like Guava, Junit, Mockito, protobufs, Jsr305, and javax inject. If you use these, understand the impact they have on the mobile platforms. Guava, for instance, will drive your precious 65k android method count up by ~14,000. It’s also important to diligently test everything that you translate after the fact.
That’s it! That’s how we wrote the League Friends app and how we shared approximately 80% of the code between the platforms. The decisions we made during development will help us create new features for League Friends and give us a foundation for any apps we build in the future. I hope this info helps you build your own mobile apps. If you’ve got any questions or want to chat about your own projects, let me know in the comments! And if all of this sounds interesting, we are hiring.