在 Flutter 插件中使用 FFI

1. 简介

Dart 的 FFI(外部函数接口)允许 Flutter 应用利用公开 C API 的现有原生库。Dart 支持在 Android、iOS、Windows、macOS 和 Linux 上运行 FFI。对于 Web 操作系统,Dart 支持 JavaScript 互操作性,但该主题不在此 Codelab 的讨论范围内。

您将构建的内容

在此 Codelab 中,您将构建一个使用 C 库的移动和桌面插件。使用此 API,您将编写一个利用该插件的简单示例应用。您的插件和应用将具备以下功能:

  • 将 C 库源代码导入新的 Flutter 插件
  • 自定义插件,以便允许在 Windows、macOS、Linux、Android 和 iOS 环境上构建插件
  • 构建应用以使用插件实现 JavaScript REPL(“读取-求值-输出”循环)

76b496eb58ef120a.png

学习内容

在此 Codelab 中,您将学习在桌面和移动平台上构建基于 FFI 的 Flutter 插件所需的实用知识,包括:

  • 生成基于 Dart FFI 的 Flutter 插件模板
  • 使用 ffigen 软件包为 C 库生成绑定代码
  • 使用 CMake 构建适用于 Android、Windows 和 Linux 的 Flutter FFI 插件
  • 使用 CocoaPods 构建适用于 iOS 和 macOS 的 Flutter FFI 插件

所需条件

  • Android Studio 4.1 或更高版本(用于 Android 开发)
  • Xcode 13 或更高版本(用于 iOS 和 macOS 开发)
  • 随带“Desktop development with C++”工作负载的 Visual Studio 2022 或 Visual Studio Build Tools 2022(用于 Windows 开发)
  • Flutter SDK
  • 您的目标开发平台所需的任何构建工具(例如,CMake 和 CocoaPods 等)。
  • 适用于您的目标开发平台的 LLVMffigen 将使用 LLVM 编译器工具套件来解析 C 头文件,以构建 Dart 中公开的 FFI 绑定。
  • 一个代码编辑器,例如 Visual Studio Code

2. 使用入门

Flutter 最近才添加了 ffigen 工具。您可以运行以下命令,确认您所安装的 Flutter 运行的是最新的稳定版本。

$ flutter doctor
Doctor summary (to see all details, run flutter doctor -v):
[✓] Flutter (Channel stable, 3.3.9, on macOS 13.1 22C65 darwin-arm, locale en)
[✓] Android toolchain - develop for Android devices (Android SDK version 33.0.0)
[✓] Xcode - develop for iOS and macOS (Xcode 14.1)
[✓] Chrome - develop for the web
[✓] Android Studio (version 2021.2)
[✓] IntelliJ IDEA Community Edition (version 2022.2.2)
[✓] VS Code (version 1.74.0)
[✓] Connected device (2 available)
[✓] HTTP Host Availability

• No issues found!

查看 flutter doctor 输出,确认其内容指示您在稳定渠道上,并且没有比您的版本更新的稳定 Flutter 版本可用。如果您的版本不稳定,或者有更新的版本可用,请运行以下两个命令将您的 Flutter 工具升级到最新稳定版本。

$ flutter channel stable
$ flutter upgrade

您可以使用以下任何设备来运行此 Codelab 中的代码:

  • 您的开发用计算机(用于构建插件和示例应用的桌面 build)
  • 一台连接到计算机并设置为开发者模式的实体 Android 或 iOS 设备
  • iOS 模拟器(需要安装 Xcode 工具)
  • Android 模拟器(需要在 Android Studio 中设置)

3. 生成插件模板

Flutter 插件开发入门

Flutter 自带适用于插件的模板,帮助您轻松上手。当您生成插件模板时,可以指定要使用的语言。

在您的工作目录中运行以下命令,以使用插件模板创建项目:

$ flutter create --template=plugin_ffi \
  --platforms=android,ios,linux,macos,windows ffigen_app

--platforms 参数指定您的插件将支持哪些平台。

您可以使用 tree 命令或操作系统的文件资源管理器来检查所生成的项目的布局。

$ tree -L 2 ffigen_app
ffigen_app
├── CHANGELOG.md
├── LICENSE
├── README.md
├── analysis_options.yaml
├── android
│   ├── build.gradle
│   ├── ffigen_app_android.iml
│   ├── local.properties
│   ├── settings.gradle
│   └── src
├── example
│   ├── README.md
│   ├── analysis_options.yaml
│   ├── android
│   ├── ffigen_app_example.iml
│   ├── ios
│   ├── lib
│   ├── linux
│   ├── macos
│   ├── pubspec.lock
│   ├── pubspec.yaml
│   └── windows
├── ffigen.yaml
├── ffigen_app.iml
├── ios
│   ├── Classes
│   └── ffigen_app.podspec
├── lib
│   ├── ffigen_app.dart
│   └── ffigen_app_bindings_generated.dart
├── linux
│   └── CMakeLists.txt
├── macos
│   ├── Classes
│   └── ffigen_app.podspec
├── pubspec.lock
├── pubspec.yaml
├── src
│   ├── CMakeLists.txt
│   ├── ffigen_app.c
│   └── ffigen_app.h
└── windows
    └── CMakeLists.txt

