Objective-C and Swift interop using package:ffigen
To use Objective-C and Swift code in your Dart program, use package:ffigen.
Dart apps running on the
Dart Native platform
(macOS or iOS) can use dart:ffi and
package:ffigen
to call Objective-C and Swift APIs.
dart:ffi enables Dart code to interact with
native C APIs. Objective-C is based on and compatible with
C, so it's possible to interact with Objective-C APIs using
only dart:ffi. However, doing so requires
significant boilerplate, 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.
You can generate Objective-C headers for Swift APIs,
enabling dart:ffi and
package:ffigen to interact with Swift.
For more information about using FFIgen, see the FFIgen README and the additional documentation.
Objective-C example
#
This guide walks you through
an example
that uses package:ffigen to generate bindings
for
AVAudioPlayer. This API requires at least macOS SDK 10.7, so check your
version and update Xcode if necessary:
xcodebuild -showsdks
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.
Configure FFIgen for Objective-C
#
First, add package:ffigen as a dev dependency
and the helpers package:objective_c and
package:ffi as a regular dependency:
dart pub add dev:ffigen objective_c ffi
Then, configure FFIgen to generate bindings for the
Objective-C header containing the API. Configure FFIgen
using YAML or Dart code; we recommend Dart for new projects.
The YAML config will be deprecated in future versions of
FFIgen. Start by creating a
generate_code.dart script somewhere in your
package. We recommend placing this file in
my_package/tool.
The generate_code.dart script should create an
FfiGenerator object, which will contain all
your configuration options, then call its
.generate() method.
import 'package:ffigen/ffigen.dart';
final config = FfiGenerator(
);
void main() => config.generate();
First, you'll tell FFIgen where to find the API you're
trying to generate bindings for. To do this, set the
headers.entryPoints option.
For this example, you'll load AVAudioPlayer.h.
This is part of the AVFAudio framework, which
is located in your Xcode installation. FFIgen includes some
helper functions to locate these sorts of APIs, such as
macSdkPath. Using these helper functions makes
your code generation script more reliable across different
machines, which might have different SDK installation
locations.
The macSdkPath utility finds the macOS SDK by
running xcrun --show-sdk-path --sdk macosx. You
can run this command in a terminal to locate your macOS
SDKs, or with --sdk iphoneos to find your iOS
SDKs. When generating bindings for an Apple API, exploring
these directories is a great way to find the right headers
to pass to FFIgen.
import 'package:ffigen/ffigen.dart';
final config = FfiGenerator(
headers: Headers(
entryPoints: [
Uri.file(
'$macSdkPath/System/Library/Frameworks/AVFAudio.framework/Headers/AVAudioPlayer.h',
),
],
),
);
void main() => config.generate();
Next, you'll define the output file. The main output of
FFIgen is a single Dart file containing bindings for the
given inputs. This file's location is defined by the
output.dartFile option.
FFIgen sometimes generates a .m file,
containing Objective-C code required for interop with the
API. FFIgen generates this file only if the API requires it
(for example, if you're using blocks or protocols). By
default, this file has the same name as the Dart bindings,
but with .m at the end of the file name. You
can change its location with the
output.objectiveCFile option. If FFIgen
produces this file, you must compile it into your package,
otherwise you might get runtime exceptions relating to
missing symbols. For this example, FFIgen doesn't generate a
.m file.
import 'package:ffigen/ffigen.dart';
final config = FfiGenerator(
headers: Headers(
entryPoints: [
Uri.file(
'$macSdkPath/System/Library/Frameworks/AVFAudio.framework/Headers/AVAudioPlayer.h',
),
],
),
output: Output(
dartFile: Uri.file('avf_audio_bindings.dart'),
),
);
void main() => config.generate();
Finally, tell FFIgen which parts of the input API to
generate bindings for. By default, FFIgen filters out all
the bindings. In this case, to generate bindings for
AVAudioPlayer, which is an Objective-C
interface, you have to set the
objectiveC.interfaces field.
Setting the objectiveC field tells FFIgen to
generate bindings for the Objective-C language. By default,
FFIgen generates C bindings.
import 'package:ffigen/ffigen.dart';
final config = FfiGenerator(
headers: Headers(
entryPoints: [
Uri.file(
'$macSdkPath/System/Library/Frameworks/AVFAudio.framework/Headers/AVAudioPlayer.h',
),
],
),
objectiveC: ObjectiveC(
interfaces: Interfaces.includeSet({'AVAudioPlayer'}),
),
output: Output(
dartFile: Uri.file('lib/avf_audio_bindings.dart'),
),
);
void main() => config.generate();
You can use includeMember to filter out
specific methods from the class, and rename or
renameMember to rename the included classes or
methods. There are similar options for protocols and
categories.
For a full list of configuration options, check out the FFIgen API documentation.
Generate the Objective-C bindings
#
To generate the bindings, navigate to the
example directory and run the script:
dart run tool/generate_code.dart
This should generate a large
avf_audio_bindings.dart file, similar to
this one. The main class of interest is AVAudioPlayer.
You might notice other classes in the file with a comment
indicating they are a stub. FFIgen generates stub bindings
for all transitive dependencies of the directly included
APIs. To generate full bindings for these stubs, add them to
the includes in your config. This stubbing behavior can be
changed with the includeTransitive options.
Use the Objective-C 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 'package:objective_c/objective_c.dart';
import 'avf_audio_bindings.dart';
const _dylibPath =
'/System/Library/Frameworks/AVFAudio.framework/Versions/Current/AVFAudio';
void main(List<String> args) async {
DynamicLibrary.open(_dylibPath);
}
Because this example loads a system library, the dylib path
points to the framework's internal 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
don't need to load anything.
The example plays 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
toDartString() method that converts it back to
a Dart String.
void main(List<String> args) async {
DynamicLibrary.open(_dylibPath);
for (final file in args) {
final fileStr = NSString(file);
print('Loading $file');
}
}
The audio player expects an NSURL, so next, use
the
fileURLWithPath:
method to convert the NSString to an
NSURL.
void main(List<String> args) async {
DynamicLibrary.open(_dylibPath);
for (final file in args) {
final fileStr = NSString(file);
print('Loading $file');
final fileUrl = NSURL.fileURLWithPath(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:
void main(List<String> args) async {
DynamicLibrary.open(_dylibPath);
for (final file in args) {
final fileStr = NSString(file);
print('Loading $file');
final fileUrl = NSURL.fileURLWithPath(fileStr);
final player = AVAudioPlayer.alloc().initWithContentsOfURL(fileUrl);
if (player == null) {
print('Failed to load audio.');
continue;
}
}
}
This Dart AVAudioPlayer object is a wrapper
around an underlying Objective-C
AVAudioPlayer* object pointer.
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 reference is automatically released.
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.
NSTimeInterval is a type alias for
double, so you can immediately use the Dart
.ceil() method to round up to the next second:
void main(List<String> args) async {
DynamicLibrary.open(_dylibPath);
for (final file in args) {
final fileStr = NSString(file);
print('Loading $file');
final fileUrl = NSURL.fileURLWithPath(fileStr);
final player = AVAudioPlayer.alloc().initWithContentsOfURL(fileUrl);
if (player == null) {
print('Failed to load audio.');
continue;
}
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:
void main(List<String> args) async {
DynamicLibrary.open(_dylibPath);
for (final file in args) {
final fileStr = NSString(file);
print('Loading $file');
final fileUrl = NSURL.fileURLWithPath(fileStr);
final player = AVAudioPlayer.alloc().initWithContentsOfURL(fileUrl);
if (player == null) {
print('Failed to load audio.');
continue;
}
final durationSeconds = player.duration.ceil();
print('$durationSeconds sec');
final status = player.play();
if (status) {
print('Playing...');
await Future<void>.delayed(Duration(seconds: durationSeconds));
} else {
print('Failed to play audio.');
}
}
}
Callbacks and multithreading limitations
#Multithreading introduces complexity to interop between Objective-C and Dart. This stems from differences between Dart isolates and OS threads, and how Apple's APIs handle concurrency:
- Dart isolates aren't the same thing as threads. Isolates run on threads but aren't guaranteed to run on any particular thread, and the VM might change which thread an isolate is running on without warning. There is an open feature request to enable isolates to be pinned to specific threads.
- While FFIgen supports converting Dart functions to Objective-C blocks, most Apple APIs don't make any guarantees about which thread a callback will run on.
- Most APIs that involve UI interaction can only be called on the main thread, also called the platform thread in Flutter.
- Many Apple APIs are not thread safe.
The first two points mean that a block created in one
isolate might be invoked on a thread running a different
isolate, or no isolate at all. Depending on the type of
block you are using, this could cause your app to crash.
When a block is created, the isolate it was created in is
its owner. Blocks created using
FooBlock.fromFunction must be invoked on the
owner isolate's thread, otherwise they will crash. Blocks
created using FooBlock.listener or
FooBlock.blocking
can be safely invoked from any thread, and the function they
wrap will (eventually) be invoked inside the owner isolate,
though these constructors are only supported for blocks that
return void. If there is user demand for it,
FooBlock.blocking might add support for
non-void return values in the future.
The third point means that directly calling some Apple APIs
using the generated Dart bindings might be thread unsafe.
This could crash your app or cause other unpredictable
behavior. In recent versions of Flutter, the main isolate
runs on the platform thread, so this isn't an issue when
invoking these thread-locked APIs from the main isolate. If
you need to invoke these APIs from other isolates, or you
need to support older versions of Flutter, you can use the
runOnPlatformThread
function. For more information, see the
Objective-C dispatch documentation.
Regarding the last point, although Dart isolates can switch threads, they only ever run on one thread at a time. The API you interact with doesn't need to be thread safe, as long as it doesn't have constraints about which thread it's called from.
You can safely interact with Objective-C code as long as you keep these limitations in mind.
Swift example
#This example demonstrates how to make a Swift class compatible with Objective-C, generate a wrapper header, and invoke it from Dart code.
The process below is manual. There's an experimental project to automate these steps called Swiftgen.
Generating the Objective-C wrapper header
#
Swift APIs can be made compatible with Objective-C, by using
the @objc annotation. Make sure to make any
classes or methods you want to use public, and
have your classes extend NSObject.
import Foundation
@objc public class SwiftClass: NSObject {
@objc public func sayHello() -> String {
return "Hello from Swift!";
}
@objc public var someField = 123;
}
To interact with a third-party library you can't modify, you might need to write an Objective-C compatible wrapper class that exposes the methods you want to use.
For more information about Objective-C / Swift interoperability, see the Swift documentation.
Once you've made your class compatible, you can generate an
Objective-C wrapper header. You can do this using Xcode, or
using the Swift command-line compiler, swiftc.
This example uses the command line:
swiftc -c swift_api.swift \
-module-name swift_module \
-emit-objc-header-path swift_api.h \
-emit-library -o libswiftapi.dylib
This command compiles the Swift file,
swift_api.swift, and generates a wrapper
header, swift_api.h. It also generates the
dylib you're going to load later,
libswiftapi.dylib.
You can verify that the header generated correctly by opening it and checking that the interfaces are what you expect. Towards the bottom of the file, you should see something like the following:
SWIFT_CLASS("_TtC12swift_module10SwiftClass")
@interface SwiftClass : NSObject
- (NSString * _Nonnull)sayHello SWIFT_WARN_UNUSED_RESULT;
@property (nonatomic) NSInteger someField;
- (nonnull instancetype)init OBJC_DESIGNATED_INITIALIZER;
@end
If the interface is missing, or doesn't have all of its
methods, make sure they're all annotated with
@objc and public.
Configuring FFIgen for Swift
#
FFIgen only sees the Objective-C wrapper header,
swift_api.h. So most of this config looks
similar to the Objective-C example, including setting the
language to objc.
ffigen:
name: SwiftLibrary
description: Bindings for swift_api.
language: objc
output: 'swift_api_bindings.dart'
exclude-all-by-default: true
objc-interfaces:
include:
- 'SwiftClass'
module:
'SwiftClass': 'swift_module'
headers:
entry-points:
- 'swift_api.h'
As before, set the language to objc, and the
entry point to the header; exclude everything by default,
and explicitly include the interface you are binding.
A key configuration difference for wrapped Swift APIs is the
objc-interfaces ->
module option. When
swiftc compiles the library, it gives the
Objective-C interface a module prefix. Internally,
SwiftClass is registered as
swift_module.SwiftClass. You need to tell
ffigen about this prefix, so it loads the
correct class from the dylib.
Not every class gets this prefix. For example,
NSString and NSObject won't get a
module prefix, because they're internal classes. This is why
the module option maps from class name to
module prefix. You can also use regular expressions to match
multiple class names at once.
The module prefix is whatever you passed to
swiftc in the -module-name flag.
In this example, it's swift_module. If you
don't explicitly set this flag, it defaults to the name of
the Swift file.
If you aren't sure what the module name is, you can also
check the generated Objective-C header. Above the
@interface, you'll find a
SWIFT_CLASS macro:
SWIFT_CLASS("_TtC12swift_module10SwiftClass")
@interface SwiftClass : NSObject
The string inside the macro contains the module name and the
class name:
"_TtC12swift_module10SwiftClass".
Swift can even demangle this name for us:
echo "_TtC12swift_module10SwiftClass" | swift demangle
This outputs swift_module.SwiftClass.
Generating the Swift bindings
#As before, to generate the bindings, navigate to the example directory and run FFIgen:
dart run ffigen
This generates swift_api_bindings.dart.
Using the Swift bindings
#Interacting with these bindings is exactly the same as for a normal Objective-C library:
import 'dart:ffi';
import 'swift_api_bindings.dart';
void main() {
final lib = SwiftLibrary(DynamicLibrary.open('libswiftapi.dylib'));
final object = SwiftClass.new1(lib);
print(object.sayHello());
print('field = ${object.someField}');
object.someField = 456;
print('field = ${object.someField}');
}
Note that the module name is not mentioned in the generated Dart API. It's only used internally to load the class from the dylib.
Now you can run the example using:
dart run example.dart