写点什么

How Can Unity+ 腾讯云开发 = 微信小游戏?

作者:蛋先生DX
  • 2024-09-13
    广东
  • 本文字数:9853 字

    阅读完需:约 32 分钟

How Can Unity+腾讯云开发=微信小游戏?

写在最前

时光飞逝,物是人非


技术方案总是带着时代的标签,曾经前沿的技术,经过时间的洗礼,可能已成为经典


如果你点进来是因实际需求而不是随意瞧瞧,请同时看看官方的最新进展,指不定有意外的收获


本故事主要讲解在使用 Unity 开发微信小游戏时,如何第一时间用上腾讯云开发的新能力,以及可能的最佳开发实践



故事背景

蛋先生:丹尼尔,好久不见,怎么愁眉苦脸的?


丹尼尔:蛋兄好,最近在折腾用 Unity 开发微信小游戏,服务用的是腾讯云开发,不过碰到些问题,一时半会儿搞不定


蛋先生:哦?什么问题,说来听听


丹尼尔:蛋兄,你也懂 Unity 吗?


蛋先生:略懂略懂


丹尼尔:如此甚好。我起初以为直接调用腾讯云开发,用官方的 Unity SDK 就行了


蛋先生:恩,这个想法没毛病


丹尼尔:但,官方并没有 Unity 版本的 SDK


蛋先生:然后呢?


丹尼尔:后来,我在将 Unity 转成微信小游戏的过程中,发现微信有提供 WeChatWASM 这个 Unity SDK,里面带了个 WX.cloud


蛋先生:那不就解了吗?


丹尼尔:我也天真地这么以为,可在 Unity 编辑器一运行,就...



蛋先生:这也正常,毕竟 Unity 编辑器又不是微信开发的,Play Mode 下没有微信小游戏运行环境,自然是跑不起来的


丹尼尔:所以我需要写代码,构建成 WebGL,再转换成微信小游戏,最后才能在微信开发者工具看到运行效果。而我运气不好,还遇到了开发者工具罢工,还得多走一步,用真机预览。这一个流程下来感觉要 1 分钟左右,每次改代码都得走这一长征路,想想头就大



蛋先生:会不会是使用姿势不对呢?


丹尼尔:我也怀疑过,直到我看到官方文档介绍的开发方式...



蛋先生:看来只能如此,不过对于微信本身的能力,大部分时间我们不需要怎么消费它的输出,好像问题也不大。比如:


var bannerAd = WX.CreateBannerAd(new WXCreateBannerAdParam(){    adUnitId = "xxxx",    adIntervals = 30,    style = new Style()    {        left = 0,        top = 0,        width = 600,        height = 200    }});
bannerAd.OnLoad(()=> { bannerAd.Show();});bannerAd.OnError((WXADErrorResponse res)=>{ Debug.Log(res.errCode);});
复制代码


丹尼尔:恩,这倒是。但对于服务接口,我们是重度消费输出数据的,比如排行榜,好友列表等。总不能来回盲写代码,走一遍长征路调试吧


蛋先生:这长征路是难免的,但可以减少次数。比如在 Unity 编辑器开发时使用 Mock 数据,等业务逻辑走通再走长征路


丹尼尔:我还是希望尽早看到集成了云开发服务后的实际效果,这样可以早点发现问题,减少在长征路上浪费的时间


蛋先生:恩...


丹尼尔:还有个问题,WeChatWASM 对云开发 SDK 的支持,存在滞后的问题,当前只支持 CallFunction,不支持最新的数据模型。我就是冲着腾讯云开发刚新鲜出炉不久的数据模型来的


蛋先生:哦...


丹尼尔:烦死了,这也不行,那也不行,蛋兄,你给出出主意呗


蛋先生:自己搞一个吧


丹尼尔:这,蛋兄,你不是开玩笑的吧?这可不是我的主业务,我只想用云开发服务而已


蛋先生:恩,可以搞,但不能瞎搞。我们来分析一下要解决的核心问题:


一 要能用上云开发的最新功能,比如数据模型;


二 争取可以早点看到实际效果,避免走长征路;


三 实现成本要低,毕竟这不是主业务