17 directories, 26 files

不妨先花时间浏览一下目录结构,了解已创建的内容及其所在位置。这有助于您开展后续工作。plugin_ffi 模板将插件的 Dart 代码放置在 lib 下,以及名称为 androidioslinuxmacoswindows 的平台特定目录下,还有最重要的 example 目录下。

对于习惯于常规 Flutter 开发的开发者来说,此结构可能会让人感到奇怪,因为顶层并未定义可执行文件。插件应当包含在其他 Flutter 项目中,但您将在 example 目录中填充代码,以确保您的插件代码能正常运行。

让我们开始吧!

4. 构建并运行示例

为确保已正确安装构建系统和前提条件,并且适用于所有受支持的平台,请为每个目标构建并运行生成的示例应用。

Windows

确保您使用的是受支持的 Windows 版本。此 Codelab 已知可在 Windows 10 和 Windows 11 上运行。

您可以在代码编辑器或命令行中构建应用。

PS C:\Users\brett\Documents> cd .\ffigen_app\example\
PS C:\Users\brett\Documents\ffigen_app\example> flutter run -d windows
Launching lib\main.dart on Windows in debug mode...Building Windows application...
Syncing files to device Windows...                                 160ms

Flutter run key commands.
r Hot reload.
R Hot restart.
h List all available interactive commands.
d Detach (terminate "flutter run" but leave application running).
c Clear the screen
q Quit (terminate the application on the device).

 Running with sound null safety

An Observatory debugger and profiler on Windows is available at: http://127.0.0.1:53317/OiKWpyHXxHI=/
The Flutter DevTools debugger and profiler on Windows is available at:
http://127.0.0.1:9100?uri=http://127.0.0.1:53317/OiKWpyHXxHI=/

您应当会看到一个正在运行的应用窗口,如下所示:

3e0aca5027bf9ee5.png

Linux

确保您使用的是受支持的 Linux 版本。此 Codelab 使用 Ubuntu 22.04.1

安装步骤 2 中列出的所有前提条件后,在终端中运行以下命令:

$ cd ffigen_app/example
$ flutter run -d linux
Launching lib/main.dart on Linux in debug mode...
Building Linux application...
Syncing files to device Linux...                                   504ms

Flutter run key commands.
r Hot reload. 🔥🔥🔥
R Hot restart.
h List all available interactive commands.
d Detach (terminate "flutter run" but leave application running).
c Clear the screen
q Quit (terminate the application on the device).

💪 Running with sound null safety 💪

An Observatory debugger and profiler on Linux is available at: http://127.0.0.1:36653/Wgek1JGag48=/
The Flutter DevTools debugger and profiler on Linux is available at:
http://127.0.0.1:9103?uri=http://127.0.0.1:36653/Wgek1JGag48=/

您应当会看到一个正在运行的应用窗口,如下所示:

d2298ee958814232.png

Android

对于 Android,您可以使用 Windows、macOS 或 Linux 进行编译。首先,确保您的开发计算机已连接 Android 设备或正在运行 Android 模拟器 (AVD) 实例。运行以下命令,确认 Flutter 能够连接到 Android 设备或模拟器:

$ flutter devices
3 connected devices:

sdk gphone64 arm64 (mobile) • emulator-5554 • android-arm64  • Android 12 (API 32) (emulator)
macOS (desktop)             • macos         • darwin-arm64   • macOS 13.1 22C65 darwin-arm
Chrome (web)                • chrome        • web-javascript • Google Chrome 108.0.5359.98

5616e9d659614460.png

macOS 和 iOS

对于 macOS 和 iOS Flutter 开发,您必须使用 macOS 计算机。

首先,在 macOS 上运行示例应用。再次确认 Flutter 看到的设备:

$ flutter devices
2 connected devices:

macOS (desktop) • macos  • darwin-arm64   • macOS 13.1 22C65 darwin-arm
Chrome (web)    • chrome • web-javascript • Google Chrome 108.0.5359.98

使用生成的插件项目运行示例应用:

$ cd ffigen_app/example
$ flutter run -d macos

您应当会看到一个正在运行的应用窗口,如下所示:

808f738662f4a43.png

对于 iOS,您可以使用模拟器或真实的硬件设备。如果使用模拟器,请先启动模拟器。现在,flutter devices 命令会将模拟器列为其可用设备之一。

$ flutter devices
3 connected devices:

