2 移动端插件开发

原文链接: https://tauri.app/develop/plugins/develop-mobile/

插件可以运行用 Kotlin(或 Java)和 Swift 编写的原生移动端代码。默认插件模板包含一个使用 Kotlin 的 Android 库项目和一个 Swift 包,其中还包含一个示例移动端命令,演示如何从 Rust 代码触发它的执行。

初始化插件项目

按照插件开发指南中的步骤初始化一个新的插件项目。

如果你已有插件并想为它添加 Android 或 iOS 能力,可以使用 plugin android init 和 plugin ios init 来引导生成移动端库项目,并指导你完成所需的改动。

默认插件模板把插件实现拆分为两个独立模块:desktop.rs 和 mobile.rs。

桌面端实现用 Rust 代码实现功能,而移动端实现则向原生移动端代码发送消息,执行某个函数并取回结果。如果两个实现之间需要共享逻辑,可以定义在 lib.rs 中:

1
2
3
4
5
6

impl<R: Runtime> <plugin-name><R> {
  pub fn do_something(&self) {
    // 做一件在桌面端和移动端之间共享的事情
  }
}

这种实现方式简化了共享 API 的过程,使其既能被命令使用,也能被 Rust 代码使用。

开发 Android 插件

Android 的 Tauri 插件被定义为继承 app.tauri.plugin.Plugin 并用 app.tauri.annotation.TauriPlugin 注解的 Kotlin 类。每个用 app.tauri.annotation.Command 注解的方法都可以被 Rust 或 JavaScript 调用。

Tauri 默认使用 Kotlin 实现 Android 插件,但如果你偏好 Java 也可以切换。生成插件后,在 Android Studio 中右键该 Kotlin 插件类,从菜单中选择 “Convert Kotlin file to Java file” 选项。Android Studio 会引导你完成向 Java 的项目迁移。

开发 iOS 插件

iOS 的 Tauri 插件被定义为继承 Tauri 包中 Plugin 类的 Swift 类。每个带有 @objc 属性和 (_ invoke: Invoke) 参数的函数(例如 @objc private func download(_ invoke: Invoke) { })都可以被 Rust 或 JavaScript 调用。

插件被定义为一个 Swift 包,因此你可以用它的包管理器管理依赖。

插件配置

关于开发插件配置的更多细节,请参阅插件开发指南的插件配置一节。

移动端的插件实例提供了一个获取插件配置的 getter:

移动端操作系统

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import android.webkit.WebView
import app.tauri.annotation.TauriPlugin
import app.tauri.annotation.InvokeArg

@InvokeArg
class Config {
    var timeout: Int? = 3000
}

@TauriPlugin
class ExamplePlugin(private val activity: Activity): Plugin(activity) {
  private var timeout: Int? = 3000

  override fun load(webView: WebView) {
    getConfig(Config::class.java).let {
       this.timeout = it.timeout
    }
  }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
struct Config: Decodable {
  let timeout: Int?
}

class ExamplePlugin: Plugin {
  var timeout: Int? = 3000

  @objc public override func load(webview: WKWebView) {
    do {
      let config = try parseConfig(Config.self)
      self.timeout = config.timeout
    } catch {}
  }
}

生命周期事件

插件可以挂接到若干生命周期事件:

  • load:插件被加载进 web view 时
  • onNewIntent:仅 Android,activity 被重新启动时

插件开发指南中还有额外的插件生命周期事件。

load

  • 时机:插件被加载进 web view 时
  • 用途:执行插件初始化代码

移动端操作系统

1
2
3
4
5
6
7
8
9
import android.webkit.WebView
import app.tauri.annotation.TauriPlugin

@TauriPlugin
class ExamplePlugin(private val activity: Activity): Plugin(activity) {
  override fun load(webView: WebView) {
    // 在这里执行插件设置
  }
}
1
2
3
4
5
class ExamplePlugin: Plugin {
  @objc public override func load(webview: WKWebView) {
    let timeout = self.config["timeout"] as? Int ?? 30
  }
}

onNewIntent

注意:仅在 Android 上可用。

