Tips & Tricks · Uncategorized

Cross Platform Abstraction

Delphi supports quite a few platforms now and the FireMonkey framework abstracts a lot of the platform specific issues for us.

But occasionally you want to use a platform-specific feature that FireMonkey does not support (yet). Or maybe you want to use it outside of the FireMonkey framework.

For example, suppose you want to add some basic text-to-speech functionality to your app. Every platform has support for this feature inside the operating system nowadays, but they all do it in a different way. On Windows, you could use the ISpVoice COM object, and on Android you would use the JTextToSpeech Java class. Both macOS and iOS have similar classes for text-to-speech, but they have different names (NSSpeechSynthesizer and AVSpeechSynthesizer) and different APIs.

As a Delphi programmer, you want to abstract away these differences and present a single text-to-speech API to your clients (we show an actual implementation of cross-platform text-to-speech in this blog post). There are multiple different ways you can accomplish this. In this post we show a couple of approaches you may want to consider.

Global APIs

If your API is a global function, you can just IFDEF the platform-specific code in the implementation section of the unit:

function GetTotalRAM: Int64;
begin
  {$IF Defined(MSWINDOWS)}
  // Windows-specific implementation
  {$ELSEIF Defined(IOS)}
  // iOS-specific implementation
  {$ELSEIF Defined(ANDROID)}
  // Android-specific implementation
  {$ELSEIF Defined(MACOS)}
  // macOS-specific implementation
  {$ELSE}
    {$MESSAGE Error 'Unsupported Platform'}
  {$ENDIF}
end;

Just remember to check for the IOS define before you check for the MACOS define (because MACOS is also defined on iOS platforms). Also, it may be a good idea to add a {$MESSAGE} compiler directive if none of the defines match. That way, if you are going to support additional platforms in the future, to compiler will warn (or error) that you are missing some code.

Using an abstract base class

A common approach is the create an abstract base class that defines the cross-platform API with virtual and abstract methods. Then, for each platform you derive a class from this base class and override the platform-specific methods:

type
  TTextToSpeech = class abstract
  public
    procedure Speak(const AText: String); virtual; abstract;
    procedure Stop; virtual; abstract;
  end;

type
  TTextToSpeechWindows = class(TTextToSpeech)
  public
    procedure Speak(const AText: String); override;
    procedure Stop; override;
  end;

etc...

Usually, you will put the derived classes in separate units that you use in the main unit depending on platform.

Then, you need a way to create a platform-specific instance of the class. You could create a global factory function:

function CreateTextToSpeech: TTextToSpeech;
begin
  {$IF Defined(MSWINDOWS)}
  Result := TTextToSpeechWindows.Create;
  {$ELSEIF Defined(IOS)}
  Result := TTextToSpeechIOS.Create;

  etc...
end;

But I prefer to create a static class function called Create so it looks and feels like a regular constructor:

type
  TTextToSpeech = class abstract
  public
    class function Create: TTextToSpeech; static;
    procedure Speak(const AText: String); virtual; abstract;
    procedure Stop; virtual; abstract;
  end;

class function TTextToSpeech.Create: TTextToSpeech;
begin
  {$IF Defined(MSWINDOWS)}
  Result := TTextToSpeechWindows.Create;
  {$ELSEIF Defined(IOS)}
  Result := TTextToSpeechIOS.Create;

  etc...
end;

Using an object interface

Instead of an abstract base class, you can also define the cross-platform API in an object interface. This is a clean solution that separates specification from implementation:

type
  ITextToSpeech = interface
    ['{1EDBA4EC-3FD6-43E6-97AF-D3715A7BF7AC}']
    procedure Speak(const AText: String);
    procedure Stop;
  end;

type
  TTextToSpeechWindows = class(TInterfacedObject, ITextToSpeech)
  protected
    { ITextToSpeech }
    procedure Speak(const AText: String);
    procedure Stop;
  end;

etc...

Additional advantages of this approach are:

  • you can completely separate interface from implementation by keeping them in separate units.
  • you don’t need to implement the interface in a specific common base class. You can just derive from TInterfacedObject or choose another class as base.
  • you also get the benefits of automatic memory management on non-ARC platforms (like Windows and macOS).

Of course, you still need some sort of factory function:

function CreateTextToSpeech: ITextToSpeech;
begin
  {$IF Defined(MSWINDOWS)}
  Result := TTextToSpeechWindows.Create;
  {$ELSEIF Defined(IOS)}
  Result := TTextToSpeechIOS.Create;

  etc...
end;

But this function returns an object interface instead of a class.

Again, you could create a static class function (like TTextToSpeech.Create) to do this as well.

Use the PIMPL pattern

You can also use the Pointer-to-Implementation (PIMPL) pattern by defining a class with a public interface that delegates the actual implementation to a different class:

type
  TTextToSpeech = class
  private
    FImpl: TTextToSpeechImpl;
  public
    constructor Create;
    destructor Destroy; override;
    procedure Speak(const AText: String);
    procedure Stop;
  end;

constructor TTextToSpeech.Create;
begin
  inherited Create;
  FImpl := TTextToSpeechImpl.Create;
end;

destructor TTextToSpeech.Destroy;
begin
  FImpl.Free;
  inherited Destroy;
end;

procedure TTextToSpeech.Speak(const AText: String);
begin
  FImpl.Speak(AText);
end;

procedure TTextToSpeech.Stop;
begin
  FImpl.Stop;
end;

In this example, the TTextToSpeechImpl class contains the platform-specific code. There are separate versions of the TTextToSpeechImpl class, one for each platform.

Like before, TTextToSpeechImpl could override an abstract base class. But it is also possible that the different TTextToSpeechImpl versions don’t share a common base class. As long as they conform to the API contract (by supplying a Speak and Stop method) it will work, without the need for virtual methods.

