Cross Platform Abstraction
January 6, 2017 Erik van Bilsen
January 6, 2017 Erik van Bilsen
[SHOWTOGROUPS=4,20]
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:
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:
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:
But I prefer to create a static class function called Create so it looks and feels like a regular constructor:
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:
Additional advantages of this approach are:
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:
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.
[/SHOWTOGROUPS]
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | 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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | 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:
1 2 3 4 5 6 7 8 9 | 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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | 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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | 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).
1 2 3 4 5 6 7 8 9 | 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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 | 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.
[/SHOWTOGROUPS]