My main activity of 2019 was a port of the Mental Canvas 3D drawing tool from Windows to iOS. About 75% of my effort was user interface code, maybe 15% on graphics and 10% other platform issues.
What’s interesting about our porting approach? Well, it’s just an unusual mix:
- It’s a UWP (Universal Windows Platform) app. UWP was Microsoft’s effort to modernize Win32 and add iOS mobile paradigms, so it is already touch-centric, relatively sandboxed and has a more mobile-like lifecycle
- We didn’t use React Native, Flutter, Electron, Xamarin, Qt or even Fuschia’s namesake Pink. We just… wrote some native code. And a lightweight abstraction layer.
- We completed the porting effort in seven months, with three developers.
- We stayed with the MVVM architecture that is the norm on Windows. Many iOS developers would call that… exceptionally ok
- We didn’t use the latest and greatest Swift tools, just plain old C++11 and Objective-C for the most part. We didn’t switch to SwiftUI when it was released half way through our port.
What combination of circumstances led to this approach? Mostly just a real life constraint: a desire to rewrite/redesign as little working code as possible, and keep the codebase’s size down.
- Starting Point
- UI Framework Choice
- Restructuring the UI Thread
- Lightweight iOS Bindings
- What About SwiftUI?
- Incremental Porting
- The Upshot: Code Reuse
Starting Point
We architected our app to be portable from day one, using a C++ games engine that had previously shipped for Windows, Linux, iOS and Android. It’s a professional 3D illustration tool and native user interface elements are expected, so an architecture was chosen to keep much of the application portable while still allowing quick development of a native UI.
At the outset of the port, our app was only running on Windows. From a portability standpoint there were three main considerations:
- User interface: the Windows UI logic was sound, but—as it was written in C++/CX—was not immediately portable to iOS
- Graphics API: Apple deprecated OpenGL 18 months prior to the start of our port, although they ultimately elected to allow it to survive 2019
- Platform: threading, file I/O, app lifecycle, networking and async primitives/APIs would all need some level of revamping. While a C++ app would normally be able to easily migrate file I/O, early UWP versions forced us to use platform-specific async I/O APIs
I’m going to focus on the user interface in this article, as that was the single largest hurdle that we faced, and the bulk of my effort. Before I get there, it’s worth a quick glance at each portion of our user interface structure:
- Model: the render thread holds the model (i.e., the contents of a 3D scene)
- ViewModel: the UI thread maintains a mirror copy of relevant parts of the model as a C++/CX object, plus some UI state. This allows use of simple binding between the View and ViewModel properties.
- View: native UI controls and their responsive layout are defined declaratively in XAML, with bindings to ViewModel properties established at compile-time
There is almost no shared memory across threads. Communication between threads is done using a dedicated incoming message queue for each thread.
UI Framework Choice
Should we use a cross-platform framework? We had a few unusual considerations
- Platforms: the combination of desktop (UWP) and mobile (iOS) narrows our choices, and we would like to protect for a future MacOS port (with Wacom tablet for input)
- Input devices: first-class pen support is unusual, particularly on mobile-focused frameworks
And some more common considerations in games or other apps:
- Look and feel: a “fun” feel is key to our app, and benefits from an “easy, responsive” user interface and native feel.
- Graphics: UI framework must be lightweight, and co-operate and overlay cleanly over a 60 fps (or even 120 fps) graphics thread
- Memory: our app is quite memory intensive, especially for mobile devices with only 3GB of RAM
We evaluated a number of frameworks before proceeding. The main frameworks under consideration were React Native, Xamarin and Unity. React Native was the strongest contender, with a Microsoft-maintained UWP port, pen support feasible (with a little effort), and a declarative/reactive UI language that was nicer than native UIKit’s imperative design. We built a quick, working prototype of our app in React Native to get a feel for the technology’s strengths and weaknesses.
In the end, though, we chose a native port. The main downsides that we saw in React Native were:
- no MacOS port
- a less “native” feel
- risk from one additional vendor dependency (Facebook) and one new technology dependency (Microsoft UWP port)
- complex toolchain
And as a more short-term issue, it was worth considering our staff’s lack of React experience. There’s always a fair bit of risk when you can’t “ease in” to a technology, and have to go straight to architecture without much concrete experience.
Restructuring the UI thread
As shown in the earlier figure, the non-portable UI code consisted of both ViewModel (~15%) and View code (~10%). The View code is unavoidably native, but what about the ViewModel code? ViewModels manage communication with the Model, which should be entirely portable; and they expose properties for the View to bind against… which depends on the platform binding mechanism.
So… let’s think about those bindings. Each ViewModel has a set of member variables that are “bindable properties”, and a notification mechanism when a change happens. In our app, we almost always use one-way bindings, and we use commands used to modify any ViewModel properties. Our UI is relatively simple, with limited animation and only a modest amount of content. We had already adopted a declarative mindset for the UI, with the aim of ensuring a single source of truth for the UI state.
The main issues we faced in making this portable were:
- Windows: the
{x:Bind}
mechanism requires the ViewModel to be a native object (in C++/CX, a ref class), and each bindable property has to be a special special native type. - iOS: at the time of port, there was no native binding mechanism. (SwiftUI was released halfway through our port effort.) We would have to roll our own.
None of this was insurmountable, and we were ultimately successful in making the ViewModel entirely portable, leaving only the View code platform-dependent.
Lightweight iOS Bindings
As a newcomer to Apple development, I found it curious that Cocoa Bindings existed on MacOS, but were never ported to iOS. Apparently they were slow and fragile, but… so are all of the awful hacks people seem to do in UIKit to keep their UI in sync.
At any rate, it proved fairly simple to write a few methods that simply track a list of source-target pair:
Source | TARGET |
---|---|
ViewModel + Property name | UIView + Keypath (KVO) |
e.g., Layer ViewModel + Opacity | e.g., UIStackView + @”opacitySlider.value” |
With some kind of notification/pub-sub system, we can watch for VM property changes, and call the UIView’s setValue:forKeyPath:
selector to update the UI accordingly. This hand-rolled binding system is not as robust or anywhere near as full-featured as a real binding system, but it is portable and does the trick for our simple user interface.
There are two little niceties that I may elaborate in a separate post:
- Compile-time keypath validity checks: borrowed from Nick Forge’s post, to catch typos earlier and avoid a compile + run cycle.
- Multi-bindings, boolean ops, and type conversions: various ways to combine/convert multiple ViewModel properties prior to binding to a UIView property.
Suffice it to say that the final syntax is something like this:
m_bindings->add
(
BIND_CHECK2(modeSegmented, setSelectedSegmentTintColor),
VmFn::IfElse(m_vm->IsNavMode, SNavModeColor, SDrawModeColor));
which is a continuously updating version of
self.modeSegmented.selectedSegmentTintColor =
m_vm->IsNavMode ? SNavModeColor : SDrawModeColor;
What About SwiftUI?
While I agree that MVVM is exceptionally ok, SwiftUI is clearly the “next stop” that Ash Furrow imagined. SwiftUI is a great fit for our architecture: bindings, declarative, relatively low-state, and the ability to use Combine to maintain the dependencies between different properties.
However, we haven’t pursued SwiftUI yet for a few reasons:
- Swift interoperability with C++ remains painful. We would need to maintain a lot of Objective C code to bridge our C++ data structures over to Swift… unless things change some day.
- Ship Timing: our ship date was September 2019 with a target of iOS11 and up. It’ll be a little while before we can limit the software to iOS 13 only.
- Development Timing: we had already ported 60% of the app by the time SwiftUI was announced. We’d heard the Catalyst rumours and very faint mentions of Amber ahead of WWDC, but there was nothing we could rely on.
I’m sure that we could use Objective C++ to bridge our C++ ViewModel data over to a SwiftUI-friendly data structure. Once enough of the market is on iOS13, we’ll face the following trade-off:
- Pro: Accelerated UI iteration using SwiftUI vs. UIKit. Less AutoLayout agony.
- Con: Pain of maintaining an Objective C++ bridge between C++11 ViewModels and Swift Views
Once Apple inevitably starts making some UI controls Swift-only, the decision will probably tip in favour of SwiftUI.
At the end of the day… if you’re going for portable, you need to use portable languages. Swift deliberately ain’t that; it’s Apple’s ecosystem accelerator and lock-in mechanism.
Incremental Porting
We had the good fortune of having a relatively well-composed set of ViewModels. While it was once a single massive ViewModel, steady refactors broke it into about 10 major ViewModels corresponding to each of the major on-screen UI elements.
One of the most pleasing benefits of that factoring was that we could port them over one at a time, in priority order. At any stage during the port, we could have:
- an iOS version that was demoable with a subset of UI
- a Windows version that still compiled and worked 100%
Towards the end of the port, business demands forced us to put all resources into getting the iOS version ready, at the cost of maintaining a fully working Windows build, but we paid that technical debt down promptly.
The Upshot: Code Reuse
Here are the architecture diagrams after completing the port.
And here’s an analysis of the source code size before and after the port:
Not too shabby. And note that a lot of that portable ViewModel code was not really a rewrite: it was more of a line-by-line translation from one dialect to another.
A few statistics:
- The size of the Windows build grew by 5% due to the port.
- Windows-specific code was 25,000 lines before the port. Only 14,000 lines of iOS-specific code was required with this platform-independent framework.
- If we hadn’t ported, and instead had 30,000 lines of iOS-specific code (including Metal backend), we’d probably be looking at a 110,000 line app. So we likely reduced our codebase size by about 10% using this approach.
In the iOS app, we can run with either an OpenGL ES 2.0 backend or a Metal backend. Given the much better Metal performance, we don’t intend to ship the OpenGL ES version, but it can be useful to have it occasionally to isolate bugs or to test in the iOS Simulator without MacOS Catalina.
So there you have it. Sometimes, doing a port the old way is a reasonable choice. No bragging rights for using the latest technologies, but… relatively few new bugs.