iPhone SE (3rd generation) (mobile) • 1BCBE334-7EC4-433A-90FD-1BC14F3BA41F • ios            • com.apple.CoreSimulator.SimRuntime.iOS-16-1 (simulator)
macOS (desktop)                     • macos                                • darwin-arm64   • macOS 13.1 22C65 darwin-arm
Chrome (web)                        • chrome                               • web-javascript • Google Chrome 108.0.5359.98

当模拟器启动后,运行 flutter run

$ cd ffigen_app/example
$ flutter run -d iphone

d39c62d1959718cd.png

iOS 模拟器优先于 macOS 目标,因此您可以跳过使用 -d 参数指定设备的步骤。

恭喜!您已经成功构建了一个可在五个不同操作系统上运行的应用。接下来,构建原生插件,并在 Dart 中使用 FFI 与该插件进行交互。

5. 在 Windows、Linux 和 Android 上使用 Duktape

您将在此 Codelab 中使用的 C 库是 Duktape。Duktape 是一个可嵌入的 JavaScript 引擎,具有高度可移植性和低资源占用的特点。在此步骤中,您将配置插件以编译 Duktape 库,将其关联至插件,然后使用 Dart 的 FFI 来访问该库。

此步骤将配置在 Windows、Linux 和 Android 上运行集成。iOS 和 macOS 集成需要通过额外的配置(超出此步骤的讨论范围)将已编译的库包含到最终的 Flutter 可执行文件中。下一步将介绍所需的额外配置。

检索 Duktape

首先,从 duktape.org 网站下载 duktape 源代码的副本。

对于 Windows,您可以通过 Invoke-WebRequest 来使用 PowerShell:

PS> Invoke-WebRequest -Uri https://duktape.org/duktape-2.7.0.tar.xz -OutFile duktape-2.7.0.tar.xz

对于 Linux,可以选择使用 wget

$ wget https://duktape.org/duktape-2.7.0.tar.xz
--2022-12-22 16:21:39--  https://duktape.org/duktape-2.7.0.tar.xz
Resolving duktape.org (duktape.org)... 104.198.14.52
Connecting to duktape.org (duktape.org)|104.198.14.52|:443... connected.
HTTP request sent, awaiting response... 200 OK
Length: 1026524 (1002K) [application/x-xz]
Saving to: ‘duktape-2.7.0.tar.xz'

duktape-2.7.0.tar.x 100%[===================>]   1002K  1.01MB/s    in 1.0s

2022-12-22 16:21:41 (1.01 MB/s) - ‘duktape-2.7.0.tar.xz' saved [1026524/1026524]

该文件是一个 tar.xz 归档文件。在 Windows 上,一种选项是下载 7Zip 工具,并按以下方式使用该工具。

PS> 7z x .\duktape-2.7.0.tar.xz

7-Zip 22.01 (x64) : Copyright (c) 1999-2022 Igor Pavlov : 2022-07-15

Scanning the drive for archives:
1 file, 1026524 bytes (1003 KiB)

Extracting archive: .\duktape-2.7.0.tar.xz
--
Path = .\duktape-2.7.0.tar.xz
Type = xz
Physical Size = 1026524
Method = LZMA2:26 CRC64
Streams = 1
Blocks = 1

Everything is Ok

Size:       19087360
Compressed: 1026524

您需要运行 7z 两次,第一次解压缩 xz 归档文件,第二次扩展 tar 文件。

PS> 7z x .\duktape-2.7.0.tar

7-Zip 22.01 (x64) : Copyright (c) 1999-2022 Igor Pavlov : 2022-07-15

Scanning the drive for archives:
1 file, 19087360 bytes (19 MiB)

Extracting archive: .\duktape-2.7.0.tar
--
Path = .\duktape-2.7.0.tar
Type = tar
Physical Size = 19087360
Headers Size = 543232
Code Page = UTF-8
Characteristics = GNU ASCII

Everything is Ok

Folders: 46
Files: 1004
Size:       18281564
Compressed: 19087360

在现代 linux 环境中,tar 只需一步即可提取内容,如下所示:

$ tar xvf duktape-2.7.0.tar.xz
x duktape-2.7.0/
x duktape-2.7.0/README.rst
x duktape-2.7.0/Makefile.sharedlibrary
x duktape-2.7.0/Makefile.coffee
x duktape-2.7.0/extras/
x duktape-2.7.0/extras/README.rst
x duktape-2.7.0/extras/module-node/
x duktape-2.7.0/extras/module-node/README.rst
x duktape-2.7.0/extras/module-node/duk_module_node.h
x duktape-2.7.0/extras/module-node/Makefile
[... and many more files]

安装 LLVM

若要使用 ffigen,您需要安装 LLVMffigen 将使用 LLVM 来解析 C 头文件。在 Windows 上,请运行以下命令:

