目录

使用 package:ffigen 实现 Objective-C 和 Swift 互操作

在 macOS 或 iOS 上运行的 Dart Native 平台上的 Dart 移动、命令行和服务器应用可以使用 dart:ffipackage:ffigen 来调用 Objective-C 和 Swift API。

dart:ffi 使 Dart 代码能够与原生 C API 交互。Objective-C 基于 C 并且与 C 兼容,因此仅使用 dart:ffi 即可与 Objective-C API 交互。但是,这样做涉及大量样板代码,因此可以使用 package:ffigen 为给定的 Objective-C API 自动生成 Dart FFI 绑定。要了解有关 FFI 以及直接与 C 代码接口的更多信息,请参阅C 互操作指南

您可以为 Swift API 生成 Objective-C 头文件,从而使 dart:ffipackage:ffigen 能够与 Swift 交互。

Objective-C 示例

#

本指南将引导您完成一个示例,该示例使用 package:ffigenAVAudioPlayer 生成绑定。此 API 至少需要 macOS SDK 10.7,因此请检查您的版本并在必要时更新 Xcode

$ xcodebuild -showsdks

生成绑定以包装 Objective-C API 与包装 C API 类似。将 package:ffigen 直接指向描述 API 的头文件,然后使用 dart:ffi 加载库。

package:ffigen 使用 LLVM 解析 Objective-C 头文件,因此您需要首先安装它。有关更多详细信息,请参阅 ffigen README 中的 安装 LLVM

配置 ffigen

#

首先,将 package:ffigen 添加为开发依赖项

$ dart pub add --dev ffigen

然后,配置 ffigen 以生成包含 API 的 Objective-C 头文件的绑定。ffigen 配置选项位于 pubspec.yaml 文件中的顶层 ffigen 条目下。或者,您可以将 ffigen 配置放在其自己的 .yaml 文件中。

yaml
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'

name 是将生成的原生库包装器类的名称,description 将在类的文档中使用。output 是 ffigen 将创建的 Dart 文件的路径。入口点是包含 API 的头文件。在此示例中,它是内部的 AVAudioPlayer.h 头文件。

另一个需要注意的事情是,如果您查看示例配置,您会看到 exclude 和 include 选项。默认情况下,ffigen 会为它在头文件中找到的所有内容以及这些绑定在其他头文件中依赖的所有内容生成绑定。大多数 Objective-C 库都依赖于 Apple 的内部库,这些库非常庞大。如果生成没有任何过滤器的绑定,则生成的文件可能会有数百万行。为了解决这个问题,ffigen 配置具有一些字段,使您能够过滤掉所有您不感兴趣的函数、结构体、枚举等。对于此示例,我们只对 AVAudioPlayer 感兴趣,因此您可以排除其他所有内容

yaml
  exclude-all-by-default: true
  objc-interfaces:
    include:
      - 'AVAudioPlayer'

由于 AVAudioPlayer 像这样显式包含,因此 ffigen 会排除所有其他接口。exclude-all-by-default 标志告诉 ffigen 排除其他所有内容。结果是,除了 AVAudioPlayer 及其依赖项(如 NSObjectNSString)之外,不包含任何内容。因此,您最终得到的是数万行绑定,而不是数百万行。

如果您需要更精细的控制,您可以单独排除或包含所有声明,而不是使用 exclude-all-by-default

yaml
  functions:
    exclude:
      - '.*'
  structs:
    exclude:
      - '.*'
  unions:
    exclude:
      - '.*'
  globals:
    exclude:
      - '.*'
  macros:
    exclude:
      - '.*'
  enums:
    exclude:
      - '.*'
  unnamed-enums:
    exclude:
      - '.*'

这些 exclude 条目都排除正则表达式 '.*',它匹配任何内容。

您还可以使用 preamble 选项在生成的文件顶部插入文本。在此示例中,preamble 用于在生成的文件顶部插入一些 linter 忽略规则

yaml
  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

有关配置选项的完整列表,请参阅 ffigen readme

生成 Dart 绑定

#

要生成绑定,请导航到示例目录并运行 ffigen

$ dart run ffigen

这将在 pubspec.yaml 文件中搜索顶层 ffigen 条目。如果您选择将 ffigen 配置放在单独的文件中,请使用 --config 选项并指定该文件

$ dart run ffigen --config my_ffigen_config.yaml

对于此示例,这将生成 avf_audio_bindings.dart

