首页
/ Dioxus 原生插件实战:用 manganis::ffi 构建跨 Android/iOS 的 Geolocation 定位插件

Dioxus 原生插件实战:用 manganis::ffi 构建跨 Android/iOS 的 Geolocation 定位插件

2026-09-05 22:21:57作者:昌雅子Ethen

本文以 Dioxus 官方示例 geolocation-native-plugin 为主体,完整拆解一个最小化原生插件应用的实现:如何通过 #[manganis::ffi] 宏自动生成本地 FFI 绑定,在 Android(Kotlin/JNI)与 iOS(Swift/ObjC)两端实现位置权限检查、权限请求弹窗与一次性定位(one-shot position)能力,并在 Web/桌面平台上通过 fallback 桩优雅降级。读完本文,你将掌握 Dioxus 原生插件的完整工程结构、Dioxus.toml 关键配置、Rust 侧 API 设计以及 dx 打包管线如何处理原生 Gradle/Swift 产物。

示例定位与能力概览

该示例位于 geolocation-native-plugin,是一个实现了原生插件的最小化 Dioxus 应用。按其 README 描述,插件提供三组能力:

  • 使用 Android/iOS 原生权限对话框检查并申请位置权限;
  • 配置一次性定位请求(高精度开关 enable_high_accuracy + 最大缓存年龄 maximum_age);
  • 检查最近一次上报的坐标、精度、海拔、航向与速度。

README 特别指出:该示例与任何插件 crate 共享同一套元数据管线——原生 Gradle/Swift 产物通过链接器符号(linker symbols)嵌入,并由 dx 自动打包进最终应用。这一点在 Native Plugin System 设计文档 中有系统性阐述。

运行示例

README 给出的启动方式(在仓库根目录执行):

dx serve --project examples/01-app-demos/geolocation --platform mobile

需要注意:仓库中示例的实际目录名是 geolocation-native-plugin,执行时请将该路径替换为真实目录名(README 中的 geolocation 路径与仓库实际目录不一致)。

运行前提与限制(来自 README 的明确说明):

  • Android/iOS 平台需要安装对应工具链(Android SDK/NDK、Xcode),原生模块才能在构建阶段编译;
  • UI 在桌面/Web 上同样可以运行,但由于插件只支持移动端目标,所有定位调用会返回错误,这些错误会内联展示在界面上——这也是该示例故意保留的行为,用于演示错误路径。

Cargo 侧的平台切换由 feature 控制,见 Cargo.toml

[features]
default = ["mobile"]
web = ["dioxus/web"]
desktop = ["dioxus/desktop"]
mobile = ["dioxus/mobile"]

工程结构与文件布局

整个示例由「Rust 应用 + 插件 Rust 层 + Android 源码 + iOS 源码」四部分组成:

examples/01-app-demos/geolocation-native-plugin/
├── Cargo.toml              # 依赖 dioxus/manganis/serde/thiserror,平台 features
├── Dioxus.toml             # bundle 元数据、iOS/Android 配置、权限声明
└── src/
    ├── main.rs             # UI 入口:权限卡片、定位卡片、Live Activity 卡片
    ├── plugin/
    │   ├── mod.rs          # Geolocation 结构体 + #[manganis::ffi] FFI 声明
    │   ├── models.rs       # 跨 FFI 的 serde 数据模型(JSON 序列化)
    │   └── error.rs        # 统一错误枚举
    ├── android/src/main/kotlin/com/dioxus/geolocation/
    │   ├── GeolocationPlugin.kt   # Kotlin 插件入口,桥接 JSON 同步调用
    │   └── Geolocation.kt         # 基于 FusedLocationProviderClient 的定位实现
    └── ios/plugin/Sources/
        ├── GeolocationPlugin.swift   # Swift 插件,CLLocationManager + Live Activity
        └── LocationActivityAttributes.swift

Dioxus.toml:插件应用的清单配置