PS> winget install -e --id LLVM.LLVM
Found LLVM [LLVM.LLVM] Version 15.0.5
This application is licensed to you by its owner.
Microsoft is not responsible for, nor does it grant any licenses to, third-party packages.
Downloading https://github.com/llvm/llvm-project/releases/download/llvmorg-15.0.5/LLVM-15.0.5-win64.exe
  ██████████████████████████████   277 MB /  277 MB
Successfully verified installer hash
Starting package install...
Successfully installed

配置您的系统路径,将 C:\Program Files\LLVM\bin 添加到您的二进制搜索路径,这将完成在 Windows 计算机上安装 LLVM 的步骤。您可以通过以下方式测试是否已正确安装 LLVM。

PS> clang --version
clang version 15.0.5
Target: x86_64-pc-windows-msvc
Thread model: posix
InstalledDir: C:\Program Files\LLVM\bin

对于 Ubuntu,可以通过以下方式安装 LLVM 依赖项。其他 Linux 发行版具有类似的 LLVM 和 Clang 依赖项。

$ sudo apt install libclang-dev
[sudo] password for brett:
Reading package lists... Done
Building dependency tree... Done
Reading state information... Done
The following additional packages will be installed:
  libclang-15-dev
The following NEW packages will be installed:
  libclang-15-dev libclang-dev
0 upgraded, 2 newly installed, 0 to remove and 0 not upgraded.
Need to get 26.1 MB of archives.
After this operation, 260 MB of additional disk space will be used.
Do you want to continue? [Y/n] y
Get:1 http://archive.ubuntu.com/ubuntu kinetic/universe amd64 libclang-15-dev amd64 1:15.0.2-1 [26.1 MB]
Get:2 http://archive.ubuntu.com/ubuntu kinetic/universe amd64 libclang-dev amd64 1:15.0-55.1ubuntu1 [2962 B]
Fetched 26.1 MB in 7s (3748 kB/s)
Selecting previously unselected package libclang-15-dev.
(Reading database ... 85898 files and directories currently installed.)
Preparing to unpack .../libclang-15-dev_1%3a15.0.2-1_amd64.deb ...
Unpacking libclang-15-dev (1:15.0.2-1) ...
Selecting previously unselected package libclang-dev.
Preparing to unpack .../libclang-dev_1%3a15.0-55.1ubuntu1_amd64.deb ...
Unpacking libclang-dev (1:15.0-55.1ubuntu1) ...
Setting up libclang-15-dev (1:15.0.2-1) ...
Setting up libclang-dev (1:15.0-55.1ubuntu1) ...

如上所述,您可以通过以下方式在 Linux 上测试 LLVM 安装。

$ clang --version
Ubuntu clang version 15.0.2-1
Target: x86_64-pc-linux-gnu
Thread model: posix
InstalledDir: /usr/bin

配置 ffigen

模板生成的顶层 pubpsec.yaml 可能包含过时的 ffigen 软件包版本。运行以下命令,更新插件项目中的 Dart 依赖项:

$ flutter pub upgrade --major-versions

现在,ffigen 软件包已是最新版本,接下来配置 ffigen 将使用哪些文件来生成绑定文件。修改项目的 ffigen.yaml 文件的内容,如下所示:

ffigen.yaml

# Run with `flutter pub run ffigen --config ffigen.yaml`.
name: DuktapeBindings
description: |
  Bindings for `src/duktape.h`.

  Regenerate bindings with `flutter pub run ffigen --config ffigen.yaml`.
output: 'lib/duktape_bindings_generated.dart'
headers:
  entry-points:
    - 'src/duktape.h'
  include-directives:
    - 'src/duktape.h'
preamble: |
  // ignore_for_file: always_specify_types
  // ignore_for_file: camel_case_types
  // ignore_for_file: non_constant_identifier_names
comments:
  style: any
  length: full

此配置包括传递给 LLVM 的 C 头文件、要生成的输出文件、放置在文件顶部的描述,以及用于添加 lint 警告的前导部分。如需详细了解键和值,请参阅ffigen文档

您需要从 Duktape 发行版中复制特定的 Duktape 文件到另一个目录,也就是 ffigen 经配置可查找到这些文件的位置。

$ cp duktape-2.7.0/src/duktape.c src/
$ cp duktape-2.7.0/src/duktape.h src/
$ cp duktape-2.7.0/src/duk_config.h src/

从技术上来说,您只需要为 ffigen 复制 duktape.h,但接下来要配置 CMake 以构建需要所有这三个文件的库。运行 ffigen 以生成新的绑定:

