在 Flutter 外掛程式中使用 FFI

1. 簡介

Dart 的 FFI (外部函式介面) 可讓 Flutter 應用程式使用現有的原生資料庫,以便公開 C API。Dart 支援 Android、iOS、Windows、macOS 和 Linux 上的 FFI。在網頁上,Dart 支援 JavaScript 互通性,但本程式碼研究室未涵蓋這個主題。

建構項目

在本程式碼研究室中,您將建構使用 C 程式庫的行動裝置和電腦外掛程式。您將使用這個 API 編寫可運用外掛程式的範例應用程式。您的外掛程式和應用程式將:

  • 將 C 程式庫原始碼匯入新的 Flutter 外掛程式
  • 自訂外掛程式,讓外掛程式可在 Windows、macOS、Linux、Android 和 iOS 上建構
  • 建構使用 JavaScript REPL (Read Reveal Print Loop) 外掛程式的應用程式

以 macOS 應用程式形式執行 Duktape REPL

課程內容

在本程式碼研究室中,您將學習在電腦和行動平台上建構以 FFI 為基礎的 Flutter 外掛程式所需的實務知識,包括:

  • 產生以 Dart FFI 為基礎的 Flutter 外掛程式範本
  • 使用 ffigen 套件為 C 程式庫產生繫結程式碼
  • 使用 CMake 建構 Android、Windows 和 Linux 適用的 Flutter FFI 外掛程式
  • 使用 CocoaPods 建構適用於 iOS 和 macOS 的 Flutter FFI 外掛程式

軟硬體需求

  • 用於 Android 開發的 Android Studio 4.1 以上版本
  • 適用於 iOS 和 macOS 開發作業的 Xcode 13 以上版本
  • Visual Studio 2022 或 Visual Studio Build Tools 2022,搭配「Desktop development with C++」工作負載,用於 Windows 桌面開發
  • Flutter SDK
  • 您要開發的平台所需的任何建構工具 (例如 CMake、CocoaPods 等)。
  • LLVM 適用於您要開發的平台ffigen 會使用 LLVM 編譯器工具套件,剖析 C 標頭檔案,以建構 Dart 中公開的 FFI 繫結。
  • 程式碼編輯器,例如 Visual Studio Code

2. 開始使用

ffigen 工具是最近新增至 Flutter 的功能。您可以執行下列指令,確認 Flutter 安裝作業是否正在執行目前的穩定版。

$ flutter doctor
Doctor summary (to see all details, run flutter doctor -v):
[✓] Flutter (Channel stable, 3.32.4, on macOS 15.5 24F74 darwin-arm64, locale en-AU)
[✓] Android toolchain - develop for Android devices (Android SDK version 36.0.0)
[✓] Xcode - develop for iOS and macOS (Xcode 16.4)
[✓] Chrome - develop for the web
[✓] Android Studio (version 2024.2)
[✓] IntelliJ IDEA Community Edition (version 2024.3.1.1)
[✓] VS Code (version 1.101.0)
[✓] Connected device (3 available)
[✓] Network resources

• No issues found!

確認 flutter doctor 輸出內容表示您使用的是穩定版,且沒有較新的 Flutter 穩定版可供使用。如果您未使用穩定版,或是有較新的版本可供使用,請執行下列兩個指令,讓您的 Flutter 工具保持最新狀態。

flutter channel stable
flutter upgrade

您可以使用下列任一裝置執行本程式碼研究室的程式碼:

  • 您的開發電腦 (用於插件和範例應用程式的桌面版本)
  • 已連接至電腦並設為開發人員模式的實體 Android 或 iOS 裝置
  • iOS 模擬器 (需要安裝 Xcode 工具)
  • Android Emulator (需要在 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 版本。這個程式碼研究室已知可在 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=/

您應該會看到類似以下的執行中應用程式視窗:

範本產生的 FFI 應用程式以 Windows 應用程式執行

Linux

確認您使用的是支援的 Linux 版本。本程式碼研究室使用 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=/

您應該會看到類似以下的執行中應用程式視窗:

範本產生的 FFI 應用程式以 Linux 應用程式執行

Android

針對 Android,您可以使用 Windows、macOS 或 Linux 進行編譯。

您需要變更 example/android/app/build.gradle.kts,才能使用適當的 NDK 版本。

example/android/app/build.gradle.kts)

android {
    // Modify the next line from `flutter.ndkVersion` to the following:
    ndkVersion = "27.0.12077973"
    // ...
}

