Criando um aplicativo de bate-papo muito primitivo em SwiftUI, usando Swift e WebSockets para criar o servidor de bate-papo. É Swift de cima a baixo, abelha!
Neste tutorial faremos um aplicativo de bate-papo bastante primitivo, mas funcional. O aplicativo será executado em iOS ou macOS – ou ambos! A beleza do SwiftUI é o pouco esforço necessário para criar um aplicativo multiplataforma.
É claro que um aplicativo de bate-papo terá muito pouca utilidade sem um servidor para conversar. Portanto, também criaremos um servidor de bate-papo muito primitivo, utilizando WebSockets. Tudo será construído em Swift e executado localmente em sua máquina.
Este tutorial pressupõe que você já tenha alguma experiência no desenvolvimento de aplicativos iOS/macOS usando SwiftUI. Embora os conceitos sejam explicados à medida que avançamos, nem tudo será abordado em profundidade. Escusado será dizer que se você digitar e seguir os passos, ao final deste tutorial você terá um aplicativo de chat funcional (para iOS e/ou macOS), que se comunica com um servidor que você também criou! Você também terá uma compreensão básica de conceitos como Swift e WebSockets do lado do servidor.
Se nada disso lhe interessa, você pode rolar até o final e baixar o código-fonte final!
Resumindo, começaremos criando um servidor muito simples, simples e sem recursos. Construiremos o servidor como um pacote Swift e, em seguida, adicionaremos o framework web Vapor como uma dependência. Isso nos ajudará a configurar um servidor WebSocket com apenas algumas linhas de código.
Depois começaremos a construir o aplicativo de chat frontend. Começando rapidamente com o básico e depois adicionando recursos (e necessidades) um por um.
Passaremos a maior parte do nosso tempo trabalhando no aplicativo, mas iremos alternar entre o código do servidor e o código do aplicativo à medida que adicionamos novos recursos.
Opcional
Vamos começar!
Abra o Xcode 12 e inicie um novo projeto ( Arquivo > Novo Projeto ). Em Multiplataforma selecione Pacote Swift .
Chame o pacote de algo lógico - algo autoexplicativo - como " ChatServer ". Em seguida, salve-o onde quiser.
Pacote rápido?
Ao criar uma estrutura ou software multiplataforma (por exemplo, Linux) em Swift, os pacotes Swift são a opção preferida. Eles são a solução oficial para a criação de código modular que outros projetos Swift podem usar facilmente. Porém, um pacote Swift não precisa necessariamente ser um projeto modular: ele também pode ser um executável independente que simplesmente usa outros pacotes Swift como dependências (que é o que estamos fazendo).
Pode ter ocorrido a você que não há nenhum projeto Xcode (
.xcodeproj
) presente para o pacote Swift. Para abrir um pacote Swift no Xcode como qualquer outro projeto, basta abrir o arquivoPackage.swift
. O Xcode deve reconhecer que você está abrindo um pacote Swift e abrir toda a estrutura do projeto. Ele irá buscar automaticamente todas as dependências no início.Você pode ler mais sobre Swift Packages e Swift Package Manager no site oficial do Swift.
Para lidar com todo o trabalho pesado de configurar um servidor, usaremos o framework web Vapor. O Vapor vem com todos os recursos necessários para criar um servidor WebSocket.
WebSockets?
Para fornecer à web a capacidade de se comunicar com um servidor em tempo real, foram criados WebSockets. É uma especificação bem descrita para comunicação segura em tempo real (baixa largura de banda) entre um cliente e um servidor. Ex: jogos multijogador e aplicativos de bate-papo. Aqueles jogos multijogador viciantes no navegador que você joga no valioso tempo de empresa? Sim, WebSockets!
No entanto, se você deseja fazer algo como streaming de vídeo em tempo real, é melhor procurar uma solução diferente. ?
Embora estejamos criando um aplicativo de bate-papo para iOS/macOS neste tutorial, o servidor que estamos criando pode facilmente se comunicar com outras plataformas com WebSockets. Na verdade: se quiser também pode fazer uma versão Android e web desta aplicação de chat, conversando com o mesmo servidor e permitindo a comunicação entre todas as plataformas!
Vapor?
A internet é uma série complexa de tubos. Até mesmo responder a uma simples solicitação HTTP requer uma grande quantidade de código. Felizmente, especialistas na área desenvolveram estruturas web de código aberto que fazem todo o trabalho duro para nós há décadas, em várias linguagens de programação. Vapor é um deles e está escrito em Swift. Ele já vem com alguns recursos do WebSocket e é exatamente o que precisamos.
O Vapor não é o único framework web baseado em Swift. Kitura e Perfect também são frameworks bem conhecidos. Embora o Vapor seja indiscutivelmente mais ativo em seu desenvolvimento.
O Xcode deve abrir o arquivo Package.swift
por padrão. É aqui que colocamos informações gerais e requisitos do nosso pacote Swift.
Antes de fazermos isso, procure na pasta Sources/ChatServer
. Deve ter um arquivo ChatServer.swift
. Precisamos renomear isso para main.swift
. Feito isso, retorne para Package.swift
.
Em products:
, remova o seguinte valor:
. library ( name : " ChatServer " , targets : [ " ChatServer " ] )
... e substitua-o por:
. executable ( name : " ChatServer " , targets : [ " ChatServer " ] )
Afinal, nosso servidor não é uma Biblioteca. Mas sim um executável independente. Devemos também definir as plataformas (e a versão mínima) nas quais esperamos que nosso servidor rode. Isso pode ser feito adicionando platforms: [.macOS(v10_15)]
sob name: "ChatServer"
:
name: " ChatServer " ,
platforms: [
. macOS ( . v10_15 ) ,
] ,
Tudo isso deve tornar nosso pacote Swift 'executável' no Xcode.
Tudo bem, vamos adicionar o Vapor como uma dependência. Nas dependencies: []
(que deve ter algumas coisas comentadas), adicione o seguinte:
. package ( url : " https://github.com/vapor/vapor.git " , from : " 4.0.0 " )
Ao salvar o arquivo Package.swift
, o Xcode deve começar a buscar automaticamente as dependências do Vapor com a versão 4.0.0
ou mais recente. Bem como todas as suas dependências.
Só precisamos fazer mais um ajuste no arquivo enquanto o Xcode faz seu trabalho: adicionar a dependência ao nosso alvo. Nos targets:
você encontrará um .target(name: "ChatServer", dependencies: [])
. Nessa matriz vazia, adicione o seguinte:
. product ( name : " Vapor " , package : " vapor " )
É isso . Nosso Package.swift
está pronto. Descrevemos nosso pacote Swift dizendo:
O Package.swift
final deve ficar assim (-ish):
// swift-tools-version:5.3
import PackageDescription
let package = Package (
name : " ChatServer " ,
platforms : [
. macOS ( . v10_15 ) ,
] ,
products : [
. executable ( name : " ChatServer " , targets : [ " ChatServer " ] )
] ,
dependencies : [
. package ( url : " https://github.com/vapor/vapor.git " , from : " 4.0.0 " )
] ,
targets : [
. target (
name : " ChatServer " ,
dependencies : [
. product ( name : " Vapor " , package : " vapor " )
] ) ,
]
)
Agora, finalmente chegou a hora de...
No Xcode, abra Sources/ChatServer/main.swift
e exclua tudo que estiver lá. Não vale nada para nós. Em vez disso, faça com que main.swift
fique assim:
import Vapor
var env = try Environment . detect ( ) // 1
let app = Application ( env ) // 2
defer { // 3
app . shutdown ( )
}
app . webSocket ( " chat " ) { req , client in // 4
print ( " Connected: " , client )
}
try app . run ( ) // 5
? Bam! Isso é tudo o que você precisa para iniciar um servidor (WebSocket) usando Vapor. Veja como isso foi fácil.
defer
e chame .shutdown()
que realizará qualquer limpeza ao sair do programa./chat
. Agora
Depois que o programa for executado com êxito, você poderá não ver nada parecido com um aplicativo. Isso ocorre porque o software de servidor não costuma ter interfaces gráficas de usuário. Mas fique tranquilo, o programa está vivo e bem em segundo plano, girando. O console Xcode deve mostrar a seguinte mensagem, entretanto:
notice codes.vapor.application : Server starting on http://127.0.0.1:8080
Isso significa que o servidor pode ouvir com êxito as solicitações recebidas. Isso é ótimo, porque agora temos um servidor WebSocket ao qual podemos começar a nos conectar!
Eu não acredito em você?
Se por algum motivo você acha que não contei nada além de mentiras hediondas esse tempo todo, você mesmo pode testar o servidor!
Abra seu navegador favorito e certifique-se de estar em uma guia vazia. (Se for Safari, você precisará ativar o modo Desenvolvedor primeiro.) Abra o Inspetor (
Cmd
+Option
+I
) e vá para o Console . Digitenew WebSocket ( 'ws://localhost:8080/chat' )e clique em Retornar. Agora dê uma olhada no console Xcode. Se tudo correr bem, agora deverá mostrar
Connected: WebSocketKit.WebSocket
.????
O servidor só pode ser acessado em sua máquina local. Isso significa que você não pode conectar seu iPhone/iPad físico ao servidor. Em vez disso, usaremos o Simulador nas etapas a seguir para testar nosso aplicativo de bate-papo.
Para testar o aplicativo de bate-papo em um dispositivo físico, algumas (pequenas) etapas extras precisam ser executadas. Consulte o Apêndice A.
Embora ainda não tenhamos terminado o back-end, é hora de passar para o front-end. O próprio aplicativo de bate-papo!
No Xcode crie um novo projeto. Desta vez, em Multiplataforma selecione App . Novamente, escolha um nome bonito para seu aplicativo e continue. (Eu escolhi o SwiftChat . Concordo, é perfeito ?)
O aplicativo não depende de nenhuma estrutura ou biblioteca externa de terceiros. Na verdade, tudo o que precisamos está disponível via Foundation
, Combine
e SwiftUI
(no Xcode 12+).
Vamos começar a trabalhar na tela de chat imediatamente. Crie um novo arquivo Swift e nomeie- ChatScreen.swift
. Não importa se você escolhe o modelo Swift File ou SwiftUI View . Estamos excluindo tudo nele de qualquer maneira.
Aqui está o kit inicial do ChatScreen.swift
:
import SwiftUI
struct ChatScreen : View {
@ State private var message = " "
var body : some View {
VStack {
// Chat history.
ScrollView { // 1
// Coming soon!
}
// Message field.
HStack {
TextField ( " Message " , text : $message ) // 2
. padding ( 10 )
. background ( Color . secondary . opacity ( 0.2 ) )
. cornerRadius ( 5 )
Button ( action : { } ) { // 3
Image ( systemName : " arrowshape.turn.up.right " )
. font ( . system ( size : 20 ) )
}
. padding ( )
. disabled ( message . isEmpty ) // 4
}
. padding ( )
}
}
}
Em ContentsView.swift
, substitua Hello World por ChatScreen()
:
struct ContentView : View {
var body : some View {
ChatScreen ( )
}
}
Uma tela em branco, por enquanto.
Esquerda: iPhone com aparência escura. À direita: iPad com aparência clara.
O que temos aqui:
Se você deseja fazer escolhas de design diferentes, vá em frente. ?
Agora vamos começar a trabalhar em alguma lógica não relacionada à UI: conectar-se ao mesmo servidor que acabamos de criar.
SwiftUI , junto com a estrutura Combine , fornece aos desenvolvedores ferramentas para implementar a separação de preocupações sem esforço em seu código. Usando o protocolo ObservableObject
e os wrappers de propriedade @StateObject
(ou @ObservedObject
), podemos implementar lógica não UI (referida como Business Logic ) em um local separado. Como as coisas deveriam ser! Afinal, a única coisa com a qual a UI deve se preocupar é exibir dados ao usuário e reagir à entrada do usuário. Não deveria se importar de onde vêm os dados ou como são manipulados.
Vindo de uma experiência em React, esse luxo é algo de que tenho muita inveja.
Existem milhares e milhares de artigos e discussões sobre arquitetura de software. Você provavelmente já ouviu ou leu sobre conceitos como MVC, MVVM, VAPOR, Clean Architecture e muito mais. Todos eles têm seus argumentos e suas aplicações.
Discutir isso está fora do escopo deste tutorial. Mas é geralmente aceito que a lógica de negócios e a lógica da UI não devem estar interligadas.
Este conceito é igualmente verdadeiro para o nosso ChatScreen . A única coisa com a qual o ChatScreen deve se preocupar é exibir as mensagens e manipular o texto inserido pelo usuário. Não se importa com ✌️We Bs Oc K eTs✌, nem deveria.
Você pode criar um novo arquivo Swift ou escrever o seguinte código na parte inferior de ChatScreen.swift
. Sua escolha. Onde quer que esteja, não se esqueça dos import
s!
import Combine
import Foundation
final class ChatScreenModel : ObservableObject {
private var webSocketTask : URLSessionWebSocketTask ? // 1
// MARK: - Connection
func connect ( ) { // 2
let url = URL ( string : " ws://127.0.0.1:8080/chat " ) ! // 3
webSocketTask = URLSession . shared . webSocketTask ( with : url ) // 4
webSocketTask ? . receive ( completionHandler : onReceive ) // 5
webSocketTask ? . resume ( ) // 6
}
func disconnect ( ) { // 7
webSocketTask ? . cancel ( with : . normalClosure , reason : nil ) // 8
}
private func onReceive ( incoming : Result < URLSessionWebSocketTask . Message , Error > ) {
// Nothing yet...
}
deinit { // 9
disconnect ( )
}
}
Isso pode ser muito para entender, então vamos examinar isso lentamente:
URLSessionWebSocketTask
em uma propriedade.URLSessionWebSocketTask
são responsáveis pelas conexões WebSocket. Eles são residentes da família URLSession
na estrutura Foundation .127.0.0.1
ou localhost
). A porta padrão dos aplicativos Vapor é 8080
. E colocamos um ouvinte para conexões WebSocket no caminho /chat
.URLSessionWebSocketTask
e o armazenamos na propriedade da instância.onReceive(incoming:)
será chamado. Mais sobre isso mais tarde.ChatScreenModel
for eliminado da memória. Este é um ótimo começo. Agora temos um lugar onde podemos colocar toda a nossa lógica WebSocket sem sobrecarregar o código da UI. É hora de ChatScreen
se comunicar com ChatScreenModel
.
Adicione o ChatScreenModel
como um objeto de estado em ChatScreen
:
struct ChatScreen : View {
@ StateObject private var model = ChatScreenModel ( ) // <- this here
@ State private var message = " "
// etc...
}
Quando devemos nos conectar ao servidor? Bem, quando a tela estiver realmente visível, é claro. Você pode ficar tentado a chamar .connect()
no init()
de ChatScreen
. Isso é uma coisa perigosa. Na verdade, no SwiftUI deve-se tentar evitar colocar qualquer coisa no init()
, pois o View pode ser inicializado mesmo quando nunca aparecerá. (Por exemplo, em LazyVStack
ou em NavigationLink(destination:)
.) Seria uma pena desperdiçar preciosos ciclos de CPU. Portanto, vamos adiar tudo para onAppear
.
Adicione um método onAppear
ao ChatScreen
. Em seguida, adicione e passe esse método para o modificador .onAppear(perform:)
de VStack
:
struct ChatScreen : View {
// ...
private func onAppear ( ) {
model . connect ( )
}
var body : some View {
VStack {
// ...
}
. onAppear ( perform : onAppear )
}
}
Espaço desperdiçado?
Muitas pessoas preferem escrever o conteúdo desses métodos in-line:
. onAppear { model . connect ( ) }Isso nada mais é do que uma preferência pessoal. Pessoalmente gosto de definir esses métodos separadamente. Sim, custa mais espaço. Mas são mais fáceis de encontrar, são reutilizáveis, evitam que o
body
fique (mais) desordenado e são indiscutivelmente mais fáceis de dobrar. ?
Da mesma forma, também devemos desconectar quando a visualização desaparecer. A implementação deve ser autoexplicativa, mas apenas para garantir:
struct ChatScreen : View {
// ...
private func onDisappear ( ) {
model . disconnect ( )
}
var body : some View {
VStack {
// ...
}
. onAppear ( perform : onAppear )
. onDisappear ( perform : onDisappear )
}
}
É muito importante fechar as conexões WebSocket sempre que deixarmos de nos preocupar com elas. Quando você fecha (normalmente) uma conexão WebSocket, o servidor será informado e poderá limpar a conexão da memória. O servidor nunca deve ter conexões inativas ou desconhecidas na memória.
Ufa. Uma jornada e tanto pela qual passamos até agora. É hora de testar.ChatScreen
, você deverá ver a mensagem Connected: WebSocketKit.WebSocket
no console Xcode do servidor. Caso contrário, refaça seus passos e comece a depurar!
Mais uma coisa™️. Também devemos testar se a conexão WebSocket é fechada quando o usuário fecha o aplicativo (ou sai ChatScreen
). Volte para o arquivo main.swift
do projeto do servidor. Atualmente nosso ouvinte WebSocket se parece com isto:
app . webSocket ( " chat " ) { req , client in
print ( " Connected: " , client )
}
Adicione um manipulador ao .onClose
de client
, executando apenas um simples print()
:
app . webSocket ( " chat " ) { req , client in
print ( " Connected: " , client )
client . onClose . whenComplete { _ in
print ( " Disconnected: " , client )
}
}
Execute novamente o servidor e inicie o aplicativo de bate-papo. Assim que o aplicativo estiver conectado, feche-o (saia dele, não apenas coloque-o em segundo plano). O console Xcode do servidor agora deve imprimir Disconnected: WebSocketKit.WebSocket
. Isso confirma que as conexões WebSocket estão realmente fechadas quando não nos importamos mais com elas. Portanto, o servidor não deve ter conexões inativas na memória.
Você está pronto para realmente enviar algo para o servidor? Rapaz, com certeza estou. Mas só por um momento, vamos pisar no freio e pensar por um segundo. Recoste-se na cadeira e olhe sem rumo, mas de alguma forma propositalmente, para o teto...
O que exatamente enviaremos para o servidor? E, igualmente importante, o que receberemos de volta do servidor?
Seu primeiro pensamento pode ser “Bem, é só enviar uma mensagem, certo?”, você estaria meio certo. Mas e quanto à hora da mensagem? E o nome do remetente? Que tal um identificador para tornar a mensagem única em relação a qualquer outra mensagem? Ainda não temos nada para o usuário criar um nome de usuário ou algo assim. Então, vamos deixar isso de lado e focar apenas no envio e recebimento de mensagens.
Teremos que fazer alguns ajustes tanto no lado do aplicativo quanto no lado do servidor. Vamos começar com o servidor.
Crie um novo arquivo Swift em Sources/ChatServer
chamado Models.swift
no projeto do servidor. Cole (ou digite) o seguinte código em Models.swift
:
import Foundation
struct SubmittedChatMessage : Decodable { // 1
let message : String
}
struct ReceivingChatMessage : Encodable , Identifiable { // 2
let date = Date ( ) // 3
let id = UUID ( ) // 4
let message : String // 5
}
Aqui está o que está acontecendo:
Decodable
.Encodable
.ReceivingChatMessage
. Observe como estamos gerando a date
e id
no lado do servidor. Isso torna o servidor a Fonte da Verdade. O servidor sabe que horas são. Se a data fosse gerada no lado do cliente, ela não seria confiável. E se o cliente tiver o relógio configurado para o futuro? Fazer com que o servidor gere a data faz com que seu relógio seja a única referência à hora.
Fusos horários?
O objeto
Date
do Swift sempre tem 00:00:00 UTC 01-01-2001 como horário de referência absoluto. Ao inicializar umDate
ou formatar um para string (por exemplo, viaDateFormatter
), a localidade do cliente será levada em consideração automaticamente. Adicionando ou subtraindo horas dependendo do fuso horário do cliente.
UUID?
Identificadores Universalmente Únicos são globalmente considerados valores aceitáveis para identificadores.
Também não queremos que o cliente envie várias mensagens com o mesmo identificador exclusivo. Seja acidentalmente ou propositalmente maliciosamente. Fazer com que o servidor gere esse identificador é uma camada extra de segurança e menos possíveis fontes de erros.
Agora então. Quando o servidor recebe uma mensagem de um cliente, ele deve repassá-la a todos os outros clientes. No entanto, isso significa que precisamos acompanhar todos os clientes conectados.
Voltar para main.swift
do projeto do servidor. Logo acima de app.webSocket("chat")
coloque a seguinte declaração:
var clientConnections = Set < WebSocket > ( )
É aqui que armazenaremos nossas conexões de clientes.
Mas espere ... Você deve estar recebendo um grande, ruim e desagradável erro de compilação. Isso ocorre porque o objeto WebSocket
não está em conformidade com o protocolo Hashable
por padrão. Não se preocupe, isso pode ser facilmente implementado (embora de forma barata). Adicione o seguinte código na parte inferior de main.swift
:
extension WebSocket : Hashable {
public static func == ( lhs : WebSocket , rhs : WebSocket ) -> Bool {
ObjectIdentifier ( lhs ) == ObjectIdentifier ( rhs )
}
public func hash ( into hasher : inout Hasher ) {
hasher . combine ( ObjectIdentifier ( self ) )
}
}
Badabing badaboom. O código acima é uma maneira rápida, mas simples, de fazer uma class
estar em conformidade com Hashable
(e por definição também Equatable
), simplesmente usando seu endereço de memória como uma propriedade única. Nota : isso só funciona para aulas. As estruturas exigirão uma implementação um pouco mais prática.
Tudo bem, agora que podemos acompanhar os clientes, substitua tudo de app.webSocket("chat")
(incluindo seu fechamento e seu conteúdo) pelo seguinte código?:
app . webSocket ( " chat " ) { req , client in
clientConnections . insert ( client )
client . onClose . whenComplete { _ in
clientConnections . remove ( client )
}
}
Quando um cliente se conecta, armazene esse cliente em clientConnections
. Quando o cliente se desconectar, remova-o do mesmo Set
. Ezpz.
A etapa final deste capítulo é adicionar o coração do servidorclient.onClose.whenComplete
inteiro - mas ainda dentro do fechamento app.webSocket("chat")
- adicione o seguinte trecho de código:
client . onText { _ , text in // 1
do {
guard let data = text . data ( using : . utf8 ) else {
return
}
let incomingMessage = try JSONDecoder ( ) . decode ( SubmittedChatMessage . self , from : data ) // 2
let outgoingMessage = ReceivingChatMessage ( message : incomingMessage . message ) // 3
let json = try JSONEncoder ( ) . encode ( outgoingMessage ) // 4
guard let jsonString = String ( data : json , encoding : . utf8 ) else {
return
}
for connection in clientConnections {
connection . send ( jsonString ) // 5
}
}
catch {
print ( error ) // 6
}
}
Novamente, do início:
.onText
ao cliente conectado.ReceivingChatMessage
com a mensagem recebida do cliente.ReceivingChatMessage
serão gerados automaticamente.ReceivingChatMessage
em uma string JSON (bem, como Data
).Por que enviá-lo de volta?
Podemos usar isso como uma confirmação de que a mensagem foi, de fato, recebida com sucesso do cliente. O aplicativo receberá de volta a mensagem da mesma forma que receberia qualquer outra mensagem. Isso evitará que tenhamos que escrever código adicional posteriormente.
Feito! O servidor está pronto para receber mensagens e repassá-las a outros clientes conectados. Execute o servidor e deixe-o ocioso em segundo plano, enquanto continuamos com o aplicativo!
Lembra daquelas estruturas SubmittedChatMessage
e ReceivingChatMessage
que criamos para o servidor? Precisamos deles para o aplicativo também. Crie um novo arquivo Swift e nomeie- Models.swift
. Embora você possa simplesmente copiar e colar as implementações, elas exigirão algumas modificações:
import Foundation
struct SubmittedChatMessage : Encodable {
let message : String
}
struct ReceivingChatMessage : Decodable , Identifiable {
let date : Date
let id : UUID
let message : String
}
Observe como os protocolos Encodable
e Decodable
foram trocados. Só faz sentido: no aplicativo, codificamos apenas SubmittedChatMessage
e apenas decodificamos ReceivingChatMessage
. O oposto do servidor. Também removemos as inicializações automáticas de date
e id
. O aplicativo não tem como gerar isso.
Ok, de volta ao ChatScreenModel
(seja em um arquivo separado ou na parte inferior de ChatScreen.swift
). Adicione o topo, mas dentro de ChatScreenModel
adicione a seguinte propriedade de instância:
@ Published private ( set ) var messages : [ ReceivingChatMessage ] = [ ]
É aqui que armazenaremos as mensagens recebidas. Graças a @Published
, o ChatScreen
saberá exatamente quando esse array for atualizado e reagirá a essa mudança. private(set)
garante que apenas ChatScreenModel
possa atualizar esta propriedade. (Afinal, é o proprietário dos dados. Nenhum outro objeto tem qualquer obrigação de modificá-los diretamente!)
Ainda dentro ChatScreenModel
, adicione o seguinte método:
func send ( text : String ) {
let message = SubmittedChatMessage ( message : text ) // 1
guard let json = try ? JSONEncoder ( ) . encode ( message ) , // 2
let jsonString = String ( data : json , encoding : . utf8 )
else {
return
}
webSocketTask ? . send ( . string ( jsonString ) ) { error in // 3
if let error = error {
print ( " Error sending message " , error ) // 4
}
}
}
Parece autoexplicativo. Mas por uma questão de consistência:
SubmittedChatMessage
que, por enquanto, apenas contém a mensagem. Abra ChatScreen.swift
e adicione o seguinte método ao ChatScreen
:
private func onCommit ( ) {
if !message . isEmpty {
model . send ( text : message )
message = " "
}
}
Este método será chamado quando o usuário pressionar o botão enviar ou pressionar Return no teclado. Embora ele só envie a mensagem se ela realmente contiver alguma coisa .
No .body
de ChatScreen
, localize TextField
e Button
e substitua-os (mas não seus modificadores ou conteúdos) pelas seguintes inicializações:
TextField ( " Message " , text : $message , onEditingChanged : { _ in } , onCommit : onCommit )
// .modifiers here
Button ( action : onCommit ) {
// Image etc
}
// .modifiers here
Quando a tecla Return é pressionada enquanto o TextField
está em foco, onCommit
será chamado. O mesmo vale quando o Button
é pressionado pelo usuário. TextField
também requer um argumento onEditingChanged
- mas descartamos isso dando-lhe um fechamento vazio.
Agora é a hora de começar a testar o que temos. Certifique-se de que o servidor ainda esteja em execução em segundo plano. Coloque alguns pontos de interrupção no fechamento client.onText
(onde o servidor lê as mensagens recebidas) em main.swift
do servidor. Execute o aplicativo e envie uma mensagem. Os pontos de interrupção em main.swift
devem ser atingidos ao receber uma mensagem do aplicativo. Se sim, ? exuberante ! ? Se não, bem... refaça seus passos e comece a depurar!
Enviar mensagens é fofo e tudo. Mas e quanto a recebê-los? (Bem, tecnicamente estamos recebendo-os, mas nunca reagindo a eles.) Você está certo!
Vamos visitar ChatScreenModel
mais uma vez. Lembra daquele método onReceive(incoming:)
? Substitua-o e forneça um método irmão conforme mostrado abaixo:
private func onReceive ( incoming : Result < URLSessionWebSocketTask . Message , Error > ) {
webSocketTask ? . receive ( completionHandler : onReceive ) // 1
if case . success ( let message ) = incoming { // 2
onMessage ( message : message )
}
else if case . failure ( let error ) = incoming { // 3
print ( " Error " , error )
}
}
private func onMessage ( message : URLSessionWebSocketTask . Message ) { // 4
if case . string ( let text ) = message { // 5
guard let data = text . data ( using : . utf8 ) ,
let chatMessage = try ? JSONDecoder ( ) . decode ( ReceivingChatMessage . self , from : data )
else {
return
}
DispatchQueue . main . async { // 6
self . messages . append ( chatMessage )
}
}
}
Então...
URLSessionWebSocketTask
? Eles só funcionam uma vez. Assim, religamos instantaneamente um novo manipulador, para que estejamos prontos para ler a próxima mensagem recebida.ReceivingChatMessage
.self.messages
. No entanto , como URLSessionWebSocketTask
pode chamar o manipulador de recebimento em um thread diferente e como o SwiftUI só funciona no thread principal, temos que agrupar nossa modificação em DispatchQueue.main.async {}
, garantindo que estamos realmente realizando a modificação no tópico principal.Explicar como e por que trabalhar com diferentes threads no SwiftUI está além do escopo deste tutorial.
Quase lá!
Verifique novamente em ChatScreen.swift
. Vê aquele ScrollView
vazio? Podemos finalmente preenchê-lo com mensagens:
ScrollView {
LazyVStack ( spacing : 8 ) {
ForEach ( model . messages ) { message in
Text ( message . message )
}
}
}
Não vai parecer espetacular de forma alguma. Mas isso servirá por enquanto. Simplesmente representamos cada mensagem com um Text
simples.
Vá em frente, execute o aplicativo. Quando você envia uma mensagem, ela deve aparecer instantaneamente na tela. Isso confirma que a mensagem foi enviada com sucesso ao servidor e que o servidor a enviou de volta ao aplicativo! Agora, se puder, abra várias instâncias do aplicativo (dica: use Simuladores diferentes). Praticamente não há limite para a quantidade de clientes! Tenha uma grande festa de bate-papo sozinho.
Continue enviando mensagens até que não haja mais espaço na tela. Notou alguma coisa? Yarp. O ScrollView
não rola automaticamente para baixo quando novas mensagens ultrapassam os limites da tela. ?
Digitar...
Lembre-se de que o servidor gera um identificador exclusivo para cada mensagem. Finalmente podemos fazer bom uso dele! A espera valeu a pena por essa recompensa incrível, garanto.
No ChatScreen
, transforme o ScrollView
nesta belezura:
ScrollView {
ScrollViewReader { proxy in // 1
LazyVStack ( spacing : 8 ) {
ForEach ( model . messages ) { message in
Text ( message . message )
. id ( message . id ) // 2
}
}
. onChange ( of : model . messages . count ) { _ in // 3
scrollToLastMessage ( proxy : proxy )
}
}
}
Em seguida, adicione o seguinte método:
private func scrollToLastMessage ( proxy : ScrollViewProxy ) {
if let lastMessage = model . messages . last { // 4
withAnimation ( . easeOut ( duration : 0.4 ) ) {
proxy . scrollTo ( lastMessage . id , anchor : . bottom ) // 5
}
}
}
ScrollView
em um ScrollViewReader
.ScrollViewReader
nos fornece um proxy
que precisaremos em breve.model.messages.count
. Quando esse valor muda, chamamos o método que acabamos de adicionar, passando para ele o proxy
fornecido por ScrollViewReader
..scrollTo(_:anchor:)
do ScrollViewProxy
. Isso diz ao ScrollView
para rolar até a Visualização com o identificador fornecido. Envolvemos isso withAnimation {}
para animar a rolagem.Et voilá...
Essas mensagens são muito exuberantes... mas seriam ainda mais exuberantes se soubéssemos quem enviou as mensagens e distinguíssemos visualmente entre mensagens recebidas e enviadas.
A cada mensagem também anexaremos um nome de usuário e um identificador de usuário. Como um nome de usuário não é suficiente para identificar um usuário, precisamos de algo único. E se o nome do usuário e de todos os outros fosse Patrick? Teríamos uma crise de identidade e seríamos incapazes de distinguir entre mensagens enviadas por Patrick e mensagens recebidas por Patrick .
Como é tradição, começamos pelo servidor, é o que dá menos trabalho.
Abra Models.swift
onde definimos SubmittedChatMessage
e ReceivingChatMessage
. Dê a ambos os bad boys uma propriedade user: String
e userID: UUID
, assim:
struct SubmittedChatMessage : Decodable {
let message : String
let user : String // <- We
let userID : UUID // <- are
}
struct ReceivingChatMessage : Encodable , Identifiable {
let date = Date ( )
let id = UUID ( )
let message : String
let user : String // <- new
let userID : UUID // <- here
}
(Não se esqueça de atualizar o arquivo Models.swift também no projeto do aplicativo!)
Voltando para main.swift
, onde você deverá ser recebido com um erro, altere a inicialização de ReceivingChatMessage
para o seguinte:
let outgoingMessage = ReceivingChatMessage (
message : incomingMessage . message ,
user : incomingMessage . user ,
userID : incomingMessage . userID
)
E é isso ! Terminamos com o servidor. É apenas o aplicativo de agora em diante. A reta final!
No projeto Xcode do aplicativo, crie um novo arquivo Swift chamado UserInfo.swift
. Coloque o seguinte código lá:
import Combine
import Foundation
class UserInfo : ObservableObject {
let userID = UUID ( )
@ Published var username = " "
}
Este será nosso EnvironmentObject
onde podemos armazenar nosso nome de usuário. Como sempre, o identificador exclusivo é um UUID imutável gerado automaticamente. De onde vem o nome de usuário? O usuário irá inserir isso ao abrir o aplicativo, antes de ser apresentada a tela de chat.
Novo horário do arquivo: SettingsScreen.swift
. Este arquivo abrigará o formulário de configurações simples:
import SwiftUI
struct SettingsScreen : View {
@ EnvironmentObject private var userInfo : UserInfo // 1
private var isUsernameValid : Bool {
!userInfo . username . trimmingCharacters ( in : . whitespaces ) . isEmpty // 2
}
var body : some View {
Form {
Section ( header : Text ( " Username " ) ) {
TextField ( " E.g. John Applesheed " , text : $userInfo . username ) // 3
NavigationLink ( " Continue " , destination : ChatScreen ( ) ) // 4
. disabled ( !isUsernameValid )
}
}
. navigationTitle ( " Settings " )
}
}
UserInfo
criada anteriormente estará acessível aqui como EnvironmentObject
.TextField
escreverá diretamente seu conteúdo em userInfo.username
.NavigationLink
que apresentará ChatScreen
quando pressionado. O botão fica desabilitado enquanto o nome de usuário é inválido. (Você percebe como inicializamos ChatScreen
no NavigationLink
? Se tivéssemos feito ChatScreen
se conectar ao servidor em seu init()
, isso teria acontecido agora !)Se desejar, você pode adicionar um pouco de elegância à tela.
Como estamos usando os recursos de navegação do SwiftUI, precisamos começar com um NavigationView
em algum lugar. ContentView
é o local perfeito para isso. Altere a implementação do ContentView
da seguinte forma:
struct ContentView : View {
@ StateObject private var userInfo = UserInfo ( ) // 1
var body : some View {
NavigationView {
SettingsScreen ( )
}
. environmentObject ( userInfo ) // 2
. navigationViewStyle ( StackNavigationViewStyle ( ) ) // 3
}
}
UserInfo
e...EnvironmentObject
, tornando-o acessível a todas as visualizações subsequentes. Agora vamos enviar os dados do UserInfo
junto com as mensagens que enviamos para o servidor. Vá para ChatScreenModel
(onde quer que você o coloque). No topo da classe adicione as seguintes propriedades:
final class ChatScreenModel : ObservableObject {
private var username : String ?
private var userID : UUID ?
// the rest ...
}
O ChatModelScreen
deve receber esses valores ao se conectar. Não é função do ChatModelScreen
saber de onde vieram essas informações. Se, no futuro, decidirmos alterar o local onde username
e userID
são armazenados, podemos deixar ChatModelScreen
intacto.
Altere o método connect()
para aceitar estas novas propriedades como argumentos:
func connect ( username : String , userID : UUID ) {
self . username = username
self . userID = userID
// etc ...
}
Finalmente, em send(text:)
, precisamos aplicar esses novos valores ao SubmittedChatMessage
que estamos enviando ao servidor:
func send ( text : String ) {
guard let username = username , let userID = userID else { // Safety first!
return
}
let message = SubmittedChatMessage ( message : text , user : username , userID : userID )
// Everything else ...
}
Aaa e é isso para ChatScreenModel
. Acabou . ??
Pela última vez, abra ChatScreen.swift
. No topo do ChatScreen
adicione:
@ EnvironmentObject private var userInfo : UserInfo
Não se esqueça de fornecer o username
e userID
para ChatScreenModel
quando a visualização aparecer:
private func onAppear ( ) {
model . connect ( username : userInfo . username , userID : userInfo . userID )
}
Agora, mais uma vez, como praticado: recoste-se na cadeira e olhe para o teto. Como devem ser as mensagens de texto? Se não estiver com disposição para pensamentos criativos, você pode usar a seguinte Visualização que representa uma única mensagem recebida (e enviada):
struct ChatMessageRow : View {
static private let dateFormatter : DateFormatter = {
let formatter = DateFormatter ( )
formatter . dateStyle = . none
formatter . timeStyle = . short
return formatter
} ( )
let message : ReceivingChatMessage
let isUser : Bool
var body : some View {
HStack {
if isUser {
Spacer ( )
}
VStack ( alignment : . leading , spacing : 6 ) {
HStack {
Text ( message . user )
. fontWeight ( . bold )
. font ( . system ( size : 12 ) )
Text ( Self . dateFormatter . string ( from : message . date ) )
. font ( . system ( size : 10 ) )
. opacity ( 0.7 )
}
Text ( message . message )
}
. foregroundColor ( isUser ? . white : . black )
. padding ( 10 )
. background ( isUser ? Color . blue : Color ( white : 0.95 ) )
. cornerRadius ( 5 )
if !isUser {
Spacer ( )
}
}
}
}
Não é particularmente excitante. Esta é a aparência em um iPhone:
(Lembra como o servidor também envia a data de uma mensagem? Aqui ela é usada para exibir a hora.)
As cores e o posicionamento são baseados na propriedade isUser
transmitida pelo pai. Neste caso, esse pai não é outro senão ChatScreen
. Como ChatScreen
tem acesso às mensagens e também ao UserInfo
, é aí que a lógica é colocada para determinar se a mensagem pertence ao usuário ou não.
ChatMessageRow
substitui o chato Text
que usávamos antes para representar mensagens:
ScrollView {
ScrollViewReader { proxy in
LazyVStack ( spacing : 8 ) {
ForEach ( model . messages ) { message in
// This one right here ?, officer.
ChatMessageRow ( message : message , isUser : message . userID == userInfo . userID )
. id ( message . id )
}
}
// etc.
}
}
Bem-vindo à linha de chegada! Você chegou até aqui! Pela última vez,
Agora você deve ter um aplicativo de bate-papo primitivo - mas funcional. Bem como um servidor que gerencia as mensagens recebidas e enviadas. Tudo escrito em Swift!
Parabéns! E muito obrigado pela leitura! ?
Você pode baixar o código final no Github.
Esquerda: iPad minúsculo com aparência escura. À direita: iPhone enorme com aparência leve.
Vamos resumir nossa jornada:
Tudo isso enquanto permaneceu completamente dentro do ecossistema rápido. Sem linguagens de programação extras, sem cocoapods ou qualquer coisa.
Obviamente, o que criamos aqui é apenas uma fração de uma fração de um aplicativo e servidor de bate -papo pronto para produção completos. Cortamos muitos cantos para economizar no tempo e na complexidade. Escusado será dizer que deve dar uma compreensão bastante básica de como um aplicativo de bate -papo funciona.
Considere os seguintes recursos para, talvez, se implementar:
ForEach
, iteramos por todas as mensagens na memória. O software de bate -papo moderno acompanha apenas um punhado de mensagens para renderizar e carregar apenas mensagens mais antigas quando o usuário rola.Aquele estranho urlsessionwebsockettask API
Se você já trabalhou com o WebSockets antes, pode compartilhar a opinião de que a API da Apple para o WebSocket's não é ... não tradicional. Você certamente não está sozinho nisso. Ter que se recuperar constantemente o manipulador de recebimento é apenas estranho . Se você acha que se sente mais confortável usando uma API WebSocket mais tradicional para iOS e MacOS, eu certamente recomendaria Starscream. É bem testado, performante e trabalha em versões mais antigas do iOS.
Bugs bugs bugs
Este tutorial foi escrito usando o Xcode 12 Beta 5 e o iOS 14 beta 5. Os bugs aparecem e desaparecem entre cada nova versão beta. Infelizmente, é impossível prever o que será e o que não funcionará nos lançamentos futuros (beta).
O servidor não apenas é executado em sua máquina local, mas também é acessível a partir da sua máquina local. Isso não é um problema ao executar o aplicativo no simulador (ou como aplicativo macOS na mesma máquina). Mas executando o aplicativo em um dispositivo físico ou em um Mac diferente, o servidor deverá ser acessado em sua rede local.
Para fazer isso, em main.swift
do código do servidor, adicione a seguinte linha diretamente após a inicialização da instância Application
:
app . http . server . configuration . hostname = " 0.0.0.0 "
Agora, no ChatScreenModel
, no connect(username:userID:)
Método, você precisa alterar o URL para combinar o IP local da sua máquina:
let url = URL ( string : " ws://127.0.0.1:8080/chat " ) !
//^^this^^^
O IP local da sua máquina pode ser encontrado de várias maneiras. Pessoalmente, sempre abro preferências do sistema> Rede , onde o IP é mostrado diretamente e pode ser selecionado e copiado.
Deve -se notar que a taxa de sucesso disso varia entre as redes. Existem muitos fatores (como a segurança) que podem impedir que isso funcione.
Muito obrigado por ler! Se você tiver alguma opinião sobre esta peça, pensamentos para melhorias ou encontrar alguns erros, por favor, por favor , entre em contato! Farei o meu melhor para melhorar continuamente este tutorial. ?