Articles Cross Platform Abstraction by Erik van Bilsen

emailx45

Местный
Регистрация
5 Май 2008
Сообщения
3,571
Реакции
2,438
Credits
573
Cross Platform Abstraction
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 Для просмотра ссылки Войди или Зарегистрируйся). 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).
Of course, you still need some sort of factory function:
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]
 

emailx45

Местный
Регистрация
5 Май 2008
Сообщения
3,571
Реакции
2,438
Credits
573
[SHOWTOGROUPS=4,20]
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:
1
2
3
4
5
6
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:
1
2
3
4
5
6
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:
1
2
3
4
5
6
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:
1
2
3
4
5
6
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:
1
2
3
4
5
6
7
8
9
10
11
12
13
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:
1WriteLn('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:
1
2
3
4
5
6
7
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:
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
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:
1
2
3
4
5
6
7
8
9
10
11
12
13
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.

[/SHOWTOGROUPS]