Dioxus.toml 展示了插件应用需要声明的完整清单(文件头指向 CLI schema packages/cli/schema.json):

[bundle]
identifier = "com.dioxuslabs.geolocation"
publisher = "Dioxus Labs"

[ios]
deployment_target = "16.2"
background_modes = ["location"]

[ios.plist]
NSSupportsLiveActivities = true

[[ios.widget_extensions]]
source = "src/ios/widget"
display_name = "Location Widget"
bundle_id_suffix = "location-widget"
deployment_target = "16.2"
module_name = "GeolocationPlugin"

[android]
min_sdk = 24
target_sdk = 34
features = ["android.hardware.location.gps"]

[permissions]
location = { precision = "fine", description = "Access your precise location to provide location-based services" }

各配置项的作用:

  • [bundle]:应用标识与发布者,生成各平台 bundle id;
  • [ios] deployment_target = "16.2"background_modes = ["location"]:定位能力要求 iOS 16.2 起可用后台定位模式;
  • [ios.plist] NSSupportsLiveActivities:启用锁屏 Live Activity 能力(对应 Rust 侧 start_live_activity 等方法);
  • [[ios.widget_extensions]]:声明一个独立的 Widget Extension 源码目录 src/ios/widget,用于承载 Live Activity 的 UI(WidgetKit 要求 Activity UI 必须放在 Extension 中);
  • [android]:最低 SDK 24、目标 SDK 34,并声明 android.hardware.location.gps 硬件特性;
  • [permissions] location:统一权限清单。plugin/mod.rs 的注释说明了映射规则:CLI 会把 location = { precision = "fine" } 自动映射为 Android 的 ACCESS_FINE_LOCATION(写入 AndroidManifest.xml)与 iOS 的 NSLocationWhenInUseUsageDescription(写入 Info.plist),description 即系统弹窗中的用途说明文案。

Rust 侧插件 API:Geolocation

数据模型(JSON 序列化契约)

跨 FFI 边界的所有数据都通过 serde 序列化为 JSON 字符串传递,模型定义在 models.rs

类型 说明
PermissionState Prompt(默认值)、PromptWithRationale(Android 12+)、GrantedDenied,序列化用 kebab-case
PermissionStatus locationcoarse_location 两个状态。Android 上分别对应 ACCESS_FINE_LOCATION / ACCESS_COARSE_LOCATION;iOS 上两者同值
PositionOptions enable_high_accuracy: bool(Android 12+ 未授予细粒度权限时被忽略)、timeout: u32(毫秒,默认 10000;Android 的 getCurrentPosition 会忽略该项,iOS 也忽略)、maximum_age: u32(毫秒,默认 0,iOS 忽略)
Coordinates / Position 十进制度坐标、精度(米)、可选的海拔/海拔精度/速度/航向,timestamp 为 Unix 纪元毫秒
LiveActivityResult / LiveActivityUpdate #[cfg(target_os = "ios")] 编译,含 activity_id 与坐标精度

FFI 声明:#[manganis::ffi] 宏

plugin/mod.rs 是插件的核心,两个 extern 块按目标平台条件编译:

// iOS/macOS:宏自动生成全部 ObjC FFI 代码,路径指向 SwiftPM 包
#[cfg(any(target_os = "ios", target_os = "macos"))]
#[manganis::ffi("src/ios/plugin")]
unsafe extern "Swift" {
    pub type GeolocationPlugin;
    pub fn getCurrentPositionJson(this: &GeolocationPlugin, optionsJson: String) -> String;
    pub fn checkPermissionsJson(this: &GeolocationPlugin) -> String;
    pub fn requestPermissionsJson(this: &GeolocationPlugin, permissionsJson: String) -> String;
    pub fn startLiveActivityJson(this: &GeolocationPlugin) -> String;
    // ...
}

