Création d'une application de chat très primitive dans SwiftUI, tout en utilisant Swift et WebSockets pour créer le serveur de chat. C'est Swift de haut en bas, abeille baie !
Dans ce tutoriel, nous allons créer une application de chat plutôt primitive mais fonctionnelle. L'application fonctionnera sur iOS ou macOS - ou les deux ! La beauté de SwiftUI réside dans le peu d’efforts nécessaires pour créer une application multiplateforme.
Bien entendu, une application de chat n’aura que très peu d’utilité sans un serveur avec qui parler. Par conséquent, nous allons également créer un serveur de discussion très primitif, en utilisant WebSockets. Tout sera construit dans Swift et exécuté localement sur votre machine.
Ce didacticiel suppose que vous avez déjà une certaine expérience dans le développement d'applications iOS/macOS à l'aide de SwiftUI. Même si les concepts seront expliqués au fur et à mesure, tout ne sera pas abordé en profondeur. Inutile de dire que si vous tapez et suivez les étapes, à la fin de ce didacticiel, vous disposerez d'une application de chat fonctionnelle (pour iOS et/ou macOS), qui communique avec un serveur que vous avez également créé ! Vous aurez également une compréhension de base de concepts tels que Swift et WebSockets côté serveur.
Si rien de tout cela ne vous intéresse, vous pouvez toujours faire défiler jusqu’à la fin et télécharger le code source final !
En bref, nous allons commencer par créer un serveur très simple, clair et sans fonctionnalités. Nous allons construire le serveur en tant que package Swift, puis ajouter le framework Web Vapor en tant que dépendance. Cela nous aidera à configurer un serveur WebSocket avec seulement quelques lignes de code.
Ensuite, nous commencerons à créer l’application de chat frontend. Commencer rapidement par les bases, puis ajouter les fonctionnalités (et les nécessités) une par une.
La plupart de notre temps sera consacré à travailler sur l'application, mais nous ferons des allers-retours entre le code du serveur et le code de l'application au fur et à mesure que nous ajouterons de nouvelles fonctionnalités.
Facultatif
Commençons !
Ouvrez Xcode 12 et démarrez un nouveau projet ( Fichier > Nouveau projet ). Sous Multiplateforme, sélectionnez Swift Package .
Appelez le package quelque chose de logique - quelque chose d'explicatif - comme " ChatServer ". Enregistrez-le ensuite où vous le souhaitez.
Forfait Swift ?
Lors de la création d'un framework ou d'un logiciel multiplateforme (par exemple Linux) dans Swift, les packages Swift sont la solution privilégiée. Il s'agit de la solution officielle pour créer du code modulaire que d'autres projets Swift peuvent facilement utiliser. Cependant, un package Swift ne doit pas nécessairement être un projet modulaire : il peut également s'agir d'un exécutable autonome qui utilise simplement d'autres packages Swift comme dépendances (c'est ce que nous faisons).
Vous avez peut-être pensé qu'aucun projet Xcode (
.xcodeproj
) n'est présent pour le package Swift. Pour ouvrir un package Swift dans Xcode comme n'importe quel autre projet, ouvrez simplement le fichierPackage.swift
. Xcode devrait reconnaître que vous ouvrez un package Swift et ouvre toute la structure du projet. Il récupérera automatiquement toutes les dépendances au début.Vous pouvez en savoir plus sur les packages Swift et Swift Package Manager sur le site officiel de Swift.
Pour gérer tout le gros du travail lié à la configuration d'un serveur, nous utiliserons le framework Web Vapor. Vapor est livré avec toutes les fonctionnalités nécessaires pour créer un serveur WebSocket.
Des WebSockets ?
Pour offrir au Web la possibilité de communiquer avec un serveur en temps réel, des WebSockets ont été créés. Il s'agit d'une spécification bien décrite pour une communication sécurisée en temps réel (faible bande passante) entre un client et un serveur. Par exemple : les jeux multijoueurs et les applications de chat. Ces jeux multijoueurs addictifs dans le navigateur auxquels vous jouez pendant votre temps précieux en entreprise ? Ouais, WebSockets !
Cependant, si vous souhaitez faire quelque chose comme le streaming vidéo en temps réel, il est préférable de rechercher une solution différente. ?
Bien que nous créions une application de chat iOS/macOS dans ce didacticiel, le serveur que nous créons peut tout aussi facilement communiquer avec d'autres plates-formes avec WebSockets. En effet : si vous le souhaitez vous pouvez également réaliser une version Android et web de cette application de chat, parlant au même serveur et permettant la communication entre toutes les plateformes !
Vapeur?
Internet est une série complexe de tubes. Même répondre à une simple requête HTTP nécessite une quantité importante de code. Heureusement, des experts dans le domaine ont développé des frameworks Web open source qui font tout le travail pour nous depuis des décennies maintenant, dans divers langages de programmation. Vapor en fait partie, et c'est écrit en Swift. Il est déjà livré avec certaines fonctionnalités WebSocket et c'est exactement ce dont nous avons besoin.
Vapor n'est cependant pas le seul framework Web propulsé par Swift. Kitura et Perfect sont également des frameworks bien connus. Bien que Vapor soit sans doute plus actif dans son développement.
Xcode devrait ouvrir le fichier Package.swift
par défaut. C'est ici que nous mettons les informations générales et les exigences de notre package Swift.
Avant de faire cela, regardez dans le dossier Sources/ChatServer
. Il devrait avoir un fichier ChatServer.swift
. Nous devons le renommer en main.swift
. Une fois cela fait, revenez à Package.swift
.
Sous products:
, supprimez la valeur suivante :
. library ( name : " ChatServer " , targets : [ " ChatServer " ] )
... et remplacez-le par :
. executable ( name : " ChatServer " , targets : [ " ChatServer " ] )
Après tout, notre serveur n'est pas une bibliothèque. Mais plutôt un exécutable autonome. Nous devons également définir les plates-formes (et la version minimale) sur lesquelles nous espérons que notre serveur fonctionnera. Cela peut être fait en ajoutant platforms: [.macOS(v10_15)]
sous name: "ChatServer"
:
name: " ChatServer " ,
platforms: [
. macOS ( . v10_15 ) ,
] ,
Tout cela devrait rendre notre package Swift « exécutable » dans Xcode.
Très bien, ajoutons Vapor comme dépendance. Dans dependencies: []
(qui devraient contenir des éléments commentés), ajoutez ce qui suit :
. package ( url : " https://github.com/vapor/vapor.git " , from : " 4.0.0 " )
Lors de l'enregistrement du fichier Package.swift
, Xcode devrait commencer automatiquement à récupérer les dépendances Vapor avec la version 4.0.0
ou plus récente. Ainsi que toutes ses dépendances.
Nous devons juste apporter un ajustement supplémentaire au fichier pendant que Xcode fait son travail : ajouter la dépendance à notre cible. Dans targets:
vous trouverez un .target(name: "ChatServer", dependencies: [])
. Dans ce tableau vide, ajoutez ce qui suit :
. product ( name : " Vapor " , package : " vapor " )
C'est ça . Notre Package.swift
est terminé. Nous avons décrit notre package Swift en lui disant :
Le Package.swift
final devrait ressembler à ceci (-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 " )
] ) ,
]
)
Maintenant, il est enfin temps de...
Dans Xcode, ouvrez Sources/ChatServer/main.swift
et supprimez tout ce qu'il contient. Cela ne vaut rien pour nous. Au lieu de cela, faites en sorte que main.swift
ressemble à ceci :
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
? Boum ! C'est tout ce qu'il faut pour démarrer un serveur (WebSocket) à l'aide de Vapor. Regardez comme c’était facile.
defer
et appelez .shutdown()
qui effectuera tout nettoyage à la sortie du programme./chat
. Maintenant
Une fois le programme exécuté avec succès, vous ne verrez peut-être rien qui ressemble à une application. En effet, les logiciels serveur n'ont généralement pas d'interface utilisateur graphique. Mais rassurez-vous, le programme est bien vivant en arrière-plan, faisant tourner ses roues. La console Xcode devrait cependant afficher le message suivant :
notice codes.vapor.application : Server starting on http://127.0.0.1:8080
Cela signifie que le serveur peut écouter avec succès les demandes entrantes. C'est génial, car nous avons maintenant un serveur WebSocket auquel nous pouvons commencer à nous connecter !
Je ne te crois pas ?
Si pour une raison quelconque vous pensez que je n'ai craché que des mensonges odieux tout ce temps, vous pouvez tester le serveur vous-même !
Ouvrez votre navigateur préféré et assurez-vous que vous êtes dans un onglet vide. (S'il s'agit de Safari, vous devrez d'abord activer le mode développeur.) Ouvrez l' inspecteur (
Cmd
+Option
+I
) et accédez à la console . Tapeznew WebSocket ( 'ws://localhost:8080/chat' )et appuyez sur Retour. Jetez maintenant un œil à la console Xcode. Si tout s'est bien passé, il devrait maintenant afficher
Connected: WebSocketKit.WebSocket
.????
Le serveur n'est accessible que depuis votre machine locale. Cela signifie que vous ne pouvez pas connecter votre iPhone/iPad physique au serveur. Au lieu de cela, nous utiliserons le simulateur dans les étapes suivantes pour tester notre application de chat.
Pour tester l'application de chat sur un appareil physique, quelques (petites) étapes supplémentaires doivent être suivies. Reportez-vous à l'annexe A.
Même si nous n’en avons pas encore fini avec le backend, il est temps de passer au frontend. L'application de chat elle-même !
Dans Xcode, créez un nouveau projet. Cette fois, sous Multiplateforme, sélectionnez App . Encore une fois, choisissez un beau nom pour votre application et continuez. (J'ai choisi SwiftChat . Je suis d'accord, c'est parfait ?)
L'application ne s'appuie sur aucun framework ou bibliothèque tiers externe. En effet, tout ce dont nous avons besoin est disponible via Foundation
, Combine
et SwiftUI
(dans Xcode 12+).
Commençons immédiatement à travailler sur l'écran de discussion. Créez un nouveau fichier Swift et nommez- ChatScreen.swift
. Peu importe que vous choisissiez le modèle Swift File ou SwiftUI View . Nous supprimons tout ce qu'il contient malgré tout.
Voici le kit de démarrage 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 ( )
}
}
}
Dans ContentsView.swift
, remplacez Hello World par ChatScreen()
:
struct ContentView : View {
var body : some View {
ChatScreen ( )
}
}
Une toile vierge, pour l’instant.
À gauche : iPhone avec un aspect sombre. À droite : iPad d’apparence claire.
Ce que nous avons ici :
Si vous souhaitez faire des choix de conception différents, allez-y. ?
Commençons maintenant à travailler sur une logique non liée à l'interface utilisateur : la connexion au serveur que nous venons de créer.
SwiftUI , avec le framework Combine , fournit aux développeurs des outils pour implémenter la séparation des préoccupations sans effort dans leur code. En utilisant le protocole ObservableObject
et les wrappers de propriétés @StateObject
(ou @ObservedObject
), nous pouvons implémenter une logique non-UI (appelée Business Logic ) dans un endroit séparé. Comme les choses devraient être ! Après tout, la seule chose dont l’interface utilisateur doit se soucier est d’afficher les données à l’utilisateur et de réagir aux entrées de l’utilisateur. Il ne devrait pas se soucier de la provenance des données ni de la manière dont elles sont manipulées.
Venant d'un milieu React, ce luxe est quelque chose que j'envie incroyablement.
Il existe des milliers et des milliers d'articles et de discussions sur l'architecture logicielle. Vous avez probablement entendu ou lu des concepts tels que MVC, MVVM, VAPOR, Clean Architecture et bien plus encore. Ils ont tous leurs arguments et leurs applications.
En discuter est hors du cadre de ce didacticiel. Mais il est généralement admis que la logique métier et la logique de l’interface utilisateur ne doivent pas être liées.
Ce concept est tout aussi vrai pour notre ChatScreen . La seule chose dont ChatScreen doit se soucier est d'afficher les messages et de gérer le texte saisi par l'utilisateur. Il ne se soucie pas de ✌️We Bs Oc K eTs✌, et il ne devrait pas non plus le faire.
Vous pouvez créer un nouveau fichier Swift ou écrire le code suivant au bas de ChatScreen.swift
. Votre choix. Où qu'il habite, assurez-vous de ne pas oublier les s 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 ( )
}
}
Cela peut être beaucoup à prendre en compte, alors examinons-le lentement :
URLSessionWebSocketTask
dans une propriété.URLSessionWebSocketTask
sont responsables des connexions WebSocket. Ils résident dans la famille URLSession
dans le framework Foundation .127.0.0.1
ou localhost
). Le port par défaut des applications Vapor est 8080
. Et nous mettons un écouteur des connexions WebSocket dans le chemin /chat
.URLSessionWebSocketTask
et la stockons dans la propriété de l'instance.onReceive(incoming:)
sera appelée. Nous en reparlerons plus tard.ChatScreenModel
est purgé de la mémoire. C'est un bon début. Nous avons maintenant un endroit où nous pouvons placer toute notre logique WebSocket sans encombrer le code de l'interface utilisateur. Il est temps que ChatScreen
communique avec ChatScreenModel
.
Ajoutez le ChatScreenModel
en tant qu'objet d'état dans ChatScreen
:
struct ChatScreen : View {
@ StateObject private var model = ChatScreenModel ( ) // <- this here
@ State private var message = " "
// etc...
}
Quand doit-on se connecter au serveur ? Eh bien, lorsque l’écran est réellement visible, bien sûr. Vous pourriez être tenté d'appeler .connect()
dans le init()
de ChatScreen
. C'est une chose dangereuse. En fait, dans SwiftUI, il faut éviter de mettre quoi que ce soit init()
, car la vue peut être initialisée même si elle n'apparaîtra jamais. (Par exemple dans LazyVStack
ou dans NavigationLink(destination:)
.) Ce serait dommage de gaspiller de précieux cycles CPU. Par conséquent, reportons tout à onAppear
.
Ajoutez une méthode onAppear
à ChatScreen
. Ajoutez ensuite et transmettez cette méthode au modificateur .onAppear(perform:)
de VStack
:
struct ChatScreen : View {
// ...
private func onAppear ( ) {
model . connect ( )
}
var body : some View {
VStack {
// ...
}
. onAppear ( perform : onAppear )
}
}
Espace gaspillé ?
De nombreuses personnes préfèrent écrire le contenu de ces méthodes en ligne :
. onAppear { model . connect ( ) }Ce n’est rien d’autre qu’une préférence personnelle. Personnellement, j'aime définir ces méthodes séparément. Oui, cela coûte plus d'espace. Mais ils sont plus faciles à trouver, réutilisables, évitent que le
body
ne soit (plus) encombré et sont sans doute plus faciles à plier. ?
De la même manière, nous devrions également nous déconnecter lorsque la vue disparaît. La mise en œuvre doit être explicite, mais juste au cas où :
struct ChatScreen : View {
// ...
private func onDisappear ( ) {
model . disconnect ( )
}
var body : some View {
VStack {
// ...
}
. onAppear ( perform : onAppear )
. onDisappear ( perform : onDisappear )
}
}
Il est très important de fermer les connexions WebSocket chaque fois que nous cessons de nous en soucier. Lorsque vous fermez (gracieusement) une connexion WebSocket, le serveur en sera informé et pourra purger la connexion de la mémoire. Le serveur ne doit jamais avoir de connexions mortes ou inconnues persistantes en mémoire.
Ouf. C'est toute une aventure que nous avons vécue jusqu'à présent. Il est temps de le tester.ChatScreen
, vous devriez voir le message Connected: WebSocketKit.WebSocket
dans la console Xcode du serveur. Sinon, revenez sur vos pas et lancez le débogage !
Encore une chose™️. Nous devons également tester si la connexion WebSocket est fermée lorsque l'utilisateur ferme l'application (ou quitte ChatScreen
). Revenez au fichier main.swift
du projet de serveur. Actuellement, notre écouteur WebSocket ressemble à ceci :
app . webSocket ( " chat " ) { req , client in
print ( " Connected: " , client )
}
Ajoutez un gestionnaire au .onClose
du client
, en n'effectuant rien d'autre qu'un simple print()
:
app . webSocket ( " chat " ) { req , client in
print ( " Connected: " , client )
client . onClose . whenComplete { _ in
print ( " Disconnected: " , client )
}
}
Réexécutez le serveur et démarrez l'application de chat. Une fois l'application connectée, fermez-la (en fait, quittez-la, ne vous contentez pas de la mettre en arrière-plan). La console Xcode du serveur devrait maintenant afficher Disconnected: WebSocketKit.WebSocket
. Cela confirme que les connexions WebSocket sont bien fermées lorsqu'on ne s'en soucie plus. Ainsi, le serveur ne doit avoir aucune connexion morte en mémoire.
Êtes-vous prêt à envoyer quelque chose au serveur ? Garçon, je le suis bien sûr. Mais juste un instant, freinons et réfléchissons une seconde. Asseyez-vous en arrière sur la chaise et regardez le plafond sans but, mais d'une manière ou d'une autre délibérément...
Qu'allons -nous envoyer exactement au serveur ? Et, tout aussi important, que recevrons-nous en retour du serveur ?
Votre première pensée sera peut-être « Eh bien, juste un SMS, n'est-ce pas ? », vous auriez à moitié raison. Mais qu’en est-il de l’heure du message ? Et le nom de l'expéditeur ? Qu'en est-il d'un identifiant pour rendre le message unique par rapport à tout autre message ? Nous n'avons encore rien permettant à l'utilisateur de créer un nom d'utilisateur ou quoi que ce soit. Alors mettons cela de côté et concentrons-nous uniquement sur l’envoi et la réception de messages.
Nous allons devoir faire quelques ajustements côté application et côté serveur. Commençons par le serveur.
Créez un nouveau fichier Swift dans Sources/ChatServer
appelé Models.swift
dans le projet de serveur. Collez (ou tapez) le code suivant dans 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
}
Voici ce qui se passe :
Decodable
.Encodable
.ReceivingChatMessage
. Notez comment nous générons la date
et id
côté serveur. Cela fait du serveur la source de la vérité. Le serveur sait quelle heure il est. Si la date devait être générée côté client, elle ne serait pas fiable. Que se passe-t-il si le client a configuré son horloge pour qu'elle soit dans le futur ? Le fait que le serveur génère la date fait de son horloge la seule référence à l'heure.
Fuseaux horaires ?
L'objet
Date
de Swift a toujours 00:00:00 UTC 01-01-2001 comme heure de référence absolue. Lors de l'initialisation d'uneDate
ou du formatage en chaîne (par exemple viaDateFormatter
), la localité du client sera automatiquement prise en compte. Ajouter ou soustraire des heures en fonction du fuseau horaire du client.
UUID ?
Les identifiants universellement uniques sont globalement considérés comme des valeurs acceptables pour les identifiants.
Nous ne voulons pas non plus que le client envoie plusieurs messages avec le même identifiant unique. Que ce soit accidentellement ou délibérément par malveillance. Le fait que le serveur génère cet identifiant constitue une couche de sécurité supplémentaire et réduit les sources d'erreurs possibles.
Maintenant alors. Lorsque le serveur reçoit un message d'un client, il doit le transmettre à tous les autres clients. Cela signifie cependant que nous devons garder une trace de chaque client connecté.
Retour à main.swift
du projet serveur. Juste au-dessus de app.webSocket("chat")
placez la déclaration suivante :
var clientConnections = Set < WebSocket > ( )
C'est ici que nous stockerons nos connexions clients.
Mais attendez … Vous devriez avoir une grosse, mauvaise et méchante erreur de compilation. En effet, l'objet WebSocket
n'est pas conforme au protocole Hashable
par défaut. Ne vous inquiétez pas cependant, cela peut être facilement (bien que peu coûteux) mis en œuvre. Ajoutez le code suivant tout en bas 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. Le code ci-dessus est un moyen rapide mais simple de rendre une class
conforme à Hashable
(et par définition également Equatable
), en utilisant simplement son adresse mémoire comme propriété unique. Remarque : cela ne fonctionne que pour les cours. Les structures nécessiteront une implémentation un peu plus pratique.
Très bien, maintenant que nous sommes capables de suivre les clients, remplacez tout ce qui concerne app.webSocket("chat")
(y compris sa fermeture et son contenu) par le code suivant ? :
app . webSocket ( " chat " ) { req , client in
clientConnections . insert ( client )
client . onClose . whenComplete { _ in
clientConnections . remove ( client )
}
}
Lorsqu'un client se connecte, stockez ledit client dans clientConnections
. Lorsque le client se déconnecte, supprimez-le du même Set
. Ezpz.
La dernière étape de ce chapitre consiste à ajouter le cœur du serveurclient.onClose.whenComplete
- mais toujours à l'intérieur de la fermeture app.webSocket("chat")
- ajoutez l'extrait de code suivant :
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
}
}
Encore une fois, du haut :
.onText
au client connecté.ReceivingChatMessage
avec le message reçu du client.ReceivingChatMessage
seront générés automatiquement.ReceivingChatMessage
en une chaîne JSON (enfin, en tant que Data
).Pourquoi le renvoyer ?
Nous pouvons utiliser cela comme une confirmation que le message a bien été reçu du client. L'application recevra le message comme elle recevrait n'importe quel autre message. Cela nous évitera d'avoir à écrire du code supplémentaire plus tard.
Fait! Le serveur est prêt à recevoir des messages et à les transmettre aux autres clients connectés. Exécutez le serveur et laissez-le inactif en arrière-plan, pendant que nous continuons avec l'application !
Vous vous souvenez des structures SubmittedChatMessage
et ReceivingChatMessage
que nous avons créées pour le serveur ? Nous en avons également besoin pour l'application. Créez un nouveau fichier Swift et nommez- Models.swift
. Bien que vous puissiez simplement copier-coller les implémentations, elles nécessiteront quelques modifications :
import Foundation
struct SubmittedChatMessage : Encodable {
let message : String
}
struct ReceivingChatMessage : Decodable , Identifiable {
let date : Date
let id : UUID
let message : String
}
Remarquez comment les protocoles Encodable
et Decodable
ont été permutés. Cela a du sens : dans l’application, nous encodons uniquement SubmittedChatMessage
et décodons uniquement ReceivingChatMessage
. Le contraire du serveur. Nous avons également supprimé les initialisations automatiques de date
et id
. L’application n’a aucune raison de les générer.
Bon, revenons à ChatScreenModel
(que ce soit dans un fichier séparé ou au bas de ChatScreen.swift
). Ajoutez le haut, mais dans ChatScreenModel
ajoutez la propriété d'instance suivante :
@ Published private ( set ) var messages : [ ReceivingChatMessage ] = [ ]
C'est ici que nous stockerons les messages reçus. Grâce à @Published
, le ChatScreen
saura exactement quand ce tableau sera mis à jour et réagira à ce changement. private(set)
s'assure que seul ChatScreenModel
peut mettre à jour cette propriété. (Après tout, c'est le propriétaire des données. Aucun autre objet n'a à les modifier directement !)
Toujours dans ChatScreenModel
, ajoutez la méthode suivante :
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
}
}
}
Cela semble explicite. Mais par souci de cohérence :
SubmittedChatMessage
qui, pour l’instant, contient simplement le message. Ouvrez ChatScreen.swift
et ajoutez la méthode suivante à ChatScreen
:
private func onCommit ( ) {
if !message . isEmpty {
model . send ( text : message )
message = " "
}
}
Cette méthode sera appelée lorsque l'utilisateur appuie sur le bouton de soumission ou lorsqu'il appuie sur Retour sur le clavier. Bien qu'il n'enverra le message que s'il contient réellement quelque chose .
Dans le .body
de ChatScreen
, localisez TextField
et Button
, puis remplacez-les (mais pas leurs modificateurs ou leur contenu) par les initialisations suivantes :
TextField ( " Message " , text : $message , onEditingChanged : { _ in } , onCommit : onCommit )
// .modifiers here
Button ( action : onCommit ) {
// Image etc
}
// .modifiers here
Lorsque la touche Retour est enfoncée alors que TextField
est focalisé, onCommit
sera appelé. Il en va de même lorsque l'utilisateur appuie sur le Button
. TextField
nécessite également un argument onEditingChanged
- mais nous l'éliminons en lui donnant une fermeture vide.
Il est maintenant temps de commencer à tester ce que nous avons. Assurez-vous que le serveur fonctionne toujours en arrière-plan. Placez quelques points d'arrêt dans la fermeture client.onText
(où le serveur lit les messages entrants) dans main.swift
du serveur. Exécutez l'application et envoyez un message. Le(s) point(s) d'arrêt dans main.swift
doivent être atteints lors de la réception d'un message de l'application. Si c'était le cas, ? luxuriante ! ? Sinon, eh bien... revenez sur vos pas et commencez le débogage !
Envoyer des messages, c’est mignon et tout. Mais qu’en est-il de leur réception ? (Eh bien, techniquement, nous les recevons, mais nous n’y réagissons jamais.) Vous avez raison !
Rencontrons ChatScreenModel
une fois de plus. Vous vous souvenez de cette méthode onReceive(incoming:)
? Remplacez-le et attribuez-lui une méthode frère comme indiqué ci-dessous :
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 )
}
}
}
Donc...
URLSessionWebSocketTask
? Ils ne fonctionnent qu'une seule fois. Ainsi, nous relions instantanément un nouveau gestionnaire, nous sommes donc prêts à lire le prochain message entrant.ReceivingChatMessage
.self.messages
. Cependant , comme URLSessionWebSocketTask
peut appeler le gestionnaire de réception sur un thread différent et comme SwiftUI ne fonctionne que sur le thread principal, nous devons envelopper notre modification dans un DispatchQueue.main.async {}
, garantissant que nous effectuons réellement la modification sur le thread principal. fil conducteur.Expliquer le comment et le pourquoi du travail avec différents threads dans SwiftUI dépasse le cadre de ce didacticiel.
On y est presque !
Revenez sur ChatScreen.swift
. Vous voyez ce ScrollView
vide ? On peut enfin le remplir de messages :
ScrollView {
LazyVStack ( spacing : 8 ) {
ForEach ( model . messages ) { message in
Text ( message . message )
}
}
}
Cela n’aura en aucun cas l’air spectaculaire. Mais cela fera l'affaire pour l'instant. Nous représentons simplement chaque message avec un Text
simple.
Allez-y, lancez l'application. Lorsque vous envoyez un message, il devrait apparaître instantanément à l'écran. Cela confirme que le message a été envoyé avec succès au serveur et que le serveur l'a renvoyé avec succès à l'application ! Maintenant, si vous le pouvez, ouvrez plusieurs instances de l'application (astuce : utilisez différents simulateurs). Il n'y a pratiquement aucune limite au nombre de clients ! Organisez une belle et grande discussion tout seul.
Continuez à envoyer des messages jusqu'à ce qu'il n'y ait plus de place sur l'écran. Vous remarquez quelque chose ? Yarp. Le ScrollView
ne défile pas automatiquement vers le bas une fois que les nouveaux messages dépassent les limites de l'écran. ?
Entrer...
N'oubliez pas que le serveur génère un identifiant unique pour chaque message. Nous pouvons enfin en faire bon usage ! L’attente en valait la peine pour cette récompense incroyable, je vous l’assure.
Dans ChatScreen
, transformez le ScrollView
en cette beauté :
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 )
}
}
}
Ajoutez ensuite la méthode suivante :
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
dans un ScrollViewReader
.ScrollViewReader
nous fournit un proxy
dont nous aurons besoin très prochainement.model.messages.count
. Lorsque cette valeur change, nous appelons la méthode que nous venons d'ajouter, en lui transmettant le proxy
fourni par ScrollViewReader
..scrollTo(_:anchor:)
du ScrollViewProxy
. Cela indique au ScrollView
de faire défiler jusqu'à la vue avec l'identifiant donné. Nous enveloppons cela withAnimation {}
pour animer le défilement.Et voilà...
Ces messages sont assez riches... mais ils le seraient encore plus si nous savions qui a envoyé les messages et si nous distinguions visuellement les messages reçus et envoyés.
À chaque message, nous joindrons également un nom d'utilisateur et un identifiant d'utilisateur. Parce qu'un nom d'utilisateur ne suffit pas à identifier un utilisateur, nous avons besoin de quelque chose d'unique. Et si l'utilisateur et tous les autres s'appelaient Patrick ? Nous aurions une crise d'identité et serions incapables de faire la distinction entre les messages envoyés par Patrick et les messages reçus par un Patrick.
Comme le veut la tradition, on commence par le serveur, c'est le moins de travail.
Ouvrez Models.swift
où nous avons défini à la fois SubmittedChatMessage
et ReceivingChatMessage
. Donnez à ces deux mauvais garçons un user: String
et userID: UUID
, comme ceci :
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'oubliez pas de mettre également à jour le fichier Models.swift dans le projet de l'application !)
De retour à main.swift
, où vous devriez être accueilli par une erreur, modifiez l'initialisation de ReceivingChatMessage
comme suit :
let outgoingMessage = ReceivingChatMessage (
message : incomingMessage . message ,
user : incomingMessage . user ,
userID : incomingMessage . userID
)
Et c'est tout ! Nous en avons fini avec le serveur. C'est juste l'application à partir de maintenant. La dernière ligne droite !
Dans le projet Xcode de l'application, créez un nouveau fichier Swift appelé UserInfo.swift
. Placez-y le code suivant :
import Combine
import Foundation
class UserInfo : ObservableObject {
let userID = UUID ( )
@ Published var username = " "
}
Ce sera notre EnvironmentObject
dans lequel nous pourrons stocker notre nom d'utilisateur. Comme toujours, l'identifiant unique est un UUID immuable généré automatiquement. D'où vient le nom d'utilisateur ? L'utilisateur le saisira lors de l'ouverture de l'application, avant de voir l'écran de discussion.
Nouvelle heure du fichier : SettingsScreen.swift
. Ce fichier hébergera le formulaire de paramètres 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
précédemment créée sera accessible ici en tant que EnvironmentObject
.TextField
écrira directement son contenu dans userInfo.username
.NavigationLink
qui présentera ChatScreen
lorsque vous appuyez dessus. Le bouton est désactivé alors que le nom d'utilisateur n'est pas valide. (Remarquez-vous comment nous initialisons ChatScreen
dans NavigationLink
? Si nous avions fait en sorte que ChatScreen
se connecte au serveur dans son init()
, il l'aurait fait maintenant !)Si vous le souhaitez, vous pouvez ajouter un peu de panache à l'écran.
Puisque nous utilisons les fonctionnalités de navigation de SwiftUI, nous devons commencer avec un NavigationView
quelque part. ContentView
est l'endroit idéal pour cela. Modifiez l'implémentation de ContentView
comme suit :
struct ContentView : View {
@ StateObject private var userInfo = UserInfo ( ) // 1
var body : some View {
NavigationView {
SettingsScreen ( )
}
. environmentObject ( userInfo ) // 2
. navigationViewStyle ( StackNavigationViewStyle ( ) ) // 3
}
}
UserInfo
et...EnvironmentObject
, le rendant accessible à toutes les vues suivantes. Maintenant, envoyons les données de UserInfo
ainsi que les messages que nous envoyons au serveur. Accédez à ChatScreenModel
(où que vous le placiez). En haut de la classe, ajoutez les propriétés suivantes :
final class ChatScreenModel : ObservableObject {
private var username : String ?
private var userID : UUID ?
// the rest ...
}
Le ChatModelScreen
devrait recevoir ces valeurs lors de la connexion. Ce n'est pas le travail de ChatModelScreen
de savoir d'où proviennent ces informations. Si, à l'avenir, nous décidons de modifier l'emplacement de stockage username
et userID
, nous pouvons laisser ChatModelScreen
intact.
Modifiez la méthode connect()
pour accepter ces nouvelles propriétés comme arguments :
func connect ( username : String , userID : UUID ) {
self . username = username
self . userID = userID
// etc ...
}
Enfin, dans send(text:)
, nous devons appliquer ces nouvelles valeurs au SubmittedChatMessage
que nous envoyons au serveur :
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 ...
}
Aaaet c'est tout pour ChatScreenModel
. C'est fini . ??
Pour la dernière fois, ouvrez ChatScreen.swift
. En haut de ChatScreen
ajoutez :
@ EnvironmentObject private var userInfo : UserInfo
N'oubliez pas de fournir le username
et userID
à ChatScreenModel
lorsque la vue apparaît :
private func onAppear ( ) {
model . connect ( username : userInfo . username , userID : userInfo . userID )
}
Maintenant, encore une fois, comme d'habitude : penchez-vous en arrière sur cette chaise et regardez le plafond. À quoi doivent ressembler les SMS ? Si vous n'êtes pas d'humeur créative, vous pouvez utiliser la vue suivante qui représente un seul message reçu (et envoyé) :
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 ( )
}
}
}
}
Ce n’est pas particulièrement excitant. Voici à quoi cela ressemble sur un iPhone :
(Rappelez-vous comment le serveur envoie également la date d'un message ? Ici, elle est utilisée pour afficher l'heure.)
Les couleurs et le positionnement sont basés sur la propriété isUser
transmise par le parent. Dans ce cas, ce parent n'est autre que ChatScreen
. Étant donné que ChatScreen
a accès aux messages ainsi qu'à UserInfo
, c'est là que la logique est placée pour déterminer si le message appartient à l'utilisateur ou non.
ChatMessageRow
remplace le Text
ennuyeux que nous utilisions auparavant pour représenter les messages :
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.
}
}
Bienvenue sur la ligne d'arrivée ! Vous avez réussi jusqu'au bout ! Pour la dernière fois,
À présent, vous devriez disposer d’une application de chat primitive – mais fonctionnelle. Ainsi qu'un serveur gérant les messages entrants et sortants. Tout est écrit en Swift !
Bravo! Et merci beaucoup d'avoir lu ! ?
Vous pouvez télécharger le code final depuis Github.
À gauche : petit iPad à l’apparence sombre. À droite : un énorme iPhone d’apparence claire.
Résumons notre parcours :
Tout cela tout en restant complètement dans l'écosystème rapide. Pas de langages de programmation supplémentaires, pas de cocoapodes ou quoi que ce soit.
Bien sûr, ce que nous avons créé ici n'est qu'une fraction d'une fraction d'une application et serveur de chat prêts à la production complète. Nous avons coupé beaucoup de coins pour économiser sur le temps et la complexité. Inutile de dire que cela devrait donner une compréhension assez basique du fonctionnement d'une application de chat.
Considérez les fonctionnalités suivantes pour, peut-être, implémentez:
ForEach
, nous itons à travers chaque message en mémoire. Le logiciel de chat moderne ne suive qu'une poignée de messages à rendre et ne chargez que dans des messages plus anciens une fois que l'utilisateur fait défiler.Cette étrange API UrlSessionWebsockettask
Si vous avez déjà travaillé avec WebSockets, vous pouvez partager l'opinion que l'API d'Apple pour WebSocket est assez ... non traditionnelle. Vous n'êtes certainement pas seul à ce sujet. Le fait de relier constamment le gestionnaire de réception est tout simplement étrange . Si vous pensez que vous êtes plus à l'aise d'utiliser une API WebSocket plus traditionnelle pour iOS et MacOS, je recommanderais certainement Starscream. Il est bien testé, performant et fonctionne sur des versions plus anciennes d'iOS.
Bugs bogues bogues
Ce tutoriel a été écrit à l'aide de Xcode 12 Beta 5 et iOS 14 Beta 5. Les bogues apparaissent et disparaissent entre chaque nouvelle version bêta. Il est malheureusement impossible de prédire ce qui va et ce qui ne fonctionnera pas à l'avenir (bêta).
Le serveur s'exécute non seulement sur votre machine locale, mais il n'est accessible qu'à partir de votre machine locale. Ce n'est pas un problème lors de l'exécution de l'application dans Simulator (ou en tant qu'application MacOS sur la même machine). Mais exécuter l'application sur un appareil physique ou sur un autre Mac, le serveur devra être rendu accessible dans votre réseau local.
Pour ce faire, dans main.swift
du code du serveur, ajoutez la ligne suivante directement après l'initialisation de l'instance Application
:
app . http . server . configuration . hostname = " 0.0.0.0 "
Maintenant, dans ChatScreenModel
, dans la connect(username:userID:)
Méthode, vous devez modifier l'URL pour correspondre à l'IP locale de votre machine:
let url = URL ( string : " ws://127.0.0.1:8080/chat " ) !
//^^this^^^
La propriété intellectuelle locale de votre machine se trouve de différentes manières. Personnellement, j'ouvre toujours les préférences du système> réseau , où l'IP est directement affichée et peut être sélectionnée et copiée.
Il convient de noter que le taux de réussite de cela varie selon les réseaux. Il y a beaucoup de facteurs (comme la sécurité) qui pourraient empêcher cela de fonctionner.
Merci beaucoup d'avoir lu! Si vous avez des opinions sur cette pièce, des réflexions pour des améliorations ou si vous avez trouvé des erreurs, s'il vous plaît, s'il vous plaît, faites -le moi savoir! Je ferai de mon mieux pour améliorer continuellement ce tutoriel. ?