Dart mobile, command-line, and server apps
running on the Dart Native platform, on macOS or iOS,
can use dart:ffi
and package:ffigen
to call Objective-C APIs.
dart:ffi
allows Dart code to interact with native C APIs.
Objective-C is based on and compatible with C,
so it is possible to interact with Objective-C APIs using only dart:ffi
.
However, doing so involves a lot of boilerplate code,
so you can use package:ffigen
to automatically generate
the Dart FFI bindings for a given Objective-C API.
To learn more about FFI and interfacing with C code directly,
see the C interop guide.
Example: AVAudioPlayer
This guide walks you through an example
that uses package:ffigen
to generate bindings for
AVAudioPlayer
.
Generating bindings to wrap an Objective-C API is similar to wrapping a C API.
Direct package:ffigen
at the header file that describes the API,
and then load the library with dart:ffi
.
package:ffigen
parses Objective-C header files
using LLVM,
so you’ll need to install that first.
See Installing LLVM
from the ffigen README for more details.
Configuring ffigen
First, add package:ffiigen
as a dev dependency:
$ dart pub add --dev ffigen
Then, configure ffigen to generate bindings for the
Objective-C header containing the API.
The ffigen configuration options go in your pubspec.yaml
file,
under a top-level ffigen
entry.
Alternatively, you can put the ffigen config in its own .yaml
file.
ffigen:
name: AVFAudio
description: Bindings for AVFAudio.
language: objc
output: 'avf_audio_bindings.dart'
headers:
entry-points:
- '/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/System/Library/Frameworks/AVFAudio.framework/Headers/AVAudioPlayer.h'
The name
is the name of the native library wrapper class
that will be generated,
and the description
will be used in the documentation for that class.
The output
is the path of the Dart file that ffigen will create.
The entry point is the header file containing the API.
In this example, it is the internal AVAudioPlayer.h
header.
Another import thing you’ll see,
if you look at the example config,
is a lot of exclusions.
By default, ffigen will generate bindings for everything
it finds in the header,
and everything that those bindings depend on in other headers.
Most Objective-C libraries depend on Apple’s internal libraries,
which are very large.
If bindings are generated without any filters,
the resulting file can be millions of lines long.
To solve this problem,
the ffigen config has fields that allow you to filter out
all the functions, structs, enums, etc., that you’re not interested in.
For this example, we’re only interested in AVAudioPlayer
,
so you can exclude everything else:
functions:
exclude:
- '.*'
structs:
exclude:
- '.*'
unions:
exclude:
- '.*'
globals:
exclude:
- '.*'
macros:
exclude:
- '.*'
enums:
exclude:
- '.*'
unnamed-enums:
exclude:
- '.*'
objc-interfaces:
include:
- 'AVAudioPlayer'
Since AVAudioPlayer
is explicitly included like this,
ffigen will exclude all other interfaces.
The exclude
entries are all excluding the regular expression '.*'
,
which matches anything.
The result is that nothing is included except AVAudioPlayer
,
and the things that it depends on, such as NSObject
and NSString
.
So instead of several million lines of bindings,
you end up with tens of thousands.
You can also use the preamble
option
to insert text at the top of the generated file.
In this example, the preamble
was used
to insert some linter ignore rules at the top of the generated file:
preamble: |
// ignore_for_file: camel_case_types, non_constant_identifier_names, unused_element, unused_field, return_of_invalid_type, void_checks, annotate_overrides, no_leading_underscores_for_local_identifiers, library_private_types_in_public_api
See the ffigen readme for a full list of configuration options.
Generating the bindings
To generate the bindings, navigate to the example directory, and run ffigen:
$ dart run ffigen
This will search in the pubspec.yaml
file for a top-level ffigen
entry.
If you chose to put the ffigen config in a separate file, use the
--config
option and specify that file:
$ dart run ffigen --config my_ffigen_config.yaml
For this example, this will generate avf_audio_bindings.dart.
This file contains a class called AVFAudio
, which is the native library
wrapper that loads all the API functions using FFI,
and provides convenient wrapper methods to call them.
The other classes in this file are all Dart wrappers
around the Objective-C interfaces that we need,
such as AVAudioPlayer
and its dependencies.
Using the bindings
Now you’re ready to load and interact with the generated library.
The example app, play_audio.dart,
loads and plays audio files passed as command line arguments.
The first step is to load the
dylib
and instantiate the native AVFAudio
library:
import 'dart:ffi';
import 'avf_audio_bindings.dart';
const _dylibPath =
'/System/Library/Frameworks/AVFAudio.framework/Versions/Current/AVFAudio';
void main(List<String> args) async {
final lib = AVFAudio(DynamicLibrary.open(_dylibPath));
Since you’re loading an internal library,
the dylib path is pointing at an internal framework dylib.
You can also load your own .dylib
file,
or if the library is statically linked into your app (often the case on iOS)
you can use DynamicLibrary.process()
:
final lib = AVFAudio(DynamicLibrary.process());
The goal of the example is to play each of the audio files
specified as command line arguments one by one.
For each argument,
you first have to convert the Dart String
to an Objective-C NSString
.
The generated NSString
wrapper has a convenient constructor
that handles this conversion,
and a toString()
method that converts it back to a Dart String
.
for (final file in args) {
final fileStr = NSString(lib, file);
print('Loading $fileStr');
The audio player expects an NSURL
, so next we use the fileURLWithPath:
method to convert the NSString
to an NSURL
.
Since :
is not a valid character in a Dart method name,
it has been translated to _
in the bindings.
final fileUrl = NSURL.fileURLWithPath_(lib, fileStr);
Now, you can construct the AVAudioPlayer
.
Constructing an Objective-C object has two stages.
alloc
allocates the memory for the object,
but doesn’t initialize it.
Methods with names starting with init*
do the initialization.
Some interfaces also provide new*
methods that do both of these steps.
To initialize the AVAudioPlayer
,
use the initWithContentsOfURL:error:
method:
final player =
AVAudioPlayer.alloc(lib).initWithContentsOfURL_error_(fileUrl, nullptr);
Objective-C uses reference counting for memory management
(through retain, release, and other functions),
but on the Dart side memory management is handled automatically.
The Dart wrapper object retains a reference to the Objective-C object,
and when the Dart object is garbage collected,
the generated code automatically releases that reference using a
NativeFinalizer
.
Next, look up the length of the audio file,
which you’ll need later to wait for the audio to finish.
The duration
is a @property(readonly)
.
Objective-C properties are translated into getters and setters
on the generated Dart wrapper object.
Since duration
is readonly
, only the getter is generated.
The resulting NSTimeInterval
is just a type aliased double
,
so you can immediately use the Dart .ceil()
method
to round up to the next second:
final durationSeconds = player.duration.ceil();
print('$durationSeconds sec');
Finally, you can use the play
method to play the audio,
then check the status, and wait for the duration of the audio file:
final status = player.play();
if (status) {
print('Playing...');
await Future<void>.delayed(Duration(seconds: durationSeconds));
} else {
print('Failed to play audio.');
}