使用 uniffi-rs 在 iOS 和 macOS 中共享 Rust 代码
本文的主要目标是验证能够在 iOS 和 macOS 应用中使用由 `uniffi-rs` 生成 Rust 共享库。项目源码可以在 Github 中查看。
2023-12-30
本文的主要目标是验证能够在 iOS 和 macOS 应用中使用由
uniffi-rs
uniffi-rs
是什么?
uniffi-rs
FFI
FFI
在 Rust 中提供了 std::ffi
uniffi-rs
FFI
uniffi-rs
uniffi-rs
Go
C#
Dart
使用
uniffi-rs
- 在 Rust 项目中添加 库;
uniffi
- 编写需要共享的 Rust 代码;
- 使用过程宏 Procedural Macros 或 UDL(UniFFI Definition Language) 文件定义需要生成的绑定代码的接口;
- 生成目标平台的共享库;
- 生成目标语言的绑定代码;
- 在项目中引入共享库和绑定。
下面用一个例子展示如何使用
uniffi-rs
关于演示项目的说明
本文中使用的演示项目是使用 Swift 语言开发的苹果应用,支持的架构为 arm64 架构,平台为 iOS 和 macOS。
这个项目的主要目标是验证能够在上述平台中通过
uniffi-rs
应用有两个页面,其功能如下所述:
- 页面一:允许用户在本机中选择 sqlite 数据库存储路径和一个需要扫描的文件夹的路径,在选定后调用 Rust 中实现的文件夹遍历、数据库创建及写入操作将待扫描文件夹(包含子文件夹)中的文件列表保存到 sqlite 数据库中,然后通过关键词匹配从 sqlite 数据库中筛选文件;
- 页面二:允许用户输入一个网址,输入后调用 Rust 中实现的网络访问方法并将网址内容在应用中展示。
本文中不会对演示项目的代码进行说明,如果有需要可以在这里查看具体代码。
使用 uniffi-rs
共享 Rust 代码库
uniffi-rs
在后面的篇幅中将介绍如何使用
uniffi-rs
注意:成文时 uniffi-rs
初始化 Rust 项目
首先使用下面的脚本创建项目目录:
mkdir -p uniffi-example/{shared_lib,app,scripts}
其中:
- Rust 共享代码项目位于 中;
uniffi-example/shared_lib
- 目标语言项目(本文中为 XCode 项目)保存在 中;
uniffi-example/app
- 辅助用的脚本保存在 中。
uniffi-example/scripts
注意:在创建共享代码的时候,包的名称不要使用太过普遍的名称,以免在导入目标项目的时候和其他依赖的库冲突。
创建完成后初始化 Rust 项目并添加依赖:
cd uniffi-example/shared_lib && cargo init --lib
cargo add uniffi rusqlite ureq -F uniffi/build,uniffi/cli,rusqlite/bundled
cargo add --build uniffi -F uniffi/bindgen
在上面的脚本中,为项目添加 4 个依赖:
- 用于生成共享库的 (需要启用
uniffi
和build
功能);cli
- 用于操作 sqlite 数据库的 (需要启用
rusqlite
功能为各个平台编译各自的 sqlite 库);bundled
- 用于发送网络请求的 ;
ureq
- 用于生成绑定文件的 (此为 build 依赖)。
uniffi
依赖添加完成后需要修改
Cargo.toml
crate-type
[lib]
crate-type = ["lib", "cdylib", "staticlib"] # 生成库文件、C 兼容的动态库文件和静态库
Cargo.toml
定义绑定代码接口
在开始编写需要共享的代码之前,需要了解在
uniffi-rs
在
uniffi-rs
- UDL(UniFFI Definition Language) 文件定义了需要向外部语言公开哪些函数、方法和类型;
- 使用过程宏 Procedural Macros可以直接在 Rust 代码中定义函数签名和类型定义并导出到绑定代码中。
本文中将使用过程宏的方式定义接口,如果希望使用 UDL 文件进行定义的话请参考官方文档。注意,这两种方式并冲突,在一个项目中是可以混用的。
编写共享代码
在项目中添加
fs.rs
use std::fs::{self};
use std::path::Path;
/// FileEntry 文件基本信息
///
/// # Notes
/// * 使用过程宏声明 FileEntry 这个结构体将被导出到绑定文件中
#[derive(Debug, uniffi::Record)]
pub struct FileEntry {
pub path: String,
pub parent_path: String,
pub is_directory: bool,
}
/// 使用递归遍历文件夹
///
/// # Arguments
///
/// * dir - 文件夹路径
/// * parent_path - 父目录路径
///
/// # Returns
///
/// * Vec<FileEntry> - 文件列表
pub fn walk_dir(dir: &str, parent_path: &str) -> Result<Vec<FileEntry>, String> {
let dir = Path::new(dir);
let parent_path = Path::new(parent_path);
let mut file_entries = Vec::new();
file_entries.push(FileEntry {
path: String::from(dir.to_string_lossy()),
parent_path: String::from(parent_path.to_string_lossy()),
is_directory: true,
});
let entries = fs::read_dir(dir).unwrap();
for entry in entries {
let entry = entry.unwrap();
let path = entry.path();
if path.is_file() {
// 将文件信息和父目录信息添加到列表中
file_entries.push(FileEntry {
path: String::from(path.to_string_lossy()),
parent_path: String::from(parent_path.to_string_lossy()),
is_directory: false,
});
} else if path.is_dir() {
// 递归处理目录,并将结果合并到当前列表
let mut sub_entries =
walk_dir(&path.to_str().unwrap(), &path.to_str().unwrap()).unwrap();
file_entries.append(&mut sub_entries);
}
}
Ok(file_entries)
}
uniffi-example/shared_lib/src/fs.rs
在项目中添加
sqlite.rs
use rusqlite::{Connection, Result};
use crate::fs::FileEntry;
/// 初始化 sqlite 数据库
///
/// # Arguments
///
/// * db_path - sqlite 数据库的保存路径
pub fn init_db(db_path: &str) -> Result<Connection, String> {
let conn = Connection::open(db_path).unwrap();
// 创建文件列表表,包括父目录路径
conn.execute(
"CREATE TABLE IF NOT EXISTS files (
id INTEGER PRIMARY KEY,
path TEXT NOT NULL,
parent_path TEXT NOT NULL,
is_directory TEXT NOT NULL
)",
[],
)
.unwrap();
Ok(conn)
}
/// 批量插入文件信息
///
/// # Arguments
///
/// * db_path - 数据库路径
/// * entry - 待插入的文件信息列表
pub fn batch_insert(db_path: &str, entries: Vec<FileEntry>) -> () {
let conn = Connection::open(db_path).unwrap();
for entry in entries {
let is_directory_str = if entry.is_directory { "True" } else { "False" };
conn.execute(
"INSERT INTO files (path, parent_path, is_directory) VALUES (?, ?, ?)",
&[&entry.path, &entry.parent_path, is_directory_str],
)
.unwrap();
}
}
/// 根据关键字搜索文件
///
/// # Arguments
///
/// * db_path - 数据库路径
/// * keyword - 关键字
///
/// # Returns
///
/// * Vec<FileEntry> - 文件列表
pub fn search(db_path: &str, keyword: &str) -> Vec<FileEntry> {
let conn = Connection::open(db_path).unwrap();
let mut stmt = conn
.prepare("SELECT * FROM files WHERE path LIKE ?1")
.unwrap();
let result = stmt
.query_map(&[&format!("%{}%", keyword)], |row| {
let path: String = row.get(1).unwrap();
let parent_path: String = row.get(2).unwrap();
let is_directory: String = row.get(3).unwrap();
Ok(FileEntry {
path,
parent_path,
is_directory: is_directory == "True",
})
})
.unwrap();
result.map(|item| item.unwrap()).collect()
}
uniffi-example/shared_lib/src/sqlite.rs
在项目中添加
request.rs
ureq
/// GET 请求
///
/// # Arguments
///
/// * url - 请求目标
pub fn get(url: String) -> Result<String, String> {
let resp = ureq::get(&url)
.set("Content-Type", "json/application")
.call();
if resp.is_err() {
return Err(resp.unwrap_err().to_string());
}
let body = resp.unwrap().into_string();
if body.is_err() {
return Err(body.unwrap_err().to_string());
}
Ok(body.unwrap())
}
uniffi-example/shared_lib/src/request.rs
最后在
lib.rs
// 本项目仅使用过程宏,因此需要在文件开头添加下面这行代码
uniffi::setup_scaffolding!();
mod fs;
mod request;
mod sqlite;
// 使用 #[uniffi::export] 声明该方法需要被导出到绑定文件中
#[uniffi::export]
pub fn init_sqlite(db_path: String) -> String {
sqlite::init_db(&db_path).unwrap();
String::from(db_path)
}
#[uniffi::export]
pub fn fetch_url(url: String) -> String {
match request::get(url) {
Ok(body) => return body,
Err(err) => return err,
};
}
#[uniffi::export]
pub fn search_sqlite(db_path: String, keyword: String) -> Vec<fs::FileEntry> {
sqlite::search(&db_path, &keyword)
}
#[uniffi::export]
pub fn walk_and_insert(db_path: String, dir: String) -> String {
let entries = fs::walk_dir(&dir, "").unwrap();
sqlite::batch_insert(&db_path, entries);
String::from("inserted")
}
uniffi-example/shared_lib/src/lib.rs
生成共享库
因为希望共享库能够在 arm64 架构下的 iOS 和 macOS 中使用,因此需要将 Rust 代码编译为这些平台下的库文件。
首先使用
rustup
rustup target add aarch64-apple-ios-sim rustup target add aarch64-apple-ios rustup target add aarch64-apple-darwin
安装完成后使用下面的指令生成库文件:
echo "Building aarch64-apple-ios"
cargo build --target aarch64-apple-ios --release
echo "Building aarch64-ios-sim"
cargo build --target aarch64-apple-ios-sim --release
echo "Building aarch64-apple-darwin"
cargo build --target aarch64-apple-darwin --release
编译成功后应该可以在
uniffi-example/shared_lib/target
release
libshared_lib.a
生成绑定文件
准备好需要共享的库文件后,使用
uniffi
首先添加
uniffi-bindgen.rs
fn main() {
uniffi::uniffi_bindgen_main()
}
uniffi-example/shared_lib/uniffi-bindgen.rs
然后在
Cargo.toml
[[bin]]
name = "uniffi-bindgen"
path = "uniffi-bindgen.rs"
uniff-example/shared_lib/Cargo.toml
完成上述操作后即可使用下面的指令调用
uniffi-bindgen
target="aarch64-apple-ios"
cargo run \
--bin uniffi-bindgen generate \
--library target/${target}/release/libshared_lib.a \
--language swift \
--out-dir out/${target}
target="aarch64-apple-ios-sim"
cargo run \
--bin uniffi-bindgen generate \
--library target/${target}/release/libshared_lib.a \
--language swift \
--out-dir out/${target}
target="aarch64-apple-darwin"
cargo run \
--bin uniffi-bindgen generate \
--library target/${target}/release/libshared_lib.a \
--language swift \
--out-dir out/${target}
执行完成后可以在
uniffi-example/shared_lib/out
使用 uniffi-rs
生成的共享代码库
uniffi-rs
以示例项目为例,下面将说明如何在 XCode 中添加共享代码库。
示例项目需要能够在 arm64 架构下的 iOS 及 macOS 平台下使用,因此需要使用前面生成的库文件和绑定文件创建 xcframework 以便将不同平台和架构的库集成到单个 Framework 中。
首先创建一个
generate-bindings.sh
pushd shared_lib
# 需要为三个 target 生成库和绑定文件
targets=("ios" "ios-sim" "darwin")
for suffix in "${targets[@]}"
do
target="aarch64-apple-${suffix}"
echo "Generating target=${target}"
cargo build --target $target --release
cargo run \
--bin uniffi-bindgen generate \
--library target/${target}/release/libshared_lib.a \
--language swift \
--out-dir out/${target}
# 将 shared_libFFI.modulemap 重命名为 module.modulemap 以便在创建 xcframework 的时候使用
mv out/${target}/shared_libFFI.modulemap out/${target}/module.modulemap
done
popd
uniffi-example/scripts/generate-bindings.sh
上述代码将创建共享库和生成绑定文件进行了整合。然后创建一个
build-framework.sh
make bindings
DEST="../app/uniffi-example/shared_lib_framework.xcframework"
ENV="release"
HEADER_DIR="out"
STATIC_LIB_NAME="libshared_lib.a"
TARGET_DIR="target"
cd shared_lib
xcodebuild -create-xcframework \
-library "${TARGET_DIR}/aarch64-apple-darwin/${ENV}/${STATIC_LIB_NAME}" \
-headers "${HEADER_DIR}/aarch64-apple-darwin" \
-library "${TARGET_DIR}/aarch64-apple-ios/${ENV}/${STATIC_LIB_NAME}" \
-headers "${HEADER_DIR}/aarch64-apple-ios" \
-library "${TARGET_DIR}/aarch64-apple-ios-sim/${ENV}/${STATIC_LIB_NAME}" \
-headers "${HEADER_DIR}/aarch64-apple-ios-sim" \
-output $DEST
uniffi-example/scripts/build-framework.sh
该脚本会使用
xcodebuild
在 XCode 中打开项目,按照下图所示将 xcframework 添加到目标中:
接着从 finder 中将
uniff-example/shared_lib/out/aarch64-apple-ios-sim/shared_lib.swift
out
shared_lib.swift
Swift`` 代码中调用在
注意:在 Rust 中的方法会使用 snake_case 命名,而在生成的绑定文件中会转换为 camelCase。
运行效果如下图所示:
注意:需要在 <app-name>.entitlements
App Sandbox
NO
补充内容
- 在使用 的过程中有很多内容参考自《Calling Rust code from C#》和《Calling Rust code from Swift on iOS and macOS》这两篇文章,如果有使用
uniffi-rs
在uniffi-rs
中共享代码的需求的话的可以读一读。C#
- 网络请求部分的 Rust 代码一开始是使用 实现的,编译出来的库在 iOS 平台上可以正常使用,但是在 macOS 平台上却会编译失败。验证了不是 Async 的问题,但是尝试了很久也没找到原因,最后只好换成 ureq 来实现网络请求。如果有跨平台的需求,在正式使用前最好通过一些用例来确认在各个平台上都是可用的。
reqwest
- 现阶段如果要在生产环境中使用 需要慎重一些,毕竟官方也在文档中提到了在后续版本中并不能确保后向兼容。
uniffi-rs