Effective fully-native multi-platform app development

Hype and excitement around cross-platform app development has never gone away. It seems to go in cycles: some big industry player releases a new framework which creates a lot of buzz, then it dies down slightly, only for another new framework to gain traction.

They all seek to let developers maximise reach and minimise development costs.

Efficient and innovative cross-platform development has always been important to us at Future Platforms; the “Kirin” model – shared application code running on both iOS and Android (but with separate UI) – has been pivotal to complex apps we have delivered for Domino’s Pizza, Glastonbury and Wembley.

We have always placed a high value on the benefits of this approach: time and effort saved by not solving problems twice over, with associated reduced costs of ownership and maintenance. The approach also ensures a single, consistent interpretation of client requirements, and therefore an equally good user experience across platforms.

But choosing to use a cross-platform approach for your app can be a risky decision. Frameworks such as React Native and Flutter add an additional layer between the developer and the machine, making it harder to diagnose any issues you run into (is this a bug in the framework, or am I just using it wrongly?). They also require the use of languages which are not otherwise used on mobile; JavaScript for React Native, Dart for Flutter, and C# for Xamarin.

For those reasons, many teams understandably choose to use a more traditional “vanilla” approach of developing two fully-native apps using all the recommended and supported languages, libraries and tooling offered by iOS and Android.

This could be considered less risky all round. It’s easier to resource developers with fully native experience. And there is no scary black box between you and the hardware. Any bugs or speed issues should therefore be easier to diagnose and fix.

Often, these apps are developed by two separate teams working independently. Taking the same requirements, teams will interpret and implement them in isolation. They then emerge at the other end with two functionally equivalent but architecturally distinct applications.

At first glance this looks like a reasonable approach. Two similar apps, but on entirely different platforms; there seems to be no particular reason to have them working closely.

This post will take a closer look at the typical architecture of a complex mobile app and ask whether we can do any better.

Clean architecture

iOS and Android development over the years has been particularly susceptible to the wrong kind of MVC pattern: the dreaded massive view controller, where all UI, business logic and other code gets muddled together in one large Activity or UIViewController. But recently there has been more and more focus in the mobile development community around applying clean architecture patterns: SOLID and MVP accompanied with test-driven development.

Applied successfully, the structure of a typical mobile app will look like this:

The components in this diagram fall in to two sections. Any code touching the iOS or Android native API surface is considered “platform specific”; these will have entirely separate implementations across platforms. What they share however is the interface by which the Presenter communicates with them.

Let’s take a simple example. Here’s an interface on to a means of storing key/value pairs:

interface LocalStorage {
  fun setString(value: String, key: String)
  fun getString(): String?
}

This could easily be implemented using SharedPreferences on Android and NSUserDefaults on iOS. Interfaces can be defined in a similar way for more complex services such as HTTP connections, or native analytics libraries, that perform essentially the same functions on both platforms.

Meanwhile the Presenter takes care of any business logic, and interacting with the views and native services when necessary. It should contain as much of the application code as possible.

Platform agnostic

If native dependencies are separated out successfully, the presenter code can be platform-agnostic. This is very helpful in our scenario of two fully-native apps. The architecture of the presenter, along with the interfaces to the native component, can be lifted from one platform to the other. Only the views and the native services need to be implemented separately on both.

This approach provides substantial value. The architecture of the presenter component represents the interpretation of the business’s requirements. It is where user journeys are crystallized and edge cases and errors are dealt with.

These requirements need to be defined clearly, and understood consistently among all stakeholders. They then need to be translated into code that correctly implements the requirements, and does so in a clear, maintainable way. This is a difficult task to do once.

Doing it twice with a separate development team will mean effort is duplicated, with inconsistencies in behaviour unavoidable for a large application.

But a more integrated development team can take advantage of the platform-agnostic nature of the presenter code, and implement the same system design across both platforms.

Design once, implement twice

The approach can be summarised as “mirroring”, or “design once, implement twice”. One platform takes the lead, and the other follows once a correct implementation has been verified.

For example, if the lead platform were Android, development would be around one sprint ahead of iOS. Features would be designed, implemented and fully tested in Kotlin first, before being ported to Swift. Porting would happen as much as possible class-by-class, method-by-method, line-by-line, with the UI implemented separately on each platform.

Kotlin and Swift are similar enough to make this a straightforward, intuitive process. An identical class and package hierarchy would be created on both platforms, with methods and variables taking the same names in both codebases.

Automated unit and acceptance tests created as a result of the TDD process can also be ported in a similar way, ensuring the correctness of the resulting code and that nothing gets “lost in translation”. This should mean that errors introduced at this point are rare.

This effective approach provides a raft of benefits. The process of porting code from Android to iOS means that more developers across the team gain a deep understanding of the code and the architecture. Knowledge is spread widely, and all developers have a common language in which to discuss the system.

Porting code to the second platform is not “free”, but undoubtedly takes much less time than developing two separate implementations in isolation. The hard thinking around implementing requirements and architecting a solution is done only once.

Having two teams implement features separately would undoubtedly have meant differing interpretations of requirements, and different unique bugs across each platform. This approach meant business logic was implemented consistently.

Code review

Code review is baked-in to this process. Porting logic to a new platform involves reading and clearly understanding the original code. This means that crucial business-logic has extra pairs of eyes on it, and therefore bugs and optimisations are frequently identified at the porting stage. The resulting improvements are then applied to both platforms.

Working in this way requires conscious effort on the part of the team to keep platforms in sync. Any refactors, enhancements or bug fixes have to be applied twice over, so it makes sense to begin the porting process for a particular feature or user story, only when it has been finished to the customer’s satisfaction on the leading platform.

We feel that the best way to work for this type of project is to use a cross-platform code sharing mechanism – one which allows sharing of business logic, but leaving the developer free to implement the UI using platform-specific tools. The Kirin framework provides a great way of doing this in Java and looking forward, we are very excited about the potential that Kotlin Multiplatform is showing for this purpose.

This development approach may be slightly non-standard, but in return we get instant code sharing across iOS and Android. Code, tests, bugfixes and updates are done once only.

However, in scenarios where a cross-platform framework is not possible or practical, the benefits of the code mirroring approach are numerous, compared to having two teams working in isolation. It is a highly effective way of delivering complex functionality across multiple platforms.


Leave a Reply

Your email address will not be published. Required fields are marked *