头图

写在最前

本故事是《How Can Unity+腾讯云开发=微信小游戏?》的续篇,主要聊的是在使用 Unity 开发微信小游戏过程中,如何使用云开发来给小游戏增添一抹实时互动的亮色(比如实时聊天)

温馨提示:各家的云开发功能各具特色,本文的云开发特指腾讯云云开发

云开发,哪个服务可实现实时聊天?

丹尼尔:蛋兄,我又来了。上次跟你聊完(请看上集《How Can Unity+腾讯云开发=微信小游戏?》)后,我已经在 Unity 微信小游戏中用上云开发的数据模型了,云函数也顺手捎上了

蛋先生:不错,挺速度的嘛

丹尼尔:这些一来一回的后端接口,使用数据模型和云函数,唰唰唰一下就搞定了,别提多爽

蛋先生:是的,对于后端接口的搭建,这些服务确实可以大大简化你的工作,让你聚焦你的业务

丹尼尔:但我现在又遇到问题了

蛋先生:我就知道,无事不登三宝殿

丹尼尔:瞧您说的,主要是来看看您,顺便问下问题啦 (′▽\`〃)

蛋先生:直说吧,啥问题

丹尼尔:我的小游戏里,玩家之间是可以聊天的,但我没发现云开发有 WebSocket 相关的服务

蛋先生:据我所知,云开发目前是没有提供这种纯粹的服务的。但是,云数据库有实时推送的功能,用它来实现你的需求应该是木有问题的

丹尼尔:啊~,在云数据库这啊,藏得够深的,How?

Unity 如何用上云数据库?

蛋先生:首先,咱们得让 Unity 能用上云数据库,你需要……

(丹尼尔打断了蛋先生的讲话)

丹尼尔:我懂我懂,这跟《How Can Unity+腾讯云开发=微信小游戏?》提到的数据模型是一个套路的

蛋先生:那你先去撸代码

image

丹尼尔:蛋兄,搞不定 (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(),要填补实现的方法就是 QueryHandlerGet<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,大致信息如下:

image

丹尼尔:等等,不是说要用云数据库吗?怎么变成了数据模型了?

蛋先生:数据模型其实是云数据库的简化版本,底层仍然是云数据库

丹尼尔:哦,原来如此!您继续

接收消息

蛋先生:假设你的用户名为 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\
代码有点粗糙,仅供参考,还望见谅!

蛋先生DX
307 声望2 粉丝

ncform / ncgen / nice-hooks 作者