Crear una aplicación de chat muy primitiva en SwiftUI, mientras se usa Swift y WebSockets para crear el servidor de chat. ¡Es Swift de arriba a abajo, abeja!
En este tutorial crearemos una aplicación de chat bastante primitiva pero funcional. La aplicación se ejecutará en iOS o macOS, ¡o en ambos! La belleza de SwiftUI es el poco esfuerzo que se necesita para crear una aplicación multiplataforma.
Por supuesto, una aplicación de chat tendrá muy poca utilidad sin un servidor con quien hablar. Por lo tanto, también crearemos un servidor de chat muy primitivo, utilizando WebSockets. Todo se construirá en Swift y se ejecutará localmente en su máquina.
Este tutorial asume que ya tienes un poco de experiencia desarrollando aplicaciones iOS/macOS usando SwiftUI. Aunque los conceptos se explicarán a medida que avancemos, no se cubrirá todo en profundidad. No hace falta decir que si escribes y sigues los pasos, al final de este tutorial tendrás una aplicación de chat funcional (para iOS y/o macOS), que se comunica con un servidor que tú también creaste. También tendrá una comprensión básica de conceptos como Swift y WebSockets del lado del servidor.
Si nada de eso te interesa, siempre puedes desplazarte hasta el final y descargar el código fuente final.
En resumen, comenzaremos creando un servidor muy simple, sencillo y sin funciones. Construiremos el servidor como un paquete Swift y luego agregaremos el marco web Vapor como una dependencia. Esto nos ayudará a configurar un servidor WebSocket con solo unas pocas líneas de código.
Luego comenzaremos a construir la aplicación de chat frontend. Comenzando rápidamente con lo básico y luego agregando características (y necesidades) una por una.
Pasaremos la mayor parte de nuestro tiempo trabajando en la aplicación, pero iremos adelante y atrás entre el código del servidor y el código de la aplicación a medida que agregamos nuevas funciones.
Opcional
¡Empecemos!
Abra Xcode 12 e inicie un nuevo proyecto ( Archivo > Nuevo proyecto ). En Multiplataforma, seleccione Paquete Swift .
Llame al paquete algo lógico, algo que se explique por sí mismo, como " ChatServer ". Luego guárdalo donde quieras.
¿Paquete rápido?
Al crear un marco o software multiplataforma (por ejemplo, Linux) en Swift, los paquetes Swift son la forma preferida de hacerlo. Son la solución oficial para crear código modular que otros proyectos Swift pueden usar fácilmente. Sin embargo, un paquete Swift no necesariamente tiene que ser un proyecto modular: también puede ser un ejecutable independiente que simplemente usa otros paquetes Swift como dependencias (que es lo que estamos haciendo).
Es posible que se le haya ocurrido que no hay ningún proyecto Xcode (
.xcodeproj
) presente para el paquete Swift. Para abrir un paquete Swift en Xcode como cualquier otro proyecto, simplemente abra el archivoPackage.swift
. Xcode debería reconocer que está abriendo un paquete Swift y abre toda la estructura del proyecto. Recuperará automáticamente todas las dependencias al principio.Puede leer más sobre Swift Packages y Swift Package Manager en el sitio web oficial de Swift.
Para manejar todo el trabajo pesado de configurar un servidor, usaremos el marco web Vapor. Vapor viene con todas las funciones necesarias para crear un servidor WebSocket.
¿WebSockets?
Para dotar a la web de la capacidad de comunicarse con un servidor en tiempo real, se crearon WebSockets. Es una especificación bien descrita para una comunicación segura en tiempo real (bajo ancho de banda) entre un cliente y un servidor. Por ejemplo: juegos multijugador y aplicaciones de chat. ¿Esos adictivos juegos multijugador en el navegador a los que has estado jugando durante tu valioso tiempo de empresa? ¡Sí, WebSockets!
Sin embargo, si desea hacer algo como la transmisión de vídeo en tiempo real, lo mejor es buscar una solución diferente. ?
Aunque en este tutorial estamos creando una aplicación de chat para iOS/macOS, el servidor que estamos creando puede comunicarse fácilmente con otras plataformas con WebSockets. De hecho: si lo deseas, también puedes crear una versión web y para Android de esta aplicación de chat, hablando con el mismo servidor y permitiendo la comunicación entre todas las plataformas.
¿Vapor?
Internet es una serie compleja de tubos. Incluso responder a una simple solicitud HTTP requiere una gran cantidad de código. Afortunadamente, los expertos en el campo han desarrollado marcos web de código abierto que hacen todo el trabajo duro por nosotros desde hace décadas, en varios lenguajes de programación. Vapor es uno de ellos y está escrito en Swift. Ya viene con algunas capacidades de WebSocket y es exactamente lo que necesitamos.
Sin embargo, Vapor no es el único marco web impulsado por Swift. Kitura y Perfect también son marcos bien conocidos. Aunque se puede decir que Vapor es más activo en su desarrollo.
Xcode debería abrir el archivo Package.swift
de forma predeterminada. Aquí es donde ponemos información general y requisitos de nuestro paquete Swift.
Sin embargo, antes de hacer eso, busque en la carpeta Sources/ChatServer
. Debería tener un archivo ChatServer.swift
. Necesitamos cambiarle el nombre a main.swift
. Una vez hecho esto, regrese a Package.swift
.
En products:
, elimine el siguiente valor:
. library ( name : " ChatServer " , targets : [ " ChatServer " ] )
... y reemplácelo con:
. executable ( name : " ChatServer " , targets : [ " ChatServer " ] )
Después de todo, nuestro servidor no es una biblioteca. Pero más bien es un ejecutable independiente. También debemos definir las plataformas (y la versión mínima) en las que esperamos que se ejecute nuestro servidor. Esto se puede hacer agregando platforms: [.macOS(v10_15)]
bajo name: "ChatServer"
:
name: " ChatServer " ,
platforms: [
. macOS ( . v10_15 ) ,
] ,
Todo esto debería hacer que nuestro paquete Swift sea 'ejecutable' en Xcode.
Muy bien, agreguemos Vapor como dependencia. En dependencies: []
(que debería tener algunas cosas comentadas), agregue lo siguiente:
. package ( url : " https://github.com/vapor/vapor.git " , from : " 4.0.0 " )
Al guardar el archivo Package.swift
, Xcode debería comenzar a buscar automáticamente las dependencias de Vapor con la versión 4.0.0
o posterior. Así como todas sus dependencias.
Sólo tenemos que hacer un ajuste más al archivo mientras Xcode hace lo suyo: agregar la dependencia a nuestro objetivo. En targets:
encontrará un .target(name: "ChatServer", dependencies: [])
. En esa matriz vacía, agregue lo siguiente:
. product ( name : " Vapor " , package : " vapor " )
Eso es todo . Nuestro Package.swift
está listo. Hemos descrito nuestro paquete Swift diciéndole:
El Package.swift
final debería verse así (-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 " )
] ) ,
]
)
Ahora finalmente ha llegado el momento de...
En Xcode, abra Sources/ChatServer/main.swift
y elimine todo lo que hay allí. No tiene ningún valor para nosotros. En su lugar, haz que main.swift
tenga este aspecto:
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! Eso es todo lo que se necesita para iniciar un servidor (WebSocket) usando Vapor. Mira lo fácil que fue eso.
defer
y llame .shutdown()
que realizará cualquier limpieza al salir del programa./chat
. Ahora
Una vez que el programa se haya ejecutado correctamente, es posible que no vea nada parecido a una aplicación. Esto se debe a que el software de servidor no suele tener interfaces gráficas de usuario. Pero tenga la seguridad de que el programa está vivo y coleando en segundo plano, haciendo girar sus ruedas. Sin embargo, la consola Xcode debería mostrar el siguiente mensaje:
notice codes.vapor.application : Server starting on http://127.0.0.1:8080
Esto significa que el servidor puede escuchar con éxito las solicitudes entrantes. ¡Esto es genial, porque ahora tenemos un servidor WebSocket al que podemos comenzar a conectarnos!
¿No te creo?
Si por alguna razón crees que no he estado escupiendo nada más que mentiras atroces todo este tiempo, ¡puedes probar el servidor tú mismo!
Abre tu navegador favorito y asegúrate de estar en una pestaña vacía. (Si es Safari, primero deberá habilitar el modo Desarrollador). Abra el Inspector (
Cmd
+Option
+I
) y vaya a la Consola . Escribenew WebSocket ( 'ws://localhost:8080/chat' )y presiona Retorno. Ahora eche un vistazo a la consola Xcode. Si todo salió bien, ahora debería mostrar
Connected: WebSocketKit.WebSocket
.????
Solo se puede acceder al servidor desde su máquina local. Esto significa que no puede conectar su iPhone/iPad físico al servidor. En su lugar, usaremos el Simulador en los siguientes pasos para probar nuestra aplicación de chat.
Para probar la aplicación de chat en un dispositivo físico, es necesario realizar algunos (pequeños) pasos adicionales. Consulte el Apéndice A.
Aunque aún no hemos terminado con el backend, es hora de pasar al frontend. ¡La aplicación de chat en sí!
En Xcode crea un nuevo proyecto. Esta vez, en Multiplataforma seleccione Aplicación . Nuevamente, elige un nombre bonito para tu aplicación y continúa. (Elegí SwiftChat . Estoy de acuerdo, ¿es perfecto ?)
La aplicación no depende de bibliotecas o marcos externos de terceros. De hecho, todo lo que necesitamos está disponible a través de Foundation
, Combine
y SwiftUI
(en Xcode 12+).
Comencemos a trabajar en la pantalla de chat de inmediato. Cree un nuevo archivo Swift y asígnele el nombre ChatScreen.swift
. No importa si elige la plantilla Swift File o SwiftUI View . Estamos eliminando todo lo que contiene de todos modos.
Aquí está el kit de inicio de 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 ( )
}
}
}
En ContentsView.swift
, reemplace Hello World con ChatScreen()
:
struct ContentView : View {
var body : some View {
ChatScreen ( )
}
}
Un lienzo en blanco, por ahora.
Izquierda: iPhone con apariencia oscura. Derecha: iPad con apariencia ligera.
Lo que tenemos aquí:
Si desea tomar decisiones de diseño diferentes, siga adelante. ?
Ahora comencemos a trabajar en alguna lógica no relacionada con la interfaz de usuario: conectarnos al mismo servidor que acabamos de crear.
SwiftUI , junto con el marco Combine , proporciona a los desarrolladores herramientas para implementar la separación de preocupaciones sin esfuerzo en su código. Usando el protocolo ObservableObject
y los envoltorios de propiedades @StateObject
(o @ObservedObject
), podemos implementar lógica que no es UI (denominada Business Logic ) en un lugar separado. ¡Como deberían ser las cosas! Después de todo, lo único que debería importarle a la interfaz de usuario es mostrar datos al usuario y reaccionar a sus entradas. No debería importarle de dónde provienen los datos ni cómo se manipulan.
Al tener experiencia en React, este lujo es algo que envidio increíblemente.
Hay miles y miles de artículos y discusiones sobre arquitectura de software. Probablemente hayas oído o leído sobre conceptos como MVC, MVVM, VAPOR, Arquitectura Limpia y más. Todos tienen sus argumentos y sus aplicaciones.
Discutir esto está fuera del alcance de este tutorial. Pero en general se acepta que la lógica empresarial y la lógica de la interfaz de usuario no deben estar entrelazadas.
Este concepto es válido también para nuestro ChatScreen . Lo único que debería importarle a ChatScreen es mostrar los mensajes y manejar el texto ingresado por el usuario. No le importan ✌️We Bs Oc K eTs✌, ni debería importarle.
Puede crear un nuevo archivo Swift o escribir el siguiente código en la parte inferior de ChatScreen.swift
. Tu elección. Dondequiera que viva, ¡asegúrate de no olvidar las import
!
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 ( )
}
}
Esto puede ser mucho para asimilar, así que repasémoslo lentamente:
URLSessionWebSocketTask
en una propiedad.URLSessionWebSocketTask
son responsables de las conexiones WebSocket. Son residentes de la familia URLSession
en el marco de Foundation .127.0.0.1
o localhost
). El puerto predeterminado de las aplicaciones Vapor es 8080
. Y colocamos un oyente para las conexiones WebSocket en la ruta /chat
.URLSessionWebSocketTask
y la almacenamos en la propiedad de la instancia.onReceive(incoming:)
. Más sobre esto más adelante.ChatScreenModel
se borre de la memoria. Este es un gran comienzo. Ahora tenemos un lugar donde podemos colocar toda nuestra lógica WebSocket sin saturar el código de la interfaz de usuario. Es hora de que ChatScreen
se comunique con ChatScreenModel
.
Agregue ChatScreenModel
como objeto de estado en ChatScreen
:
struct ChatScreen : View {
@ StateObject private var model = ChatScreenModel ( ) // <- this here
@ State private var message = " "
// etc...
}
¿Cuándo debemos conectarnos al servidor? Bueno, cuando la pantalla esté realmente visible, por supuesto. Puede tener la tentación de llamar a .connect()
en el init()
de ChatScreen
. Esto es algo peligroso. De hecho, en SwiftUI uno debe intentar evitar poner nada en init()
, ya que la Vista se puede inicializar incluso cuando nunca aparecerá. (Por ejemplo, en LazyVStack
o en NavigationLink(destination:)
.) Sería una pena desperdiciar valiosos ciclos de CPU. Por lo tanto, pospongamos todo para onAppear
.
Agregue un método onAppear
a ChatScreen
. Luego agregue y pase ese método al modificador .onAppear(perform:)
de VStack
:
struct ChatScreen : View {
// ...
private func onAppear ( ) {
model . connect ( )
}
var body : some View {
VStack {
// ...
}
. onAppear ( perform : onAppear )
}
}
¿Espacio desperdiciado?
Mucha gente prefiere escribir el contenido de estos métodos en línea:
. onAppear { model . connect ( ) }Esto no es más que una preferencia personal. Personalmente me gusta definir estos métodos por separado. Sí, cuesta más espacio. Pero son más fáciles de encontrar, reutilizables, evitan que el
body
se abarrote (más) y posiblemente sean más fáciles de plegar. ?
Del mismo modo, también deberíamos desconectarnos cuando la vista desaparezca. La implementación debería explicarse por sí misma, pero por si acaso:
struct ChatScreen : View {
// ...
private func onDisappear ( ) {
model . disconnect ( )
}
var body : some View {
VStack {
// ...
}
. onAppear ( perform : onAppear )
. onDisappear ( perform : onDisappear )
}
}
Es muy importante cerrar las conexiones WebSocket siempre que dejemos de preocuparnos por ellas. Cuando cierra (con gracia) una conexión WebSocket, el servidor será informado y podrá borrar la conexión de la memoria. El servidor nunca debe tener conexiones muertas o desconocidas en la memoria.
Uf. Todo un viaje por el que hemos pasado hasta ahora. Es hora de probarlo.ChatScreen
, debería ver el mensaje Connected: WebSocketKit.WebSocket
en la consola Xcode del servidor. Si no, vuelve sobre tus pasos y comienza a depurar.
Una cosa más™️. También debemos probar si la conexión WebSocket se cierra cuando el usuario cierra la aplicación (o sale de ChatScreen
). Regrese al archivo main.swift
del proyecto del servidor. Actualmente nuestro oyente WebSocket tiene este aspecto:
app . webSocket ( " chat " ) { req , client in
print ( " Connected: " , client )
}
Agregue un controlador al .onClose
de client
, realizando nada más que una simple print()
:
app . webSocket ( " chat " ) { req , client in
print ( " Connected: " , client )
client . onClose . whenComplete { _ in
print ( " Disconnected: " , client )
}
}
Vuelva a ejecutar el servidor e inicie la aplicación de chat. Una vez que la aplicación esté conectada, ciérrela (salga de ella, no la ponga simplemente en segundo plano). La consola Xcode del servidor ahora debería imprimir Disconnected: WebSocketKit.WebSocket
. Esto confirma que las conexiones WebSocket se cierran cuando ya no nos preocupamos por ellas. Por lo tanto, el servidor no debería tener conexiones inactivas en la memoria.
¿Estás listo para enviar algo al servidor? Chico, seguro que lo soy. Pero sólo por un momento, frenemos y pensemos por un segundo. Recuéstese en la silla y mire sin rumbo fijo, pero de alguna manera decidida, al techo...
¿Qué enviaremos exactamente al servidor? Y, lo que es igualmente importante, ¿qué recibiremos del servidor?
Tu primer pensamiento puede ser "Bueno, solo envía un mensaje de texto, ¿verdad?", estarías en lo cierto. Pero ¿qué pasa con la hora del mensaje? ¿Qué pasa con el nombre del remitente? ¿Qué pasa con un identificador para que el mensaje sea único respecto de cualquier otro mensaje? No tenemos nada para que el usuario cree un nombre de usuario ni nada por el momento. Así que dejemos eso a un lado y centrémonos únicamente en enviar y recibir mensajes.
Tendremos que hacer algunos ajustes tanto en el lado de la aplicación como en el del servidor. Comencemos con el servidor.
Cree un nuevo archivo Swift en Sources/ChatServer
llamado Models.swift
en el proyecto del servidor. Pegue (o escriba) el siguiente código en 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
}
Esto es lo que está pasando:
Decodable
.Encodable
.ReceivingChatMessage
. Tenga en cuenta cómo estamos generando la date
y id
en el lado del servidor. Esto convierte al servidor en la Fuente de la Verdad. El servidor sabe qué hora es. Si la fecha se generara en el lado del cliente, no se puede confiar en ella. ¿Qué pasa si el cliente tiene su reloj configurado para el futuro? Hacer que el servidor genere la fecha hace que su reloj sea la única referencia a la hora.
¿Zonas horarias?
El objeto
Date
de Swift siempre tiene las 00:00:00 UTC del 01-01-2001 como hora de referencia absoluta. Al inicializar unaDate
o formatear una como cadena (por ejemplo, medianteDateFormatter
), la localidad del cliente se tendrá en cuenta automáticamente. Sumar o restar horas dependiendo de la zona horaria del cliente.
UUID?
Los identificadores universalmente únicos se consideran globalmente valores aceptables para los identificadores.
Tampoco queremos que el cliente envíe varios mensajes con el mismo identificador único. Ya sea de forma accidental o intencionadamente maliciosa. Hacer que el servidor genere este identificador es una capa adicional de seguridad y menos posibles fuentes de errores.
Ahora bien. Cuando el servidor recibe un mensaje de un cliente, debe pasarlo a todos los demás clientes. Sin embargo, esto significa que tenemos que realizar un seguimiento de cada cliente que esté conectado.
Volver a main.swift
del proyecto del servidor. Justo encima de app.webSocket("chat")
coloque la siguiente declaración:
var clientConnections = Set < WebSocket > ( )
Aquí es donde almacenaremos las conexiones de nuestros clientes.
Pero espera ... Deberías recibir un error de compilación grande, malo y desagradable. Esto se debe a que el objeto WebSocket
no se ajusta al protocolo Hashable
de forma predeterminada. Sin embargo, no se preocupe, esto se puede implementar fácilmente (aunque a bajo costo). Agregue el siguiente código en la 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. El código anterior es una forma rápida pero sencilla de hacer que una class
se ajuste a Hashable
(y por definición también Equatable
), simplemente usando su dirección de memoria como una propiedad única. Nota : esto sólo funciona para clases. Las estructuras requerirán una implementación un poco más práctica.
Muy bien, ahora que podemos realizar un seguimiento de los clientes, reemplace todo app.webSocket("chat")
(incluido su cierre y su contenido) con el siguiente código:
app . webSocket ( " chat " ) { req , client in
clientConnections . insert ( client )
client . onClose . whenComplete { _ in
clientConnections . remove ( client )
}
}
Cuando un cliente se conecta, almacene dicho cliente en clientConnections
. Cuando el cliente se desconecte, elimínelo del mismo Set
. Ezpz.
El último paso de este capítulo es agregar el corazón del servidor.client.onClose.whenComplete
, pero aún dentro del cierre de app.webSocket("chat")
, agregue el siguiente fragmento 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
}
}
De nuevo, desde arriba:
.onText
al cliente conectado.ReceivingChatMessage
con el mensaje recibido del cliente.ReceivingChatMessage
se generarán automáticamente.ReceivingChatMessage
en una cadena JSON (bueno, como Data
).¿Por qué devolverlo?
Podemos utilizar esto como confirmación de que el mensaje, de hecho, se recibió correctamente del cliente. La aplicación recibirá el mensaje como recibiría cualquier otro mensaje. Esto evitará que tengamos que escribir código adicional más adelante.
¡Hecho! El servidor está listo para recibir mensajes y transmitirlos a otros clientes conectados. Ejecute el servidor y déjelo inactivo en segundo plano mientras continuamos con la aplicación.
¿Recuerda esas estructuras SubmittedChatMessage
y ReceivingChatMessage
que creamos para el servidor? También los necesitamos para la aplicación. Cree un nuevo archivo Swift y asígnele el nombre Models.swift
. Aunque podrías simplemente copiar y pegar las implementaciones, requerirán un poco de modificación:
import Foundation
struct SubmittedChatMessage : Encodable {
let message : String
}
struct ReceivingChatMessage : Decodable , Identifiable {
let date : Date
let id : UUID
let message : String
}
Observe cómo se han intercambiado los protocolos Encodable
y Decodable
. Tiene sentido: en la aplicación, solo codificamos SubmittedChatMessage
y solo decodificamos ReceivingChatMessage
. Lo contrario del servidor. También eliminamos las inicializaciones automáticas de date
e id
. La aplicación no tiene por qué generarlos.
Bien, volvamos a ChatScreenModel
(ya sea en un archivo separado o en la parte inferior de ChatScreen.swift
). Agregue la parte superior, pero dentro de ChatScreenModel
agregue la siguiente propiedad de instancia:
@ Published private ( set ) var messages : [ ReceivingChatMessage ] = [ ]
Aquí almacenaremos los mensajes recibidos. Gracias a @Published
, ChatScreen
sabrá exactamente cuándo se actualiza esta matriz y reaccionará a este cambio. private(set)
se asegura de que solo ChatScreenModel
pueda actualizar esta propiedad. (Después de todo, es el propietario de los datos. ¡Ningún otro objeto tiene por qué modificarlos directamente!)
Aún dentro de ChatScreenModel
, agregue el siguiente 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 que se explica por sí mismo. Pero por razones de coherencia:
SubmittedChatMessage
que, por ahora, solo contiene el mensaje. Abra ChatScreen.swift
y agregue el siguiente método a ChatScreen
:
private func onCommit ( ) {
if !message . isEmpty {
model . send ( text : message )
message = " "
}
}
Este método se llamará cuando el usuario presione el botón enviar o cuando presione Retorno en el teclado. Aunque solo enviará el mensaje si realmente contiene algo .
En el .body
de ChatScreen
, ubique TextField
y Button
, luego reemplácelos (pero no sus modificadores o contenidos) con las siguientes inicializaciones:
TextField ( " Message " , text : $message , onEditingChanged : { _ in } , onCommit : onCommit )
// .modifiers here
Button ( action : onCommit ) {
// Image etc
}
// .modifiers here
Cuando se presiona la tecla Retorno mientras TextField
está enfocado, se llamará onCommit
. Lo mismo ocurre cuando el usuario presiona el Button
. TextField
también requiere un argumento onEditingChanged
, pero lo descartamos dándole un cierre vacío.
Ahora es el momento de empezar a probar lo que tenemos. Asegúrese de que el servidor todavía se esté ejecutando en segundo plano. Coloque algunos puntos de interrupción en el cierre client.onText
(donde el servidor lee los mensajes entrantes) en main.swift
del servidor. Ejecute la aplicación y envíe un mensaje. Los puntos de interrupción en main.swift
deben alcanzarse al recibir un mensaje de la aplicación. Si así fuera, ? exuberante ! ? Si no, bueno... ¡vuelve sobre tus pasos y comienza a depurar!
Enviar mensajes es lindo y todo. Pero ¿qué pasa con recibirlos? (Bueno, técnicamente los recibimos, pero nunca reaccionamos ante ellos). ¡Tienes razón!
Visitemos ChatScreenModel
una vez más. ¿Recuerdas el método onReceive(incoming:)
? Reemplácelo y asígnele un método hermano como se muestra a continuación:
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 )
}
}
}
Entonces...
URLSessionWebSocketTask
? Sólo funcionan una vez. Por lo tanto, volvemos a vincular instantáneamente un nuevo controlador, de modo que estemos listos para leer el siguiente mensaje entrante.ReceivingChatMessage
.self.messages
. Sin embargo , debido a que URLSessionWebSocketTask
puede llamar al controlador de recepción en un subproceso diferente, y debido a que SwiftUI solo funciona en el subproceso principal, tenemos que envolver nuestra modificación en DispatchQueue.main.async {}
, asegurando que realmente estemos realizando la modificación en el hilo principal.Explicar los cómo y los porqués de trabajar con diferentes subprocesos en SwiftUI está más allá del alcance de este tutorial.
¡Ya casi llegamos!
Vuelva a iniciar sesión en ChatScreen.swift
. ¿Ves ese ScrollView
vacío? Finalmente podemos llenarlo con mensajes:
ScrollView {
LazyVStack ( spacing : 8 ) {
ForEach ( model . messages ) { message in
Text ( message . message )
}
}
}
No va a lucir espectacular de ninguna manera. Pero esto funcionará por ahora. Simplemente representamos cada mensaje con un Text
plano.
Adelante, ejecuta la aplicación. Cuando envías un mensaje, debería aparecer instantáneamente en la pantalla. ¡Esto confirma que el mensaje se envió exitosamente al servidor y que el servidor lo envió nuevamente a la aplicación! Ahora, si puedes, abre varias instancias de la aplicación (consejo: utiliza diferentes simuladores). ¡Prácticamente no hay límite para la cantidad de clientes! Ten una gran fiesta de charla tú solo.
Sigue enviando mensajes hasta que no quede espacio en la pantalla. ¿Notas algo? Yarp. ScrollView
no se desplaza automáticamente hacia la parte inferior una vez que los mensajes nuevos superan los límites de la pantalla. ?
Ingresar...
Recuerde, el servidor genera un identificador único para cada mensaje. ¡Por fin podemos darle un buen uso! La espera valió la pena por esta increíble recompensa, te lo aseguro.
En ChatScreen
, convierte ScrollView
en esta belleza:
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 )
}
}
}
Luego agregue el siguiente 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
en un ScrollViewReader
.ScrollViewReader
nos proporciona un proxy
que necesitaremos muy pronto.model.messages.count
. Cuando este valor cambia, llamamos al método que acabamos de agregar y le pasamos el proxy
proporcionado por ScrollViewReader
..scrollTo(_:anchor:)
de ScrollViewProxy
. Esto le dice a ScrollView
que se desplace a la Vista con el identificador dado. Envolvemos esto withAnimation {}
para animar el desplazamiento.Et voilá...
Estos mensajes son bastante exuberantes... pero serían aún más exuberantes si supiéramos quién envió los mensajes y distinguiéramos visualmente entre los mensajes recibidos y enviados.
Con cada mensaje adjuntaremos también un nombre de usuario y un identificador de usuario. Como un nombre de usuario no es suficiente para identificar a un usuario, necesitamos algo único. ¿Qué pasaría si el nombre del usuario y de todos los demás fuera Patrick? Tendríamos una crisis de identidad y no podríamos distinguir entre los mensajes enviados por Patrick y los mensajes recibidos por Patrick .
Como es tradición, empezamos por el servidor, es lo que menos trabajo requiere.
Abra Models.swift
donde definimos SubmittedChatMessage
y ReceivingChatMessage
. Dale a estos dos chicos malos una propiedad user: String
y userID: UUID
, así:
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
}
(¡No olvide actualizar también el archivo Models.swift en el proyecto de la aplicación!)
Volviendo a main.swift
, donde debería recibir un error, cambie la inicialización de ReceivingChatMessage
a lo siguiente:
let outgoingMessage = ReceivingChatMessage (
message : incomingMessage . message ,
user : incomingMessage . user ,
userID : incomingMessage . userID
)
¡Y eso es todo ! Hemos terminado con el servidor. Es solo la aplicación de ahora en adelante. ¡La recta final!
En el proyecto Xcode de la aplicación, cree un nuevo archivo Swift llamado UserInfo.swift
. Coloque el siguiente código allí:
import Combine
import Foundation
class UserInfo : ObservableObject {
let userID = UUID ( )
@ Published var username = " "
}
Este será nuestro EnvironmentObject
donde podremos almacenar nuestro nombre de usuario. Como siempre, el identificador único es un UUID inmutable generado automáticamente. ¿De dónde viene el nombre de usuario? El usuario ingresará esto al abrir la aplicación, antes de que se le presente la pantalla de chat.
Nueva hora de archivo: SettingsScreen.swift
. Este archivo albergará el formulario de configuración simple:
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
creada previamente será accesible aquí como EnvironmentObject
.TextField
escribirá directamente su contenido en userInfo.username
.NavigationLink
que presentará ChatScreen
cuando se presione. El botón está deshabilitado mientras el nombre de usuario no sea válido. (¿Notas cómo inicializamos ChatScreen
en NavigationLink
? Si hubiéramos hecho que ChatScreen
se conectara al servidor en su init()
, ¡lo habría hecho ahora mismo !)Si lo deseas, puedes agregar un poco de estilo a la pantalla.
Dado que estamos usando las funciones de navegación de SwiftUI, debemos comenzar con NavigationView
en alguna parte. ContentView
es el lugar perfecto para esto. Cambie la implementación de ContentView
de la siguiente manera:
struct ContentView : View {
@ StateObject private var userInfo = UserInfo ( ) // 1
var body : some View {
NavigationView {
SettingsScreen ( )
}
. environmentObject ( userInfo ) // 2
. navigationViewStyle ( StackNavigationViewStyle ( ) ) // 3
}
}
UserInfo
y...EnvironmentObject
, haciéndolo accesible para todas las vistas posteriores. Ahora a enviar los datos de UserInfo
junto con los mensajes que enviamos al servidor. Vaya a ChatScreenModel
(donde lo coloque). En la parte superior de la clase agregue las siguientes propiedades:
final class ChatScreenModel : ObservableObject {
private var username : String ?
private var userID : UUID ?
// the rest ...
}
ChatModelScreen
debería recibir estos valores al conectarse. No es trabajo de ChatModelScreen
saber de dónde proviene esta información. Si, en el futuro, decidimos cambiar dónde se almacenan tanto username
como userID
, podemos dejar ChatModelScreen
intacto.
Cambie el método connect()
para aceptar estas nuevas propiedades como argumentos:
func connect ( username : String , userID : UUID ) {
self . username = username
self . userID = userID
// etc ...
}
Finalmente, en send(text:)
, necesitamos aplicar estos nuevos valores al SubmittedChatMessage
que enviamos al 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 y eso es todo para ChatScreenModel
. Está terminado . ??
Por última vez, abra ChatScreen.swift
. En la parte superior de ChatScreen
agrega:
@ EnvironmentObject private var userInfo : UserInfo
No olvide proporcionar el username
y userID
a ChatScreenModel
cuando aparezca la vista:
private func onAppear ( ) {
model . connect ( username : userInfo . username , userID : userInfo . userID )
}
Ahora, una vez más, como se practica: recuéstese en esa silla y mire hacia el techo. ¿Cómo deberían verse los mensajes de texto? Si no está de humor para el pensamiento creativo, puede utilizar la siguiente Vista que representa un único mensaje recibido (y enviado):
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 ( )
}
}
}
}
No tiene un aspecto particularmente emocionante. Así es como se ve en un iPhone:
(¿Recuerda que el servidor también envía la fecha de un mensaje? Aquí se usa para mostrar la hora).
Los colores y el posicionamiento se basan en la propiedad isUser
transmitida por el padre. En este caso, ese padre no es otro que ChatScreen
. Debido a que ChatScreen
tiene acceso a los mensajes así como a UserInfo
, es allí donde se coloca la lógica para determinar si el mensaje pertenece al usuario o no.
ChatMessageRow
reemplaza el aburrido Text
que usábamos antes para representar mensajes:
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.
}
}
¡Bienvenidos a la meta! ¡Has llegado hasta aquí! Por última vez,
A estas alturas ya deberías tener una aplicación de chat primitiva, pero funcional. Además de un servidor que maneja los mensajes entrantes y salientes. ¡Todo escrito en Swift!
¡Felicitaciones! ¡Y muchas gracias por leer! ?
Puedes descargar el código final desde Github.
Izquierda: iPad pequeño con apariencia oscura. Derecha: iPhone enorme con apariencia liviana.
Resumamos nuestro viaje:
Todo eso mientras se mantiene completamente dentro del ecosistema Swift. Sin lenguajes de programación adicionales, sin cocoapods ni nada.
Por supuesto, lo que creamos aquí es solo una fracción de una fracción de una aplicación y servidor de chat de producción completa. Cortamos muchas esquinas para ahorrar a tiempo y complejidad. No hace falta decir que debería dar una comprensión bastante básica de cómo funciona una aplicación de chat.
Considere las siguientes características para, tal vez, implementar usted mismo:
ForEach
, iteramos a través de cada mensaje en la memoria. El software de chat moderno solo realiza un seguimiento de un puñado de mensajes para renderizar, y solo cargue en mensajes más antiguos una vez que el usuario se desplaza.Esa extraña API de UrlsessionwebSocketTask
Si alguna vez ha trabajado con WebSockets antes, puede compartir la opinión de que la API de Apple para WebSocket es bastante ... no tradicional. Ciertamente no estás solo en esto. Tener que revertir constantemente el controlador de recepción es simplemente extraño . Si crees que te sientes más cómodo usando una API WebSocket más tradicional para iOS y MacOS, entonces ciertamente recomendaría StarsCream. Está bien probado, performando y trabaja en versiones anteriores de iOS.
Errores errores errores
Este tutorial fue escrito usando Xcode 12 Beta 5 e iOS 14 Beta 5. Los errores aparecen y desaparecen entre cada nueva versión beta. Desafortunadamente, es imposible predecir qué se verá y qué funcionará en el futuro (beta).
El servidor no solo se ejecuta en su máquina local, sino que solo se puede acceder desde su máquina local. Esto no es un problema al ejecutar la aplicación en simulador (o como aplicación macOS en la misma máquina). Pero ejecutar la aplicación en un dispositivo físico, o en una Mac diferente, el servidor deberá hacerse accesible en su red local.
Para hacer esto, en main.swift
del código del servidor, agregue la siguiente línea directamente después de inicializar la instancia Application
:
app . http . server . configuration . hostname = " 0.0.0.0 "
Ahora en ChatScreenModel
, en el método connect(username:userID:)
, debe cambiar la URL para que coincida con la IP local de su máquina:
let url = URL ( string : " ws://127.0.0.1:8080/chat " ) !
//^^this^^^
La IP local de su máquina se puede encontrar de varias maneras. Personalmente, siempre abro las preferencias del sistema> Red , donde la IP se muestra directamente y se puede seleccionar y copiar.
Cabe señalar que la tasa de éxito de esto varía entre las redes. Hay muchos factores (como la seguridad) que podrían evitar que esto funcione.
¡Muchas gracias por leer! Si tiene alguna opinión sobre esta pieza, pensamientos para mejoras o encuentras algunos errores, por favor, por favor, ¡hágamelo saber! Haré todo lo posible para mejorar continuamente este tutorial. ?