// Android:宏自动生成 JNI 代码,路径指向 Gradle 工程
#[cfg(target_os = "android")]
#[manganis::ffi("src/android")]
unsafe extern "Kotlin" {
    pub type GeolocationPlugin;
    pub fn getCurrentPositionJson(this: &GeolocationPlugin, optionsJson: String) -> String;
    // ...
}

宏的三条职责在设计文档 11-NATIVE-PLUGIN-FFI.md 中列明:① 输出一类新的资产(源码目录);② 生成对应的 FFI 函数(运行时通过 JNI/ObjC 查找调用,而非静态链接);③ 打包器随后提取元数据并编译源码。该方案相对传统 build.rs 的优势在于:不绑定 Cargo、对 IDE 零干扰、缓存由 bundler 统一管理、且原生编译发生在 rustc 之后。

Geolocation 结构体本身是一个薄封装:懒初始化原生 GeolocationPlugin 实例,然后把 Rust 侧选项序列化为 JSON、调用 FFI 函数、解析返回的 JSON(若 JSON 中带有 error 字段则转换为 Error::LocationUnavailable)。对外暴露的方法即 UI 所需的全部 API:check_permissions()request_permissions(Option<Vec<PermissionType>>)get_current_position(Option<PositionOptions>),以及仅 iOS 可用的 start_live_activity() / update_live_activity() / end_live_activity()

非原生平台 fallback

mod.rs 末尾 定义了 Web/桌面等目标的 fallback 模块:GeolocationPlugin::new() 与所有 *Json 函数统一返回 Error::PlatformBridge("Geolocation is only supported on Android, iOS, and macOS")。这样 UI 层代码无需任何条件编译即可在桌面/Web 上运行,只是每次调用都会把错误消息展示在界面的错误横幅里。错误模型定义在 error.rs,包括 JsonPlatformBridgeLocationUnavailable 与 iOS 专属的 LiveActivity 四类,且实现 Serialize 便于直接回传前端。

UI 层:main.rs 的信号驱动集成

main.rs 展示了插件与 Dioxus 信号系统的组合方式。App 组件用一组 use_signal 维护状态:geolocation(插件实例)、permission_statuslast_positionerroruse_high_accuracy(默认开启)与 max_age_input(默认 "0")。

关键事件处理模式:

let on_fetch_position = move |_| {
    let maximum_age = max_age_input.read().trim().parse::<u32>().unwrap_or(0);
    let options = PositionOptions {
        enable_high_accuracy: use_high_accuracy(),
        timeout: 10_000,
        maximum_age,
    };
    match geolocation.write().get_current_position(Some(options)) {
        Ok(position) => { last_position.set(Some(position)); error.set(None); }
        Err(err) => error.set(Some(err.to_string())),
    }
};

注意 timeout 固定为 10 秒(与 PositionOptions 的默认值一致),maximum_age 由界面输入框解析而来,解析失败回退为 0(要求实时定位)。界面由三张卡片构成:

  • Permissions:Check / Request 两个按钮,点击后用 PermissionBadge 展示 locationcoarse_location 的状态(Granted/Denied/Needs prompt);
  • Current position:高精度开关按钮 + “Max cached age (ms)” 数字输入 + “Get current position” 按钮,成功后以 CoordinateRow 网格展示经纬度(6 位小数)、精度、海拔、海拔精度、速度、航向与时间戳;
  • Live Activity:仅 #[cfg(target_os = "ios")] 编译的卡片,提供 Start / Update / End 三个按钮;非 iOS 平台该组件编译为空 VNode::empty(),UI 代码因此保持跨平台可编译。

Android 端实现

Kotlin 侧由两个类组成。Geolocation.kt 负责真正的定位:

  • sendLocation:先检查 Google Play Services 可用性与系统定位开关,再根据 enable_high_accuracy 选择 FusedLocationProviderClient 的优先级——高精度用 PRIORITY_HIGH_ACCURACY,否则在网络 provider 可用时用 PRIORITY_BALANCED_POWER_ACCURACY,否则 PRIORITY_LOW_POWER
  • getLastLocation(maximumAge):遍历所有 provider 的 getLastKnownLocation,以纳秒级 elapsedRealtimeNanos 比较缓存年龄,满足 maximumAge 约束时直接返回缓存点,避免一次新的卫星/网络定位往返。

