游戲的網(wǎng)絡(luò)模塊其實(shí)有很多可以深挖的技術(shù)灌闺、細(xì)節(jié)供大家討論,本文僅作為筆者的經(jīng)驗(yàn)總結(jié)與記錄坏瞄,對(duì)在Unity中如何開發(fā)一個(gè)快速可用的網(wǎng)絡(luò)模塊進(jìn)行介紹與記錄桂对。我希望能在需要使用時(shí)參考這篇文章就能快速搭建一個(gè)簡(jiǎn)易基礎(chǔ)的網(wǎng)絡(luò)模塊,如果能幫到讀者就算這篇文章有了大作用了鸠匀。
游戲的網(wǎng)絡(luò)通常分為了短鏈接與長(zhǎng)鏈接兩種蕉斜。
短鏈接:以HTTP通訊為基礎(chǔ),由客戶端發(fā)起向服務(wù)器請(qǐng)求數(shù)據(jù)缀棍。
長(zhǎng)鏈接:大多情況下以TCP通訊為基礎(chǔ),在客戶端與服務(wù)器之間構(gòu)建穩(wěn)定鏈接爬范,以便于相互傳輸數(shù)據(jù)父腕;具體通訊協(xié)議依據(jù)需求可以選擇可靠UDP等其他通訊協(xié)議。
在Unity的實(shí)現(xiàn)
注意事項(xiàng)
因?yàn)槲覀兪褂肬nity開發(fā)游戲或應(yīng)用青瀑,都不希望因網(wǎng)絡(luò)發(fā)送或接收數(shù)據(jù)導(dǎo)致界面出現(xiàn)卡頓璧亮,所以常見的在主線程調(diào)用同步發(fā)送方式就不屬于我們的選擇范圍萧诫。
我們需要盡可能采取各種異步的形式,既能夠向服務(wù)器發(fā)送或請(qǐng)求數(shù)據(jù)枝嘶,又不影響主線程的運(yùn)行帘饶。
短鏈接
Unity中實(shí)現(xiàn)短鏈接是非常簡(jiǎn)單的,因?yàn)閁nity內(nèi)部封裝了UnityWebRequest
方法躬络,極大簡(jiǎn)化了我們發(fā)送HTTP請(qǐng)求的復(fù)雜度尖奔。
客戶端通過HTTP向服務(wù)器請(qǐng)求數(shù)據(jù),一般分為GET
與POST
兩種方式穷当。對(duì)應(yīng)這兩種方式,Unity提供了UnityWebRequest.Post
與UnityWebRequest.Get
靜態(tài)方法淹禾,以創(chuàng)建UnityWebRequest
實(shí)例馁菜,或者也可以依據(jù)自身需求,通過調(diào)用UnityWebRequest
的構(gòu)造方法來創(chuàng)建實(shí)例铃岔。
接下來我將著重講述POST
數(shù)據(jù)通過UnityWebRequest
如何實(shí)現(xiàn)(因?yàn)?code>GET參數(shù)外露汪疮,太容易被破解了,所以項(xiàng)目中也很少再使用它)毁习。
首先是UnityWebRequest
的構(gòu)建與數(shù)據(jù)發(fā)送:
UnityWebRequest webRequest = new UnityWebRequest(url, "POST"); // 初始化使用POST的UnityWebRequest智嚷,并附上目標(biāo)地址
webRequest.SetRequestHeader("", ""); // 設(shè)置HTTP的頭信息
// 初始化UnityWebRequest的downloadHandler與uploadHandler
webRequest.downloadHandler = (DownloadHandler) new DownloadHandlerBuffer();
webRequest.uploadHandler = (UploadHandler) new UploadHandlerRaw(data);
webRequest.uploadHandler.contentType = "application/x-www-form-urlencoded";
var asyncOperation = webRequest.SendWebRequest(); // 數(shù)據(jù)發(fā)送
在調(diào)用了UnityWebRequest.SendWebRequest
之后,會(huì)得到一個(gè)UnityWebRequestAsyncOperation
類型的實(shí)例纺且。作為繼承自AsyncOperation
的類盏道,UnityWebRequestAsyncOperation
除了能表示請(qǐng)求的進(jìn)度以及是否結(jié)束外,還附帶有創(chuàng)建了本次請(qǐng)求的UnityWebRequest
的實(shí)例的引用载碌。后續(xù)數(shù)據(jù)的接收方案都基于此猜嘱。
短鏈接的接收方案1——completed回調(diào)
當(dāng)調(diào)用了UnityWebRequest.SendWebRequest
之后,可以對(duì)得到的UnityWebRequestAsyncOperation
類型實(shí)例內(nèi)的completed
事件添加行為嫁艇,以期在收到服務(wù)器返回消息后能夠立即處理接收到的數(shù)據(jù)朗伶。
asyncOperation.completed += (a) =>
{
// 將事件傳入的 AsyncOperation 轉(zhuǎn)換為 UnityWebRequestAsyncOperation,
// 并從webRequest的downloadHandler中獲取下載的數(shù)據(jù)
Do(((UnityWebRequestAsyncOperation)a).webRequest.downloadHandler.data);
};
短鏈接的接收方案2——Update
當(dāng)調(diào)用UnityWebRequest.SendWebRequest
獲取到UnityWebRequestAsyncOperation
類型的實(shí)例后步咪,可以將該實(shí)例加入一個(gè)隊(duì)列论皆。依托網(wǎng)絡(luò)模塊的心跳(Update方法,這里的網(wǎng)絡(luò)模塊指你自己實(shí)現(xiàn)的網(wǎng)絡(luò)架構(gòu)猾漫,如何讓它的Update執(zhí)行起來由你說了算)点晴,在每次心跳時(shí)檢測(cè)隊(duì)列內(nèi)的請(qǐng)求是否完成,并對(duì)完成的請(qǐng)求執(zhí)行后續(xù)操作静袖。
// 存放 UnityWebRequestAsyncOperation 的列表
private List<UnityWebRequestAsyncOperation> _asyncOperations = new List<UnityWebRequestAsyncOperation>();
public void Update()
{
for (int i = 0; i < _asyncOperations.Count; i++)
{
var asyncOperation = _asyncOperations[i];
if (!asyncOperation.isDone)
{
// 請(qǐng)求未完成觉鼻,則略過后續(xù)處理邏輯
continue;
}
// 從webRequest的downloadHandler中獲取下載的數(shù)據(jù)并處理
Do(asyncOperation.webRequest.downloadHandler.data);
asyncOperation.Dispose();
// 移除已處理完的請(qǐng)求
_asyncOperations.RemoveAt(i--);
}
}
需要說明的是,處理完下載數(shù)據(jù)后一定要對(duì)asyncOperation
執(zhí)行Dispose
操作队橙,否則Unity很可能會(huì)有Native數(shù)據(jù)泄露的報(bào)錯(cuò)坠陈。初步判定這是Unity對(duì)UnityWebRequest管理導(dǎo)致的報(bào)錯(cuò)萨惑。
UnityWebRequest的結(jié)果
上述代碼并沒有加入處理網(wǎng)絡(luò)錯(cuò)誤的內(nèi)容。
UnityWebRequest
對(duì)于錯(cuò)誤的處理仇矾,主要是識(shí)別UnityWebRequest.result
的值庸蔼。其本質(zhì)是一個(gè)枚舉,具體如下:
枚舉值 | 含義 | 解釋 |
---|---|---|
InProgress | 請(qǐng)求尚未結(jié)束 | - |
Success | 請(qǐng)求成功 | - |
ConnectionError | 與服務(wù)器通信失敗 | 例如贮匕,請(qǐng)求無(wú)法連接或無(wú)法建立安全通道 |
ProtocolError | 服務(wù)器返回一個(gè)錯(cuò)誤響應(yīng) | 請(qǐng)求成功地與服務(wù)器進(jìn)行了通信姐仅,但收到了連接協(xié)議定義的錯(cuò)誤 |
DataProcessingError | 數(shù)據(jù)處理錯(cuò)誤。 | 請(qǐng)求成功地與服務(wù)器通信刻盐,但在處理接收到的數(shù)據(jù)時(shí)遇到了錯(cuò)誤掏膏。例如,數(shù)據(jù)已損壞或格式不正確 |
長(zhǎng)鏈接
大多數(shù)情況下敦锌,我們游戲開發(fā)的長(zhǎng)鏈接都是建立在TCP協(xié)議之上的馒疹,所以在Unity的實(shí)現(xiàn)中,主要用到的就是Socket
乙墙。
基于異步的需求颖变,我們大體有兩種方案實(shí)現(xiàn):
- 使用C#的
Socket
的Begin
與End
方法實(shí)現(xiàn)連接、發(fā)送听想、接收腥刹,如BeginConnect
/EndConnect
、BeginSend
/EndSend
汉买、BeginReceive
/EndReceive
衔峰; - 利用多線程,開啟單獨(dú)的線程進(jìn)行Socket的連接录别、發(fā)送朽色、接收功能,主線程與網(wǎng)絡(luò)線程之間采用環(huán)形Buffer實(shí)現(xiàn)數(shù)據(jù)交互组题。
方案1——Begin/End方法對(duì)
對(duì)這些方法對(duì)的解釋葫男,建議直接查看MSDN上的解釋:Socket 類 (System.Net.Sockets) | Microsoft Docs。下面著重講數(shù)據(jù)發(fā)送與接收的方案崔列。
數(shù)據(jù)發(fā)送梢褐,因?yàn)椴捎昧水惒椒桨福哉w難度不大赵讯,只是在EndSend
調(diào)用時(shí)需要判斷返回的已發(fā)送的字節(jié)數(shù)是否與BeginSend
發(fā)送的一致盈咳,以及會(huì)否try-catch到異常。一旦發(fā)現(xiàn)數(shù)據(jù)發(fā)送異常(實(shí)際發(fā)送數(shù)量小于開始發(fā)送的數(shù)量边翼,或捕獲到了異常)鱼响,就需要考慮做斷線處理。
public void Send(byte[] data)
{
_socket.BeginSend(data, 0, data.Length, SocketFlags.None, OnSend, _socket);
}
private void OnSend(IAsyncResult result)
{
try
{
Socket socket = result.AsyncState as Socket;
int sendLen = socket.EndSend(result);
// 驗(yàn)證發(fā)送數(shù)據(jù)長(zhǎng)度
}
catch (Exception e)
{
// 關(guān)閉Socket
}
}
數(shù)據(jù)接收的方案有很多组底,我所才用的方案需要與底層數(shù)據(jù)協(xié)議相關(guān)聯(lián)丈积。
底層數(shù)據(jù)協(xié)議:
字節(jié)數(shù) | 4 byte | n byte |
---|---|---|
含義: | 數(shù)據(jù)長(zhǎng)度 | 數(shù)據(jù)體 |
詳情: | 存儲(chǔ)數(shù)據(jù)體的長(zhǎng)度n筐骇,因占用4字節(jié)而存儲(chǔ)為Int型 | 具體的數(shù)據(jù)體,可以是一個(gè)PB協(xié)議序列化后的內(nèi)容江滨,也可以是PB類型id與PB數(shù)據(jù)的組合 |
基于上面的協(xié)議铛纬,就可以給出代碼:
// 數(shù)據(jù)緩存buffer
byte[] buffer = new byte[1024];
void BeginReceive()
{
// 開始接收數(shù)據(jù)長(zhǎng)度
_socket.BeginReceive(buffer, 0, 4, SocketFlags.None, OnReceiveHead, _socket);
}
void OnReceiveHead(IAsyncResult result)
{
try
{
Socket socket = result.AsyncState as Socket;
int sendLen = socket.EndReceive(result);
// 將接收到的數(shù)據(jù)長(zhǎng)度轉(zhuǎn)化為int值
int bodyLen = BitConverter.ToInt32(buffer);
// 開始接收數(shù)據(jù)體
socket.BeginReceive(buffer, 0, bodyLen, SocketFlags.None, OnReceiveHead, _socket);
}
catch (Exception e)
{
// 關(guān)閉Socket
}
}
void OnReceiveBody(IAsyncResult result)
{
try
{
Socket socket = result.AsyncState as Socket;
int sendLen = socket.EndReceive(result);
// 對(duì)接收到的數(shù)據(jù)進(jìn)行處理
BeginReceive(); // 繼續(xù)開始接收下一條數(shù)據(jù)
}
catch (Exception e)
{
// 關(guān)閉Socket
}
}
方案的具體執(zhí)行在上面的代碼中已給出,雖然缺少了很多異常的驗(yàn)證以及拼包的操作唬滑,但是上面的代碼已經(jīng)能夠說明我們方案的思路告唆。
這個(gè)方案就是借助了Socket
的異步操作可以指定接收數(shù)據(jù)長(zhǎng)度的特性,將每次數(shù)據(jù)接收分為了兩部分:
- 接收數(shù)據(jù)的長(zhǎng)度信息
- 依據(jù)已經(jīng)接收到的數(shù)據(jù)長(zhǎng)度信息晶密,繼續(xù)接收指定長(zhǎng)度的數(shù)據(jù)體
這兩部分構(gòu)成了一條完整數(shù)據(jù)的接收流程擒悬,每一步完成后再繼續(xù)執(zhí)行下一步,直到socket主動(dòng)關(guān)閉或收到異常信息(可能是服務(wù)器主動(dòng)斷開連接)稻艰。
方案2——多線程
除了利用Socket
所提供的異步方法茄螃,我們還可以將它的同步方法用在非主線程中。
網(wǎng)絡(luò)上關(guān)于Socket
的同步方法實(shí)現(xiàn)發(fā)送與接收的文章已經(jīng)很多了连锯,這里不做贅述,只是提醒這一部分我們需要仔細(xì)設(shè)計(jì)的是環(huán)形隊(duì)列的部分用狱。
PS:大多數(shù)使用多線程的網(wǎng)絡(luò)方案运怖,主要是為了減輕主線程的計(jì)算、序列化夏伊、反序列化的壓力摇展。一旦要考慮這些,就說明當(dāng)前的使用場(chǎng)景是一個(gè)與服務(wù)器進(jìn)行高頻互動(dòng)的場(chǎng)景溺忧。
環(huán)形隊(duì)列本身可以參考linux中的實(shí)現(xiàn)咏连,只是需要我們依據(jù)項(xiàng)目需求去設(shè)定隊(duì)列的長(zhǎng)度,只有有了合適的長(zhǎng)度才能去使用無(wú)鎖環(huán)形隊(duì)列以幫助我們提升效率鲁森。
發(fā)送祟滴、接收分別采用兩個(gè)環(huán)形隊(duì)列。
因?yàn)槎嗑€程收到的數(shù)據(jù)都會(huì)直接放到接收隊(duì)列中歌溉,所以需要網(wǎng)絡(luò)模塊的心跳介入垄懂,每一幀都去處理接收隊(duì)列中的數(shù)據(jù)(雖然我們可以采用委托的形式,綁定Action以便于收到數(shù)據(jù)后立即執(zhí)行痛垛,但是因?yàn)槎嗑€程中牽扯到主線程內(nèi)Unity相關(guān)的運(yùn)算大多是不支持的草慧,所以就需要在主線程內(nèi)讀取數(shù)據(jù)并處理,最合適的地方就是網(wǎng)絡(luò)模塊的心跳方法)匙头。
C#中socket的異步用法與注意事項(xiàng)
我的項(xiàng)目中的長(zhǎng)鏈接使用了方案1漫谷,所以有一些開發(fā)當(dāng)中遇到的坑,在此記錄并警醒蹂析。
異步操作在多線程內(nèi)執(zhí)行
C#中socket
的Begin
方法的回調(diào)舔示,是在一根單獨(dú)的線程內(nèi)執(zhí)行的碟婆。這就引入了一些與多線程相關(guān)的問題。
比如:當(dāng)我們將異步接收的數(shù)據(jù)放入隊(duì)列斩郎,而Unity又在心跳內(nèi)從隊(duì)列中拿取數(shù)據(jù)脑融,這就牽扯到了多線程對(duì)隊(duì)列資源的爭(zhēng)搶,需要考慮線程安全問題與解決方案缩宜。
再比如:當(dāng)我們?cè)谶B接成功的回調(diào)內(nèi)調(diào)用函數(shù)X
時(shí)肘迎,這個(gè)X
可能也會(huì)在主線程內(nèi)調(diào)用,這時(shí)就凸顯出函數(shù)X
內(nèi)線程安全的重要性锻煌。我們項(xiàng)目中的一個(gè)bug就與此相關(guān)妓布,就是因?yàn)槲覀冊(cè)谶@個(gè)X
函數(shù)中使用了一個(gè)公共的緩存數(shù)組,導(dǎo)致這個(gè)函數(shù)X
被Unity主線程與socket的回調(diào)線程同時(shí)調(diào)用宋梧,出現(xiàn)了意想不到的結(jié)果匣沼。
關(guān)閉socket與異步操作的時(shí)序
當(dāng)關(guān)閉socket時(shí),我們依此調(diào)用了socket的Shutdown
捂龄、Disconnect
释涛、Close
方法,而此時(shí)我們的BeginReceive
還處于阻塞狀態(tài)倦沧。因?yàn)?code>BeginReceive多線程實(shí)現(xiàn)的原因唇撬,我們的Close
方法執(zhí)行過程內(nèi)并不會(huì)立即執(zhí)行BeginReceive
的終止操作,這就導(dǎo)致我們?cè)?code>Close調(diào)用后的邏輯可能會(huì)先于BeginReceive
的中止執(zhí)行展融。也是因?yàn)檫@個(gè)原因窖认,當(dāng)我們?cè)谡{(diào)用Close
之后立即執(zhí)行Socket
的Connect
以期建立一個(gè)新鏈接時(shí),卻受到了鏈接斷開的結(jié)果(因?yàn)槲覀兊拇a中當(dāng)BeginReceive
終止時(shí)執(zhí)行斷開鏈接操作)告希。
以上扑浸,就是我對(duì)于一個(gè)簡(jiǎn)易而又基礎(chǔ)的網(wǎng)絡(luò)模塊的整理與總結(jié)。