본문 바로가기

Mobile/Flutter

flutter에서 Rust library 사용하기 (1a): Android + JNA

이번에 소개할 내용은 지난번에 이어서 Flutter(Android) 에서 Rust 기반 library 를 사용하는 방법입니다. 지난번에 JNI 기술을 써서 연동했다면 이번에는 좀 더 진보된 JNA(Java Native Access)를 사용하는 방법을 다루겠습니다. 아래는 순서를 좀 바꿨습니다. 실제 연동을 해보니 JNI 보다 JNA 가 더 C library측의 Java의 dependency가 없어서 C library는 iOS에서도 그대로 사용가능했습니다. JNI(1b)의 포스트와 중복이 있긴 한데, 이 포스트만 보시는 분들을 위해 유지했습니다.

이번 포스트에 다루는 Rust library와의 연동방법은 JNA와 Flutter Method Channel을 사용합니다. Flutter code와 Rust 간의 UI code와 Rust library 간의 2단계를 거치게 됩니다. Rust library와 C Library 부분은 iOS, Windows 등 타 OS와 native code를 공유하게 됩니다.

대상 독자:

  • Android, iOS 간 코드를 하나의 programming language로 공유하고 싶은 분
  • Multi-platform으로써의 Rust의 가능성을 확인하고 싶은 분
  • Android와 NDK, JNA(Java Native Access)에 대한 이해가 있으신 분
  • Rust에 대한 기본적인 이해가 있으신 분

 

준비물:

  • Android 개발환경 (Android Studio, SDK, NDK)
    • Android SDK는 v30 이상
    • NDK는 v22를 사용합니다. (v22보다 최신버전은 빌드가 되지 않을 수 있습니다.)
  • Flutter 개발환경 
    • v3.3.4 사용
  • Rust 개발환경 (rustc v1.64.0)
  • Windows or macOS

 

단계:

  1. Flutter 프로젝트 생성
  2. Rust 개발 환경 설정
  3. Rust library 생성
  4. Rust library 코드 작성
  5. Rust library wrapper for C API 프로젝트 생성
  6. Rust library wrapper for C API 코드 작성
  7. Rust library 프로젝트 설정
  8. Rust library 를 Android용 library로 빌드
  9. Android의 Flutter Method Channel 작성
  10. Android build script 수정
  11. Flutter UI 코드 수정

 

1. Flutter 프로젝트 생성

이 후 flutter_rust_app 폴더가 (app path)를 의미합니다.

flutter create flutter_rust_app

2. Rust 개발 환경 설정

android용 library로 빌드하기 위해 target을 추가합니다.

rustup target add aarch64-linux-android
rustup target add armv7-linux-androideabi
rustup target add i686-linux-android
rustup target add x86_64-linux-android

3. Rust library 생성: rust_lib

순수 Rust 코드로만 작성된 Rust 용 library 입니다.

cd flutter_rust_app
cargo init --lib rust_lib

4. Rust library 코드  작성: rust_lib/src/lib.rs

기본적으로 제공되는 코드를 삭제하고 아래의 greet 함수와 unit test 코드로 대체한다.

pub fn greet(to: String) -> String {
    format!("Hello, {}", to)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_greet() {
        let result = greet("World".to_owned());
        assert_eq!(result, "Hello, World".to_owned());
    }
}

5. Rust library wrapper for C API 프로젝트 생성: rust_clib

Rust library를 C API 기반으로 wrapping 해주는 library입니다.

cargo init --lib rust_clib

6. Rust library wrapper for C API 코드 작성: rust_clib/src/lib.rs

lib.rs 에 있는 코드를 삭제하고 아래의 코드를 넣습니다.

이 코드는 Rust library 함수를 C library 형태로 wrapping 하고 C library에 맞게  conversion 해주는 코드입니다. JNI와 차이점은 JNI는 C Library code에 JNI를 위한 function을 제공해야하는 반면 JNA는 일반적인  C library 형태로 구현하기 때문에 JNA에 의존적인 코드가 들어가지 않아 외부 C Library 사용에 더 유연한 방법입니다.

use rust_lib;
use std::ffi::{c_char, CStr, CString};

fn string_to_c_chars(s: String) -> *mut c_char {
    let cstring = CString::new(s).unwrap();
    cstring.into_raw()
}

