MongoDB
 sql >> Base de Dados >  >> NoSQL >> MongoDB

Como escrevi um aplicativo no topo das paradas em uma semana com Realm e SwiftUI

Construindo um Rastreador de Missões Elden Ring


Amei Skyrim. Eu alegremente passei várias centenas de horas jogando e repetindo. Então, quando ouvi falar recentemente de um novo jogo, o Skyrim da década de 2020 , Eu tenho que comprar isso. Assim começa minha saga com Elden Ring, o massivo RPG de mundo aberto com orientação de história de George R.R. Martin.

Na primeira hora do jogo, aprendi como os jogos Souls podem ser brutais. Entrei em cavernas interessantes à beira do penhasco apenas para morrer tão longe que não consegui recuperar meu cadáver.

Perdi todas as minhas runas.

Fiquei boquiaberta maravilhada enquanto descia de elevador até o rio Siofra, apenas para descobrir que a morte horrível me esperava, longe do local de graça mais próximo. Eu corajosamente fugi antes que pudesse morrer novamente.

Conheci figuras fantasmagóricas e NPCs fascinantes que me tentaram com algumas linhas de diálogo… que esqueci imediatamente assim que precisei.

10/10, altamente recomendado.

Uma coisa em particular sobre Elden Ring me irritou - não havia rastreador de missões. Sempre o bom esporte, abri um documento do Notes no meu iPhone. Claro, isso não foi o suficiente.

Eu precisava de um aplicativo para me ajudar a rastrear os detalhes do jogo de RPG. Nada na App Store realmente combinava com o que eu estava procurando, então aparentemente eu precisaria escrevê-lo. Chama-se Shattered Ring e já está disponível na App Store.


Opções de tecnologia


Durante o dia, escrevo documentação para o Realm Swift SDK. Recentemente, escrevi um aplicativo de modelo SwiftUI para o Realm para fornecer aos desenvolvedores um modelo inicial SwiftUI para construir, completo com fluxos de login. A equipe do Realm Swift SDK tem enviado constantemente recursos do SwiftUI, o que o tornou - na minha opinião provavelmente tendenciosa - um ponto de partida simples para o desenvolvimento de aplicativos.

Eu queria algo que eu pudesse construir super rápido - parcialmente para que eu pudesse voltar a jogar Elden Ring em vez de escrever um aplicativo, e parcialmente para vencer outros aplicativos no mercado enquanto todos ainda estão falando sobre Elden Ring. Eu não poderia levar meses para construir este aplicativo. Eu queria ontem. Realm + SwiftUI tornaria isso possível.

Modelagem de dados


Eu sabia que queria rastrear missões no jogo. O modelo de missão foi fácil:

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

Tudo o que eu realmente precisava era de um nome, um bool para alternar quando a missão estivesse completa, um campo de notas e um identificador exclusivo.

Enquanto pensava na minha jogabilidade, porém, percebi que não precisava apenas de missões - eu também queria acompanhar os locais. Eu tropecei - e rapidamente quando comecei a morrer - tantos lugares legais que provavelmente tinham personagens não-jogadores (NPCs) interessantes e itens incríveis. Eu queria ser capaz de acompanhar se eu tinha limpado um local, ou apenas fugido dele, para que eu pudesse me lembrar de voltar mais tarde e verificá-lo quando eu tivesse equipamentos melhores e mais habilidades. Então eu adicionei um objeto de localização:

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

Hum. Isso se parecia muito com o modelo de busca. Eu realmente precisava de um objeto separado? Então pensei em um dos primeiros locais que visitei - a Igreja de Elleh - que tinha uma bigorna de ferreiro. Na verdade, eu ainda não tinha feito nada para melhorar meu equipamento, mas pode ser bom saber quais locais terão a bigorna de ferreiro no futuro quando eu quiser ir a algum lugar para fazer uma atualização. Então eu adicionei outro bool:

@Persisted var hasSmithAnvil = false

Então pensei em como esse mesmo local também tinha um comerciante. Eu poderia querer saber no futuro se um local tinha um comerciante. Então eu adicionei outro bool:

@Persisted var hasMerchant = false

Excelente! Objeto de localização classificado.

Mas... havia algo mais. Eu continuei recebendo todos esses detalhes interessantes da história dos NPCs. E o que aconteceu quando eu completei uma missão - eu precisaria voltar a um NPC para coletar uma recompensa? Isso exigiria que eu soubesse quem havia me dado a missão e onde eles estavam localizados. Hora de adicionar um terceiro modelo, o NPC, que uniria tudo:

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 = ""
}

