
寫在最前
本故事是《How Can Unity+騰訊云開發(fā)=微信小游戲?》的續(xù)篇,主要聊的是在使用 Unity 開發(fā)微信小游戲過(guò)程中,如何使用云開發(fā)來(lái)給小游戲增添一抹實(shí)時(shí)互動(dòng)的亮色(比如實(shí)時(shí)聊天)
溫馨提示:各家的云開發(fā)功能各具特色,本文的云開發(fā)特指騰訊云云開發(fā)
云開發(fā),哪個(gè)服務(wù)可實(shí)現(xiàn)實(shí)時(shí)聊天?
丹尼爾:蛋兄,我又來(lái)了。上次跟你聊完(請(qǐng)看上集《How Can Unity+騰訊云開發(fā)=微信小游戲?》)后,我已經(jīng)在 Unity 微信小游戲中用上云開發(fā)的數(shù)據(jù)模型了,云函數(shù)也順手捎上了
蛋先生:不錯(cuò),挺速度的嘛
丹尼爾:這些一來(lái)一回的后端接口,使用數(shù)據(jù)模型和云函數(shù),唰唰唰一下就搞定了,別提多爽
蛋先生:是的,對(duì)于后端接口的搭建,這些服務(wù)確實(shí)可以大大簡(jiǎn)化你的工作,讓你聚焦你的業(yè)務(wù)
丹尼爾:但我現(xiàn)在又遇到問(wèn)題了
蛋先生:我就知道,無(wú)事不登三寶殿
丹尼爾:瞧您說(shuō)的,主要是來(lái)看看您,順便問(wèn)下問(wèn)題啦 (′▽`〃)
蛋先生:直說(shuō)吧,啥問(wèn)題
丹尼爾:我的小游戲里,玩家之間是可以聊天的,但我沒發(fā)現(xiàn)云開發(fā)有 WebSocket 相關(guān)的服務(wù)
蛋先生:據(jù)我所知,云開發(fā)目前是沒有提供這種純粹的服務(wù)的。但是,云數(shù)據(jù)庫(kù)有實(shí)時(shí)推送的功能,用它來(lái)實(shí)現(xiàn)你的需求應(yīng)該是木有問(wèn)題的
丹尼爾:啊~,在云數(shù)據(jù)庫(kù)這啊,藏得夠深的,How?
Unity 如何用上云數(shù)據(jù)庫(kù)?
蛋先生:首先,咱們得讓 Unity 能用上云數(shù)據(jù)庫(kù),你需要……
(丹尼爾打斷了蛋先生的講話)
丹尼爾:我懂我懂,這跟《How Can Unity+騰訊云開發(fā)=微信小游戲?》提到的數(shù)據(jù)模型是一個(gè)套路的
蛋先生:那你先去擼代碼

丹尼爾:蛋兄,搞不定 (o_ _)?。這云數(shù)據(jù)庫(kù)的 API 不像數(shù)據(jù)模型那么簡(jiǎn)單,我實(shí)在想不出如何用一個(gè)萬(wàn)能 JS 函數(shù)搞定
蛋先生:咳咳~。那咱們先把云數(shù)據(jù)庫(kù)增刪查改的調(diào)用示例整理出來(lái),如下
var db = app.database();
db.collection("hello").add({...})
db.collection("hello").doc("...").remove()
db.collection("hello").where({...}).remove()
db.collection("hello").doc("...").get()
db.collection("hello").where({...}).get()
db.collection("hello").get()
db.collection("hello").doc("...").update({...})
db.collection("hello").doc("...").set({...})
db.collection("hello").where({...}).update({...})
你看出什么門道了沒?
丹尼爾:都有 collection?都是鏈?zhǔn)秸{(diào)用?
蛋先生:說(shuō)到重點(diǎn)了,鏈?zhǔn)秸{(diào)用。鏈?zhǔn)秸{(diào)用就像是一串糖葫蘆,一步接一步:方法名,入?yún)?,方法名,入?yún)?..
丹尼爾:然后呢?
蛋先生:根據(jù)這個(gè)規(guī)律,我們可以定一個(gè) chainList 入?yún)?lái)實(shí)現(xiàn) JS 函數(shù),每一項(xiàng)就是一個(gè)方法名和方法入?yún)?。代碼如下
Database_API: async function (callbackId, params) {
...
const { collectionName, chainList } = asmLibraryArg
.Utils()
.parseInputParams(params);
...
let db;
if (platform === constants.PLATFROM.WX) {
db = wx.cloud.database();
} else if (platform === constants.PLATFROM.WEB) {
db = app.database();
}
let chainObj = db.collection(collectionName);
chainList.forEach((chainItem) => {
const method = chainItem.method;
const optionsStr = chainItem.optionsStr;
let options = optionsStr ? JSON.parse(optionsStr) : "";
...
chainObj = chainObj[method](options);
});
const data = await chainObj;
asmLibraryArg.Utils().sendMessage(callbackId, data.data || data);
}
丹尼爾:你他 * 的真是個(gè)人才
蛋先生:夸人可以,但要文明
丹尼爾:嘻嘻,接下來(lái)就是 Unity 實(shí)現(xiàn)了
蛋先生:我們可以把剛剛整理的調(diào)用示例發(fā)給 GPT,讓它幫咱們生成初步的接口定義和類實(shí)現(xiàn),我們?cè)僬{(diào)整一下即可。大概的 Prompt 如下
JS 是這么調(diào)用的
var db = app.database();
db.collection("hello").add({})
...
我希望在 Unity 也能這樣調(diào)用,請(qǐng)幫我設(shè)計(jì)相應(yīng)的類或接口
丹尼爾:可以啊,AI 用得溜溜的
蛋先生:基操而已。接下來(lái)我們來(lái)填補(bǔ)真正的實(shí)現(xiàn)細(xì)節(jié)
丹尼爾:好咧~
(溫馨提醒:請(qǐng)參考下邊的【代碼塊一】進(jìn)行閱讀)
蛋先生:對(duì)于每一個(gè)鏈?zhǔn)秸{(diào)用,我們只需實(shí)現(xiàn)最后的方法
比如 db.collection("hello").where({...}).get(),要填補(bǔ)實(shí)現(xiàn)的方法就是 QueryHandler 的 Get<T> 方法
而它的實(shí)現(xiàn)僅僅是提供 collection 名稱(collectionName)和鏈?zhǔn)秸{(diào)用的方法名和入?yún)ⅲ╟hainList)
公共邏輯實(shí)現(xiàn) CommonHandler 跟數(shù)據(jù)模型的實(shí)現(xiàn)基本一致,這里就不作贅述
//【代碼塊一】
private class Database : IDatabase
{
public ICollection Collection(string name) => new CollectionHandler(name);
private static async Task<T> CommonHandler<T>(DatabaseAPIParam param)
{
(string, TaskCompletionSource<string>) asyncTask = Internal.GetAsyncTask();
Internal.Database_API(asyncTask.Item1, JsonConvert.SerializeObject(param));
string result = await asyncTask.Item2.Task;
return Internal.ParseOutputResult<T>(result);
}
public class CollectionHandler : ICollection
{
private readonly string collectionName;
public CollectionHandler(string name)
{
collectionName = name;
}
...
public IQuery Where(object filter) => new QueryHandler(collectionName, filter);
...
}
...
public class QueryHandler : IQuery
{
private string collectionName;
private object filter;
public QueryHandler(string collectionName, object filter)
{
this.collectionName = collectionName;
this.filter = filter;
}
public Task<T> Get<T>()
{
return CommonHandler<T>(new DatabaseAPIParam()
{
collectionName = collectionName,
chainList = new[] {
new ChainItem() {
method = "where",
optionsStr = JsonConvert.SerializeObject(filter)
},
new ChainItem() {
method = "get",
optionsStr = ""
}
}
});
}
...
}
}
private class ChainItem
{
public string method { get; set; }
public string optionsStr { get; set; }
}
private class DatabaseAPIParam
{
public string collectionName { get; set; }
public ChainItem[] chainList { get; set; }
}
實(shí)時(shí)推送 Watch,需要重點(diǎn)講講
丹尼爾:云數(shù)據(jù)庫(kù)這種一來(lái)一回的模式,被你這么一說(shuō),對(duì)接起來(lái)還是挺簡(jiǎn)單的。然而到現(xiàn)在,實(shí)時(shí)推送還沒有呢
蛋先生:實(shí)時(shí)推送的對(duì)接有點(diǎn)不一樣,我們先來(lái)看下 JS 的調(diào)用示例
var db = app.database();
const watcher = db
.collection("hello")
.where({
// query...
})
.watch({
onChange: function (data) {
...
},
onError: function (err) {
...
}
});
// watcher.close()
丹尼爾:恩,請(qǐng)把"有點(diǎn)"去掉,謝謝
蛋先生:為了更好地理解,我們要從實(shí)時(shí)推送的生命周期說(shuō)起。以下是對(duì)應(yīng) JS 版本的在 Unity 調(diào)用 Watch 的代碼
var watchObj = database.Collection("hello").Where(new Dictionary<string, object>
{
// query...
})
.Watch(new WatchParams<ModelHello>()
{
OnChange = (WatchChangeData<ModelHello> data) =>
{
...
},
OnError = (string err) =>
{
...
}
});
丹尼爾:接下來(lái)又是一大波讓人頭疼的代碼片段嗎?(>人<;)
蛋先生:嘿嘿,代碼是不可避免的,依然需要結(jié)合下邊代碼【腳本C】和【腳本J】來(lái)看(溫馨提示:【腳本C】和【腳本J】為往下一點(diǎn)點(diǎn)的兩個(gè)大代碼塊)
連接的建立
丹尼爾:Come on,我已經(jīng)準(zhǔn)備好了!
蛋先生:【腳本C】中的 Watch<T> 方法是一切的開始
public IWatchObj Watch<T>(WatchParams<T> param)
首先,我們獲取 uuid,作為 JS 與 Unity 溝通的憑證
然后,實(shí)例化一個(gè) WatchObj 對(duì)象,并把它保存在 watchDictionary 字典中,以備后用
接著,調(diào)用 Database_API JS 方法
最后,把 WatchObj 返回
丹尼爾:我注意到 watch 的入?yún)⑹?action = open
蛋先生:眼力不錯(cuò)。這里設(shè)計(jì)了入?yún)?action,是為了可以支持多種行為(當(dāng)前只需支持 open 和 close)
丹尼爾:好,請(qǐng)繼續(xù)!
蛋先生:緊接著就到了 Database_API JS 方法這?!灸_本J】中加了個(gè)分支邏輯(通過(guò)判斷鏈?zhǔn)秸{(diào)用最后的方法名是否為 watch)來(lái)處理 watch 行為,即調(diào)用云數(shù)據(jù)庫(kù)的 watch API,這樣連接就建立上了。我們利用 JS 函數(shù)也是對(duì)象的特性,將 watch 對(duì)象同樣保存起來(lái),后續(xù) close 的實(shí)現(xiàn)就靠它了
消息的接收
丹尼爾:Nice,請(qǐng)繼續(xù)!
蛋先生:好嘞!我們通過(guò) onChange 和 onError 這兩位偵探,來(lái)監(jiān)聽消息(正常消息和異常消息一個(gè)不落)。只要有風(fēng)吹草動(dòng),它們就會(huì)通過(guò) SendMessage 去通知 Unity。
丹尼爾:那 Unity 在哪接收消息呢?
蛋先生:依然在 OnAsyncFnCompleted。我們?cè)?callbackId 上動(dòng)了點(diǎn)手腳,增加了分類信息。比如說(shuō),"watch_" 開頭的,就是專門為 watch 類型的。
丹尼爾:我剛剛就好奇 string uuid = "watch_" + Guid.NewGuid().ToString(); 這里的 uuid 生成規(guī)則,現(xiàn)在解惑了
蛋先生:恩,最后,我們通過(guò) watchObj 的 PerformXXXAction 來(lái)觸發(fā)具體事件的執(zhí)行。這就完成了整個(gè)消息監(jiān)聽的流程了
連接的關(guān)閉
丹尼爾:關(guān)閉應(yīng)該就是通過(guò) watchObj 的 close 方法了
蛋先生:沒錯(cuò)。具體就是通過(guò) action = close 去通知 JS 執(zhí)行實(shí)際的關(guān)閉邏輯了
//【腳本C】
public class TCBSDK : MonoBehaviour
{
private class Database : IDatabase
{
...
public class QueryHandler : IQuery
{
...
public IWatchObj Watch<T>(WatchParams<T> param)
{
string uuid = "watch_" + Guid.NewGuid().ToString();
WatchObj cls = new(uuid, (string data) => param.OnChange(JsonConvert.DeserializeObject<WatchChangeData<T>>(data)), (string data) => param.OnError(JsonConvert.DeserializeObject<string>(data)));
Internal.watchDictionary.Add(uuid, cls);
Internal.Database_API(uuid, JsonConvert.SerializeObject(new DatabaseAPIParam()
{
collectionName = collectionName,
chainList = new[] {
new ChainItem()
{
method = "where",
optionsStr = JsonConvert.SerializeObject(filter)
},
new ChainItem()
{
method = "watch",
optionsStr = JsonConvert.SerializeObject(new Dictionary<string, string>{
["action"] = "open"
})
}
}
}));
return cls;
}
}
...
}
private class Internal {
public static readonly Dictionary<string, WatchObj> watchDictionary = new();
...
}
...
private class WatchObj : IWatchObj
{
...
public WatchObj(string callbackIdInput, OnWatchHandler<string> changeCallback, OnWatchHandler<string> errorCallback)
{
callbackId = callbackIdInput;
OnChange += changeCallback;
OnError += errorCallback;
}
public void Close()
{
Internal.Database_API(callbackId, JsonConvert.SerializeObject(new DatabaseAPIParam()
{
chainList = new[] {
new ChainItem() {
method = "watch",
optionsStr = JsonConvert.SerializeObject(new Dictionary<string, string>{
["action"] = "close"
})
},
}
}));
Internal.watchDictionary.Remove(callbackId);
}
public void PerformChangeAction(string msg)
{
OnChange?.Invoke(msg);
}
public void PerformErrorAction(string err)
{
OnError?.Invoke(err);
}
}
public void OnAsyncFnCompleted(string result)
{
AsyncResponse<string> res = Internal.ParseOutputResult<AsyncResponse<string>>(result);
if (res.callbackId.StartsWith("watch_"))
{
var resultData = Internal.ParseOutputResult<Dictionary<string, object>>(res.result);
if (resultData.ContainsKey("err"))
{
Internal.watchDictionary[res.callbackId].PerformErrorAction(resultData["err"] as string);
}
else
{
Internal.watchDictionary[res.callbackId].PerformChangeAction(JsonConvert.SerializeObject(resultData["data"]));
}
}
else
{
...
}
}
}
//【腳本J】
Database_API: async function (callbackId, params) {
callbackId = UTF8ToString(callbackId);
const { collectionName, chainList } = asmLibraryArg
.Utils()
.parseInputParams(params);
...
let lastItem = chainList[chainList.length - 1];
if (lastItem.method === "watch") {
// watch 的特殊處理
const { action } = JSON.parse(lastItem.optionsStr);
if (action === "open") {
// 啟動(dòng) watch
chainList.forEach((chainItem) => {
const method = chainItem.method;
const optionsStr = chainItem.optionsStr;
if (method === "watch") {
chainObj = chainObj.watch({
onChange: function (data) {
...
asmLibraryArg.Utils().sendMessage(callbackId, { data });
},
onError: function (err) {
asmLibraryArg.Utils().sendMessage(callbackId, { err });
},
});
} else {
chainObj = chainObj[method](
optionsStr ? JSON.parse(optionsStr) : ""
);
}
});
asmLibraryArg.Database_API[callbackId] = chainObj;
} else if (action === "close") {
// 關(guān)閉 watch
if (asmLibraryArg.Database_API[callbackId]) {
asmLibraryArg.Database_API[callbackId].close();
delete asmLibraryArg.Database_API[callbackId];
}
}
} else {
// 普通異步接口調(diào)
...
}
}
如何用實(shí)時(shí)推送完成實(shí)時(shí)聊天
丹尼爾:這下終于可以用上云數(shù)據(jù)庫(kù)的實(shí)時(shí)推送了,那么具體怎么實(shí)現(xiàn)實(shí)時(shí)聊天呢?
蛋先生:好問(wèn)題,實(shí)時(shí)推送是靠監(jiān)聽云數(shù)據(jù)庫(kù)的數(shù)據(jù)變化來(lái)實(shí)現(xiàn)的。所以我們得先給聊天消息建一個(gè)數(shù)據(jù)模型 chat_message,大致信息如下:

丹尼爾:等等,不是說(shuō)要用云數(shù)據(jù)庫(kù)嗎?怎么變成了數(shù)據(jù)模型了?
蛋先生:數(shù)據(jù)模型其實(shí)是云數(shù)據(jù)庫(kù)的簡(jiǎn)化版本,底層仍然是云數(shù)據(jù)庫(kù)
丹尼爾:哦,原來(lái)如此!您繼續(xù)
接收消息
蛋先生:假設(shè)你的用戶名為 Daniel,你在和 Tom 聊天。那么要接收 Tom 發(fā)給你的消息,可以按 from 和 to 這兩個(gè)條件去查詢,如下
// 接收消息
var database = app.Database();
var watchObj = database.Collection("chat_message").Where(new Dictionary<string, object>
{
["from"] = "Tom",
["to"] = "Daniel"
})
.Watch(new WatchParams<ModelChatMessage>()
{
OnChange = (WatchChangeData<ModelChatMessage> data) =>
{
if (data.type != "init")
{
Debug.Log($"接收到的消息:{JsonConvert.SerializeObject(data.docChanges)}");
}
},
OnError = (string err) =>
{
Debug.Log($"watch err: {err}");
}
});
這樣當(dāng)有符合查詢條件的數(shù)據(jù)插入時(shí),你就會(huì)實(shí)時(shí)收到插入的數(shù)據(jù)信息了
發(fā)送消息
丹尼爾:懂了!發(fā)送消息應(yīng)該就是插入一條數(shù)據(jù)咯,如下
await app.Models.Create<ModelsCreateRes>(new ModelsReqParams()
{
modelName = "chat_message",
options = new Dictionary<string, object>
{
["data"] = new Dictionary<string, string>
{
["from"] = "Daniel", // 發(fā)送人
["to"] = "Tom", // 接收方
["content"] = "Hi man" // 消息內(nèi)容
}
}
});
蛋先生:很好!接下來(lái)就是你的自由發(fā)揮時(shí)間了
以上完整代碼請(qǐng)移步到倉(cāng)庫(kù):https://github.com/daniel-dx/unity-cloudbase-demo
代碼有點(diǎn)粗糙,僅供參考,還望見諒!