写在最前
本故事是《How Can Unity+腾讯云开发=微信小游戏?》的续篇,主要聊的是在使用 Unity 开发微信小游戏过程中,如何使用云开发来给小游戏增添一抹实时互动的亮色(比如实时聊天)
温馨提示:各家的云开发功能各具特色,本文的云开发特指腾讯云云开发
云开发,哪个服务可实现实时聊天?
丹尼尔:蛋兄,我又来了。上次跟你聊完(请看上集《How Can Unity+腾讯云开发=微信小游戏?》)后,我已经在 Unity 微信小游戏中用上云开发的数据模型了,云函数也顺手捎上了
蛋先生:不错,挺速度的嘛
丹尼尔:这些一来一回的后端接口,使用数据模型和云函数,唰唰唰一下就搞定了,别提多爽
蛋先生:是的,对于后端接口的搭建,这些服务确实可以大大简化你的工作,让你聚焦你的业务
丹尼尔:但我现在又遇到问题了
蛋先生:我就知道,无事不登三宝殿
丹尼尔:瞧您说的,主要是来看看您,顺便问下问题啦 (′▽`〃)
蛋先生:直说吧,啥问题
丹尼尔:我的小游戏里,玩家之间是可以聊天的,但我没发现云开发有 WebSocket 相关的服务
蛋先生:据我所知,云开发目前是没有提供这种纯粹的服务的。但是,云数据库有实时推送的功能,用它来实现你的需求应该是木有问题的
丹尼尔:啊~,在云数据库这啊,藏得够深的,How?
Unity 如何用上云数据库?
蛋先生:首先,咱们得让 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; }
}
实时推送 Watch,需要重点讲讲
丹尼尔:云数据库这种一来一回的模式,被你这么一说,对接起来还是挺简单的。然而到现在,实时推送还没有呢
蛋先生:实时推送的对接有点不一样,我们先来看下 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
代码有点粗糙,仅供参考,还望见谅!