Excelente! Agora eu podia rastrear NPCs. Eu poderia adicionar notas para me ajudar a acompanhar esses detalhes interessantes da história enquanto esperava para ver o que aconteceria. Eu poderia associar missões e locais com NPCs. Depois de adicionar este objeto, ficou óbvio que este era o objeto que conectava os outros. NPCs estão em locais. Mas eu sabia de algumas leituras on-line que às vezes os NPCs se movem no jogo, então os locais teriam que suportar várias entradas - daí a lista. NPCs dão missões. Mas isso também deveria ser uma lista, porque o primeiro NPC que conheci me deu mais de uma missão. Varre, do lado de fora do Cemitério Despedaçado quando você entra no jogo pela primeira vez, me disse para “Siga os fios da graça” e “vá para o castelo”. Certo, ordenado!

Agora eu poderia usar meus objetos com wrappers de propriedade SwiftUI para começar a criar a interface do usuário.

Visualizações do SwiftUI + Wrappers de propriedades mágicas do Realm


Como tudo depende do NPC, eu começaria com as visualizações do NPC. O @ObservedResults O wrapper de propriedade oferece uma maneira fácil de fazer isso.

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)
        }
    }
}

Agora eu podia percorrer uma lista de todos os NPCs, tinha um onDelete automático ação para remover NPCs e pode adicionar a implementação do Realm de .searchable quando eu estava pronto para adicionar pesquisa e filtragem. E era basicamente uma linha para conectá-lo ao meu modelo de dados. Eu mencionei que Realm + SwiftUI é incrível? Foi bastante fácil fazer a mesma coisa com Locais e Missões e possibilitar que os usuários do aplicativo mergulhem em seus dados por qualquer caminho.

Então, minha visão de detalhes do NPC poderia funcionar com o @ObservedRealmObject wrapper de propriedade para exibir os detalhes do NPC e facilitar a edição do 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()
        }
    }
}

Outro benefício do @ObservedRealmObject era que eu poderia usar o $ notação para iniciar uma escrita rápida, então o campo de notas seria apenas editável. Os usuários poderiam tocar e apenas adicionar mais notas, e o Realm apenas salvaria as alterações. Não há necessidade de uma visualização de edição separada ou de abrir uma transação de gravação explícita para atualizar as notas.

Nesse ponto, eu tinha um aplicativo funcionando e poderia facilmente tê-lo enviado.

Mas... eu tive um pensamento.

Uma das coisas que eu adorava nos jogos de RPG de mundo aberto era reproduzi-los como personagens diferentes e com escolhas diferentes. Então, talvez eu queira repetir Elden Ring como uma classe diferente. Ou - talvez este não fosse um rastreador de Elden Ring especificamente, mas talvez eu pudesse usá-lo para rastrear qualquer jogo de RPG. E os meus jogos de D&D?

Se eu quisesse rastrear vários jogos, precisava adicionar algo ao meu modelo. Eu precisava de um conceito de algo como um jogo ou uma jogada.

Iterando no modelo de dados


Eu precisava de algum objeto para abranger os NPCs, Locais e Missões que faziam parte isso playthrough, para que eu pudesse mantê-los separados de outros playthroughs. E daí se isso fosse um jogo?

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>()
}

Tudo bem! Excelente. Agora posso rastrear os NPCs, locais e missões que estão neste jogo e mantê-los distintos de outros jogos.

O objeto Game foi fácil de conceber, mas quando comecei a pensar no @ObservedResults na minha opinião, percebi que não funcionaria mais. @ObservedResults retornar todos os resultados para um tipo de objeto específico. Então, se eu quisesse exibir apenas os NPCs deste jogo, precisaria mudar meus pontos de vista.*
  • Swift SDK versão 10.24.0 adicionou a capacidade de usar a sintaxe Swift Query em @ObservedResults , que permite filtrar os resultados usando o where parâmetro. Estou definitivamente refatorando para usar isso em uma versão futura! A equipe do Swift SDK tem lançado constantemente novos itens do SwiftUI.

Oh. Além disso, eu precisaria de uma maneira de distinguir os NPCs neste jogo dos de outros jogos. Hm. Agora pode ser hora de olhar para backlinking. Depois de explorar o Realm Swift SDK Docs, adicionei isso ao modelo NPC:

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

Agora eu poderia vincular os NPCs ao objeto Game. Mas, infelizmente, agora meus pontos de vista ficam mais complicados.

Atualizando visualizações SwiftUI para as alterações de modelo