fn c_chars_to_string(c_chars: *const c_char) -> String {
    let c_str = unsafe { CStr::from_ptr(c_chars) };
    match c_str.to_str() {
        Err(_) => "error".to_owned(),
        Ok(string) => string.to_owned(),
    }
}

#[no_mangle]
pub extern "C" fn cgreet(to: *const c_char) -> *mut c_char {
    let result = rust_lib::greet(c_chars_to_string(to));
    string_to_c_chars(result)
}

7. Rust library 프로젝트 설정: rust_android_lib/Cargo.toml, .cargo/config.toml

build 결과는 C용 dynamic library 이고, rust_lib 의존성 추가, jni crate를 추가하는 설정입니다.

[lib]
crate-type = ["cdylib"]

[dependencies]
rust_lib = { path = "../rust_lib" }

[target.'cfg(target_os="android")'.dependencies]
jni = { version = "0.19.0", default-features = false }

(app path) 밑에 .cargo 폴더를 만들고 그 안에 config.toml 파일을 아래와 같이 추가합니다. (ndk path)는 여러분의 환경에 맞는 경로로 변경해야 합니다. macOS라면 (ndk path)는 /Users/(user)/Library/Android/sdk/ndk 가 될 겁니다.

[build]
target = [
    "aarch64-linux-android",
    "armv7-linux-androideabi",
    "i686-linux-android",
    "x86_64-linux-android",
]

[target.aarch64-linux-android]
ar = "(ndk path)/22.1.7171670/toolchains/llvm/prebuilt/darwin-x86_64/bin/llvm-ar"
linker = "(ndk path)/22.1.7171670/toolchains/llvm/prebuilt/darwin-x86_64/bin/aarch64-linux-android30-clang"

[target.armv7-linux-androideabi]
ar = "(ndk path)/22.1.7171670/toolchains/llvm/prebuilt/darwin-x86_64/bin/llvm-ar"
linker = "(ndk path)/22.1.7171670/toolchains/llvm/prebuilt/darwin-x86_64/bin/armv7a-linux-androideabi30-clang"

[target.i686-linux-android]
ar = "(ndk path)/22.1.7171670/toolchains/llvm/prebuilt/darwin-x86_64/bin/llvm-ar"
linker = "(ndk path)/22.1.7171670/toolchains/llvm/prebuilt/darwin-x86_64/bin/i686-linux-android30-clang"

[target.x86_64-linux-android]
ar = "(ndk path)/22.1.7171670/toolchains/llvm/prebuilt/darwin-x86_64/bin/llvm-ar"
linker = "(ndk path)/22.1.7171670/toolchains/llvm/prebuilt/darwin-x86_64/bin/x86_64-linux-android30-clang"

8. Rust library workspace 설정: Cargo.toml

두개의 Rust library를 하나의 workspace로 묶기 위한 설정 파일(Cargo.toml)을 app path에 추가합니다.

[workspace]
members = ["rust_lib", "rust_clib"]

9. Rust library 를 Android용 library 빌드

cargo build --release

빌드된 결과를 Android project에 참고할 수 있도록 symbolic link를 생성해 줍니다. 아래는 symbolic link를 한번에 생성하게 하는 bash script (link_libs.sh) 를 생성합니다. 

#!/bin/bash

BASE_PATH=$PWD/target
BUILD_TARGET=release
LIB_NAME=librust_clib.so
JNI_LIBS_PATH=android/app/src/main/jniLibs

rm -rf $JNI_LIBS_PATH

mkdir -p $JNI_LIBS_PATH/arm64-v8a
mkdir -p $JNI_LIBS_PATH/armeabi-v7a
mkdir -p $JNI_LIBS_PATH/x86
mkdir -p $JNI_LIBS_PATH/x86_64

ln -s $BASE_PATH/aarch64-linux-android/$BUILD_TARGET/$LIB_NAME $JNI_LIBS_PATH/arm64-v8a/$LIB_NAME
ln -s $BASE_PATH/armv7-linux-androideabi/$BUILD_TARGET/$LIB_NAME $JNI_LIBS_PATH/armeabi-v7a/$LIB_NAME
ln -s $BASE_PATH/i686-linux-android/$BUILD_TARGET/$LIB_NAME $JNI_LIBS_PATH/x86/$LIB_NAME
ln -s $BASE_PATH/x86_64-linux-android/$BUILD_TARGET/$LIB_NAME $JNI_LIBS_PATH/x86_64/$LIB_NAME

