使用 FFI 繫結到原生程式碼
要在 Flutter 程式中使用原生程式碼,請使用 dart:ffi 庫和 package_ffi 模板。
Flutter 應用可以使用 dart:ffi 庫來呼叫原生 API。FFI 代表 外部函式介面 (foreign function interface)。類似功能的其他術語包括原生介面 (native interface) 和語言繫結 (language bindings)。
自 Flutter 3.38 起,繫結原生程式碼的推薦方式是使用 flutter create --template=package_ffi 命令。此模板使用構建鉤子 (build hooks) 在 build.dart 指令碼中配置原生構建,不再需要特定於作業系統的構建檔案。此方法適用於 Flutter 和 Dart 獨立專案。
如果您需要使用 Flutter 外掛 API,或者需要在 Android 上配置 Google Play 服務執行時,請使用標準外掛模板 (flutter create --template=plugin)。
建立 FFI 包
#要建立 FFI 包,請執行以下命令
flutter create --template=package_ffi native_add
cd native_add
這將建立一個包含以下特定內容的包
-
lib/native_add.dart:定義包 API 的 Dart 程式碼。 -
lib/native_add_bindings_generated.dart:為原生程式碼生成的 Dart 繫結。 src/native_add.c:原生 C 原始碼。src/native_add.h:原生程式碼的 C 標頭檔案。-
hook/build.dart:由 Flutter SDK 執行以編譯原生程式碼的指令碼。 -
ffigen.yaml:用於package:ffigen生成 Dart 繫結的配置檔案。 -
pubspec.yaml:包定義,用於啟用build.dart鉤子。
原生程式碼
#原生程式碼位於 src/native_add.c 和 src/native_add.h 中。C 函式 sum 定義在 .c 檔案中,其簽名位於標頭檔案中。該函式被標記為匯出,以便可以從 Dart 呼叫。
構建鉤子 (build hook)
#原生程式碼會自動編譯並打包到您的應用中。這是透過 hook/build.dart 指令碼(一種構建鉤子)完成的。
這意味著您不再需要編寫特定於作業系統的構建檔案(如 Linux/Windows 的 CMakeLists.txt、iOS/macOS 的 .podspec 或 Android 的 build.gradle)來編譯原生程式碼。
構建鉤子使用 package:native_toolchain_c 將 C 程式碼編譯為動態庫。您可以自定義此檔案以構建其他原生語言或下載預編譯的二進位制檔案。
Dart 程式碼
#Dart 程式碼定義了包的公共 API。
生成繫結
#為了繫結到原生程式碼,模板使用 package:ffigen 從標頭檔案 (src/native_add.h) 生成繫結。生成配置在 ffigen.yaml 中。
這將生成 lib/native_add_bindings_generated.dart。
呼叫原生函式
#lib/native_add_bindings_generated.dart 中生成的繫結包含 @Native() external 函式。這些函式會在執行時根據構建鉤子(在構建時執行)輸出的程式碼資產自動解析。這意味著不需要特定於作業系統的 dlopen 動態庫邏輯,從而使 Dart 程式碼真正實現了跨平臺。
主庫檔案 lib/native_add.dart 暴露了這些函式。您的應用隨後可以透過匯入 package:native_add/native_add.dart 來呼叫這些函式。
測試
#生成的包在 test/native_add_test.dart 中包含一個單元測試,展示瞭如何測試原生函式。
其他用例
#系統庫
#要連結到系統庫,您可以修改 build.dart 鉤子來指定連結模式。您無需編譯原始碼,而是建立一個 CodeAsset 並設定其 linkMode。
對於 Android、iOS、Linux 和 macOS 上的許多系統庫,您可以使用 LookupInProcess() 在主程序中查詢符號。
對於 Windows,您通常使用 DynamicLoadingSystem() 並提供 DLL 的名稱。
以下是一個連結到系統庫以獲取主機名的 build.dart 示例
// hook/build.dart
import 'package:hooks/hooks.dart';
import 'package:code_assets/code_assets.dart';
void main(List<String> args) async {
await build(args, (input, output) async {
final targetOS = input.target.os;
switch (targetOS) {
case OS.android || OS.iOS || OS.linux || OS.macOS:
output.assets.code.add(
CodeAsset(
package: 'host_name',
name: 'src/third_party/unix.dart',
linkMode: LookupInProcess(),
),
);
case OS.windows:
output.assets.code.add(
CodeAsset(
package: 'host_name',
name: 'src/third_party/windows.dart',
linkMode: DynamicLoadingSystem(Uri.file('ws2_32.dll')),
),
);
default:
throw Exception('Unsupported target os: $targetOS');
}
});
}
Dart 檔案 (unix.dart, windows.dart) 將包含使用這些系統庫中符號的 external 函式。
在 Android 上打包 libc++_shared.so
#
雖然 libc++_shared.so 隨 Android NDK 一起提供,但它不是系統庫。如果您的應用或包使用了 C++ 標準庫,或者包含了依賴於它的多個共享庫,則您的應用需要打包 libc++_shared.so。
要將此庫打包到應用中,請新增對 package:android_libcpp_shared 的依賴,它使用自己的構建鉤子從本地安裝的 NDK 為每個目標架構打包 libc++_shared.so。
閉源庫
#您還可以使用構建鉤子連結到預編譯的閉源庫。推薦的做法是在構建時下載預編譯的二進位制檔案,並使用檔案雜湊驗證其完整性。
在您的 build.dart 鉤子中,您可以:
- 從 URL 下載庫。
- 驗證下載檔案的雜湊值。
- 將庫放入構建輸出目錄。
- 建立一個指向該庫並使用
DynamicLoading的CodeAsset。
這是一個 CodeAsset 建立的簡化示例
// hook/build.dart
import 'package:hooks/hooks.dart';
import 'package:code_assets/code_assets.dart';
void main(List<String> args) async {
await build(args, (input, output) async {
// 1. Download the library from a URL.
// 2. Verify the hash of the downloaded file.
// 3. Place the library in the build output directory.
output.assets.code.add(
CodeAsset(
package: input.packageName,
name: 'src/my_lib.dart', // Dart file with bindings
linkMode: DynamicLoadingBundled(),
file: input.outputDirectory.resolve('my_lib.so'),
),
);
});
}
您需要透過擁有預編譯庫的不同版本來處理不同的架構和平臺。
有關更多示例,請參閱 code_assets 包示例。
動態庫命名準則
#在為打包程式碼資產的包實現 build.dart 鉤子時,確保所有目標架構和 SDK 中動態庫命名的一致性至關重要。
在 Apple 平臺(iOS 和 macOS)上,動態庫被打包進框架 (frameworks) 中。Flutter 的構建系統依賴於這些名稱來生成元資料和可分發格式(如 XCFrameworks)。
跨架構的一致性
#對於給定的資產 ID,您的鉤子將被多次呼叫(每個架構一次)。無論目標架構如何(例如 arm64 與 x64),您的鉤子必須生成相同的檔名。
-
原因:在單個 SDK 構建中,Flutter 使用
lipo將特定於架構的二進位制檔案組合成單個通用(胖)二進位制檔案。如果架構具有不同的檔名,工具將非確定性地選擇一個併發出警告。此外,如果動態庫被重新命名,執行時的錯誤訊息對您的使用者來說將難以理解。 -
推薦操作:避免在檔名中新增架構字尾(例如,使用
libsqlite3.dylib而不是libsqlite3_arm64.dylib)。相反,將檔案寫入input.outputDirectory(每個架構唯一)或input.outputDirectoryShared的架構特定子目錄中(例如input.outputDirectoryShared.resolve('$architecture/'))。
跨 SDK 的一致性 (iOS)
#在為 iOS 構建時,您的鉤子會因 SDK 和架構的不同值而被多次呼叫。物理裝置 (iphoneos) 和模擬器 (iphonesimulator) 的呼叫都必須為相同的資產 ID 生成相同的框架名稱。
-
原因:Flutter 使用
xcodebuild -create-xcframework來組合這些輸出。Xcode 要求 XCFramework 內的所有平臺切片共享相同的框架名稱,以實現無縫連結。如果檔名不同,Flutter 工具將無法建立正確的 XCFramework,並且flutter build ios-framework等命令將會失敗。 -
推薦操作:不要在模擬器構建中使用
_sim或_simulator等字尾。XCFramework 結構已經在內部處理了平臺分離(例如MyLib.xcframework/ios-arm64_x86_64-simulator/MyLib.framework)。相反,將檔案寫入input.outputDirectory(每個 SDK 唯一)或input.outputDirectoryShared的 SDK 特定子目錄中。
資產集的一致性
#對於給定的目標平臺,您的鉤子必須在所有 SDK 中生成相同的資產 ID 集。
-
原因:Apple 的構建系統和 App Store 驗證要求應用中包含的所有框架都與目標裝置相容。如果您為模擬器 (
iphonesimulator) 生成了資產,但沒有為物理裝置 (iphoneos) 生成,則生成的 XCFramework 將包含一個在裝置上沒有對應項的切片。這可能導致構建失敗,或因為在裝置構建中包含了僅限模擬器的二進位制檔案而被 Apple 拒絕上架。 -
推薦操作:確保您的
build.dart鉤子邏輯始終如一地處理所有受支援的 SDK。如果您為一個 SDK 生成了資產,則必須為該平臺的其他所有 SDK 生成對應的資產。對於 SDK 特定的程式碼,您可以為其他 SDK 使用存根 (stub) 實現。