본문 바로가기

Mobile/Flutter

flutter에서 Rust library 사용하기 (1b): Android + JNI

이번에 소개할 내용은 Flutter 에서 Rust 기반 library 를 사용하는 방법입니다. 원래는 iOS 까지 다뤄서 어떻게 공유하는지 한 번에 보여드리려고 했으나, 내용이 많아서 아래와 2~3회로 나눠서 다룰 예정입니다.

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

대상 독자:

  • Android, iOS 간 코드를 하나의 programming language로 공유하고 싶은 분
  • Multi-platform으로써의 Rust의 가능성을 확인하고 싶은 분
  • Android와 NDK, JNI(Java Native Interface)에 대한 이해가 있으신 분
  • 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 기반 JNI wrapper library 생성
  6. Rust 기반 JNI wrapper library 코드 작성
  7. Rust library 프로젝트 설정
  8. Rust library 를 Android용 library로 빌드
  9. Android의 Flutter Method Channel 작성
  10. 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 new --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 기반 JNI wrapper library 프로젝트 생성: rust_android_lib

Rust library와 Android JNI를 연결해주기 위한 C Library Wrapper 입니다.

cargo new --lib rust_android_lib

6. Rust 기반 JNI wrapper library 코드 작성: rust_android_lib/src/lib.rs

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

이 코드는 JNI 함수에서 Rust library code를 호출하고 JNI에 맞게 conversion 해주는 코드입니다.

특이사항이 있는데, android app의 MainActivity의 패키지 이름에 "flutter_rust_app" 과 같이 "_"가 들어가게 되면 아래의 JNI function을 만들때, "_"를 "_1"로 바꿔줘야 합니다. 그렇지 않으면 Java의 패키지 폴더 구분으로 쓰이던 "."이 "_"로 변환되는 것과 구분이 되지 않아 실행시 "No implementation found" 에러가 발생합니다.

extern crate jni;
/// Expose the JNI interface for android below
#[cfg(target_os = "android")]
#[allow(non_snake_case)]
use jni::objects::{JClass, JString};
use jni::sys::jstring;
use jni::JNIEnv;
use rust_lib::greet;
use std::ffi::{CStr, CString};

#[no_mangle]
// NOTE method name SHOULD NOT have "_". Otherwise the function cannot find on android.
// NOTE _1 means _ for jni spec.
// See https://stackoverflow.com/questions/16069209/invoking-jni-functions-in-android-package-name-containing-underscore
pub unsafe extern "system" fn Java_com_example_flutter_1rust_1app_MainActivity_greet(
    env: JNIEnv,
    _: JClass,
    java_to: JString,
) -> jstring {
    // Our Java companion code might pass-in "world" as a string, hence the name.
    let javastr_to = env.get_string(java_to).expect("invalid to string").as_ptr();
    let cstr_to = CStr::from_ptr(javastr_to);
    let str_to = match cstr_to.to_str() {
        Ok(str_to) => str_to.to_owned(),
        Err(_) => "there".to_owned(),
    };
    let result = greet(str_to);
    // Retake pointer so that we can use it below and allow memory to be freed when it goes out of scope.
    let world_ptr = CString::from_raw(CString::new(result).unwrap().into_raw());
    let output = env
        .new_string(world_ptr.to_str().unwrap())
        .expect("Couldn't create java string!");

    output.into_inner()
}

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_android_lib"]

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_android_lib.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"

    init {
        System.loadLibrary("rust_android_lib")
    }

    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 { greet(it) }
                if (message !=null) {
                    result.success(message)
                } else {
                    result.error("UNAVAILABLE", "can't get greet message", null)
                }
            } else {
                result.notImplemented()
            }
        }
    }
}

10. 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)에서 호출하기 위해서 해줘야할 것에 대해서 알아봤습니다. 이 방법이 간단하지는 않고 2단계를 거쳐야 하지만, 내부 동작을 이해하는 데는 도움이 될 것으로 보입니다.

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

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

2022/10/30 업데이트: JNI 방식은 jni branch로 이전했고, main branch는 JNA 방식으로 변경했습니다. JNA 방식에 대해서는 별도의 포스트를 남기겠습니다.

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

 

참고 사이트