18. Estado, eventos y canales en Tauri
Secciones
Ajustes de lectura
Ninguna voz para este idioma en este navegador.
Más voces
Código y términos técnicos en inglés
Desactivado: la voz de la página lo lee todo, sin pausa entre dos voces.
Ninguna voz para este idioma en este navegador.
Más voces
Más voces: Microsoft Edge ofrece voces naturales en línea, y Windows (Configuración › Hora e idioma › Voz), macOS y Android instalan otras.
Ejemplo completo: l16-tauri/src-tauri/src/state.rs y ui/main.js.
Los comandos de la lección 17 eran funciones de sus argumentos. Una aplicación real también necesita datos que vivan entre dos llamadas, y mensajes que Rust envía sin que se los pidan: una lista que ha cambiado, una búsqueda que avanza.
Lo que ya conoces
Sección titulada «Lo que ya conoces»| Necesidad | Tauri | .NET | Java | Electron |
|---|---|---|---|---|
| Un objeto compartido para la aplicación | Builder::manage + State<T> |
un singleton en la inyección de dependencias | un bean singleton de Spring | una variable en el proceso principal |
| Avisar a todas las ventanas de que algo ha pasado | Emitter::emit + listen |
WeakReferenceMessenger |
ApplicationEventPublisher |
webContents.send |
| Enviar en flujo los resultados de una llamada | ipc::Channel<T> |
IProgress<T>, IAsyncEnumerable<T> |
Flow.Publisher, el Flux de Reactor |
un MessagePort |
Estado gestionado
Sección titulada «Estado gestionado»El explorador guarda en memoria los acordes favoritos. El estado es un tipo corriente (de src-tauri/src/state.rs, líneas 14-16):
/// Los símbolos de los acordes favoritos, compartidos por todas las ventanas.#[derive(Default)]pub struct Favorites(Mutex<Vec<String>>);Se registra una sola vez, al construir la aplicación (el setup de la lección 17):
builder .manage(state::Favorites::default()) .manage(state::SearchControl::default())y un comando lo pide añadiendo un parámetro State<'_, Favorites>, que Tauri rellena en lugar de leerlo del JSON (de src-tauri/src/state.rs, líneas 24-27):
#[tauri::command]pub fn favorites(favorites: State<'_, Favorites>) -> Vec<String> { favorites.0.lock().unwrap().clone()}De su funcionamiento se derivan cuatro reglas (gestión del estado):
- El estado se busca por tipo, como un servicio registrado por su tipo en .NET. Dos listas de cadenas necesitan dos tipos:
FavoritesyRecentChords, no dosMutex<Vec<String>>. Registrar dos veces el mismo tipo detiene la aplicación al arrancar; la aserción deBuilder::manage:
assert!( self.state.set(state), "state for type '{type_name}' is already being managed",);- Los comandos se ejecutan en varios hilos (lección 17), así que el estado debe ser
Send + Syncy todo lo mutable va detrás de unMutex, unRwLocko un atómico (lección 12). No hay dispatcher que lo oculte, igual que no lo hay en un singleton de ASP.NET Core. Tauri envuelve él mismo el estado en unArc. - Olvidar
manageno es un error de compilación. El tipo solo se busca cuando se llama al comando, y la llamada se rechaza (del teststate_that_was_never_managed_rejects_the_callensrc-tauri/tests/ipc.rs):
recent_chords -> "state not managed for field `recent` on command `recent_chords`. You must call `.manage()` before using this command"La guía de Tauri, en su sección sobre tipos que no coinciden, dice que el tipo equivocado provoca un panic en ejecución. Para un comando, en Tauri 2.11.5, la promesa se rechaza y la aplicación sigue funcionando; fuera de los comandos, Manager::state sí hace panic, y try_state devuelve en su lugar un Option.
- Fuera de un comando,
Manager::stateotry_statelo leen desde laAppo unAppHandle, por ejemplo ensetupo en un hilo.
Un MutexGuard a través de .await
Sección titulada «Un MutexGuard a través de .await»En un comando async se aplican las reglas de la lección 13: un std::sync::MutexGuard no es Send, y Tauri necesita que el future sea Send para ejecutarlo en su runtime (doctest compile_fail):
use std::sync::Mutex;
async fn save(_list: &[String]) {}
#[tauri::command]async fn add_favorite( favorites: tauri::State<'_, Mutex<Vec<String>>>, symbol: String,) -> Result<(), String> { let mut list = favorites.lock().unwrap(); list.push(symbol); save(&list).await; Ok(())}error: future cannot be sent between threads safely --> examples\e18_guard_across_await.rs:5:1 | 5 | #[tauri::command] | ^^^^^^^^^^^^^^^^^ future returned by `add_favorite` is not `Send`... = help: within `impl Future<Output = Result<(), std::string::String>>`, the trait `std::marker::Send` is not implemented for `std::sync::MutexGuard<'_, Vec<std::string::String>>`note: future is not `Send` as this value is used across an await --> examples\e18_guard_across_await.rs:12:17 | 10 | let mut list = favorites.lock().unwrap(); | -------- has type `std::sync::MutexGuard<'_, Vec<std::string::String>>` which is not `Send` 11 | list.push(symbol); 12 | save(&list).await; | ^^^^^ await occurs here, with `mut list` maybe used laterLa solución habitual es copiar lo que necesitas y liberar el lock antes del .await, en un bloque. La guía de Tauri, citando la de tokio, mantiene el Mutex estándar como opción por defecto y reserva tokio::sync::Mutex para un guard que de verdad deba vivir a través de un .await.
Y como State<'_, T> es un préstamo, un comando async que lo recibe debe devolver un Result, la regla vista en la lección 17 para &str:
use std::sync::Mutex;
#[tauri::command]async fn favorites(favorites: tauri::State<'_, Mutex<Vec<String>>>) -> Vec<String> { favorites.lock().unwrap().clone()}error[E0277]: async commands that contain references as inputs must return a `Result` --> examples\e18_async_state.rs:4:72 |4 | async fn favorites(favorites: tauri::State<'_, Mutex<Vec<String>>>) -> Vec<String> { | ^^^ the trait `AsyncCommandMustReturnResult` is not implemented for `Vec<std::string::String>`Eventos: Rust avisa a la página
Sección titulada «Eventos: Rust avisa a la página»Cuando se añade un favorito, todas las ventanas que muestran la lista deben actualizarse, incluidas las que no pidieron nada. El comando emite un evento después de cambiar el estado (de src-tauri/src/state.rs, líneas 29-49):
/// Añade o quita un favorito y luego avisa a todas las ventanas con un evento `favorites-changed`.#[tauri::command]pub fn toggle_favorite<R: Runtime>( app: AppHandle<R>, favorites: State<'_, Favorites>, symbol: &str,) -> Result<Vec<String>, CommandError> { let symbol = symbol.parse::<Chord>()?.to_string(); let snapshot = { let mut list = favorites.0.lock().unwrap(); match list.iter().position(|favorite| *favorite == symbol) { Some(index) => { list.remove(index); } None => list.push(symbol), } list.clone() }; // el lock se libera aquí, antes de hablar con los webviews app.emit("favorites-changed", &snapshot)?; Ok(snapshot)}- Tauri proporciona un parámetro
AppHandle, igual queState. Es genérico sobre el runtime para que los tests simulados puedan llamar al mismo comando. emitenvía a todos los webviews;emit_toapunta a una ventana por su etiqueta.- El payload es una instantánea tomada bajo el lock y emitida una vez liberado: nunca mantengas un lock mientras llamas hacia fuera, la regla que conoces de
locken C# ysynchronizeden Java. ?funciona sobreemitporqueCommandErrorimplementaFrom<tauri::Error>(lección 17).
La página se suscribe una vez, al arrancar, con listen (de ui/main.js, líneas 168-170):
// Todas las ventanas reciben favorites-changed, sea cual sea la ventana que cambió la listaawait listen("favorites-changed", (event) => showFavorites(event.payload));showFavorites(await invoke("favorites"));y toggleFavorite ignora el resultado del comando: el evento actualiza la lista, en esta ventana y en las demás. Lo que recibió la página, capturado en la ventana:
toggle_favorite returned ["Am"]; listener got {"event":"favorites-changed","id":2,"payload":["Am"]}El código Rust también puede escuchar, mediante el trait Listener, y así es como el test simulado comprueba el evento sin webview (de src-tauri/tests/ipc.rs, líneas 124-135):
#[test]fn toggle_favorite_emits_an_event() { let (app, webview) = app(); let (sender, received) = mpsc::channel(); app.listen("favorites-changed", move |event| { sender.send(event.payload().to_string()).unwrap(); }); invoke(&webview, "toggle_favorite", json!({ "symbol": "Bb7" })).unwrap(); let payload = received.recv_timeout(Duration::from_secs(5)).unwrap(); println!("favorites-changed payload: {payload}"); assert_eq!(payload, r#"["Bb7"]"#);}favorites-changed payload: ["Bb7"]El payload debe ser Serialize y Clone, porque puede ir a varios destinos; pasar una referencia, &snapshot, cumple Clone sin copiar la lista. Un tipo de payload sin Clone:
use tauri::{AppHandle, Emitter};
#[derive(serde::Serialize)]struct Favorites { symbols: Vec<String>,}
#[tauri::command]fn announce(app: AppHandle) -> Result<(), String> { let payload = Favorites { symbols: vec!["Am".into()] }; app.emit("favorites-changed", payload).map_err(|e| e.to_string())}error[E0277]: the trait bound `Favorites: Clone` is not satisfied --> examples\e18_emit_not_clone.rs:11:35 | 11 | app.emit("favorites-changed", payload).map_err(|e| e.to_string()) | ---- ^^^^^^^ the trait `Clone` is not implemented for `Favorites` | | | required by a bound introduced by this call |note: required by a bound in `emit` --> C:\Users\spare\.cargo\registry\src\index.crates.io-1949cf8c6b5b557f\tauri-2.11.5\src\lib.rs:946:26 |946 | fn emit<S: Serialize + Clone>(&self, event: &str, payload: S) -> Result<()> { | ^^^^^ required by this bound in `Emitter::emit`help: consider annotating `Favorites` with `#[derive(Clone)]`Los nombres de eventos son cadenas, y nada comprueba que la página escuche el nombre que emite Rust: una errata pasa en silencio, como con un messenger indexado por cadena. La guía de Tauri añade dos límites: los eventos no están diseñados “for low latency or high throughput situations”, y sus payloads son siempre cadenas JSON.
Canales: un flujo para una llamada
Sección titulada «Canales: un flujo para una llamada»La búsqueda de posiciones del explorador prueba 7,5 millones de digitaciones y encuentra sus resultados a lo largo de un segundo. La página los quiere según van llegando, con una barra de progreso y un botón Cancel. Un evento llegaría a todas las ventanas, y necesitaría un identificador para distinguir dos búsquedas; un canal pertenece a una sola llamada. La página lo crea y lo pasa como argumento (de ui/main.js, líneas 99-128):
const onEvent = new Channel();onEvent.onmessage = (message) => { switch (message.event) { case "voicings": for (const frets of message.data.frets) { if (list.children.length < MAX_LISTED) { const item = document.createElement("li"); item.textContent = frets; list.append(item); } } break; case "progress": $("progress").value = message.data.done / message.data.total; break; case "finished": { const { found, elapsedMs, cancelled } = message.data; const shown = Math.min(found, MAX_LISTED); status.textContent = `${found} voicings in ${elapsedMs} ms${cancelled ? " (cancelled)" : ""}, ${shown} shown`; break; } }};
try { await invoke("find_voicings", { symbol: $("chord-input").value, maxSpan: Number($("span-select").value), onEvent, });El comando lo recibe como un Channel<SearchEvent>. Los mensajes son un único enum Rust, serializado con una etiqueta adyacente para que JavaScript pueda hacer switch sobre event (de src-tauri/src/state.rs, líneas 51-72):
/// Lo que `find_voicings` envía en flujo al frontend, en orden.#[derive(Clone, Serialize)]#[serde( tag = "event", content = "data", rename_all = "camelCase", rename_all_fields = "camelCase")]pub enum SearchEvent { Voicings { frets: Vec<String>, }, Progress { done: usize, total: usize, }, Finished { found: usize, elapsed_ms: u128, cancelled: bool, },}/// Busca todas las posiciones de un acorde en un hilo bloqueante y envía los resultados en flujo.#[tauri::command]pub async fn find_voicings( symbol: String, max_span: u8, control: State<'_, SearchControl>, on_event: Channel<SearchEvent>,) -> Result<usize, CommandError> { let chord: Chord = symbol.parse()?; let options = SearchOptions { max_span, ..SearchOptions::default() }; let cancel = Arc::clone(&control.cancel); cancel.store(false, Ordering::Relaxed);
let found = tauri::async_runtime::spawn_blocking(move || { let started = Instant::now(); let voicings = theory::find_voicings(&chord, &options, &cancel, |progress, batch| { if !batch.is_empty() { let frets = batch.iter().map(Voicing::to_string).collect(); // Un envío solo falla si el webview ya no existe: no queda nadie a quien avisar let _ = on_event.send(SearchEvent::Voicings { frets }); } let _ = on_event.send(SearchEvent::Progress { done: progress.done, total: progress.total, }); }); let _ = on_event.send(SearchEvent::Finished { found: voicings.len(), elapsed_ms: started.elapsed().as_millis(), cancelled: cancel.load(Ordering::Relaxed), }); voicings.len() }) .await?; Ok(found)}- El canal es un parámetro como cualquier otro, y
ChannelesSend: se mueve dentro del closure despawn_blocking, y la búsqueda envía desde su hilo bloqueante. theory::find_voicingsno sabe nada de Tauri: recibe un closure llamado después de cada lote (las posiciones de traste de la cuerda Mi grave, 14 en total), la misma forma que unIProgress<T>pasado a un método .NET.- La promesa de
invokese resuelve cuando el comando retorna, después del último mensaje.
Los mensajes que recibió la página para Bbm7 con una extensión de 3 trastes, en una compilación debug, con el tiempo transcurrido desde la llamada (abreviado: se han quitado los mensajes entre 379 ms y 1032 ms):
invoke resolved with 16 after 1114 ms, 17 messages77 ms {"event":"voicings","data":{"frets":["x-1-3-1-2-1","x-1-3-1-2-4","x-1-3-3-2-4","x-x-8-10-9-9"]}}78 ms {"event":"progress","data":{"done":1,"total":14}}152 ms {"event":"progress","data":{"done":2,"total":14}}227 ms {"event":"progress","data":{"done":3,"total":14}}305 ms {"event":"progress","data":{"done":4,"total":14}}379 ms {"event":"progress","data":{"done":5,"total":14}}1032 ms {"event":"progress","data":{"done":13,"total":14}}1113 ms {"event":"progress","data":{"done":14,"total":14}}1114 ms {"event":"finished","data":{"found":16,"elapsedMs":1110,"cancelled":false}}Los mensajes llegan en orden, cada 75 ms más o menos, mientras la búsqueda avanza: “designed to be fast and deliver ordered data”, en palabras de la guía.
Cancelar
Sección titulada «Cancelar»Cancel llama a otro comando, que activa un indicador en el estado gestionado (de src-tauri/src/state.rs, líneas 18-22):
/// Permite que `cancel_search` detenga la búsqueda iniciada por `find_voicings`.#[derive(Default)]pub struct SearchControl { cancel: Arc<AtomicBool>,}#[tauri::command]pub fn cancel_search(control: State<'_, SearchControl>) { control.cancel.store(true, Ordering::Relaxed);}La búsqueda comprueba el AtomicBool entre dos lotes: es el CancellationToken de .NET, o el Thread.interrupt de Java comprobado a mano, sin el soporte de ninguno de los dos en las bibliotecas. El Arc permite que el hilo bloqueante conserve el indicador, porque un State no puede salir del comando. Una búsqueda de C con una extensión de 4, cancelada 300 ms después de empezar:
cancelled search resolved with 19 after 381 ms{"event":"progress","data":{"done":5,"total":14}}{"event":"finished","data":{"found":19,"elapsedMs":378,"cancelled":true}}La búsqueda se detuvo tras su quinto lote, el que se estaba ejecutando cuando se activó el indicador, y devolvió las 19 posiciones encontradas hasta entonces. Un indicador para toda la aplicación basta para una búsqueda a la vez; el ejercicio 2 gestiona varias.
La llamada completa como secuencia, desde el invoke de la página hasta la promesa resuelta, con el botón Cancel como opción.
sequenceDiagram participant Page as página participant Cmd as find_voicings participant Search as hilo bloqueante participant Cancel as cancel_search Page->>Cmd: invoke con symbol, maxSpan y el Channel Cmd->>Search: spawn_blocking, el Channel se mueve dentro loop después de cada lote Search-->>Page: mensajes voicings y progress end opt el botón Cancel Page->>Cancel: invoke Cancel->>Search: pone el AtomicBool a true, leído entre dos lotes end Search-->>Page: mensaje finished Search->>Cmd: número de voicings encontrados Cmd-->>Page: la promesa de invoke se resuelve¿Evento o canal?
Sección titulada «¿Evento o canal?»| Evento | Canal | |
|---|---|---|
| Destinatarios | todos los listeners, en todas las ventanas (o un destino con emit_to) |
la llamada que lo creó |
| Lo crea | Rust, en cualquier momento | la página, para un invoke |
| Se identifica por | un nombre de tipo cadena | el propio argumento |
| Adecuado para | “algo ha cambiado”: favoritos, ajustes, un archivo guardado | “aquí va la siguiente parte”: progreso, resultados de búsqueda, una descarga |
| Advertencia de la guía | no para baja latencia ni alto rendimiento | pensado para flujos ordenados y rápidos |
Puntos clave
Sección titulada «Puntos clave»manageregistra un valor por tipo; los comandos lo reciben conState<'_, T>. El estado mutable necesita unMutex, unRwLocko atómicos, porque los comandos se ejecutan en varios hilos.- Un
manageolvidado se descubre en ejecución, al llamar al comando: la promesa se rechaza constate not managed. - Libera los locks antes de emitir o de esperar: un
std::sync::MutexGuarda través de.awaitno compila en un comando. - Los eventos difunden mensajes JSON con nombre a todas las ventanas; los payloads deben ser
Serialize + Clone, y los nombres no se comprueban. - Un canal envía mensajes ordenados a la única llamada que lo creó; muévelo al hilo de trabajo y cancela con un
AtomicBoolcompartido.
Ejercicios
Sección titulada «Ejercicios»- Recuerda los cinco últimos acordes deletreados, el más reciente primero, sin duplicados: tras
C,Am,F,G,Am,Dm,E7y unHminválido, un comandorecent_chordsdevuelve["E7", "Dm", "Am", "G", "F"].
Solución
Un nuevo tipo de estado, para que no choque con Favorites, y un comando que deletrea y recuerda (de src-tauri/tests/l18_solutions.rs, líneas 17-39):
#[derive(Default)]struct RecentChords(Mutex<VecDeque<String>>);
const RECENT: usize = 5;
#[tauri::command]fn spell_and_remember( recent: State<'_, RecentChords>, symbol: &str,) -> Result<ChordView, CommandError> { let chord: Chord = symbol.parse()?; let symbol = chord.to_string(); let mut list = recent.0.lock().unwrap(); list.retain(|previous| *previous != symbol); list.push_front(symbol); list.truncate(RECENT); Ok(chord.into())}
#[tauri::command]fn recent_chords(recent: State<'_, RecentChords>) -> Vec<String> { recent.0.lock().unwrap().iter().cloned().collect()}Un VecDeque añade por delante sin coste. Hm nunca llega a la lista, porque ? retorna antes de tomar el lock. Registrarlo requiere .manage(RecentChords::default()); el test comprueba la lista esperada con el runtime simulado.
- Con un solo
SearchControl, Cancel detiene la búsqueda que esté en curso, y empezar una segunda búsqueda reinicia el indicador de la primera. Cambia el estado para que cada búsqueda tenga su propio indicador ycancel_searchreciba el identificador de la búsqueda.
Solución
Un mapa de identificador a indicador (de src-tauri/tests/l18_solutions.rs, líneas 58-86):
#[derive(Default)]struct Searches { next_id: AtomicU32, running: Mutex<HashMap<u32, Arc<AtomicBool>>>,}
impl Searches { fn start(&self) -> (u32, Arc<AtomicBool>) { let id = self.next_id.fetch_add(1, Ordering::Relaxed); let flag = Arc::new(AtomicBool::new(false)); self.running.lock().unwrap().insert(id, Arc::clone(&flag)); (id, flag) }
fn finish(&self, id: u32) { self.running.lock().unwrap().remove(&id); }}
#[tauri::command]fn cancel_search(searches: State<'_, Searches>, id: u32) -> bool { match searches.running.lock().unwrap().get(&id) { Some(flag) => { flag.store(true, Ordering::Relaxed); true } None => false, }}find_voicings llamaría a start antes de spawn_blocking, enviaría el identificador como primer mensaje del canal (una variante Started { id }) para que la página sepa qué cancelar, y llamaría a finish al terminar la búsqueda, cancelada o no. El test inicia dos búsquedas, cancela la segunda, comprueba que solo su indicador está activado, y que cancelar una búsqueda terminada devuelve false.
- Para cada necesidad, elige un evento o un canal y di por qué: una ventana de ajustes cambia el tema de todas las demás ventanas; un comando copia una carpeta e informa de cada archivo copiado; el backend detecta que un archivo abierto en la aplicación ha cambiado en disco.
Solución
- Tema: un evento. Todas las ventanas deben reaccionar, incluidas las que no iniciaron nada, y es un único mensaje pequeño.
- Copia de carpeta: un canal. El progreso pertenece a la llamada que inició la copia, llega en orden y puede ser frecuente; dos copias a la vez tendrían cada una su propio canal.
- Archivo cambiado en disco: un evento. Nadie llamó a un comando, así que no hay canal por el que enviar: Rust inicia la conversación, desde un hilo que vigila los archivos y guarda un
AppHandle.