websockets
Creating a very primitive chat app in SwiftUI, while using Swift and WebSockets to create the chat server. It’s Swift from top to bottom, bay-bee!
在SwiftUI中创建一个非常原始的聊天应用程序,同时使用Swift和WebSockets创建聊天服务器。 从上到下都是迅捷的,海湾蜂!
This tutorial is also available in Markdown, together with the final code, on Github.
Markdown中还提供了本教程,以及Github上的最终代码。
介绍 (Introduction)
In this tutorial we’ll make a rather primitive, but functional chat app. The app will run on iOS or macOS — or both! The beauty of SwiftUI is how little effort it takes to make a multiplatform app.
在本教程中,我们将制作一个相当原始但实用的聊天应用程序。 该应用程序将在iOS或macOS上运行-或同时在两者上运行! SwiftUI的优点在于制作一个多平台应用程序需要花费很少的精力。
Of course, a chat app will have very little use without a server to talk to. Hence we’ll be making a very primitive chat server as well, utilizing WebSockets. Everything will be built in Swift and run locally on your machine.
当然,如果没有服务器可与之聊天,则聊天应用将很少使用。 因此,我们还将利用WebSockets制造一个非常原始的聊天服务器。 一切都将在Swift中构建,并在您的计算机上本地运行。
This tutorial assumes you already have a bit of experience developing iOS/macOS apps using SwiftUI. Although concepts will be explained as we go, not everything will be covered in depth. Needless to say, if you type along and follow the steps, by the end of this tutorial you’ll have a working chat app (for iOS and/or macOS), that communicates with a server that you also made! You will also have a basic understanding of concepts like server-side Swift and WebSockets.
本教程假定您已经具有使用SwiftUI开发iOS / macOS应用程序的经验。 尽管我们将在本文中解释概念,但并不是所有内容都会深入介绍。 不用说,如果您键入并按照步骤进行操作,那么到本教程结束时,您将拥有一个可正常使用的聊天应用程序(适用于iOS和/或macOS),可与您同时制造的服务器通信! 您还将对服务器端Swift和WebSockets等概念有基本的了解。
If none of that interests you, you can always just download the final code from Github.
如果您对这些都不感兴趣,可以随时从Github下载最终代码 。
快速摘要 (Quick summary of what’s to come)
In short, we will start by making a very simple, plain, featureless server. We’ll build the server as a Swift Package, then add the Vapor web framework as a dependency. This will help us setup a WebSocket server with just a few lines of code.
简而言之,我们将从制作一个非常简单,简单,无功能的服务器开始。 我们将服务器构建为Swift软件包,然后将Vapor Web框架添加为依赖项。 这将帮助我们仅用几行代码来设置WebSocket服务器。
Afterwards we will start building the frontend chat app. Quickly starting with the basics, then adding features (and necessities) one by one.
之后,我们将开始构建前端聊天应用程序。 快速从基础开始,然后逐一添加功能(和必需品)。
Most of our time will be spent working on the app, but we’ll be going back and forth between the server code and the app code as we add new features.
我们大部分时间将花在开发应用程序上,但是随着我们添加新功能,我们将在服务器代码和应用程序代码之间来回切换。
要求 (Requirements)
- macOS 10.15+ macOS 10.15以上
- Xcode 12 beta 5+ Xcode 12 Beta 5+
Optional
可选的
macOS 11 beta
macOS 11测试版
(if you want to run the app on macOS)
(如果您想在macOS上运行该应用程序)
iPhone/iPad running iOS 14 beta 5+
运行iOS 14 Beta 5+的iPhone / iPad
(if you want to run the app on a physical device)
(如果您想在物理设备上运行该应用程序)
Let’s begin!
让我们开始!
创建服务器 (Creating the server)
Open Xcode 12 and start a new project (File > New Project). Under Multiplatform select Swift Package.
打开Xcode 12并启动一个新项目( File> New Project )。 在Multiplatform下,选择Swift Package 。
Call the Package something logical — something self explanatory — like “ChatServer”. Then save it wherever you like.
将该软件包称为逻辑的(可以自我解释的),例如“ ChatServer ”。 然后将其保存在您喜欢的任何位置。
Swift Package?
迅捷套餐?
When creating a framework or multiplatform software (e.g. for macOS and Linux) in Swift, Swift Packages are the preferred way to go. They’re the official solution for creating modular code that other Swift projects can easily use. A Swift Package doesn’t necessarily have to be a modular project though: it can also be a stand-alone executable that simply uses other Swift Packages as dependencies (which is what we’re doing).
在Swift中创建框架或多平台软件(例如,用于macOS和Linux)时,Swift软件包是首选的方式。 它们是创建其他Swift项目可以轻松使用的模块化代码的官方解决方案。 但是,Swift软件包不一定必须是模块化项目:它也可以是独立的可执行文件,它仅将其他Swift软件包用作依赖项(这就是我们正在做的事情)。
It may have occurred to you that there’s no Xcode project (
.xcodeproj
) present for the Swift Package. To open a Swift Package in Xcode like any other project, simply open thePackage.swift
file. Xcode should recognize you're opening a Swift Package and opens the entire project structure. It will automatically fetch all the dependencies at the start.您可能已经想到,Swift软件包不存在Xcode项目(
.xcodeproj
)。 要像其他任何项目一样在Xcode中打开Swift包,只需打开Package.swift
文件。 Xcode应该识别出您正在打开Swift软件包并打开了整个项目结构。 它会在开始时自动获取所有依赖项。You can read more about Swift Packages and Swift Package Manager on the official Swift website.
您可以在Swift的官方网站上阅读有关Swift Packages和Swift Package Manager的更多信息。
设置Package.swift (Setup Package.swift)
To handle all the heavy lifting of setting up a server, we’ll be using the Vapor web framework. Vapor comes with all the necessary features to create a WebSocket server.
为了处理设置服务器的所有繁重工作,我们将使用Vapor Web框架 。 蒸气具有创建WebSocket服务器所需的所有必要功能。
WebSockets?
WebSockets?
To provide the web with the ability to communicate with a server in realtime, WebSockets were created. It’s a well described spec for safe realtime (low-bandwidth) communication between a client and a server. E.g.: multiplayer games and chat apps. Those addictive in-browser multiplayer games you’ve been playing on valuable company time? Yup, WebSockets!
为了使Web能够与服务器实时通信,创建了WebSocket。 这是对客户端和服务器之间的安全实时(低带宽)通信的规范描述。 例如:多人游戏和聊天应用程序。 您在宝贵的公司时间里玩的那些 令人上瘾 的浏览器内 多人 游戏 ? 是的,WebSockets!
However, if you wish to do something like realtime video streaming you’re best looking for a different solution.
但是,如果您希望做类似实时视频流的操作,则最好寻找其他解决方案。
Though we’re making an iOS/macOS chat app in this tutorial, the server we’re making can just as easily talk to other platforms with WebSockets. Indeed: if you want you could also make an Android and web version of this chat app, talking to the same server and allowing for communication between all platforms!
尽管在本教程中我们正在制作一个iOS / macOS聊天应用程序,但是我们所制作的服务器可以通过WebSockets与其他平台轻松对话。 确实:如果您愿意,还可以制作此聊天应用程序的Android和网络版本,与同一台服务器交谈并允许所有平台之间进行通信!
Vapor?
蒸气 ?
The internet is a complex series of tubes. Even responding to a simple HTTP request requires some serious amount of code. Luckily, experts in the field have developed open source web frameworks that do all the hard work for us for decades now, in various programming languages. Vapor is one of them, and it’s written in Swift. It already comes with some WebSocket capabilities and it’s exactly what we need.
互联网是一系列复杂的管道。 即使响应简单的HTTP请求,也需要大量的代码。 幸运的是,该领域的专家已经开发出开源的Web框架,这些框架已经用数十年的各种编程语言为我们完成了所有艰苦的工作。 蒸气是其中之一,它是用Swift编写的。 它已经带有一些WebSocket功能,这正是我们所需要的。
Vapor isn’t the only Swift powered web framework though. Kitura and Perfect are also well known frameworks. Though Vapor is arguably more active in its development.
蒸汽并不是唯一由Swift支持的Web框架。 Kitura和Perfect也是众所周知的框架。 尽管可以说Vapor在其发展中更加活跃。
Xcode should open the Package.swift
file by default. This is where we put general information and requirements of our Swift Package.
Xcode默认情况下应打开Package.swift
文件。 这是我们放置Swift程序包的一般信息和要求的地方。
Before we do that though, look in the Sources/ChatServer
folder. It should have a ChatServer.swift
file. We need to rename this to main.swift
. Once that's done, return to Package.swift
.
不过,在执行此操作之前, Sources/ChatServer
查看Sources/ChatServer
文件夹。 它应该有一个ChatServer.swift
文件。 我们需要将其重命名为main.swift
。 完成后,返回Package.swift
。
Under products:
, remove the following value:
在products:
,删除以下值:
.library(name: "ChatServer", targets: ["ChatServer"])
… and replace it with:
…并替换为:
.executable(name: "ChatServer", targets: ["ChatServer"])
After all, our server isn’t a Library. But a stand-alone executable, rather. We should also define the platforms (and minimum version) we expect our server to run on. This can be done by adding platforms: [.macOS(v10_15)]
under name: "ChatServer"
:
毕竟,我们的服务器不是图书馆。 但是是一个独立的可执行文件。 我们还应该定义我们希望服务器在其上运行的平台(和最低版本)。 这可以通过添加以下platforms: [.macOS(v10_15)]
来完成platforms: [.macOS(v10_15)]
, name: "ChatServer"
:
name: "ChatServer",
platforms: [
.macOS(.v10_15),
],
All this should make our Swift Package ‘runnable’ in Xcode.
所有这些都应该使我们的Swift包在Xcode中“可运行”。
Alright, let’s add Vapor as a dependency. In dependencies: []
(which should have some commented-out stuff), add the following:
好吧,让我们添加Vapor作为依赖项。 在dependencies: []
(应该有一些注释掉的东西)中,添加以下内容:
.package(url: "https://github.com/vapor/vapor.git", from: "4.0.0")
When saving the Package.swift
file, Xcode should start automatically fetching the Vapor dependencies with verison 4.0.0
or newer. As well as all its dependencies.
保存Package.swift
文件时,Xcode应该开始自动获取版本为4.0.0
或更高版本的Vapor依赖项。 以及它所有的依赖。
We just have to make one more adjustment to the file while Xcode is doing its thing: adding the dependency to our target. In targets:
you will find a .target(name: "ChatServer", dependencies: [])
. In that empty array, add the following:
Xcode在做它的事情时,我们只需要对文件进行另一种调整:将依赖项添加到目标中。 在targets:
您将找到一个.target(name: "ChatServer", dependencies: [])
。 在该空数组中,添加以下内容:
.product(name: "Vapor", package: "vapor")
That’s it. Our Package.swift
is done. We've described our Swift Package by telling it:
就是这样 。 我们的Package.swift
完成了。 我们通过告诉我们Swift包描述了它:
- It’s an executable, not a library 它是可执行文件,而不是库
To import the Vapor web framework dependency (and all its dependencies)
要导入蒸气Web框架的依赖(以及所有依赖的 )
- Link the Vapor dependency to our executable, making it accessible in our code 将Vapor依赖项链接到我们的可执行文件,使其在我们的代码中可访问
The final Package.swift
should look like this(-ish):
最终的Package.swift
应该看起来像这样(-ish):
Now, it’s finally time for…
现在,终于到了……
编写一些实际的代码 (Writing some actual code)
In Xcode, open Sources/ChatServer/main.swift
and delete everything in there. It's worthless to us. Instead, make main.swift
look like this:
在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
Bam! That’s all it takes to start a (WebSocket) server using Vapor. Look at how effortless that was.
am! 这就是使用Vapor启动(WebSocket)服务器所需的全部。 看看那是多么轻松。
- First we make a default Environment configuration. 首先,我们进行默认的环境配置。
- We initialize a Vapor Application instance and pass it the Environment. 我们初始化一个蒸气应用程序实例并将其传递给环境。
Register a
defer
and call.shutdown()
which will perform any cleanup when exiting the program.注册一个
defer
并调用.shutdown()
,它将在退出程序时执行任何清理。Start listening to any incoming WebSocket connections on
/chat
.开始侦听
/chat
上任何传入的WebSocket连接。- Acually start the Vapor Application instance. 精确启动Vapor Application实例。
Now ▶️ run the program in Xcode and grab something to drink. Building the first time takes a while as Xcode will need to build all those Vapor dependencies first. (But only once)
现在▶️在Xcode中运行该程序,然后拿点东西喝。 第一次构建需要一段时间,因为Xcode将需要首先构建所有这些Vapor依赖项。 (但只有一次)
Once the program has successfully run, you may not see anything resembling an app. That’s because server software don’t tend to have graphical user interfaces. But rest assured, the program is alive and well in the background, spinning its wheels. The Xcode console should show the following message, however:
程序成功运行后,您可能看不到任何类似于应用程序的内容。 这是因为服务器软件通常不具有图形用户界面。 但是请放心,该程序在后台运行且运行良好,运转自如。 但是,Xcode控制台应显示以下消息:
notice codes.vapor.application : Server starting on http://127.0.0.1:8080
This means the server can successfully listen to incoming requests. This is great, because we now have a WebSocket server we can start connecting to!
这意味着服务器可以成功侦听传入的请求。 太好了,因为我们现在有了一个WebSocket服务器,可以开始连接了!
Testing the WebSocket connection in a browser. 在浏览器中测试WebSocket连接。I don’t believe you?
我不相信你
If for whatever reason you think I’ve been spewing nothing but heinous lies this whole time, you can test the server yourself!
如果出于某种原因您认为我一直以来一直在撒谎,那么您可以自己测试服务器!
Open up your favourite browser and make sure you’re in an empty tab. (If it’s Safari, you will need to enable Developer mode first.) Open the Inspector (
Cmd
+Option
+I
) and go to the Console. Type in打开您喜欢的浏览器,并确保您处于空白标签中。 (如果是Safari,则需要首先启用“ 开发人员”模式 。)打开检查器 (
Cmd
+Option
+I
)并转到“ 控制台” 。 输入new WebSocket('ws://localhost:8080/chat')
新的WebSocket('ws:// localhost:8080 / chat')
and hit Return. Now take a look at the Xcode console. If all went well, it should now show
Connected: WebSocketKit.WebSocket
.然后按回车键。 现在看一下Xcode控制台。 如果一切顺利,现在应该显示
Connected: WebSocketKit.WebSocket
。
Important重要Important (⚠️ Important ⚠️)
The server is only accessible from your local machine. This means you cannot connect your physical iPhone/iPad to the server. Instead, we’ll be using the Simulator in the following steps to test our chat app.
该服务器只能从本地计算机访问。 这意味着您无法将物理iPhone / iPad连接到服务器。 相反,我们将在以下步骤中使用模拟器来测试聊天应用程序。
To test the chat app on a physical device, some (small) extra steps need to be taken. Refer to Appendix A for more details.
要在物理设备上测试聊天应用程序,需要采取一些(小的)额外步骤。 有关更多详细信息,请参见附录A。
创建应用 (Creating the app)
Though we’re not done with the backend yet, it’s time to move to the frontend. The chat app itself!
尽管我们还没有完成后端的工作,但是现在该转向前端了。 聊天应用程序本身!
In Xcode create a new project. This time, under Multiplatform select App. Again, choose a beautiful name for your app and continue. (I chose SwiftChat. I agree, it’s perfect )
在Xcode中创建一个新项目。 这次,在Multiplatform下,选择App 。 同样,为您的应用选择一个漂亮的名称,然后继续。 (我选择了SwiftChat 。我同意,这很完美 )
The app does not rely on any external third-party frameworks or libraries. Indeed, everything we need is available via Foundation
, Combine
and SwiftUI
(in Xcode 12+).
该应用程序不依赖任何外部第三方框架或库。 确实,我们需要的一切都可以通过Foundation
, Combine
和SwiftUI
(在Xcode 12+中)获得。
Let’s start working on the chat screen immediately. Create a new Swift file and name it ChatScreen.swift
. It doesn't matter whether you choose the Swift File or the SwiftUI View template. We're deleting everything in it regardless.
让我们立即开始在聊天屏幕上工作。 创建一个新的Swift文件,并将其命名为ChatScreen.swift
。 选择Swift文件还是SwiftUI视图模板都没有关系。 无论如何,我们都会删除其中的所有内容。
Here’s the starter’s kit of ChatScreen.swift
:
这是ChatScreen.swift
的入门套件:
In ContentsView.swift
, replace the Hello World with ChatScreen()
:
在ContentsView.swift
,用ChatScreen()
替换Hello World :
struct ContentView: View {
var body: some View {
ChatScreen()
}
}
Left: iPhone with dark appearance. Right: iPad with light appearance.
左:外观较暗的iPhone。 右:iPad外观较浅。
What we have here:
我们在这里拥有:
- A ScrollView where we will place all our messages in. 一个ScrollView,我们将在其中放置所有消息。
- The message box where the user can type in their message. 用户可以在其中输入消息的消息框。
- A submit button to send the message (though later on users will also be able to submit by pressing Return). 发送消息的提交按钮(尽管以后用户也可以通过按Return键来提交消息)。
- Disabling the submit button if the message is empty. 如果消息为空,则禁用提交按钮。
If you wish to make different design choices, go right ahead.
如果您希望做出不同的设计选择,请继续。
连接到服务器 (Connecting to the server)
Now let’s start working on some non-UI related logic: connecting to the very server we just made.
现在,让我们开始研究一些与UI无关的逻辑:连接到我们刚创建的服务器。
SwiftUI, together with the Combine framework, provides developers with tools to implement Seperation of Concerns effortlessly in their code. Using the ObservableObject
protocol and @StateObject
(or @ObservedObject
) property wrappers we can implement non-UI logic (referred to as Business Logic) in a separate place. As things should be! After all, the only thing the UI should care about is displaying data to the user and reacting to user input. It shouldn't care where the data comes from, or how it's manipulated.
SwiftUI与Combine框架一起为开发人员提供了在其代码中轻松实现关注分离的工具。 使用ObservableObject
协议和@StateObject
(或@ObservedObject
)属性包装器,我们可以在单独的位置实现非UI逻辑(称为Business Logic )。 事情应该是! 毕竟,UI唯一需要关心的就是向用户显示数据并对用户输入做出React。 它不在乎数据来自何处或如何操作。
Coming from a React background, this luxury is something I’m incredibly envious of.
来自React的背景,这种奢侈让我羡慕不已。
There are thousands upon thousands articles and discussions about software architecture. You’ve probably heard or read about concepts like MVC, MVVM, VAPOR, Clean Architecture and more. They all have their arguments and their applications.
关于软件体系结构的文章和讨论成千上万。 您可能已经听说过或阅读了有关MVC,MVVM,VAPOR,Clean Architecture等概念的信息。 他们都有自己的论据和应用。
Discussing these is out-of-scope for this tutorial. But it’s generally agreed upon that business logic and UI logic should not be intertwined.
在本教程中不讨论这些内容。 但是,人们普遍同意,业务逻辑和UI逻辑不应交织在一起。
This concept is true just as much for our ChatScreen. The only thing the ChatScreen should care about is displaying the messages and handling the user-input text. It doesn’t care about ✌️WeBsOcKeTs✌, nor should it.
这个概念同样适用于我们的ChatScreen 。 ChatScreen唯一需要关心的是显示消息并处理用户输入的文本。 它不在乎✌️WeBsOcKeTs✌,也不在乎。
You can create a new Swift file or write the following code at the bottom of ChatScreen.swift
. Your choice. Wherever it lives, make sure you don't forget the import
s!
您可以创建一个新的Swift文件,也可以在ChatScreen.swift
的底部编写以下代码。 你的选择。 无论它在哪里,请确保您不要忘记import
!
This may be a lot to take in, so let’s slowly go through it:
这可能要花费很多,所以让我们慢慢看一下:
We store a
URLSessionWebSocketTask
in a property.我们将
URLSessionWebSocketTask
存储在一个属性中。We store a
URLSessionWebSocketTask
in a property.URLSessionWebSocketTask
objects are responsible for WebSocket connections. They're residents of theURLSession
family in the Foundation framework.我们将
URLSessionWebSocketTask
存储在一个属性中。URLSessionWebSocketTask
对象负责WebSocket连接。 他们是Foundation框架中URLSession
家庭的居民。- Public method to start the connection. 用于启动连接的公共方法。
URL to our server.
我们服务器的URL。
Remember: the server runs locally on your machine (which means we use the IP
请记住:服务器在您的计算机上本地运行(这意味着我们使用IP
127.0.0.1
orlocalhost
). The default port of Vapor applications is8080
. And we put a listener to WebSocket connections in the/chat
path.127.0.0.1
或localhost
)。 Vapor应用程序的默认端口为8080
。 然后,我们在/chat
路径中放置一个WebSocket连接的侦听器。We create a
URLSessionWebSocketTask
and store it in the instance's propety.我们创建一个
URLSessionWebSocketTask
并将其存储在实例的属性中。We bind an ‘on message’ handler.
我们绑定一个“ on message”处理程序。
Whenever a message is received from the server, the method
每当从服务器收到消息时,该方法
onReceive(incoming:)
will be called. More on this later.onReceive(incoming:)
将被调用。 稍后再详细介绍。- Start the WebSocket connection. 启动WebSocket连接。
- A public method to close the connection (very important!). 关闭连接的公共方法(非常重要!)。
Disconnecting the WebSocket connection.
断开WebSocket连接。
With WebSockets, you have the option to inform the server
使用WebSockets,您可以选择通知服务器
why a client disconnected. With optional additional data provided.
为什么客户端断开连接。 提供可选的附加数据。
Make sure we gracefully disconnect when the
ChatScreenModel
is purged from memory.当从内存中清除
ChatScreenModel
时,请确保我们正常断开连接。
This is a great start. We now have a place where we can put all our WebSocket logic without cluttering the UI code. It’s time to have ChatScreen
communicate with ChatScreenModel
.
这是一个很好的开始。 现在,我们可以放置所有WebSocket逻辑而又不会使UI代码混乱。 是时候让ChatScreen
与ChatScreenModel
通信了。
Add the ChatScreenModel
as a State Object in ChatScreen
:
添加ChatScreenModel
在状态对象ChatScreen
:
struct ChatScreen: View {
@StateObject private var model = ChatScreenModel() // @State private var message = ""
// etc...
}
When should we connect to the server? Well, when the screen is actually visible, of course. You may be tempted to call .connect()
in the init()
of ChatScreen
. This is a dangerous thing. In fact, in SwiftUI one should try to avoid putting anything the init()
, as the View can be initialized even when it will never appear. (For instance in LazyVStack
or in NavigationLink(destination:)
.) It'd be a shame to waste precious CPU cycles. Therefore, let's defer everything to onAppear
.
我们什么时候应该连接到服务器? 好吧,当然,当屏幕实际可见时。 你可能会打电话给.connect()
中init()
的ChatScreen
。 这是危险的事情。 实际上,在SwiftUI中,应该尝试避免放置任何init()
,因为即使视图永远不会出现,也可以对其进行初始化。 (例如,在LazyVStack
或NavigationLink(destination:)
。)浪费宝贵的CPU周期是可耻的。 因此,让我们将所有内容推迟到onAppear
。
Add an onAppear
method to ChatScreen
. Then add and pass that method to the .onAppear(perform:)
modifier of VStack
:
向ChatScreen
添加一个onAppear
方法。 然后添加和方法传递给.onAppear(perform:)
的改性剂VStack
:
struct ChatScreen: View {
// ...
private func onAppear() {
model.connect()
}
var body: some View {
VStack {
// ...
}
.onAppear(perform: onAppear)
}
}
Wasted space?
浪费空间了吗?
Plenty of people prefer to write the contents of these methods inline instead:
很多人更喜欢内联地编写这些方法的内容:
.onAppear { model.connect() }
.onAppear {model.connect()}
This is nothing but a personal preference. Personally I like to define these methods separately. Yes, it costs more space. But they’re easier to find, are reusable, prevent the
body
from getting (more) cluttered and are arguably easier to fold.这不过是个人喜好。 我个人喜欢单独定义这些方法。 是的,它花费更多的空间。 但是它们更容易找到,可重复使用,防止
body
变得(更)混乱,并且可以说更容易折叠。
By the same token, we should also disconnect when the view disappears. The implementation should be self explanatory, but just in case:
同样,当视图消失时,我们也应该断开连接。 实现应该是自我解释的,但以防万一:
struct ChatScreen: View {
// ...
private func onDisappear() {
model.disconnect()
}
var body: some View {
VStack {
// ...
}
.onAppear(perform: onAppear)
.onDisappear(perform: onDisappear)
}
}
It’s very important to close WebSocket connections whenever we stop caring about them. When you (gracefully) close a WebSocket connection, the server will be informed and can purge the connection from memory. The server should never have dead or unknown connections lingering in memory.
每当我们停止关心WebSocket连接时,关闭它们都是非常重要的。 当您(正常)关闭WebSocket连接时,服务器将收到通知,并可以从内存中清除连接。 服务器不应该有死的或未知的连接在内存中挥之不去。
Phew. Quite a ride we’ve been through so far. Time to test it out. ▶️ Run the app! (Use Simulator if you’re testing for iOS.) Make sure you still have the server running in your other Xcode window. When the app has successfully started and is displaying ChatScreen
, you should see the Connected: WebSocketKit.WebSocket
message in the Xcode console of the server. If not, retrace your steps and start debugging!
ew 到目前为止,我们已经经历了一段不小的旅程。 该测试一下了。 ▶️运行应用程序! (如果要为iOS进行测试,请使用Simulator。)请确保服务器仍在其他Xcode窗口中运行。 当应用程序成功启动并显示ChatScreen
,您应该在服务器的Xcode控制台中看到Connected: WebSocketKit.WebSocket
消息。 如果不是,请追溯您的步骤并开始调试!
测试断开 (Testing disconnection)
One more thing™️. We should also test whether the WebSocket connection is closed when the user closes the app (or leaves ChatScreen
). Head back to the main.swift
file of the server project. Currently our WebSocket listener looks like this:
还有一件事™️。 当用户关闭应用程序(或离开ChatScreen
)时,我们还应该测试WebSocket连接是否关闭。 回到服务器项目的main.swift
文件。 当前,我们的WebSocket侦听器如下所示:
app.webSocket("chat") { req, client in
print("Connected:", client)
}
Add a handler to the .onClose
of client
, performing nothing but a simple print()
:
将处理程序添加到client
的.onClose
,仅执行简单的print()
:
app.webSocket("chat") { req, client in
print("Connected:", client) client.onClose.whenComplete { _ in
print("Disconnected:", client)
}
}
Re-run the server and start the chat app. Once the app is connected, close the app (actually exit it, don’t just put it in the background). The Xcode console of the server should now print Disconnected: WebSocketKit.WebSocket
. This confirms that WebSocket connections are indeed closed when we no longer care about them. Thus the server should have no dead connections lingering in memory.
重新运行服务器并启动聊天应用程序。 连接应用程序后,关闭应用程序(实际上退出它,不要只是将其放在后台)。 服务器的Xcode控制台现在应显示Disconnected: WebSocketKit.WebSocket
。 这证实了当我们不再关心WebSocket连接时,确实确实关闭了它们。 因此,服务器不应在内存中留下任何死连接。
发送和接收消息 (Sending and receiving messages)
You ready to actually send something to the server? Boy, I sure am. But just for a moment, let’s put on the brakes and think for a second. Lean back in the chair and stare aimlessly, yet somehow purposefully at the ceiling…
您准备好将某些内容实际发送到服务器了吗? 男孩,我肯定是。 但是片刻,让我们刹车一下,思考一秒钟。 靠在椅子上,漫不经心地凝视着,却不知何故地盯着天花板……
What exactly will be we sending to the server? And, just as importantly, what will we be receiving back from the server?
我们到底要发送到服务器什么? 而且,同样重要的是, 我们会怎样从服务器接收回来?
Your first thought may be “Well, just text, right?”, you’d be half right. But what about the time of the message? What about the sender’s name? What about an identifier to make the message unique from any other message? We don’t have anything for the user to create a username or anything just yet. So let’s put that to the side and just focus on sending and receiving messages.
您的第一个想法可能是“好吧,只是文字,对不对?”,您会说对了一半。 但是消息的时间呢? 寄件人的名字呢? 那么使该消息与其他任何消息唯一的标识符又如何呢? 我们还没有任何可让用户创建用户名的内容。 因此,我们将其放在一边,仅专注于发送和接收消息。
We’re going to have to make some adjustments on both the app- and server-side. Let’s start with the server.
我们将不得不在应用程序和服务器端进行一些调整。 让我们从服务器开始。
服务器端 (Server-side)
Create a new Swift file in Sources/ChatServer
called Models.swift
in the server project. Paste (or type) the following code into Models.swift
:
在服务器项目的Sources/ChatServer
创建一个名为Models.swift
的新Swift文件。 将以下代码粘贴(或键入)到Models.swift
:
import Foundationstruct SubmittedChatMessage: Decodable { // 1
let message: String
}struct ReceivingChatMessage: Encodable, Identifiable { // 2
let date = Date() // 3
let id = UUID() // 4
let message: String // 5
}
Here’s what’s going on:
这是怎么回事:
This will be the data the server receives from individual clients when they send a message. For now, it’s just a message (String). Username et al will be added later on. Because we’re only receiving this type of data, we only need to decode it. Hence the
Decodable
protocol.这将是服务器在各个客户端发送消息时从它们接收的数据。 目前,这只是一条消息(字符串)。 用户名等将在以后添加。 因为我们只接收这种类型的数据,所以我们只需要对其解码即可。 因此,可
Decodable
协议。This will be the data sent to indiviual clients. Because we only have to encode it, it’s conforming to the
Encodable
protocol.这将是发送到个人客户端的数据。 因为我们只需要编码,所以它符合
Encodable
协议。The date of the message. This will be automatically generated when initializing a
ReceivingChatMessage
.消息的日期。 这将在初始化
ReceivingChatMessage
时自动生成。- A unique identifier for the message. Just like the date, this too will be automatically generated. 消息的唯一标识符。 就像日期一样,这也会自动生成。
- The message received earlier, now being sent to all the clients connected to the server. 先前收到的消息,现在被发送到连接到服务器的所有客户端。
Do note how we’re generating the date
and id
on the server-side. This makes the server the Source of Truth. The server knows what time it is. If the date were to be generated on the client-side, it cannot be trusted. What if the client has their clock setup to be in the future? Having the server generate the date makes its clock the only reference to time.
请注意我们如何在服务器端生成date
和id
。 这使服务器成为真理之源 。 服务器知道现在几点了。 如果要在客户端生成日期,则不能信任该日期。 如果客户端的时钟设置在将来会怎样? 由服务器生成日期使它的时钟成为时间的唯一参考。
Timezones?
时区?
Swift’s
Date
object always has 00:00:00 UTC 01-01-2001 as absolute reference time. When initializing aDate
or format one to string (e.g. viaDateFormatter
), the client's locality will be taken into consideration automatically. Adding or subtracting hours depending on the client's timezone.Swift的
Date
对象始终将00:00:00 UTC 01-01-2001作为绝对参考时间。 在初始化Date
或将Date
格式化为字符串时(例如,通过DateFormatter
),将自动考虑客户端的位置。 增加或减少小时数取决于客户的时区。UUID?
UUID?
Universally Unique Identifiers are globally regarded as acceptable values for identifiers.
全局唯一标识符在全球范围内被视为标识符的可接受值。
We also don’t want the client to send multiple messages with the same unique identifier. Whether accidentally or purposefully maliciously. Having the server generate this identifier is one extra layer of security and less possible sources of errors.
我们也不希望客户端发送具有相同唯一标识符的多条消息。 无论是偶然还是有目的的恶意。 让服务器生成此标识符是安全性的额外一层,并且减少了错误的可能来源。
Now then. When the server receives a message from a client, it should pass it along to every other client. This does, however, mean we have to keep track of every client that’s connected.
接着。 服务器从客户端接收消息时,应将其传递给其他所有客户端。 但是,这确实意味着我们必须跟踪连接的每个客户端。
Back to main.swift
of the server project. Right above app.webSocket("chat")
put the following declaration:
返回服务器项目的main.swift
。 在app.webSocket("chat")
上方,放置以下声明:
var clientConnections = Set()
This is where we’ll store our client connections.
这是我们存储客户连接的地方。
But wait… You should be getting a big, bad, nasty compile error. That’s because the WebSocket
object does not conform to the Hashable
protocol by default. No worries though, this can be easily (albeit cheapishly) implemented. Add the following code at the very bottom of main.swift
:
但是,等等……您应该遇到一个大的,糟糕的,令人讨厌的编译错误。 这是因为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))
}
}
Badabing badaboom. The above code is a quick but simple way to make a class
conform to Hashable
(and by definition also Equatable
), by simply using its memory address as a unique property. Note: this only works for classes. Structs will require a little more hands-on implementation.
Badabing badaboom。 上面的代码是通过简单地将class
的内存地址用作唯一属性,使class
符合Hashable
(并且根据定义也为Equatable
)的一种快速但简单的方法。 注意:这仅适用于课程。 结构将需要更多的动手实施。
Alright, so now that we’re able to keep track of clients, replace everything of app.webSocket("chat")
(including its closure and its contents) with the following code :
好了,所以现在我们能够跟踪客户,更换的一切app.webSocket("chat")
包括其关闭和它的内容)用下面的代码:
app.webSocket("chat") { req, client in
clientConnections.insert(client)
client.onClose.whenComplete { _ in
clientConnections.remove(client)
}
}
When a client connects, store said client into clientConnections
. When the client disconnects, remove it from the same Set
. Ezpz.
当客户端连接时,将所述客户端存储到clientConnections
。 当客户端断开连接时,将其从同一Set
删除。 Ezpz。
The final step in this chapter is adding the heart of the server↔️app communication. Below the entirety of client.onClose.whenComplete
- but still inside the app.webSocket("chat")
closure - add the following snippet of code:
本章的最后一步是添加服务器↔️app通信的核心 。 在client.onClose.whenComplete
的整体client.onClose.whenComplete
(但仍在app.webSocket("chat")
闭包内部app.webSocket("chat")
,添加以下代码段:
Again, from the top:
同样,从顶部开始:
Bind an
.onText
handler to the connected client.将
.onText
处理程序绑定到连接的客户端。Everytime the server receives text from this client, this handler will be called. Here we have the opportunity to parse and validate the incoming text.
每当服务器从该客户端接收文本时,将调用此处理程序。 在这里,我们有机会解析和验证传入的文本。
Decode the incoming message.
解码传入的消息。
This will be our validation step. We don’t want to continue if the client sends unacceptable payloads.
这将是我们的验证步骤。 如果客户端发送了不可接受的负载,我们不想继续。
Initialize a
ReceivingChatMessage
with the message received from the client.使用从客户端收到的消息初始化
ReceivingChatMessage
。Remember that the date and unique identifier of
请记住,日期和唯一标识符
ReceivingChatMessage
will be generated automatically.ReceivingChatMessage
将自动生成。Encode the
ReceivingChatMessage
to a JSON string (well, asData
).将
ReceivingChatMessage
编码为JSON字符串(以及Data
)。Send the encoded JSON string to every client.
将编码的JSON字符串发送给每个客户端。
Yes, this includes sending it to the original sender as well.
是的,这还包括将其发送到原始发件人。
Why send it back?
为什么要寄回去?
We can use this as a confirmation that the message was, in fact, received successfully from the client. The app will receive back the message just like it’d receive any other message. This will prevent us from having to write additional code later on.
我们可以将其用作确认实际上已成功从客户端收到消息的确认。 该应用程序将收到该消息,就像收到其他消息一样。 这将避免我们以后必须编写其他代码。
Done! The server is ready to receive messages and pass them along to other connected clients. Run the server and let it idle in the background, as we continue with the app!
做完了! 服务器已准备好接收消息并将它们传递给其他已连接的客户端。 运行服务器,让它在后台空闲,因为我们继续该应用程序!
发送客户端 (Sending client-side)
Rememeber those SubmittedChatMessage
and ReceivingChatMessage
structs we made for the server? We need them for the app as well. Create a new Swift file and name it Models.swift
. Though you could just copy-paste the implementations, they will require a bit of modification:
记得那些我们为服务器制作的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
}
Notice how the Encodable
and Decodable
protocols have been swapped. It only makes sense: in the app, we only encode SubmittedChatMessage
and only decode ReceivingChatMessage
. The opposite of the server. We also removed the automatic initializations of date
and id
. The app has no business generating these.
注意如何交换可Encodable
和可Decodable
协议。 这只有道理:在应用程序中,我们仅对SubmittedChatMessage
进行编码,并且仅对ReceivingChatMessage
进行解码。 与服务器相反。 我们还删除了date
和id
的自动初始化。 该应用没有业务产生这些。
Okay, back to ChatScreenModel
(whether it's in a separate file or at the bottom of ChatScreen.swift
). Add the top, but inside ChatScreenModel
add the following instance property:
好的,回到ChatScreenModel
(无论是在单独的文件中还是在ChatScreen.swift
的底部)。 添加顶部,但在ChatScreenModel
添加以下实例属性:
@Published private(set) var messages: [ReceivingChatMessage] = []
This where we’ll store received messages. Thanks to @Published
, the ChatScreen
will know exactly when this array gets updated and will react to this change. private(set)
makes sure only ChatScreenModel
can update this property. (After all, it's the owner of the data. No other object has any business modifying it directly!)
我们将在这里存储收到的消息。 感谢@Published
, ChatScreen
将确切知道何时更新此数组,并将对此更改做出React。 private(set)
确保只有ChatScreenModel
可以更新此属性。 (毕竟,它是数据的所有者。没有其他对象可以直接对其进行任何修改!)
Still inside ChatScreenModel
, add the following method:
仍在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
}
}
}
It seems self-explanatory. But for consistency’s sake:
似乎不言而喻。 但是为了保持一致性:
Create the payload we’ll be sending to the server:
创建我们将发送到服务器的有效负载:
A
一个
SubmittedChatMessage
that, for now, just holds the message.SubmittedChatMessage
,目前仅保存该消息。- Turn our payload into a JSON string. 将我们的有效负载转换为JSON字符串。
- Send the JSON string to the server. 将JSON字符串发送到服务器。
If any errors occurred, simply print the error.
如果发生任何错误,只需打印错误。
Of course, in a real app you’d respond a bit more respectfully to such an error.
当然,在真实的应用程序中,您会对此错误做出更为尊重的回应。
Open ChatScreen.swift
and add the following method to ChatScreen
:
打开ChatScreen.swift
并将以下方法添加到ChatScreen
:
private func onCommit() {
if !message.isEmpty {
model.send(text: message)
message = ""
}
}
This method will be called when the user either presses the submit button or when pressing Return on the keyboard. Though it’ll only send the message if it actually contains anything.
当用户按下提交按钮或按下键盘上的Return键时,将调用此方法。 尽管它仅在实际包含任何内容的情况下才会发送消息。
In the .body
of ChatScreen
, locate the TextField
and Button
, then replace them (but not their modifiers or contents) with the following initializations:
在.body
的ChatScreen
,找到TextField
和Button
,然后用以下初始化替换它们(而不是它们的修饰符或内容):
TextField("Message", text: $message, onEditingChanged: { _ in }, onCommit: onCommit)
// .modifiers hereButton(action: onCommit) {
// Image etc
}
// .modifiers here
When the Return key is pressed while the TextField
is focused, onCommit
will be called. Same goes for when the Button
is pressed by the user. TextField
also requires an onEditingChanged
argument - but we discard that by giving it an empty closure.
在聚焦TextField
同时按Return键时,将调用onCommit
。 用户按下Button
也是如此。 TextField
还需要一个onEditingChanged
参数-但是我们通过给它一个空的闭包来丢弃它。
Now is the time to start testing what we have. Make sure the server is still running in the background. Place some breakpoints in the client.onText
closure (where the server reads incoming messages) in main.swift
of the server. Run the app and send a message. The breakpoint(s) in main.swift
should be hit upon receiving a message from the app. If it did, lush! If not, well... retrace your steps and start debugging!
现在是时候开始测试我们拥有的东西了。 确保服务器仍在后台运行。 放置在一些断点client.onText
封闭在(其中服务器读取输入消息) main.swift
服务器。 运行该应用程序并发送一条消息。 收到来自应用程序的消息后, 应该点击main.swift
的断点。 如果是这样, 郁郁葱葱 ! not如果没有,那么...回溯您的步骤并开始调试!
接收客户端 (Receiving client-side)
Sending messages is cute and all. But what about receiving them? (Well, technically we are receiving them, just never reacting to them.) Right you are!
发送消息非常可爱。 但是接收它们呢? (嗯,从技术上讲,我们正在接受它们,只是从不对它们做出React。)正确,您是对的!
Let’s visit ChatScreenModel
once more. Remember that onReceive(incoming:)
method? Replace it and give it a sibling method as shown below:
让我们再次访问ChatScreenModel
。 还记得onReceive(incoming:)
方法吗? 替换它,并为其提供同级方法,如下所示:
So…
所以…
Those receive handlers we bind to
URLSessionWebSocketTask
? They only work once. Thus, we instantly rebind a new handler, so we're ready to read the next incoming message.那些我们绑定到
URLSessionWebSocketTask
接收处理程序? 他们只工作一次。 因此,我们立即重新绑定了一个新的处理程序,因此我们准备读取下一条传入的消息。- If successful, we pick out the contents of the message and let another method deal with it further. 如果成功,我们将选择消息的内容,然后让另一种方法进一步处理它。
- If not successful, simply print an error to the console. 如果不成功,只需将错误打印到控制台。
- This method is responsible for parsing the incoming (successful) message. 此方法负责解析传入(成功)消息。
A WebSocket message can be either binary or text. So far we’ve been sending JSONs back and forther — which are text formats (more on this later). Thus we only handle messages containing strings. Afterwards we decode the data to
ReceivingChatMessage
.WebSocket消息可以是二进制或文本。 到目前为止,我们一直在向后和向前发送JSON,这是文本格式(稍后会详细介绍)。 因此,我们仅处理包含字符串的消息。 之后,我们将数据解码到
ReceivingChatMessage
。Plop the decoded message into
self.messages
. However, becauseURLSessionWebSocketTask
can call the receive handler on a different thread, and because SwiftUI only works on the main thread, we have to wrap our modification in aDispatchQueue.main.async {}
, assuring we're actually performing the modification on the main thread.将解码后的消息放入
self.messages
。 但是 ,由于URLSessionWebSocketTask
可以在其他线程上调用接收处理程序,并且因为SwiftUI仅在主线程上工作,所以我们必须将修改包装在DispatchQueue.main.async {}
,以确保我们实际上在主线程。
Explaining the hows and whys of working with different threads in SwiftUI is beyond the scope of this tutorial.
解释在SwiftUI中使用不同线程的方式和原因超出了本教程的范围。
Nearly there!
就快到了!
Check back in on ChatScreen.swift
. See that empty ScrollView
? We can finally populate it with messages:
在ChatScreen.swift
上ChatScreen.swift
。 看到那个空的ScrollView
吗? 我们最终可以用消息填充它:
ScrollView {
LazyVStack(spacing: 8) {
ForEach(model.messages) { message in
Text(message.message)
}
}
}
It’s not going to look spectacular by any means. But this’ll do the job for now. We simply represent every message with a plain o’ Text
.
无论如何,它看起来都不会很壮观。 但这现在就可以完成。 我们仅用纯Text
表示每个消息。
Go ahead, run the app. When you send a message, it should instantly appear on the screen. This confirms the message was successfully sent to the server, and the server successfully sent it back to the app! Now, if you can, open up multiple instances of the app (tip: use different Simulators). There’s virtually no limit to the amount of clients! Have a nice big chat party all by yourself.
继续,运行应用程序。 发送消息时,该消息应立即显示在屏幕上。 这确认消息已成功发送到服务器,并且服务器已成功将其发送回应用程序! 现在,如果可以的话,打开该应用程序的多个实例(提示:使用不同的模拟器)。 客户数量几乎没有限制! 独自举办一个很棒的大型聊天聚会。
Keep sending messages until there’s no room left on the screen. Notice anything? Yarp. The ScrollView
doesn't automatically scroll to the bottom once new messages are beyond the screen's bounds.
继续发送消息,直到屏幕上没有剩余空间为止。 注意到什么了吗? Yarp。 新消息超出屏幕范围后, ScrollView
不会自动滚动到底部。
Enter…
输入…
自动滚动 (Autoscrolling)
Remember, the server generates a unique identifier for each message. We can finally put it to good use! The wait was worth it for this incredible payoff, I assure you.
记住,服务器为每个消息生成一个唯一的标识符。 我们终于可以好好利用它了! 我向您保证,这次令人难以置信的回报值得等待。
In ChatScreen
, turn the ScrollView
into this beauty:
在ChatScreen
,将ScrollView
变成以下ChatScreen
:
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)
}
}
}
Then add the following method:
然后添加以下方法:
private func scrollToLastMessage(proxy: ScrollViewProxy) {
if let lastMessage = model.messages.last { // 4
withAnimation(.easeOut(duration: 0.4)) {
proxy.scrollTo(lastMessage.id, anchor: .bottom) // 5
}
}
}
We’re wrapping the contents of the
ScrollView
in aScrollViewReader
.我们将
ScrollView
的内容包装在ScrollViewReader
。The
的
ScrollViewReader
provides us with aproxy
that we'll need very soon.ScrollViewReader
为我们提供了我们很快需要的proxy
。- Give each message a unique identifier (simply using the message’s identifier). 给每个消息一个唯一的标识符(只需使用消息的标识符)。
Keep track of changes to
model.messages.count
. When this value changes, we call the method we just added, passing it theproxy
provided byScrollViewReader
.跟踪对
model.messages.count
的更改。 当此值更改时,我们将调用刚添加的方法,并向其传递ScrollViewReader
提供的proxy
。- Safely get the latest message. 安全获取最新消息。
Call the
.scrollTo(_:anchor:)
method of theScrollViewProxy
. This tells theScrollView
to scroll to the View with the given identifier. We wrap this inwithAnimation {}
to animate the scrolling.调用
ScrollViewProxy
的.scrollTo(_:anchor:)
方法。 这告诉ScrollView
滚动到具有给定标识符的View。 我们将其封装在withAnimation {}
以动画化滚动。
Et voilà…
Etvoilà…
添加用户信息 (Adding user information)
These messages are pretty lush… but it’d be even lush-er if we knew who sent the messages and visually distinguish between received and sent messages.
这些消息非常丰富……但是,如果我们知道谁发送了消息并在视觉上区分接收到的消息和发送的消息,则消息甚至会更加郁郁葱葱 。
With each message we will also attach a username and a user identifier. Because a username isn’t enough to identify a user, we need something unique. What if the user and everyone else’s name was Patrick? We’d have an identity crisis and would be unable to distinguish between messages sent by Patrick and messages received by a Patrick.
对于每条消息,我们还将附加一个用户名和一个用户标识符。 由于用户名不足以识别用户,因此我们需要一些独特的东西。 如果用户和其他所有人的名字叫Patrick怎么办? 我们希望有一个身份危机,将无法由帕特里克收到帕特里克发送的消息和消息之间的区别。
As is tradition, we start with the server, it’s the least amount of work.
按照传统,我们从服务器开始,这是最少的工作。
Open up Models.swift
where we defined both SubmittedChatMessage
and ReceivingChatMessage
. Give both of these bad boys a username: String
and userID: UUID
property, like so:
打开Models.swift
,在其中我们定义了SubmittedChatMessage
和ReceivingChatMessage
。 给这两个坏男孩一个username: 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
}
Returning to main.swift
, where you should be greeted with an error, change the initialization of ReceivingChatMessage
to the following:
返回到main.swift
,在这里您会遇到一个错误,将ReceivingChatMessage
的初始化更改为以下内容:
let outgoingMessage = ReceivingChatMessage(
message: incomingMessage.message,
user: incomingMessage.user,
userID: incomingMessage.userID
)
And that's it! We're done with the server. It's just the app from here on out. The home stretch!
就是这样 ! 服务器已经完成。 这只是从现在开始的应用程序。 回家舒展!
In the app's Xcode project, create a new Swift file called UserInfo.swift
. Place the following code there:
在应用程序的Xcode项目中,创建一个名为UserInfo.swift
的新Swift文件。 将以下代码放在此处:
import Combine
import Foundation
class UserInfo: ObservableObject {
let userID = UUID()
@Published var username = ""
}
This will be our EnvironmentObject
where we can store our username in. As always, the unique identifier is an automatically generated immutable UUID. Where does the username come from? The user will input this when opening the app, before being presented the chat screen.
这将是我们的EnvironmentObject
,我们可以在其中存储用户名。与往常一样,唯一标识符是自动生成的不可变UUID。 用户名来自哪里? 用户将在打开应用程序时输入此内容,然后显示聊天屏幕。
New file time: SettingsScreen.swift
. This file will house the simple settings form:
新文件时间: SettingsScreen.swift
。 此文件将包含简单的设置表单:
The previously created
UserInfo
class will be accessible here as anEnvironmentObject
.先前创建的
UserInfo
类将在此处作为EnvironmentObject
进行访问。- A simple validation to make sure the username isn’t just whitespace. 一个简单的验证,以确保用户名不只是空格。
The
TextField
will directly write its contents intouserInfo.username
.TextField
将直接将其内容写入userInfo.username
。The
NavigationLink
that will presentChatScreen
when pressed. The button is disabled while the username is invalid. (Do you notice how we initializeChatScreen
in theNavigationLink
? Had we madeChatScreen
connect to the server in itsinit()
, it would've done so right now!)按下时将显示
ChatScreen
的NavigationLink
。 用户名无效时,该按钮被禁用。 (您是否注意到我们是如何在NavigationLink
初始化ChatScreen
的?如果我们使ChatScreen
在其init()
连接到服务器,它现在就已经做到了 !)
If you wish you can add a little panache to screen.
如果您愿意,可以在屏幕上添加一点煎饼 。
Since we’re using SwiftUI’s navigation features, we need to start off with a NavigationView
somewhere. ContentView
is the perfect spot for this. Change ContentView
's implementation as follows:
由于我们正在使用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
}
}
We initialize an instance of
UserInfo
and...我们初始化
UserInfo
的实例,然后...… pass it along as an
EnvironmentObject
, making it accessible to all succeeding views.…将其作为
EnvironmentObject
传递,使其可用于所有后续视图。- This is just to make the app not use a columned navigation view on certain screen sizes. 这只是为了使应用程序在某些屏幕尺寸上不使用列式导航视图。
Now to send the data of UserInfo
along with the messages we send to the server. Go to ChatScreenModel
(wherever you put it). At the top of the class add the following properties:
现在发送UserInfo
的数据以及我们发送到服务器的消息。 转到ChatScreenModel
(无论放置在何处)。 在类的顶部添加以下属性:
final class ChatScreenModel: ObservableObject {
private var username: String?
private var userID: UUID?
// the rest ...
}
The ChatModelScreen
should receive these values when connecting. It's not ChatModelScreen
's job to know where this information came from. If, in the future, we decide to change where both username
and userID
are stored, we can leave ChatModelScreen
untouched.
连接时, ChatModelScreen
应该接收这些值。 知道此信息的来源不是ChatModelScreen
的工作。 如果将来我们决定更改username
和userID
的存储位置,则可以保持ChatModelScreen
不变。
Change the connect()
method to accept these new properties as arguments:
更改connect()
方法以接受以下新属性作为参数:
func connect(username: String, userID: UUID) {
self.username = username
self.userID = userID // etc ...
}
Finally, in send(text:)
, we need to apply these new values to the SubmittedChatMessage
we're sending to the server:
最后,在send(text:)
,我们需要将这些新值应用于要发送到服务器的SubmittedChatMessage
:
func send(text: String) {
guard let username = username, let userID = userID else {
return
}
let message = SubmittedChatMessage(message: text, user: username, userID: userID)
// Everything else ...
}
Aaaand that’s it for ChatScreenModel
. It's finished.
Aaa,就是ChatScreenModel
。 完成了 。
For the final time, open up ChatScreen.swift
. At the top of ChatScreen
add:
最后一次打开ChatScreen.swift
。 在ChatScreen
的顶部添加:
@EnvironmentObject private var userInfo: UserInfo
Don’t forget to supply the username
and userID
to ChatScreenModel
when the view appears:
当视图出现时,请不要忘记向ChatScreenModel
提供username
和userID
ChatScreenModel
:
private func onAppear() {
model.connect(username: userInfo.username, userID: userInfo.userID)
}
Now, once again, as practiced: Lean back in that chair and look up at the ceiling. What should the text messages look like? If you’re in no mood for creative thinking, you can use the following View that represents a single received (and sent) message:
现在,再次按照惯例进行操作:向后靠在椅子上,抬头看着天花板。 短信应该是什么样的? 如果您不想进行创造性思维,则可以使用以下表示单个已接收(和已发送)消息的视图:
It’s not particularly exciting looking. Here’s what it looks like on an iPhone:
看起来并不特别令人兴奋。 这是在iPhone上的外观:
(Remember how the server also sends the date of a message? Here it’s used to display the time.)
(还记得服务器还如何发送消息的日期吗?这里是用来显示时间的。)
Colors and positioning are based on the isUser
property that's passed down by the parent. In this case, that parent is none other than ChatScreen
. Because ChatScreen
has access to the messages as well as the UserInfo
, it's there where the logic is placed to determine whether the message belongs to the user or not.
颜色和位置基于父级传递的isUser
属性。 在这种情况下,该父对象就是ChatScreen
。 由于ChatScreen
可以访问消息以及 UserInfo
,因此可以在其中放置逻辑以确定消息是否属于用户。
ChatMessageRow
replaces the boring Text
we used before to represent messages:
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.
}
}
Welcome to the finish line! You’ve made it all the way here! For the final time, ▶️ run the app and chat away.
欢迎来到终点线! 您已经做到了! 最后一次,▶️运行该应用并聊天。
By now you should have a primitive — but fuctioning — chat app. As well as a server handling the incoming and outgoing messages. All written in Swift!
到现在为止,您应该拥有一个简单但实用的聊天应用程序。 以及处理传入和传出消息的服务器。 全部用Swift编写!
Congrats! And thank you very much for reading!
恭喜! 非常感谢您的阅读!
You can download the final code from Github.
您可以从Github下载最终代码。
Left: Tiny iPad with dark appearance. Right: Huge iPhone with light appearance. 左:外观暗淡的微型iPad。 右:巨大的iPhone,外观亮丽。回顾 (Recap)
Let’s sum up our journey:
让我们总结一下我们的旅程:
- We created a server as a Swift Package executable. 我们将服务器创建为Swift Package可执行文件。
- We used the Vapor web framework to listen to WebSocket connections. 我们使用了Vapor Web框架来侦听WebSocket连接。
- We programmed the server to receive, parse and send payloads to connected clients. 我们对服务器进行了编程,以接收,解析有效载荷并将其发送到连接的客户端。
- We created a basic SwiftUI to send and render messages. 我们创建了一个基本的SwiftUI来发送和呈现消息。
We used the provided WebSocket APIs in Foundation to provide the communication with the server.
我们使用Foundation中提供的WebSocket API来提供与服务器的通信。
All that while completely staying within the Swift ecosystem. No extra programming languages, no Cocoapods or anything.
All that while completely staying within the Swift ecosystem. No extra programming languages, no Cocoapods or anything.
最后的话 (Final words)
Of course, what we created here is only a fraction of a fraction of a complete, production ready chat app and server. We cut a lot of corners to save on time and complexity. Needless to say it should give a pretty basic understanding of how a chat app works.
Of course, what we created here is only a fraction of a fraction of a complete, production ready chat app and server. We cut a lot of corners to save on time and complexity. Needless to say it should give a pretty basic understanding of how a chat app works.
Consider the following features to, perhaps, implement yourself:
Consider the following features to, perhaps, implement yourself:
Multiple channels
Multiple channels
Our server basically accounts for just one channel to chat in. Everyone who connects joins the same party. Modern chat software (e.g. Discord, Slack and Teams) all allow for multiple channels for people to talk in. They even have private chats!
Our server basically accounts for just one channel to chat in. Everyone who connects joins the same party. Modern chat software (eg Discord, Slack and Teams) all allow for multiple channels for people to talk in. They even have private chats!
Respectful autoscroll
Respectful autoscroll
You may have noticed the scrollview now
You may have noticed the scrollview now
always scrolls to the bottom whenever a message is received. This is seriously annoying for users who manually scrolled up to read earlier messages. A respectful chat app only scrolls to the bottom automatically if the scrollview was already at the bottom.
always scrolls to the bottom whenever a message is received. This is seriously annoying for users who manually scrolled up to read earlier messages. A respectful chat app only scrolls to the bottom automatically if the scrollview was already at the bottom .
Splice the amount of messages in memory
Splice the amount of messages in memory
Currently, using the
Currently, using the
ForEach
View, we iterate through every message in memory. Modern chat software only keep track of a handful of messages to render, and only load in older messages once the user scrolls up.ForEach
View, we iterate through every message in memory. Modern chat software only keep track of a handful of messages to render, and only load in older messages once the user scrolls up.Server messages
Server messages
It’s common courtesy to announce your arrival whenever you enter a party. A feature you see in all chat software are server-generated messages announcing people joining or leaving the party.
It's common courtesy to announce your arrival whenever you enter a party. A feature you see in all chat software are server-generated messages announcing people joining or leaving the party.
Final notes (Final notes)
That odd URLSessionWebSocketTask APIIf you’ve ever worked with WebSockets before, you may share the opinion that Apple’s API for WebSocket’s are quite… non-traditional. You’re certainly not alone on this. Having to constantly rebind the receive handler is just odd. If you think you’re more comfortable using a more traditional WebSocket API for iOS and macOS then I would certainly recommend Starscream. It’s well tested, performant and works on older versions of iOS.
That odd URLSessionWebSocketTask API If you've ever worked with WebSockets before, you may share the opinion that Apple's API for WebSocket's are quite… non-traditional. You're certainly not alone on this. Having to constantly rebind the receive handler is just odd. If you think you're more comfortable using a more traditional WebSocket API for iOS and macOS then I would certainly recommend Starscream . It's well tested, performant and works on older versions of iOS.
Bugs bugs bugsThis tutorial was written using Xcode 12 beta 5 and iOS 14 beta 5. Bugs appear and disappear between each new beta version. It is unfortunately impossible to predict what will and what won’t work in future (beta) releases.
Bugs bugs bugs This tutorial was written using Xcode 12 beta 5 and iOS 14 beta 5. Bugs appear and disappear between each new beta version. It is unfortunately impossible to predict what will and what won't work in future (beta) releases.
Appendix A: running on physical device (Appendix A: running on physical device)
The server not only runs on your local machine, it’s only accessible from your local machine. This isn’t a problem when running the app in Simulator (or as macOS app on the same machine). But running the app on a physical device, or on a different Mac, the server will have to be made accessible in your local network.
The server not only runs on your local machine, it's only accessible from your local machine. This isn't a problem when running the app in Simulator (or as macOS app on the same machine). But running the app on a physical device, or on a different Mac, the server will have to be made accessible in your local network.
To do this, in main.swift
of the server code, add the following line directly after initializing the Application
instance:
To do this, in main.swift
of the server code, add the following line directly after initializing the Application
instance:
app.http.server.configuration.hostname = "0.0.0.0"
Now in ChatScreenModel
, in the connect(username:userID:)
method, you need to change the URL to match your machine's local IP:
Now in ChatScreenModel
, in the connect(username:userID:)
method, you need to change the URL to match your machine's local IP:
let url = URL(string: "ws://127.0.0.1:8080/chat")!
//^^this^^^
Your machine’s local IP can be found in various ways. Personally I always just open System Preferences > Network, where the IP is directly shown, ready to be selected and copied.
Your machine's local IP can be found in various ways. Personally I always just open System Preferences > Network , where the IP is directly shown, ready to be selected and copied.
It should be noted that the success rate of this varies between networks. There are a lot of factors (like security) that could prevent this from working.
It should be noted that the success rate of this varies between networks. There are a lot of factors (like security) that could prevent this from working.
Thank you so much for reading! If you have any opinions on this piece, thoughts for improvements, or found some errors, please, please, please let me know! I will do my very best to continuously improve this tutorial.
Thank you so much for reading! If you have any opinions on this piece, thoughts for improvements, or found some errors, please, please, please let me know! I will do my very best to continuously improve this tutorial.
翻译自: https://medium.com/@frzi/a-simple-chat-app-with-swiftui-and-websockets-or-swift-in-the-back-swift-in-the-front-78b34c3dc912
websockets