GeolocationPlugin.kt 是 Rust JNI 层调用的桥接入口:

  • checkPermissionsJson():用 ContextCompat.checkSelfPermission 分别检查细/粗粒度权限,映射为 granted|denied|prompt 三态 JSON;
  • requestPermissionsJson():通过 ActivityCompat.requestPermissions 触发系统授权对话框(request code 1001),用 CountDownLatch 最多等待 5 秒后返回最新状态;
  • getCurrentPositionJson(optionsJson):解析 enableHighAccuracy/timeout/maximumAge(默认 10000ms/0ms),先尝试 getLastLocation 命中缓存,未命中则发起 FusedLocation 请求,并用 Timer 实现超时——等待 timeout + 2000ms 后若仍未取到结果,返回 {"error":"Timeout waiting for location."}
  • locationToPositionJson():把 Location 转为与 Rust Position 模型 camelCase 对齐的 JSON(含 Android 8+ 的 verticalAccuracyMeters)。

iOS 端实现

Swift 侧 GeolocationPlugin.swift 是一个 NSObject + CLLocationManagerDelegate

  • getCurrentPositionJson(_:):若 CLLocationManager.location 缓存点满足 maximumAge 则立即返回;否则设置 desiredAccuracy(高精度 kCLLocationAccuracyBest / 低精度 kCLLocationAccuracyKilometer),状态未定时先 requestWhenInUseAuthorization(),然后 requestLocation() 发起单次请求,通过信号量 + RunLoop 轮询在超时窗口内同步等待回调;超时返回 {"error":"Timeout waiting for location"}
  • checkPermissionsJson():把 CLAuthorizationStatus 映射为 prompt(notDetermined)/denied(restricted、denied)/granted(authorizedAlways、authorizedWhenInUse),coarseLocationlocation 同值;若系统定位总开关关闭则直接返回错误 JSON;
  • Live Activity 三个方法基于 ActivityKit(Activity<LocationPermissionAttributes>),要求 iOS 16.2+ 且 areActivitiesEnabledstartLiveActivityJson 返回 activity ID 与当前坐标,endLiveActivityJson.immediate 策略结束所有活动。

打包管线:linker 元数据如何进入 dx

README 提到该示例“shares the same metadata pipeline as any plugin crate”。结合 设计文档 可以理解为:#[manganis::ffi] 宏在编译期把源码目录信息以链接器符号形式嵌入二进制,dx 在 rustc 之后的 bundler 阶段提取这些 PluginMeta 条目,然后分别走 Swift(swiftc)或 Kotlin/Gradle 编译管线,把产物链接进最终应用包;对源文件做哈希缓存,未变更则跳过编译。从源码结构看,本示例目录中没有 build.rs,原生编译完全由这条 bundler 管线承担——这也是该设计文档选择“linker metadata”而非 build.rs 的核心动机(不拖慢 rust-analyzer、缓存可控、与构建系统解耦)。

实验清单(Things to try)

按 README 建议的操作顺序验证插件行为:

  1. 点击 Check permissions 查看 OS 当前授权状态(granted/denied/prompt);
  2. 点击 Request permissions 从应用内触发原生授权对话框;
  3. 切换 High accuracy 开关、设置 Max cached age 后再请求当前定位;
  4. 观察每次新读数到达时坐标网格的更新,或操作失败时的错误横幅(权限被拒、运行在不支持的平台等)。

在桌面/Web 上运行第 4 步时,错误横幅会显示 fallback 的 PlatformBridge 消息,这正好验证了跨平台降级路径的行为符合 README 的预期。

参考路径索引

登录后查看全文
热门项目推荐
相关项目推荐