Published on

Rust 与 Flutter 混合开发

Authors
  • avatar
    Name
    皓月之明
    Twitter

与本文相关系列的文章阅读顺序

  1. Rust 与 Flutter 混合开发(本文)
  2. Rust 与 Dart 的数据交互
Table of Contents

随着丰富的终端产品的出现,跨平台的需求也随之出现,Java 曾凭着「一次编写,到处运行(Write once, run anywhereWORA」的口号快速提升市场占有率。

为了减少 App 开发成本,大多数软件都会选择跨平台技术,例如桌面端常见的 Electron,移动端常见的 React Native 和 Flutter。

此类框架提升了跨平台 App 开发的下限,但也限制了上限。对于用户体验要求极高的场景,这会成为瓶颈,因此又需要用各平台的原生 UI 框架实现。这样全面或者局部采用原生开发,将由开发人员承担跨平台的成本,即对于同样页面的需求,在不同平台采用不同语言去多次实现。

为了共享通用逻辑,Kotlin 推出了 Kotlin Multiplatform Mobile (KMM) ,它的架构如下图所示:

KMM

KMM 从官网的介绍看,也能够支持各种包括桌面端,移动端和便携设备在内的多平台,不过现在我们关注的是另一个方案,由 Rust 或者 C/C++ 作为业务逻辑层的跨平台方案,下图展示了其架构,本质上和 KMM 一样。

hybrid-rust-and-flutter

这里的两层和 KMM 中展示的两层作用一致,Rust 通过编译出各个平台的静态或者动态库,供原生工程使用,唯一例外的是 Web 端是通过 WebAssembly 支持的。

图中的 UI 列出了各平台可能使用的原生 UI 框架,当然这些框架也可以替换成 Flutter,甚至是 Flutter 和原生 UI 框架混合。UI 仅负责编写 App 页面,其余工作交给下层的业务逻辑和核心逻辑。

业务逻辑和核心负责业务逻辑和维护数据等与 UI 无关的工作,只要能够编译出各个平台的库的语言都可以作为该层的编程语言。我们选择使用 Rust 编写是因为它拥有和 C/C++ 一样的广泛用途,以及高效的运行和内存效率,此外还拥有 C/C++ 没有的优势 —— 安全。

实现原理

在应用程序开发中,ABI(Application Binary Interface)是两个二进制程序之间的接口,一个 ABI 定义了机器码如何存取数据结构和运算程序,机器码是一种低级的,依赖于硬件的格式。与此相反,API 在源代码中定义了这种访问,这是一种相对高级的,与硬件无关的、通常是人类可读的格式。

术语 FFI(Foreign function interface)来自于 Common Lisp 规范,该规范明确提到了跨语言调用的语言特性。其他语言也许有不同的称谓,例如 Java 将 FFI 称为 JNI(Java Native Interface),但 FFI 被更多语言所接受,已经成为提供此类服务的机制的通用术语。

FFI 的主要功能是将一种编程语言(host 语言,或定义 FFI 的语言)的语义和调用习惯与另一种语言(guest 语言)的语义和习惯相匹配。这个过程还必须考虑到两者的运行环境和 ABI。后面会看到 Rust 于此相关的工具链的使用方式。

下文描述了 Rust 代码是如何与常见的 App 平台集成,这些平台包括 iOS、Android、MacOS、Windows、Linux 以及 Web。示例是在 Rust 中提供 md5 计算接口,在 Flutter 中调用。

创建项目

本文的代码属于 hello world 级,在实际项目中建议使用预配置的模板,工具化的工作流能够节省大量的时间和精力。参见 flutter_rust_bridge

:::tip 环境清单 rustc 1.63.0-nightly & Flutter 3.3.4 • channel stable 运行于 MacBookPro M1。 :::

运行下列命令创建 Flutter 项目:

flutter create demo

进入 Flutter 项目目录,创建名为 native 的 Rust 项目:

cd demo
cargo new --lib native

进入 native 目录,修改 Cargo.toml,为 Rust 项目指定 crate-type

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

静态库用于 iOS,动态库用于其他平台。

实现 md5

计算 md5 将直接使用 md5 crate,运行命令添加依赖:

cd native
cargo add md5

删除 src/lib.rs 中的默认代码,添加如下代码:

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

#[no_mangle]
pub extern "C" fn md5(data: *const c_char) -> *mut c_char {
    let cstr = unsafe { CStr::from_ptr(data)};
    let rstr = String::from_utf8_lossy(cstr.to_bytes()).to_string();
    let digest = md5::compute(rstr);
    CString::new(format!("{:x}", digest)).unwrap().into_raw()
}

函数的输入输出类型使用的是 *const c_char*mut c_char,在函数体中会被转换成对应的 CStrCString,因为 Rust String 和 C String 的规则不一致,例如 Rust 使用 UTF8 编码,而 C String 有多种编码,或是 C String 是 Null-Terminated String,而 Rust String 不是。

CStr 之于 CString 犹如 &str 之于 StringCStr 是一段字符串切片,不拥有所有权,CString 拥有所有权。因此将 Rust String 转换为 C String 会用到 CString,将 C String 转换为 Rust String 会用到 CStr,更多细节可以参考 Rust FFI 文档。

extern "C" 指定使用 C ABI,也可以省略 "C",它是默认值。no_mangle 属性关闭 Rust 编译器的命名混淆,使它能够被链接器识别。这是每个要暴露的接口需要进行的设置。更多细节参见 Rust 与 Dart 的数据交互

生成 C 头文件

cbindgen 用于为导出 C API 的 Rust 库生成 C/C++11 头文件。运行下列命令安装 cbindgen:

cargo install cbindgen

创建 cbindgen.toml,内容可以从模板中复制,将 no_includes 的值改为 true,并将 language 改为 "C",因为下文使用的 ffigen 只能识别 C header。运行 cbindgen 生成头文件:

mkdir -p target
cbindgen src/lib.rs -c cbindgen.toml > target/bindings.h

因为只有一个 md5 函数,所以生成的 bindings.h 也比较简单:

extern "C" {

char *md5(const char *data);

} // extern "C"

为 Dart 生成 FFI API

Dart 与 FFI 相关的包有 dart:ffi 提供调用 C API,读/写、分配/释放原生内存 ,ffi 提供 Dart String 和 C String 的转换。ffigen 用于生成代码。

回到 Flutter 项目目录,添加依赖:

flutter pub add ffi ffigen

打开 pubspec.yaml,添加 ffigen 配置:

ffigen:
  output: 'lib/generated_bindings.dart'
  description: 'Demonstration of flutter calling rust functions'
  headers:
    entry-points:
      - 'native/target/bindings.h'
  name: 'FlutterRustBindings'

执行 flutter pub run ffigen,生成 lib/generated_bindings.dart。如果碰到问题可以查看 ffigen 文档查看依赖。查看生成的代码可以看到之前在 Rust 实现的 md5 函数:

ffi.Pointer<ffi.Char> md5(ffi.Pointer<ffi.Char> data);

它的输入输出全是指针,为了更简单的使用,最好进行一层封装,创建 lib/api.dart,添加代码:

import 'dart:ffi';
import 'dart:io';

import 'package:ffi/ffi.dart';

import 'generated_bindings.dart';

class FlutterRust {
  static final FlutterRustBindings _bindings =
      FlutterRustBindings(_loadLibrary());

  static DynamicLibrary _loadLibrary() {
    if (Platform.isIOS || Platform.isMacOS) {
      return DynamicLibrary.executable();
    } else if (Platform.isWindows) {
      return DynamicLibrary.open("native.dll");
    } else {
      return DynamicLibrary.open("libnative.so");
    }
  }

  static String md5(String value) {
    return _bindings
        .md5(value.toNativeUtf8().cast<Char>())
        .cast<Utf8>()
        .toDartString();
  }
}

对于不同操作系统,加载库调用的方法是不一样的。

修改 Flutter 代码

为了在 Flutter 中调用 md5 并显示,对 Flutter 默认生成的项目进行微小的修改,把 build 函数体中 Center 组件的 child 替换为 Text(FlutterRust.md5("hello world"))。此时遗留的一些代码可以自行删去,不删也不影响。

配置 iOS 项目

有多种方式可以配置 iOS 项目,出于演示目的,本节采用的方式是在 XCode 中手动完成配置。

:::warning 在 M 系列 CPU 的 Mac 电脑上,使用模拟器就可以运行,如果使用的是 Intel CPU 的 Mac 电脑,无法使用模拟器运行,请使用真机。 :::

修改 iOS 项目签名

  1. 在项目根目录打开 ios/Runner.xcworkspace
  2. 在 Project Navigator 选中 Runner
  3. 切换到 Signing & Capabilities 页签
  4. 选择一个团队
  5. 修改 Bundle Identifier 成唯一的 ID

编译 Rust 代码

为 Rust 添加 iOS 编译目标:

rustup target add aarch64-apple-ios
rustup target add aarch64-apple-ios-sim

进入 native 目录,编译 Rust 代码:

cargo build --target aarch64-apple-ios --release
# 进入输出文件夹
cd target/aarch64-apple-ios/release
# 删除没用的文件
rm -rf `ls | grep -v "libnative.a"`
# 回到 native 文件夹
cd -

编译后的文件位于 target/TARGET_TRIPLE/release,对于 iOS 来说,只需要静态库,由于保留动态库会让编译报错,所以我们删除了没用的文件。

如果是在模拟器运行,需要把 target 改为 aarch64-apple-ios-sim,本节示例使用的是真机调试。

配置 XCode

  1. Project Navigator 选中 Runner
  2. 切换到 Build Phases 页签,展开 Link Binary With Libraries
  3. 把之前编译的 target/aarch64-apple-ios/release/libnative.a 拖动到此区域
  4. 切换到 Build Settings 页签,找到 Library Search Paths,双击打开,并拖入 target/aarch64-apple-ios/release 文件夹
  5. 找到 Header Search Paths,双击打开,并拖入 target 文件夹
  6. 点击菜单 File -> Add Files to "Runner",选择 target 文件夹中的 bindings.h
  7. 打开 Runner 下的 Runner-Bridging-Header.h 文件,添加一行代码:
#import "bindings.h"

解决无法找到 symbol

此时运行 Flutter 项目能够编译通过,但是会提示 Failed to lookup symbol 'md5'

如果我们是原生项目,而不是 Flutter 项目,那么调用 Rust 代码是在 Swift 编写的,而现在由于是 Flutter 项目,代码是 Dart 写的。这导致了一种情况,由于 Swift 没有调用 Rust 的那些接口,导致编译器优化了这部分符号,所以需要在 Swift 引用下接口。

  1. 打开 AppDelegate.swift
  2. 添加以下代码:
public func dummyMethodToEnforeBunding() {
    md5("")
}

上述代码只是引用了,没有实际调用 FFI 接口。

此时运行 flutter run -d YOUR_IPHONE_UDID ,可以成功看到画面了。

run on iphone

配置 Android 项目

:::tip 确保安装了 Android NDK 22。但是如果使用了 NDK 23+,请留意下一节的内容,否则可以跳过。 :::

使用 NDK 23+

由于此 issue 描述的问题,使用 NDK 23+ 需要对 NDK 中的文件进行一些修改才能编译,如果使用 NDK 22 可以跳过这个步骤。

进入 NDK 的目录下查找 libunwind.a 文件:

$ find . -name libunwind.a
./toolchains/llvm/prebuilt/darwin-x86_64/lib64/clang/12.0.8/lib/linux/i386/libunwind.a
./toolchains/llvm/prebuilt/darwin-x86_64/lib64/clang/12.0.8/lib/linux/arm/libunwind.a
./toolchains/llvm/prebuilt/darwin-x86_64/lib64/clang/12.0.8/lib/linux/aarch64/libunwind.a
./toolchains/llvm/prebuilt/darwin-x86_64/lib64/clang/12.0.8/lib/linux/x86_64/libunwind.a

找到了 4 个文件,分别对应 4 个 ABI。本示例运行的宿主机是 M1 芯片的 MacBook Pro,使用的设备是 Android 模拟器,M1 的 Android 模拟器架构是 Arm64,target triple 是 aarch64-linux-android。所以需要修改的 ABI 是 aarch64,更多的对应关系可以查看下表:

ABItriple
armeabi-v7aarmv7a-linux-androideabi
arm64-v8aaarch64-linux-android
x86i686-linux-android
x86-64x86_64-linux-android

进入 aarch64 目录,执行:

cat << EOF > libgcc.a
INPUT(-lunwind)
EOF

编译 so

进入 native 目录,创建文件 .cargo/config,此文件用于 cargo 编译的配置。添加如下内容:

[target.aarch64-linux-android]
linker = "<PATH_TO_NDK>/toolchains/llvm/prebuilt/darwin-x86_64/bin/aarch64-linux-android26-clang"

[target.armv7-linux-androideabi]
linker = "<PATH_TO_NDK>/toolchains/llvm/prebuilt/darwin-x86_64/bin/armv7a-linux-androideabi29-clang"

上例添加了两条配置。如前文所属,笔者使用的 ABI 是 aarch64-linux-android,对应着第一条配置,另一个常用的 armv7a-linux-androideabi 是第二条,此处作为示例。不同的 CPU 架构、不同的操作系统请按照上述示例规则添加配置。

运行命令编译动态库:

cargo build --target=aarch64-linux-android --release

在 Android 项目中创建存放动态链接库的目录,并将编译产物复制过去:

mkdir -p ../android/app/src/main/jniLibs/
mkdir -p ../android/app/src/main/jniLibs/arm64-v8a
# 复制编译产物
cp target/aarch64-linux-android/release/libnative.so ../android/app/src/main/jniLibs/arm64-v8a/

运行

执行 flutter run -d YOUR_ANDROID_ID ,可以成功看到画面了。

run on android

配置 MacOS 项目

编译 Rust 代码

为 Rust 添加 MacOS 的编译目标:

rustup target add aarch64-apple-darwin
rustup target add x86_64-apple-darwin

M1 芯片选择前者,Intel 芯片选择后者。示例代码的运行环境是 M1 芯片的电脑,所以使用前者,如果读者使用的是 Intel CPU,请将命令中的 triple 替换成 x86_64-apple-darwin。进入 native 目录,编译 Rust 代码:

cargo build --target aarch64-apple-darwin --release

编译后的文件位于 target/TARGET_TRIPLE/release

配置 XCode

  1. Project Navigator 选中 Runner
  2. 切换到 Build Phases 页签,展开 Link Binary With Libraries
  3. 把之前编译的 target/aarch64-apple-darwin/release/libnative.so 拖动到此区域
  4. 切换到 Build Settings 页签,找到 Library Search Paths,双击打开,并拖入 target/aarch64-apple-darwin/release 文件夹

运行

执行 flutter run -d macos 可以看到如下画面:

run on macos

配置 Windows、Linux 项目**

** Windows 和 Linux 都是通过 CMake 集成,步骤完全一样,下文以 Windows 为例。

编译 Rust 代码

为 Rust 添加 Windows 的编译目标:

rustup target add x86_64-pc-windows-msvc

编译代码:

cargo build --target x86_64-pc-windows-msvc --release

编译后的文件位于 target/x86_64-pc-windows-msvc/release

配置 CMake

corrosion 工具用于将 Rust Crate 集成进 CMake。下载 rust.cmake 到 windows 目录,打开 CMakeLists.txt,在 include(flutter/generated_plugins.cmake) 下添加一行:

include(./rust.cmake)

运行

执行 flutter run -d windows 可以看到如下画面:

run on windows

配置 Web 端

Rust 可以通过编译成 WebAssembly 支持 Web 平台,使用的工具是 wasm-pack,先安装工具:

cargo install wasm-pack

为了支持 WebAssembly,需要对 Rust 项目进行一些修改。另外,虽然可以继续沿用上面使用的 Flutter 项目,但是这需要架通 Flutter 和 HTML/JavaScript 的桥梁,会占用不小的篇幅,这不是本文的目的,下面我们选择使用 HTML/JavaScript 完成 md5() 调用。

修改 Rust 代码

首先引入 wasm-bindgen crate,它将允许代码中使用 wasm_bindgen 属性,用于标记那些应该暴露在 WASM 模块中的接口。

cargo add wasm-bindgen

打开 api.rs 添加以下代码:

#[wasm_bindgen]
pub fn md5_string(data: &str) ->String {
    let digest = md5::compute(data);
    format!("{:x}", digest)
}

为需要导出函数添加 wasm_bindgen 属性。为了导出 WASM 和 FFI,现在有些重复的代码,而且并没有完全做到一套代码,全端通用。这些问题现在不是重点,在工具链的帮助下,这些差异性都可以消除,更多细节参见 flutter_rust_bridge

进入 native 目录运行命令生成 WASM 文件:

wasm-pack build

生成的文件位于 native/pkg 目录下,除了 wasm 文件,wasm-pack 还帮我们生成了以下文件:

  1. *.js:Rust 导出的 API 的 JavaScript 封装,和上文中 cbindgen 的作用类似。
  2. *.d.ts:用于 TypeScript。从 native_bg.wasm.d.ts 还能看到 WASM 文件中导出了 memory API,更多内容参见 Rust 与 Dart 的数据交互
  3. *.wasm:Rust 编译产物。
  4. package.json:npm 包配置文件。

创建 HTML 项目

HTML 项目将使用 create-wasm-app 模板,在 native 目录下执行以下命令生成 HTML 项目:

npm init wasm-app web
# 进入 web 目录
cd web
# 安装依赖
npm i
# 打开开发服务器
npm start

打开开发服务器后,终端会打印出开发地址,默认为 http://localhost:8080,打开后,该模板默认会弹出一个 alert

进入 native/pkg 目录,执行 npm link,由于这个目录下有 package.json 文件,使得当前目录下的 npm 包可以被其他本地 npm 包作为依赖。

回到 native/web 目录,执行 npm link native 链接上一步的包,其中 native 就是 native/pkg/package.json 里的 name 字段值。

修改 native/web/index.js 文件,删除所有代码,并添加以下代码:

import * as wasm from 'native'
document.body.innerText = wasm.md5_string('hello world')

运行

在上一节开发服务器已经被打开了,切换到之前打开的网页,此时它应该自动刷新了,并在网页上显示一串字符串 5eb63bbbe01eeed093cb22bb8f5acdc3

run on web

内存问题

上文我们完成了 Rust 在常见 App 发行平台的集成和运行,实际上现在的代码有些问题。

md5 计算是 Rust 提供的,它的内存也是在 Rust 分配的,一旦数据使用 CString.into_raw 传递到外部,Rust 就不再自动管理它的生命周期,所以需要下面这样一个释放函数:

#[no_mangle]
pub extern "C" fn free_string(ptr: *mut c_char) {
    unsafe { drop(CString::from_raw(ptr)); }
}

在 Dart 可以这样封装:

static freeString(String value) {
    _bindings.free_string(value.toNativeUtf8().cast<Char>());
}

通过将 md5() 返回的字符串传递给 freeString() 即可。更多细节参见 Rust 与 Dart 的数据交互


参考资料

  1. Rust std::ffi
  2. C interop using dart:ffi
  3. flutter_rust_bridge
  4. Building and Deploying a Rust library on Android
  5. cbindgen
  6. Use the NDK with other build systems
  7. corrosion
  8. wasm-pack docs
  9. Rust 🦀 和 WebAssembly 🕸