본문 바로가기

Mobile/Flutter

Flutter Desktop 에서 Rust library 사용하기 (3): Windows, macOS

이전 포스트 들에서는 모바일 앱에서 Rust library 를 사용하는 방법을 직접 구현해 봤습니다. 이번에는 cbindgen, ffigen 툴을 활용해서 좀 더 쉽게 Rust library를 연동하고 Desktop OS(Windows, macOS)용 Flutter 앱에서 사용하는 예제를 만들어 보겠습니다. flutter library 생성시 plugin_ffi template을 이용하면 sum을 구하는 예제가 있는데, 여기서는 문자열을 주고 받는 함수로 변경해 보겠습니다.

대상 독자:

  • Flutter 에 대한 기본지식이 있는 사람
  • Rust 에 대한 기본지식이 있는 사람
  • 둘 간의 연동을 원하는 사람

준비물:

  • Windows 
    • Windows 10 이상
    • MSVC 2019 이상
  • macOS
    • macOS 13.5.x (Ventura) 이상
    • Xcode 15.x 이상
  • Rust v1.x 이상 개발환경
  • Flutter v3.x 이상 개발환경

단계:

  1. 개발환경 셋업
  2. Rust library 개발
  3. C header file 생성
  4. Flutter library 및 예제 프로젝트 생성
  5. Flutter library 생성
  6. Example code 수정
  7. 실행 및 테스트

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 도 넣어봤습니다.

지금까지의 과정을 직접 코딩해보는 유튜브 동영상도 있으니 참고하세요.

아래는 실행 결과화면을 다시 캡춰한 영상입니다.