Erstellen einer sehr einfachen Chat-App in SwiftUI, während Swift und WebSockets zum Erstellen des Chat-Servers verwendet werden. Es ist schnell von oben bis unten, Biene!
In diesem Tutorial erstellen wir eine eher einfache, aber funktionale Chat-App. Die App läuft auf iOS oder macOS – oder auf beiden! Das Schöne an SwiftUI ist, wie wenig Aufwand es erfordert, eine Multiplattform-App zu erstellen.
Ohne einen Server, mit dem man kommunizieren kann, nützt eine Chat-App natürlich kaum etwas. Daher werden wir auch einen sehr einfachen Chat-Server erstellen, der WebSockets nutzt. Alles wird in Swift erstellt und lokal auf Ihrem Computer ausgeführt.
In diesem Tutorial wird davon ausgegangen, dass Sie bereits über ein wenig Erfahrung in der Entwicklung von iOS-/macOS-Apps mit SwiftUI verfügen. Obwohl die Konzepte im Laufe der Zeit erklärt werden, wird nicht alles ausführlich behandelt. Wenn Sie mittippen und den Schritten folgen, erhalten Sie am Ende dieses Tutorials natürlich eine funktionierende Chat-App (für iOS und/oder macOS), die mit einem Server kommuniziert, den Sie ebenfalls erstellt haben! Sie verfügen außerdem über ein grundlegendes Verständnis von Konzepten wie serverseitigem Swift und WebSockets.
Wenn Sie das alles nicht interessiert, können Sie jederzeit bis zum Ende scrollen und den endgültigen Quellcode herunterladen!
Kurz gesagt, wir beginnen damit, einen sehr einfachen, einfachen Server ohne Funktionen zu erstellen. Wir erstellen den Server als Swift-Paket und fügen dann das Vapor-Webframework als Abhängigkeit hinzu. Dies wird uns helfen, mit nur wenigen Codezeilen einen WebSocket-Server einzurichten.
Anschließend beginnen wir mit dem Aufbau der Frontend-Chat-App. Beginnen Sie schnell mit den Grundlagen und fügen Sie dann nach und nach Funktionen (und Notwendigkeiten) hinzu.
Die meiste Zeit werden wir mit der Arbeit an der App verbringen, aber wir werden zwischen dem Servercode und dem App-Code hin und her wechseln, wenn wir neue Funktionen hinzufügen.
Optional
Fangen wir an!
Öffnen Sie Xcode 12 und starten Sie ein neues Projekt ( Datei > Neues Projekt ). Wählen Sie unter Multiplattform die Option Swift Package aus.
Nennen Sie das Paket etwas Logisches – etwas Selbsterklärendes – wie „ ChatServer “. Speichern Sie es dann an einem beliebigen Ort.
Swift-Paket?
Beim Erstellen eines Frameworks oder einer Multiplattform-Software (z. B. Linux) in Swift sind Swift-Pakete die bevorzugte Vorgehensweise. Sie sind die offizielle Lösung zum Erstellen modularen Codes, den andere Swift-Projekte problemlos verwenden können. Ein Swift-Paket muss jedoch nicht unbedingt ein modulares Projekt sein: Es kann auch eine eigenständige ausführbare Datei sein, die einfach andere Swift-Pakete als Abhängigkeiten verwendet (was wir tun).
Möglicherweise ist Ihnen aufgefallen, dass für das Swift-Paket kein Xcode-Projekt (
.xcodeproj
) vorhanden ist. Um ein Swift-Paket in Xcode wie jedes andere Projekt zu öffnen, öffnen Sie einfach die DateiPackage.swift
. Xcode sollte erkennen, dass Sie ein Swift-Paket öffnen, und die gesamte Projektstruktur öffnen. Beim Start werden automatisch alle Abhängigkeiten abgerufen.Weitere Informationen zu Swift Packages und Swift Package Manager finden Sie auf der offiziellen Swift-Website.
Um die ganze schwere Arbeit beim Einrichten eines Servers zu bewältigen, verwenden wir das Vapor-Webframework. Vapor verfügt über alle notwendigen Funktionen zum Erstellen eines WebSocket-Servers.
WebSockets?
Um dem Web die Möglichkeit zu geben, in Echtzeit mit einem Server zu kommunizieren, wurden WebSockets erstellt. Es handelt sich um eine gut beschriebene Spezifikation für eine sichere Echtzeitkommunikation (mit geringer Bandbreite) zwischen einem Client und einem Server. Zum Beispiel: Multiplayer-Spiele und Chat-Apps. Diese süchtig machenden In-Browser-Multiplayer-Spiele, die Sie in Ihrer wertvollen Arbeitszeit gespielt haben? Ja, WebSockets!
Wenn Sie jedoch beispielsweise Video-Streaming in Echtzeit durchführen möchten, suchen Sie am besten nach einer anderen Lösung. ?
Obwohl wir in diesem Tutorial eine Chat-App für iOS/MacOS erstellen, kann der Server, den wir erstellen, genauso einfach mit anderen Plattformen über WebSockets kommunizieren. Tatsächlich: Wenn Sie möchten, können Sie auch eine Android- und eine Webversion dieser Chat-App erstellen, die mit demselben Server kommuniziert und die Kommunikation zwischen allen Plattformen ermöglicht!
Dampf?
Das Internet ist eine komplexe Aneinanderreihung von Röhren. Selbst die Beantwortung einer einfachen HTTP-Anfrage erfordert eine beträchtliche Menge Code. Glücklicherweise haben Experten auf diesem Gebiet bereits seit Jahrzehnten Open-Source-Webframeworks entwickelt, die uns die ganze harte Arbeit abnehmen, und zwar in verschiedenen Programmiersprachen. Vapor ist eines davon und es ist in Swift geschrieben. Es verfügt bereits über einige WebSocket-Funktionen und ist genau das, was wir brauchen.
Vapor ist jedoch nicht das einzige Swift-basierte Web-Framework. Kitura und Perfect sind ebenfalls bekannte Frameworks. Obwohl Vapor in seiner Entwicklung wohl aktiver ist.
Xcode sollte standardmäßig die Datei Package.swift
öffnen. Hier finden Sie allgemeine Informationen und Anforderungen unseres Swift-Pakets.
Bevor wir das tun, schauen Sie jedoch im Ordner Sources/ChatServer
nach. Es sollte eine ChatServer.swift
Datei haben. Wir müssen dies in main.swift
umbenennen. Sobald dies erledigt ist, kehren Sie zu Package.swift
zurück.
Entfernen Sie unter products:
den folgenden Wert:
. library ( name : " ChatServer " , targets : [ " ChatServer " ] )
... und ersetzen Sie es durch:
. executable ( name : " ChatServer " , targets : [ " ChatServer " ] )
Schließlich ist unser Server keine Bibliothek. Sondern eher eine eigenständige ausführbare Datei. Wir sollten auch die Plattformen (und die Mindestversion) definieren, auf denen unser Server voraussichtlich läuft. Dies kann durch Hinzufügen platforms: [.macOS(v10_15)]
unter name: "ChatServer"
erfolgen:
name: " ChatServer " ,
platforms: [
. macOS ( . v10_15 ) ,
] ,
All dies sollte unser Swift-Paket in Xcode „ausführbar“ machen.
Okay, fügen wir Vapor als Abhängigkeit hinzu. Fügen Sie in dependencies: []
(das einige auskommentierte Inhalte enthalten sollte) Folgendes hinzu:
. package ( url : " https://github.com/vapor/vapor.git " , from : " 4.0.0 " )
Beim Speichern der Package.swift
Datei sollte Xcode automatisch damit beginnen, die Vapor-Abhängigkeiten mit Version 4.0.0
oder neuer abzurufen. Sowie alle seine Abhängigkeiten.
Wir müssen nur noch eine weitere Anpassung an der Datei vornehmen, während Xcode seine Arbeit erledigt: das Hinzufügen der Abhängigkeit zu unserem Ziel. In targets:
finden Sie ein .target(name: "ChatServer", dependencies: [])
. Fügen Sie in diesem leeren Array Folgendes hinzu:
. product ( name : " Vapor " , package : " vapor " )
Das ist es . Unser Package.swift
ist fertig. Wir haben unser Swift-Paket folgendermaßen beschrieben:
Das endgültige Package.swift
sollte so aussehen (-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 " )
] ) ,
]
)
Jetzt ist es endlich Zeit für...
Öffnen Sie in Xcode Sources/ChatServer/main.swift
und löschen Sie alles darin. Für uns ist es wertlos. Lassen Sie main.swift
stattdessen wie folgt aussehen:
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
? Bumm! Das ist alles, was Sie brauchen, um einen (WebSocket-)Server mit Vapor zu starten. Schauen Sie sich an, wie mühelos das war.
defer
und rufen Sie .shutdown()
auf, das beim Beenden des Programms alle Bereinigungen durchführt./chat
. Jetzt
Sobald das Programm erfolgreich ausgeführt wurde, sehen Sie möglicherweise nichts, was einer App ähnelt. Das liegt daran, dass Serversoftware in der Regel nicht über grafische Benutzeroberflächen verfügt. Aber seien Sie versichert, das Programm läuft im Hintergrund und dreht seine Räder. Die Xcode-Konsole sollte jedoch die folgende Meldung anzeigen:
notice codes.vapor.application : Server starting on http://127.0.0.1:8080
Dies bedeutet, dass der Server eingehende Anfragen erfolgreich abhören kann. Das ist großartig, denn wir haben jetzt einen WebSocket-Server, mit dem wir eine Verbindung herstellen können!
Ich glaube dir nicht?
Wenn Sie aus irgendeinem Grund glauben, ich hätte die ganze Zeit nur abscheuliche Lügen verbreitet, können Sie den Server selbst testen!
Öffnen Sie Ihren bevorzugten Browser und stellen Sie sicher, dass Sie sich in einem leeren Tab befinden. (Wenn es sich um Safari handelt, müssen Sie zuerst den Entwicklermodus aktivieren.) Öffnen Sie den Inspektor (
Cmd
+Option
+I
) und gehen Sie zur Konsole . Geben Sie einnew WebSocket ( 'ws://localhost:8080/chat' )und drücken Sie die Eingabetaste. Werfen Sie nun einen Blick auf die Xcode-Konsole. Wenn alles gut gelaufen ist, sollte jetzt
Connected: WebSocketKit.WebSocket
angezeigt werden.????
Der Server ist nur von Ihrem lokalen Computer aus zugänglich. Das bedeutet, dass Sie Ihr physisches iPhone/iPad nicht mit dem Server verbinden können. Stattdessen verwenden wir in den folgenden Schritten den Simulator, um unsere Chat-App zu testen.
Um die Chat-App auf einem physischen Gerät zu testen, müssen einige (kleine) zusätzliche Schritte unternommen werden. Siehe Anhang A.
Obwohl wir mit dem Backend noch nicht fertig sind, ist es Zeit, zum Frontend überzugehen. Die Chat-App selbst!
Erstellen Sie in Xcode ein neues Projekt. Wählen Sie dieses Mal unter „Multiplattform“ die Option „App“ aus. Wählen Sie erneut einen schönen Namen für Ihre App und fahren Sie fort. (Ich habe mich für SwiftChat entschieden. Ich stimme zu, es ist perfekt ?)
Die App ist nicht auf externe Frameworks oder Bibliotheken von Drittanbietern angewiesen. Tatsächlich ist alles, was wir brauchen, über Foundation
, Combine
und SwiftUI
(in Xcode 12+) verfügbar.
Beginnen wir sofort mit der Arbeit am Chat-Bildschirm. Erstellen Sie eine neue Swift-Datei und nennen Sie sie ChatScreen.swift
. Dabei spielt es keine Rolle, ob Sie sich für die Vorlage „Swift File“ oder „SwiftUI View“ entscheiden. Wir löschen trotzdem alles darin.
Hier ist das Starter-Kit von 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 ( )
}
}
}
Ersetzen Sie in ContentsView.swift
Hello World durch ChatScreen()
:
struct ContentView : View {
var body : some View {
ChatScreen ( )
}
}
Eine leere Leinwand, vorerst.
Links: iPhone mit dunklem Erscheinungsbild. Rechts: iPad mit heller Optik.
Was wir hier haben:
Wenn Sie andere Designentscheidungen treffen möchten, fahren Sie fort. ?
Beginnen wir nun mit der Arbeit an einer Logik, die nichts mit der Benutzeroberfläche zu tun hat: Wir stellen eine Verbindung zu genau dem Server her, den wir gerade erstellt haben.
SwiftUI stellt Entwicklern zusammen mit dem Combine- Framework Tools zur Verfügung, mit denen sie Seperation of Concerns mühelos in ihren Code implementieren können. Mit dem ObservableObject
-Protokoll und den Eigenschaften-Wrappern @StateObject
(oder @ObservedObject
) können wir Nicht-UI-Logik (als Business Logic bezeichnet) an einer separaten Stelle implementieren. So wie es sein sollte! Schließlich sollte sich die Benutzeroberfläche nur darum kümmern, dem Benutzer Daten anzuzeigen und auf Benutzereingaben zu reagieren. Es sollte egal sein, woher die Daten kommen oder wie sie manipuliert werden.
Da ich einen React-Hintergrund habe, bin ich unglaublich neidisch auf diesen Luxus.
Es gibt Abertausende Artikel und Diskussionen zum Thema Softwarearchitektur. Sie haben wahrscheinlich schon von Konzepten wie MVC, MVVM, VAPOR, Clean Architecture und mehr gehört oder gelesen. Sie alle haben ihre Argumente und ihre Anwendungen.
Die Diskussion dieser Themen würde den Rahmen dieses Tutorials sprengen. Es besteht jedoch allgemein Einigkeit darüber, dass Geschäftslogik und UI-Logik nicht miteinander verflochten sein sollten.
Dieses Konzept trifft auch auf unseren ChatScreen zu. Das Einzige, worum sich der ChatScreen kümmern sollte, ist die Anzeige der Nachrichten und die Verarbeitung des Benutzereingabetextes. Es kümmert sich nicht um ✌️We Bs Oc K eTs✌, und das sollte es auch nicht.
Sie können eine neue Swift-Datei erstellen oder den folgenden Code unten in ChatScreen.swift
schreiben. Ihre Wahl. Wo auch immer es lebt, vergessen Sie nicht die 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 ( )
}
}
Das kann eine Menge sein, also gehen wir es langsam durch:
URLSessionWebSocketTask
in einer Eigenschaft.URLSessionWebSocketTask
-Objekte sind für WebSocket-Verbindungen verantwortlich. Sie sind Bewohner der URLSession
Familie im Foundation -Framework.127.0.0.1
oder localhost
verwenden). Der Standardport von Vapor-Anwendungen ist 8080
. Und wir haben einen Listener für WebSocket-Verbindungen in den /chat
Pfad eingefügt.URLSessionWebSocketTask
und speichern sie im Eigentum der Instanz.onReceive(incoming:)
aufgerufen. Mehr dazu später.ChatScreenModel
aus dem Speicher gelöscht wird. Das ist ein toller Anfang. Wir haben jetzt einen Ort, an dem wir unsere gesamte WebSocket-Logik unterbringen können, ohne den UI-Code zu überladen. Es ist an der Zeit, dass ChatScreen
mit ChatScreenModel
kommuniziert.
Fügen Sie das ChatScreenModel
als Statusobjekt in ChatScreen
hinzu:
struct ChatScreen : View {
@ StateObject private var model = ChatScreenModel ( ) // <- this here
@ State private var message = " "
// etc...
}
Wann sollten wir uns mit dem Server verbinden? Na ja, natürlich, wenn der Bildschirm tatsächlich sichtbar ist. Sie könnten versucht sein .connect()
im init()
von ChatScreen
aufzurufen. Das ist eine gefährliche Sache. Tatsächlich sollte man in SwiftUI versuchen, das Einfügen von init()
zu vermeiden, da die Ansicht auch dann initialisiert werden kann, wenn sie nie angezeigt wird. (Zum Beispiel in LazyVStack
oder in NavigationLink(destination:)
.) Es wäre eine Schande, wertvolle CPU-Zyklen zu verschwenden. Lassen Sie uns daher alles auf onAppear
verschieben.
Fügen Sie ChatScreen
eine onAppear
-Methode hinzu. Fügen Sie dann diese Methode hinzu und übergeben Sie sie an den Modifikator .onAppear(perform:)
von VStack
:
struct ChatScreen : View {
// ...
private func onAppear ( ) {
model . connect ( )
}
var body : some View {
VStack {
// ...
}
. onAppear ( perform : onAppear )
}
}
Verschwendeter Platz?
Viele Leute ziehen es stattdessen vor, den Inhalt dieser Methoden inline zu schreiben:
. onAppear { model . connect ( ) }Das ist nichts anderes als eine persönliche Präferenz. Persönlich definiere ich diese Methoden gerne separat. Ja, es kostet mehr Platz. Aber sie sind leichter zu finden, wiederverwendbar, verhindern, dass der
body
(noch) unübersichtlich wird, und lassen sich wohl leichter falten. ?
Aus dem gleichen Grund sollten wir auch die Verbindung trennen, wenn die Ansicht verschwindet. Die Implementierung sollte selbsterklärend sein, aber nur für den Fall:
struct ChatScreen : View {
// ...
private func onDisappear ( ) {
model . disconnect ( )
}
var body : some View {
VStack {
// ...
}
. onAppear ( perform : onAppear )
. onDisappear ( perform : onDisappear )
}
}
Es ist sehr wichtig, WebSocket-Verbindungen zu schließen, wenn sie uns nicht mehr wichtig sind. Wenn Sie eine WebSocket-Verbindung (anständig) schließen, wird der Server darüber informiert und kann die Verbindung aus dem Speicher löschen. Der Server sollte niemals tote oder unbekannte Verbindungen im Speicher haben.
Puh. Eine ziemliche Fahrt, die wir bisher hinter uns haben. Zeit, es auszuprobieren.ChatScreen
anzeigt, sollte in der Xcode-Konsole des Servers die Meldung Connected: WebSocketKit.WebSocket
angezeigt werden. Wenn nicht, gehen Sie Ihre Schritte zurück und beginnen Sie mit dem Debuggen!
Noch etwas™️. Wir sollten auch testen, ob die WebSocket-Verbindung geschlossen wird, wenn der Benutzer die App schließt (oder ChatScreen
verlässt). Gehen Sie zurück zur Datei main.swift
des Serverprojekts. Derzeit sieht unser WebSocket-Listener so aus:
app . webSocket ( " chat " ) { req , client in
print ( " Connected: " , client )
}
Fügen Sie dem .onClose
von client
einen Handler hinzu, der lediglich einen einfachen print()
ausführt:
app . webSocket ( " chat " ) { req , client in
print ( " Connected: " , client )
client . onClose . whenComplete { _ in
print ( " Disconnected: " , client )
}
}
Führen Sie den Server erneut aus und starten Sie die Chat-App. Sobald die App verbunden ist, schließen Sie die App (beenden Sie sie tatsächlich, stellen Sie sie nicht einfach in den Hintergrund). Die Xcode-Konsole des Servers sollte nun Disconnected: WebSocketKit.WebSocket
ausgeben. Dies bestätigt, dass WebSocket-Verbindungen tatsächlich geschlossen werden, wenn sie uns nicht mehr wichtig sind. Daher sollten auf dem Server keine toten Verbindungen im Speicher verbleiben.
Sind Sie bereit, tatsächlich etwas an den Server zu senden? Junge, das bin ich sicher. Aber lasst uns für einen Moment auf die Bremse treten und einen Moment nachdenken. Lehnen Sie sich im Stuhl zurück und starren Sie ziellos und doch irgendwie zielstrebig an die Decke ...
Was genau senden wir an den Server? Und was genauso wichtig ist: Was erhalten wir vom Server zurück?
Ihr erster Gedanke könnte sein: „Naja, nur Text schreiben, oder?“, da haben Sie halb recht. Aber wie sieht es mit dem Zeitpunkt der Nachricht aus? Was ist mit dem Namen des Absenders? Wie wäre es mit einer Kennung, um die Nachricht von jeder anderen Nachricht eindeutig zu machen? Wir haben noch keine Möglichkeit für den Benutzer, einen Benutzernamen oder ähnliches zu erstellen. Lassen wir das also beiseite und konzentrieren uns einfach auf das Senden und Empfangen von Nachrichten.
Wir müssen sowohl auf der App- als auch auf der Serverseite einige Anpassungen vornehmen. Beginnen wir mit dem Server.
Erstellen Sie im Serverprojekt eine neue Swift-Datei in Sources/ChatServer
mit dem Namen Models.swift
. Fügen Sie den folgenden Code in Models.swift
ein (oder geben Sie ihn ein):
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
}
Folgendes ist los:
Decodable
-Protokoll.Encodable
-Protokoll.ReceivingChatMessage
initialisiert wird. Beachten Sie, wie wir das date
und id
auf der Serverseite generieren. Dies macht den Server zur Quelle der Wahrheit. Der Server weiß, wie spät es ist. Wenn das Datum clientseitig generiert würde, wäre es nicht vertrauenswürdig. Was passiert, wenn der Kunde seine Uhr so eingestellt hat, dass sie in der Zukunft liegt? Wenn der Server das Datum generiert, ist seine Uhr der einzige Hinweis auf die Zeit.
Zeitzonen?
Date
Objekt von Swift hat immer 00:00:00 UTC 01-01-2001 als absolute Referenzzeit. Beim Initialisieren einesDate
oder beim Formatieren eines Datums als Zeichenfolge (z. B. überDateFormatter
) wird der Standort des Clients automatisch berücksichtigt. Abhängig von der Zeitzone des Kunden werden Stunden addiert oder subtrahiert.
UUID?
Universell eindeutige ID- Entifizierer werden weltweit als akzeptable Werte für Identifikatoren angesehen.
Wir möchten auch nicht, dass der Client mehrere Nachrichten mit derselben eindeutigen Kennung sendet. Ob versehentlich oder absichtlich böswillig. Die Generierung dieser Kennung durch den Server stellt eine zusätzliche Sicherheitsebene dar und verringert mögliche Fehlerquellen.
Nun denn. Wenn der Server eine Nachricht von einem Client empfängt, sollte er diese an alle anderen Clients weiterleiten. Dies bedeutet jedoch, dass wir den Überblick über jeden verbundenen Client behalten müssen.
Zurück zur main.swift
des Serverprojekts. Direkt über app.webSocket("chat")
geben Sie die folgende Erklärung ein:
var clientConnections = Set < WebSocket > ( )
Hier speichern wir unsere Client-Verbindungen.
Aber warten Sie ... Sie sollten einen großen, schlimmen und unangenehmen Kompilierungsfehler erhalten. Das liegt daran, dass das WebSocket
-Objekt standardmäßig nicht dem Hashable
-Protokoll entspricht. Aber keine Sorge, dies lässt sich leicht (wenn auch kostengünstig) umsetzen. Fügen Sie den folgenden Code ganz unten in main.swift
hinzu:
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. Der obige Code ist eine schnelle, aber einfache Möglichkeit, eine class
konform mit Hashable
(und per Definition auch Equatable
) zu machen, indem einfach ihre Speicheradresse als eindeutige Eigenschaft verwendet wird. Hinweis : Dies funktioniert nur für Klassen. Strukturen erfordern eine etwas praktischere Implementierung.
Okay, da wir nun in der Lage sind, den Überblick über die Kunden zu behalten, ersetzen wir alles von app.webSocket("chat")
(einschließlich seiner Schließung und seines Inhalts) durch den folgenden Code?:
app . webSocket ( " chat " ) { req , client in
clientConnections . insert ( client )
client . onClose . whenComplete { _ in
clientConnections . remove ( client )
}
}
Wenn ein Client eine Verbindung herstellt, speichern Sie diesen Client in clientConnections
. Wenn der Client die Verbindung trennt, entfernen Sie ihn aus demselben Set
. Ezpz.
Der letzte Schritt in diesem Kapitel ist das Hinzufügen des Herzstücks des Serversclient.onClose.whenComplete
– aber immer noch innerhalb des app.webSocket("chat")
-Abschlusses – den folgenden Codeausschnitt hinzu:
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
}
}
Nochmals von oben:
.onText
-Handler an den verbundenen Client.ReceivingChatMessage
mit der vom Client empfangenen Nachricht.ReceivingChatMessage
automatisch generiert werden.ReceivingChatMessage
in eine JSON-Zeichenfolge (also als Data
).Warum es zurückschicken?
Wir können dies als Bestätigung dafür verwenden, dass die Nachricht tatsächlich erfolgreich vom Client empfangen wurde. Die App erhält die Nachricht wie jede andere Nachricht zurück. Dadurch wird verhindert, dass wir später zusätzlichen Code schreiben müssen.
Erledigt! Der Server ist bereit, Nachrichten zu empfangen und an andere verbundene Clients weiterzuleiten. Starten Sie den Server und lassen Sie ihn im Hintergrund laufen, während wir mit der App fortfahren!
Erinnern Sie sich an die SubmittedChatMessage
und ReceivingChatMessage
Strukturen, die wir für den Server erstellt haben? Wir brauchen sie auch für die App. Erstellen Sie eine neue Swift-Datei und nennen Sie sie Models.swift
. Obwohl Sie die Implementierungen einfach kopieren und einfügen könnten, sind einige Änderungen erforderlich:
import Foundation
struct SubmittedChatMessage : Encodable {
let message : String
}
struct ReceivingChatMessage : Decodable , Identifiable {
let date : Date
let id : UUID
let message : String
}
Beachten Sie, dass die Protokolle Encodable
und Decodable
vertauscht wurden. Es macht nur Sinn: In der App kodieren wir nur SubmittedChatMessage
und dekodieren nur ReceivingChatMessage
. Das Gegenteil des Servers. Wir haben auch die automatischen Initialisierungen von date
und id
entfernt. Die App hat nichts damit zu tun, diese zu generieren.
Okay, zurück zu ChatScreenModel
(ob in einer separaten Datei oder am Ende von ChatScreen.swift
). Fügen Sie die Oberseite hinzu, aber fügen Sie innerhalb ChatScreenModel
die folgende Instanzeigenschaft hinzu:
@ Published private ( set ) var messages : [ ReceivingChatMessage ] = [ ]
Hier speichern wir empfangene Nachrichten. Dank @Published
weiß der ChatScreen
genau, wann dieses Array aktualisiert wird, und reagiert auf diese Änderung. private(set)
stellt sicher, dass nur ChatScreenModel
diese Eigenschaft aktualisieren kann. (Schließlich ist es der Eigentümer der Daten. Kein anderes Objekt hat das Recht, diese direkt zu ändern!)
Fügen Sie noch innerhalb von ChatScreenModel
die folgende Methode hinzu:
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
}
}
}
Es scheint selbsterklärend. Aber der Konsistenz halber:
SubmittedChatMessage
, die vorerst nur die Nachricht enthält. Öffnen Sie ChatScreen.swift
und fügen Sie die folgende Methode zu ChatScreen
hinzu:
private func onCommit ( ) {
if !message . isEmpty {
model . send ( text : message )
message = " "
}
}
Diese Methode wird aufgerufen, wenn der Benutzer entweder die Schaltfläche „Senden“ oder die Eingabetaste auf der Tastatur drückt. Allerdings wird die Nachricht nur gesendet, wenn sie tatsächlich etwas enthält .
Suchen Sie im .body
von ChatScreen
nach TextField
und Button
und ersetzen Sie sie (aber nicht ihre Modifikatoren oder Inhalte) durch die folgenden Initialisierungen:
TextField ( " Message " , text : $message , onEditingChanged : { _ in } , onCommit : onCommit )
// .modifiers here
Button ( action : onCommit ) {
// Image etc
}
// .modifiers here
Wenn die Eingabetaste gedrückt wird, während das TextField
fokussiert ist, wird onCommit
aufgerufen. Das Gleiche gilt, wenn der Button
vom Benutzer gedrückt wird. TextField
erfordert auch ein onEditingChanged
-Argument – aber wir verwerfen das, indem wir ihm einen leeren Abschluss geben.
Jetzt ist es an der Zeit, zu testen, was wir haben. Stellen Sie sicher, dass der Server weiterhin im Hintergrund läuft. Platzieren Sie einige Haltepunkte im client.onText
-Abschluss (wo der Server eingehende Nachrichten liest) in main.swift
des Servers. Führen Sie die App aus und senden Sie eine Nachricht. Der/die Haltepunkt(e) in main.swift
sollten beim Empfang einer Nachricht von der App erreicht werden. Wenn ja, ? üppig ! ? Wenn nicht, dann gehen Sie Ihre Schritte zurück und beginnen Sie mit dem Debuggen!
Das Versenden von Nachrichten ist süß und so. Aber wie sieht es mit dem Empfang aus? (Naja, technisch gesehen empfangen wir sie, reagieren aber nie darauf.) Da hast du recht!
Besuchen wir ChatScreenModel
noch einmal. Erinnern Sie sich an die onReceive(incoming:)
Methode? Ersetzen Sie es und weisen Sie ihm eine Geschwistermethode zu, wie unten gezeigt:
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 )
}
}
}
Also...
URLSessionWebSocketTask
? Sie funktionieren nur einmal. Daher binden wir sofort einen neuen Handler neu, sodass wir bereit sind, die nächste eingehende Nachricht zu lesen.ReceivingChatMessage
.self.messages
ein. Da URLSessionWebSocketTask
jedoch den Empfangshandler in einem anderen Thread aufrufen kann und SwiftUI nur im Hauptthread funktioniert, müssen wir unsere Änderung in ein DispatchQueue.main.async {}
einbinden, um sicherzustellen, dass wir die Änderung tatsächlich am durchführen Hauptthread.Das Wie und Warum der Arbeit mit verschiedenen Threads in SwiftUI zu erklären, würde den Rahmen dieses Tutorials sprengen.
Fast geschafft!
Schauen Sie noch einmal auf ChatScreen.swift
vorbei. Sehen Sie das leere ScrollView
? Endlich können wir es mit Nachrichten füllen:
ScrollView {
LazyVStack ( spacing : 8 ) {
ForEach ( model . messages ) { message in
Text ( message . message )
}
}
}
Es wird auf keinen Fall spektakulär aussehen. Aber das wird vorerst reichen. Wir stellen einfach jede Nachricht mit einem einfachen Text
dar.
Fahren Sie fort und führen Sie die App aus. Wenn Sie eine Nachricht senden, sollte diese sofort auf dem Bildschirm erscheinen. Dies bestätigt, dass die Nachricht erfolgreich an den Server gesendet wurde und der Server sie erfolgreich an die App zurückgesendet hat! Öffnen Sie nun, wenn möglich, mehrere Instanzen der App (Tipp: Verwenden Sie verschiedene Simulatoren). Der Anzahl der Kunden sind praktisch keine Grenzen gesetzt! Veranstalten Sie ganz alleine eine schöne große Chat-Party.
Senden Sie so lange Nachrichten, bis auf dem Bildschirm kein Platz mehr ist. Fällt Ihnen etwas auf? Yarp. Die ScrollView
scrollt nicht automatisch nach unten, sobald neue Nachrichten außerhalb der Bildschirmgrenzen liegen. ?
Eingeben...
Denken Sie daran, dass der Server für jede Nachricht eine eindeutige Kennung generiert. Endlich können wir es sinnvoll nutzen! Das Warten auf diese unglaubliche Auszahlung hat sich gelohnt, das versichere ich Ihnen.
Verwandeln Sie in ChatScreen
die ScrollView
in diese Schönheit:
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 )
}
}
}
Fügen Sie dann die folgende Methode hinzu:
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
in einen ScrollViewReader
.ScrollViewReader
stellt uns einen proxy
zur Verfügung, den wir sehr bald benötigen werden.model.messages.count
. Wenn sich dieser Wert ändert, rufen wir die gerade hinzugefügte Methode auf und übergeben ihr den von ScrollViewReader
bereitgestellten proxy
..scrollTo(_:anchor:)
des ScrollViewProxy
auf. Dadurch wird ScrollView
angewiesen, zur Ansicht mit der angegebenen Kennung zu scrollen. Wir packen dies in withAnimation {}
ein, um das Scrollen zu animieren.Et voilà...
Diese Nachrichten sind ziemlich üppig ... aber es wäre noch üppiger, wenn wir wüssten, wer die Nachrichten gesendet hat, und visuell zwischen empfangenen und gesendeten Nachrichten unterscheiden würden.
Mit jeder Nachricht fügen wir außerdem einen Benutzernamen und eine Benutzerkennung hinzu. Da ein Benutzername nicht ausreicht, um einen Benutzer zu identifizieren, benötigen wir etwas Einzigartiges. Was wäre, wenn der Name des Benutzers und aller anderen Patrick wäre? Wir hätten eine Identitätskrise und wären nicht in der Lage, zwischen von Patrick gesendeten und von einem Patrick empfangenen Nachrichten zu unterscheiden.
Wie es Tradition ist, beginnen wir mit dem Server, das ist der geringste Arbeitsaufwand.
Öffnen Sie Models.swift
, wo wir sowohl SubmittedChatMessage
als auch ReceivingChatMessage
definiert haben. Geben Sie diesen beiden bösen Jungs einen user: String
und userID: UUID
-Eigenschaft, etwa so:
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
}
(Vergessen Sie nicht, auch die Models.swift-Datei im Projekt der App zu aktualisieren!)
Wenn Sie zu main.swift
zurückkehren, wo Sie mit einer Fehlermeldung begrüßt werden sollten, ändern Sie die Initialisierung von ReceivingChatMessage
wie folgt:
let outgoingMessage = ReceivingChatMessage (
message : incomingMessage . message ,
user : incomingMessage . user ,
userID : incomingMessage . userID
)
Und das ist es ! Wir sind mit dem Server fertig. Von nun an ist es nur noch die App. Die Zielgerade!
Erstellen Sie im Xcode-Projekt der App eine neue Swift-Datei mit dem Namen UserInfo.swift
. Platzieren Sie dort den folgenden Code:
import Combine
import Foundation
class UserInfo : ObservableObject {
let userID = UUID ( )
@ Published var username = " "
}
Dies wird unser EnvironmentObject
sein, in dem wir unseren Benutzernamen speichern können. Wie immer ist die eindeutige Kennung eine automatisch generierte unveränderliche UUID. Woher kommt der Benutzername? Der Benutzer gibt dies beim Öffnen der App ein, bevor ihm der Chat-Bildschirm angezeigt wird.
Neue Dateizeit: SettingsScreen.swift
. Diese Datei enthält das einfache Einstellungsformular:
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
Klasse kann hier als EnvironmentObject
zugegriffen werden.TextField
schreibt seinen Inhalt direkt in userInfo.username
.NavigationLink
, der beim Drücken ChatScreen
anzeigt. Die Schaltfläche ist deaktiviert, solange der Benutzername ungültig ist. (Ist Ihnen aufgefallen, wie wir ChatScreen
im NavigationLink
initialisieren? Hätten wir ChatScreen
in seiner init()
dazu gebracht, eine Verbindung zum Server herzustellen, hätte dies jetzt geschehen!)Wenn Sie möchten, können Sie dem Bildschirm etwas mehr Schwung verleihen.
Da wir die Navigationsfunktionen von SwiftUI verwenden, müssen wir irgendwo mit einer NavigationView
beginnen. ContentView
ist dafür der perfekte Ort. Ändern Sie die Implementierung von ContentView
wie folgt:
struct ContentView : View {
@ StateObject private var userInfo = UserInfo ( ) // 1
var body : some View {
NavigationView {
SettingsScreen ( )
}
. environmentObject ( userInfo ) // 2
. navigationViewStyle ( StackNavigationViewStyle ( ) ) // 3
}
}
UserInfo
und ...EnvironmentObject
weiter und machen Sie es für alle nachfolgenden Ansichten zugänglich. Jetzt senden wir die Daten von UserInfo
zusammen mit den Nachrichten, die wir an den Server senden. Gehen Sie zu ChatScreenModel
(wo auch immer Sie es abgelegt haben). Fügen Sie oben in der Klasse die folgenden Eigenschaften hinzu:
final class ChatScreenModel : ObservableObject {
private var username : String ?
private var userID : UUID ?
// the rest ...
}
Der ChatModelScreen
sollte diese Werte beim Herstellen einer Verbindung erhalten. Es ist nicht die Aufgabe von ChatModelScreen
zu wissen, woher diese Informationen stammen. Wenn wir uns in Zukunft dazu entschließen, den Speicherort username
und userID
zu ändern, können wir ChatModelScreen
unverändert lassen.
Ändern Sie die Methode connect()
um diese neuen Eigenschaften als Argumente zu akzeptieren:
func connect ( username : String , userID : UUID ) {
self . username = username
self . userID = userID
// etc ...
}
Schließlich müssen wir in send(text:)
diese neuen Werte auf die SubmittedChatMessage
anwenden, die wir an den Server senden:
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 ...
}
Uuuuund das war’s für ChatScreenModel
. Es ist fertig . ??
Öffnen Sie zum letzten Mal ChatScreen.swift
. Fügen Sie oben im ChatScreen
Folgendes hinzu:
@ EnvironmentObject private var userInfo : UserInfo
Vergessen Sie nicht, den username
und userID
an ChatScreenModel
anzugeben, wenn die Ansicht angezeigt wird:
private func onAppear ( ) {
model . connect ( username : userInfo . username , userID : userInfo . userID )
}
Nun noch einmal wie geübt: Lehnen Sie sich im Stuhl zurück und schauen Sie an die Decke. Wie sollen die Textnachrichten aussehen? Wenn Sie keine Lust auf kreatives Denken haben, können Sie die folgende Ansicht verwenden, die eine einzelne empfangene (und gesendete) Nachricht darstellt:
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 ( )
}
}
}
}
Es sieht nicht besonders aufregend aus. So sieht es auf einem iPhone aus:
(Erinnern Sie sich, dass der Server auch das Datum einer Nachricht sendet? Hier wird es zur Anzeige der Uhrzeit verwendet.)
Farben und Positionierung basieren auf der isUser
-Eigenschaft, die vom übergeordneten Element weitergegeben wird. In diesem Fall ist dieses übergeordnete Element kein anderer als ChatScreen
. Da ChatScreen
sowohl Zugriff auf die Nachrichten als auch auf UserInfo
hat, wird dort die Logik platziert, um zu bestimmen, ob die Nachricht dem Benutzer gehört oder nicht.
ChatMessageRow
ersetzt den langweiligen Text
wir zuvor zur Darstellung von Nachrichten verwendet haben:
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.
}
}
Willkommen an der Ziellinie! Du hast es bis hierher geschafft! Zum letzten Mal,
Mittlerweile sollten Sie über eine einfache, aber funktionierende Chat-App verfügen. Sowie ein Server, der die ein- und ausgehenden Nachrichten verarbeitet. Alles in Swift geschrieben!
Glückwunsch! Und vielen Dank fürs Lesen! ?
Sie können den endgültigen Code von Github herunterladen.
Links: Winziges iPad mit dunklem Aussehen. Rechts: Riesiges iPhone mit leichter Optik.
Fassen wir unsere Reise zusammen:
All das, während ich vollständig im Swift -Ökosystem bleibt. Keine zusätzlichen Programmiersprachen, keine Cocoapods oder irgendetwas.
Das, was wir hier erstellt haben, ist natürlich nur ein Bruchteil eines Bruchteils einer vollständigen Produktions -Ready -Chat -App und Server. Wir haben viele Ecken geschnitten, um Zeit und Komplexität zu sparen. Unnötig zu erwähnen, dass es ein ziemlich grundlegendes Verständnis dafür vermitteln sollte, wie eine Chat -App funktioniert.
Betrachten Sie die folgenden Funktionen, um sich möglicherweise zu implementieren:
ForEach
-Ansicht jede Nachricht im Speicher. Moderne Chat -Software verfolgen nur eine Handvoll Nachrichten, um sie zu rendern, und laden Sie nur ältere Nachrichten, sobald der Benutzer aufgeregt wird.Diese seltsame URLSESSIONWebsockettask -API
Wenn Sie jemals mit Websockets gearbeitet haben, können Sie die Meinung teilen, dass Apples API für Websocket ziemlich ... nicht traditionell sind. Sie sind sicherlich nicht allein. Der Empfangshandler ständig wiederholen zu müssen, ist einfach seltsam . Wenn Sie der Meinung sind, dass Sie sich wohler mit einer traditionelleren WebSocket -API für iOS und macOS bequem haben, würde ich Starscream auf jeden Fall empfehlen. Es ist gut getestet, leistungsfähig und arbeitet an älteren Versionen von iOS.
Bugs Bugs Bugs
Dieses Tutorial wurde mit Xcode 12 Beta 5 und iOS 14 Beta 5 geschrieben. Fehler erscheinen und verschwinden zwischen jeder neuen Beta -Version. Es ist leider unmöglich vorherzusagen, was in zukünftigen (Beta) -Stremisierungen wird und was nicht.
Der Server wird nicht nur auf Ihrem lokalen Computer ausgeführt, sondern auch von Ihrem lokalen Computer zugänglich . Dies ist kein Problem beim Ausführen der App im Simulator (oder als MacOS -App auf demselben Computer). Ausführen der App auf einem physischen Gerät oder auf einem anderen Mac muss der Server in Ihrem lokalen Netzwerk zugänglich gemacht werden.
Fügen Sie dazu in main.swift
des Servercode die folgende Zeile direkt nach der Initialisierung der Application
hinzu:
app . http . server . configuration . hostname = " 0.0.0.0 "
Jetzt in ChatScreenModel
, in der connect(username:userID:)
Methode:
let url = URL ( string : " ws://127.0.0.1:8080/chat " ) !
//^^this^^^
Die lokale IP Ihrer Maschine ist auf verschiedene Weise gefunden. Persönlich öffne ich immer nur Systemeinstellungen> Netzwerk , in dem die IP direkt angezeigt wird und ausgewählt und kopiert werden kann.
Es ist zu beachten, dass die Erfolgsquote dieser Netzwerke variiert. Es gibt viele Faktoren (wie Sicherheit), die verhindern könnten, dass dies funktioniert.
Vielen Dank für das Lesen! Wenn Sie Meinungen zu diesem Stück, Gedanken zu Verbesserungen oder einige Fehler gefunden haben, bitte lassen Sie es mich bitte wissen! Ich werde mein Bestes tun, um dieses Tutorial kontinuierlich zu verbessern. ?