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”