There are some variations on this theme. For example, instead of using a class, the implementation could be an object interface again (like ITextToSpeechImpl).

Also, because the main class (TTextToSpeech) now only contains a single field pointing to the implementation, we could also make TTextToSpeech a record instead of a class and thereby make it a bit more light weight.

Use virtual class methods

What we discussed so far mostly applies to unrelated global APIs or entire classes that have platform-specific implementations. But what if you have a collection of APIs that are related, but are global. To keep things organized, you might still want to put them in a class:

type
  TSystemInformation = class
  public
    function MachineName: String;
    function TotalRAM: Int64;
  end;

However, since these APIs are global, it doesn’t make sense to create multiple instances of the TSystemInformation class. You could either make this class a singleton, or you can turn it into a static class that only has static methods:

type
  TSystemInformation = class // static
  public
    class function MachineName: String; static;
    class function TotalRAM: Int64; static;
  end;

Note that Delphi doesn’t have a concept of “static” classes, but it certainly supports classes with only static methods.

But again, the implementation of these methods is highly platform-specific. We could IFDEF the method implementations like we did at the beginning of this post. But we could also take advantage of a Delphi-specific language feature that you won’t find in C++, C# or Java: virtual class methods and class reference types.

Like before, we create an abstract base class and platform-specific derived classes. Only this time, we use virtual class methods instead:

type
  TSystemInformationBase = class
  public
    class function MachineName: String; virtual; abstract;
    class function TotalRAM: Int64; virtual; abstract;
  end;

Note that the static keywords are replaced with virtual and abstract here.

In case you are wondering: Delphi supports both class methods and static class methods (in addition to instance methods). The difference between the two is that class methods, like instance methods, have an implicit self parameter. But in the case of class methods, the self parameter signifies the class (and not the instance). On the other hand, static class methods don’t have this implicit self parameter, and thus cannot be virtual.

A platform-specific descendant may look like this:

type
  TSystemInformationWindows = class(TSystemInformationBase)
  public
    class function MachineName: String; override;
    class function TotalRAM: Int64; override;
  end;

It overrides the class methods. Now we can use the following “trick” to define a static TSystemInformation class that delegates its implementation to a derived class:

type
  TSystemInformationClass = class of TSystemInformationBase;

var
  TSystemInformation: TSystemInformationClass = nil;

initialization
  {$IF Defined(MSWINDOWS)}
  TSystemInformation := TSystemInformationWindows;
  {$ELSEIF Defined(ANDROID)}
  TSystemInformation := TSystemInformationAndroid;
  ...etc
end.

Even though TSystemInformation is actually a global variable now, we can treat it as a static class:

WriteLn('Your machine name: ' + TSystemInformation.MachineName);

You probably won’t use this approach a lot, but it may be useful in certain situations.

Using interchangeable services

An approach that is used inside the FMX.Platform.* units is to offer certain platform services as interfaces. Each interface groups related services together. For example, the IFMXSystemFontService interface provides information about the default system font. It can be used like this:

var
  FontSrv: IFMXSystemFontService;
begin
  if TPlatformServices.Current.SupportsPlatformService(
     IFMXSystemFontService, FontSrv)
  then
    ShowMessage('Default font: ' + FontSrv.GetDefaultFontFamilyName);

Different platforms have different implementations of the IFMXSystemFontService interface.

FireMonkey even allows you to replace a certain service with your own implementation. Say for example, that we want the default font to display 25% bigger. We start by creating our own class that implements IFMXSystemFontService. It keeps a reference to the original font service so it can forward any methods we don’t care to customize, or modify the result of an original method:

type
  TLargerFontService = class(TInterfacedObject, IFMXSystemFontService)
  private
    FOrigService: IFMXSystemFontService;
  protected
    { IFMXSystemFontService }
    function GetDefaultFontFamilyName: String;
    function GetDefaultFontSize: Single;
  public
    constructor Create(const AOrigService: IFMXSystemFontService);
  end;

{ TLargerFontService }

constructor TLargerFontService.Create(
  const AOrigService: IFMXSystemFontService);
begin
  inherited Create;
  FOrigService := AOrigService;
end;

function TLargerFontService.GetDefaultFontFamilyName: String;
begin
  { Use original default font family name }
  Result := FOrigService.GetDefaultFontFamilyName;
end;

function TLargerFontService.GetDefaultFontSize: Single;
begin
  { Increase original default font size }
  Result := FOrigService.GetDefaultFontSize * 1.25;
end;

We can than replace the original font service with the following code:

procedure SetLargerFontService;
var
  OrigFontService, LargerFontService: IFMXSystemFontService;
begin
  if (TPlatformServices.Current.SupportsPlatformService(
    IFMXSystemFontService, OrigFontService)) then
  begin
    LargerFontService := TLargerFontService.Create(OrigFontService);
    TPlatformServices.Current.RemovePlatformService(IFMXSystemFontService);
    TPlatformServices.Current.AddPlatformService(IFMXSystemFontService,
      LargerFontService);
  end;
end;

If we do this at application startup (in the initialization section of the main form), then all text that is set to the default font size will display 25% bigger.

This is also a great way to customize the default font family name for your application, without having to change individual controls or creating a custom style book.

Creating a services-style model for your own platform-specific code may be overkill, but it can definitely be useful in certain scenarios.

Other approaches

The list of approaches discussed above is certainly not exhaustive. I am sure there are other ways you could solve the cross-platform abstraction problem. If you are using a different approach, let us know in the comments. We’re interested in what you have come up with…

3 thoughts on “Cross Platform Abstraction

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s