跳到主內容

Objective-C 和 Swift 互操作性,使用 package:ffigen

在 macOS 或 iOS 上執行的 Dart 移動、命令列和伺服器應用程式,在 Dart Native 平臺上,可以使用 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 標頭檔案。

如果您檢視示例配置,還會看到另一個重要內容:excludeinclude 選項。預設情況下,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 隔離區(isolates)與作業系統執行緒之間的關係,以及 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 選項將類名對映到模組字首的原因。您還可以使用正則表示式一次匹配多個類名。

模組字首是您傳遞給 swiftc-module-name 標誌的值。在此示例中,它是 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