在 SwiftUI 中建立一個非常原始的聊天應用程序,同時使用 Swift 和 WebSockets 建立聊天伺服器。從上到下都是 Swift,海灣蜜蜂!
在本教程中,我們將製作一個相當原始但功能齊全的聊天應用程式。該應用程式將在 iOS 或 macOS 上運行 - 或兩者兼而有之! SwiftUI 的美妙之處在於製作多平台應用程式所需的精力非常少。
當然,如果沒有伺服器進行交談,聊天應用程式就沒有什麼用處。因此,我們也將利用 WebSockets 製作一個非常原始的聊天伺服器。一切都將用 Swift 建置並在您的電腦上本地運行。
本教學假設您已經有一些使用 SwiftUI 開發 iOS/macOS 應用程式的經驗。儘管我們會不斷解釋概念,但不會深入涵蓋所有內容。不用說,如果您輸入並按照步驟操作,在本教程結束時您將擁有一個可以工作的聊天應用程式(適用於 iOS 和/或 macOS),它可以與您也製作的伺服器進行通訊!您還將對伺服器端 Swift 和 WebSocket 等概念有基本的了解。
如果您對這些都不感興趣,您可以隨時滾動到最後並下載最終的原始程式碼!
簡而言之,我們將從製作一個非常簡單、普通、無功能的伺服器開始。我們將伺服器建置為 Swift 套件,然後新增 Vapor Web 框架作為依賴項。這將幫助我們只需幾行程式碼即可設定 WebSocket 伺服器。
之後我們將開始建立前端聊天應用程式。快速從基礎知識開始,然後一一添加功能(和必需品)。
我們的大部分時間將花在應用程式上,但當我們添加新功能時,我們將在伺服器程式碼和應用程式程式碼之間來回切換。
選修的
讓我們開始吧!
開啟 Xcode 12 並啟動一個新專案(檔案 > 新專案)。在多平台下選擇Swift Package 。
將該套件稱為符合邏輯的名稱 - 不言自明的名稱 - 例如“ ChatServer ”。然後將其保存到您喜歡的任何地方。
斯威夫特包?
在 Swift 中建立框架或多平台軟體(例如 Linux)時,Swift 套件是首選方法。它們是創建其他 Swift 專案可以輕鬆使用的模組化程式碼的官方解決方案。不過,Swift 套件不一定是模組化專案:它也可以是一個獨立的可執行文件,只需使用其他 Swift 套件作為依賴項(這就是我們正在做的事情)。
您可能會想到 Swift 套件不存在 Xcode 專案 (
.xcodeproj
)。要像其他項目一樣在 Xcode 中開啟 Swift Package,只需開啟Package.swift
檔案即可。 Xcode 應該會辨識出您正在開啟 Swift Package 並開啟整個專案結構。它會在開始時自動取得所有依賴項。您可以在 Swift 官方網站上閱讀有關 Swift Packages 和 Swift Package Manager 的更多資訊。
為了處理設定伺服器的所有繁重工作,我們將使用 Vapor Web 框架。 Vapor 具備創建 WebSocket 伺服器所需的所有功能。
WebSockets?
為了讓網路能夠與伺服器即時通信,WebSockets 應運而生。這是一個詳細描述的規範,用於客戶端和伺服器之間的安全即時(低頻寬)通訊。例如:多人遊戲和聊天應用程式。您在寶貴的公司時間裡玩過哪些令人上癮的瀏覽器內多人遊戲?是的,WebSockets!
但是,如果您希望執行即時視訊串流之類的操作,那麼您最好尋找不同的解決方案。 ?
儘管我們在本教程中製作了一個 iOS/macOS 聊天應用程序,但我們製作的伺服器可以使用 WebSocket 輕鬆地與其他平台進行通訊。事實上:如果您願意,您還可以製作此聊天應用程式的 Android 和 Web 版本,與同一伺服器對話並允許所有平台之間進行通訊!
汽?
互聯網是一系列複雜的管道。即使回應簡單的 HTTP 請求也需要大量程式碼。幸運的是,該領域的專家已經開發了開源 Web 框架,可以使用各種程式語言為我們完成幾十年來的所有艱苦工作。 Vapor 就是其中之一,它是用 Swift 編寫的。它已經附帶了一些 WebSocket 功能,這正是我們所需要的。
不過,Vapor 並不是唯一一個由 Swift 支援的 Web 框架。 Kitura 和 Perfect 也是眾所周知的框架。儘管 Vapor 的開發可以說更為積極。
Xcode 應預設開啟Package.swift
檔案。這是我們放置 Swift 包的一般資訊和要求的地方。
不過,在執行此操作之前,請先查看Sources/ChatServer
資料夾。它應該有一個ChatServer.swift
檔案。我們需要將其重命名為main.swift
。完成後,返回Package.swift
。
在products:
下,刪除以下值:
. library ( name : " ChatServer " , targets : [ " ChatServer " ] )
...並將其替換為:
. executable ( name : " ChatServer " , targets : [ " ChatServer " ] )
畢竟,我們的伺服器不是圖書館。而是一個獨立的可執行檔。我們還應該定義我們期望伺服器運行的平台(和最低版本)。這可以透過在name: "ChatServer"
下新增platforms: [.macOS(v10_15)]
來完成:
name: " ChatServer " ,
platforms: [
. macOS ( . v10_15 ) ,
] ,
所有這些都應該使我們的 Swift 包在 Xcode 中「可運行」。
好吧,讓我們添加 Vapor 作為依賴項。在dependencies: []
(應該有一些註解掉的東西)中,加入以下內容:
. package ( url : " https://github.com/vapor/vapor.git " , from : " 4.0.0 " )
儲存Package.swift
檔案時,Xcode 應開始自動取得版本4.0.0
或更高版本的 Vapor 依賴項。以及它的所有依賴項。
我們只需要在 Xcode 執行其操作時對檔案再進行一項調整:將依賴項新增至我們的目標。在targets:
你會找到一個.target(name: "ChatServer", dependencies: [])
。在該空數組中,加入以下內容:
. product ( name : " Vapor " , package : " vapor " )
就是這樣。我們的Package.swift
已經完成了。我們透過告訴它來描述我們的 Swift 包:
最終的Package.swift
應該如下所示(-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 " )
] ) ,
]
)
現在,終於到了...
在 Xcode 中,開啟Sources/ChatServer/main.swift
並刪除其中的所有內容。對我們來說毫無價值。相反,讓main.swift
看起來像這樣:
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
?嘭!這就是使用 Vapor 啟動 (WebSocket) 伺服器所需的全部內容。看看那是多麼輕鬆。
defer
並呼叫.shutdown()
它將在退出程式時執行任何清理。/chat
上任何傳入的 WebSocket 連線。現在
程式成功運行後,您可能看不到任何類似應用程式的內容。這是因為伺服器軟體往往不具有圖形使用者介面。但請放心,程式在背景運作良好,運作良好。但是,Xcode 控制台應顯示以下訊息:
notice codes.vapor.application : Server starting on http://127.0.0.1:8080
這表示伺服器可以成功偵聽傳入的請求。這太棒了,因為我們現在有了一個可以開始連線的 WebSocket 伺服器!
我不相信你?
如果你出於某種原因認為我一直在撒謊,除了令人髮指的謊言之外,你可以自己測試伺服器!
打開您最喜歡的瀏覽器並確保您處於空白選項卡中。 (如果是 Safari,您需要先啟用開發人員模式。)開啟檢查器(
Cmd
+Option
+I
)並前往控制台。輸入new WebSocket ( 'ws://localhost:8080/chat' )並按回車鍵。現在來看看 Xcode 控制台。如果一切順利,它現在應該顯示
Connected: WebSocketKit.WebSocket
。????
該伺服器只能從您的本機電腦存取。這意味著您無法將實體 iPhone/iPad 連接到伺服器。相反,我們將在以下步驟中使用模擬器來測試我們的聊天應用程式。
要在實體設備上測試聊天應用程序,需要採取一些(小的)額外步驟。請參閱附錄 A。
雖然我們還沒有完成後端,但現在是時候轉向前端了。聊天應用程式本身!
在 Xcode 中建立一個新專案。這次,在Multiplatform下選擇App 。再次為您的應用程式選擇一個漂亮的名稱並繼續。 (我選擇了SwiftChat 。我同意,它很完美?)
該應用程式不依賴任何外部第三方框架或程式庫。事實上,我們需要的一切都可以透過Foundation
、 Combine
和SwiftUI
(在 Xcode 12+ 中)獲得。
讓我們立即開始在聊天螢幕上工作。建立一個新的 Swift 檔案並將其命名為ChatScreen.swift
。選擇Swift 檔案模板還是SwiftUI 視圖模板並不重要。無論如何,我們都會刪除其中的所有內容。
這是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 ( )
}
}
}
在ContentsView.swift
中,將Hello World替換為ChatScreen()
:
struct ContentView : View {
var body : some View {
ChatScreen ( )
}
}
現在是一塊空白畫布。
左:深色外觀的 iPhone。右:iPad,外觀輕盈。
我們這裡有什麼:
如果您想做出不同的設計選擇,那就繼續吧。 ?
現在讓我們開始處理一些非 UI 相關的邏輯:連接到我們剛剛建立的伺服器。
SwiftUI與組合框架一起為開發人員提供了在程式碼中輕鬆實現關注點分離的工具。使用ObservableObject
協定和@StateObject
(或@ObservedObject
)屬性包裝器,我們可以在單獨的地方實作非 UI 邏輯(稱為業務邏輯)。事情就該如此!畢竟,UI 唯一應該關心的是向使用者顯示資料並對使用者輸入做出反應。它不應該關心數據來自哪裡,或如何操作。
來自 React 的背景,這種奢侈是我非常羨慕的。
關於軟體架構有成千上萬的文章和討論。您可能聽過或讀過 MVC、MVVM、VAPOR、乾淨架構等概念。他們都有自己的論點和應用。
討論這些超出了本教程的範圍。但人們普遍認為業務邏輯和 UI 邏輯不應該交織在一起。
這個概念對我們的ChatScreen來說同樣適用。 ChatScreen唯一應該關心的是顯示訊息和處理使用者輸入的文字。它不關心 ✌️We Bs Oc K eTs✌,也不應該關心。
您可以建立一個新的 Swift 檔案或在ChatScreen.swift
的底部編寫以下程式碼。你的選擇。無論它位於何處,請確保不要忘記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 ( )
}
}
這可能需要理解的內容很多,所以讓我們慢慢地過一次:
URLSessionWebSocketTask
儲存在屬性中。URLSessionWebSocketTask
物件負責 WebSocket 連線。它們是Foundation框架中URLSession
系列的居民。127.0.0.1
或localhost
)。 Vapor應用程式的預設連接埠是8080
。我們在/chat
路徑中放置了一個 WebSocket 連線監聽器。URLSessionWebSocketTask
並將其儲存在實例的屬性中。onReceive(incoming:)
方法。稍後會詳細介紹這一點。ChatScreenModel
從記憶體中清除時我們可以正常斷開連線。這是一個很好的開始。現在,我們可以在一個地方放置所有 WebSocket 邏輯,而不會弄亂 UI 程式碼。是時候讓ChatScreen
與ChatScreenModel
進行通訊了。
將ChatScreenModel
加入為ChatScreen
中的狀態物件:
struct ChatScreen : View {
@ StateObject private var model = ChatScreenModel ( ) // <- this here
@ State private var message = " "
// etc...
}
我們什麼時候應該連接到伺服器?嗯,當然,當螢幕實際可見時。您可能會想要在ChatScreen
的init()
中呼叫.connect()
。這是一件危險的事。事實上,在 SwiftUI 中,應該盡量避免在init()
放置任何內容,因為即使 View 永遠不會出現,也可以對其進行初始化。 (例如在LazyVStack
或NavigationLink(destination:)
中。)浪費寶貴的 CPU 週期將是一種恥辱。因此,讓我們將一切推遲到onAppear
。
將onAppear
方法加入ChatScreen
。然後加入該方法並將其傳遞給VStack
的.onAppear(perform:)
修飾符:
struct ChatScreen : View {
// ...
private func onAppear ( ) {
model . connect ( )
}
var body : some View {
VStack {
// ...
}
. onAppear ( perform : onAppear )
}
}
浪費空間?
很多人喜歡內聯編寫這些方法的內容:
. onAppear { model . connect ( ) }這只是個人喜好。我個人喜歡單獨定義這些方法。是的,它需要更多空間。但它們更容易找到,可重複使用,防止
body
變得(更)混亂,並且可以說更容易折疊。 ?
同樣的道理,當視圖消失時我們也應該斷開連線。實現應該是不言自明的,但以防萬一:
struct ChatScreen : View {
// ...
private func onDisappear ( ) {
model . disconnect ( )
}
var body : some View {
VStack {
// ...
}
. onAppear ( perform : onAppear )
. onDisappear ( perform : onDisappear )
}
}
每當我們不再關心 WebSocket 連線時,關閉它們非常重要。當您(優雅地)關閉 WebSocket 連線時,伺服器將收到通知並可從記憶體中清除該連線。伺服器不應該在記憶體中殘留死連線或未知連線。
唷。到目前為止,我們已經經歷了相當一段旅程。是時候測試一下了。ChatScreen
時,您應該在伺服器的 Xcode 控制台中看到Connected: WebSocketKit.WebSocket
訊息。如果沒有,請回溯您的步驟並開始偵錯!
還有一件事™️。我們還應該測試當使用者關閉應用程式(或離開ChatScreen
)時 WebSocket 連線是否關閉。返回伺服器專案的main.swift
檔案。目前我們的 WebSocket 監聽器如下所示:
app . webSocket ( " chat " ) { req , client in
print ( " Connected: " , client )
}
將處理程序加入client
的.onClose
中,只執行簡單的print()
:
app . webSocket ( " chat " ) { req , client in
print ( " Connected: " , client )
client . onClose . whenComplete { _ in
print ( " Disconnected: " , client )
}
}
重新運行伺服器並啟動聊天應用程式。連接應用程式後,關閉應用程式(實際上是退出它,不要只是將其放在背景)。伺服器的 Xcode 控制台現在應該列印Disconnected: WebSocketKit.WebSocket
。這證實了當我們不再關心 WebSocket 連線時,它們確實已關閉。因此,伺服器不應在記憶體中殘留死連線。
您準備好實際向伺服器發送內容了嗎?男孩,我當然。但暫時讓我們停下來思考一下。靠在椅子上,漫無目的地凝視著天花板…
我們究竟要向伺服器發送什麼?而且,同樣重要的是,我們將從伺服器收到什麼?
您的第一個想法可能是“好吧,只是短信,對嗎?”,您只對了一半。但是訊息發送的時間呢?寄件者的名字呢?是否有一個標識符可以使該訊息與任何其他訊息不同?我們還沒有任何東西供用戶創建用戶名或任何東西。因此,讓我們把它放在一邊,只專注於發送和接收訊息。
我們必須在應用程式端和伺服器端進行一些調整。讓我們從伺服器開始。
在伺服器專案中的Sources/ChatServer
中建立一個名為Models.swift
的新 Swift 檔案。將以下程式碼貼上(或輸入)到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
}
這是發生的事情:
Decodable
協定。Encodable
協議。ReceivingChatMessage
時自動產生。請注意我們如何在伺服器端產生date
和id
。這使得伺服器成為事實來源。伺服器知道現在是什麼時間。如果日期是在客戶端產生的,則它不可信。如果客戶將時鐘設定為將來的時間怎麼辦?讓伺服器產生日期使其時鐘成為時間的唯一參考。
時區?
Swift 的
Date
物件始終將 00:00:00 UTC 01-01-2001 作為絕對參考時間。當初始化Date
或格式為字串時(例如透過DateFormatter
),將自動考慮客戶端的位置。根據客戶的時區加註或減少小時數。
UUID?
通用唯一識別碼在全球範圍內被視為可接受的識別碼值。
我們也不希望客戶端發送具有相同唯一識別碼的多個訊息。無論是無意的還是故意的惡意。讓伺服器產生此標識符是一層額外的安全性,並且減少了可能的錯誤來源。
現在那麼。當伺服器收到來自客戶端的訊息時,它應該將其傳遞給所有其他客戶端。然而,這確實意味著我們必須追蹤每個連線的客戶端。
回到伺服器專案的main.swift
。在app.webSocket("chat")
的正上方放置以下聲明:
var clientConnections = Set < WebSocket > ( )
這是我們儲存客戶端連線的地方。
但是等等...您應該會遇到一個大的、糟糕的、令人討厭的編譯錯誤。這是因為WebSocket
物件預設不符合Hashable
協定。不過不用擔心,這可以很容易(儘管成本低廉)實現。在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 ) )
}
}
巴達賓巴達布姆。上面的程式碼是一種快速而簡單的方法,只需使用類別的記憶體位址作為唯一屬性,即可使class
符合Hashable
(根據定義也Equatable
)。注意:這僅適用於課程。結構將需要更多的實際實現。
好吧,現在我們已經能夠追蹤客戶端了,用以下程式碼取代app.webSocket("chat")
的所有內容(包括其閉包及其內容):
app . webSocket ( " chat " ) { req , client in
clientConnections . insert ( client )
client . onClose . whenComplete { _ in
clientConnections . remove ( client )
}
}
當客戶端連線時,將所述客戶端儲存到clientConnections
中。當客戶端斷開連接時,將其從同一個Set
中刪除。埃茲普茲。
本章的最後一步是新增伺服器的核心client.onClose.whenComplete
下方 - 但仍在app.webSocket("chat")
閉包內 - 添加以下程式碼片段:
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
}
}
再次從頂部開始:
.onText
處理程序綁定到連線的客戶端。ReceivingChatMessage
。ReceivingChatMessage
的日期和唯一識別碼將自動產生。ReceivingChatMessage
編碼為 JSON 字串(即Data
)。為什麼要寄回來?
我們可以用它來確認訊息實際上已從客戶端成功接收。該應用程式將像接收任何其他訊息一樣接收回該訊息。這將使我們以後不必編寫額外的程式碼。
完畢!伺服器已準備好接收訊息並將它們傳遞給其他連接的用戶端。運行伺服器並讓它在後台空閒,我們繼續使用該應用程式!
還記得我們為伺服器建立的SubmittedChatMessage
和ReceivingChatMessage
結構嗎?我們的應用程式也需要它們。建立一個新的 Swift 檔案並將其命名為Models.swift
。儘管您可以複製貼上實現,但它們需要進行一些修改:
import Foundation
struct SubmittedChatMessage : Encodable {
let message : String
}
struct ReceivingChatMessage : Decodable , Identifiable {
let date : Date
let id : UUID
let message : String
}
請注意Encodable
和Decodable
協定是如何交換的。這才有意義:在應用程式中,我們僅對SubmittedChatMessage
進行編碼,並僅對ReceivingChatMessage
進行解碼。與伺服器相反。我們也刪除了date
和id
的自動初始化。該應用程式沒有產生這些內容的業務。
好的,回到ChatScreenModel
(無論是在單獨的檔案中還是在ChatScreen.swift
的底部)。新增頂部,但在ChatScreenModel
內部新增以下實例屬性:
@ Published private ( set ) var messages : [ ReceivingChatMessage ] = [ ]
我們將在此處儲存收到的訊息。感謝@Published
, ChatScreen
將準確知道該數組何時更新並對這一更改做出反應。 private(set)
確保只有ChatScreenModel
可以更新此屬性。 (畢竟,它是資料的擁有者。其他物件沒有任何權利直接修改它!)
仍在ChatScreenModel
中,加入以下方法:
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
}
}
}
這似乎是不言自明的。但為了保持一致性:
SubmittedChatMessage
僅儲存訊息。開啟ChatScreen.swift
並將以下方法加入到ChatScreen
:
private func onCommit ( ) {
if !message . isEmpty {
model . send ( text : message )
message = " "
}
}
當使用者按下提交按鈕或按下鍵盤上的 Return 時,將呼叫此方法。但只有當訊息確實包含任何內容時,它才會發送訊息。
在ChatScreen
的.body
中,找到TextField
和Button
,然後用以下初始化替換它們(但不是它們的修飾符或內容):
TextField ( " Message " , text : $message , onEditingChanged : { _ in } , onCommit : onCommit )
// .modifiers here
Button ( action : onCommit ) {
// Image etc
}
// .modifiers here
當TextField
獲得焦點時按下 Return 鍵, onCommit
將被呼叫。當使用者按下Button
時也是如此。 TextField
還需要一個onEditingChanged
參數 - 但我們透過給它一個空閉包來放棄它。
現在是開始測試我們所擁有的東西的時候了。確保伺服器仍在後台運行。在伺服器的main.swift
中的client.onText
閉包(伺服器讀取傳入訊息的位置)中放置一些斷點。運行應用程式並發送訊息。收到來自應用程式的訊息後,應命定main.swift
中的斷點。如果是的話,?鬱鬱蔥蔥! ?如果沒有,那麼...回溯您的步驟並開始調試!
發送訊息很可愛。但接收它們又如何呢? (好吧,從技術上講,我們正在接收它們,只是從未對它們做出反應。)對,你說得對!
讓我們再次造訪ChatScreenModel
。還記得onReceive(incoming:)
方法嗎?替換它並給它一個兄弟方法,如下所示:
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 )
}
}
}
所以...
URLSessionWebSocketTask
那些接收處理程序?他們只工作一次。因此,我們立即重新綁定一個新的處理程序,以便我們準備好讀取下一條傳入訊息。ReceivingChatMessage
。self.messages
中。然而,因為URLSessionWebSocketTask
可以在不同的執行緒上呼叫接收處理程序,並且因為 SwiftUI 僅在主執行緒上工作,所以我們必須將修改包裝在DispatchQueue.main.async {}
中,以確保我們實際上是在主執行緒。解釋在 SwiftUI 中使用不同線程的方式和原因超出了本教學的範圍。
快到了!
返回查看ChatScreen.swift
。看到那個空的ScrollView
了嗎?我們終於可以用訊息填滿它了:
ScrollView {
LazyVStack ( spacing : 8 ) {
ForEach ( model . messages ) { message in
Text ( message . message )
}
}
}
無論如何,它看起來都不會很壯觀。但這暫時可以完成工作。我們只是用純Text
表示每個訊息。
繼續,運行應用程式。當您發送訊息時,它應該立即顯示在螢幕上。這確認訊息已成功發送到伺服器,並且伺服器已成功將其發送回應用程式!現在,如果可以的話,打開應用程式的多個實例(提示:使用不同的模擬器)。客戶數量幾乎沒有限制!一個人舉辦一場愉快的大型聊天聚會。
繼續發送訊息,直到螢幕上沒有剩餘空間。注意到什麼了嗎?雅普。一旦新訊息超出螢幕範圍, ScrollView
不會自動捲動到底部。 ?
進入...
請記住,伺服器為每個訊息產生一個唯一的識別碼。我們終於可以好好利用它了!我向你保證,為了這個令人難以置信的回報,等待是值得的。
在ChatScreen
中,將ScrollView
變成這樣的美:
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 )
}
}
}
然後加入以下方法:
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
的內容包裝在ScrollViewReader
中。ScrollViewReader
為我們提供了一個我們很快就會需要的proxy
。model.messages.count
的變更。當該值發生變化時,我們呼叫剛剛新增的方法,並將ScrollViewReader
提供的proxy
傳遞給它。ScrollViewProxy
的.scrollTo(_:anchor:)
方法。這告訴ScrollView
滾動到具有給定標識符的視圖。我們將其包裝在withAnimation {}
中以設定滾動動畫。瞧...
這些訊息非常豐富......但如果我們知道誰發送了訊息並在視覺上區分接收和發送的訊息,那就更豐富了。
對於每條訊息,我們還將附加使用者名稱和使用者識別碼。因為用戶名不足以識別用戶,所以我們需要唯一的東西。如果使用者和其他人的名字都是派崔克怎麼辦?我們會遇到身分危機,無法區分派崔克發送的訊息和派崔克收到的訊息。
按照傳統,我們從伺服器開始,這是最少的工作量。
開啟Models.swift
,我們在其中定義了SubmittedChatMessage
和ReceivingChatMessage
。給這兩個壞男孩一個user: String
和userID: UUID
屬性,如下:
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
}
(不要忘記更新應用程式專案中的 Models.swift 檔案!)
返回main.swift
,您應該在其中看到一個錯誤,將ReceivingChatMessage
的初始化更改為以下內容:
let outgoingMessage = ReceivingChatMessage (
message : incomingMessage . message ,
user : incomingMessage . user ,
userID : incomingMessage . userID
)
就是這樣!我們已經完成了伺服器的工作。從現在開始這只是應用程式。衝刺衝刺!
在應用程式的 Xcode 專案中,建立一個名為UserInfo.swift
的新 Swift 檔案。將以下程式碼放在那裡:
import Combine
import Foundation
class UserInfo : ObservableObject {
let userID = UUID ( )
@ Published var username = " "
}
這將是我們可以在其中儲存使用者名稱EnvironmentObject
。用戶名從哪裡來?用戶將在開啟應用程式時輸入此內容,然後再顯示聊天畫面。
新檔案時間: SettingsScreen.swift
。該文件將包含簡單的設定表單:
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
類別將在此處以EnvironmentObject
進行存取。TextField
會直接將其內容寫入userInfo.username
。ChatScreen
的NavigationLink
。當使用者名稱無效時該按鈕被停用。 (您是否注意到我們如何在NavigationLink
中初始化ChatScreen
?如果我們在其init()
中讓ChatScreen
連接到伺服器,它現在就會這樣做!)如果你願意,你可以在螢幕上添加一點華麗。
由於我們使用 SwiftUI 的導航功能,因此我們需要從某個地方的NavigationView
開始。 ContentView
是實現此目的的最佳場所。更改ContentView
的實作如下:
struct ContentView : View {
@ StateObject private var userInfo = UserInfo ( ) // 1
var body : some View {
NavigationView {
SettingsScreen ( )
}
. environmentObject ( userInfo ) // 2
. navigationViewStyle ( StackNavigationViewStyle ( ) ) // 3
}
}
UserInfo
的實例並...EnvironmentObject
傳遞,使其可供所有後續視圖存取。現在將UserInfo
的資料與我們傳送到伺服器的訊息一起傳送。前往ChatScreenModel
(無論你把它放在哪裡)。在類別的頂部添加以下屬性:
final class ChatScreenModel : ObservableObject {
private var username : String ?
private var userID : UUID ?
// the rest ...
}
連接時ChatModelScreen
應接收這些值。 ChatModelScreen
的工作不是了解此資訊的來源。如果將來我們決定更改username
和userID
的儲存位置,則可以保持ChatModelScreen
不變。
更改connect()
方法以接受這些新屬性作為參數:
func connect ( username : String , userID : UUID ) {
self . username = username
self . userID = userID
// etc ...
}
最後,在send(text:)
中,我們需要將這些新值套用到我們傳送到伺服器的SubmittedChatMessage
:
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 ...
}
啊啊,這就是ChatScreenModel
的內容。完了。 ??
最後一次開啟ChatScreen.swift
。在ChatScreen
頂部添加:
@ EnvironmentObject private var userInfo : UserInfo
當視圖出現時,不要忘記向ChatScreenModel
提供username
和userID
:
private func onAppear ( ) {
model . connect ( username : userInfo . username , userID : userInfo . userID )
}
現在,再次按照慣例:靠在椅子上,抬頭看著天花板。簡訊應該是什麼樣子?如果您沒有心情進行創造性思維,您可以使用以下代表單一接收(和發送)訊息的視圖:
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 ( )
}
}
}
}
看起來並不是特別令人興奮。這是它在 iPhone 上的樣子:
(還記得伺服器如何發送訊息的日期嗎?這裡它用於顯示時間。)
顏色和定位基於父級傳遞的isUser
屬性。在本例中,該父級正是ChatScreen
。因為ChatScreen
可以存取訊息以及UserInfo
,所以它是放置邏輯以確定訊息是否屬於使用者的地方。
ChatMessageRow
取代了我們之前用來表示訊息的無聊Text
:
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.
}
}
歡迎來到終點線!你已經成功到這裡了!最後一次,
現在您應該擁有一個原始但功能齊全的聊天應用程式。以及處理傳入和傳出訊息的伺服器。全部用 Swift 編寫!
恭喜!非常感謝您的閱讀! ?
您可以從 Github 下載最終程式碼。
左:黑色外觀的小型 iPad。右:巨大的 iPhone,外觀輕盈。
讓我們總結一下我們的旅程:
所有這一切都是完全留在 Swift 生態系統內的。沒有額外的程式語言,沒有 Cocoapods 或任何東西。
當然,我們在這裡創建的只是完整的、生產就緒的聊天應用程式和伺服器的一小部分。我們走捷徑以節省時間並降低複雜性。不用說,它應該讓您對聊天應用程式的工作原理有一個非常基本的了解。
考慮以下功能,也許您可以自己實現:
ForEach
視圖迭代記憶體中的每個訊息。現代聊天軟體僅追蹤少數要呈現的訊息,並且僅在用戶向上滾動時加載較舊的訊息。那個奇怪的 URLSessionWebSocketTask API
如果您以前使用過 WebSocket,您可能會同意 Apple 的 WebSocket API 非常...非傳統。您當然不是唯一一個這樣做的人。必須不斷地重新綁定接收處理程序是很奇怪的。如果您認為在 iOS 和 macOS 上使用更傳統的 WebSocket API 更舒服,那麼我絕對會推薦 Starscream。它經過充分測試,性能優良,並且可以在舊版本的 iOS 上運行。
錯誤錯誤錯誤
本教學是使用 Xcode 12 beta 5 和 iOS 14 beta 5 編寫的。不幸的是,我們無法預測未來(測試版)版本中哪些功能有效,哪些功能無效。
伺服器不僅在您的本機電腦上運行,而且只能從本機電腦存取。在模擬器中執行應用程式(或在同一台電腦上作為 macOS 應用程式)運行應用程式時,這不是問題。但是在實體裝置或不同的 Mac 上執行應用程式時,必須可以在本機網路中存取伺服器。
為此,請在伺服器程式碼的main.swift
中,在初始化Application
實例後直接新增以下行:
app . http . server . configuration . hostname = " 0.0.0.0 "
現在在ChatScreenModel
connect(username:userID:)
方法中,您需要變更 URL 以符合您電腦的本機 IP:
let url = URL ( string : " ws://127.0.0.1:8080/chat " ) !
//^^this^^^
您可以透過多種方式找到您機器的本機IP。就我個人而言,我總是只打開“系統偏好設定”>“網路” ,其中直接顯示 IP,並且可以選擇和複製。
應該注意的是,不同網路的成功率有所不同。有許多因素(例如安全性)可能會阻止其發揮作用。
非常感謝您的閱讀!如果您對這篇文章有任何意見,改進的想法,或發現一些錯誤,請,請,請告訴我!我將盡我最大的努力不斷改進本教程。 ?