이전 포스트 들에서는 모바일 앱에서 Rust library 를 사용하는 방법을 직접 구현해 봤습니다. 이번에는 cbindgen, ffigen 툴을 활용해서 좀 더 쉽게 Rust library를 연동하고 Desktop OS(Windows, macOS)용 Flutter 앱에서 사용하는 예제를 만들어 보겠습니다. flutter library 생성시 plugin_ffi template을 이용하면 sum을 구하는 예제가 있는데, 여기서는 문자열을 주고 받는 함수로 변경해 보겠습니다.
- flutter에서 Rust library 사용하기 (1a): Android + JNA (본 포스트)
- flutter에서 Rust library 사용하기 (1b): Android + JNI
- flutter에서 Rust library 사용하기 (2): iOS
- flutter desktop 에서 Rust library 사용하기 (3): Windows, macOS
대상 독자:
- Flutter 에 대한 기본지식이 있는 사람
- Rust 에 대한 기본지식이 있는 사람
- 둘 간의 연동을 원하는 사람
준비물:
- Windows
- Windows 10 이상
- MSVC 2019 이상
- macOS
- macOS 13.5.x (Ventura) 이상
- Xcode 15.x 이상
- Rust v1.x 이상 개발환경
- Flutter v3.x 이상 개발환경
단계:
- 개발환경 셋업
- Rust library 개발
- C header file 생성
- Flutter library 및 예제 프로젝트 생성
- Flutter library 생성
- Example code 수정
- 실행 및 테스트
1. 개발환경 셋업
Rust용 cbindgen을 설치합니다.
cargo install cbindgen
2. Rust library 개발
아래와 같이 library 프로젝트를 생성합니다.
cargo new --lib greet_rs
Cargo.toml 에 아래 내용을 추가합니다. (greet_rs/Cargo.toml)
[lib]
crate-type = ["cdylib"]
cbindgen.toml 을 아래와 같이 생성합니다. (greet_rs/cbindgen.toml) 자세한 옵션은 User Guide를 참고하세요.
# default: "C++"
language = "C"
# default: doesn't emit an include guard
include_guard = "__GREET_RS_GEN_H__"
library 소스를 작성합니다. (greet_rs/src/main.rs) 일부러 길게 동작하는 function 처럼 되게 3초 sleep을 주어 구현해 봅니다.
use std::borrow::Cow;
use std::ffi::{c_int, CStr, CString};
use std::os::raw::c_char;
use std::time::Duration;
///
/// # Safety
///
/// The return value should be deallocated after use.
///
#[no_mangle]
pub unsafe extern "C" fn greet(c_someone: *const c_char) -> *mut c_char {
let someone = unsafe { CStr::from_ptr(c_someone) }.to_str().unwrap();
// delay for long-run simulation
std::thread::sleep(Duration::from_secs(3));
let output = format!("Hello, {}", someone);
let c_output = Cow::from(output);
CString::new(c_output.as_ref()).unwrap().into_raw()
}
3. C header file 생성
cbindgen을 실행해서 c header file을 생성합니다.
pushd greet_rs
cbindgen -o greet_rs_gen.h
popd
생성된 c header file을 보면 아래와 같이 나옵니다.
#ifndef __GREET_RS_GEN_H__
#define __GREET_RS_GEN_H__
/* Generated with cbindgen:0.26.0 */
#include <stdarg.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>
char *greet(const char *input);
#endif /* __GREET_RS_GEN_H__ */
4. Flutter library 및 예제 프로젝트 생성
아래와 같이 template을 plugin_ffi 으로부터 flutter library 프로젝트를 생성합니다.
flutter create --template=plugin_ffi greet_flutter
생성된 프로젝트는 아래와 같은 folder 구조가 생깁니다.
- example/: flutter library를 사용한 예제 flutter 앱의 소스가 들어 있습니다.
- lib/: dart로 작성된 library code
- src/: c로 작성된 예제 library 소스. 이 포스트에서는 이 소스를 사용하지 않습니다. 다만 빌드스크립트가 이를 활용하기 때문에 그대로 둡니다.
5. Flutter library 수정
flutter 에서 rust 의 function 을 호출하는 부분을 c header 의 정의에 맞게 변경해주어야 합니다. template 에서 제공된 소스는 C로 작성한 int sum(int a, int b) 함수이고, Flutter 에서 사용할 수 있도록 wrapping 하는 구조인데, 이 C 소스를 사용하지 않고 위에서 구현한 Rust library로 대체하려고 합니다.
greet_flutter/lib 폴더에 보면 두개의 library dart 파일이 있는데, greet_flutter_bindings_generated.dart 는 ffigen을 이용해서 C header 파일을 dart 코드로 자동으로 생성한 파일입니다. greet_flutter.dart 라는 파일은 Flutter 앱에서 이 bindings 을 쓰기 쉽도록 sum() 함수를 sync와 async call로 wrapping 해줍니다.
따라서, sum() 함수와 연관된 부분을 수정해야 하는데 다음과 같은 절차로 진행합니다.
ffigen.yaml 을 수정합니다. (greet_flutter/ffigen.yaml)
# Run with `flutter pub run ffigen --config ffigen.yaml`.
name: GreetFlutterBindings
description: |
Bindings for `../greet_rs/greet_rs_gen.h`.
Regenerate bindings with `flutter pub run ffigen --config ffigen.yaml`.
output: 'lib/greet_flutter_bindings_generated.dart'
headers:
entry-points:
- '../greet_rs/greet_rs_gen.h'
include-directives:
- '../greet_rs/greet_rs_gen.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
ffigen을 실행해서 greet_flutter_bindings_generated.dart 를 업데이트 합니다. 이 부분은 자동 생성된 코드여서 고칠 게 없습니다.
pushd greet_flutter
flutter pub run ffigen --config ffigen.yaml
popd
greet_flutter.dart 안에 dart 로 wrapping 된 부분은 직접 수정해줘야 합니다. (greet_flutter/lib/greet_flutter.dart)
주로 바꾼 내용은 외부에 노출되는 함수정의, _Request, _Response class를 받은 String 과 처리된 결과를 return 하도록 수정했고, C function에 값을 넘겨줄 때 c 용 자료 구조로 변환하는 코드를 추가했습니다. 반대방향도 마찬가지구요.
// You have generated a new plugin project without specifying the `--platforms`
// flag. An FFI plugin project that supports no platforms is generated.
// To add platforms, run `flutter create -t plugin_ffi --platforms <platforms> .`
// in this directory. You can also find a detailed instruction on how to
// add platforms in the `pubspec.yaml` at
// https://flutter.dev/docs/development/packages-and-plugins/developing-packages#plugin-platforms.
import 'dart:async';
import 'dart:ffi';
import 'dart:io';
import 'dart:isolate';
import 'package:ffi/ffi.dart';
import 'package:flutter/foundation.dart';
import 'greet_flutter_bindings_generated.dart';
/// A very short-lived native function.
///
/// For very short-lived functions, it is fine to call them on the main isolate.
/// They will block the Dart execution while running the native function, so
/// only do this for native functions which are guaranteed to be short-lived.
String greet(String someone) {
var cSomeone = someone.toNativeUtf8() as Pointer<Char>;
var cResult = _bindings.on_request(cSomeone);
var result = cResult.cast<Utf8>().toDartString();
calloc.free(cSomeone);
calloc.free(cResult);
return result;
}
/// A longer lived native function, which occupies the thread calling it.
///
/// Do not call these kind of native functions in the main isolate. They will
/// block Dart execution. This will cause dropped frames in Flutter applications.
/// Instead, call these native functions on a separate isolate.
///
/// Modify this to suit your own use case. Example use cases:
///
/// 1. Reuse a single isolate for various different kinds of requests.
/// 2. Use multiple helper isolates for parallel execution.
Future<String> greetAsync(String someone) async {
final SendPort helperIsolateSendPort = await _helperIsolateSendPort;
final int requestId = _nextRequestId++;
final _Request request = _Request(requestId, someone);
final Completer<String> completer = Completer<String>();
_requests[requestId] = completer;
helperIsolateSendPort.send(request);
return completer.future;
}
const String _libName = 'greet_rs';
/// The dynamic library in which the symbols for [GreetFlutterBindings] can be found.
final DynamicLibrary _dylib = () {
if (Platform.isMacOS || Platform.isIOS) {
return DynamicLibrary.open('lib$_libName.dylib');
}
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}');
}();
/// The bindings to the native functions in [_dylib].
final GreetFlutterBindings _bindings = GreetFlutterBindings(_dylib);
/// A request to compute `sum`.
///
/// Typically sent from one isolate to another.
class _Request {
final int id;
final String someone;
const _Request(this.id, this.someone);
}
/// A response with the result of `sum`.
///
/// Typically sent from one isolate to another.
class _Response {
final int id;
final String result;
const _Response(this.id, this.result);
}
/// Counter to identify [_SumRequest]s and [_SumResponse]s.
int _nextRequestId = 0;
/// Mapping from [_SumRequest] `id`s to the completers corresponding to the correct future of the pending request.
final Map<int, Completer<String>> _requests = <int, Completer<String>>{};
/// The SendPort belonging to the helper isolate.
Future<SendPort> _helperIsolateSendPort = () async {
// The helper isolate is going to send us back a SendPort, which we want to
// wait for.
final Completer<SendPort> completer = Completer<SendPort>();
// Receive port on the main isolate to receive messages from the helper.
// We receive two types of messages:
// 1. A port to send messages on.
// 2. Responses to requests we sent.
final ReceivePort receivePort = ReceivePort()
..listen((dynamic data) {
if (data is SendPort) {
// The helper isolate sent us the port on which we can sent it requests.
completer.complete(data);
return;
}
if (data is _Response) {
// The helper isolate sent us a response to a request we sent.
final Completer<String> completer = _requests[data.id]!;
_requests.remove(data.id);
completer.complete(data.result);
return;
}
throw UnsupportedError('Unsupported message type: ${data.runtimeType}');
});
// Start the helper isolate.
await Isolate.spawn((SendPort sendPort) async {
final ReceivePort helperReceivePort = ReceivePort()
..listen((dynamic data) {
// On the helper isolate listen to requests and respond to them.
if (data is _Request) {
final String result = send(data.someone);
final _Response response = _Response(data.id, result);
sendPort.send(response);
return;
}
throw UnsupportedError('Unsupported message type: ${data.runtimeType}');
});
// Send the port to the main isolate on which we can receive requests.
sendPort.send(helperReceivePort.sendPort);
}, receivePort.sendPort);
// Wait until the helper isolate has sent us back the SendPort on which we
// can start sending requests.
return completer.future;
}();
6. Example code 수정
Desktop을 지원하기 위해서 아래와 같이 해당 platform 을 추가합니다.
Windows 라면,
flutter create --platform=windows greet_flutter
macOS라면,
flutter create --platform=macos greet_flutter
Example flutter application 코드를 아래와 같이 수정합니다. (greet_flutter/example/lib/main.dart) sum, sumAsync 를 호출하는 부분을 greet, greetAsync 로 변경하고, 이 함수를 실행할 버튼, 결과를 출력하는 텍스트박스를 추가했습니다. greetAsync 버튼을 여러번 눌러도 중복실행이 되지 않도록 flag 값을 두었습니다.
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:greet_flutter/on_request_struct.dart';
import 'package:greet_flutter/greet_flutter.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
String? greetresult =
Future<String?> greetAsyncresult =
Future.delayed(const Duration(seconds: 0), () => "welcome");
@override
void initState() {
super.initState();
}
@override
Widget build(BuildContext context) {
const textStyle = TextStyle(fontSize: 25);
const spacerSmall = SizedBox(height: 10);
return MaterialApp(
home: Scaffold(
appBar: AppBar(
title: const Text('Native Packages'),
),
body: SingleChildScrollView(
child: Container(
padding: const EdgeInsets.all(10),
child: Column(
children: [
const Text(
'This calls a native function through FFI that is shipped as source in the package. '
'The native code is built as part of the Flutter Runner build.',
style: textStyle,
textAlign: TextAlign.center,
),
spacerSmall,
ElevatedButton(
onPressed: _onGreetPressed,
child: const Text('Greet'),
),
spacerSmall,
ElevatedButton(
onPressed: _onGreetAsyncPressed,
child: const Text('Greet Async'),
),
spacerSmall,
Text(
'greetResult: $greetResult',
style: textStyle,
textAlign: TextAlign.center,
),
spacerSmall,
FutureBuilder<String?>(
future: result,
builder:
(BuildContext context, AsyncSnapshot<String?> value) {
debugPrint(
'future builder: hasData=${value.hasData}, hasError=${value.hasError}, data=${value.data}');
final displayValue =
(value.data != null) ? value.data : 'loading';
return Text(
'greetAsyncResult: $displayValue',
style: textStyle,
textAlign: TextAlign.center,
);
},
),
],
),
),
),
),
);
}
void _onGreetPressed() {
setState(() {
greetResult = send("Sync World");
});
}
void _onGreetAsyncPressed() {
setState(() {
greetAsyncResult = greetAsync("Async World");
});
}
}
7. 실행 및 테스트
이제 예제 앱을 실행할 준비가 거의 끝나갑니다.
Flutter 앱을 실행하기 전에, 빌드된 Rust library 를 Flutter 실행환경으로 복사합니다.
Windows 라면, 아래의 명령을 실행해 줍니다.
copy /y greet_rs\target\debug\greet_rs.dll greet_flutter\example\
macOS 라면, 아래의 명령을 실행해 줍니다. 대상 경로가 아래의 경로가 아니면 앱에서 library를 로딩하지 못합니다.
cp -f greet_rs/target/debug/libgreet_rs.dylib greet_flutter/example/build/macos/Build/Products/Debug/example.app/Contents/Frameworks/
이제 Flutter 예제 앱을 실행합니다. 아래는 Windows 환경입니다.
pushd greet_flutter\example
flutter run -d windows
popd
macOS라면, 아래를 명령으로 실행합니다.
pushd overpass_flutter/example
flutter run -d macos
popd
자, 이제 아래와 같이 실행화면이 보입니다. Greet 버튼은 sync call이라서 UI가 block 될 겁니다. Greet Async 버튼은 async call 이라 UI가 전혀 blocking 되지 않습니다. 실제 production 환경에서는 sync call을 사용하면 안되다는 것을 보여주기 위해서 sync call 도 넣어봤습니다.
지금까지의 과정을 직접 코딩해보는 유튜브 동영상도 있으니 참고하세요.
아래는 실행 결과화면을 다시 캡춰한 영상입니다.