Thursday, June 07, 2007

Port Model Train Software to OS X - Under the Hood

This is the second of two posts on my experience helping to port Train Player to Mac OS X. This is about my theories and designs for using OS X application technologies in a project which shares code with a Windows C++ application. This is not a code level look at such things, but a top level view. Also, while the framework was designed from the start to be cross-platform sharing code between Mac and PC, TrainPlayer for the PC is still using its old codebase, and may never make use of the techniques described below.

Why Is Cross-Platform Development Hard?

It may be a myth that the Russians chose a different railroad gauge to prevent invasion, but it is not a myth that commercial OS companies avoid open standard development APIs to keep their developers their developers. Neither Microsoft nor Apple cares much for Java, with Microsoft going so far as to invent a language, C# which looks like someone took a thesaurus to Java. The careful software engineer can still write the bulk of an application in vanilla C++ with wrappers to native GUI and application frameworks. But you have to be very careful.

Rejected Paths

You can write C++ applications in the Qt framework and have it run on Windows, Mac OS X, and Linux. I've written three such applications and every day use such Qt applications as MythFrontend and a Perforce client. My problem with Qt is that I don't believe it possible to write a first class OS X application in Qt, you can make a serviceable Windows application in Qt, but then again Windows users are not known for their esthetic sense. Also, there are licensing fees for commercial development, and Qt does a lot of weird things with C++ to allow the visual design of interfaces, and as a side effect locking you into Qt.

We could have written the whole application in Cocoa with no re-use of the Windows code, using just the document schema. But, that's an awful lot of wisdom to throw away.

The Path We Took

What is more valuable, the code you write, or the code you get for free with a framework? The framework code, because you won't have to throw money and time at maintenance. Do not replicate what are given for free on a platform for the sake of sharing cross-platform code, even if it means replicating some bit of functionality on the other platforms, or horrors having the Mac version have a different behavior than the PC version. I can't tell you how many times I've heard the same excuse about how the manual will be confusing if they aren't exactly the same. Get used to it, Mac applications are similar to PC applications, but they are different and Mac users demand their differences. And, few read the manual.

For example, most applications have simple menu needs. You could setup all the menu items in TrainPlayer in Apple's Interface Builder in an hour. You could add a menu item in 4 minutes. It is not worth anybody's time putting together some elaborate framework of XML descriptions files so that you don't have to edit the menus in both Interface Builder and Visual Studio. Yes, occasionally you will forget to add a menu item to the other platforms; better that then maintaining 500+ lines of code for cross-platform menus. Such code would be especially problematic in Cocoa where many menu items are loosely wired to the first NSResponder instead of a document or an application classes. On the other hand, if your menu needs are complex--perhaps you have a dozen different versions of your application, all with different menu subsets--then its time to write yourself an XML schema and go to town. Thankfully, such was not the case here.

Of course, Cocoa makes it so easy to do just about anything in the GUI that every time this comes up you end up just whipping up a little objective C class to provide data to: the car collections dialog, the pre-defined layouts browser. If any of these did anything truly complicated, the proscribed action would be to create a cross-platform model class, but anytime the Objective-C to C++ glue code is nearly the same size as the actual C++ code, it's time to just keep that platform specific.

Which brings me to what, if anything, is cross platform here? Anything that involves manipulation of the document class and its data structures is cross platform. We created a subclass of NSDocument and had it host an instance of the C++ object which contains the actual document data structures. The NSDocument subclass does things like pass in menu commands and connecting the cross platform code to the main view in the document window.

Cross-platform GUI

Any view which is composed of non-standard content, i.e. isn't composed of buttons, text, sliders, etc, but is something special to this application is created by adding a NSView we called a LayeredView and giving it a C++ based DrawingSource. This would include the main document view, the clock, the control panel, the side view of trains in the control panel and in the toolbar, and the switch editing panel. All these views, and only one NSView class. All this rendered content, and the logic for it is entirely cross platform. On the PC, I've written a corresponding MFC class which provides the same function as a proof of concept.

The LayeredView object for each platform provides:
  • Hosting for an arbitrary number of layers.
  • Capture of mouse and keyboard events passed to the DrawingSource
  • Drawing each layer when requested by the source.
  • Popping up contextual menus
  • Zoom level support

A DrawingSource object provides:
  • A list of drawing primitives (more later) for each layer
  • An affine transformation for each layer
  • Mouse tracking
  • Key press handling
  • Determination of which contextual menu to show.
  • Idle handling

