Portable iOS / Android App Architecture
At Casper, we have an iOS and Android app for our Glow light, and a small mobile engineering team.
We recently took the opportunity to improve portability of our code across the two platforms. I want to share the architecture that enables this, yet provides enough simplicity for an application of our modest size. It shares the Clean architecture philosophy around dependencies.
We didn’t start with this architecture — our app has been evolving into it as our needs, team and product change.
What does portable code look like?
- It can be moved between iOS and Android with little modification.
- It has no direct dependencies on UI or external frameworks.
This is the most important feature. “Direct” is key here — it’s OK for dependencies to exist, as long as they’re through protocols we defined. - It’s super easy to test.
That’s kind of a given, ’cause of the previous bullet. Tests will give us confidence we ported the code correctly. - Uses RxSwift/RxJava to manage concurrency
Because it’s way easier to port Rx than many platform-specific concurrency APIs (e.g.dispatch_queue
,Thread
, etc)
What about UI?
We don’t worry much about fully portable UI, for a few reasons:
- UI shouldn’t be complex enough to yield huge time savings from portability. If we succeed in making the UI layer small and dumb, we won’t gain much from portability.
- The cross-platform UI frameworks (such as ReactNative and Flutter) are generally less flexible, and are slower to react to platform updates or new features. This isn’t always a problem, but it was for us.
That said, portable UI can be a great choice for thin clients, or where only portions of the UI are portable.
Why not write domain logic in, say, C++?
Mostly because it’s easier to find Swift and Kotlin developers :)
The Architecture
Pure Swift and Kotlin are very similar, especially when Rx is leveraged heavily. Such code is easy to port.
On the other hand, each platform will have significant differences when it comes to UI frameworks, networking libraries, data storage, etc. Code that directly depends on such things is hard to port.
The architecture simply creates a barrier between these two areas of portable and non-portable code.
Non-portable code depends only on the portable code. Never the other way around.
Portable
- All your business rules and entities
- Fully covered by tests (it’s easy to test!)
Non-portable
- Presentation
- External, platform-specific libraries and frameworks
You want as much code as possible to be in the Portable camp, because you’ll save time by writing it once. It helps to make the Portable code a library that your application depends on, or at least to think of it as such.
Portable
Domain
Think of this part as a library (or make it a library, if you can). It has your app’s secret sauce. This part provides all the business logic to the application: it decides when to get data/entities, it performs calculations, it maps data, etc. It just doesn’t care so much of how data is stored, or where it goes once it leaves this layer. It consists of:
- Use cases (AKA workers). Plain Swift/Kotlin classes that you wrote. They’re actions your users can perform — they do the important work. They interact with external stuff through an interface, and provide data to your presenters (using Rx is handy here for async data).
- Entities. These are usually structs that just hold data.
- Interfaces/protocols. Your use cases interact with all the external stuff through these interfaces, also called gateways. They’re injected into your use case constructors, to avoid a direct dependency between your use case and platform-specific code.
This part of your app should be portable and testable, since there are no dependencies.
Your tests should cover use cases. You generally inject a fake of your gateway interfaces into your use case, and write your tests against that.
For example, suppose you have a ViewProfileUseCase
, which requires that the interface UserProfileGateway
is passed into its constructor. Your production code would have real concrete implementations of this gateway, such as AmazonCognitoProfileGateway
, or RetrofitCasperProfileGateway
. But your UseCase tests can create a StubUserProfileGateway
, which just returns some stubbed values.
Not Portable
Presentation
Presentation patterns such as MVVM, MVC and MVP just deal with… presentation. It probably doesn’t matter which you go with, as long as the rest of the architecture is followed.
We like MVP and use it on both platforms — the consistency helps us find bugs and make changes more quickly across platforms.
I won’t go into too much detail on presentation and MVP, but will highlight the key component roles:
- Presenter. A highly testable, plain Swift/Kotlin class. It’s only concerned with the behavior of a scene — how to display something, how to handle input — it shouldn’t deal with higher-level business rules at all. It makes data easy for your view to use, by transforming it. As a nice-to-have, it’s mostly portable between iOS and Android, but exceptions to this can exist.
- View protocol. Describes what your view can do or show. Your presenter depends on this protocol, so it shouldn’t have platform-specific dependencies (e.g. don’t have a method that takes a UIImage), or your presenter becomes less portable.
- View implementation. This is typically a
UIViewController
orActivity
. This class should not do anything important: ideally, there’s no code here at all, and everything you need is in XML or XIBs - Coordinator. We use this to handle our navigation. There’s one per each section of the app. Creates, transitions and passes arguments between views (e.g.
UIViewControllers
). It’s very platform-specific.
External Stuff
This part holds concrete, platform-specific implementations of gateway interfaces. For instance, you might have implementations specific to persistence and networking frameworks such as Alamofire, Retrofit, CoreData, ContentProvider, Realm, etc.
In addition, it holds any entities that these concrete implementations might need (things like API-specific request/response objects).
For example, the platform-specific implementations of AppSettingsGateway
that live here might be:
- On iOS,
UserDefaultsAppSettingsGateway
- On Android,
SharedPrefsAppSettingsGateway
- For tests, an
InMemoryAppSettingsGateway
(or e.g.MockAppSettingsGateway
, depending on what we need to test)
Parting Thoughts
There are many application architectures and patterns to choose from. Some, like VIPER, have many rules, and it’s hard to know if we’ve followed them as intended. Others, like MVC, have very few rules, and leave many questions unanswered. This architecture is what’s right for the size of our project and team today, but it’s always evolving to fit our changing needs, means, and experience.