前言
本文翻译自 Real-Time Communication with Streams Tutorial for 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 语言, 那么这本书确实有点恐怖, 但说不定今天是你面对恐惧的时候呢:]
希望你能享受这篇流教程, 像往常一样, 如果你有任何问题请毫无顾忌的让我知道或者在下方留言~
来源: https://juejin.im/post/5aa2bdc8518825558358d71d