在 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
。与往常一样,唯一标识符是自动生成的不可变 UUID。用户名从哪里来?用户将在打开应用程序时输入此内容,然后再显示聊天屏幕。
新文件时间: 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合作,您可以分享意见,即Websocket的Apple API非常……非传统。当然,您并不孤单。不得不不断重新固定接收处理程序很奇怪。如果您认为使用更传统的Websocket API为iOS和MACOS更舒服,那么我肯定会推荐StarsCream。它经过了经过良好的测试,表现效果,并且可以在iOS的较旧版本上工作。
错误错误
本教程使用Xcode 12 beta 5和iOS 14 beta 5编写。出现错误并消失在每个新的beta版本之间。不幸的是,无法预测将来的将来的和什么(beta)发行的内容。
服务器不仅可以在本地计算机上运行,而且只能从本地计算机访问。在模拟器中运行该应用时(或同一台计算机上的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并可以选择和复制。
应当指出的是,网络之间的成功率各不相同。有很多因素(例如安全性)可以阻止这种情况起作用。
非常感谢您的阅读!如果您对这篇文章有任何意见,改进的想法或发现了一些错误,请,请让我知道!我将尽力不断改进本教程。 ?