1. 为什么绕不开原生SDK:Unity侧的几个真实痛点
做Unity手游接入微信登录和分享,最早的时候我也偷过懒,想直接在C#层用现成的社交插件包办一切。但真到iOS端上架和联调阶段,问题全冒出来了:回调不触发、版本升级后API变了、微信App跳转回来没动静,甚至还有账号体系对不上号的情况。折腾几轮之后我才下了结论——iOS端接微信,必须走原生Objective-C插件包一层,再把C#和原生代码之间打通,这不是可选项,而是稳定性的基本前提。
微信登录和分享本质上是跨App跳转协作,流程是这样的:你的App先向系统申请唤起微信,微信处理完授权或者分享动作之后,再通过URL Scheme或者Universal Link把你拉回来。这套跳转机制在iOS上属于系统级行为,Unity的Application.OpenURL只能做到"跳出去",做不了"被拉回来之后接管回调"。所以必须在AppDelegate这个生命周期入口里挂上微信SDK的handleOpenURL,Unity自己是碰不到这些的。
再说个更现实的问题:微信SDK每年都在更新,尤其是Universal Link普及之后,老的纯URL Scheme注册方式在iOS 9以上的系统处处受限。第三方商店里的"全家桶"社交插件往往更新滞后,你为了等一个兼容补丁,可能要卡版本卡到吐血。与其被别人卡脖子,不如自己维护一层薄皮原生插件,微信SDK一有新版随时能换。
还有账号体系上的坑。Unity的Social命名空间默认面向Game Center这类平台,跟微信的openid、unionid完全不是一套东西。如果你的游戏后台还需要把微信用户和自有账号体系做关联,那一定得自己控制取码、换token、拉用户信息这一整条链路,任何中间层帮你"简化"都是在替你做决定,出了问题定位起来非常痛苦。
所以我这篇内容,就按我在项目里实际操作过的路径来写。从插件目录搭建、C#与原生接口设计,到微信登录和分享的完整实现,最后附上一堆真机联调的经验。内容基于通用实践,你所在环境版本不同的话,重点看思路和排查方法。
2. 搭桥准备:iOS端插件工程的目录与接口约定
2.1 插件目录布局与构建产物管理
Unity里放iOS原生插件有固定规矩。我习惯在项目根目录下建Assets/Plugins/iOS,这个目录里的.h、.m、.mm文件在构建Xcode工程时会被自动拷进去,而且不需要额外做任何导入配置,省心很多。如果你的插件代码里引用了微信SDK,微信的WechatOpenSDK-Xcode目录也是一样放在这里,或者用.podspec方式在Xcode里走CocoaPods,二选一。
我自己的经验是:不要混用这两种方式。要么全部手动拖目录,要么全部走CocoaPods,混用会导致linker阶段一堆重复符号。手动方式对Unity构建更友好,团队新成员接手时打开Xcode一眼能看懂;CocoaPods方式升级SDK方便,但每次Unity构建后都得在Xcode里执行pod install一次,CI上要额外多一步。
建议目录结构大概长这样:
Assets/Plugins/iOS/ WechatAuth.h WechatAuth.mm WechatSDK/ WechatOpenSDK.xcframework ... Info.plist这里的WechatAuth.mm就是我们的桥接层,里面混着Objective-C和C函数,目的是让C#通过[DllImport("__Internal")]能直接调到底下。注意,iOS上Unity调用原生代码只有一条近路,就是extern "C"包裹的C函数,C++和Objective-C的方法没法直接暴露给C#。
2.2 C#与Objective-C之间的接口签名约定
桥接层的接口设计直接决定后续的复用成本和排障难度。我在C#侧声明了一个静态类,把所有微信相关入口都收拢在里面:
using System.Runtime.InteropServices; using UnityEngine; public static class WechatNative { [DllImport("__Internal")] private static extern void WechatRegister(string appId, string universalLink); [DllImport("__Internal")] private static extern void WechatLoginRequest(string state); [DllImport("__Internal")] private static extern bool WechatIsInstalled(); [DllImport("__Internal")] private static extern void WechatShareText(string text, int scene); [DllImport("__Internal")] private static extern void WechatShareWebPage(string title, string description, string url, string thumbPath, int scene); public static void Init() { if (Application.platform != RuntimePlatform.IPhonePlayer) return; WechatRegister("wxe1234abcd5678efg", "https://yourdomain.com/wechat/"); } }你一定要记住一件事:[DllImport("__Internal")]这套机制只对iOS生效,如果你在Android或者编辑器里也调了这些方法,直接抛EntryPointNotFoundException。所以还是老老实实在入口处判断Application.platform == RuntimePlatform.IPhonePlayer。
原生侧的入口函数也要跟着一一对应。微信API是Objective-C的,所以在.mm文件里我习惯用extern "C"包一层转发函数,把C字符串转成NSString,再丢给WXApi相关方法处理:
extern "C" { void _WechatRegister(const char *appId, const char *universalLink) { NSString *appIdStr = [NSString stringWithUTF8String:appId]; NSString *linkStr = [NSString stringWithUTF8String:universalLink]; [[WechatAuth shared] registerApp:appIdStr universalLink:linkStr]; } void _WechatLoginRequest(const char *state) { NSString *stateStr = [NSString stringWithUTF8String:state]; [[WechatAuth shared] sendLoginReq:stateStr]; } void _WechatShareWebPage(const char *title, const char *desc, const char *url, const char *thumbPath, int scene) { NSString *titleStr = [NSString stringWithUTF8String:title]; NSString *descStr = [NSString stringWithUTF8String:desc]; NSString *urlStr = [NSString stringWithUTF8String:url]; NSString *thumbStr = [NSString stringWithUTF8String:thumbPath]; [[WechatAuth shared] shareWebPage:titleStr desc:descStr url:urlStr thumbPath:thumbStr scene:scene]; } }我在C#里面故意用了string类型,Marshal会默认按UTF-8转成const char*。这里有个小坑,如果你的C#字符串里带中文,并且C#文件没保存成UTF-8编码,会出现传过去乱码的情况。所以C#脚本文件我全部在编辑器里设置成了UTF-8 without BOM,虽然Unity默认多半没问题,但团队协作时代码合并时容易改坏,多提一嘴没坏处。
2.3 Universal Links与URL Scheme的参数核对
这一节必须在写代码之前就先核对完,不然后面回调死都到不了AppDelegate。
先说你需要在Xcode的Info.plist里加两样东西。第一是URL Scheme,这个主要给老版本系统和分享跳转兜底:
<key>CFBundleURLTypes</key> <array> <dict> <key>CFBundleURLSchemes</key> <array> <string>wxe1234abcd5678efg</string> </array> </dict> </array> <key>LSApplicationQueriesSchemes</key> <array> <string>weixin</string> <string>weixinUL</string> </array>LSApplicationQueriesSchemes是给canOpenURL用的,微信SDK内部会拿这个来判断用户到底装没装微信。漏了这一项,最典型的症状就是分享网页时SDK提示"微信未安装",但用户手机上明明有微信。
第二是Universal Link。现在新版微信SDK登录回调基本都走Universal Link,需要做两件事:在Xcode的Signing & Capabilities里加Associated Domains,权限值填applinks:yourdomain.com;然后在你自己的服务器根目录放一个apple-app-site-association文件,内容类似:
{ "applinks": { "apps": [], "details": [ { "appID": "TEAMID.com.yourcompany.yourapp", "paths": ["/wechat/*", "/wechat"] } ] } }appID的格式是"你的Team ID + 点号 + Bundle Identifier",别写错,写错了Universal Link直接作废。这个文件不需要认证,只要放在HTTPS可达的根路径下就行,而且必须返回application/json的Content-Type。
微信开发平台后台那边,你要填的Universal Link长这样:https://yourdomain.com/wechat/。两边的路径要一致,后台填什么,SDK注册时也填什么。这里我踩过一次,后台填的是/wechat/,注册代码里却写成了/wechat,少个斜杠,iOS系统匹配路径规则很死板,结果登录授权后微信拉起App失败。
3. 微信登录接入:从注册到授权回调的完整链路
3.1 注册App与请求授权的实际配置
整个登录链路的第一步是注册。调用[WXApi registerApp:universalLink:]这行代码的时机很重要,必须在App启动早期,application:didFinishLaunchingWithOptions里面就要执行。对Unity项目来说,AppDelegate是Unity自动生成的,所以要对这个文件做扩展。这里我推荐使用Unity的ApplicationDelegate注入机制,或者直接在自己的插件类里实现UIApplicationDelegate方法。
如果你嫌改Unity生成的AppDelegate太麻烦,还有一个办法:直接在插件类里自己实现application:openURL:options:方法,并把微信SDK的注册调用挂到一个在Unity场景里常驻的MonoBehaviour上。但注意,注册调用是持久的,如果场景切换导致这个MonoBehaviour被销毁了,后续登录一样会出问题。所以我强烈建议在WechatBridge的Awake里执行注册,并且挂这个脚本的GameObject必须DontDestroyOnLoad。
请求授权时,需要构造SendAuthReq:
- (void)sendLoginReq:(NSString *)state { SendAuthReq *req = [[SendAuthReq alloc] init]; req.scope = @"snsapi_userinfo"; req.state = state; [WXApi sendReq:req completion:nil]; }scope填snsapi_userinfo可以拿到用户详细信息,如果只是需要登录标识,用snsapi_base也行。state是防CSRF用的,我的做法是C#侧生成一段随机字符串传进来,同时发给游戏后端,等回调时核对,对不上就丢弃。别拿当前时间戳直接当state,有条件的团队也可以用UUID。
[WXApi sendReq:completion:]里的completion只表示SDK派发请求是否成功,跟你登录成不成功没有半点关系。它返回的BOOL代表微信是否被成功唤起,如果这里返回NO,最常见的情况是用户没装微信、或者微信SDK没注册成功。
3.2 授权回调处理与用户信息获取
微信处理完授权后,会通过Universal Link唤起你的App,这个时候AppDelegate的continueUserActivity:方法会被调用,我们需要把NSUserActivity转交给微信SDK:
- (BOOL)application:(UIApplication *)application continueUserActivity:(NSUserActivity *)userActivity restorationHandler:(void (^)(NSArray<id> *restorationHandler))restorationHandler { return [WXApi handleOpenUniversalLink:userActivity delegate:[WechatAuth shared]]; }然后所有微信SDK的回调最终都会汇聚到WechatAuth这个WXApiDelegate的onResp:方法。登录成功时收到的对象是SendAuthResp,里面最关键的两个字段是code和state。
- (void)onResp:(BaseResp *)resp { if ([resp isKindOfClass:[SendAuthResp class]]) { SendAuthResp *authResp = (SendAuthResp *)resp; NSMutableDictionary *dict = [NSMutableDictionary dictionary]; dict[@"errCode"] = @(authResp.errCode); dict[@"state"] = authResp.state ?: @""; if (authResp.errCode == 0) { dict[@"code"] = authResp.code ?: @""; } NSData *data = [NSJSONSerialization dataWithJSONObject:dict options:0 error:nil]; NSString *json = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; UnitySendMessage("WechatBridge", "OnLoginResponse", json.UTF8String); } }拿到code只是第一步,第二步是用这个code去微信开放平台的https://api.weixin.qq.com/sns/oauth2/access_token接口换access_token和openid。这一步千万不要在客户端做,因为你得传AppSecret,而AppSecret是绝对不能跟包走的,反编译一下Unity的global-metadata.dat就能扒出来。正确姿势是把code传到自己的游戏后端,由后端去换token、拉用户信息,再返回给你定制的登录态。
拿到access_token后拉用户信息的接口是:
GET https://api.weixin.qq.com/sns/userinfo?access_token=ACCESS_TOKEN&openid=OPENID返回值里有nickname、headimgurl、unionid这些字段。unionid在需要打通公众号、小程序等多套微信产品账号时特别关键,建议后端把unionid作为统一用户标识,别只存openid,不然以后要做跨App用户合并时血压拉满。
3.3 登录态保存与重复回调的保护
客户端拿到后端签发的登录态之后,GameClient通常就直接进主界面了。但这里有个实际场景:用户在一个账号上授权登录成功,切后台杀掉微信,再回来,Unity场景可能已经重建了。所以登录态必须持久化到PlayerPrefs或者后端token管理服务里,不要只放在内存静态变量中。
另外,onResp:回调在极端情况下可能重复进入。比如Universal Link连续唤起App两次,或者Unity侧网络差导致登录请求超时重发,然后两个SendAuthResp都成功返回。我的C#侧处理逻辑是维护一个"正在处理登录回调"的布尔标志,同时带一个自增序号,每次都校验state和序号是否匹配:
public void OnLoginResponse(string json) { var data = JsonUtility.FromJson<LoginResponseData>(json); if (data.errCode != 0) { OnLoginFailed(data.errCode); return; } if (data.state != _pendingState) { Debug.LogWarning("登录回调state不匹配,丢弃"); return; } _pendingState = null; StartCoroutine(SendCodeToServer(data.code)); }state不匹配的回调必须静默丢弃,别弹窗也别上报,正常业务里基本撞不到,真撞到了多半是中间有劫持或者自己代码重复发起了请求。
4. 微信分享:网页、图片与小程序的发送与回调
4.1 各类消息的组装规则与缩略图限制
分享是日常最容易出视觉效果问题的模块。微信SDK里分享消息的模型统一是WXMediaMessage加一个mediaObject。比如最常见的网页分享:
- (void)shareWebPage:(NSString *)title desc:(NSString *)desc url:(NSString *)url thumbPath:(NSString *)thumbPath scene:(int)scene { WXMediaMessage *message = [[WXMediaMessage alloc] init]; message.title = title; message.description = desc; UIImage *thumbImage = [UIImage imageWithContentsOfFile:thumbPath]; if (thumbImage) { [message setThumbImage:thumbImage]; } WXWebpageObject *webObject = [WXWebpageObject object]; webObject.webpageUrl = url; message.mediaObject = webObject; SendMessageToWXReq *req = [[SendMessageToWXReq alloc] init]; req.bText = NO; req.message = message; req.scene = scene; [WXApi sendReq:req completion:nil]; }这里最大的限制是缩略图。微信官方要求缩略图大小不能超过128KB,但实际联调下来,越接近这个上限,分享卡片渲染越慢,甚至部分iOS版本会失败。我个人的稳妥做法是:缩略图边长限制在120x120以内,PNG转JPG压缩后控制在32KB左右。超过这个范围,微信收到图后会自己二次压缩,几次压缩下来画质糊是小事,有时候连大图预览都没了。
缩略图最好在Unity侧提前处理成Texture2D再保存到临时目录,然后把文件路径传给原生层,别让原生层去网络下载。真机上用dataWithContentsOfURL拉一张未加密的HTTP图,被App Transport Security挡住下载失败,分享出去的卡片没缩略图,丑得让人想卸载游戏。
4.2 会话、朋友圈、收藏三种场景的差异
微信分享有四个场景值,常用的是这三个:
| 场景 | 值 | 用途 |
|---|---|---|
| 会话 | 0 | 单人聊天、群聊 |
| 朋友圈 | 1 | 朋友圈信息流 |
| 收藏 | 2 | 微信收藏 |
代码里req.scene就传这个值。注意朋友圈的分享限制比会话严格得多:网页分享在朋友圈里不会展示完整的描述文字,只显示标题和链接预览;小程序消息不支持分享到朋友圈;音乐和视频消息在朋友圈里不能自动播放。所以交互设计上,建议朋友圈分享按钮的文案和素材都要单独准备一套,不要直接复用会话分享的配置。
分享到会话时,微信还会自动识别你的URL,如果是一条优酷链接或者苹果App Store链接,微信会进一步解析成特殊卡片,连带标题和描述都可能被系统替换掉。这对游戏推广类链接影响不大,但如果是普通的H5活动页,最好确认一下分享出去后卡片展示是否符合预期。
4.3 分享结果的返回码与用户取消的区分
分享请求发出后,用户可能完成分享、取消、或者微信SDK直接报错。这些状态还是通过onResp:返回,只不过这次对象是SendMessageToWXResp:
if ([resp isKindOfClass:[SendMessageToWXResp class]]) { SendMessageToWXResp *msgResp = (SendMessageToWXResp *)resp; NSMutableDictionary *dict = [NSMutableDictionary dictionary]; dict[@"errCode"] = @(msgResp.errCode); NSData *data = [NSJSONSerialization dataWithJSONObject:dict options:0 error:nil]; UnitySendMessage("WechatBridge", "OnShareResponse", [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding].UTF8String); }主要的返回码我整理了一张表,方便你排查问题:
| errCode | 含义 | 处理建议 |
|---|---|---|
| 0 | 分享成功 | 正常发放奖励 |
| -2 | 用户取消 | 不发奖励,可提示下次 |
| -4 | 用户拒绝授权 | 检查授权状态处理 |
| -1 | 业务参数错误 | 查看raw数据里的错误信息 |
| -3 | 鉴权失败 | 确认AppID和Universal Link配置 |
| -6 | 无效通用链接 | 检查后台和AASA匹配 |
这里最容易踩的坑是:微信返回成功,并不代表消息真的送达了。它只是说"微信App成功接收到了这个分享请求",如果用户分享完立即断网,对方侧可能收不到。所以分享任务奖励的发放建议放在分享成功回调之后做二次校验,或者干脆用后端任务系统放宽校验条件。
分享回调里遇到errCode == -2一定要和失败区分开。很多游戏的分享奖励逻辑把取消也算作失败,导致用户多点了一次取消就再也领不到奖励,客服投诉量直接起飞。最好设计成:成功发基础奖励,取消不发奖励但保留再次分享的入口,失败则允许重试,三次失败给一次客服兜底入口。
5. 真机实测:我在Xcode里处理的几个异常
5.1 App Transport Security与图片加载失败
ATS全称App Transport Security,是iOS 9开始强制默认开启的安全策略,所有HTTP明文请求默认被禁止。微信SDK本身走HTTPS没问题,但你的分享缩略图如果来自一个HTTP的图片地址,原生层用dataWithContentsOfURL加载就会静默失败。症状是:分享出去没有缩略图,但标题链接都正常。
我解决这个问题的方式很简单粗暴:Unity侧先把远程图片拉下来,转成本地JPG文件,再传路径给原生层。这样既能控制尺寸,又能绕开ATS,还省一次原生层网络请求。如果你实在要从原生层拉HTTP资源,可以在Info.plist里加NSAppTransportSecurity的NSAllowsArbitraryLoads,但不推荐,上线审核容易被留意。
5.2 冷启动后回调丢失
微信登录时用户跳去微信,你App可能被系统挂起甚至终止。等用户授权完再回来,如果App已经被杀掉,系统会走冷启动流程拉起你的App,然后再通过Universal Link把回调交给你。
这种情况下Unity场景从头加载,MonoBehaviour的初始化顺序正常,但有一个细节:如果WechatNative.Init()放在某个场景物体的Awake里,而Universal Link回调触发时机早于Unity完成初始化,原生层的onResp:回调可能先到,UnitySendMessage发向一个还没注册好的GameObject,消息就丢了。
我应对的办法是双保险。第一,插件原生层收到onResp:时,先判断GameObject是否存在,如果不存在,把回调内容暂存在一个NSMutableArray里;第二,C#侧Awake初始化完成后主动调一个原生方法WechatFlushPendingCallbacks(),把暂存的回调一次性补发过来。这样无论冷启动时序多乱,回调都不会弄丢。
5.3 真机诊断日志的读取习惯
联调时直接在Xcode连接真机看log是基本操作,但我更建议你从一开始就在原生层埋点上日志。我在WechatAuth里专门写了几个日志函数,把注册参数、onResp:返回的errCode、原始错误信息原样打到NSLog里,然后C#侧也把每次调用原生方法的入参和回调收据写进Unity的Debug.Log。
真机上报问题时,用户可能根本不在你身边,这时候一个可复现的日志路径比什么都有用。只要用户愿意配合,你拿到的Xcode日志能快速定位到到底是注册、跳转、还是回调阶段出了问题。我见过太多同事把问题定位停留在"微信就是回调不到"的层面,其实Xcode里一行errCode直接把原因写得明明白白。
6. 最后再聊点工程化建议
插件跑通之后,真正让项目活得久的是工程规范。微信SDK升级至少要留出两个工作日的回归时间,不能今天换版本明天就发提审。我的做法是CI脚本里锁定SDK版本号,构建时打印SHA256值,哪天出了问题能确认线上包到底用的哪个版本。
后端侧记住一个原则:所有微信用户身份相关的接口,都不要让客户端直接传openid作为唯一凭据。客户端容易伪造,而且openid在不同App下不互通,容易搞混。以code换身份那次请求里,让后端返回一个你自己的会话token,后续所有接口都用这个token说话。
登录态过期的问题也值得提前设计。微信access_token有效期大约2小时,refresh_token有效期约30天。建议后端在两小时有效期快到的时候自动用refresh_token续期,客户端无感。不要指望客户端记住什么,移动端App进程随时可能死掉,登录态的维护逻辑全部放后端最稳。
我自己做这套包厢反复调了几轮之后最大的体会是,iOS接入微信登录分享这件事,90%的坑不在代码逻辑,而在配置一致性。AppID、Universal Link、Bundle ID、Team ID、URL Scheme,这五样东西只要有一个对不上,看起来都是"回调失败"同一个症状,排查起来却完全不同的路径。所以前期花二十分钟把这些配置统一整理成一张表,放到项目文档首页,后续谁接手都不会再迷茫。