前言
本文翻译自Real-Time Communication with Streams Tutorial for iOS
翻译的不对的地方还请多多包涵指正,谢谢~
iOS流式即时通讯教程
从时间初始,人们就已开始梦想着更好地跟遥远的兄弟通讯的方式。从信鸽到无线电波,我们一直在努力将通讯变得更清晰更高效。
在现代中,一种技术已成为我们寻求相互理解的重要的工具:简易网络套接字。
现代网络基础结构的第四层,套接字是任何从文本编辑到游戏在线通讯的核心。
为何是套接字
你可能会奇怪,“为什么不优先使用URLSession
而选择低级API?”。如果你没觉得奇怪,可以假装你觉得......
好问题_ URLSession
通讯是基于HTTP
网络协议。使用HTTP
,通讯是以【请求-响应】方式进行。这意味着在大部分App大多数网络代码都遵循以下模式:
- 从
server
端请求JSON
数据 - 在代理方法内接收并使用
JSON
但当你希望server
告诉App一些事情是怎么办嘞?对于这种事情HTTP
确实处理的不太好。诚然,你可以通过不断请求server
看是否有更新来实现,也叫轮询
,或者你可以更狡猾点使用长轮询
,但这些技术都感觉不那么自然且都有自己的缺陷。最后,为什么要限制自己一定要使用请求-响应的范式如果它不是一个合适的工具嘞?
注:长轮询 ---- 原文没有
长轮询是传统轮旋技术的变种,可以模拟信息从服务端推送到客户端。使用长轮询,客户端像普通的轮询一样请求服务端。但当服务端没有任何信息可以给到服务端时,
server
会持有这个请求等待可用的信息而不是发送一个空信息给客户端。一旦server
有可发送的信息(或者超时),就发送一个响应给客户端。客户端通常会收到信息后立即在请求server
,这样服务基本会一致有一个等待中的用于响应客户端的请求。在web/AJAX
中,长连接被叫做Comet
。长轮询本身并不是一个推送技术,但可以用于在长连接不可能实现的情况下使用。
在这篇流式教程中,你将会学习如何使用套接字直接创建一个实时的聊天应用。
程序中不是每个客户端都去检查服务端是否有更新,而是使用在聊天期间持续存在的输入输出流。
开始~
开始前,下载这个启动包,包含了聊天App和用Go
语言写的server
代码。你不用担心自己需要写Go
代码,只需启动server
用来跟客户端交互。
启动并运行server
server
代码是使用Go
写完的并且已帮你编译好。假如你不相信从网上下载的已编译好的可执行文件,文件夹中有源代码,你可以自己编译。
为了运行已编译好的server
,打开你的终端,切到下载的文件夹并输入以下命令,并接下来输入你的开机密码:
sudo ./server
在你输入完密码后,应该能看到 Listening on 127.0.0.1:80。聊天server
开始运行啦~ 现在你可以调到下个章节了。
假如你想自己编译Go
代码,需要用Homebrew
安装Go
。
没有Homebrew
工具的话,需要先安装它。打开终端,复制如下命令贴到终端。
/usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)
然后,使用如下命令安装Go
:
brew install go
一旦完成安装,切到下载的代码位置并在终端使用如下编译命令:
go build server.go
最终,你可以启动server
,使用上述启动服务器的代码。
瞅瞅现有的App
下一步,打开DogeChat
工程,编译并运行,你会看到已经帮你写好的界面:
如上图所示,DogeChat
已经写好可以允许用户输入名字后进入到聊天室。不幸的是,前一个工程师不知道怎么写聊天App因此他写完了所有的界面和基本的跳转,留下了网络层部分给你。
创建聊天室
在开始编码前,切到 ChatRoomViewController.swift 文件。你可以看到你有了一个界面处理器,它能接收来自输入栏的信息,也可以通过使用Message
对象配置cell的TableView
来展示消息。
既然你已经有了ViewController
,那么你只需要创建一个ChatRoom
来处理繁重的工作。
开始写新类前,我想快速列举下新类的功能。对于它,我们希望能处理这些事情:
- 打开聊天室服务器的连接
- 允许通过提供名字来进入聊天室
- 用户能够收发信息
- 当时完成时关闭连接
现在你知道你该做什么啦,点击Command+N
创建新的文件。选择Cocoa Touch Class
并将它命名为ChatRoom
。
创建输入输出流
现在,继续并替换在文件内的内容如下:
import UIKit
class ChatRoom: NSObject {
//1
var inputStream: InputStream!
var outputStream: OutputStream!
//2
var username = ""
//3
let maxReadLength = 4096
}
这里,你定义了ChatRoom
类,并声明了为使沟通更高效的属性。
- 首先,你有了输入输出流。使用这对类可以让你创建基于app和
server
的套接字。自然地,你会通过输出流来发送消息,输出流接收消息。 - 下一步,你定义了
username
变量用于存储当前用户的名字 - 最后定义了
maxReadLength
。该变量限制你单次发送信息的数据量
然后,切到ChatRoomViewController.swift
并在类的内部商法添加ChatRoom
属性:
let chatRoom = ChatRoom()
目前你已经构建了类的基础结构,是时候开始你之前列举类功能的第一项了---打开server
与App间的连接。
开启连接
返回到ChatRoom.swift
文件在属性定义的下方,加入以下代码:
func setupNetworkCommunication() {
// 1
var readStream: Unmanaged<CFReadStream>?
var writeStream: Unmanaged<CFWriteStream>?
// 2
CFStreamCreatePairWithSocketToHost(kCFAllocatorDefault,
"localhost" as CFString,
80,
&readStream,
&writeStream)
}
这里发生了:
- 第一段,创建了两个未初始化的且不会自动内存管理的套接字流
- 将读写套接字联系起来并将其连上主机的套接字,这里的端口号是80。
这个函数传入四个参数,第一个是你要用来初始化流的分配类型。尽可能地使用kCFAllocatorDefault
,但如果遇到你希望它有不同表现的时候有其他的选项。
下一步,你指定了hostname
。此时你只需要连接本地机器,但如果你有远程服务得指定IP
,你可以在此使用它。
然后,你指定了连接通过80端口,这是在server
端设定的一个端口号。
最后,你传入了读写的流指针,这个方法能使用已连接的内部的读写流来初始化它们。
现在你已获得了出事后的流,你可以通过添加以下两行代码存储它们的引用:
inputStream = readStream!.takeRetainedValue()
outputStream = writeStream!.takeRetainedValue()
在不受管理的对象上调用takeRetainedValue()
可以让你同步获得一个保留的引用并且消除不平衡的保留(an unbalanced retain),因此之后内存不会泄露。现在当你需要流时你可以使用它们啦。
下一步,为了让app能够合理地响应网络事件,这些流需要添加进runloop
内。在setupNetworkCommunication
函数内部最后添加以下两行代码:
inputStream.schedule(in: .current, forMode: .commonModes)
outputStream.schedule(in: .current, forMode: .commonModes)
你已经准备好打开“洪流之门”了~ 开始吧,添加以下代码(还在setupNetworkCommunication
函数内部最后):
inputStream.open()
outputStream.open()
这就是全部啦。我们回到ChatRoomViewController.swift
类,在viewWillAppear
函数内添加如下代码:
chatRoom.setupNetworkCommunication()
在本地服务器上,现在你已打开了客户端和服务端连接。再次编译运行代码,将会看到跟你写代码之前一模一样的界面。
参与聊天
现在你已连上了服务端,是时候发一些消息了~ 第一件事情你可能会说我到底是谁。之后,你也希望开始发送信息给其他人了。
这里提出了一个重要的问题:因为你有两种消息,需要想个办法来区分他们。
通信协议
降到TCP
层好处之一是你可以定义自己的协议来决定一个信息的有效与否。对于HTTP
,你需要想到这些烦人的动作:Get
,PUT
和PATCH
。需要构造URL
并使用合适的头部和各种各样的事情。
这里我们之后两种信息,你可以发送:
iam:Luke
来进入聊天室并通知世界你的名字。你可以说:
msg:Hey, how goes it mang?
来发送一个消息给任何一个在聊天室的人。
这样纯粹且简单。
这样显然不安全,因此不要在工作中使用它。
你知道了服务器的期望格式,可以在ChatRoom
写一个方法来进入聊天室了。仅有的参数就是名字了。
为实现它,添加如下方法到刚添加的方法后面:
funcfunc joinChatjoinChat(username: String)(username: String) {
{ //1//1
letlet data = data = "iam:"iam:\(username)\(username)"".data(using: .ascii)!
.data(using: .ascii)! //2//2
selfself.username = username
.username = username //3//3
__ = data.withUnsafeBytes { outputStream.write($ = data.withUnsafeBytes { outputStream.write($00, maxLength: data., maxLength: data.countcount) }
}) } }
- 首先,使用简单的聊天协议构造了消息
- 然后,保存了刚传进来的名字,之后可以在发送消息的时候使用它
- 最后,将消息写入输出流。这比你预想的要复杂一些,
write(_:maxLength:)
方法将一个不安全的指针引用作为第一个参数。withUnsafeBytes(of:_:)
方法提供一个非常便利的方式在闭包的安全范围内处理一些数据的不安全指针。
方法已就绪,回到ChatRoomViewController.swift
并在viewWillAppear(_:)
方法内最后添加进入聊天室的方法调用。
chatRoom.joinChat(username: username)
现在编译并运行,输入名字进入界面看看:
同样什么也没发生?
稍等,我来解释下~ 去看看终端程序。就在 Listening on 127.0.0.1:80
下方,你会看到 Luke has joined,或如果你的名字不是Luke的话就是其他的内容。
这是个好消息,但你肯定更希望看到在手机屏幕上成功的迹象。
响应即将来临的消息
幸运的是,服务器接收的消息就像你刚刚发送的一样,并且发送给在聊天的每个人,包括你自己。更幸运的是,app本就已可在ChatRoomViewController
的表格界面上展示即将要来的消息。
所有你要做的就是使用inputStream
来捕捉这些消息,将其转换成Message
对象,并将它传出去让表格做显示。
为响应消息,第一个需要做的事情是让ChatRoom
成为输入流的代理。首先,到ChatRoom.swift
最底部添加以下扩展:
extension ChatRoom: StreamDelegate {
}
现在ChatRoom
已经采用了StreamDelegate
协议,可以申明为inputStream
的代理了。
添加以下代码到setupNetworkCommunication()
方法内,并且刚好在schedule(_:forMode:)
方法之前。
inputStream.delegate = self
下一步,在扩展中添加stream(_:handle:)
的实现:
func stream(_ aStream: Stream, handle eventCode: Stream.Event) {
switch eventCode {
case Stream.Event.hasBytesAvailable:
print("new message received")
case Stream.Event.endEncountered:
print("new message received")
case Stream.Event.errorOccurred:
print("error occurred")
case Stream.Event.hasSpaceAvailable:
print("has space available")
default:
print("some other event...")
break
}
}
这里你处理了即将来的可能在流上会发生的事件。你最感兴趣的一个应该是Stream.Event.hasBytesAvailable
,因为这意味着有消息需要你读~
下一步,写一个处理即将来的消息的方法。在下面方法下添加:
private func readAvailableBytes(stream: InputStream) {
//1
let buffer = UnsafeMutablePointer<UInt8>.allocate(capacity: maxReadLength)
//2
while stream.hasBytesAvailable {
//3
let numberOfBytesRead = inputStream.read(buffer, maxLength: maxReadLength)
//4
if numberOfBytesRead < 0 {
if let _ = stream.streamError {
break
}
}
//Construct the Message object
}
}
- 首先,创建一个缓冲区,可以用来读取消息字节
- 下一步,一直循环到输入流没有字节读取了为止
- 在每一步循环中,调用
read(_:maxLength:)
方法读取流中的字节并将它放入传进来的缓冲区中 - 如果读取的字节数小于0,说明错误发生并退出
该方法需要在输入流有字节可用的时候调用,因此在stream(_:handle:)
内的Stream.Event.hasBytesAvailable
中调用这个方法:
readAvailableBytes(stream: aStream as! InputStream)
此时,你获得了一个充满字节的缓冲区!在完成这个方法前,你需要写另一个辅助方法将缓冲区编程Message
对象。
将如下代码放到readAvailableBytes(_:)
后面:
private func processedMessageString(buffer: UnsafeMutablePointer<UInt8>,
length: Int) -> Message? {
//1
guard let stringArray = String(bytesNoCopy: buffer,
length: length,
encoding: .ascii,
freeWhenDone: true)?.components(separatedBy: ":"),
let name = stringArray.first,
let message = stringArray.last else {
return nil
}
//2
let messageSender:MessageSender = (name == self.username) ? .ourself : .someoneElse
//3
return Message(message: message, messageSender: messageSender, username: name)
}
- 首先,使用缓冲区和长度初始化一个
String
对象。设置该对象是ASCII
编码,并告诉对象在使用完缓冲区的时候释放它,并使用:
符号来分割消息,因此你就可以分别获得名字和消息。 - 下一步,你知道你或者其他人基于名字发送了一个消息。在真是的app中,可能会希望用一个独特的令牌来区分不同的人,但在这里这样就可以了。
- 最后,使用刚才获得的字符串构造
Message
对象并返回
在readAvailableBytes(_:)
方法的最后添加以下if-let
代码来使用构造Message
的方法:
if let message = processedMessageString(buffer: buffer, length: numberOfBytesRead) {
//Notify interested parties
}
此时,你已准备将Message
发送给某人了,但是谁呢?
创建ChatRoomDelegate
协议
OK,你肯定希望告诉ChatRoomViewController.swift
新的消息来了,但你并没有它的引用。因为它持有了ChatRoom
的强引用,你不希望显示地申明一个ChatRoomViewController
属性来创建引用循环。
这是使用代理协议的绝佳时刻。ChatRoom
不关系哪个对象想知道新消息,它就是负责告诉某人就好。
在ChatRoom.swift
的顶部,添加下面简单的协议定义:
protocol ChatRoomDelegate: class {
func receivedMessage(message: Message)
}
下一步,添加weak
可选属性来保留一个任何想成为ChatRoom
代理的对象引用。
weak var delegate: ChatRoomDelegate?
现在,回到readAvailableBytes(_:)
方法并在if-let
内添加下面的代码:
delegate?.receivedMessage(message: message)
为完成它,回到ChatRoomViewController.swift
并在MessageInputDelegate
代理扩展下面添加对ChatRoomDelegate
的扩展
extension ChatRoomViewController: ChatRoomDelegate {
func receivedMessage(message: Message) {
insertNewMessageCell(message)
}
}
就像我之前说的,其余的工作都已经帮你做好了,insertNewMessageCell(_:)
方法会接收你的消息并妥善地添加合适的cell
到表格上。
现在,在viewWillAppear(_:)
内调用它的super
代码后将界面控制器设置为ChatRoom
的代理。
chatRoom.delegate = self
再一次编译运行,输入你的名字进入到聊天页面:
聊天室现在成功展示了一个表明你进入聊天室的cell
。你正式地发送了一条消息并接收了来自基于套接字TCP
服务器的消息。
发送消息
是时候允许用户发送真正的文本消息啦~
回到ChatRoom.swift
并在类定义的底部添加如下代码:
func sendMessage(message: String) {
let data = "msg:\(message)".data(using: .ascii)!
_ = data.withUnsafeBytes { outputStream.write($0, maxLength: data.count) }
}
该方法就像之前写的joinChat(_:)
方法,将你发送的msg
转成作为真正消息的文本。
因为你希望在inputBar
告诉ChatRoomViewController
用户已点击Send
按钮时发送消息,回到ChatRoomViewController.swift
并找到MessageInputDelegate
的扩展。
这里,你会找到一个叫sendWasTapped(_:)
的空方法。为了真正来发送消息,直接就将它传给chatRoom
。
chatRoom.sendMessage(message: message)
这就是发送功能的全部啦~ server
将会收到消息并将其转发给任何人,ChatRoom
将会与以加入房间的方式被通知到消息。
再次运行并发送消息:
若你想看到别人在这里聊天,打开一个新的终端,并输入:
telnet localhost 80
这样允许你用命令行的方式连接到TCP
服务器。现在那里可以发送跟app相同的命令:
iam:gregg
然后,发送一条消息:
msg:Ay mang, wut's good?
恭喜你,已成功创建了聊天客户端~
清理工作
如果你之前有写过任何关于文件的编程,你应该知道当文件使用完时的良好习惯。事实证明,像在Unix
中的任何其他事情一样,开着的套接字连接是使用文件句柄来表示的,这意味着像其他文件一样,在使用完毕后,你需要关闭它。
在sendMessage(_:)
方法后面添加如下方法
func stopChatSession() {
inputStream.close()
outputStream.close()
}
你可能已猜到,该方法会关闭流并使得消息不能被接收或者发送出去。这也会将流从之前添加的runloop中移除掉。
为最终完成它,在Stream.Event.endEncountered
代码分支下添加调用该方法的代码:
stopChatSession()
然后,回到ChatRoomViewController.swift
并在viewWillDisappear(_:)
内也添加上述代码。
这样,就大功告成了~
何去何从
想下完整代码,请点击这里
目前你已经掌握(至少是看过一个简单的例子)关于套接字网络的基础,还有几种方法来扩展你的眼界。
UDP 套接字
本教程是关于TCP
通讯的例子,TCP
会建立一个连接并尽可能保证数据包可达。作为选择,你可以使用UDP
,或者数据包套接字通讯。这些套接字并没有如此的传输保证,这意味着他们更加快速且更小的开销。在游戏领域他们很实用。体验过延迟吗?那样意味着你遇到了糟糕的连接,许多应该收到的包被丢弃了。
WebSockets
另一种想这样给应用使用HTTP
的技术叫WebSockets。不像传统的TCP
套接字,WebSockets
至少保持与HTTP的关系,并且可以用于实现与传统套接字相同的实时通信目标,所有这一切都来自浏览器的舒适性和安全性。当然WebSockets
也可以在iOS上使用,我们刚好有这篇教程如果你想学习更多内容的话。
Beej的网络编程指南
最后,如果你真的想深入了解网络,看看免费的在线书籍--Beej的网络编程指南。抛开奇怪的昵称,这本书提供了非常详尽且写的很好的套接字编程。如果你害怕C语言,那么这本书确实有点“恐怖”,但说不定今天是你面对恐惧的时候呢:]
希望你能享受这篇流教程,像往常一样,如果你有任何问题请毫无顾忌的让我知道或者在下方留言~