sql >> Base de Datos >  >> NoSQL >> MongoDB

Cómo escribí una aplicación que encabezó las listas de éxitos en una semana con Realm y SwiftUI

Creación de un rastreador de misiones de Elden Ring

Me encantaba Skyrim. Felizmente pasé varios cientos de horas jugando y reproduciéndolo. Entonces, cuando escuché recientemente sobre un nuevo juego, el Skyrim de la década de 2020 , Tuve que comprarlo. Así comienza mi saga con Elden Ring, el enorme juego de rol de mundo abierto con la guía de la historia de George R.R. Martin.

En la primera hora del juego, aprendí lo brutales que pueden ser los juegos de Souls. Me deslicé en interesantes cuevas en los acantilados solo para morir tan adentro que no pude recuperar mi cadáver.

Perdí todas mis runas.

Me quedé boquiabierto con asombro mientras bajaba en el ascensor hasta el río Siofra, solo para descubrir que me esperaba una muerte espeluznante, lejos del sitio de gracia más cercano. Me escapé valientemente antes de que pudiera morir de nuevo.

Conocí figuras fantasmales y NPC fascinantes que me tentaron con algunas líneas de diálogo... que inmediatamente olvidé tan pronto como las necesité.

10/10, muy recomendable.

Una cosa en particular sobre Elden Ring me molestó:no había un rastreador de misiones. Siempre el buen deporte, abrí un documento de Notas en mi iPhone. Por supuesto, eso no fue suficiente.

Necesitaba una aplicación que me ayudara a rastrear los detalles del juego de rol. Nada en la App Store realmente coincidía con lo que estaba buscando, por lo que aparentemente tendría que escribirlo. Se llama Shattered Ring y ya está disponible en la App Store.

Opciones tecnológicas

Durante el día, escribo documentación para Realm Swift SDK. Recientemente había escrito una aplicación de plantilla de SwiftUI para Realm para proporcionar a los desarrolladores una plantilla de inicio de SwiftUI para construir, completa con flujos de inicio de sesión. El equipo Realm Swift SDK ha estado enviando constantemente características de SwiftUI, lo que lo ha convertido, en mi opinión probablemente parcial, en un punto de partida muy simple para el desarrollo de aplicaciones.

Quería algo que pudiera construir súper rápido, en parte para poder volver a jugar Elden Ring en lugar de escribir una aplicación, y en parte para vencer a otras aplicaciones en el mercado mientras todos todavía hablan de Elden Ring. No pude tomarme meses para construir esta aplicación. Lo quería ayer. Realm + SwiftUI lo haría posible.

Modelado de datos

Sabía que quería realizar un seguimiento de las misiones en el juego. El modelo de búsqueda fue fácil:

class Quest: Object, ObjectKeyIdentifiable {
    @Persisted(primaryKey: true) var _id: ObjectId
    @Persisted var name = ""
    @Persisted var isComplete = false
    @Persisted var notes = ""
}

Todo lo que realmente necesitaba era un nombre, un bool para alternar cuando la búsqueda estaba completa, un campo de notas y un identificador único.

Sin embargo, mientras pensaba en mi juego, me di cuenta de que no solo necesitaba misiones, sino que también quería realizar un seguimiento de las ubicaciones. Tropecé con, y salí rápidamente cuando comencé a morir, en tantos lugares geniales que probablemente tenían personajes no jugadores (PNJ) interesantes y un botín increíble. Quería poder hacer un seguimiento de si había despejado una ubicación o si simplemente me había escapado de ella, para poder recordar volver más tarde y verificarla una vez que tuviera mejor equipo y más habilidades. Así que agregué un objeto de ubicación:

class Location: Object, ObjectKeyIdentifiable {
    @Persisted(primaryKey: true) var _id: ObjectId
    @Persisted var name = ""
    @Persisted var isCleared = false
    @Persisted var notes = ""
}

Mmm. Eso se parecía mucho al modelo de búsqueda. ¿Realmente necesitaba un objeto separado? Luego pensé en uno de los primeros lugares que visité, la Iglesia de Elleh, que tenía un yunque de herrero. En realidad, todavía no había hecho nada para mejorar mi equipo, pero sería bueno saber qué ubicaciones tenían el yunque de herrero en el futuro cuando quisiera ir a algún lugar para hacer una actualización. Así que agregué otro bool:

@Persisted var hasSmithAnvil = false

Entonces pensé en cómo ese mismo lugar también tenía un comerciante. Podría querer saber en el futuro si una ubicación tenía un comerciante. Así que agregué otro bool:

@Persisted var hasMerchant = false

¡Estupendo! Objeto de ubicación ordenado.

Pero había algo más. Seguí recibiendo todos estos datos interesantes de la historia de los NPC. ¿Y qué sucedió cuando completé una misión? ¿Tendría que volver con un NPC para obtener una recompensa? Eso requeriría que supiera quién me había dado la misión y dónde estaban ubicados. Es hora de agregar un tercer modelo, el NPC, que uniría todo:

class NPC: Object, ObjectKeyIdentifiable {
    @Persisted(primaryKey: true) var _id: ObjectId
    @Persisted var name = ""
    @Persisted var isMerchant = false
    @Persisted var locations = List<Location>()
    @Persisted var quests = List<Quest>()
    @Persisted var notes = ""
}

¡Estupendo! Ahora podía rastrear a los NPC. Podría agregar notas para ayudarme a realizar un seguimiento de esos datos interesantes de la historia mientras esperaba a ver qué se desarrollaba. Podría asociar misiones y ubicaciones con NPC. Después de agregar este objeto, se hizo evidente que este era el objeto que conectaba a los demás. Los NPC están en las ubicaciones. Pero sabía por algunas lecturas en línea que a veces los NPC se mueven en el juego, por lo que las ubicaciones tendrían que admitir múltiples entradas, de ahí la lista. Los NPC dan misiones. Pero eso también debería ser una lista, porque el primer NPC que conocí me dio más de una misión. Varre, justo afuera del Shattered Graveyard cuando ingresas al juego por primera vez, me dijo que "siga los hilos de la gracia" y "ve al castillo". ¡Correcto, solucionado!

Ahora podría usar mis objetos con contenedores de propiedad de SwiftUI para comenzar a crear la interfaz de usuario.

Vistas de SwiftUI + Envolturas de propiedades mágicas de Realm

Dado que todo depende del NPC, comenzaría con las vistas de NPC. El @ObservedResults El contenedor de propiedades le brinda una manera fácil de hacer esto.

struct NPCListView: View {
    @ObservedResults(NPC.self) var npcs

    var body: some View {
        VStack {
            List {
                ForEach(npcs) { npc in
                    NavigationLink {
                        NPCDetailView(npc: npc)
                    } label: {
                        NPCRow(npc: npc)
                    }
                }
                .onDelete(perform: $npcs.remove)
                .navigationTitle("NPCs")
            }
            .listStyle(.inset)
        }
    }
}

Ahora podía iterar a través de una lista de todos los NPC, tenía un onDelete automático acción para eliminar NPC, y podría agregar la implementación de Realm de .searchable cuando estaba listo para agregar búsqueda y filtrado. Y era básicamente una línea para conectarlo a mi modelo de datos. ¿Mencioné que Realm + SwiftUI es increíble? Fue bastante fácil hacer lo mismo con Ubicaciones y Misiones, y hacer posible que los usuarios de la aplicación se sumergieran en sus datos a través de cualquier ruta.

Entonces, mi vista detallada de NPC podría funcionar con @ObservedRealmObject contenedor de propiedades para mostrar los detalles del NPC y facilitar la edición del NPC:

struct NPCDetailView: View {
    @ObservedRealmObject var npc: NPC

