mcpField
D
dart-setup-ffi-assets

dart-setup-ffi-assets

github.com/flutter开发3.1k14.3k 次安装2 天前更新
代码仓库

关于 dart-setup-ffi-assets

面向 Dart 包作者的技能指南,说明如何通过 Native Assets hooks 将 C/C++ 源码编译并打包为 Code Assets。涵盖本地编译与符号裁剪,以及下载预编译库并校验完整性的方案。

主要功能

  1. 01配置 Native Assets 构建与链接 hooks
  2. 02使用 CBuilder 和 CLibrary 编译 C 库
  3. 03通过符号映射执行 tree-shaking
  4. 04下载并校验预编译动态库
  5. 05为离线环境提供本地编译回退

适用场景

  1. 01将 C/C++ 源码纳入 Dart 包构建
  2. 02按 FFI 使用情况裁剪原生库符号
  3. 03为缺少编译工具链的环境提供预编译库

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/flutter/agent-plugins --skill dart-setup-ffi-assets

安装后在 Claude Code 中输入/dart-setup-ffi-assets确认已加载。

详细介绍

使用原生资源 Hook 将 C 代码编译为代码资源

通过构建和链接 Hook,将原生 C/C++ 源代码编译并打包为 代码资源,集成到 Dart 的 原生资源 功能中并实现自动化。

目录


简介

在 Dart 的 原生资源 功能中,软件包可以将原生代码(如 C/C++ 库)打包为 代码资源,并在标准开发周期中(例如 dart run、dart test、dart build 和 flutter run)自动捆绑。代码资源 的打包由软件包 hook/ 文件夹中的两个程序化 Hook 脚本驱动:

  1. hook/build.dart:将本地 C 源码编译为机器码,或将预构建的原生二进制文件打包为代码资源,以适配特定的主机/目标架构。
  2. hook/link.dart:链接已构建的代码资源,应用高级摇树优化,以剥离未使用的原生符号并压缩运行时二进制文件的大小。

约束

重要

确保所有文件解析都与平台无关。切勿硬编码绝对目标路径、Shell 脚本或系统命令变量。始终使用 Platform.script.resolve() 或基于 Uri 的解析,以确保脚本完全可移植。

  • Hook 位置:编译和打包 Hook 必须严格位于软件包根目录下的 hook/ 目录中:
    • hook/build.dart(构建执行阶段)
    • hook/link.dart(可选的打包、链接或树摇阶段)
  • 编译工具链标准:使用 package:native_toolchain_c 中的程序化 API(例如 CBuilder 和 CLibrary)运行编译工具链。切勿通过 Shell 命令调用原始的 gcc、clang 或 msvc。
  • 前言和许可证标头:每个手工编写和生成的源文件(包括绑定、辅助程序和 Hook)都必须严格包含目标软件包的版权和许可证标头。
  • 树摇映射:如果使用编译器树摇,则必须通过 FFIgen 生成的记录使用映射,将目标 Dart 方法名称(例如 Method.name)映射回其原始 Native C 符号名称。映射文件必须位于 lib/src/third_party/ 下,并且扩展名必须严格为 .g.dart(例如 sqlite3.record_use_mapping.g.dart)。
  • 预编译库的完整性保障:如果采用动态下载模式:
    • 加密验证:必须使用预配置的查找表校验下载的预构建二进制文件,其中包含 MD5 或 SHA-256 哈希,以确保二进制文件完整性并防止篡改。
    • 优雅恢复:为离线开发者提供支持回退方案(例如通过 local_build 等标志在本地运行编译器)。

Native 互操作软件包

Code Assets 的程序化构建和链接 Hook 依赖三个专用的 Native 互操作软件包:

依赖项用途关键 API 抽象
package:hooks主要的编排器,用于定义执行边界。build(args, callback)、link(args, callback)
package:native_toolchain_c检测本地编译器(MSVC、Xcode/Clang、GCC)并执行构建工具链。CLibrary、CBuilder、LinkerOptions.treeshake
package:code_assets对传递给动态加载器的代码元数据记录进行建模。CodeAsset、DynamicLoadingBundled

分步工作流

步骤 1:添加依赖项