$ flutter pub run ffigen --config ffigen.yaml
Running in Directory: '/home/brett/GitHub/codelabs/ffigen_codelab/step_05'
Input Headers: [./src/duktape.h]
[WARNING]: No definition found for declaration - (Cursor) spelling: duk_hthread, kind: 2, kindSpelling: StructDecl, type: 105, typeSpelling: struct duk_hthread, usr: c:@S@duk_hthread
[WARNING]: No definition found for declaration - (Cursor) spelling: duk_hthread, kind: 2, kindSpelling: StructDecl, type: 105, typeSpelling: struct duk_hthread, usr: c:@S@duk_hthread
[WARNING]: Generated declaration '__va_list_tag' start's with '_' and therefore will be private.
Finished, Bindings generated in /home/brett/GitHub/codelabs/ffigen_codelab/step_05/./lib/duktape_bindings_generated.dart

您将在每个操作系统上看到不同的警告。一项已知事实是,Duktape 2.7.0 可以在 Windows、Linux 和 macOS 上通过 clang 进行编译。因此,您现在可以忽略这些警告。

配置 CMake

CMake 是一个构建系统生成系统。该插件使用 CMake 为 Android、Windows 和 Linux 生成构建系统,以将 Duktape 包含到生成的 Flutter 二进制文件中。您需要修改模板生成的 CMake 配置文件,如下所示。

src/CMakeLists.txt

cmake_minimum_required(VERSION 3.10)

project(ffigen_app_library VERSION 0.0.1 LANGUAGES C)

add_library(ffigen_app SHARED
  duktape.c                     # Modify
)

set_target_properties(ffigen_app PROPERTIES
  PUBLIC_HEADER duktape.h       # Modify
  PRIVATE_HEADER duk_config.h   # Add
  OUTPUT_NAME "ffigen_app"      # Add
)

# Add from here...
if (WIN32)
set_target_properties(ffigen_app PROPERTIES
  WINDOWS_EXPORT_ALL_SYMBOLS ON
)
endif (WIN32)
# ... to here.

target_compile_definitions(ffigen_app PUBLIC DART_SHARED_LIB)

CMake 配置会添加源文件,并且(更重要的是)还将更改 Windows 上生成的库文件的默认行为,即更改为默认导出所有 C 符号。这是一种 CMake 解决方案,可帮助将 Duktape 这种 Unix 风格的库移植到 Windows 环境。

lib/ffigen_app.dart 的内容替换为以下代码。

lib/ffigen_app.dart

import 'dart:ffi';
import 'dart:io';
import 'package:ffi/ffi.dart' as ffi;

import 'duktape_bindings_generated.dart';

const String _libName = 'ffigen_app';

final DynamicLibrary _dylib = () {
  if (Platform.isMacOS || Platform.isIOS) {
    return DynamicLibrary.open('$_libName.framework/$_libName');
  }
  if (Platform.isAndroid || Platform.isLinux) {
    return DynamicLibrary.open('lib$_libName.so');
  }
  if (Platform.isWindows) {
    return DynamicLibrary.open('$_libName.dll');
  }
  throw UnsupportedError('Unknown platform: ${Platform.operatingSystem}');
}();

final DuktapeBindings _bindings = DuktapeBindings(_dylib);

class Duktape {
  Duktape() {
    ctx =
        _bindings.duk_create_heap(nullptr, nullptr, nullptr, nullptr, nullptr);
  }

  void evalString(String jsCode) {
    var nativeUtf8 = jsCode.toNativeUtf8();
    _bindings.duk_eval_raw(
        ctx,
        nativeUtf8.cast<Char>(),
        0,
        0 |
            DUK_COMPILE_EVAL |
            DUK_COMPILE_SAFE |
            DUK_COMPILE_NOSOURCE |
            DUK_COMPILE_STRLEN |
            DUK_COMPILE_NOFILENAME);
    ffi.malloc.free(nativeUtf8);
  }

  int getInt(int index) {
    return _bindings.duk_get_int(ctx, index);
  }

  void dispose() {
    _bindings.duk_destroy_heap(ctx);
    ctx = nullptr;
  }

  late Pointer<duk_hthread> ctx;
}

此文件负责加载动态链接库文件(对于 Linux 和 Android,此文件为 .so;对于 Windows,此文件为 .dll),并提供一个封装容器,用于向底层 C 代码公开一个更符合 Dart 规范的接口。

将示例应用的 main.dart 中的内容替换为以下代码。

example/lib/main.dart

import 'package:ffigen_app/ffigen_app.dart';
import 'package:flutter/material.dart';

