多點(diǎn)互聯(lián) (Multipeer Connectivity,即 MPC) 是在 2013 年的 WDCC 中提出的,期間做過(guò)不少宣傳,但是卻很少有案例能夠成功有效地使用它。接下來(lái),就讓我們來(lái)看一看如何正確使用 MPC,尤其是在游戲中的應(yīng)用。
多點(diǎn)互聯(lián)是蘋(píng)果的一個(gè)傳輸無(wú)關(guān)的網(wǎng)絡(luò)框架,提供網(wǎng)絡(luò)的發(fā)現(xiàn)、創(chuàng)建和通信功能。可以說(shuō)它是 Bonjour 的精神傳承者, Bonjour 可以在 LAN 和 Wi-Fi 的網(wǎng)絡(luò)下高效地識(shí)別設(shè)備。
MPC 的關(guān)鍵用途在于創(chuàng)建臨時(shí)網(wǎng)絡(luò)中的點(diǎn)對(duì)點(diǎn)連接,而不需要考慮天氣、無(wú)線、藍(lán)牙等各種因素,只需要有個(gè)人網(wǎng)絡(luò)就行。一旦創(chuàng)建之后,各個(gè)節(jié)點(diǎn)可以安全地共享消息、數(shù)據(jù)和文件資源。
絕大部分 MPC 的功能在更高層的 GameKit 框架中都可以找到。使用 GameKit 可以讓開(kāi)發(fā)者接觸到有用的游戲概念,抽離底層的網(wǎng)絡(luò)協(xié)議。
大部分的游戲都更適合用 GameKit 開(kāi)發(fā),它有很多直接使用 MPC 實(shí)現(xiàn)的游戲相關(guān)的封裝。不過(guò)作為 MPC 的進(jìn)階手冊(cè),本文主要涉及 MPC 的各種使用技巧。
當(dāng)你的游戲或應(yīng)用需要在近距離的多臺(tái)設(shè)備中進(jìn)行連接的時(shí)候, MPC 可以大幅提高用戶體驗(yàn)。不論你是想要建立一個(gè)遠(yuǎn)程控制還是多人游戲, MPC 都可以幫助你減少用戶使用過(guò)程中的阻力,減少服務(wù)器的開(kāi)銷,甚至可以減少網(wǎng)絡(luò)延時(shí)等問(wèn)題。
比如一個(gè)遠(yuǎn)程控制的應(yīng)用,如果它不需要用戶進(jìn)行任何設(shè)置,而是在安裝后立即自動(dòng)連接到被控制端上,那么應(yīng)用的品質(zhì)會(huì)得到很大的提升。不論這個(gè)遠(yuǎn)程控制針對(duì)的是游戲、軟件展示、音頻播放還是其他東西,都是這樣。DeckRocket 就是一個(gè)很好的開(kāi)源的例子,它是一個(gè)用來(lái)遠(yuǎn)程遙控 DeckSet 幻燈片的 iOS 應(yīng)用。
多用戶游戲也可以從 MPC 的零配置和離線連接特性中受益。比如一個(gè)包含游戲邏輯、規(guī)則和存檔功能的卡牌類游戲,可以在不聯(lián)網(wǎng)的狀態(tài)下讓任意兩名玩家進(jìn)行即時(shí)對(duì)戰(zhàn)。在這篇文章里,我們將會(huì)從 CardsAgainst 這個(gè)真實(shí)的應(yīng)用中選取一些例子進(jìn)行說(shuō)明。 CardsAgainst 是著名游戲 Cards Against Humanity 的開(kāi)源 iOS 版本,完整的項(xiàng)目源代碼可以在 Github 獲取。
本文中的其他示例則選自 PeerKit,一個(gè) Github 上的開(kāi)源框架,用來(lái)構(gòu)建事件驅(qū)動(dòng)且無(wú)需配置的 MPC 應(yīng)用。
有很多種方法可以把 MPC 的設(shè)備偵測(cè)概念整合到應(yīng)用中。接下來(lái)我們將介紹三種廣泛使用的設(shè)計(jì)模式。
蘋(píng)果提供了一個(gè)內(nèi)置的 ViewController ,可以很方便地進(jìn)行匹配和初始化連接。只需要設(shè)置好 serviceType 和 session 并且彈出一個(gè) MCBrowserViewController 即可,MPC 會(huì)幫你做好剩下的事情。注意,serviceType 最多是 15 位 ASCII 字符。使用方法通常像逆向的 DNS 標(biāo)記一樣 (例如: io-objc-mpc):
let session = MCSession(peer: MCPeerID(displayName: "Mary"))
let serviceType = "io-objc-mpc" // 最多 15 ASCII 字符
window!.rootViewController = MCBrowserViewController(serviceType: serviceType, session: session)
http://wiki.jikexueyuan.com/project/objc/images/18-9.png" alt="" />
不過(guò),我們無(wú)法輕易地對(duì) MCBrowserViewController 進(jìn)行自定義,而你有可能想設(shè)置自己的匹配原則,那么請(qǐng)移步下面的章節(jié)。
如果你的游戲的匹配機(jī)制是先選取一個(gè)主節(jié)點(diǎn)來(lái)協(xié)調(diào)游戲邏輯,然后其他次節(jié)點(diǎn)和主節(jié)點(diǎn)進(jìn)行連接,那么你應(yīng)該充分利用這些信息,只需要從主節(jié)點(diǎn)進(jìn)行公示,然后次節(jié)點(diǎn)進(jìn)行瀏覽即可:
http://wiki.jikexueyuan.com/project/objc/images/18-10.gif" alt="" />
// 主節(jié)點(diǎn)公示
advertiser = MCNearbyServiceAdvertiser(peer: myPeerID, discoveryInfo: discoveryInfo, serviceType: serviceType)
advertiser.delegate = self
advertiser.startAdvertisingPeer()
// 次節(jié)點(diǎn)瀏覽
mcBrowser = MCNearbyServiceBrowser(peer: myPeerID, serviceType: serviceType)
mcBrowser.delegate = self
mcBrowser.startBrowsingForPeers()
但是,總是有那么一些情況,最好能在應(yīng)用運(yùn)行之前就建立好連接,而不用用戶進(jìn)行任何操作。下面的章節(jié)就展示了如何實(shí)現(xiàn)這樣的功能。
MPC 能夠極大地減少用戶體驗(yàn)的阻力。當(dāng)你以正確的方式把它整合到應(yīng)用中時(shí),你的用戶可以在安裝應(yīng)用之后立即開(kāi)始通信,而不用任何配置。這會(huì)是一件大快所有人心的大好事。
http://wiki.jikexueyuan.com/project/objc/images/18-11.gif" alt="" />
為了實(shí)現(xiàn)這個(gè)功能,我們需要同時(shí)對(duì)會(huì)話進(jìn)行公示和查看,我們把這種行為稱之為 收發(fā) (transceiving = transmitting and receiving)。
在多節(jié)點(diǎn)進(jìn)行收發(fā)的時(shí)候,競(jìng)爭(zhēng)問(wèn)題是一個(gè)重大的挑戰(zhàn),因?yàn)榭赡軙?huì)有很多節(jié)點(diǎn)同時(shí)嘗試連接彼此。這便是領(lǐng)袖選舉 (Leader Election) 問(wèn)題,這個(gè)問(wèn)題已經(jīng)被深入地討論和研究,并且有一些很好地解決方案。
下面介紹一種簡(jiǎn)單而有效的方法。在邀請(qǐng)其他節(jié)點(diǎn)加入會(huì)話的時(shí)候,將每個(gè)節(jié)點(diǎn)的運(yùn)行時(shí)間包含到元數(shù)據(jù) (metadata) 里,公示的節(jié)點(diǎn)總是加入到最早的會(huì)話中:
// 瀏覽者的委托代碼
func browser(browser: MCNearbyServiceBrowser!, foundPeer peerID: MCPeerID!, withDiscoveryInfo info: [NSObject : AnyObject]!) {
var runningTime = -timeStarted.timeIntervalSinceNow
let context = NSData(bytes: &runningTime, length: sizeof(NSTimeInterval))
browser.invitePeer(peerID, toSession: mcSession, withContext: context, timeout: 30)
}
// 公示者的委托代碼
func advertiser(advertiser: MCNearbyServiceAdvertiser!, didReceiveInvitationFromPeer peerID: MCPeerID!, withContext context: NSData!, invitationHandler: ((Bool, MCSession!) -> Void)!) {
var runningTime = -timeStarted.timeIntervalSinceNow
var peerRunningTime = NSTimeInterval()
context.getBytes(&peerRunningTime)
let isPeerOlder = (peerRunningTime > runningTime)
invitationHandler(isPeerOlder, mcSession)
if isPeerOlder {
advertiser.stopAdvertisingPeer()
}
}
MPC 提供了幾種發(fā)送和接收數(shù)據(jù)的方式,每種方式都有自己獨(dú)有的特點(diǎn)和取舍。
當(dāng)發(fā)送少量事件驅(qū)動(dòng)的數(shù)據(jù) (最多幾 kb) 的時(shí)候,比如游戲事件 (開(kāi)始/暫停/退出),使用這個(gè)方法:sendData(_:toPeers:withMode:error:)。
為了封裝傳輸?shù)臄?shù)據(jù),CardsAgainst 定義了一個(gè)游戲事件的枚舉類型,在接下來(lái)對(duì)隨行的數(shù)據(jù)進(jìn)行序列化和反序列化的時(shí)候也會(huì)用到:
// 所有的游戲事件
enum Event: String {
case StartGame = "StartGame",
Answer = "Answer",
CancelAnswer = "CancelAnswer",
Vote = "Vote",
NextCard = "NextCard",
EndGame = "EndGame"
}
// 可靠地 (使用 .Reliable 模式) 向節(jié)點(diǎn)發(fā)送事件,有可能有隨行數(shù)據(jù)
func sendEvent(event: Event, object: AnyObject? = nil, toPeers peers: [MCPeerID] = session.connectedPeers as [MCPeerID]) {
if peers.count == 0 {
return
}
var rootObject: [String: AnyObject] = ["event": event.rawValue]
if let object = object {
rootObject["object"] = object
}
let data = NSKeyedArchiver.archivedDataWithRootObject(rootObject)
session.sendData(data, toPeers: peers, withMode: .Reliable, error: nil)
}
// 使用例
sendEvent(.StartGame, ["initialData": "hello objc.io!"])
具體內(nèi)容可以參考 ConnectionManager.swift 的源代碼。
就像是 TCP/UDP 一樣,MPC 有可靠傳輸和不可靠傳輸兩種模式。MCSessionSendDataMode 包含了這兩種模式。
如果要在可靠模式 (.Reliable) 下發(fā)送數(shù)據(jù):
let message = "Hello objc.io!"
let data = message.dataUsingEncoding(NSUTF8StringEncoding)!
var error: NSError? = nil
if !session.sendData(data, toPeers: peers, withMode: .Reliable, error: &error) {
println("error: \(error!)")
}
如果你發(fā)送的數(shù)據(jù)十分關(guān)鍵,直接關(guān)系到你的游戲能否正常運(yùn)行,比如開(kāi)始或者暫停游戲,使用可靠模式 (.Reliable):
如果與準(zhǔn)確性和有序性相比,速度的優(yōu)先級(jí)更高,比如發(fā)送傳感器的數(shù)據(jù),那么不可靠模式 (.Unreliable) 可能更適合。務(wù)必權(quán)衡利弊,在考慮好流的情況下,選擇最適合你的方案。
當(dāng)你發(fā)送大量數(shù)據(jù) (幾百 kB 甚至幾 MB) 的時(shí)候,比如文件,應(yīng)該使用 sendResourceAtURL(_:withName:toPeer:withCompletionHandler:) 方法。它可以通過(guò) NSProgress 對(duì)象讓發(fā)送方和接收方同時(shí)監(jiān)控傳輸進(jìn)度。
這是 DeckRocket 中的例子:
pdfProgress = session!.sendResourceAtURL(url, withName: filePath.lastPathComponent, toPeer: peer) { error in
dispatch_async(dispatch_get_main_queue()) {
self.pdfProgress!.removeObserver(self, forKeyPath: "fractionCompleted", context: &ProgressContext)
if error != nil {
HUDView.show("Error!\n\(error.localizedDescription)")
} else {
HUDView.show("Success!")
}
}
}
pdfProgress!.addObserver(self, forKeyPath: "fractionCompleted", options: .New, context: &ProgressContext)
對(duì)于流數(shù)據(jù),比如傳感器的讀數(shù)或者持續(xù)更新的用戶坐標(biāo)信息等等,可以使用 startStreamWithName(_:toPeer:error:) 方法把數(shù)據(jù)寫(xiě)到 NSOutputStream 中。接收者則通過(guò) NSInputStream 讀取數(shù)據(jù)流:
// 接收者
public func session(session: MCSession!, didReceiveStream stream: NSInputStream!, withName streamName: String!, fromPeer peerID: MCPeerID!) {
// 假設(shè)是一個(gè) UInt8 的流
var buffer = [UInt8](count: 8, repeatedValue: 0)
stream.open()
// 讀取單個(gè)字節(jié)
if stream.hasBytesAvailable {
let result: Int = stream.read(&buffer, maxLength: buffer.count)
println("result: \(result)")
}
}
雖然 MPC 很強(qiáng)大,但同時(shí)也面臨不少挑戰(zhàn)。下面列舉一下你可能會(huì)遇到的問(wèn)題。
MPC 只能用于 iOS 7、iOS 8 和 OS X 10.10 ,所以如果不是蘋(píng)果的設(shè)備,或者不是最新的 OS X 發(fā)行版的話,那么請(qǐng)忘了 MPC 吧。跨平臺(tái)的應(yīng)用或者游戲需要依賴別的替代品。
盡管在 iOS 7 之后蘋(píng)果對(duì) MPC 的可靠性做了很大的提升,可靠性依舊是 MPC 的痛處。不得不考慮到連接失敗的情況,而且為了盡可能覆蓋很多邊界情況,還需要做不少額外的功課。
撇開(kāi)因無(wú)線連接的損耗所導(dǎo)致的網(wǎng)絡(luò)延時(shí)不談,編寫(xiě)即時(shí)型網(wǎng)絡(luò)的代碼有點(diǎn)像是寫(xiě)本地的多線程代碼。在假設(shè)事件發(fā)送成功之前,務(wù)必在合適的位置對(duì)關(guān)鍵傳輸加鎖,從而確保所有節(jié)點(diǎn)確認(rèn)接收關(guān)鍵事件。
游戲常常需要共享狀態(tài),比如游戲是否開(kāi)始或暫停,玩家是否退出等等。如果玩家在對(duì)手即將發(fā)動(dòng)致命一擊的時(shí)候暫停游戲了會(huì)怎么樣? MPC 將異步的游戲邏輯競(jìng)爭(zhēng)留給開(kāi)發(fā)者來(lái)決定。使用 GameKit 這樣的框架對(duì)集中邏輯很有幫助,但是同時(shí)也犧牲了一些靈活性作為代價(jià)。
用 MPC 來(lái)寫(xiě)一個(gè)復(fù)雜的游戲無(wú)疑充滿了挑戰(zhàn)性。你可以了解一下其他選擇再做決定。
蘋(píng)果在 GameKit 中投入了很多想法。盡管它強(qiáng)制要求使用指定的模型和結(jié)構(gòu)模式,并且還需要放棄會(huì)話連接過(guò)程中的一些控制,但是它確實(shí)抽離了很多底層的工作,減輕了工作量。
用 GameKit 開(kāi)發(fā)游戲可以同時(shí)滿足點(diǎn)對(duì)點(diǎn)模式和傳統(tǒng)網(wǎng)絡(luò)連接模式的需求。
WebSocket 協(xié)議 (RFC 6455) 允許服務(wù)器端和客戶端之間進(jìn)行雙向通信。每個(gè)節(jié)點(diǎn)需要建立一個(gè)新的 websocket 連接。該協(xié)議建立在 TCP 的基礎(chǔ)上,所以不提供類似 MPC 的 .Unreliable 信息發(fā)送模式。不像 MPC,websocket 不提供任何網(wǎng)絡(luò)創(chuàng)建或者設(shè)備檢測(cè)功能,所以服務(wù)器端和客戶端都必須連接在同一個(gè)網(wǎng)絡(luò)上。它常用于和 Bonjour 關(guān)聯(lián)使用。
如果是構(gòu)建跨平臺(tái)游戲或應(yīng)用,那么 WebSocket 可以說(shuō)是極具吸引力的,不過(guò)它需要一個(gè)有自定義后臺(tái)的連接。
目前Swift (starscream) 和 Objective-C (SocketRocket、jetfire) 都有不少現(xiàn)成的 WebSocket 類庫(kù)可供使用。
把 MPC 整合到你的游戲或者應(yīng)用中的過(guò)程不會(huì)很復(fù)雜,但是卻能極大的提升用戶體驗(yàn),希望讀完本文你也認(rèn)同此觀點(diǎn)。
如果想了解關(guān)于 MPC 的更多內(nèi)容,下面的資料可能會(huì)有所幫助。
Multipeer Connectivity Reference
Multipeer Connectivity WWDC 2013 Session
NSHipster Article on Multipeer Connectivity
PeerKit: An open-source Swift framework for building event-driven, zero-config MPC apps
CardsAgainst: An open-source iOS game built with MPC
DeckRocket: An open-source presentation remote control app for iOS/OSX built with MPC