Customers ask me for advise for how to best structure their "cross-platform" projects with Elements, all the time. So with this post, I'd like to summarize some experiences and best practices from cross-platform and multi-platform projects I have worked on myself over the past year or two.
Not all of these approaches will make sense for every project, as a lot depends on your goals and also on the kind of project and the platforms involved. But I hope this will give you some starting points and general ideas.
Shared Projects
One feature I want to mention before we get started are Elements' Shared Projects. Shared projects are nothing magical, and you don't need to use then, but they help a lot with file management.
In essence, a Shared Project is just a dumb container for files that will be part of multiple projects. You can either add the same code file(s) to multiple projects, or you can put them in a Shared Project, and have the "real" projects reference the shared one – pulling in all the files.
The effect to the compiler is exactly the same, but it makes it a lot easier to manage (and add new) files, if you can do it in a central place.
In addition to the docs topic linked above, this tutorial covers Shared Projects well and tells you everything you need.
Different Sharing Models
There are several different ways to write cross-platform and/or sharable code, and – as mentioned above – it depends a lot on your exact use case, which method (or combination of methods) works best.
I will explain a few approaches I took in code I'm working on here internally at RemObjects and externally, as well as for personal projects, and leave it to you to pick what's best for your case.
#1 Fully Platform Independent Code
Sometimes (or for some portions of your app), the easiest option is just to write code that is completely unaware of the platform, and can compile anywhere.
This is especially useful and doable for non-UI based functionality, libraries, command line tools and "algorithmic" code such as mathematical computations, business logic or workflows.
A couple of internal projects here at RemObjects come to mind that follow this approach.
- Our open source CodeGen4 library is written in this way, and a good place to look at for inspiration. CodeGen compiles for Cocoa, .NET ands Java, and the codebase has, literally, not a single
#ifdef
. - EBuild, our upcoming replacement for MSBuild to control the Elements build tool chain is also written this way. It does have a few conditional defines, but in general, the way it's structured I can just write my logic and not worry about .NET vs. Cocoa, and the code I write just works, no matter what it's compiled for.
The key for a code base like this is making heavy use of Elements RTL, our cross-platform base library. Elements RTL has become incredibly sophisticated (partially driven by Fire/Water, more on that below), covering areas such as collections, XML and JSON parsing, String manipulations encodings and conversions, working with dates and times, and much, much more.
For the right kind of code base, you'll find you can just forget about .NET, Cocoa, Java APIs, buckle down on Elements RTL and write code that builds and runs everywhere.
For parts not covered by Elements RTL (yet), you can write your own simple abstractions, or maybe even consider contributing a class or two to the open source project:
Custom Abstractions
Elements RTL covers a lot, but not everything. For the Curaçao Weather app that I wrote as a personal project, this was the case for some of the shared code.
While things such as JSON reading, date time parsing, etc. were covered well, the app needed some types that are out of the scope for Elements RTL. For example, the app works with maps a lot, so I created simple helper types that abstracted data such as a geolocation (latitude, longitude and altitude).
I mapped these test types to native classes on the supported platforms (eg. CLLocation
on iOS and the Google MapLocation
class on Android), so that I could write common code that did the math on this (for example, determine if a user's location was on the island), while still passing this data into each platforms' native map APIs as needed. I also created thin extensions for Image
and Color
classes, so that shared code could refer to, say Color.yellow
or Image.imageNamed("RainIcon")
and get platform-specific bitmap classes.
#if COCOA
typealias Image = UIKit.UIImage
#elseif JAVA
typealias Image = android.graphics.drawable.Drawable
#endif
#if JAVA
extension Image {
static func imageWithContentsOfFile(_ filename: File) -> BitmapImage? {
let bmOptions = android.graphics.BitmapFactory.Options()
return android.graphics.BitmapFactory.decodeFile(filename, bmOptions)
}
static func imageNamed(_ name: String) -> Image? {
...
}
I find it easiest to try and stick to one platform's naming conventions (here, Image.imageWithContentsOfFile
, for example) and then create a matching extension on the other(s).
So, Rule #1: Use Elements RTL where you can. Rule #2: Mapped types or alias+extensions are two good options for abstracting existing APIs so you can use them in a platform-agnostic way.
Mixing Shared and Custom Code
Staying with Weather for a bit, I ended up with essentially two sets of code: one large section of business logic was in my shared project, and entirely shared (with a few #ifdef
s). On top of that, the actual views and platform-specific code was written twice.
Essentially, I created a regular new iOS app, and a new Android app. I added Views (in iOS parlance) and Activities/Fragments (on Android) as if I was creating two standalone apps (well, in a matter of speaking, I was). A lot of the apps' structure was deliberately different between the two platforms.
But both projects did just reference the shared project with all the actual meat and logic and business class.
Each app had its own code for how and where to display things, how to navigate between views, or even to show the Map view – because these things were actually different in the two apps. But whether it was CurrentCondiitonView
on iOS or CurrentConditionFragment
on Android – both call into the same classes to get their data, and that shared code takes care of downloading, caching, refreshing etc. All coded once.
Sharing Some UI Code
Depending on the view (and how different it is between apps), different decisions can be made for where to draw the line between "logic" and "UI". For example, many views in Weather are plain table views with data. Driving, say, the order of items to show could (and should) be shared.
The app actually does not go quite far enough, as I wrote the iOS version first using classic "large switch statement" logic to drive the UITableView
. When I started to work on the Android version, a different approach was needed where I populate an array with information that drives what data to show in what table row. In hindsight (and for v2), I should go ahead and move a lot of the Android logic over into the shared code, and let the iOS table view leverage the same logic.
Sharing More UI Code
Fire/Water is the second big "UI"-based project that's driven my experience with code sharing.
Until half a year ago, Fire was a pure Mac app that used Cocoa thru-out – all the low-level code was NSString
here and NSURL
there, and all the UI was (and still is) driven by native Cocoa control and the MVC model of ViewControllers and (XIB-based) Views.
The first step was to refactor all the non-UI code using the same paradigms as discussed above. This was a large undertaking given the vast scope and size of Fire, but nonetheless very straight-forward. It took about 2-3 months, and that's including creating large portions of Elements RTL based on what Fire needed (such as our sophisticated Url
and XmlDocument
classes).
But that's all boring, if you've come this far. More interesting is the approach to sharing the UI code.
Here's what the files for a view in Fire would look like, before the conversion:
Essentially a code file with the View Controller, and the XIB file that defines the view ‐ classic Cocoa.
Here's what the a similar view looks like now, for Fire/Water:
You see there's a few more files. The View Controller is still there. In addition to the .XIB that defines the Mac UI, there's also a .XAML that defines the Windows UI (with a matching but usually mostly empty .xaml.cs
).
There are also two new files, named .Cocoa.cs
and .WPF.cs
. What are these? Essentially what I did was split the view controller into a partial
class. The root file has all the shared code and logic of the view (which is the bulk), while the .Cocoa
and .WPF
parts have platform-specific implementations (and are enclosed in a giant #ifdef
, each).
The level of abstraction here depends a lot on the kind of view. Some views are vastly different between the platforms and have a lot of per-platform code; others have almost all the logic shared, with just a few per-platform helpers. In some cases, we even forgo the separate parts and just use a few #ifdefs
(if that) in the view controller:
Finally, some views I even chose to not convert/merge at all (like the Welcome view showing above), because they are just too different and separate between Fire and Water. Fire's Welcome screen is a dedicated form that shows when no documents are open, while Water's Welcome view is the view shown in the document window before a solution is opened &dnash; sometimes you want/need radically different UX paradigms between the different platforms.
As a result, Fire/Water is structured much different than Weather. For Fire/Water, pretty much all the code lives in shared project(s) and in shared classes (often with per-platform parts); it's much closer to a "cross-platform" app. Weather is more like two separate apps that just "happen" to use a shared library that provides all the logic.
Of course, Fire/Water vs. Weather are also nowhere near comparable in size, scope and complexity ;).
Taking it to 10x
There's much more chance to totally share code between platforms than you would sometimes think.
For Fire/Water, all the back-end code is the obvious, low-hanging fruit. The project system, the code that loads projects, parses them, deals with templates, manages files and changes, etc. all that was easy to share right at the start.
One place where I was amazed how much could be shared was the code editor itself. For Fire, I wrote a complete text editor engine from scratch; essentially an NSView that handles everything itself, from text input to drawing, over all the fancy overlays we do (like inline messages, current block highlights, etc.) to popup like code completion.
How much were we able to share? Pretty much all of it. Here's the SharedCodeEditor project:
FCECanvasCodeSnippet (long story ;) is the core editor view (an NSView
or UserControl
, respectively). Pretty much everything you see here is shared across platforms, including the drawRect
routine. There's a couple of .Cocoa
/.WPF
partial pairs:
- One for keyboard actions; how these get exposed to the UI is vastly different for Cocoa and for .WPF. All the actual action handlers are in the shared file, of course, as are the checks for what actions to enable. The Cocoa version basically just implements
validateUserInterfaceItem
while the WPF does a bunch ofCommandBindings
andInputBindings
. - One for some helper/abstraction methods for drawing, that help keep the
drawRect
function and logic shared - And finally two base level files with core view methods that are platform-specific (such as size calculations, or interacting with the scrolling container).
Like in Weather, but on a grander scale, additional abstraction classes help.
For example, on Cocoa the syntax highlighting and rendering code hugely depended on NSAttributedString, along with "string attributes" that Cocoa represents as an NSDictionary.
I created abstraction/extension classes AttributedString
and a new StringAttributes
class. The former is a mere alias to Foundation.NSAttributedString
and System.Windows.Media.FormattedText
along with a bunch of extension methods on WPF to make the APIs match. For the latter, Cocoa used plain untyped dictionaries to represent format styles, but I decided to create a slightly higher-level abstraction there.
As the end result, pretty much the entire Syntax Highlighting subsystem not only "just compiled" for WPF, the same code base also seamlessly creates exactly the objects that each platform needs to render efficiently.
type
{$IF FIRE}
StringAttributes = public NSDictionary;
StringAttributes_Extension = public extension class(StringAttributes)
public
constructor withFont(aFont: Font)
color(aColor: Color)
truncatesTail(aTruncatesTail: Boolean := false);
begin
...
end;
property font: Font read self[NSFontAttributeName];
property color: Color read self[NSForegroundColorAttributeName];
end;
{$ELSEIF WATER}
StringAttributes = public class
public
property font: Font;
property color: Color;
property typeface :=
new Typeface(font.Family, font.Style, font.Weight, font.Stretch); lazy;
constructor withFont(aFont: Font)
color(aColor: Color)
truncatesTail(aTruncatesTail: Boolean := false);
begin
...
end;
method mutableCopy: MutableStringAttributes; inline;
begin
result := new MutableStringAttributes withFont(font) color(color);
end;
end;
{$ENDIF}
In Summary
Again, the goal here is not to tell you "do it like this", but to give you ideas to get you started. There's no one-size-fits-all approach for this, and luckily Elements is designed to not force a specific level of abstraction (or lack of) on you – you'll decide yourself, based on your apps' needs, your resources, and also your goals.
I hope this article will get you going – and once you do, I'd love to hear about your experiences, or any abstraction/sharing ideas you come up with!