Flutter 플러그인에서 FFI 사용

1. 소개

Dart의 FFI(Foreign Function Interface)를 사용하면 Flutter 앱이 C API를 노출하는 기존 네이티브 라이브러리를 활용할 수 있습니다. Dart는 Android, iOS, Windows, macOS, Linux에서 FFI를 지원합니다. 웹의 경우 Dart는 JavaScript 상호 운용성을 지원하지만 이번 Codelab에서는 이 주제를 다루지 않습니다.

빌드할 항목

이 Codelab에서는 C 라이브러리를 사용하는 모바일 및 데스크톱 플러그인을 빌드합니다. 이 API를 사용하여 플러그인을 활용하는 간단한 예시 앱을 작성합니다. 플러그인과 앱에서는 다음 작업을 실행합니다.

  • C 라이브러리 소스 코드를 새 Flutter 플러그인으로 가져옵니다.
  • Windows, macOS, Linux, Android, iOS에서 빌드할 수 있도록 플러그인을 맞춤설정합니다.
  • JavaScript REPL(Read Eval Print Loop)용 플러그인을 사용하는 애플리케이션을 빌드합니다.

76b496eb58ef120a.png

학습할 내용

이 Codelab에서는 다음을 비롯하여 데스크톱 및 모바일 플랫폼에서 모두 FFI 기반 Flutter 플러그인을 빌드하는 데 필요한 실용적인 지식을 알아봅니다.

  • Dart FFI 기반 Flutter 플러그인 템플릿 생성
  • ffigen 패키지를 사용하여 C 라이브러리의 바인딩 코드 생성
  • CMake를 사용하여 Android, Windows, Linux용 Flutter FFI 플러그인 빌드
  • CocoaPods를 사용하여 iOS, macOS용 Flutter FFI 플러그인 빌드

필요한 항목

  • Android 스튜디오 4.1 이상(Android 개발용)
  • Xcode 13 이상(iOS, macOS 개발용)
  • Visual Studio 2022 또는 Visual Studio 빌드 도구 2022(Desktop development with C++(C++를 사용한 데스크톱 개발) 워크로드 포함)(Windows 개발용)
  • Flutter SDK
  • 개발할 때 사용할 플랫폼에 필요한 빌드 도구(예: CMake, CocoaPods 등)
  • 개발할 때 사용할 플랫폼용 LLVM. LLVM 컴파일러 도구 모음은 ffigen이 Dart에서 노출된 FFI 바인딩을 빌드하기 위해 C 헤더 파일을 파싱하는 데 사용합니다.
  • 코드 편집기(예: 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 출력을 통해 안정화 버전 채널을 실행하고 있으며 제공되는 더 최신의 Flutter 안정화 버전이 없음을 확인합니다. 안정화 버전을 사용하고 있지 않거나 더 최신 버전이 제공되는 경우 다음 두 명령어를 실행하여 Flutter 도구를 업그레이드합니다.

$ flutter channel stable
$ flutter upgrade

다음 기기 중 하나를 사용하여 이 Codelab의 코드를 실행할 수 있습니다.

  • 개발용 컴퓨터(플러그인 및 예시 앱의 데스크톱 빌드용)
  • 컴퓨터에 연결되어 있으며 개발자 모드로 설정된 실제 Android 또는 iOS 기기
  • iOS 시뮬레이터(Xcode 도구 설치 필요)
  • Android Emulator(Android 스튜디오 설정 필요)

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, 플랫폼별 디렉터리인 android, ios, linux, macos, windows와 가장 중요한 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 명령어가 시뮬레이터를 사용 가능한 기기 중 하나로 나열합니다.

$ 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 매개변수로 기기 지정을 건너뛸 수 있습니다.

축하합니다. 다섯 가지 운영체제에서 애플리케이션을 빌드하고 실행했습니다. 이제 네이티브 플러그인을 빌드하고 FFI를 사용하여 Dart에서 이 플러그인과 상호작용합니다.

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을 사용하려면 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

바이너리 검색 경로에 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 `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 헤더 파일과 생성할 출력 파일, 파일 상단에 배치할 설명, 린트 경고를 추가하는 데 사용되는 서문 섹션이 포함됩니다. 키 및 값에 관한 자세한 내용은 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/

기술적으로는 ffigenduktape.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 구성은 소스 파일을 추가하고 무엇보다 기본적으로 C 기호를 모두 내보내도록 Windows에서 생성된 라이브러리 파일의 기본 동작을 수정합니다. 이는 Unix 스타일 라이브러리(Duktape이 해당함)를 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

다음과 같이 앱이 실행됩니다.

이 두 스크린샷은 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에서 플러그인이 작동하도록 해 보겠습니다. 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. 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에 표시된 각 줄의 모양을 세 가지 유형 중 하나로 쉽게 표현할 수 있습니다. 이 시점에서 이 코드에 관한 일종의 오류가 코드에 표시될 수 있습니다. 생성해야 하는 추가 코드가 있기 때문입니다. 다음과 같이 해당 작업을 실행하세요.

$ 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에 게시하는 방법에 관한 전체 문서를 확인할 수 있습니다.