丹尼尔:恩,那怎么办?

解决用上云开发最新功能的问题

蛋先生:云开发推出新功能,总是第一时间在 JS 环境(包括云函数,小程序和 Web 页面)提供的,对吧




丹尼尔:没错


蛋先生:所以我们实际应该直接调用这些 SDK,在 Web 环境下就调用 js-sdk,在小游戏环境下就调用小程序 sdk,这样就能确保享受到最新的功能了


丹尼尔:这些是运行在 JS 环境的吧


蛋先生:没错!所以我们需要实现一个包裹层,这个包裹层对内使用 Unity 脚本去调用 Javascript 函数,对外提供云开发的 Unity 版本 SDK



丹尼尔:具体怎么实现呢?


蛋先生:实现细节咱们后面再讲

解决少走长征路,尽早看到实际效果的问题

丹尼尔:好吧,我还有一个问题,为什么要考虑 Web 环境呢?我只想开发微信小游戏而已啊


蛋先生:这就是为了解决第二个核心问题 - 缩短调试路径。虽然我们在 Unity 编辑器的 Play Mode 无法预览效果,但我们可以退而求其次,构建成 WebGL。当你开发完实际的调用云开发的代码后,按 Command + B ,这会构建成 WebGL 应用,并自动在浏览器打开,你就可以直接预览实际效果了,这样可以省去转换成小游戏的步骤,时间上也会节省不少


丹尼尔:虽然不能在 Unity 编辑器直接预览,还是有点小遗憾,但这样的调试路径还是可以接受的


蛋先生:恩,是时候总结一下这种方案的可能的最佳开发流程了


【Unity 编辑器开发阶段】:使用 Mock 数据,这样可省去构建成 WebGL 的时间,同时可确定业务逻辑所使用接口的输入和输出数据结构


【浏览器调试阶段】:根据确定的业务逻辑接口,通过 Unity TCB SDK Wrapper 开发调用云开发服务的代码,构建成 WebGL,预览实际的效果


【微信小游戏预览阶段】:最后直接构建成微信小游戏,进行最终效果预览


技术实现细节

丹尼尔:哎呦,不错哦!不过,我现在比较好奇的是怎么实现这个,以及实现的成本咋样?


蛋先生:那咱们接着继续聊

Unity 调用 Javascript 同步方法

丹尼尔:首先,Unity 怎么调用 Javascript 方法呢?


蛋先生:分两种情况,同步和异步。同步方法比较简单,官方文档 已经很详细了,但咱们也来个小示例


首先,定义个要被 Unity 调用的 JavaScript 方法。我们在 Assets/Plugins 目录下创建 .jslib 后缀的文件,比如叫 tcbsdk.jslib


// Assets/Plugins/tcbsdk.jslib
const asmLibraryArg = { Hello: function () { console.log("Hello, world!"); }, ...}mergeInto(LibraryManager.library, asmLibraryArg);
复制代码


接着,在 C# 脚本里进行映射,比如 Assets/Scripts/DemoSDK.cs


// Assets/Scripts/DemoSDK.cs
public class DemoSDK{ [DllImport("__Internal")] public static extern void Hello();}
复制代码


然后,你就可以通过 DemoSDK.Hello() 调用 tcbsdk.jslib 里的 Hello 方法了


丹尼尔:看上去挺简单的,有啥需要注意的吗?


蛋先生:还真有,tcbsdk.jslib 里的 asmLibraryArg 这个变量的存在是有意义的,且不能修改成其它变量名


丹尼尔:为啥?


蛋先生:它的存在主要是为了方法间互相调用。来,我们继续看例子,看看 HelloCallOtherFn 是怎么调用 Hello 的


