跳到主内容

使用 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.csrc/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 示例

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 钩子中,您可以:

  1. 从 URL 下载库。
  2. 验证下载文件的哈希值。
  3. 将库放入构建输出目录。
  4. 创建一个指向该库并使用 DynamicLoadingCodeAsset

这是一个 CodeAsset 创建的简化示例

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 {
    // 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,您的钩子将被多次调用(每个架构一次)。无论目标架构如何(例如 arm64x64),您的钩子必须生成相同的文件名。

  • 原因:在单个 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) 实现。