向软件包添加 Code Assets Hook 和工具链依赖项。必须直接从 pub.dev 获取这些依赖项。

可以使用 CLI 自动添加:

dart pub add code_assets hooks native_toolchain_c record_use dev:ffigen

或者在目标软件包的 pubspec.yaml 中手动声明:

dependencies:
  code_assets: ^1.0.0
  hooks: ^0.1.0
  native_toolchain_c: ^0.1.0
  record_use: ^0.6.0

dev_dependencies:
  ffigen: ^20.1.1
步骤 2:定义 C 规范

在 lib/src/c_library.dart 中定义目标 C 库的编译元数据。这样,构建和链接 Hook 就能共享一份关于资源、名称和源文件的单一事实来源。

步骤 3:实现构建和链接 Hook 脚本

在 hook/build.dart 中编写编译编排脚本,并在 hook/link.dart 中编写死代码消除逻辑。

步骤 4:运行 Hook 周期

运行标准测试套件时,会在后台动态启动构建和链接 Hook 生命周期:

dart test

选择集成方式

在 Dart 中集成和交付 C/C++ 原生资产有两种主要方式。请选择符合项目需求的一种:

方面方法 1:本地编译与树摇方法 2:下载预编译文件
主要使用场景当软件包直接包含 C/C++ 源代码,且你希望最大限度地优化体积时。当本地编译速度较慢或过程复杂,或者希望避免对开发者主机工具链的要求时。
主机工具链要求需要预先安装平台 C 编译器(Xcode 工具、MSVC、GCC)。开发者或用户的机器无需配置编译器。
二进制优化高级。未使用的符号会被完全树摇,从而减小库体积。标准。直接随包提供常规编译的二进制文件。
离线配置完全支持。可完全离线使用。需要联网下载库,并提供离线回退方案。

方法 1:通过链接器树摇进行本地编译(推荐)

在此方式中,构建钩子会调用本地工具链(GCC、Clang、MSVC)直接编译源文件。随后,链接钩子会利用编译器选项过滤输出符号,只保留用户代码中调用的目标方法。这是 pkgs/code_assets/example/sqlite 中采用的标准且稳健的 SQLite 模式。

主机编译器工具链前提条件

由于 package:native_toolchain_c 会将实际的动态编译委托给主机操作系统的默认工具链,因此开发机器必须预先安装以下编译器软件包之一:

  • macOS:Xcode 命令行工具。通过以下命令安装:
    xcode-select --install
    
  • Linux:GCC 或 Clang。通过以下命令安装:
    sudo apt install build-essential
    
  • Windows:MSVC(Microsoft Visual C++)。安装 Visual Studio Installer,并选择 使用 C++ 的桌面开发工作负载。

注意:如果在主机路径中未找到兼容的工具链,构建钩子脚本会抛出编译执行异常。请务必指定编译器约束;如果无法保证工具链可用,请采用方法 2。

C 源代码和绑定配置

假设有一个定义简单数学函数的 C 源文件 third_party/sqlite/sqlite3.c,其入口点头文件位于 third_party/sqlite/sqlite3.h:

#ifndef SQLITE3_H_
#define SQLITE3_H_

const char *sqlite3_libversion(void);

#endif // SQLITE3_H_

我们使用程序化的 FFIgen 脚本(tool/ffigen.dart)在 lib/src/third_party/sqlite3.g.dart 中创建 FFI 绑定,以启用记录的用法跟踪,并在 lib/src/third_party/sqlite3.record_use_mapping.g.dart 中生成查找元数据映射:

// AUTO-GENERATED FILE - DO NOT MODIFY.
// Generated via ffigen.

const recordUseMapping = {
  'sqlite3_libversion': 'sqlite3_libversion',
};
定义 C 库构建规范

在 lib/src/c_library.dart 中定义集中式库规范:

import 'package:native_toolchain_c/native_toolchain_c.dart';

/// The C build specification for the sqlite library.
final cLibrary = CLibrary(
  name: 'sqlite3',
  assetName: 'src/third_party/sqlite3.g.dart',
  sources: ['third_party/sqlite/sqlite3.c'],
);
实现 hook/build.dart

使用 CLibrary.build 实现 hook/build.dart。此操作会将库构建为动态库(例如 .so、.dylib 或 .dll),并将其放在钩子的目标目录中:

import 'package:code_assets/code_assets.dart';
import 'package:hooks/hooks.dart';
import 'package:sqlite/src/c_library.dart';

void main(List<String> args) async {
  await build(args, (input, output) async {
    if (input.config.buildCodeAssets) {
      await cLibrary.build(
        input: input,
        output: output,
        defines: {
          if (input.config.code.targetOS == OS.windows)
            // Ensure C functions are explicitly exported in the Windows DLL
            'SQLITE_API': '__declspec(dllexport)',
        },
      );
    }
  });
}
实现 hook/link.dart

在 hook/link.dart 中实现链接优化阶段。此阶段会利用编译器树摇选项(LinkerOptions.treeshake),根据符号使用记录编译出经过最小化且已移除死代码的二进制文件:

import 'package:hooks/hooks.dart';
import 'package:native_toolchain_c/native_toolchain_c.dart';
import 'package:record_use/record_use.dart';
import 'package:sqlite/src/c_library.dart';
import 'package:sqlite/src/third_party/sqlite3.record_use_mapping.g.dart';

void main(List<String> arguments) async {
  await link(arguments, (input, output) async {
    await cLibrary.link(
      input: input,
      output: output,
      linkerOptions: LinkerOptions.treeshake(
        // Map Dart Method references back to raw C symbol names
        symbolsToKeep: input.recordedUses?.calls.keys.cast<Method>().map(
          (e) => recordUseMapping[e.name]!,
        ),
      ),
    );
  });
}

方法 2:下载预编译动态库

另一种方式是在中央构建机器上预先编译二进制文件并将其归档,然后在构建钩子执行期间下载目标二进制文件。这与 download_asset 钩子软件包中演示的范式一致。

为什么要下载预编译二进制文件?
  • 主机环境限制:在本地编译大型 C/C++ 库需要完整的编译器环境(GCC、Xcode/SDK、Visual Studio),而开发者使用的主机可能并不具备这些环境。
  • 编译速度:下载预编译文件只需几毫秒,而编译过程可能需要数分钟。
  • 跨平台支持:如果主机架构受限,可以避免交叉编译带来的限制。

实现预编译文件的动态下载

我们会配置构建钩子来检测本地编译标志(例如 local_build)。如果未指定该标志,钩子会使用 HttpClient 下载特定平台的库,计算 MD5 哈希值,并通过配置的哈希值查找表确认下载安全,然后将二进制文件注册为 CodeAsset:

1. 定义目标哈希值(lib/src/hook_helpers/hashes.dart)

在软件包源码中为各平台文件定义目标 MD5 哈希校验:

const assetHashes = {
  'libnative_add_macos_arm64.dylib': '4a88f50438a98402db2dbd47b59eb412',
  'libnative_add_linux_x64.so': '9f5e15043aa98402dcdbbd47b59ea520',
  'native_add_windows_x64.dll': 'a881e5043ba98402acdebd47b59fa321',
};
2. 钩子下载助手(lib/src/hook_helpers/download.dart)

使用动态目标文件名匹配实现下载和完整性校验逻辑:

import 'dart:io';
import 'package:code_assets/code_assets.dart';
import 'package:crypto/crypto.dart';

const version = '1.0.0';

Uri downloadUri(String target) => Uri.parse(
  'https://github.com/my-org/my-native-repo/releases/download/$version/$target',
);

Future<File> downloadAsset(
  OS targetOS,
  Architecture targetArchitecture,
  Directory outputDir,
) async {
  final fileName = targetOS.dylibFileName('native_add_${targetOS.name}_${targetArchitecture.name}');
  final uri = downloadUri(fileName);
  
  final client = HttpClient()..findProxy = HttpClient.findProxyFromEnvironment;
  final request = await client.getUrl(uri);
  final response = await request.close();
  
  if (response.statusCode != 200) {
    throw ArgumentError('Download target $uri failed: Code ${response.statusCode}');
  }
  
  final targetFile = File.fromUri(outputDir.uri.resolve(fileName));
  await targetFile.create(recursive: true);
  await response.pipe(targetFile.openWrite());
  
  return targetFile;
}

