Rust in Everything
基于 tauri 2.12.5

参数、返回值与 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" 改回原名(见命令宏)
嵌套结构体的字段不改成 camelCasekey 只用于顶层的 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。第三行是个问题:

📝NOTE
没有蠢问题

问: @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 的路径不同)。

📝NOTE
没有蠢问题

问: 为什么不给所有 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)
    }
  }
📝NOTE
冷知识:debug 构建里 async 命令多一次装箱

上面这段是 #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)上协议回调在哪个线程,本卷没有实测。

❗IMPORTANT
动脑筋:同步命令 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 把这个请求交给 tauri scheme 的 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 照常运行。

💬 关联讨论

发起讨论
加载中...