  • 时机:activity 被重新启动时。更多信息见 Activity#onNewIntent。
  • 用途:处理应用被重新启动的情况,例如点击通知或访问深链接时。
1
2
3
4
5
6
7
8
9
import android.content.Intent
import app.tauri.annotation.TauriPlugin

@TauriPlugin
class ExamplePlugin(private val activity: Activity): Plugin(activity) {
  override fun onNewIntent(intent: Intent) {
    // 处理新的 intent 事件
  }
}

添加移动端命令

在各自的移动端项目中都有一个插件类,可以在其中定义可由 Rust 代码调用的命令:

移动端操作系统

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import app.tauri.annotation.Command
import app.tauri.annotation.TauriPlugin

@TauriPlugin
class ExamplePlugin(private val activity: Activity): Plugin(activity) {
  @Command
  fun openCamera(invoke: Invoke) {
    val ret = JSObject()
    ret.put("path", "/path/to/photo.jpg")
    invoke.resolve(ret)
  }
}

如果你想使用 Kotlin 的 suspend 函数,需要使用自定义的 coroutine scope:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import app.tauri.annotation.Command
import app.tauri.annotation.TauriPlugin

// 如果目的是获取数据,请改为 Dispatchers.IO
val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())

@TauriPlugin
class ExamplePlugin(private val activity: Activity): Plugin(activity) {
  @Command
  fun openCamera(invoke: Invoke) {
    scope.launch {
      openCameraInner(invoke)
    }
  }

  private suspend fun openCameraInner(invoke: Invoke) {
    val ret = JSObject()
    ret.put("path", "/path/to/photo.jpg")
    invoke.resolve(ret)
  }
}
1
2
3
4
5
class ExamplePlugin: Plugin {
	@objc public func openCamera(_ invoke: Invoke) throws {
    invoke.resolve(["path": "/path/to/photo.jpg"])
	}
}
使用 tauri::plugin::PluginHandle 从 Rust 调用移动端命令:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
use serde::{Deserialize, Serialize};
use tauri::Runtime;

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CameraRequest {
  quality: usize,
  allow_edit: bool,
}

#[derive(Deserialize)]
pub struct Photo {
  path: PathBuf,
}


impl<R: Runtime> <plugin-name;pascal-case><R> {
  pub fn open_camera(&self, payload: CameraRequest) -> crate::Result<Photo> {
    self
      .0
      .run_mobile_plugin("openCamera", payload)
      .map_err(Into::into)
  }
}

命令参数

参数会被序列化传给命令,可以在移动端插件中用 Invoke::parseArgs 函数解析,它接收一个描述参数对象的类。

Android

在 Android 上,参数被定义为用 @app.tauri.annotation.InvokeArg 注解的类。内部对象也必须加注解:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import android.webkit.WebView
import app.tauri.annotation.Command
import app.tauri.annotation.InvokeArg
import app.tauri.annotation.TauriPlugin

@InvokeArg
internal class OpenAppArgs {
  lateinit var name: String
  var timeout: Int? = null
}

@InvokeArg
internal class OpenArgs {
  lateinit var requiredArg: String
  var allowEdit: Boolean = false
  var quality: Int = 100
  var app: OpenAppArgs? = null
}

@TauriPlugin
class ExamplePlugin(private val activity: Activity): Plugin(activity) {
  @Command
  fun openCamera(invoke: Invoke) {
    val args = invoke.parseArgs(OpenArgs::class.java)
  }
}

iOS

在 iOS 上,参数被定义为继承 Decodable 的类。内部对象也必须继承 Decodable 协议:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
class OpenAppArgs: Decodable {
  let name: String
  var timeout: Int?
}

class OpenArgs: Decodable {
  let requiredArg: String
  var allowEdit: Bool?
  var quality: UInt8?
  var app: OpenAppArgs?
}

class ExamplePlugin: Plugin {
	@objc public func openCamera(_ invoke: Invoke) throws {
    let args = try invoke.parseArgs(OpenArgs.self)

    invoke.resolve(["path": "/path/to/photo.jpg"])
	}
}

