Flutter プラグインで FFI を使用する

1. はじめに

Dart の FFI(他言語関数インターフェース)を使用すると、Flutter アプリから、C API を公開している既存のネイティブ ライブラリを使用できるようになります。Dart では、Android、iOS、Windows、macOS、Linux で FFI をサポートしています。ウェブに関しては、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 など)
  • 開発で使用するプラットフォーム用の LLVM。LLVM コンパイラ ツール スイートは、ffigen が 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.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 の出力で、stable チャンネルを購読していることと、より新しい入手可能な安定版の Flutter リリースがないことを確認してください。stable でない場合、またはより新しい入手可能なリリースがある場合、次の 2 つのコマンドを実行して、Flutter ツールを最新版にしてください。

$ flutter channel stable
$ flutter upgrade

この Codelab のコードは、次のいずれかのデバイスを使用して実行できます。

  • 開発用パソコン(プラグインとサンプルアプリのパソコン用ビルド)
  • パソコンに接続され、デベロッパー モードに設定された物理デバイス(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 がサポート対象のバージョンであることを確認します。この 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 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

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 コマンドを実行すると、利用可能なデバイスの 1 つとしてシミュレータが表示されるようになります。

$ 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 パラメータを使用したデバイスの指定はスキップできます。

おめでとうございます。5 つのオペレーティング システムでアプリケーションのビルドと実行ができました。次は、ネイティブ プラグインを作成し、FFI を使用して Dart から使用できるようにします。

5. Windows、Linux、Android で Duktape を使用する

この Codelab で使用する C ライブラリは、Duktape です。Duktape は、移植性とコンパクトなフットプリントを重視した、埋め込み可能な JavaScript エンジンです。このステップでは、Duktape ライブラリをコンパイルして、プラグインにリンクし、Dart の FFI を使用してアクセスできるように、プラグインを設定します。

このステップでは、Windows、Linux、Android で機能するように統合を設定します。iOS と macOS の統合では、コンパイルされたライブラリを最終的な Flutter 実行ファイルに含めるために、追加の設定(前のステップで説明したもの以外)が必要となります。必要となる追加の設定は、次のステップで説明します。

Duktape を取得する

まず、duktape のソースコードを取得するために、ウェブサイト duktape.org からダウンロードします。

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 を 2 回実行する必要があります。1 回目は xz 圧縮の伸張、2 回目は 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 を使って次の 1 つのステップで内容を抽出できます。

$ 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 を使用するには、ffigen で C ヘッダーの解析に使用する LLVM のインストールが必要です。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

Windows マシンへの LLVM のインストールを完了するために、システムパスの設定でバイナリ サーチパスに C:\Program Files\LLVM\bin を追加します。次のようにして正しくインストールされているかどうかをテストできます。

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.yamlffigen パッケージは、バージョンが古くなっている可能性があります。次のコマンドを実行して、プラグイン プロジェクトの 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 をコピーするだけでよいのですが、3 つのすべてを必要とするライブラリをビルドするための 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 はビルドシステム生成システムです。このプラグインでは、生成される Flutter バイナリに Duktape が含まれるように、CMake を使用して Android、Windows、Linux 用のビルドシステムを生成します。テンプレートで生成された 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 シンボルを公開するように変更します。これは、Duktape のような Unix スタイルのライブラリを Windows 環境に移植できるようにする、CMake のワーク アラウンドです。

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

次のように実行されているアプリが表示されます。

2 つのスクリーンショットは、[Run JavaScript] ボタンを押す前と押した後を示しています。これは、Dart から JavaScript コードを実行し、結果を画面に表示していることを示しています。

Android

Android は Linux カーネルベースの OS であり、デスクトップ Linux ディストリビューションにある程度類似しています。CMake ビルドシステムにより、両プラットフォーム間の違いのほとんどは隠蔽できます。ビルドして Android で実行するには、Android Emulator が実行されている(または Android デバイスが接続されている)ことを確認します。アプリを実行します。以下に例を示します。

$ cd example
$ flutter run -d emulator-5554

Android で実行されているサンプルアプリが表示されます。

6. macOS と iOS で Duktape を使用する

次は、macOS と iOS という非常に関連性の強い 2 つのオペレーティング システムでプラグインを動作させます。macOS から始めます。CMake では macOS と iOS がサポートされていますが、macOS と iOS の Flutter ではライブラリの実装に CocoaPods を使用するため、Linux と Android 向けに行った作業の結果は再利用しません。

クリーンアップ

前のステップでは、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 では、C と C++ のコードをインポートするために CocoaPods を使用します。つまり、このパッケージを CocoaPods のビルド インフラストラクチャに統合する必要があるということです。前のステップで CMake でビルドできるように設定した C コードを再利用できるように、macOS プラットフォーム ランナーに転送ファイルを 1 つ追加する必要があります。

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 ファイルを 1 つ追加する必要があります。

ios/Classes/duktape.c

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

このファイル 1 つで、プラグインは iOS で動作するようにも設定されました。いつものように実行します。

$ flutter run -d iPhone

おめでとうございます。5 つのプラットフォームでネイティブ コードを統合できました。これはお祝いに値することです。次のステップでは、もっと実用的なユーザー インターフェースになりそうです。

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';
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 のユニオン型機能を利用して、REPL に表示される各行の形状を 3 つの型のいずれかとして簡単に表現できるようにしています。この時点では、コードになんらかの形でエラーが表示されていると思いますが、それは生成する必要のあるコードがまだあるためです。それを次のようにして行います。

$ flutter pub run build_runner build

これにより example/lib/duktape_message.freezed.dart ファイルが生成されます。これは先程入力したコードに必要なものです。

次に、macOS 設定に 2 つの変更を行って、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 に公開する方法に関するドキュメントの全文については、プラグイン パッケージの開発をご覧ください。