The similarities are more than skin-deep
Novoda has always built amazing products for Android, and more recently decided to expand our expertise to other platforms. Last summer, Novoda were approached by Oddschecker to build their new mobile product. This went on to become our first project on both iOS and Android.
I joined the project (and the company) with the goal of bringing the same engineering focus and quality that Novoda is known for on Android to iOS. With a small, brand new team of iOS developers, we wanted to be able to work alongside the Android developers and benefit from the existing expertise, processes and culture that have contributed to Novoda's success.
In this post, I’ll discuss high level architecture and the project structure decisions we made as well as the concerted efforts we took to align them on both platforms. I'm going to discuss why we took the approach we took, what that architecture looks like, and some of the key benefits it provides for both Novoda and our clients.
An app is an app. Underneath the cosmetic and slight navigation pattern differences, iOS and Android applications offer very similar experiences to their users. Creating great applications is time-consuming and expensive; anything that speeds up the development of amazing, robust and maintainable applications is a victory for our developers and clients alike.
Until we achieve cross-platform nirvana, how can we facilitate the development process on multiple platforms simultaneously?
Language and Tools
Whenever we build apps, we have to establish a domain-language for talking with the business and make sure we're working towards the same goal. The first thing we did was to make sure we did the same across platforms. Rather than look to share code, we looked to establish a shared mental model and a common set of terms. This meant creating similar layers in the application (more on that later), but it also went right down to how we structured the project and what frameworks we used.
Betting is a particularly arcane and complex domain, so it's proven particularly important that we all agreed on the meaning of various common domain terms, and also on how we refer to various parts of the app.
We made the decision to group things by these domains (e.g.
BetReceipt), rather than by layer (e.g.
View). Beyond the fact that we generally consider this a sensible way to organise a project, it also makes it much easier to deal with slight differences across platforms. Android has Activities and iOS has ViewControllers, but knowledge of these differences isn't needed to successfully navigate your way through the codebase. If you're interested in any aspect of account management or bet placement, you can find the relevant folder and the appropriate layers and components will soon become apparent.
Early on in the project we agreed to make use of ReactiveX (Rx) on both platforms. RxJava was already familiar to Novoda and we were keen to pick it up on iOS as well. We adopted RxSwift, right about the time it was being picked as the official Swift implementation of Rx. Although a potential risk due to the lack of maturity of the framework at the time, it's one that we considered worth taking and one that has paid off considerably.
One of the key components to cross-platform architecture is having standard interfaces/protocols. The implementation can be totally different, but as long as the various components present similar APIs then we can have sensible discussions about how to extend, modify, fix or improve the application. Beyond the numerous advantages that Rx itself offers, having a standard way to transport data throughout the app on both platforms is a great advantage when designing cross-platform APIs.
Layers of Abstraction
Let's get a little more concrete about our layers of abstraction. The exact terms and architecture are not crucial here, the fact that they are platform-agnostic and that we agreed on them on both platforms throughout the project are the key.
Service, Repositories and Data Sources
Whatever you call the business logic layer of your application, somewhere along the line you'll need to fetch data from a network, make some requests, store data to or fetch data from disk, etc. This is where it happens, and anything above the networking or database layer is entirely platform agnostic and can be discussed as such.
For us, this meant all data is exposed as Observables, containing not only the data but also the state captured in the pipeline. This
DataState contains both optional, immutable data and whether the
Service responsible for it is
Busy, or has an
Error. Other parts of the application can subscribe to updates and the UI layer can make sensible decisions as to how to display this to the user.
Any actions on the domain are carried out via
void methods, that is, no result is provided directly. Any changes to the state, and thus to the UI, are reflected in changes to the various
DataStates that can be observed. This approach is inspired by various uni-directional data flow architectures used in web frameworks, including Flux. I'm personally a big fan of this approach, but once again, the key part is that this was exactly the same on both iOS and Android.
Façades and View Models
Facades are what we called the objects that convert the generic-domain APIs into the requirements for a particular context, generally corresponding to a “screen” in the application. This is where we combine various data streams into a
ViewModel that contains all the presentation data for the current context. Note that in our case, a
ViewModel is a simple value type, the
Facade is what receives user commands from the UI layer and interacts with the
On iOS this meant
ViewControllers that would subscribe to updates and bind themselves to this
ViewModel, on Android the Activity would use a
Presenter to bind the
ViewModel and the
View. There is scope for even further alignment between the platforms nearer the UI layer, but nonetheless the differences are minor and localised enough as not to get in the way of fruitful collaboration.
Core and Mobile
A slice through the app’s components and the data flow
Novoda has long had a clear separation between the "Core" and the "Mobile" layer of any application. This started out in part from a desire to unit test back in a time where this was almost impossible on Android, but this pure Java Core used by an Android layer leads to a clean separation between the domain concerns and the UI concerns. This is something that all applications should strive for.
On iOS, in Swift, this has meant a Core that relies only on Foundation and the Swift Standard Library (and in our case, RxSwift). Since iOS 8 and dynamic frameworks, Core can be a separate target that needs to clearly define its public API, thus enforcing a clean separation between the layers.
For the architecture described above, this means
DataSources living in Core and
Views living in Mobile.
This simple separation is a good step towards a proper hexagonal architecture, decoupling your business logic from the platform detail. In the ideal case, this Core would be truly cross-platform. Java2Objc is an attempt in this direction, but it still requires transpiling between languages. As Swift compiles to more and more platforms, and with Foundation being reimplemented in Swift, this could become genuinely viable. I dream of a Swift Core that can be shared across iOS, Android, the Desktop and the server.
One worry of working so closely across platforms that has cropped up is the idea that the resulting product could end up only reaching the lowest-common-denominator when it comes to feature-set. This is particularly true as our entire process is shared, down to having a single user story to be implemented on both platforms. If we're always working on the "the same app", how can we make the best of what each platform has to offer?
Not only are differences in capabilities between the platforms get smaller each year, but the issue is easily avoided if the team is encouraged and willing to be flexible when necessary. If anything, a feature available natively on one platform might trigger some creative thinking in how to provide similar functionality on another, improving the experience for all users.
More than the sum of its parts
Whilst we’re still not sharing code across platforms, a shared architecture and a shared domain language has provided us with numerous advantages. For one, it enabled developers proficient on both platforms to effortlessly switch between them, maintaining the same mental model of the domain and the application structure.
But as mentioned throughout this post, even without cross-platform developers, such an approach enables the entire team to discuss implementation decisions, possible issues, and estimate the complexity of upcoming features. This ceasefire in the platform wars enabled traditionally silo-ed teams to communicate and collaborate. Two pairs of developers can operate as a single team of four, sharing the impact of effort, insights and discoveries to both platforms whilst maintaining the platform-specific expertise required for top quality applications.
This general collaborative approach extends to how we always aim to include backend developers, designers, testers and stakeholders in the process of making applications. If you're looking to offer high quality experiences to your users on both platforms and you want to deliver these in a timely and cost-effective manner, you need a single, cross-functional team.
- Agree on terminology and keep the same project structure
- Use similar tools and frameworks where possible
- Have consistent interfaces/protocols
- Separate your business logic from your application layer in a consistent manner
- Be flexible and creative
- Have a single happy team :)