// Assets/Plugins/tcbsdk.jslib
const asmLibraryArg = { Hello: function () { console.log("Hello, world!"); }, HelloCallOtherFn: function () { console.log("Call HelloCallOtherFn"); // 方法间调用方式一 _Hello(); // 方法间调用方式二 asmLibraryArg.Hello(); }, ...}mergeInto(LibraryManager.library, asmLibraryArg);
复制代码


丹尼尔:asmLibraryArg.Hello(); 这个我懂,但为啥是 _Hello();?明明方法名是 Hello 啊


蛋先生:嘿嘿,jslib 里的方法在构建成 WebGL 时都会经过加工后合并进 webgl.wasm.framework.unityweb.js ,这个文件你可以在生成的 WebGL 产物里找到,来看个代码片段


// webgl.wasm.framework.unityweb.js
var unityFramework = (() => { return function (unityFramework) { ... function _Hello() { console.log("Hello, world!"); } function _HelloCallOtherFn() { console.log("Call HelloCallOtherFn"); // 方法间调用方式一 _Hello(); // 方法间调用方式二 asmLibraryArg.Hello(); }, ... var asmLibraryArg = { Hello: _Hello, ... } ... }})
复制代码


看到没?真相就在这儿了。不过,我不推荐用 _Hello(),因为这样你就没法用编辑器的功能,比如点击跳转到方法定义。



我们前面提到变量名必须是 asmLibraryArg,这其实是一种取巧的方式,这样即可以实现方法间调用,又可以充分享受编辑器的智能辅助体验,一箭双雕



丹尼尔:蛋兄,你可真是个小机灵鬼


蛋先生:咳咳~

Unity 调用 Javascript 异步方法

丹尼尔:那接下来咱们聊聊如何调用异步方法


蛋先生:异步调用是个环,咱们得从一次异步方法调用的整个过程说起


丹尼尔:你说吧,反正那些又臭又长的代码我是不想看的


蛋先生:嘿嘿,代码是不可避免的,还得结合下边代码【脚本 C】和【脚本 J】来看(温馨提示:【脚本 C】和【脚本 J】为往下一点点的两个大的代码片段)。假设我们现在要调用以下异步方法


HelloWithReturnResult result = await DemoSDK.Instance.HelloAsyncFn(new HelloWithInputParams() { name = "daniel666" });
复制代码


在【脚本 C】中,HelloAsyncFn 方法执行时,会先通过 GetAsyncTask() 取得这次调用的 callbackId 和对应的 TaskCompletionSource 异步任务,然后调用 JavaScript 方法,并等待 TaskCompletionSource 任务的完成。


丹尼尔:那谁来通知 TaskCompletionSource 任务完成呢?


蛋先生:好问题!看【脚本 J】中 HelloAsyncFn 的实现


异步任务执行完后,会执行 asmLibraryArg.Utils().sendMessage(callbackId, result);


这个方法实际执行的发送消息代码是 Module.SendMessage("DemoSDK", "OnAsyncFnCompleted", param),它会通知名为 "DemoSDK" 的 GameObject 去执行脚本组件 DemoSDK 的 OnAsyncFnCompleted 方法


丹尼尔:哦哦,我刚才就想问【脚本 C】里的这些代码是干嘛的,原来是为了创建一个 GameObject 来接收消息。


GameObject gameObject = new("DemoSDK");DemoSDK demoSDK = gameObject.AddComponent<DemoSDK>();
复制代码


蛋先生:正解!你可真是个小聪明,这么快就看出来了


接下来【脚本 C】的 OnAsyncFnCompleted 方法就会收到消息,然后根据 callbackId 让具体的 TaskCompletionSource 任务执行 SetResult,从而完成任务,这样整个异步调用就闭环了。


丹尼尔:懂了。但为什么是 Module.SendMessage 呢?这个 Module 是从哪儿冒出来的?


蛋先生:真相依然在 webgl.wasm.framework.unityweb.js


// webgl.wasm.framework.unityweb.js
var unityFramework = (() => { return function (unityFramework) { ... var Module = typeof unityFramework != "undefined" ? unityFramework : {}; ... function SendMessage(gameObject, func, param) { ... } Module["SendMessage"] = SendMessage; ... }})
复制代码


丹尼尔:666,简直洞若观火


//【脚本C】:Assets/Scripts/DemoSDK.cs
public class DemoSDK : MonoBehaviour{ private static readonly Lazy<DemoSDK> _instance = new Lazy<DemoSDK>(() => { GameObject gameObject = new("DemoSDK"); DemoSDK demoSDK = gameObject.AddComponent<DemoSDK>(); return demoSDK; }); public static DemoSDK Instance => _instance.Value; private DemoSDK() { }
private Dictionary<string, TaskCompletionSource<string>> tcsDictionary = new();
public async Task<HelloWithReturnResult> HelloAsyncFn(HelloWithInputParams input) { (string, TaskCompletionSource<string>) asyncTask = GetAsyncTask();
// 调用 JavaScript 方法 Internal.HelloAsyncFn(asyncTask.Item1, Internal.ParseInputParams(input));
// 返回 Task 让调用方等待 var result = await asyncTask.Item2.Task; return Internal.ParseOutputResult<HelloWithReturnResult>(result); }
public void OnAsyncFnCompleted(string result) { AsyncResponse<string> res = Internal.ParseOutputResult<AsyncResponse<string>>(result);
tcsDictionary[res.callbackId].SetResult(res.result); tcsDictionary.Remove(res.callbackId); }
private (string, TaskCompletionSource<string>) GetAsyncTask() { string uuid = Guid.NewGuid().ToString(); TaskCompletionSource<string> tcs = new(); tcsDictionary.Add(uuid, tcs); return (uuid, tcs); }
private class Internal { [DllImport("__Internal")] public static extern void HelloAsyncFn(string callbackId, string input); }
private class AsyncResponse<T> { public string callbackId { get; set; } public T result { get; set; } }}
复制代码


//【脚本J】:Assets/Plugins/tcbsdk.jslib
const asmLibraryArg = { HelloAsyncFn: async function (callbackId, input) { ... const result = await new Promise((resolve) => setTimeout(() => { resolve(input), 2000; }) ); asmLibraryArg.Utils().sendMessage(callbackId, result); }, Utils: function () { const utils = { ... sendMessage(callbackId, result) { const unityInstance = Module; const constants = asmLibraryArg.Constants(); unityInstance.SendMessage( constants.DEMO_CALLBACK_OBJECT_NAME, constants.CALLBACK_METHOD_NAME, JSON.stringify({ callbackId, result: JSON.stringify(result || ""), }) ); }, }; return utils; }, Constants: function () { return { ... DEMO_CALLBACK_OBJECT_NAME: "DemoSDK", CALLBACK_METHOD_NAME: "OnAsyncFnCompleted", }; }, ...}mergeInto(LibraryManager.library, asmLibraryArg);
复制代码

集成腾讯云开发 js-sdk 和小程序 sdk

丹尼尔:说了这么多,好像还不知道要怎么集成 js-sdk 和小程序 sdk


蛋先生:搞清楚了如何实现 Unity 调用 Javascript 之后,问题不就迎刃而解了嘛


因为 @cloudbase/wx-cloud-client-sdk@cloudbase/js-sdk 都提供了 UMD 格式的完整版本,所以我们只需要将 SDK 的完整代码复制放到 jslib 里面(请留意 CloudbaseJSSdkScript 和 CloudbaseWXCloudClientSdkScript 方法)即可。以下是 init 的实现示例


// Assets/Plugins/tcbsdk.jslib
const asmLibraryArg = {
/** * params.env: 环境ID */ CloudInit: async function (callbackId, params) { ... const input = asmLibraryArg.Utils().parseInputParams(params); if (platform === constants.PLATFROM.WX) { const { init } = asmLibraryArg.GetCloudbaseWXCloudClientSdkInstance(); await wx.cloud.init({ env: input.env, }); const app = init(wx.cloud); ... } else if (platform === constants.PLATFROM.WEB) { const cloudbase = asmLibraryArg.GetCloudbaseJSSdkScriptInstance(); const app = cloudbase.init({ env: input.env, }); ... } asmLibraryArg.Utils().sendMessage(callbackId); },
/** * 获取 @cloudbase/wx-cloud-client-sdk 实例 */ GetCloudbaseWXCloudClientSdkInstance: function () { const global = asmLibraryArg.GetGlobalData(); if (!global.wxCloudClientSDK) { asmLibraryArg.CloudbaseWXCloudClientSdkScript.call(global); } return global.wxCloudClientSDK; },
/** * 获取 cloudbase-js-sdk 实例 */ GetCloudbaseJSSdkScriptInstance: function () { const global = asmLibraryArg.GetGlobalData(); if (!global.cloudbase) { asmLibraryArg.CloudbaseJSSdkScript.call(global); } return global.cloudbase; },
/** * 加载 @cloudbase/wx-cloud-client-sdk */ CloudbaseWXCloudClientSdkScript: function () { (function (exports) { /** * 以下代码来自于 https://unpkg.com/@cloudbase/wx-cloud-client-sdk@1.2.1/lib/wxCloudClientSDK.umd.js */ ... })(); },
/** * 加载 cloudbase-js-sdk */ CloudbaseJSSdkScript: function () { (function (exports) { /** * 以下代码来自于 https://static.cloudbase.net/cloudbase-js-sdk/2.7.13-beta.0/cloudbase.full.js */ ... })(); },};
mergeInto(LibraryManager.library, asmLibraryArg);
复制代码


丹尼尔:在微信小游戏中并不需要 js-sdk,这样岂不是会将 js-sdk 打包进小游戏里,不太合适吧


蛋先生:没错,不过别担心,办法是有滴。在 jslib 定义的方法并非都会打包,只有那些显示声明 [DllImport("__Internal")] 的才会打包进去,如下


[DllImport("__Internal")]public static extern void Hello();
复制代码


所以,我们可以定义一个 Preprocessor Symbols(比如 WEIXINMINIGAME),在准备转换成小游戏时,可以在构建 WebGL 的设置中提供这个预定义符号,这样 CloudbaseJSSdkScript 方法就会忽略掉了


#if !WEIXINMINIGAME            [DllImport("__Internal")]            private static extern void CloudbaseJSSdkScript();#endif
复制代码


丹尼尔:Nice

云开发数据模型的实现建议

丹尼尔:感觉我现在就可以动手了,想要什么功能就搞什么功能,主动权在手上的感觉真好。就先拿数据模型开刀吧,蛋兄有什么建议吗?


蛋先生:通过查阅 云开发数据模型 SDK 文档,我们可以发现一个规律,就是所有方法都是同一种固定模式 models.[model name].[api name](JSON input)


models.post.create({...})models.post.delete({...})models.post.get({...})models.post.update({...})
复制代码


所以呢,我们只需要在 .jslib 文件里定一个万能的 Models_API 方法,就全都搞定了


// Assets/Plugins/tcbsdk.jslib
Models_API: async function (callbackId, apiName, params) { callbackId = UTF8ToString(callbackId); apiName = UTF8ToString(apiName); const { modelName, options: optionsStr } = asmLibraryArg .Utils() .parseInputParams(params); const options = JSON.parse(optionsStr); const app = asmLibraryArg.Utils().getApp(); const models = app.models; const { data } = await models[modelName][apiName](options); asmLibraryArg.Utils().sendMessage(callbackId, data);},
复制代码


同样在 C# 脚本中实现一个万能的 ModelAPI 方法,而 Get, Create 等方法其实就是通过 ModelAPI 方法来实现的


// Assets/Scripts/TCBSDK.cs
public class TCBSDK : MonoBehaviour{ private class Models : IModels { private static readonly Lazy<Models> _instance = new Lazy<Models>(() => new()); public static Models Instance => _instance.Value; private Models() { }
public Task<T> Get<T>(ModelsReqParams input) { return ModelAPI<T>(input, "get"); } public Task<T> Create<T>(ModelsReqParams input) { return ModelAPI<T>(input, "create"); } ...
private async Task<T> ModelAPI<T>(ModelsReqParams input, string apiName) { var realInput = new Dictionary<string, object> { ["modelName"] = input.modelName, ["options"] = JsonConvert.SerializeObject(input.options) };
(string, TaskCompletionSource<string>) asyncTask = Internal.GetAsyncTask();
Internal.Models_API(asyncTask.Item1, apiName, Internal.ParseInputParams(realInput));
string result = await asyncTask.Item2.Task; return Internal.ParseOutputResult<T>(result); } }
private class Internal { [DllImport("__Internal")] public static extern void Models_API(string callbackId, string apiName, string input); } }
public class ModelsReqParams{ public string modelName { get; set; } public Dictionary<string, object> options { get; set; }}
复制代码


丹尼尔:入参 options 是个 Dictionary 类型,会不会不太好,是不是还是明确类型好点?


蛋先生:这个问题问得好!如果从代码编写规范来说,当然是要定义类型的,裸奔毕竟不太好。但从实际使用角度来看,Dictionary 反而可能是个不错的选择。因为它省时省力,还对开发者相当友好。


丹尼尔:对开发者友好,这怎么说?


蛋先生:你看看腾讯云开发的云后台,在每个数据模型旁边,都会贴心地给出常用 API 的代码范例,基本上复制过来,稍微改改就能用。



所以,你可以在 web 环境下先尽情调试 JSON 入参,直到结果符合预期。然后再把这 JSON 扔给 GPT,让它帮你生成对应的 C# Dictionary,一气呵成



最后,把代码贴过来,大功告成~


var options = new Dictionary<string, object>{    ["filter"] = new Dictionary<string, object>    {        ["where"] = new Dictionary<string, object>        {            ["$and"] = new List<Dictionary<string, object>>                {                    new Dictionary<string, object>                    {                        ["_id"] = new Dictionary<string, string>                        {                            ["$eq"] = id                        }                    }                }        }    }};ModelHello hello = await TCBSDK.Instance.ModelsGet<ModelHello>(new ModelsReqParams() { modelName = "hello", options = options });
复制代码


丹尼尔:也对,真正的类型校验,云开发的数据模型会严格把关,SDK 只要开发者用得顺手,问题不大

业务逻辑服务接口实现

丹尼尔:最后聊一下业务逻辑服务接口的实现吧,你觉得 Mock 数据要如何实现比较优雅?


蛋先生:通过 interface 声明接口,然后通过 UNITY_EDITOR 预处理符号来判断应该采用哪种实现,编辑器环境用 MockGameAPI,其它则用 RealGameAPI


    public interface IGameAPI    {        Task<ModelsList<GameRealm>> GetRealmList();        ...    }
public class GameAPI {#if UNITY_EDITOR public static IGameAPI Instance = new MockGameAPI();#else public static IGameAPI Instance = new RealGameAPI();#endif }
public class MockGameAPI : IGameAPI { public Task<ModelsList<GameRealm>> GetRealmList() { return Task.FromResult(new ModelsList<GameRealm>() { records = new List<GameRealm> { new() { _id = "mock_realm_id", name = "默认" } }, total = 1 }); } ... }
public class RealGameAPI : IGameAPI { public Task<ModelsList<GameRealm>> GetRealmList() {
var options = new Dictionary<string, object> { ... };
return TCBSDK.Instance.ModelsList<ModelsList<GameRealm>>(new ModelsReqParams() { modelName = Constants.ModelName.GameRealm, options = options }); } }
复制代码


这样在调用服务接口来实现游戏业务逻辑时就不用关心环境了


var tcbList = await GameAPI.Instance.GetRealmList();
复制代码


丹尼尔:明白了,感谢蛋兄,我准备大干一场了


蛋先生:加油,再见


以上完整代码请移步到仓库:https://github.com/daniel-dx/unity-cloudbase-demo代码有点粗糙,仅供参考,还望见谅!



写在最后,别有用心

作为一个前端开发者,零 Unity 零 C# 基础,2 周时间从入门到“精通”( _( ゚Д゚)ノ 从没见过如此厚颜无耻之人),交出这份作业对我来说还算满意


但还是想通过这篇文章来抛砖引玉,指不定有哪位大神能提供其它意想不到的解决方案呢,您说,是吧!( ̄︶ ̄)↗


发布于: 刚刚阅读数: 2
用户头像

蛋先生DX

关注

一源一世界 2019-03-03 加入

我是只会用大白话来聊技术的蛋先生DX ncform / ncgen / nice-hooks 开源项目作者

评论

发布
暂无评论
How Can Unity+腾讯云开发=微信小游戏?_腾讯云_蛋先生DX_InfoQ写作社区