請確認您已將 Android 裝置連上開發電腦,或正在執行 Android Emulator (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

在您有搭載 Android 裝置 (實體裝置或模擬器) 後,請執行下列指令:

cd ffigen_app/example
flutter run

Flutter 會詢問您要執行的裝置。從清單中選取適當的裝置。

範本產生的 FFI 應用程式在 Android 模擬器中執行

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

您應該會看到類似以下的執行中應用程式視窗:

範本產生的 FFI 應用程式以 Linux 應用程式執行

如果是 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

在您有 iOS 裝置 (實體裝置或模擬器) 可供執行後,請執行下列指令:

cd ffigen_app/example
flutter run

Flutter 會詢問您要執行的裝置。從清單中選取適當的裝置。

範本產生的 FFI 應用程式在 iOS 模擬器中執行

iOS 模擬器優先於 macOS 目標,因此您可以略過使用 -d 參數指定裝置。

恭喜,您已成功在五個不同的作業系統上建構及執行應用程式。接下來,請使用 FFI 建構原生外掛程式,並透過 Dart 與其進行介面連結。

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

您在本程式碼研究室中會使用的 C 程式庫是 Duktape。Duktape 是可嵌入的 JavaScript 引擎,主要著重於可攜性和小巧的占用空間。在這個步驟中,您將設定外掛程式來編譯 Duktape 程式庫,將其連結至外掛程式,然後使用 Dart 的 FFI 存取該程式庫。

這個步驟會將整合設定設為在 Windows、Linux 和 Android 上運作。iOS 和 macOS 整合作業需要額外的設定 (除了本步驟中詳細說明的部分),才能將編譯的程式庫納入最終的 Flutter 可執行檔。下一個步驟會說明其他必要的設定。

擷取 Duktape

首先,請從 duktape.org 網站下載 duktape 原始碼的副本。

針對 Windows,您可以使用 PowerShell 搭配 Invoke-WebRequest

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 安裝作業。您可以按照下列步驟測試是否已正確安裝。

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 `dart run ffigen --config ffigen.yaml`.
name: DuktapeBindings
description: |
  Bindings for `src/duktape.h`.

  Regenerate bindings with `dart 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
ignore-source-errors: true

這項設定包含要傳遞至 LLVM 的 C 標頭檔案、要產生的輸出檔案、要放在檔案頂端的說明,以及用於新增 Lint 警告的前言部分。

檔案結尾有一個需要進一步說明的設定項目。自 ffigen 11.0.0 版起,如果 clang 在剖析標頭檔案時產生警告或錯誤,則繫結產生器預設不會產生繫結。

由於 Duktape 指標缺少可空性類型指定符,因此 Duktape 標頭檔案會在 macOS 上觸發 clang 並產生警告。如要全面支援 macOS 和 iOS,Duktape 需要在 Duktape 程式碼庫中新增這些類型指標。在此期間,我們決定將 ignore-source-errors 標記設為 true,藉此忽略這些警告。

在正式版應用程式中,您應先移除所有編譯器警告,再發布應用程式。不過,針對 Duktape 執行這項操作不在本程式碼研究室的範圍內。

如要進一步瞭解其他鍵和值,請參閱 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 來產生新的繫結:

$ dart run ffigen --config ffigen.yaml
Building package executable... (1.5s)
Built ffigen:ffigen.
[INFO]   : Running in Directory: '/Users/brett/Documents/GitHub/codelabs/ffigen_codelab/step_05'
[INFO]   : Input Headers: [file:///Users/brett/Documents/GitHub/codelabs/ffigen_codelab/step_05/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 '__builtin_va_list' starts with '_' and therefore will be private.
[INFO]   : Finished, Bindings generated in /Users/brett/Documents/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)

if (ANDROID)
  # Support Android 15 16k page size
  target_link_options(ffigen_app PRIVATE "-Wl,-z,max-page-size=16384")
endif()

CMake 設定會新增來源檔案,更重要的是,會修改 Windows 上產生的程式庫檔案預設行為,預設匯出所有 C 符號。這是 CMake 的解決方法,可協助將 Duktape 等 Unix 風格的程式庫移植至 Windows 世界。

lib/ffigen_app.dart 的內容替換為下列內容。

lib/ffigen_app.dart

import 'dart:ffi';
import 'dart:io' show Platform;
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 慣用介面。

由於這個檔案會直接匯入 ffi 套件,因此您必須將套件從 dev_dependencies 移至 dependencies。如要快速執行這項操作,請執行下列指令:

dart pub add ffi

將範例的 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

您應該會看到執行中的應用程式,如下所示:

顯示在 Windows 應用程式中初始化的 Duktape

在 Windows 應用程式中顯示 Duktape JavaScript 輸出內容

這兩張螢幕截圖分別顯示按下「Run JavaScript」按鈕前後的畫面。這會示範如何從 Dart 執行 JavaScript 程式碼,並在畫面上顯示結果。

Android

Android 是基於 Linux 核心的作業系統,與桌面 Linux 發行版本有些類似。CMake 建構系統可隱藏這兩個平台之間的大部分差異。如要在 Android 上建構及執行應用程式,請確認 Android 模擬器正在執行 (或已連結 Android 裝置)。執行應用程式。例如:

cd example
flutter run -d emulator-5554

您現在應該會看到在 Android 上執行的範例應用程式:

顯示在 Android 模擬器中初始化的 Duktape

在 Android 模擬器中顯示 Duktape JavaScript 輸出內容

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

顯示在 macOS 應用程式中初始化的 Duktape

在 macOS 應用程式中顯示 Duktape JavaScript 輸出內容

iOS

與 macOS 設定類似,iOS 也需要新增單一轉送 C 檔案。

ios/Classes/duktape.c

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

有了這個單一檔案,您的外掛程式現在也已設定為在 iOS 上執行。照常執行。

flutter run -d iPhone

顯示在 iOS 模擬器中初始化的 Duktape

在 iOS 模擬器中顯示 Duktape JavaScript 輸出內容

恭喜!您已成功在五個平台上整合原生程式碼。這正是值得慶祝的好日子!甚至可以是功能更強大的使用者介面,您將在下一個步驟中建立。

7. 實作 Read Eval Print Loop

在快速互動環境中,與程式設計語言互動會更有趣。這種環境的原始實作方式是 LISP 的 Read Eval Print Loop (REPL)。您將在這個步驟中實作類似 Duktape 的內容。

讓應用程式可供正式發布

目前與 Duktape C 程式庫互動的程式碼會假設不會發生任何錯誤。另外,在測試期間,它不會載入 Duktape 動態連結程式庫。如要讓這項整合作業準備好正式發布,您需要對 lib/ffigen_app.dart 進行一些變更。

lib/ffigen_app.dart

import 'dart:ffi';
import 'dart:io' show Platform;
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 switch (Abi.current()) {
        Abi.windowsArm64 => DynamicLibrary.open(
          p.canonicalize(
            p.join(r'build\windows\arm64\runner\Debug', '$_libName.dll'),
          ),
        ),
        Abi.windowsX64 => DynamicLibrary.open(
          p.canonicalize(
            p.join(r'build\windows\x64\runner\Debug', '$_libName.dll'),
          ),
        ),
        _ => throw 'Unsupported platform',
      };
    }
    // 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;
}

這需要新增 path 套件。

flutter pub add path

載入動態連結程式庫的程式碼已擴充,可處理在測試執行器中使用外掛程式的情況。這可讓您編寫整合測試,以便以 Flutter 測試的方式測試此 API。用於評估 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 的聯集型別功能,讓 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) {
                    return switch (messages[idx]) {
                      DuktapeMessageCode code => Padding(
                        padding: const EdgeInsets.symmetric(vertical: 2),
                        child: Text(
                          '> ${code.code}',
                          style: GoogleFonts.firaCode(
                            textStyle: Theme.of(context).textTheme.titleMedium,
                          ),
                        ),
                      ),
                      DuktapeMessageResponse response => Padding(
                        padding: const EdgeInsets.symmetric(vertical: 2),
                        child: Text(
                          '= ${response.result}',
                          style: GoogleFonts.firaCode(
                            textStyle: Theme.of(context).textTheme.titleMedium,
                            color: Colors.blue[800],
                          ),
                        ),
                      ),
                      DuktapeMessageError error => Padding(
                        padding: const EdgeInsets.symmetric(vertical: 2),
                        child: Text(
                          error.log,
                          style: GoogleFonts.firaCode(
                            textStyle: Theme.of(context).textTheme.titleSmall,
                            color: Colors.red[800],
                            fontWeight: FontWeight.bold,
                          ),
                        ),
                      ),
                      DuktapeMessage message => Padding(
                        padding: const EdgeInsets.symmetric(vertical: 2),
                        child: Text(
                          'Unhandled message $message',
                          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,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

這個程式碼中包含許多內容,但本程式碼研究室無法一一說明。建議您先查看相關文件,然後再執行程式碼並修改程式碼。

cd example
flutter run

在 Linux 應用程式中執行 Duktape REPL

在 Windows 應用程式中執行 Duktape REPL

在 iOS 模擬器中執行 Duktape REPL

在 Android 模擬器中執行的 Duktape REPL

8. 恭喜

恭喜!您已成功建立適用於 Windows、macOS、Linux、Android 和 iOS 的 Flutter FFI 外掛程式!

建立外掛程式後,您可能會想在線上分享,讓其他人也能使用。如要進一步瞭解如何將外掛程式發布至 pub.dev,請參閱「開發外掛程式套件」一文。