內容

使用套件: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:ffigen 來產生 AVAudioPlayer 的繫結。此 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 標頭。

如果您查看 範例設定,會看到另一個重要事項,即排除和包含選項。預設情況下,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 自述檔案,以取得所有設定選項的完整清單。

產生 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 介面的 Dart 包裝器,例如 AVAudioPlayer 及其依賴項。

使用繫結

#

現在您可以載入並與產生的函式庫互動。範例應用程式 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 使用參照計數進行記憶體管理(透過保留、釋放和其他函式),但在 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 隔離區與執行緒不同。隔離區會在執行緒上執行,但無法保證在特定執行緒上執行,而且 VM 可能會在不預警的情況下變更隔離區執行的執行緒。有一個 開放功能要求,可讓隔離區固定到特定執行緒。
  • 雖然 ffigen 支援將 Dart 函式轉換為 Objective-C 區塊,但大多數 Apple API 都不保證回呼會在哪些執行緒上執行。
  • 大多數涉及 UI 互動的 API 只能在主執行緒上呼叫,在 Flutter 中也稱為平台執行緒。
  • 許多 Apple API 不是執行緒安全的

前兩點表示在一個隔離區中建立的回呼,可能會在執行不同隔離區或完全沒有隔離區的執行緒上呼叫。這可能會導致您的應用程式崩潰,具體取決於您使用的回呼類型。使用 Pointer.fromFunctionNativeCallable.isolateLocal 建立的回呼必須在擁有者隔離區的執行緒上呼叫,否則它們會崩潰。使用 NativeCallable.listener 建立的回呼可以安全地從任何執行緒呼叫。

第三點表示直接使用產生的 Dart 繫結呼叫某些 Apple API 可能不是執行緒安全的。這可能會導致您的應用程式崩潰,或造成其他無法預測的行為。您可以透過撰寫一些將呼叫傳送至主執行緒的 Objective-C 程式碼來解決此限制。如需更多資訊,請參閱 Objective-C 傳送文件

關於最後一點,雖然 Dart 隔離區可以切換執行緒,但它們一次只會在一個執行緒上執行。因此,您互動的 API 不一定必須是執行緒安全的,只要它不是執行緒不友善的,而且沒有關於從哪個執行緒呼叫它的限制即可。

只要您牢記這些限制,就可以安全地與 Objective-C 程式碼互動。

Swift 範例

#

範例 示範如何讓 Swift 類別與 Objective-C 相容、產生包裝標頭,並從 Dart 程式碼呼叫它。

產生 Objective-C wrapper 標頭

#

Swift API 可以透過使用 @objc 註解與 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