内容字号:默认大号超大号

段落设置:段首缩进取消段首缩进

字体设置:切换到微软雅黑切换到宋体

iOS流式即时通讯教程

2018-03-12 17:11 出处:清屏网 人气: 评论(0

从时间初始,人们就已开始梦想着更好地跟遥远的兄弟通讯的方式。从信鸽到无线电波,我们一直在努力将通讯变得更清晰更高效。

在现代中,一种技术已成为我们寻求相互理解的重要的工具:简易网络套接字。

现代网络基础结构的第四层,套接字是任何从文本编辑到游戏在线通讯的核心。

为何是套接字

你可能会奇怪,“为什么不优先使用 URLSession 而选择低级API?”。如果你没觉得奇怪,可以假装你觉得......

好问题^_^ URLSession 通讯是基于 HTTP 网络协议。使用 HTTP ,通讯是以【请求-响应】方式进行。这意味着在大部分App大多数网络代码都遵循以下模式:

  1. server 端请求 JSON 数据
  2. 在代理方法内接收并使用 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 来处理繁重的工作。

开始写新类前,我想快速列举下新类的功能。对于它,我们希望能处理这些事情:

  1. 打开聊天室服务器的连接
  2. 允许通过提供名字来进入聊天室
  3. 用户能够收发信息
  4. 当时完成时关闭连接

现在你知道你该做什么啦,点击 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 类,并声明了为使沟通更高效的属性。

  1. 首先,你有了输入输出流。使用这对类可以让你创建基于app和 server 的套接字。自然地,你会通过输出流来发送消息,输出流接收消息。
  2. 下一步,你定义了 username 变量用于存储当前用户的名字
  3. 最后定义了 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)
}

这里发生了:

  1. 第一段,创建了两个未初始化的且不会自动内存管理的套接字流
  2. 将读写套接字联系起来并将其连上主机的套接字,这里的端口号是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 ,你需要想到这些烦人的动作: GetPUTPATCH 。需要构造 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) }
}) } }
  1. 首先,使用简单的聊天协议构造了消息
  2. 然后,保存了刚传进来的名字,之后可以在发送消息的时候使用它
  3. 最后,将消息写入输出流。这比你预想的要复杂一些, 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
    
  }
}
  1. 首先,创建一个缓冲区,可以用来读取消息字节
  2. 下一步,一直循环到输入流没有字节读取了为止
  3. 在每一步循环中,调用 read(_:maxLength:) 方法读取流中的字节并将它放入传进来的缓冲区中
  4. 如果读取的字节数小于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)
}
  1. 首先,使用缓冲区和长度初始化一个 String 对象。设置该对象是 ASCII 编码,并告诉对象在使用完缓冲区的时候释放它,并使用 : 符号来分割消息,因此你就可以分别获得名字和消息。
  2. 下一步,你知道你或者其他人基于名字发送了一个消息。在真是的app中,可能会希望用一个独特的令牌来区分不同的人,但在这里这样就可以了。
  3. 最后,使用刚才获得的字符串构造 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语言,那么这本书确实有点“恐怖”,但说不定今天是你面对恐惧的时候呢:]

希望你能享受这篇流教程,像往常一样,如果你有任何问题请毫无顾忌的让我知道或者在下方留言~

分享给小伙伴们:
本文标签: 即时通讯iOS

相关文章

发表评论愿您的每句评论,都能给大家的生活添色彩,带来共鸣,带来思索,带来快乐。

CopyRight © 2015-2016 QingPingShan.com , All Rights Reserved.

清屏网 版权所有 豫ICP备15026204号