cover

使用 uniffi-rs 在 iOS 和 macOS 中共享 Rust 代码

本文的主要目标是验证能够在 iOS 和 macOS 应用中使用由 `uniffi-rs` 生成 Rust 共享库。项目源码可以在 Github 中查看。

2023-12-30

本文的主要目标是验证能够在 iOS 和 macOS 应用中使用由 uniffi-rs 生成的 Rust 共享库。项目源码可以在这里查看。

uniffi-rs 是什么?

FFI(Foreign Function Interface)是一种用于在不同编程语言之间进行交互和调用函数的技术。使用 FFI 的目的是允许从一种编程语言编写的程序中调用由另一种编程语言编写的函数或数据结构。

在 Rust 中提供了 std::ffi 标准库用于实现 FFI。而 uniffi-rs 则是由 Mozilla 开发的一套用于实现 FFI 的开源项目,使用 uniffi-rs 可以为目标语言生成相应的绑定文件 Bindings ,并提供必要的头文件,然后在目标语言中调用编译后的 Rust 代码。

uniffi-rs 默认支持 Kotlin、Python、Ruby 和 Swift,同时社区中也提供了生成 GoC#Dart 绑定的实现。

使用 uniffi-rs 的有以下几个步骤:

  1. 在 Rust 项目中添加 uniffi 库;
  2. 编写需要共享的 Rust 代码;
  3. 使用过程宏 Procedural Macros 或 UDL(UniFFI Definition Language) 文件定义需要生成的绑定代码的接口;
  4. 生成目标平台的共享库;
  5. 生成目标语言的绑定代码;
  6. 在项目中引入共享库和绑定。

下面用一个例子展示如何使用 uniffi-rs

关于演示项目的说明

本文中使用的演示项目是使用 Swift 语言开发的苹果应用,支持的架构为 arm64 架构,平台为 iOS 和 macOS。

这个项目的主要目标是验证能够在上述平台中通过 uniffi-rs 共享由 Rust 开发的代码。

应用有两个页面,其功能如下所述:

  1. 页面一:允许用户在本机中选择 sqlite 数据库存储路径和一个需要扫描的文件夹的路径,在选定后调用 Rust 中实现的文件夹遍历、数据库创建及写入操作将待扫描文件夹(包含子文件夹)中的文件列表保存到 sqlite 数据库中,然后通过关键词匹配从 sqlite 数据库中筛选文件;
  2. 页面二:允许用户输入一个网址,输入后调用 Rust 中实现的网络访问方法并将网址内容在应用中展示。

本文中不会对演示项目的代码进行说明,如果有需要可以在这里查看具体代码。

使用 uniffi-rs 共享 Rust 代码库

在后面的篇幅中将介绍如何使用 uniffi-rs 共享 Rust 代码。

注意:成文时 uniffi-rs 的版本为 0.25.3,如果版本更新下文中的代码可能无法正常使用。

初始化 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 个依赖:

  1. 用于生成共享库的 uniffi(需要启用 buildcli 功能);
  2. 用于操作 sqlite 数据库的 rusqlite(需要启用 bundled 功能为各个平台编译各自的 sqlite 库);
  3. 用于发送网络请求的 ureq
  4. 用于生成绑定文件的 uniffi(此为 build 依赖)。

依赖添加完成后需要修改 Cargo.toml 文件中的 crate-type 以指定编译结果的类型:

[lib]
crate-type = ["lib", "cdylib", "staticlib"] # 生成库文件、C 兼容的动态库文件和静态库

定义绑定代码接口

在开始编写需要共享的代码之前,需要了解在 uniffi-rs 中如何定义生成的绑定代码的接口。

uniffi-rs 中支持使用 UDL(UniFFI Definition Language) 和过程宏 Procedural Macros 定义接口:

  1. UDL(UniFFI Definition Language) 文件定义了需要向外部语言公开哪些函数、方法和类型;
  2. 使用过程宏 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)
}

在项目中添加 sqlite.rs 文件,在这个文件中实现 sqlite 数据库的创建、插入和检索:

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()
}

在项目中添加 request.rs 文件,在这个文件中使用 ureq 实现 HTTP GET 方法:

/// 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())
}

最后在 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")
}

生成共享库

因为希望共享库能够在 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()
}

然后在 Cargo.toml 文件中添加下面的内容:

[[bin]]
name = "uniffi-bindgen"
path = "uniffi-bindgen.rs"

完成上述操作后即可使用下面的指令调用 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 文件夹中看到为 iOS 和 macOS 生成的绑定文件。

使用 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

上述代码将创建共享库和生成绑定文件进行了整合。然后创建一个 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

该脚本会使用 xcodebuild 工具创建一个 xcframework 包。

在 XCode 中打开项目,按照下图所示将 xcframework 添加到目标中:

在 XCode 中点击左侧导航栏的项目名称,选择 Target,然后选择 General Tab,找到 Frameworks, Libraries, and Embedded Content,点击 +,选择 Add Other/Add Files,选择刚才创建的 xcframework

接着从 finder 中将 uniff-example/shared_lib/out/aarch64-apple-ios-sim/shared_lib.swift 拖拽到 XCode 项目文件中(在 out 文件夹中选择任意一个 shared_lib.swift 文件都是可以的),完成后即可在 Swift`` 代码中调用在 shared_lib` 中编写的方法和类型。

注意:在 Rust 中的方法会使用 snake_case 命名,而在生成的绑定文件中会转换为 camelCase。

运行效果如下图所示:

在 iOS 和 macOS 应用中使用 Rust 代码的示例

注意:需要在 <app-name>.entitlements 文件中将 App Sandbox 设置为 NO 才能通过 Rust 代码执行写入本地文件的操作。

补充内容

  • 在使用 uniffi-rs 的过程中有很多内容参考自《Calling Rust code from C#》《Calling Rust code from Swift on iOS and macOS》这两篇文章,如果有使用 uniffi-rsC# 中共享代码的需求的话的可以读一读。
  • 网络请求部分的 Rust 代码一开始是使用 reqwest 实现的,编译出来的库在 iOS 平台上可以正常使用,但是在 macOS 平台上却会编译失败。验证了不是 Async 的问题,但是尝试了很久也没找到原因,最后只好换成 ureq 来实现网络请求。如果有跨平台的需求,在正式使用前最好通过一些用例来确认在各个平台上都是可用的。
  • 现阶段如果要在生产环境中使用 uniffi-rs 需要慎重一些,毕竟官方也在文档中提到了在后续版本中并不能确保后向兼容。