Future<String> hashAsset(File file) async {
  return md5.convert(await file.readAsBytes()).toString();
}
3. 实现 hook/build.dart

编写最终的下载构建钩子,并加入本地编译回退机制:

import 'dart:io';
import 'package:code_assets/code_assets.dart';
import 'package:hooks/hooks.dart';
import 'package:my_download_package/src/hook_helpers/hashes.dart';
import 'package:my_download_package/src/hook_helpers/download.dart';
import 'package:native_toolchain_c/native_toolchain_c.dart';

void main(List<String> args) async {
  await build(args, (input, output) async {
    final localBuild = input.userDefines['local_build'] as bool? ?? false;

    if (localBuild) {
      final name = 'native_add_${input.config.code.targetOS.name}_${input.config.code.targetArchitecture.name}';
      final builder = CBuilder.library(
        name: name,
        assetName: 'native_add.dart',
        sources: ['src/native_add.c'],
      );
      await builder.run(input: input, output: output);
    } else {
      final targetOS = input.config.code.targetOS;
      final targetArch = input.config.code.targetArchitecture;
      final outputDir = Directory.fromUri(input.outputDirectory);

      final file = await downloadAsset(targetOS, targetArch, outputDir);

      final fileHash = await hashAsset(file);
      final expectedFileName = targetOS.dylibFileName('native_add_${targetOS.name}_${targetArch.name}');
      final expectedHash = assetHashes[expectedFileName];

      if (fileHash != expectedHash) {
        throw Exception(
          'Security Mismatch: File $expectedFileName hash verification failed! '
          'Found hash: $fileHash, expected: $expectedHash.'
        );
      }

      output.assets.code.add(
        CodeAsset(
          package: input.packageName,
          name: 'native_add.dart',
          linkMode: DynamicLoadingBundled(),
          file: file.uri,
        ),
      );
    }
  });
}

验证清单

在宣布构建钩子或链接钩子实现完成之前,务必进行以下检查:

1. 本地执行沙箱

运行单元测试,并确认原生资源的编译/链接流程已完成,且没有运行时或构建工具异常:

dart test
2. 验证目标输出

进入软件包的目标目录,确认已为主机系统创建动态二进制资源:

  • macOS:确认 .dart_tool/resources/ 或目标目录中包含 .dylib 文件。
  • Linux:确认 .dart_tool/resources/ 或目标目录中包含 .so 文件。
  • Windows:确认 .dart_tool/resources/ 或目标目录中包含 .dll 文件。
3. 验证摇树剥离

为确保链接钩子确实剥离了未使用的原生符号并压缩了二进制打包内容,请执行以下验证:

  1. 编译 CLI/应用的生产版本包:
    dart build cli bin/main.dart
    
  2. 进入包含动态库的已编译构建目录。
  3. 查询导出的动态符号表:
    • macOS:
      nm -gU build/cli/lib/libsqlite3.dylib
      
    • Linux:
      nm -D build/cli/lib/libsqlite3.so
      
    • Windows(使用 MSVC Developer Command Prompt):
      dumpbin /EXPORTS build\cli\lib\sqlite3.dll
      
  4. 确认目标导出项:确认命令输出中仅包含明确保留的入口函数(例如 sqlite3_libversion),且不包含任何未引用或已剥离的符号。
  5. 不打包的情况:如果应用没有导入或调用原生库中的任何方法:
    • 确认链接钩子日志显示:Skipping linking as no symbols are to be kept.
    • 确认生产版本包中未构建或放置任何库(不会生成 .dylib/.so/.dll 文件,从而节省包体积)。
4. 验证离线兼容性(用户定义)

确认离线兼容性已完全启用,且下载回退机制在完全离线的情况下也能正常执行:

  1. 在软件包的 pubspec.yaml(或工作区根目录的 pubspec.yaml)中为软件包配置 local_build: true 定义:
    hooks:
      user_defines:
        <your_package_name>:
          local_build: true
    
  2. 禁用计算机的网络适配器,或在沙箱化的离线 Shell 中运行。
  3. 启动单元测试:
    dart test
    
  4. 确认测试套件成功使用主机编译器编译本地源文件,没有编译错误,也从未尝试发起网络下载请求。

评论

支持 Markdown