本故事是《How Can Unity+腾讯云开发=微信小游戏?》的续篇,主要聊的是在使用 Unity 开发微信小游戏过程中,如何使用云开发来给小游戏增添一抹实时互动的亮色(比如实时聊天)
温馨提示:各家的云开发功能各具特色,本文的云开发特指腾讯云云开发
丹尼尔:蛋兄,我又来了。上次跟你聊完(请看上集《How Can Unity+腾讯云开发=微信小游戏?》)后,我已经在 Unity 微信小游戏中用上云开发的数据模型了,云函数也顺手捎上了
蛋先生:不错,挺速度的嘛
丹尼尔:这些一来一回的后端接口,使用数据模型和云函数,唰唰唰一下就搞定了,别提多爽
蛋先生:是的,对于后端接口的搭建,这些服务确实可以大大简化你的工作,让你聚焦你的业务
丹尼尔:但我现在又遇到问题了
蛋先生:我就知道,无事不登三宝殿
丹尼尔:瞧您说的,主要是来看看您,顺便问下问题啦 (′▽`〃)
蛋先生:直说吧,啥问题
丹尼尔:我的小游戏里,玩家之间是可以聊天的,但我没发现云开发有 WebSocket 相关的服务
蛋先生:据我所知,云开发目前是没有提供这种纯粹的服务的。但是,云数据库有实时推送的功能,用它来实现你的需求应该是木有问题的
丹尼尔:啊~,在云数据库这啊,藏得够深的,How?
蛋先生:首先,咱们得让 Unity 能用上云数据库,你需要……
(丹尼尔打断了蛋先生的讲话)
丹尼尔:我懂我懂,这跟《How Can Unity+腾讯云开发=微信小游戏?》提到的数据模型是一个套路的
蛋先生:那你先去撸代码
丹尼尔:蛋兄,搞不定 (o_ _)ノ。这云数据库的 API 不像数据模型那么简单,我实在想不出如何用一个万能 JS 函数搞定
蛋先生:咳咳~。那咱们先把云数据库增删查改的调用示例整理出来,如下
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?都是链式调用?
蛋先生:说到重点了,链式调用。链式调用就像是一串糖葫芦,一步接一步:方法名,入参,方法名,入参…
丹尼尔:然后呢?
蛋先生:根据这个规律,我们可以定一个 chainList
入参来实现 JS 函数,每一项就是一个方法名和方法入参。代码如下
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); }
丹尼尔:你他 * 的真是个人才
蛋先生:夸人可以,但要文明
丹尼尔:嘻嘻,接下来就是 Unity 实现了
蛋先生:我们可以把刚刚整理的调用示例发给 GPT,让它帮咱们生成初步的接口定义和类实现,我们再调整一下即可。大概的 Prompt 如下
JS 是这么调用的 var db = app.database(); db.collection("hello").add({}) ... 我希望在 Unity 也能这样调用,请帮我设计相应的类或接口
丹尼尔:可以啊,AI 用得溜溜的
蛋先生:基操而已。接下来我们来填补真正的实现细节
丹尼尔:好咧~
(温馨提醒:请参考下边的【代码块一】进行阅读)
蛋先生:对于每一个链式调用,我们只需实现最后的方法
比如 db.collection("hello").where({...}).get()
,要填补实现的方法就是 QueryHandler
的 Get<T>
方法
而它的实现仅仅是提供 collection 名称(collectionName)和链式调用的方法名和入参(chainList)
公共逻辑实现 CommonHandler
跟数据模型的实现基本一致,这里就不作赘述
//【代码块一】 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; } }
丹尼尔:云数据库这种一来一回的模式,被你这么一说,对接起来还是挺简单的。然而到现在,实时推送还没有呢
蛋先生:实时推送的对接有点不一样,我们先来看下 JS 的调用示例
var db = app.database(); const watcher = db .collection("hello") .where({ // query... }) .watch({ onChange: function (data) { ... }, onError: function (err) { ... } }); // watcher.close()
丹尼尔:恩,请把"有点"去掉,谢谢
蛋先生:为了更好地理解,我们要从实时推送的生命周期说起。以下是对应 JS 版本的在 Unity 调用 Watch
的代码
var watchObj = database.Collection("hello").Where(new Dictionary<string, object> { // query... }) .Watch(new WatchParams<ModelHello>() { OnChange = (WatchChangeData<ModelHello> data) => { ... }, OnError = (string err) => { ... } });
丹尼尔:接下来又是一大波让人头疼的代码片段吗?(>人<;)
蛋先生:嘿嘿,代码是不可避免的,依然需要结合下边代码【脚本C】和【脚本J】来看(温馨提示:【脚本C】和【脚本J】为往下一点点的两个大代码块)
丹尼尔:Come on,我已经准备好了!
蛋先生:【脚本C】中的 Watch<T>
方法是一切的开始
public IWatchObj Watch<T>(WatchParams<T> param)
首先,我们获取 uuid
,作为 JS 与 Unity 沟通的凭证
然后,实例化一个 WatchObj
对象,并把它保存在 watchDictionary
字典中,以备后用
接着,调用 Database_API
JS 方法
最后,把 WatchObj
返回
丹尼尔:我注意到 watch
的入参是 action = open
蛋先生:眼力不错。这里设计了入参 action
,是为了可以支持多种行为(当前只需支持 open 和 close)
丹尼尔:好,请继续!
蛋先生:紧接着就到了 Database_API
JS 方法这。【脚本J】中加了个分支逻辑(通过判断链式调用最后的方法名是否为 watch)来处理 watch 行为,即调用云数据库的 watch API,这样连接就建立上了。我们利用 JS 函数也是对象的特性,将 watch 对象同样保存起来,后续 close 的实现就靠它了
丹尼尔:Nice,请继续!
蛋先生:好嘞!我们通过 onChange 和 onError 这两位侦探,来监听消息(正常消息和异常消息一个不落)。只要有风吹草动,它们就会通过 SendMessage 去通知 Unity。
丹尼尔:那 Unity 在哪接收消息呢?
蛋先生:依然在 OnAsyncFnCompleted
。我们在 callbackId 上动了点手脚,增加了分类信息。比如说,“watch_” 开头的,就是专门为 watch 类型的。
丹尼尔:我刚刚就好奇 string uuid = "watch_" + Guid.NewGuid().ToString();
这里的 uuid 生成规则,现在解惑了
蛋先生:恩,最后,我们通过 watchObj 的 PerformXXXAction 来触发具体事件的执行。这就完成了整个消息监听的流程了
丹尼尔:关闭应该就是通过 watchObj 的 close 方法了
蛋先生:没错。具体就是通过 action = close
去通知 JS 执行实际的关闭逻辑了
//【脚本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") { // 启动 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") { // 关闭 watch if (asmLibraryArg.Database_API[callbackId]) { asmLibraryArg.Database_API[callbackId].close(); delete asmLibraryArg.Database_API[callbackId]; } } } else { // 普通异步接口调 ... } }
丹尼尔:这下终于可以用上云数据库的实时推送了,那么具体怎么实现实时聊天呢?
蛋先生:好问题,实时推送是靠监听云数据库的数据变化来实现的。所以我们得先给聊天消息建一个数据模型 chat_message,大致信息如下:
丹尼尔:等等,不是说要用云数据库吗?怎么变成了数据模型了?
蛋先生:数据模型其实是云数据库的简化版本,底层仍然是云数据库
丹尼尔:哦,原来如此!您继续
蛋先生:假设你的用户名为 Daniel,你在和 Tom 聊天。那么要接收 Tom 发给你的消息,可以按 from 和 to 这两个条件去查询,如下
// 接收消息 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}"); } });
这样当有符合查询条件的数据插入时,你就会实时收到插入的数据信息了
丹尼尔:懂了!发送消息应该就是插入一条数据咯,如下
await app.Models.Create<ModelsCreateRes>(new ModelsReqParams() { modelName = "chat_message", options = new Dictionary<string, object> { ["data"] = new Dictionary<string, string> { ["from"] = "Daniel", // 发送人 ["to"] = "Tom", // 接收方 ["content"] = "Hi man" // 消息内容 } } });
蛋先生:很好!接下来就是你的自由发挥时间了
以上完整代码请移步到仓库:https://github.com/daniel-dx/unity-cloudbase-demo
代码有点粗糙,仅供参考,还望见谅!