Como eu quero apenas um subconjunto de meus objetos agora (e isso foi antes do @ObservedResults update), mudei minhas visualizações de lista de @ObservedResults para @ObservedRealmObject , observando o jogo:

@ObservedRealmObject var game: Game

Agora eu ainda recebo os benefícios da escrita rápida para adicionar e editar NPCs, locais e missões no jogo, mas meu código de lista teve que atualizar um pouco:

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

Ainda não é ruim, mas outro nível de relacionamentos a considerar. E como isso não está usando @ObservedResults , não consegui usar a implementação do Realm de .searchable , mas teria que implementá-lo eu mesmo. Não é grande coisa, mas mais trabalho.

Objetos congelados e anexando a listas


Agora, até este ponto, eu tenho um aplicativo de trabalho. Eu poderia enviar isso como está. Tudo ainda é simples com os wrappers de propriedade do Realm Swift SDK fazendo todo o trabalho.

Mas eu queria que meu aplicativo fizesse mais.

Eu queria ser capaz de adicionar Locais e Missões a partir da visualização do NPC e tê-los anexados automaticamente ao NPC. E eu queria poder ver e adicionar um doador de missões na visão de missões. E eu queria poder visualizar e adicionar NPCs a locais a partir da visualização de local.

Tudo isso exigia muitos acréscimos a listas e, quando comecei a tentar fazer isso com gravações rápidas depois de criar o objeto, percebi que não ia funcionar. Eu teria que passar manualmente os objetos e anexá-los.

O que eu queria era fazer algo assim:

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)
    }
}

Foi aí que algo que não era totalmente óbvio para mim como um novo desenvolvedor começou a me atrapalhar. Eu nunca tive que fazer nada com threading e objetos congelados antes, mas eu estava recebendo falhas cujas mensagens de erro me faziam pensar que isso estava relacionado a isso. Felizmente, lembrei-me de escrever um exemplo de código sobre o descongelamento de objetos congelados para que você possa trabalhar com eles em outros threads, então voltei aos documentos - desta vez à página Threading que abrange objetos congelados. (Mais melhorias que a equipe do Realm Swift SDK adicionou desde que entrei no MongoDB - yay!)

Depois de visitar os documentos, tive algo assim:

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)
    }
}

Isso parecia certo, mas ainda estava travando. Mas por que? (Foi quando eu me xinguei por não fornecer um exemplo de código mais completo nos documentos. Trabalhar neste aplicativo definitivamente produziu alguns tickets para melhorar nossa documentação em algumas áreas!)

Depois de pesquisar nos fóruns e consultar o grande oráculo Google, me deparei com um tópico onde alguém estava falando sobre esse assunto. Acontece que você precisa descongelar não apenas o objeto ao qual está tentando anexar, mas também o que está tentando anexar. Isso pode ser óbvio para um desenvolvedor mais experiente, mas me fez tropeçar por um tempo. Então o que eu realmente precisava era algo assim:

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)
    }
}

Excelente! Problema resolvido. Agora eu poderia criar todas as funções necessárias para lidar manualmente com a adição (e remoção, como se vê) de objetos.

Todo o resto é apenas SwiftUI


Depois disso, tudo o que tive que aprender para produzir o aplicativo foi apenas SwiftUI, como filtrar, como tornar os filtros selecionáveis ​​pelo usuário e como implementar minha própria versão de .searchable .



Definitivamente, há algumas coisas que estou fazendo com a navegação que são menos do que ideais. Existem algumas melhorias de UX que ainda quero fazer. E trocando meu @ObservedRealmObject var game: Game de volta para @ObservedResults com o novo material de filtragem ajudará com algumas dessas melhorias. Mas, no geral, os wrappers de propriedade do Realm Swift SDK tornaram a implementação desse aplicativo simples o suficiente para que até eu pudesse fazê-lo.



No total, construí o aplicativo em dois fins de semana e algumas noites da semana. Provavelmente, em um fim de semana daquela época, eu estava ficando preso com o problema de anexar às listas e também criando um site para o aplicativo, obtendo todas as capturas de tela para enviar para a App Store e todas as coisas “negócios” que acompanham ser um desenvolvedor de aplicativos independentes.



Mas estou aqui para lhe dizer que se eu, um desenvolvedor menos experiente com exatamente um aplicativo anterior em meu nome - e com muito feedback do meu líder - puder fazer um aplicativo como o Shattered Ring, você também pode. E é muito mais fácil com o SwiftUI + os recursos SwiftUI do Realm Swift SDK. Confira o SwiftUI Quick Start para um bom exemplo e veja como é fácil.