Estado de toda la app en SwiftUI
Al terminar: Guardar el partido en juego en un objeto de toda la app, repartido con el entorno de SwiftUI.
Al terminar, Marcador tendrá un objeto nuevo, PartidoEnJuego, que guarda el partido que se está jugando y lo reparte a todas las pantallas por el entorno de SwiftUI. Todavía nadie lo usa, así que la app se ve igual; pero queda montado el sitio donde vivirá el partido mientras la app esté abierta, con cinco pruebas que ya lo ejercitan.
Importa porque, en las lecciones siguientes, tres pantallas distintas tendrán que ver el mismo partido: la suya, la de otro partido y una barra que aparece en todas. Si cada una tuviera su copia, enseñarían marcadores distintos. Temas: qué es el estado de toda la app y cuándo no conviene, cómo se reparte con @State, .environment y @Environment(Tipo.self) (lo que ya haces con el contenedor), por qué @AppStorage también lo es, y por qué .task no sirve para un partido que debe seguir vivo. Nivel 2: usar.
Qué es el estado de toda la app
El estado es lo que la app recuerda en cada momento: el texto escrito en un campo, el minuto de un partido, la lista de equipos. Casi todo el estado es de una pantalla y muere con ella. El estado de toda la app es el que no cumple eso: un dato que varias pantallas leen a la vez, que tiene que seguir vivo aunque cualquiera de ellas se cierre y que por eso se guarda en una sola instancia, que vive lo que la app esté abierta.
Ya lo usas sin nombre. ContenedorDeLaApp guarda el repositorio de partidos y el notificador, y cualquier pantalla los lee del entorno. La duración elegida (90 o 60 minutos) se guarda con @AppStorage y la leen varias vistas. Hoy se le pone nombre, porque el partido en juego es el primer dato de Marcador que lo necesita de verdad.
En SwiftUI se monta con tres piezas, y ya conoces las tres:
- Una clase
@Observableque es la dueña del dato y la única que lo cambia (private(set)). - Crearla una sola vez, en la raíz, con
@State, y repartirla con.environment(objeto). - Leerla en cualquier vista con
@Environment(Tipo.self).
En Marcador, el sitio donde se crea es el contenedor, que ya es un @State de MarcadorApp: no hace falta un @State nuevo por cada pieza.
Esa excepción es lo que ya viste en @Observable: el proyecto compila, pero la app se detiene al abrir la pantalla que pide un objeto que nadie puso. Es el precio de leer por tipo: el compilador no puede comprobar que alguien lo repartió. En La barra del partido en juego, en iOS verás una forma de leer del entorno que no detiene la app.
@AppStorage también es estado de toda la app
@AppStorage("clave") es estado de toda la app sin crear ningún objeto: dos vistas que declaran la misma clave leen y escriben el mismo valor, y cuando una lo cambia, las dos se redibujan. La diferencia con el objeto del entorno es qué guarda: @AppStorage solo sirve para valores pequeños y simples (un número, un texto, un interruptor) que además sobreviven al cierre de la app; el objeto del entorno sirve para cualquier cosa, pero se pierde al cerrarla.
Un ejemplo solo para leer (la vista previa de abajo es lo que verías si lo pegaras en un proyecto):
import SwiftUI
// Dos vistas, la MISMA clave de UserDefaults: es estado de toda la app sin crear ningún objeto.
struct SelectorDeTamanoView: View {
// Si la clave no existe todavía, vale 16.
@AppStorage("tamanoDeLetra") private var tamano = 16
var body: some View {
// `Stepper` es el control con «−» y «+». `$tamano` es el enlace: ahí escribe el nuevo valor.
// `in: 12...28` es el rango permitido: no baja de 12 ni sube de 28.
Stepper("Tamaño: \(tamano)", value: $tamano, in: 12...28)
}
}
struct TextoDeLecturaView: View {
// La MISMA clave: cuando el Stepper cambia el valor, este texto se redibuja solo.
@AppStorage("tamanoDeLetra") private var tamano = 16
var body: some View {
Text("Había una vez una tienda de pan.")
// `.system(size:)` pide un número decimal (`CGFloat`), y `tamano` es un entero (`Int`):
// `CGFloat(tamano)` lo convierte.
.font(.system(size: CGFloat(tamano)))
}
}
#Preview {
VStack {
SelectorDeTamanoView()
TextoDeLecturaView()
}
.padding()
}Lo que se ve: un selector «Tamaño: 16» y debajo la frase. Al pulsar «+» en la vista previa interactiva, el número sube y la letra de la frase crece, aunque son dos vistas distintas que no se conocen.
Cuándo NO usarlo
El estado de toda la app es cómodo, y por eso se abusa de él. Tiene un coste: cualquier pantalla puede depender de él sin que se note, es más difícil de probar y, si algo cambia, hay que mirar dónde más se lee. La regla es guardar el dato lo más abajo posible y subirlo solo cuando haga falta:
| Peldaño | Cuándo | Ejemplo en Marcador |
|---|---|---|
@State en la vista | Solo esa vista lo usa y vive lo que ella | El texto que escribes en el campo «Nombre» de un jugador |
El ViewModel de la pantalla | Es la lógica de una pantalla; nace y muere con ella | La lista de árbitros que carga su pantalla |
| Estado de toda la app | Lo leen varias pantallas a la vez y tiene que seguir vivo cuando cualquiera de ellas se cierra | El partido en juego: lo ven su pantalla, la de otro partido y la barra, y sigue aunque salgas |
La pregunta para decidir tiene dos mitades: «¿lo necesitan varias pantallas a la vez, y tiene que seguir vivo cuando una se cierre?». Si las dos respuestas son sí, es de toda la app; con una sola, casi siempre basta el peldaño de abajo. Si dudas, empieza por el peldaño de abajo. Subir un dato un peldaño cuando la app lo pida es fácil; bajar uno que ya leen diez pantallas, no.
.task muere con la pantalla
En El partido arranca con .task(id:) quitaste el botón «Empezar» y dejaste que .task lanzara el partido. Funcionaba porque la pantalla era la dueña del partido: si se iba, el partido se cancelaba, y eso era justo lo que querías.
Ahora lo que quieres es lo contrario: que el partido siga aunque la pantalla se vaya. Un .task es de la vista: nace cuando aparece y SwiftUI lo cancela cuando desaparece. Para que el partido sobreviva, la Task tiene que tener otro dueño, uno que viva lo que la app. Ese dueño será PartidoEnJuego, que guardará su propia Task en una propiedad. Cómo se arranca desde la pantalla lo verás en El partido sigue aunque salgas, en iOS; hoy solo se monta el dueño.
Una nota sobre @Bindable. En @Observable lo necesitabas para escribir en un campo con $. Aquí no hace falta: las vistas solo leen el partido en juego y llaman a sus funciones, nunca escriben en sus campos.
Ejemplo: el carrito de una tienda, para todas las pantallas
Un ejemplo solo para leer, con otro tema. Una tienda tiene un catálogo donde se añaden productos y una insignia «Carrito: N» que se ve siempre. Las dos tienen que ver el mismo carrito, así que es estado de toda la app: una clase @Observable, creada una vez, repartida con .environment.
import SwiftUI
// El estado de toda la app de una tienda: el carrito. UNA sola instancia, para todas las pantallas.
@Observable
final class Carrito {
// `private(set)`: las vistas lo leen, pero solo el carrito lo cambia.
private(set) var productos: [String] = []
func anadir(_ nombre: String) {
productos.append(nombre)
}
}
// Pantalla 1: el catálogo. LEE el carrito del entorno y le añade productos.
struct CatalogoView: View {
// Se lee por TIPO: `Carrito.self`. Si nadie lo repartió, la app se detiene al abrir esta vista.
@Environment(Carrito.self) private var carrito
var body: some View {
List(["Pan", "Leche", "Queso"], id: \.self) { producto in
Button("Añadir \(producto)") { carrito.anadir(producto) }
}
}
}
// Pantalla 2: la insignia del carrito. No sabe nada del catálogo: lee EL MISMO carrito.
struct InsigniaDelCarritoView: View {
@Environment(Carrito.self) private var carrito
var body: some View {
Text("Carrito: \(carrito.productos.count)")
.padding()
}
}
#Preview {
VStack {
CatalogoView()
InsigniaDelCarritoView()
}
.environment(Carrito()) // se crea UNA vez y se reparte a las dos vistas
}Lo que se ve en la vista previa: una lista con tres botones y, debajo, «Carrito: 0». Al pulsar «Añadir Pan» la insignia pasa a «Carrito: 1», y al pulsar «Añadir Queso», a «Carrito: 2». Ninguna de las dos vistas le pasa nada a la otra: las dos leen el mismo objeto. Si en el #Preview quitaras el .environment(Carrito()), la vista previa dejaría de funcionar por la excepción de arriba.
Reto · Ejercicio: el mini reproductor
Una aplicación de música tiene dos vistas que tienen que ver la misma canción: una lista de canciones y, abajo, un mini reproductor que dice qué suena. Es el gemelo en SwiftUI del ejercicio de Estado de toda la app: el partido en juego.
En la carpeta Ejercicios de tu proyecto, crea la subcarpeta MiniReproductor (clic derecho sobre MarcadorApp/Ejercicios → New Folder) y dentro un fichero MiniReproductorView.swift (clic derecho sobre MiniReproductor → New File from Template… → Swift File, solo con el target MarcadorApp).
Escribe:
- Una estructura
Cancioncon un título y un artista, comparable con==. - Una clase observable
Reproductor, que guarda la canción que suena (puede no sonar ninguna) y solo ella la cambia; con una función para reproducir una canción. - Una vista
ListaDeCancionesViewcon dos canciones, «Imagine» de John Lennon y «Yesterday» de The Beatles, un botón por canción que la reproduce. - Una vista
MiniReproductorViewcon un texto en el formato «título · artista»: «Imagine · John Lennon» si suena esa canción, y «Elige una canción» si no suena ninguna. - Un
#Previewcon las dos vistas una encima de la otra, que reparta un soloReproductora las dos.
Pruébalo en la vista previa del lienzo, en modo interactivo (Editor → Canvas; si dice que está en pausa, pulsa Resume). Lo que debe verse:
| Haces | Debe verse abajo |
|---|---|
| Nada | «Elige una canción» |
| Tocas «Imagine» | «Imagine · John Lennon» |
| Tocas «Yesterday» | «Yesterday · The Beatles» |
Ver solución
import SwiftUI
struct Cancion: Equatable {
let titulo: String
let artista: String
}
// El estado de toda la app: la canción que suena. UNA instancia, en el entorno.
@Observable
final class Reproductor {
private(set) var actual: Cancion?
func reproducir(_ cancion: Cancion) { actual = cancion }
}
private let canciones = [Cancion(titulo: "Imagine", artista: "John Lennon"), Cancion(titulo: "Yesterday", artista: "The Beatles")]
struct ListaDeCancionesView: View {
@Environment(Reproductor.self) private var reproductor
var body: some View {
// `id: \.titulo`: cada fila se identifica por el título de su canción (el título no se repite).
List(canciones, id: \.titulo) { cancion in
Button(cancion.titulo) { reproductor.reproducir(cancion) }
}
}
}
struct MiniReproductorView: View {
@Environment(Reproductor.self) private var reproductor
// Si suena una canción, «título · artista»; si no suena ninguna (`actual` es `nil`), una invitación.
private var texto: String {
if let cancion = reproductor.actual {
return "\(cancion.titulo) · \(cancion.artista)"
} else {
return "Elige una canción"
}
}
var body: some View {
Text(texto)
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(.bar) // el fondo translúcido de las barras del sistema
}
}
#Preview {
VStack(spacing: 0) {
ListaDeCancionesView()
MiniReproductorView()
}
.environment(Reproductor()) // UNA instancia para las dos vistas
}La lista y el mini reproductor no se conocen: ninguna le pasa la canción a la otra. Las dos leen el mismo Reproductor del entorno, y el #Preview lo crea una vez y se lo da a las dos con un solo .environment. Si crearas un Reproductor() dentro de cada vista (con @State, por ejemplo), cada una tendría el suyo y el mini reproductor no se enteraría nunca de lo que tocas en la lista.
Paso 1 · PartidoEnJuego.swift
Objetivo: el objeto que guarda el partido que se está jugando, con la regla de «uno a la vez».
Crea PartidoEnJuego.swift: clic derecho sobre Core/Domain → New File from Template… → Swift File, solo con el target MarcadorApp. Lo leen la barra, el marcador y la app entera, y no es de una sola funcionalidad: por eso va en Core/Domain, junto a ErrorDeRed.
Piezas nuevas de este paso:
private(set) var directo: Directo?: el partido en juego, o el que acaba de terminar. Es opcional porque a veces no hay ninguno. Solo este objeto lo cambia.Task<Void, Never>?: el tipo de una tarea guardada. El primer hueco es lo que devuelve (Void, nada) y el segundo, el error que puede lanzar (Never, ninguno). El?es porque al principio no hay tarea.@ObservationIgnored: como en tus otros objetos observables, para lo que no se pinta. La tarea no cambia nada en pantalla.empezar(_:) -> Bool: devuelvetruesi lo ha empezado yfalsesi no, porque ya hay otro sin terminar. Es la regla de «uno a la vez», y vive aquí, no en una pantalla. La tarea es suya:tarea = Task { await nuevo.jugar() }.cerrar(): quita el partido, pero solo si ya terminó. En juego no se cierra.directo(id:): el directo de ese partido, si es el que está en juego. La pantalla lo reutilizará en vez de crear otro. Ojo: el objeto tiene una propiedaddirecto(sin paréntesis) y una funcióndirecto(id:)(con ellos). Swift las distingue por los paréntesis y el parámetroid:; aquí las dos hablan del mismo partido.if let directo, !directo.terminado: la forma corta deif let directo = directo: «si hay un directo, llámalodirectoaquí dentro». La condición de después de la coma,!directo.terminado, también tiene que cumplirse.directooself.directo: mientras esa constante existe (dentro de eseif, o en el resto de la función con unguard let directo, como el decerrar()),directoes la constante, y para hablar de la propiedad del objeto hay que escribirself.directo. Por esocerrar()termina conself.directo = nil, yempezarescribedirecto = nuevoa secas: después de suif, la constante ya no existe ydirectoes la propiedad.condición ? a : b: el operador de tres partes dedirecto(id:): si la condición es verdadera valea, y si no,b. Aquí: «si el directo actual es el de esteid, devuélvelo; si no,nil».directo?.partidoIdesnilcuando no hay directo, y unnilnunca es igual a un número, así que la condición sale falsa y se devuelvenil.deinit: igual que enDirecto: anota en el registro cuándo se libera el objeto (mira la nota de abajo).
Intenta escribirlo tú. Una clase PartidoEnJuego, final, del hilo principal y observable. Guarda el directo actual (opcional, de solo lectura desde fuera) y la tarea que lo juega (opcional, sin observar). Tiene directo(id:), empezar(_:), que no hace nada y devuelve false si hay un directo sin terminar, y si no lo guarda, lanza su tarea y devuelve true; y cerrar(), que solo funciona si el directo ya terminó. Y un deinit que anota «PartidoEnJuego LIBERADO».
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
import Foundation
import Observation
// ESTADO DE TODA LA APP: el partido que se está jugando (gemelo de `PartidoEnJuego` en Android).
//
// Lo leen varias pantallas a la vez: la del propio partido, la barra de abajo (en todas las pestañas)
// y la de otro partido (para decir «Ya hay un partido en juego»). Por eso no vive en ningún ViewModel:
// hay UNO solo, lo crea el contenedor de la app y se reparte por el entorno (`.environment`).
//
// LA TAREA ES SUYA, no de `.task`. Un `.task` de una vista se cancela al cerrar esa vista; esta `Task`
// la guarda este objeto, que vive lo que la app. Por eso el partido sigue aunque salgas de su pantalla.
//
// Regla: UNO A LA VEZ. Mientras un partido no ha terminado, no se puede empezar otro. Al terminar, se
// queda aquí («Final») hasta que alguien lo cierra.
@MainActor
@Observable
final class PartidoEnJuego {
// El partido en juego (o el que acaba de terminar). Solo este objeto lo cambia.
private(set) var directo: Directo?
// La tarea que lo juega. No se pinta: `@ObservationIgnored`. Las pruebas la esperan con `await tarea?.value`.
@ObservationIgnored private(set) var tarea: Task<Void, Never>?
// En la app no se libera nunca (vive lo que el contenedor); en las pruebas, sí.
deinit {
Registro.anotar("PartidoEnJuego LIBERADO")
}
// El directo de ESE partido, si es el que está en juego. La pantalla lo reutiliza en vez de crear otro.
func directo(id: Int) -> Directo? {
directo?.partidoId == id ? directo : nil
}
// Empieza `nuevo`. Devuelve `false`, sin hacer nada, si hay otro partido sin terminar.
func empezar(_ nuevo: Directo) -> Bool {
if let directo, !directo.terminado { return false }
directo = nuevo
tarea = Task { await nuevo.jugar() }
return true
}
// Quitar el partido (la X de la barra). Solo cuando ya terminó: en juego no se cierra.
func cerrar() {
guard let directo, directo.terminado else { return }
tarea = nil
self.directo = nil
}
}Paso 2 · Dónde vive: el contenedor y la raíz
Objetivo: que haya un PartidoEnJuego para toda la app, creado por el contenedor y repartido por el entorno.
Dos cambios pequeños. En ContenedorDeLaApp.swift, una propiedad nueva entre las del dominio y las del sistema. El contenedor vive lo que la app, así que el partido en juego vive lo que la app; y como lo crea una vez, es el mismo para todas las pantallas:
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
// ---- dominio ----
let publicarCronica: PublicarCronica
// ---- estado de toda la app ----
// El partido en juego: UNO para toda la app (si cada pantalla tuviera el suyo, la barra y el partido
// verían cosas distintas). Vive lo que el contenedor, es decir, lo que la app.
let partidoEnJuego = PartidoEnJuego()
// ---- sistema ----
let notificador: NotificadorDeSistemaEl resto del fichero no cambia: solo se añade el bloque «estado de toda la app», con sus cuatro líneas.
Y en MarcadorApp.swift, una línea más en el body, debajo de las dos que ya repartían el repositorio y el notificador:
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
.environment(contenedor.partidos)
.environment(contenedor.notificador)
// Estado de toda la app: cualquier vista lo lee con `@Environment(PartidoEnJuego.self)`.
.environment(contenedor.partidoEnJuego)El resto del fichero no cambia.
Compila con Cmd+B. La app se ve igual: nadie lee todavía el PartidoEnJuego.
Paso 3 · Las pruebas del partido en juego
Objetivo: cinco pruebas, sin pantallas y sin ViewModel, que cubren las reglas del objeto.
Crea PartidoEnJuegoTests.swift en MarcadorAppTests/Core/Domain. Esa carpeta de pruebas aún no existe: clic derecho sobre MarcadorAppTests/Core (donde está Dobles.swift) → New Folder, de nombre Domain; después, clic derecho sobre ella → New File from Template… → Swift File, solo con el target MarcadorAppTests. Cinco piezas que merecen una línea:
await enJuego.tarea?.value: espera a que la tarea guardada termine. Con minutos de 0 ms, el partido entero se juega en un instante.msPorMinuto: la función de ayudadirecto(_:msPorMinuto:ciclo:)lo deja en0si no le dices otra cosa: el partido entero se juega en un instante. ConmsPorMinuto: 60_000, un minuto de partido dura un minuto de verdad. Con eso, el partido empieza y se queda en el minuto 0 mientras dura la prueba: así comprobamos las reglas de «uno a la vez» con un partido en juego. (60_000es 60000: el guion bajo solo ayuda a leerlo.) El comentario de la función de ayuda dice «msPorMinutogrande» y se refiere a ese60_000, que lo pasas tú; si no le pasas nada, vale0._ = enJuego.empezar(...):empezar(_:)devuelve unBool, y Swift avisa si lo ignoras sin más. El_ =dice «sé que devuelve algo y aquí no lo necesito»: se tira el valor a propósito.weak var referencia: la referencia débil que ya conoces (Ver un ciclo de retención), para comprobar que, al cerrar el partido, nadie retiene al directo.let directo = directo(1): una constante con el mismo nombre que la función de ayuda, como en El directo, en su propia clase: a la derecha, con paréntesis, es la función; a la izquierda, la constante nueva.
Intenta escribirlas tú, con el partido de ejemplo:
empezarLoPoneEnJuego: empezar el partido 1 devuelvetrue; tras esperar la tarea, el directo es el del partido 1 y está en el minuto 90.noDejaEmpezarOtroMientrasSeJuega: con el partido 1 en juego, empezar el 2 devuelvefalse; el directo sigue siendo el del 1, ydirecto(id: 2)esnil.cerrarNoHaceNadaMientrasSeJuega: cerrar con el partido en juego no quita nada.alTerminarSeCierraYDejaEmpezarOtro: cuando el 1 termina se puede empezar el 2, y al terminar y cerrar no queda directo.elDirectoSeLiberaAlCerrar: con el partido terminado el directo sigue ahí («Final»); al cerrarlo, la referencia débil queda vacía.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
import Testing
import Torneo
@testable import MarcadorApp
// Pruebas del partido en juego (estado de toda la app). Sin vistas y sin ViewModel.
@MainActor
struct PartidoEnJuegoTests {
// `msPorMinuto` grande: el partido empieza y se queda en el minuto 0 mientras dura la prueba.
private func directo(_ id: Int, msPorMinuto: Int = 0, ciclo: Bool = false) -> Directo {
Directo(partidoId: id, partido: Ejemplo.rayoContraToros, duracion: 90, msPorMinuto: msPorMinuto, reintroducirElCiclo: ciclo)
}
@Test func empezarLoPoneEnJuego() async {
let enJuego = PartidoEnJuego()
#expect(enJuego.empezar(directo(1)))
await enJuego.tarea?.value
#expect(enJuego.directo?.partidoId == 1)
#expect(enJuego.directo?.minuto == 90)
}
@Test func noDejaEmpezarOtroMientrasSeJuega() {
let enJuego = PartidoEnJuego()
_ = enJuego.empezar(directo(1, msPorMinuto: 60_000))
#expect(!enJuego.empezar(directo(2)))
#expect(enJuego.directo?.partidoId == 1)
#expect(enJuego.directo(id: 2) == nil)
}
@Test func cerrarNoHaceNadaMientrasSeJuega() {
let enJuego = PartidoEnJuego()
_ = enJuego.empezar(directo(1, msPorMinuto: 60_000))
enJuego.cerrar()
#expect(enJuego.directo?.partidoId == 1)
}
@Test func alTerminarSeCierraYDejaEmpezarOtro() async {
let enJuego = PartidoEnJuego()
_ = enJuego.empezar(directo(1))
await enJuego.tarea?.value
#expect(enJuego.empezar(directo(2)))
await enJuego.tarea?.value
enJuego.cerrar()
#expect(enJuego.directo == nil)
}
// Sustituye a la prueba del ciclo del ViewModel: el directo vive en el partido en juego, y al
// cerrarlo nadie lo retiene (con `[weak self]` en el cronómetro).
@Test func elDirectoSeLiberaAlCerrar() async {
let enJuego = PartidoEnJuego()
weak var referencia: Directo?
do {
let directo = directo(1)
referencia = directo
_ = enJuego.empezar(directo)
}
await enJuego.tarea?.value
#expect(referencia != nil) // terminado, sigue ahí («Final»)
enJuego.cerrar()
#expect(referencia == nil)
}
}Lanza Cmd+U.
Punto de control
- Cmd+B termina en
Build Succeededy Cmd+U da 66 pruebas en verde: 50 unitarias y 16 de interfaz (con tus ejercicios de pruebas, 69). Las cinco nuevas son las dePartidoEnJuegoTests. - La app se ve y se comporta igual que antes de la lección: el partido sigue arrancando solo al abrirlo y se cancela al cerrarlo, porque todavía lo lanza el
.taskde la pantalla. Nadie lee elPartidoEnJuego. - La carpeta
EjerciciostieneMiniReproductor, y su vista previa interactiva cambia el texto de abajo al tocar una canción (si el lienzo no responde, el fichero al menos compila). - Sabes decir qué es el estado de toda la app y qué tres piezas lo montan en SwiftUI, por qué
@AppStoragetambién lo es, en qué peldaño guardarías el texto de un campo y en cuál el partido en juego, y por qué laTaskdel partido tiene que guardarla un objeto de toda la app y no un.task.
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.¿Qué es «estado de toda la app»?
2.Una vista declara
@Environment(PartidoEnJuego.self), pero nadie hizo.environment(…)con ese objeto. ¿Qué pasa?3.Dos vistas declaran
@AppStorage("tamanoDeLetra")y una de ellas cambia el valor. ¿Qué ocurre con la otra?4.¿Cuál de estos datos NO debería ser estado de toda la app?
5.¿Por qué
PartidoEnJuegoguarda su propiaTasken lugar de lanzar el partido con.taskdesde la pantalla?