    var body: some View {
        VStack {
            HStack {
            Text("Notes")
                 .font(.title2)
                 Spacer()
            if npc.isMerchant {
                Image(systemName: "dollarsign.square.fill")
            }
        Spacer()
        Text($npc.notes)
        Spacer()
        }
    }
}

Otro beneficio del @ObservedRealmObject fue que podía usar el $ notación para iniciar una escritura rápida, por lo que el campo de notas solo sería editable. Los usuarios podían tocar y simplemente agregar más notas, y Realm simplemente guardaba los cambios. No se necesita una vista de edición independiente ni abrir una transacción de escritura explícita para actualizar las notas.

En este punto, tenía una aplicación que funcionaba y podría haberla enviado fácilmente.

Pero… tuve un pensamiento.

Una de las cosas que me encantaron de los juegos de rol de mundo abierto fue reproducirlos como diferentes personajes y con diferentes opciones. Así que tal vez me gustaría volver a jugar Elden Ring como una clase diferente. O, tal vez, este no era un rastreador de Elden Ring específicamente, pero tal vez podría usarlo para rastrear cualquier juego de rol. ¿Qué pasa con mis juegos de D&D?

Si quería rastrear varios juegos, necesitaba agregar algo a mi modelo. Necesitaba un concepto de algo como un juego o una jugada.

Iterando en el modelo de datos

Necesitaba algún objeto para abarcar los PNJ, las ubicaciones y las misiones que formaban parte de esto playthrough, para poder mantenerlos separados de otros playthroughs. ¿Y qué si eso fuera un juego?

class Game: Object, ObjectKeyIdentifiable {
    @Persisted(primaryKey: true) var _id: ObjectId
    @Persisted var name = ""
    @Persisted var npcs = List<NPC>()
    @Persisted var locations = List<Location>()
    @Persisted var quests = List<Quest>()
}

¡Bien! Estupendo. Ahora puedo realizar un seguimiento de los PNJ, las ubicaciones y las misiones de este juego y diferenciarlos de otros juegos.

El objeto Game era fácil de concebir, pero cuando comencé a pensar en @ObservedResults en mi opinión, me di cuenta de que eso ya no funcionaría. @ObservedResults devolver todos los resultados para un tipo de objeto específico. Entonces, si quisiera mostrar solo los NPC para este juego, tendría que cambiar mis vistas.*