const String jsCode = '1+2';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatefulWidget {
  const MyApp({super.key});

  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  late Duktape duktape;
  String output = '';

  @override
  void initState() {
    super.initState();
    duktape = Duktape();
    setState(() {
      output = 'Initialized Duktape';
    });
  }

  @override
  void dispose() {
    duktape.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    const textStyle = TextStyle(fontSize: 25);
    const spacerSmall = SizedBox(height: 10);
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: const Text('Duktape Test'),
        ),
        body: Center(
          child: Container(
            padding: const EdgeInsets.all(10),
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                Text(
                  output,
                  style: textStyle,
                  textAlign: TextAlign.center,
                ),
                spacerSmall,
                ElevatedButton(
                  child: const Text('Run JavaScript'),
                  onPressed: () {
                    duktape.evalString(jsCode);
                    setState(() {
                      output = '$jsCode => ${duktape.getInt(-1)}';
                    });
                  },
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

您现在可以使用以下命令再次运行示例应用:

$ cd example
$ flutter run

应用的运行效果应如下所示:

这两个屏幕截图显示了按下 Run JavaScript 按钮之前和之后的情况。这演示了在 Dart 中执行 JavaScript 代码并在屏幕上显示结果的过程。

Android

Android 是一个基于内核的 Linux 操作系统,有点类似于桌面 Linux 发行版。CMake 构建系统可以隐藏两个平台之间的大部分差异。如需在 Android 上构建和运行,请确保 Android 模拟器正在运行(或已连接 Android 设备)。运行应用。例如:

$ cd example
$ flutter run -d emulator-5554

您现在应看到在 Android 上运行的示例应用:

6. 在 macOS 和 iOS 上使用 Duktape

接下来,让您的插件在 macOS 和 iOS 这两个密切相关的操作系统上运行。从 macOS 开始。CMake 支持 macOS 和 iOS,但您不必重复为 Linux 和 Android 完成的工作,因为 macOS 和 iOS 上的 Flutter 会使用 CocoaPods 来导入库。

清理

在上一步中,您构建了一个适用于 Android、Windows 和 Linux 的应用,并且能正常运行。但是,您现在需要清理原始模板中留下来的几个文件。请按照以下方式删除这些文件。

$ rm src/ffigen_app.c
$ rm src/ffigen_app.h
$ rm ios/Classes/ffigen_app.c
$ rm macos/Classes/ffigen_app.c

macOS

macOS 平台上的 Flutter 使用 CocoaPods 来导入 C 和 C++ 代码。这意味着,需要将此软件包集成到 CocoaPods 构建基础架构中。如需重新使用您在上一步中配置为使用 CMake 进行构建的 C 代码,您需要在 macOS 平台运行程序中添加一个转发文件。

macos/Classes/duktape.c

#include "../../src/duktape.c"

该文件利用强大的 C 预处理器来包含您在上一步中设置的原生源代码中的源代码。如需详细了解其工作原理,请参阅 macos/ffigen_app.podspec

现在运行此应用将遵循您在 Windows 和 Linux 上看到的相同模式。

$ cd example
$ flutter run -d macos

iOS

与 macOS 设置类似,在 iOS 中,您也需要添加一个转发 C 文件。

ios/Classes/duktape.c

#include "../../src/duktape.c"

添加此文件后,您的插件现在也已配置为在 iOS 上运行。照常运行应用。

$ flutter run -d iPhone

恭喜!您已经在五个平台上成功集成了原生代码。可喜可贺。在下一步中,您将构建一个功能更强大的用户界面。

7. 实现“读取-求值-输出”循环

在快速交互环境中,与编程语言进行交互会更有趣。这种环境的原始实现是 LISP 的读取-求值-输出循环 (REPL)。在此步骤中,您将使用 Duktape 实现类似的功能。

准备发布到生产环境

当前与 Duktape C 库进行交互的代码假定不会发生任何错误。而且,在测试过程中,该代码不会加载 Duktape 动态链接库。如需让此集成能够发布到生产环境,您需要对 lib/ffigen_app.dart 进行一些更改。

lib/ffigen_app.dart

import 'dart:ffi';
import 'dart:io';
import 'package:ffi/ffi.dart' as ffi;
import 'package:path/path.dart' as p;             // Add this import

import 'duktape_bindings_generated.dart';

const String _libName = 'ffigen_app';

final DynamicLibrary _dylib = () {
  if (Platform.isMacOS || Platform.isIOS) {
    // Add from here...
    if (Platform.environment.containsKey('FLUTTER_TEST')) {
      return DynamicLibrary.open('build/macos/Build/Products/Debug'
          '/$_libName/$_libName.framework/$_libName');
    }
    // ...to here.
    return DynamicLibrary.open('$_libName.framework/$_libName');
  }
  if (Platform.isAndroid || Platform.isLinux) {
    // Add from here...
    if (Platform.environment.containsKey('FLUTTER_TEST')) {
      return DynamicLibrary.open(
          'build/linux/x64/debug/bundle/lib/lib$_libName.so');
    }
    // ...to here.
    return DynamicLibrary.open('lib$_libName.so');
  }
  if (Platform.isWindows) {
    // Add from here...
    if (Platform.environment.containsKey('FLUTTER_TEST')) {
      return DynamicLibrary.open(p.canonicalize(
          p.join(r'build\windows\runner\Debug', '$_libName.dll')));
    }
    // ...to here.
    return DynamicLibrary.open('$_libName.dll');
  }
  throw UnsupportedError('Unknown platform: ${Platform.operatingSystem}');
}();

final DuktapeBindings _bindings = DuktapeBindings(_dylib);

class Duktape {
  Duktape() {
    ctx =
        _bindings.duk_create_heap(nullptr, nullptr, nullptr, nullptr, nullptr);
  }

  // Modify this function
  String evalString(String jsCode) {
    var nativeUtf8 = jsCode.toNativeUtf8();
    final evalResult = _bindings.duk_eval_raw(
        ctx,
        nativeUtf8.cast<Char>(),
        0,
        0 |
            DUK_COMPILE_EVAL |
            DUK_COMPILE_SAFE |
            DUK_COMPILE_NOSOURCE |
            DUK_COMPILE_STRLEN |
            DUK_COMPILE_NOFILENAME);
    ffi.malloc.free(nativeUtf8);

    if (evalResult != 0) {
      throw _retrieveTopOfStackAsString();
    }

    return _retrieveTopOfStackAsString();
  }

  // Add this function
  String _retrieveTopOfStackAsString() {
    Pointer<Size> outLengthPtr = ffi.calloc<Size>();
    final errorStrPtr = _bindings.duk_safe_to_lstring(ctx, -1, outLengthPtr);
    final returnVal =
        errorStrPtr.cast<ffi.Utf8>().toDartString(length: outLengthPtr.value);
    ffi.calloc.free(outLengthPtr);
    return returnVal;
  }

  void dispose() {
    _bindings.duk_destroy_heap(ctx);
    ctx = nullptr;
  }

  late Pointer<duk_hthread> ctx;
}

加载动态链接库的代码现已经过扩展,可处理在测试运行程序中使用插件的情况。这样一来,可以编写一个集成测试,以便将此 API 作为 Flutter 测试来运行。评估 JavaScript 代码字符串的代码现已经过扩展,可为正确处理错误情况,例如代码不完整或代码不正确。此额外代码显示了如何处理以字节数组形式返回字符串,并且需要将其转换为 Dart 字符串的情况。

添加软件包

在创建 REPL 时,您将显示用户与 Duktape JavaScript 引擎之间的交互。用户输入代码行,Duktape 以计算结果或异常作为响应。您将使用 freezed 来减少需要编写的样板代码量。您还将使用 google_fonts 让所显示的内容更符合主题,并使用 flutter_riverpod 进行状态管理。

在示例应用中添加所需的依赖项:

$ cd example
$ flutter pub add flutter_riverpod freezed_annotation google_fonts
$ flutter pub add -d build_runner freezed

接下来,创建一个文件来记录 REPL 交互:

example/lib/duktape_message.dart

import 'package:freezed_annotation/freezed_annotation.dart';

part 'duktape_message.freezed.dart';

@freezed
class DuktapeMessage with _$DuktapeMessage {
  factory DuktapeMessage.evaluate(String code) = DuktapeMessageCode;
  factory DuktapeMessage.response(String result) = DuktapeMessageResponse;
  factory DuktapeMessage.error(String log) = DuktapeMessageError;
}

此类使用 freezed 的 union 类型功能来支持轻松通过三种类型之一来表示 REPL 中显示的每条线的形状。此时,您的代码或许会显示某种形式的错误,因为需要生成额外的代码。为此,请运行以下命令。

$ flutter pub run build_runner build

这将生成您刚才键入的代码所依赖的 example/lib/duktape_message.freezed.dart 文件。

接下来,您需要对 macOS 配置文件进行两处修改,以允许 google_fonts 发出针对字体数据的网络请求。

example/macos/Runner/DebugProfile.entitlements

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
        <key>com.apple.security.app-sandbox</key>
        <true/>
        <key>com.apple.security.cs.allow-jit</key>
        <true/>
        <key>com.apple.security.network.server</key>
        <true/>
        <!-- Add from here... -->
        <key>com.apple.security.network.client</key>
        <true/>
        <!-- ...to here -->
</dict>
</plist>

example/macos/Runner/Release.entitlements

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
        <key>com.apple.security.app-sandbox</key>
        <true/>
        <!-- Add from here... -->
        <key>com.apple.security.network.client</key>
        <true/>
        <!-- ...to here -->
</dict>
</plist>

构建 REPL

现在,您已经更新了集成层以处理错误,并且已经为交互构建了数据表示。接下来,您需要构建示例应用的用户界面。

example/lib/main.dart

import 'package:ffigen_app/ffigen_app.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';

import 'duktape_message.dart';

void main() {
  runApp(const ProviderScope(child: DuktapeApp()));
}

final duktapeMessagesProvider =
    StateNotifierProvider<DuktapeMessageNotifier, List<DuktapeMessage>>((ref) {
  return DuktapeMessageNotifier(messages: <DuktapeMessage>[]);
});

class DuktapeMessageNotifier extends StateNotifier<List<DuktapeMessage>> {
  DuktapeMessageNotifier({required List<DuktapeMessage> messages})
      : duktape = Duktape(),
        super(messages);
  final Duktape duktape;

  void eval(String code) {
    state = [
      DuktapeMessage.evaluate(code),
      ...state,
    ];
    try {
      final response = duktape.evalString(code);
      state = [
        DuktapeMessage.response(response),
        ...state,
      ];
    } catch (e) {
      state = [
        DuktapeMessage.error('$e'),
        ...state,
      ];
    }
  }
}

class DuktapeApp extends StatelessWidget {
  const DuktapeApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      title: 'Duktape App',
      home: DuktapeRepl(),
    );
  }
}

class DuktapeRepl extends ConsumerStatefulWidget {
  const DuktapeRepl({
    super.key,
  });

  @override
  ConsumerState<DuktapeRepl> createState() => _DuktapeReplState();
}

class _DuktapeReplState extends ConsumerState<DuktapeRepl> {
  final _controller = TextEditingController();
  final _focusNode = FocusNode();
  var _isComposing = false;

  void _handleSubmitted(String text) {
    _controller.clear();
    setState(() {
      _isComposing = false;
    });
    setState(() {
      ref.read(duktapeMessagesProvider.notifier).eval(text);
    });
    _focusNode.requestFocus();
  }

  @override
  Widget build(BuildContext context) {
    final messages = ref.watch(duktapeMessagesProvider);
    return Scaffold(
      backgroundColor: Colors.white,
      appBar: AppBar(
        title: const Text('Duktape REPL'),
        elevation: Theme.of(context).platform == TargetPlatform.iOS ? 0.0 : 4.0,
      ),
      body: Column(
        children: [
          Flexible(
            child: Ink(
              color: Theme.of(context).scaffoldBackgroundColor,
              child: SafeArea(
                bottom: false,
                child: ListView.builder(
                  padding: const EdgeInsets.all(8.0),
                  reverse: true,
                  itemBuilder: (context, idx) => messages[idx].when(
                    evaluate: (str) => Padding(
                      padding: const EdgeInsets.symmetric(vertical: 2),
                      child: Text(
                        '> $str',
                        style: GoogleFonts.firaCode(
                          textStyle: Theme.of(context).textTheme.titleMedium,
                        ),
                      ),
                    ),
                    response: (str) => Padding(
                      padding: const EdgeInsets.symmetric(vertical: 2),
                      child: Text(
                        '= $str',
                        style: GoogleFonts.firaCode(
                          textStyle: Theme.of(context).textTheme.titleMedium,
                          color: Colors.blue[800],
                        ),
                      ),
                    ),
                    error: (str) => Padding(
                      padding: const EdgeInsets.symmetric(vertical: 2),
                      child: Text(
                        str,
                        style: GoogleFonts.firaCode(
                          textStyle: Theme.of(context).textTheme.titleSmall,
                          color: Colors.red[800],
                          fontWeight: FontWeight.bold,
                        ),
                      ),
                    ),
                  ),
                  itemCount: messages.length,
                ),
              ),
            ),
          ),
          const Divider(height: 1.0),
          SafeArea(
            top: false,
            child: Container(
              decoration: BoxDecoration(color: Theme.of(context).cardColor),
              child: _buildTextComposer(),
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildTextComposer() {
    return IconTheme(
      data: IconThemeData(color: Theme.of(context).colorScheme.secondary),
      child: Container(
        margin: const EdgeInsets.symmetric(horizontal: 8.0),
        child: Row(
          children: [
            Text('>', style: Theme.of(context).textTheme.titleMedium),
            const SizedBox(width: 4),
            Flexible(
              child: TextField(
                controller: _controller,
                decoration: const InputDecoration(
                  border: InputBorder.none,
                ),
                onChanged: (text) {
                  setState(() {
                    _isComposing = text.isNotEmpty;
                  });
                },
                onSubmitted: _isComposing ? _handleSubmitted : null,
                focusNode: _focusNode,
              ),
            ),
            Container(
              margin: const EdgeInsets.symmetric(horizontal: 4.0),
              child: IconButton(
                icon: const Icon(Icons.send),
                onPressed: _isComposing
                    ? () => _handleSubmitted(_controller.text)
                    : null,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

这段代码包含许多操作,但其具体解释不在此 Codelab 的讨论范围以内。我建议您运行代码,并在查看相应文档后对代码进行适当修改。

$ cd example
$ flutter run

8. 恭喜

恭喜!您已经成功为 Windows、macOS、Linux、Android 和 iOS 创建了一个基于 Flutter FFI 的插件!

在创建插件后,您可能希望在线共享该插件,以便他人使用。有关如何将插件发布到 pub.dev 的完整文档,请参阅开发插件软件包