此文件包含一个名为 AVFAudio 的类,它是使用 FFI 加载所有 API 函数的原生库包装器,并提供方便的包装器方法来调用它们。此文件中的其他类都是围绕我们需要的 Objective-C 接口(例如 AVAudioPlayer 及其依赖项)的 Dart 包装器。

使用绑定

#

现在,您已准备好加载并与生成的库进行交互。示例应用 play_audio.dart 加载并播放作为命令行参数传递的音频文件。第一步是加载 dylib 并实例化原生 AVFAudio

dart
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));

由于您正在加载内部库,因此 dylib 路径指向内部框架 dylib。您还可以加载自己的 .dylib 文件,或者如果该库静态链接到您的应用(在 iOS 上通常是这种情况),您可以使用 DynamicLibrary.process()

dart
  final lib = AVFAudio(DynamicLibrary.process());

该示例的目标是逐个播放作为命令行参数指定的每个音频文件。对于每个参数,您首先必须将 Dart String 转换为 Objective-C NSString。生成的 NSString 包装器具有一个方便的构造函数来处理此转换,以及一个 toString() 方法将其转换回 Dart String

dart
  for (final file in args) {
    final fileStr = NSString(lib, file);
    print('Loading $fileStr');

音频播放器需要一个 NSURL,因此接下来我们使用 fileURLWithPath: 方法将 NSString 转换为 NSURL。由于 : 不是 Dart 方法名称中的有效字符,因此它在绑定中已转换为 _

dart
    final fileUrl = NSURL.fileURLWithPath_(lib, fileStr);

现在,您可以构造 AVAudioPlayer。构造 Objective-C 对象有两个阶段。alloc 为对象分配内存,但不会初始化它。名称以 init* 开头的方法执行初始化。某些接口还提供执行这两个步骤的 new* 方法。

要初始化 AVAudioPlayer,请使用 initWithContentsOfURL:error: 方法

dart
    final player =
        AVAudioPlayer.alloc(lib).initWithContentsOfURL_error_(fileUrl, nullptr);

Objective-C 使用引用计数进行内存管理(通过 retain、release 和其他函数),但在 Dart 端,内存管理是自动处理的。Dart 包装器对象保留对 Objective-C 对象的引用,并且当 Dart 对象被垃圾回收时,生成的代码会自动使用 NativeFinalizer 释放该引用。

接下来,查找音频文件的时长,稍后需要用到它来等待音频播放完成。 duration 是一个 @property(readonly)。Objective-C 属性会被转换为生成的 Dart 包装对象上的 getter 和 setter。由于 durationreadonly,因此只会生成 getter。

生成的 NSTimeInterval 只是一个类型别名 double,因此您可以立即使用 Dart 的 .ceil() 方法将其向上舍入到下一个整数秒。

dart
    final durationSeconds = player.duration.ceil();
    print('$durationSeconds sec');

最后,您可以使用 play 方法播放音频,然后检查状态,并等待音频文件的时长。

dart
    final status = player.play();
    if (status) {
      print('Playing...');
      await Future<void>.delayed(Duration(seconds: durationSeconds));
    } else {
      print('Failed to play audio.');
    }

回调和多线程限制

#

多线程问题是 Dart 对 Objective-C 互操作实验性支持的最大限制。这些限制是由于 Dart 隔离区和操作系统线程之间的关系,以及 Apple API 处理多线程的方式造成的。

  • Dart 隔离区与线程不同。隔离区在线程上运行,但不保证在任何特定线程上运行,并且虚拟机可能会在没有警告的情况下更改隔离区运行的线程。有一个 开放的功能请求,允许将隔离区固定到特定的线程。
  • 虽然 ffigen 支持将 Dart 函数转换为 Objective-C 代码块,但大多数 Apple API 并不保证回调将在哪个线程上运行。
  • 大多数涉及 UI 交互的 API 只能在主线程上调用,在 Flutter 中也称为平台线程。
  • 许多 Apple API 是 非线程安全的

前两点意味着在一个隔离区中创建的回调可能会在运行不同隔离区的线程上调用,或者根本没有隔离区。根据您使用的回调类型,这可能会导致您的应用程序崩溃。使用 Pointer.fromFunctionNativeCallable.isolateLocal 创建的回调必须在所有者隔离区的线程上调用,否则它们会崩溃。使用 NativeCallable.listener 创建的回调可以安全地从任何线程调用。

第三点意味着直接使用生成的 Dart 绑定调用某些 Apple API 可能不是线程安全的。这可能会导致您的应用程序崩溃,或导致其他不可预测的行为。您可以通过编写一些将调用分派到主线程的 Objective-C 代码来解决此限制。有关详细信息,请参阅 Objective-C dispatch 文档

关于最后一点,虽然 Dart 隔离区可以切换线程,但它们一次只在一个线程上运行。因此,您正在与之交互的 API 不一定必须是线程安全的,只要它不是线程敌对的,并且没有关于从哪个线程调用的约束即可。

只要您记住这些限制,就可以安全地与 Objective-C 代码交互。

Swift 示例

#

这个 示例演示了如何使 Swift 类与 Objective-C 兼容,生成包装器头文件,并从 Dart 代码中调用它。

生成 Objective-C 包装器头文件

#

可以使用 @objc 注释使 Swift API 与 Objective-C 兼容。请确保将您要使用的任何类或方法设置为 public,并让您的类继承 NSObject

swift
import Foundation

@objc public class SwiftClass: NSObject {
  @objc public func sayHello() -> String {
    return "Hello from Swift!";
  }

  @objc public var someField = 123;
}

如果您尝试与第三方库交互,并且无法修改其代码,则可能需要编写一个与 Objective-C 兼容的包装器类,该类公开您要使用的方法。

有关 Objective-C / Swift 互操作性的更多信息,请参阅 Swift 文档

使您的类兼容后,您可以生成一个 Objective-C 包装器头文件。您可以使用 Xcode 或 Swift 命令行编译器 swiftc 来完成此操作。此示例使用命令行。

$ swiftc -c swift_api.swift             \
    -module-name swift_module           \
    -emit-objc-header-path swift_api.h  \
    -emit-library -o libswiftapi.dylib

此命令会编译 Swift 文件 swift_api.swift,并生成一个包装器头文件 swift_api.h。它还会生成您稍后要加载的 dylib libswiftapi.dylib

您可以通过打开头文件并检查接口是否符合预期来验证头文件是否已正确生成。在文件底部附近,您应该看到类似以下内容的内容:

objc
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

如果接口丢失或缺少所有方法,请确保它们都使用 @objcpublic 注释。

配置 ffigen

#

Ffigen 只会看到 Objective-C 包装器头文件 swift_api.h。因此,大多数配置看起来与 Objective-C 示例类似,包括将语言设置为 objc

yaml
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'
  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

与之前一样,将语言设置为 objc,并将入口点设置为头文件;默认情况下排除所有内容,并显式包含您要绑定的接口。

包装的 Swift API 和纯 Objective-C API 的配置之间的一个重要区别:objc-interfaces -> module 选项。当 swiftc 编译库时,它会为 Objective-C 接口添加模块前缀。在内部,SwiftClass 实际上注册为 swift_module.SwiftClass。您需要将此前缀告知 ffigen,以便它从 dylib 加载正确的类。

并非每个类都会获得此前缀。例如,NSStringNSObject 不会获得模块前缀,因为它们是内部类。这就是为什么 module 选项将类名映射到模块前缀。您还可以使用正则表达式一次匹配多个类名。

模块前缀是您在 -module-name 标志中传递给 swiftc 的任何内容。在此示例中,它是 swift_module。如果您没有显式设置此标志,则它会默认为 Swift 文件的名称。

如果您不确定模块名称是什么,您还可以检查生成的 Objective-C 头文件。在 @interface 上方,您会找到一个 SWIFT_CLASS 宏。

objc
SWIFT_CLASS("_TtC12swift_module10SwiftClass")
@interface SwiftClass : NSObject

宏中的字符串有点神秘,但您可以看到它包含模块名称和类名称:"_TtC12swift_module10SwiftClass"

Swift 甚至可以为我们反解此名称。

$ echo "_TtC12swift_module10SwiftClass" | swift demangle

这将输出 swift_module.SwiftClass

生成 Dart 绑定

#

与之前一样,导航到示例目录,并运行 ffigen。

$ dart run ffigen

这将生成 swift_api_bindings.dart

使用绑定

#

与这些绑定的交互与普通 Objective-C 库的交互完全相同。

dart
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}');
}

请注意,生成的 Dart API 中没有提及模块名称。它仅在内部用于从 dylib 加载类。

现在,您可以使用以下命令运行示例:

$ dart run example.dart