  • La versión 10.24.0 de Swift SDK agregó la capacidad de usar la sintaxis de Swift Query en @ObservedResults , que le permite filtrar los resultados usando el where parámetro. ¡Definitivamente estoy refactorizando para usar esto en una versión futura! El equipo de Swift SDK ha estado lanzando constantemente nuevas funciones de SwiftUI.

Vaya. Además, necesitaría una forma de distinguir los NPC en este juego de los de otros juegos. Hrm. Ahora podría ser el momento de investigar el backlinking. Después de explorar Realm Swift SDK Docs, agregué esto al modelo NPC:

@Persisted(originProperty: "npcs") var npcInGame: LinkingObjects<Game>

Ahora podría vincular los NPC al objeto Game. Pero, por desgracia, ahora mis puntos de vista se vuelven más complicados.

Actualización de las vistas de SwiftUI para los cambios de modelo

Como solo quiero un subconjunto de mis objetos ahora (y esto fue antes de @ObservedResults actualización), cambié mis vistas de lista de @ObservedResults a @ObservedRealmObject , observando el juego:

@ObservedRealmObject var game: Game

Ahora todavía obtengo los beneficios de la escritura rápida para agregar y editar NPC, ubicaciones y misiones en el juego, pero mi código de lista tuvo que actualizarse un poco:

ForEach(game.npcs) { npc in
    NavigationLink {
        NPCDetailView(npc: npc)
    } label: {
        NPCRow(npc: npc)
    }
}
.onDelete(perform: $game.npcs.remove

Todavía no está mal, pero otro nivel de relaciones a considerar. Y dado que esto no está usando @ObservedResults , no pude usar la implementación de Realm de .searchable , pero tendría que implementarlo yo mismo. No es gran cosa, pero más trabajo.

Objetos congelados y agregados a listas

Ahora, hasta este punto, tengo una aplicación que funciona. Podría enviar esto como está. Todo sigue siendo simple con los contenedores de propiedades Realm Swift SDK haciendo todo el trabajo.

Pero quería que mi aplicación hiciera más.

Quería poder agregar ubicaciones y misiones desde la vista de PNJ y que se agregaran automáticamente al PNJ. Y quería poder ver y agregar un asignador de misiones desde la vista de misiones. Y quería poder ver y agregar NPC a las ubicaciones desde la vista de ubicación.

Todo esto requería agregar muchas listas, y cuando comencé a intentar hacer esto con escrituras rápidas después de crear el objeto, me di cuenta de que no iba a funcionar. Tendría que pasar objetos manualmente y agregarlos.

Lo que quería era hacer algo como esto:

func addLocationToNpc(npc: NPC, game: Game, locationName: String) {
    let realm = try! Realm()
    let thisLocation = game.locations.where { $0.name == locationName }.first!

    try! realm.write {
        npc!.locations.append(thisLocation)
    }
}

Aquí es donde algo que no era del todo obvio para mí como nuevo desarrollador comenzó a interponerse en mi camino. Realmente nunca antes había tenido que hacer nada con subprocesos y objetos congelados, pero recibía bloqueos cuyos mensajes de error me hacían pensar que esto estaba relacionado con eso. Afortunadamente, recordé haber escrito un ejemplo de código sobre cómo descongelar objetos congelados para que pueda trabajar con ellos en otros subprocesos, por lo que volví a los documentos, esta vez a la página Subprocesos que cubre Objetos congelados. (Más mejoras que el equipo Realm Swift SDK ha agregado desde que me uní a MongoDB - ¡bien!)

Después de visitar los documentos, tuve algo como esto:

func addLocationToNpc(npc: NPC, game: Game, locationName: String) {
    let realm = try! Realm()
    Let thawedNPC = npc.thaw()
    let thisLocation = game.locations.where { $0.name == locationName }.first!

    try! realm.write {
        thawedNPC!.locations.append(thisLocation)
    }
}

Eso se veía bien, pero aún se estaba estrellando. ¿Pero por qué? (Aquí es cuando me maldije por no proporcionar un ejemplo de código más completo en los documentos. ¡Trabajar en esta aplicación definitivamente ha producido algunos tickets para mejorar nuestra documentación en algunas áreas!)

Después de explorar los foros y consultar el gran oráculo de Google, me encontré con un hilo en el que alguien hablaba sobre este tema. Resulta que tiene que descongelar no solo el objeto al que está tratando de agregar, sino también lo que está tratando de agregar. Esto puede ser obvio para un desarrollador más experimentado, pero me hizo tropezar por un tiempo. Así que lo que realmente necesitaba era algo como esto:

func addLocationToNpc(npc: NPC, game: Game, locationName: String) {
    let realm = try! Realm()
    let thawedNpc = npc.thaw()
    let thisLocation = game.locations.where { $0.name == locationName     }.first!
    let thawedLocation = thisLocation.thaw()!

    try! realm.write {
        thawedNpc!.locations.append(thawedLocation)
    }
}

¡Estupendo! Problema resuelto. Ahora podía crear todas las funciones que necesitaba para manejar manualmente la adición (y la eliminación, según parece) de objetos.

Todo lo demás es solo SwiftUI

Después de esto, todo lo demás que tuve que aprender para producir la aplicación fue solo SwiftUI, como filtrar, hacer que los filtros sean seleccionables por el usuario y cómo implementar mi propia versión de .searchable .

Definitivamente hay algunas cosas que estoy haciendo con la navegación que no son óptimas. Hay algunas mejoras de UX que todavía quiero hacer. Y cambiando mi @ObservedRealmObject var game: Game volver a @ObservedResults con el nuevo material de filtrado ayudará con algunas de esas mejoras. Pero, en general, los contenedores de propiedades Realm Swift SDK hicieron que implementar esta aplicación fuera lo suficientemente simple como para que incluso yo pudiera hacerlo.

En total, creé la aplicación en dos fines de semana y algunas noches entre semana. Probablemente un fin de semana de ese tiempo me quedé atascado con el problema de agregar a las listas y también crear un sitio web para la aplicación, obtener todas las capturas de pantalla para enviar a la App Store y todas las cosas de "negocios" que conlleva ser un desarrollador de aplicaciones independientes.

Pero estoy aquí para decirte que si yo, un desarrollador menos experimentado con exactamente una aplicación anterior a mi nombre, y con muchos comentarios de mi líder, puedo crear una aplicación como Shattered Ring, tú también puedes. Y es muchísimo más fácil con SwiftUI + las funciones SwiftUI de Realm Swift SDK. Consulte el inicio rápido de SwiftUI para ver un buen ejemplo y ver lo fácil que es.