参数、返回值与 InvokeResolver
主线位置:调用主线上
#[command]包装代码的里面和后面。generate_handler!的闭包把Invoke交给某个命令的包装代码(宏展开见 #[command] 与 generate_handler!),包装代码对每个参数调CommandArg::from_command,调用你的函数,再把返回值交给InvokeResolver;resolver 调ipc/protocol.rs里的应答闭包,编成 HTTP 响应(编码见 ipc 协议)。本页讲中间这三段:取参数、编返回值和错误、在哪个线程上回应。
flowchart TB
msg["InvokeMessage 的 payload"] --> kind{"Json 还是 Raw"}
kind -->|Json| item["CommandItem 当 Deserializer"]
kind -->|Raw| reqarg["ipc::Request 取原始字节"]
item -->|"按 key 取值"| serde["serde_json::Value 反序列化"]
serde --> userfn["你的函数"]
reqarg --> userfn
userfn --> ret{"返回值"}
ret -->|"T: Serialize"| jsonb["InvokeResponseBody::Json"]
ret -->|"ipc::Response"| rawb["InvokeResponseBody::Raw"]
ret -->|"Err E: Serialize"| inverr["InvokeError"]
jsonb --> resolver["InvokeResolver::respond"]
rawb --> resolver
inverr --> resolver
本页的实验 crate 有两个,都从模板 base 复制,tauri =2.12.5、--features tauri/custom-protocol,平台 M2 Max、macOS 27、rustc 1.99.0:
ipc2-mock:一组命令,用tauri::test的MockRuntime和get_ipc_response逐个喂请求体,看反序列化和错误的结果,不建真窗口。ipc2-app:隐藏窗口的真实应用,withGlobalTauri打开,页面用环境变量MODE选一组测试,结果经report命令打印。JS 侧收到什么、同步命令卡住什么,都在这里测。
CommandItem:一个只认一个键的 Deserializer
普通参数(实现了 Deserialize 的类型)走 CommandArg 的通用实现:
impl<'de, D: Deserialize<'de>, R: Runtime> CommandArg<'de, R> for D {
fn from_command(command: CommandItem<'de, R>) -> Result<D, InvokeError> {
let name = command.name;
let arg = command.key;
#[cfg(feature = "tracing")]
let _span = tracing::trace_span!("ipc::request::deserialize_arg", arg = arg).entered();
Self::deserialize(command).map_err(|e| crate::Error::InvalidArgs(name, arg, e).into())
}
}
D::deserialize 的参数不是 JSON 文本,而是 CommandItem 本身:它实现了 serde 的 Deserializer,错误类型就是 serde_json::Error。几乎每个 deserialize_* 方法都由宏 pass! 生成,同一个模式:先 deserialize_json 取出请求体里 key 对应的那个 serde_json::Value,再把调用原样转给这个 Value 的 Deserializer 实现:
fn deserialize_json(self) -> serde_json::Result<&'a serde_json::Value> {
if self.key.is_empty() {
return Err(serde_json::Error::custom(format!(
"command {} has an argument with no name with a non-optional value",
self.name
)));
}
match &self.message.payload {
InvokeBody::Raw(_body) => Err(serde_json::Error::custom(format!(
"command {} expected a value for key {} but the IPC call used a bytes payload",
self.name, self.key
))),
InvokeBody::Json(v) => match v.get(self.key) {
Some(value) => Ok(value),
None => Err(serde_json::Error::custom(format!(
"command {} missing required key {}",
self.name, self.key
))),
},
}
}
只有 deserialize_option 是手写的:键不存在时 visitor.visit_none(),存在时交给 Value::deserialize_option。Option<T> 的 Deserialize 正是调 deserialize_option,所以可选参数可以干脆不传。
Value 作为 Deserializer 怎么把数字、字符串、对象交给 visitor,是 serde 卷 Value 讲的东西;&str 参数为什么能借用请求体,见 生命周期 'de 与借用。tauri 自己只做了两件事:按 key 取值,和把错误包成 crate::Error::InvalidArgs,它的 Display 是 invalid args `{1}` for command `{0}`: {2}。
ipc2-mock 里的几个命令:
#[derive(Debug, Deserialize)]
struct Query {
page_size: u32,
tag: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct QueryCamel {
page_size: u32,
}
#[tauri::command]
fn args(user_id: u32, note: Option<String>) -> String {
format!("user_id={user_id} note={note:?}")
}
#[tauri::command(rename_all = "snake_case")]
fn args_snake(user_id: u32) -> u32 {
user_id
}
#[tauri::command]
fn nested(query: Query) -> String {
format!("{query:?}")
}
#[tauri::command]
fn nested_camel(query: QueryCamel) -> u32 {
query.page_size
}
#[tauri::command]
fn small(n: u8) -> u8 {
n
}
#[tauri::command]
fn opt_only(note: Option<String>) -> String {
format!("{note:?}")
}
#[tauri::command]
fn unnamed(_: u32) {}
逐个用不同的请求体调用(cargo test -- --nocapture,Ok(Json ..) 是成功时响应体的 JSON 文本,Err(..) 是 JS 侧会拿到的错误值):
args {"userId":1,"note":"hi"} => Ok(Json "user_id=1 note=Some(\"hi\")")
args {"userId":1} => Ok(Json "user_id=1 note=None")
args {"userId":1,"note":null} => Ok(Json "user_id=1 note=None")
args {"user_id":1} => Err("invalid args `userId` for command `args`: command args missing required key userId")
args {"userId":"1"} => Err("invalid args `userId` for command `args`: invalid type: string \"1\", expected u32")
args {"userId":-1} => Err("invalid args `userId` for command `args`: invalid value: integer `-1`, expected u32")
args {"userId":1.5} => Err("invalid args `userId` for command `args`: invalid type: floating point `1.5`, expected u32")
args {"userId":1,"note":5} => Err("invalid args `note` for command `args`: invalid type: integer `5`, expected a string")
args {"userId":1,"extra":true} => Ok(Json "user_id=1 note=None")
args [1,"hi"] => Err("invalid args `userId` for command `args`: command args missing required key userId")
args_snake {"user_id":7} => Ok(Json 7)
args_snake {"userId":7} => Err("invalid args `user_id` for command `args_snake`: command args_snake missing required key user_id")
nested {"query":{"page_size":10}} => Ok(Json "Query { page_size: 10, tag: None }")
nested {"query":{"pageSize":10}} => Err("invalid args `query` for command `nested`: missing field `page_size`")
nested {"query":{"page_size":10,"tag":"x"}} => Ok(Json "Query { page_size: 10, tag: Some(\"x\") }")
nested_camel {"query":{"pageSize":10}} => Ok(Json 10)
small {"n":300} => Err("invalid args `n` for command `small`: invalid value: integer `300`, expected u8")
small {"n":null} => Err("invalid args `n` for command `small`: invalid type: null, expected u8")
unnamed {"":1} => Err("invalid args `` for command `unnamed`: command unnamed has an argument with no name with a non-optional value")
...
args <3 bytes> => Err("invalid args `userId` for command `args`: command args expected a value for key userId but the IPC call used a bytes payload")
opt_only <3 bytes> => Err("invalid args `note` for command `opt_only`: command opt_only expected a value for key note but the IPC call used a bytes payload")
opt_only {} => Ok(Json "None")
读这张表要分清两层错误:
- tauri 这一层只有三种文本:
missing required key(键不在对象里)、expected a value for key ... bytes payload(请求体是原始字节)、has an argument with no name(参数模式是_,宏给的key是空串)。它们来自CommandItem,句子里的命令名和键名来自宏生成的name、key。 - serde 这一层是取到值之后的事:
invalid type、invalid value、missing field,原文和serde_json::from_value给的一样,见 serde 卷的 invalid type 和 missing field。
几个容易想错的地方:
| 现象 | 原因 |
|---|---|
user_id 参数要用 userId 传 | 宏把参数名转成 lowerCamelCase 当 key;rename_all = "snake_case" 改回原名(见命令宏) |
| 嵌套结构体的字段不改成 camelCase | key 只用于顶层的 v.get(key);取出的 Value 交给 Query 自己的派生代码,字段名按 Query 上的 serde 属性,nested 要 page_size,nested_camel 加了 #[serde(rename_all = "camelCase")] 才要 pageSize(属性见 serde 卷 属性) |
Option 参数可以不传,也可以传 null | 键缺失时 deserialize_option 直接 visit_none;null 由 Value 处理成 None |
非 Option 参数传 null 不算「缺键」 | 键在,值是 null,错误来自 serde:invalid type: null |
| 多余的键没事 | 只按 key 取,不检查其他键 |
| 不能按位置传参 | 请求体是数组时 Value::get(&str) 返回 None,报缺键 |
请求体是原始字节时 Option 参数也报错 | deserialize_option 先判断 InvokeBody::Raw,不会当作「没传」 |
原始字节进来:InvokeBody 和 ipc::Request
请求体在 Rust 侧是 InvokeBody:Json(serde_json::Value) 或 Raw(Vec<u8>)。哪一种由 parse_invoke_request 按 Content-Type 决定(application/octet-stream 是 Raw),而 JS 侧什么时候发 application/octet-stream,由 processIpcMessage 决定(见 JS 侧:invoke 与回调)。
上一节看到,Raw 请求体没法交给任何普通参数。要拿原始字节,参数写成 tauri::ipc::Request:它的 CommandArg 实现不看 key,直接借出整个 payload 和请求头。ipc2-app 的命令:
#[tauri::command]
fn raw_in(request: Request<'_>) -> String {
let ct = request
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("-")
.to_string();
match request.body() {
InvokeBody::Raw(b) => format!("Raw {:?} content-type={ct}", &b[..b.len().min(8)]),
InvokeBody::Json(v) => format!("Json {v} content-type={ct}"),
}
}
页面用四种参数调它(MODE=basic,debug 构建):
out.rawU8 = await attempt('raw_in', new Uint8Array([1, 2, 3]));
out.rawBuf = await attempt('raw_in', new Uint8Array([4, 5]).buffer);
out.rawArray = await attempt('raw_in', [1, 2, 3]);
out.u8InObject = await attempt('raw_in', { data: new Uint8Array([1, 2, 3]) });
"rawU8": {
"ok": "Raw [1, 2, 3] content-type=application/octet-stream"
},
"rawBuf": {
"ok": "Raw [4, 5] content-type=application/octet-stream"
},
"rawArray": {
"ok": "Raw [49, 44, 50, 44, 51] content-type=application/octet-stream"
},
"u8InObject": {
"ok": "Json {\"data\":[1,2,3]} content-type=application/json"
},
Uint8Array 和 ArrayBuffer 作为整个参数时原样到达。放在对象里的 Uint8Array 被 JSON.stringify 的替换函数转成数字数组,到 Rust 侧是 JSON。第三行是个问题:
问: @tauri-apps/api 的 InvokeArgs 类型写着 Record<string, unknown> | number[] | ArrayBuffer | Uint8Array,那 invoke('raw_in', [1, 2, 3]) 应该收到三个字节 [1, 2, 3],为什么是 [49, 44, 50, 44, 51]?
答: 那五个字节是 ASCII 的 1,2,3。process-ipc-message-fn.js 把 Array.isArray(message) 和 ArrayBuffer 归为一类,标成 application/octet-stream,然后把这个 JS 数组原样当 fetch 的 body;fetch 不认识数组,按 String([1, 2, 3]) 转成文本发出去。所以在 fetch 这条路上,number[] 作为整个参数是错的,要发字节就用 Uint8Array。这一行判断是 #7170 引入 ipc-protocol.js 时就有的。本卷只在 macOS 上测了自定义协议这条路。
返回值:IpcResponse
包装代码把命令的返回值交给 resolver,resolver 要的是 IpcResponse:
impl<T: Serialize> IpcResponse for T {
fn body(self) -> crate::Result<InvokeResponseBody> {
serde_json::to_string(&self)
.map(Into::into)
.map_err(Into::into)
}
}
/// The IPC response.
pub struct Response {
body: InvokeResponseBody,
}
impl IpcResponse for Response {
fn body(self) -> crate::Result<InvokeResponseBody> {
Ok(self.body)
}
}
任何 Serialize 类型都被 serde_json::to_string 成 InvokeResponseBody::Json,响应头 application/json,JS 侧 response.json()。ipc::Response 不实现 Serialize,所以不命中上面的通用实现,Response::new(Vec<u8>) 得到 InvokeResponseBody::Raw,响应头 application/octet-stream,JS 侧 response.arrayBuffer()。Vec<u8> 本身是 Serialize,返回它得到的是 JSON 数字数组:
#[tauri::command]
fn raw_out(n: usize) -> Response {
Response::new(vec![7u8; n])
}
#[tauri::command]
fn bytes_out(n: usize) -> Vec<u8> {
vec![7u8; n]
}
"rawOut": {
"ok": "ArrayBuffer(5)"
},
"bytesOut": {
"ok": [
7,
7,
7,
7,
7
]
}
序列化可能失败。body() 的错误经 From<Result<T, E>> for InvokeResponse 变成 InvokeError::from_error(err),也就是错误的 Display 文本,Promise 被拒绝。ipc2-mock 里返回一个键是元组的 HashMap:
bad_ok {} => Err("JSON error: key must be a string")
前缀 JSON error: 来自 crate::Error::Json 的 Display。原因见 serde 卷的 key must be a string。
错误:InvokeError 从任何 Serialize 来
返回 Result<T, E> 时,E 要满足 Into<InvokeError>。InvokeError 只是一个 serde_json::Value,有一个对所有 Serialize 的 From:
impl<T: Serialize> From<T> for InvokeError {
#[inline]
fn from(value: T) -> Self {
serde_json::to_value(value)
.map(Self)
.unwrap_or_else(Self::from_error)
}
}
错误值序列化成什么,JS 的 catch 就拿到什么。ipc2-app 里一个相邻标签的错误枚举:
#[derive(Debug, Serialize)]
#[serde(tag = "kind", content = "message")]
enum MyError {
NotFound(String),
Denied { user: String },
}
#[tauri::command]
fn fail(which: &str) -> Result<String, MyError> {
match which {
"nf" => Err(MyError::NotFound(format!("no item {which}"))),
"denied" => Err(MyError::Denied { user: "rie".into() }),
_ => Ok("fine".into()),
}
}
页面 catch (e) 后记下 e 和 typeof e:
"failNf": {
"err": {
"kind": "NotFound",
"message": "no item nf"
},
"type": "object"
},
"failDenied": {
"err": {
"kind": "Denied",
"message": {
"user": "rie"
}
},
"type": "object"
},
"missing": {
"err": "invalid args `name` for command `greet`: command greet missing required key name",
"type": "string"
},
JS 拿到的是普通对象,不是 Error 实例,没有 stack;tauri 自己产生的错误(缺参数、ACL、找不到命令)都是字符串。前端要按 e.kind 分支,就让错误类型序列化成带标签的对象(标签写法见 serde 卷 内部标签与相邻标签)。
反过来,没有实现 Serialize 的错误类型不能直接用。ipc2-mock 加一个返回 Result<Vec<u8>, std::io::Error> 的命令,cargo build 报:
error[E0599]: the method `blocking_kind` exists for reference `&Result<Vec<u8>, std::io::Error>`, but its trait bounds were not satisfied
--> src/main.rs:129:1
|
129 | #[tauri::command]
| ^^^^^^^^^^^^^^^^^ method cannot be called on `&Result<Vec<u8>, std::io::Error>` due to unsatisfied trait bounds
...
= note: the following trait bounds were not satisfied:
`std::io::Error: Into<InvokeError>`
which is required by `Result<Vec<u8>, std::io::Error>: tauri::ipc::private::ResultKind`
`Result<Vec<u8>, std::io::Error>: IpcResponse`
which is required by `&Result<Vec<u8>, std::io::Error>: tauri::ipc::private::ResponseKind`
= note: this error originates in the macro `__cmd__io_direct` which comes from the expansion of the macro `tauri::generate_handler` (in Nightly builds, run with -Z macro-backtrace for more info)
报错说的是 blocking_kind 找不到,这是命令宏那页讲的自动引用特化:Result 那一档要求 E: Into<InvokeError>,不满足;退到 &T: IpcResponse 那一档,又要求整个 Result 是 Serialize,也不满足,两档都落空。真正的信息在 note 里的 std::io::Error: Into<InvokeError>。常见的修法是包一层,把 Display 文本序列化出去:
#[derive(Debug)]
struct IoError(std::io::Error);
impl Serialize for IoError {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&self.0.to_string())
}
}
fail_io {} => Err("No such file or directory (os error 2)")
fail_string {} => Err("plain string")
bad_err {} => Err("key must be a string")
最后一行:错误值本身序列化失败时,From 里的 unwrap_or_else(Self::from_error) 把 serde_json::Error 的文本当错误交出去(没有 JSON error: 前缀,和 bad_ok 的路径不同)。
问: 为什么不给所有 std::error::Error 实现 From,让 io::Error 直接能用?
答: impl<T: Serialize> From<T> for InvokeError 已经是一个覆盖所有类型的实现,再加一个 impl<E: std::error::Error> From<E>,两者在「既是 Serialize 又是 Error」的类型上重叠,Rust 的一致性规则不允许。tauri 选了 Serialize 这一边,因为错误最终要变成 JS 能读的数据;Error 那一边留成两个显式的函数 InvokeError::from_error(取 to_string())和 from_anyhow(取 {error:#},带上整条 cause 链),返回类型写成 Result<T, InvokeError> 时用 map_err(InvokeError::from_error)。tauri::Error 自己另有一个 From,所以返回 tauri::Result<T> 的命令可以直接写。
InvokeResolver:一次性的应答
InvokeResolver 里存着 webview、命令名、两个回调 id,以及 on_message 包好的应答闭包,类型是 Arc<Mutex<Option<Box<OwnedInvokeResponder<R>>>>>。它能 Clone(on_message 自己留了一份用来报「not found」),但所有出口最后都到 return_result:
(responder.lock().unwrap().take().expect("resolver consumed"))(
webview,
cmd,
response,
success_callback,
error_callback,
);
take() 保证应答闭包只被调用一次:一个 WKURLSchemeTask 只能结束一次,一个 JS Promise 只能落定一次。第二次调用会 panic「resolver consumed」。
出口有几种,区别只在怎么得到 InvokeResponse:
| 方法 | 用途 | 在哪个线程应答 |
|---|---|---|
respond(Result<T, InvokeError>)、resolve(T)、reject(E: Serialize)、invoke_error | 同步命令的 kind.block 走 respond;on_message 的各种拒绝走 reject | 调用者的线程 |
respond_async(future) | 手写处理函数时用,结果还没编码 | async_runtime::spawn 出去的任务里 |
respond_async_serialized(future) | async 命令的包装代码用,future 直接给出 InvokeResponseBody | 同上 |
respond_async_serialized 在 debug 和 release 下实现不同:
pub fn respond_async_serialized<F>(self, task: F)
where
F: Future<Output = Result<InvokeResponseBody, InvokeError>> + Send + 'static,
{
// Dynamic dispatch the call in dev for a faster compile time
// TODO: Revisit this and see if we can do this for the release build as well if the performance hit is not a problem
#[cfg(debug_assertions)]
{
self.respond_async_serialized_dyn(Box::pin(task))
}
#[cfg(not(debug_assertions))]
{
self.respond_async_serialized_inner(task)
}
}
上面这段是 #13464(「refactor: dynamic dispatch async commands in debug」,首次发布于 tauri 2.6.0)加的。之前 respond_async_serialized 直接对泛型 F 调 async_runtime::spawn,每个 async 命令的 future 类型都单态化出一份 spawn 相关的代码。改成 debug 下先 Box::pin 成 dyn Future,所有 async 命令共用一份 respond_async_serialized_inner::<Pin<Box<dyn Future ..>>>。CHANGELOG:「Use dynamic dispatch for async commands in dev, this should speed up the compilation time by quite a bit, and significantly reduces the incremental compilation time」。release 仍走泛型,代价是编译时间,换来少一次堆分配和间接调用。
同步命令卡住的是什么
同步命令在主线程上执行、主线程上应答,async 命令在 tokio-rt-worker 上,这在一次 invoke 和 异步运行时 里都测过。「同步命令卡住界面」这句话具体卡住了什么?ipc2-app 的 MODE=block 用两个都睡 1 秒的命令对比:
#[tauri::command]
fn sleep_sync(ms: u64) -> String {
std::thread::sleep(std::time::Duration::from_millis(ms));
format!("slept {ms} ms on {}", thread_name())
}
#[tauri::command]
async fn sleep_async(ms: u64) -> String {
std::thread::sleep(std::time::Duration::from_millis(ms));
format!("slept {ms} ms on {}", thread_name())
}
async 版本故意也用阻塞的 std::thread::sleep,只比较「在哪个线程上阻塞」。页面在命令执行期间做三件事:用 MessageChannel 自己给自己发消息,数 300 毫秒里 JS 事件循环转了几圈;再调一次同步命令 greet;再用 fetch 取一次 tauri://localhost/index.html。后两者从发出到完成的时间就是延迟:
function spin(ms) {
// count event-loop turns with MessageChannel (setTimeout is throttled in a hidden window)
return new Promise((resolve) => {
const mc = new MessageChannel();
let n = 0;
const until = performance.now() + ms;
mc.port1.onmessage = () => {
n++;
if (performance.now() < until) mc.port2.postMessage(0); else resolve(n);
};
mc.port2.postMessage(0);
});
}
async function blockTest(cmd) {
let ticks = 0;
const id = setInterval(() => ticks++, 10);
const idleTurns = await spin(300);
const idleTicks = ticks;
ticks = 0;
const t0 = performance.now();
const p = inv(cmd, { ms: 1000 });
await new Promise(r => setTimeout(r, 0));
const g0 = performance.now();
const gp = inv('greet', { name: 'x' }).then(() => Math.round(performance.now() - g0));
const ap = fetch('tauri://localhost/index.html').then(r => r.text()).then(() => Math.round(performance.now() - g0));
const busyTurns = await spin(300);
// ... 等 p、gp、ap 全部落定后记录
}
debug 构建的输出:
REPORT {
"mode": "block",
"data": [
{
"cmd": "sleep_sync",
"result": "slept 1000 ms on main",
"totalMs": 1002,
"eventLoopTurnsIn300msIdle": 16291,
"eventLoopTurnsIn300msDuring": 17099,
"setInterval10msTicksIdle300ms": 6,
"setInterval10msTicksDuring": 1,
"greetLatencyMs": 1002,
"assetLatencyMs": 1002
},
{
"cmd": "sleep_async",
"result": "slept 1000 ms on tokio-rt-worker",
"totalMs": 1006,
"eventLoopTurnsIn300msIdle": 7356,
"eventLoopTurnsIn300msDuring": 4627,
"setInterval10msTicksIdle300ms": 6,
"setInterval10msTicksDuring": 1,
"greetLatencyMs": 1,
"assetLatencyMs": 1
}
]
}
- 页面的 JS 没有停。
sleep_sync期间事件循环 300 毫秒转了 17099 圈,和空闲时的 16291 圈是一个量级。WKWebView 的 JS 跑在单独的 WebContent 进程里,应用进程的主线程睡着并不影响它。 - 停的是应用进程的主线程。 同步命令睡着的那一秒,另一个
invoke('greet')等了 1002 毫秒,连取一个内嵌资源也等了 1002 毫秒。原因相同:WebKit 在主线程上回调WKURLSchemeHandler的start_task(见 wry 在 macOS 上),主线程被占着,所有ipc://、tauri://、asset://请求都进不来。tauri://的资源本来是async_runtime上读的,但请求连start_task都到不了。主线程同时也是 tao 的事件循环,窗口事件、eval、run_on_main_thread都要排队。 - async 版本:主线程立即空出来,
greet和资源请求 1 毫秒完成;被阻塞的只是一个 tokio 工作线程。阻塞工作线程本身也有代价(它占着运行时的一个 worker),该用spawn_blocking,见 tokio 卷 blocking 线程池。 setInterval(.., 10)在 300 毫秒里只触发了 6 次,与命令无关:窗口是隐藏的,WebKit 对不可见页面的定时器做了节流。这也是实验改用MessageChannel计数的原因。
所以界面「卡住」的表现是:页面上的动画、事件处理照常,但依赖 Rust 的一切(invoke 回应、图片和脚本这些经自定义协议加载的资源、窗口拖动和缩放)都停住。Windows(WebView2)和 Linux(WebKitGTK)上协议回调在哪个线程,本卷没有实测。
sleep_sync 睡 1 秒期间,页面用 fetch 取 tauri://localhost/index.html。tauri:// 协议的处理是 spawn 到 async_runtime 上做的,这个 fetch 要多久?平台 M2 Max、macOS 27,tauri 2.12.5,debug 构建。动脑筋答案
"cmd": "sleep_sync",
...
"greetLatencyMs": 1002,
"assetLatencyMs": 1002
- 约 1 秒,和同步命令剩下的时间一样长;换成
sleep_async时是 1 毫秒。 async_runtime只负责读资源、生成响应,前提是请求先被 wry 收下。WebKit 把这个请求交给taurischeme 的WKURLSchemeHandler时,回调发生在主线程上,而主线程正在sleep_sync里。主线程空出来之前,start_task根本不会被调用,后面的spawn也就无从谈起。- 这也说明「把协议处理挪到别的线程」救不了同步命令:卡住的是入口,不是处理。
小结
- 普通参数由
CommandItem充当Deserializer:按key从请求体的 JSON 对象里取一个Value,再交给 serde;缺键、原始字节、空键名是 tauri 的三种错误,其余是 serde 的原文,外面包一层invalid args ... for command ...。 - 只有顶层键按 camelCase(或
rename_all = "snake_case")改名,嵌套结构体字段按它自己的 serde 属性;Option参数可以缺键;按位置传参不行。 - 原始字节:JS 传
Uint8Array/ArrayBuffer作为整个参数,Rust 用ipc::Request取InvokeBody::Raw;number[]在fetch这条路上会被转成文本1,2,3。返回ipc::Response得到ArrayBuffer,返回Vec<u8>得到数字数组。 - 错误类型只要
Serialize,JS 拿到的就是它的 JSON(对象而不是Error);std::io::Error这类不能直接用,报错是blocking_kind找不到,包一层或InvokeError::from_error。 InvokeResolver的应答闭包只能调一次;async 命令经respond_async_serialized在 tokio 上应答,debug 下多一次Box::pin(#13464)。- 同步命令卡住的是应用进程的主线程:其他 invoke、
tauri://资源、窗口事件都排队;WebContent 进程里的 JS 照常运行。