아래와 같이 실행합니다. 이제 빌드된 library binary 들은 android/app/src/main/jniLibs에 architecture 에 맞게 링크가 됩니다.

. ./link_libs.sh

9. Android 의 Flutter Method Channel 작성: android/app/src/main/kotlin/com/example/flutter_rust_app/MainActivity.kt

아래와 같이 MainActivity.kt 파일을 수정합니다.

Rust library를 로딩하고, Flutter Platform Integration API 를 통해서 실행하고 결과를 반환해주면 됩니다.

package com.example.flutter_rust_app

import androidx.annotation.NonNull
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel

class MainActivity: FlutterActivity() {
    private val CHANNEL = "com.example.flutter_rust_app/greet"

    // JNA way
    interface CGreet : Library {
        fun cgreet(to: String): String

        companion object {
            val INSTANCE = Native.load(
                "rust_clib",
                CGreet::class.java
            ) as CGreet
        }
    }

    external fun greet(to: String): String

    override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)
        MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler {
                call, result ->
            // This method is invoked on the main thread.
            if (call.method == "greet") {
                val to = call.argument<String>("to")
                val message = to?.let { CGreet.INSTANCE.cgreet(it) }
                if (message !=null) {
                    result.success(message)
                } else {
                    result.error("UNAVAILABLE", "can't get greet message", null)
                }
            } else {
                result.notImplemented()
            }
        }
    }
}

10. Android build script 수정: android/app/build.gradle

Android용 JNA library를 추가하기 위해 build.gradle에 아래의 내용을 추가합니다.

dependencies {
...
    implementation ('net.java.dev.jna:jna:5.12.1@aar') {
        transitive = true
    }
}

11. Flutter UI 코드 수정: lib/main.dart

이제 마지막 단계입니다. Flutter App에서 Android 에서 구현한 Platform Integration API를 호출하고 결과를 화면에 출력하는 코드입니다.

기본 counter 소스에 아래의 내용을 추가하면 됩니다. Rust library의 함수를 호출하기 위한 Button과 그 결과를 출력하는 Label 위젯 2개를 추가합니다.

이 부분은 iOS 에서도 재사용할 예정입니다.

...
import 'package:flutter/services.dart';
...

class _MyHomePageState extends State<MyHomePage> {
...
  static const platform = MethodChannel('com.example.flutter_rust_app/greet');
  String _message = 'message here';
...

  @override
  Widget build(BuildContext context) {
...
            ElevatedButton(
              onPressed: () async {
                String result;
                try {
                  result =
                      await platform.invokeMethod('greet', {'to': 'World'});
                } on PlatformException catch (e) {
                  result = "Failed to get greet message: '${e.message}'.";
                }
                setState(() {
                  _message = result;
                });
              },
              child: const Text('Greet'),
            ),
            Text(
              _message,
              style: Theme.of(context).textTheme.headline4,
            ),
...

 

이제 모든 작업이 완료되었고 flutter run으로 앱을 빌드하고 실행하면 아래와 같은 화면이 나오고, 화면에서 Greet 버튼을 클릭하면 버튼 아래의 메시지가 "Hello World"로 바뀔 겁니다.

 

결론

지금까지 Rust library code를 Flutter app (Android + JNA)에서 호출하기 위해서 해줘야할 것에 대해서 알아봤습니다. 이 방법이 간단하지는 않고 2단계를 거쳐야 하지만, 내부 동작을 이해하는 데는 도움이 될 것으로 보입니다.

다음에는 iOS에서도 동작할 수 있도록 해보겠습니다.

실행가능한 예제 코드는 아래의 링크에서 공유합니다.

https://github.com/yeoupooh/flutter_rust_app

 

GitHub - yeoupooh/flutter_rust_app: Sharing Rust libray with Flutter

Sharing Rust libray with Flutter. Contribute to yeoupooh/flutter_rust_app development by creating an account on GitHub.

github.com

 

참고 사이트