Because the LayeredView on the Mac is an Objective-C object, the DrawingSource does not talk directly to the LayeredView but forwards requests for such services as layer invalidation, size changes, and screen coordinate conversions through a C++ DrawingDelegate object which the LayeredView installs in the DrawingSource when they are initialized together. In a mixed language setup, you will have to create these small interface objects anytime cross-platform C++ has to interface with some other language.

Drawing Lists

The DrawingSource provides a DrawingList which is just an STL vector of primitive graphic operations on primitive graphic structures. For example, adding a point to the current path is an operation, stroking the current path is an operation, drawing a bitmap at a given point with a given rotation inside a given box is an operation, etc. Lists have a number of advantages over the alternative of providing a wrapper API--providing a whole slew of drawing methods like FrameRect(), FillRect(), FrameOval(), FillOval(), FrameBezier(), FillBezier(), DrawText(), ClipPath()...--as graphic toolkits often don't map well to other toolkits. The PC TrainPlayer, for instance, makes heavy use of GDI Brush objects, to which a Cocoa programmer might say "What that?" and munge together some state object which approximates a Brush. With a DrawingList you are just creating a list of platform neutral instructions for drawing something, you don't have to worry about passing through a platform appropriate drawing context, you don't have to worry about flushing too often or not often enough. There will be a single routine implemented which knows all about drawing on the current platform and it will be optimized for rapid drawing. On Mac using the Quartz API, on the PC using GDI+. You could easily imagine alternative renderers based upon OpenGL or any other modern toolkit.

DrawingLists are conducive to layered drawing. Just keep a separate list for each layer. If nothing changes in that layer--maybe it's the layer of station switches in TrainPlayer and the current update involves the cars moving--then no need to recalculate it, just redraw it.

Another nice thing about DrawingLists, which I didn't take advantage of here because of all the legacy code, is that they can be generated on a separate thread. On multi-core machines (i.e.) all new machines, this can be a big win, especially if determining what to draw is computationally intensive. And each layer could have its own thread. Graphic toolkits tend to require drawing be done in the main thread, making any preparatory step which can be done in child threads helpful.

Data Types

I prefer using cross-platform data structures based upon STL or Boost, but sometimes this is not practical. Necessity and performance needs sometimes force the use of platform specific structures. You don't want to spend your rendering time converting raw bitmaps into platform native images. Therefore, I defined a set of macros:
#if _MAC
#include
typedef CGImageRef ImageBasis;
typedef CFStringRef StringBasis;
typedef std::string FileNameBasis; // treate as UTF-8
typedef CFURLRef FilePathBasis;
typedef CGAffineTransform AffineTransformBasis;

...

#define STRING_RETAIN(nativeString) if(nativeString != 0) ::CFRetain(nativeString);
#define STRING_RELEASE(nativeString) if(nativeString != 0) ::CFRelease(nativeString);
#define STRING_LENGTH(nativeString) (nativeString == 0)?0: ::CFStringGetLength(nativeString)
...
#else // not _MAC
#include
#include
typedef std::wstring FileNameBasis;
typedef std::wstring FilePathBasis;
typedef boost::shared_ptr ImageBasis;
typedef Gdiplus::Matrix AffineTransformBasis;
typedef std::string StringBasis;
...

In general, the cross-platform code get passed these structures, and they pass them through unchanged except for memory management issues through to the platform specific code.

And by the way, whatever string class you use, make sure any string the user sees is created and stored as Unicode; it's 2007 people!

Mac Specific Code

Rendering was done with the Quartz API with an assist from ATSUI for text rendering. Layers were handled via CGLayerRef (which TrainPlayer later simulated to get OS X 10.3 support). Utility windows, dialogs and the inspector floater were straight Cocoa using Core Bindings to interface with my NSDocument derivative. This was my first major use of Core Bindings, and I was extremely disappointed in its stability; the biggest problem I had in this development was trying to get around odd crashes deep in Core Bindings.

PC Specific Code

As I said, I only created a proof of concept drawing list renderer for the PC using the GDI+ toolkit embedded in an MFC application. GDI+ can do much of what Quartz can, although I missed the ability to cast shadows from arbitrary objects, and I had to do extra work to get layers.

Conclusions

I'm thankful for the opportunity to try out my theories on cross-platform development on a live target. I'm most pleased to come up with a methodology which has the potential to share large amounts of code between platforms, while still allowing me to create a first class OS X application. I'm especially fond of the drawing list design, and find it a good way to factor drawing calls even if cross-platform development wasn't important.

If I had had more time to devote to the project, I'd have worked at integrating technologies like the platform specific undo managers into the design, but with the birth of my daughter I have zero excess development time. Things could always be better, but I gave TrainPlayer a good start.