从移动端插件调用 Rust

出于性能和可复用性的考虑,把插件代码写在 Rust 中往往更可取。虽然 Tauri 不直接提供从插件代码调用 Rust 的机制,但在 Android 上使用 JNI、在 iOS 上使用 FFI 可以让插件调用共享代码,即使应用 WebView 处于挂起状态也可以。

Android

在插件的 Cargo.toml 中把 jni crate 添加为依赖:

1
jni = "0.21"

静态加载应用库,并在 Kotlin 代码中定义 native 函数。在本例中,Kotlin 类是 com.example.HelloWorld,我们需要从 Rust 侧引用完整的包名。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14

init {
  try {
    // 加载原生库(libapp_lib.so)
    // 这是 Cargo 以 crate-type = ["cdylib"] 构建出的共享库
    System.loadLibrary("app_lib")
    Log.d(TAG, "Successfully loaded libapp_lib.so")
  } catch (e: UnsatisfiedLinkError) {
    Log.e(TAG, "Failed to load libapp_lib.so", e)
    throw e
  }
}

external fun helloWorld(name: String): String?

然后在插件的 Rust 代码中定义 JNI 要查找的函数。函数格式为 Java_package_class_method,因此对于上面这个类,它会变成 Java_com_example_HelloWorld_helloWorld,从而被我们的 helloWorld 方法调用:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
#[no_mangle]
pub extern "system" fn Java_com_example_HelloWorld_helloWorld(
    mut env: JNIEnv,
    _class: JClass,
    name: JString,
) -> jstring {
    log::debug!("Calling JNI Hello World!");
    let result = format!("Hello, {}!", name);

    match env.new_string(result) {
        Ok(jstr) => jstr.into_raw(),
        Err(e) => {
            log::error!("Failed to create JString: {}", e);
            std::ptr::null_mut()
        }
    }
}

iOS

iOS 只使用标准的 C FFI,因此不需要任何新依赖。在你的 Swift 代码中加入钩子以及必要的清理。这些函数可以取任何合法名称,但必须用 @_silgen_name(FFI_FUNC) 注解,其中 FFI_FUNC 是要从 Rust 调用的函数名:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
private static func helloWorldFFI(_ name: UnsafePointer<CChar>) -> UnsafeMutablePointer<CChar>?

@_silgen_name("free_hello_result_ffi")
private static func freeHelloResult(_ result: UnsafeMutablePointer<CChar>)

static func helloWorld(name: String) -> String? {
  // 调用 Rust FFI
  let resultPtr = name.withCString({ helloWorldFFI($0) })

  // 把 C 字符串转换为 Swift String
  let result = String(cString: resultPtr)

  // 释放 C 字符串
  freeHelloResult(resultPtr)

  return result
}

然后实现 Rust 侧。这里的 extern 函数必须与 Swift 侧的 @_silgen_name 注解一致:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
pub unsafe extern "C" fn hello_world_ffi(c_name: *const c_char) -> *mut c_char {
    let name = match CStr::from_ptr(c_name).to_str() {
        Ok(s) => s,
        Err(e) => {
            log::error!("[iOS FFI] Failed to convert C string: {}", e);
            return std::ptr::null_mut();
        }
    };

    let result = format!("Hello, {}!", name);

    match CString::new(result) {
        Ok(c_str) => c_str.into_raw(),
        Err(e) => {
            log::error!("[iOS FFI] Failed to create C string: {}", e);
            std::ptr::null_mut()
        }
    }
}

#[no_mangle]
pub unsafe extern "C" fn free_hello_result_ffi(result: *mut c_char) {
    if !result.is_null() {
        drop(CString::from_raw(result));
    }
}

Android 16KB 内存页

Google 正在推动把所有新提交的 Android 应用都要求使用 16KB 内存页。使用 NDK 28 或更高版本构建应当会自动生成满足该要求的打包产物,但如果必须使用更旧的 NDK 版本,或者生成的文件没有按 16KB 对齐,可以在 .cargo/config.toml 中加入以下内容来向 rustc 标明这一点:

1
rustflags = ["-C", "link-arg=-Wl,-z,max-page-size=16384"]

权限

如果插件需要终端用户授予权限,Tauri 简化了检查与请求权限的过程。

移动端操作系统

首先定义所需权限的列表,以及用于在代码中标识每个组的别名。这在 TauriPlugin 注解中完成:

1
2
3
4
5
  permissions = [
    Permission(strings = [Manifest.permission.POST_NOTIFICATIONS], alias = "postNotification")
  ]
)
class ExamplePlugin(private val activity: Activity): Plugin(activity) { }

首先重写 checkPermissions 和 requestPermissions 函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class ExamplePlugin: Plugin {
  @objc open func checkPermissions(_ invoke: Invoke) {
    invoke.resolve(["postNotification": "prompt"])
  }

  @objc public override func requestPermissions(_ invoke: Invoke) {
    // 在这里请求权限
    // 然后兑现该请求
    invoke.resolve(["postNotification": "granted"])
  }
}
Tauri 会自动为该插件实现两个命令:checkPermissions 和 requestPermissions。 这些命令可以直接从 JavaScript 或 Rust 调用:

语言

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import { invoke, PermissionState } from '@tauri-apps/api/core'

interface Permissions {
  postNotification: PermissionState
}

// 检查权限状态
const permission = await invoke<Permissions>('plugin:<plugin-name>|checkPermissions')

if (permission.postNotification === 'prompt-with-rationale') {
  // 向用户说明为什么需要该权限
}

// 请求权限
if (permission.postNotification.startsWith('prompt')) {
  const state = await invoke<Permissions>('plugin:<plugin-name>|requestPermissions', { permissions: ['postNotification'] })
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
use serde::{Serialize, Deserialize};
use tauri::{plugin::PermissionState, Runtime};

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct PermissionResponse {
  pub post_notification: PermissionState,
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct RequestPermission {
  post_notification: bool,
}

impl<R: Runtime> Notification<R> {
  pub fn request_post_notification_permission(&self) -> crate::Result<PermissionState> {
    self.0
      .run_mobile_plugin::<PermissionResponse>("requestPermissions", RequestPermission { post_notification: true })
      .map(|r| r.post_notification)
      .map_err(Into::into)
  }

  pub fn check_permissions(&self) -> crate::Result<PermissionResponse> {
    self.0
      .run_mobile_plugin::<PermissionResponse>("checkPermissions", ())
      .map_err(Into::into)
  }
}

插件事件

插件可以在任何时候使用 trigger 函数发出事件:

移动端操作系统

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
class ExamplePlugin(private val activity: Activity): Plugin(activity) {
    override fun load(webView: WebView) {
      trigger("load", JSObject())
    }

    override fun onNewIntent(intent: Intent) {
      // 处理新的 intent 事件
      if (intent.action == Intent.ACTION_VIEW) {
        val data = intent.data.toString()
        val event = JSObject()
        event.put("data", data)
        trigger("newIntent", event)
      }
    }

    @Command
    fun openCamera(invoke: Invoke) {
      val payload = JSObject()
      payload.put("open", true)
      trigger("camera", payload)
    }
}
1
2
3
4
5
6
7
8
9
class ExamplePlugin: Plugin {
  @objc public override func load(webview: WKWebView) {
    trigger("load", data: [:])
  }

  @objc public func openCamera(_ invoke: Invoke) {
    trigger("camera", data: ["open": true])
  }
}
之后可以在 NPM 包中使用 addPluginListener 辅助函数调用这些辅助函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import { addPluginListener, PluginListener } from '@tauri-apps/api/core';

export async function onRequest(
	handler: (url: string) => void
): Promise<PluginListener> {
	return await addPluginListener(
		'<plugin-name>',
		'event-name',
		handler
	);
}
最后修改 September 25, 2026: 更新 (4c0ee2db0)