微信小游戏开发者技术手册-安全篇

特色

一、代码加固

为提高微信小游戏代码的安全性,平台针对小游戏游戏场景提供了定制化的深度保护功能,可实现对小游戏前端代码混淆,以防止代码暴露,提高攻击者阅读、二次开发成本,一定程度上也能提高黑产制作外挂的成本;还为代码增加了水印能力,防止核心代码被搬运或二次上线到其它平台。

“游戏深度保护” 插件只需在微信开发者工具中直接进行安装即可,操作便捷,无需额外耗时。建议涉及小游戏中的核心算法逻辑、版权内容等需要安全保护的内容,都可以进行深度保护。本插件由小游戏平台提供,不会泄露原代码。开发者也应注意自身开发规范的严谨性,保证游戏的代码质量,保护游戏用户账号安全。

以下是插件各项能力以及使用介绍。

1.1 代码混淆

代码混淆是利用抽象语法树(AST)解析,对代码进行功能等价的转换。主要做两件事情:名字擦除、结构打乱。

名字擦除是指将代码中的变量名、函数名、类名进行重命名,将属性名、常量值等隐藏起来。经过名字擦除的代码,搜索不到原代码中的名字。可以有效地规避攻击者做关键字搜索,定位代码逻辑。

结构打乱是指将代码的控制流等价转换,比如对象定义、类的定义、以及块内的代码,进行打乱重排。经过结构打乱后的代码,很难分析它的执行流程,可以有效地防止攻击者读懂代码或预测逻辑。

以下分别是示例代码和经过本插件混淆后的代码。可以看到,原代码中的变量、属性、字面量等信息皆被擦除或隐藏,原代码结构也被打乱,很难分析执行顺序。

1.2 代码水印

代码水印是在代码混淆时,放置到代码中一段隐蔽且唯一的信息。它具备以下特点:

  • 隐蔽性:水印本身也是代码,放在混淆代码中,参与运行,具备比较强的隐蔽性。
  • 等价性:水印不会影响原代码逻辑。
  • 唯一性:水印是根据微信小游戏的身份、加水印时间等信息经过加密生成的,是唯一的。

水印在一定程度上可以证明开发者与游戏的关联关系,从而用于辅助判断代码与开发者的归属关系。当开发者怀疑加固的代码被搬运到其它平台时,开发者可以寻求平台帮助提取水印以用于辅助判断。

1.3 体积&性能

根据大盘已有加固案例:平均体积膨胀在 30% 以内。如果原代码本身比较小,膨胀比率可能略大一些。原代码比较大时(>1M),膨胀比例会接近于前述值;性能、cpu、内存几乎不变。

如果原代码已经是压缩甚至是混淆过的代码,那么膨胀率可能会比较高。建议开发者直接使用源代码进行加固。

1.4 如何使用

申请白名单,下载并安装 1.06.2401020 或以上版本的开发者工具、安装 “游戏深度保护” 插件即可使用。详细说明见:游戏深度保护 | 微信开放社区(文章置顶评论有申请白名单方式)。

二、内容安全升级开放

基于游戏领域海量数据积累和多年行业经验,研发游戏垂类的智能模型和定制策略方案,高效过滤游戏领域常见的谩骂、低俗、营销广告等内容及各种文字对抗变种。

2.1 单日限额提升

当前很多小游戏单日UGC文本数已经超过接口msgSecCheck的200w/day的限额,特别是小游戏周末高峰做活动期间UGC量级可能突增好几倍,导致小游戏的晚高峰UGC内容无法过内容安全审核,存在非常大的安全风险。

因此平台单独提供一个扩大限额(1000w/day)的新接口供超额的小游戏开发者使用,新接口和老接口(小程序和小游戏共用的接口msgSecCheck)所有协议完全一样,开发者使用切换时只需要更改调用链接即可,切换后不用再请求老接口。同时新接口还有三个升级优化点:

  • 新接口平均耗时降低300ms,提升聊天互动场景的用户体验
  • 取消”用户未在近两小时访问小游戏”的请求限制
  • 取消”不合法的openid”用户请求限制,也就是非微信端用户的UGC内容也可以访问
  • 为了防止刷量和攻击,新接口使用需要开发者联系小助手进行申请。
  • 目前开放了文本接口,图片新接口预计2024年内开放。 (取消限频,取消用户限制,增加低俗软色情能力)。

小游戏文本内容安全新接口:https://api.weixin.qq.com/wxa/game/content_spam/msg_sec_check?access_token=ACCESS_TOKEN

仅url路径不同,其他输入输出参数可参考老接口msgSecCheck文档

2.2 游戏垂类专属

除了红线类(敏感、色情、违法等)内容的基础识别能力模型之外,游戏业务目前更多更常见黄线类的辱骂攻击低俗挑逗灰产广告引流三大类。特别是在游戏公屏群聊,私聊互动,社区帖子评论场景,昵称签名等场景下会存在大量谩骂、低俗内容,这些都非常容易损害正常用户体验和小游戏的口碑,还会存在玩家投诉和监管的压力。此外在小游戏中营销广告引流内容批量刷屏时,也会损害小游戏开发者利益。

基于多年游戏行业的数据经验积累,基于大数据训练更适合游戏的文本模型和定制安全策略,目前具备以下内容识别能力:

  • 辱骂攻击,低俗挑逗,软色情文本
  • 变种辱骂低俗文本:包括同音、谐音、同义词、emoji、拼音、缩写等组合对抗文本
  • 群聊和评论场景拆字叠楼对抗文本 (非实时)
  • 灰黑产广告引流内容
  • 文本语种类型识别
  • 文本无意义内容

同时针对黑灰产广告引流内容,每天还会从发表内容,发表频次,发表行为等多维度发现可疑内容,并经过人工审核确认后加词和加黑种子快速与黑灰产对抗。此外还会根据日常发现的灰产引流样本来定期更新文本营销引流模型,持续拦截新的灰黑产引流变种内容。

  • 上述游戏垂类的黄线类内容识别能力已在老接口msgSecCheck上线生效,默认生效辱骂、低俗、广告引流。

2.3 策略可定制化

当前小游戏种类,产品形态,数量都非常多样性,因此通用的内容识别能力和模型保障在小游戏整体数据上的效果。针对一些特殊的、个性化的小游戏内容安全需求,可以切换小游戏内容安全新接口的方式接入,在黄线类内容的识别能力、策略上可以进行定制化调整和运营。

例如,某小游戏开发者可能想要将一些无意义、灌水刷经验的评论内容进行沉底,避免影响小游戏内社区的UGC内容质量,这时候就可申请配置”无意义文本模型”。或者开发者不想要管控一些日常的广告内容(可能存在一些活动广告),可以申请取消”文本营销引流”相关模型策略。更多定制需求可以联系小助手

  • 需要切换新接口才能使用策略定制化能力,并且可定制范围只限于黄线内容。

三、小游戏对抗外挂实践

3.1 认识小游戏外挂

不管是app游戏,还是微信小游戏,都很容易面临外挂的攻击,外挂对游戏造成的影响主要有:

  • 用户投诉
  • 影响游戏公平性(如排行榜)
  • 游戏正常收益受损

就目前而言,小游戏外挂可以分为两类,协议挂和内存挂,本文将分别介绍这两类外挂,以及一些对抗方式。

3.1.1 协议挂

黑产利用小游戏客户端JS代码泄露+中间人攻击抓包的方式,获得小游戏与开发者后台之间通信的所有细节,从而实现篡改游戏远端存储数据或者实现脱机的一种外挂,常用手法:

  • 反编译获取小游戏源代码逆向
  • 中间人攻击抓包分析参数

部分偏离线玩法的小游戏提供了存档功能,由于代码未混淆,且部分游戏后台未对用户数据做校验,游戏内的存档数据可以被任意修改,并重新签名覆盖,如下图,游戏的存档数据为一串json字符串,通过逆向游戏代码,提取出加密和解密函数,可以任意修改游戏数据后覆盖存档。

 

3.1.2 内存挂

黑产利用内存搜索工具,定位游戏内存数值并直接修改的一种外挂,常用工具:

  • PC端:CheatEngine
  • 安卓端:GG修改器

以飞机大战举例,修改游戏内数值:

3.2 外挂对抗

3.2.1 协议挂对抗实践

协议挂能够成立有两个前提:

  • 攻击者反编译获取小游戏代码逆向逻辑
  • 攻击者利用中间人攻击抓包分析游戏与后台的协议

其中反编译获取小游戏代码逻辑,可以使用第一章中的 游戏深度保护插件 来对抗。针对抓包,则有三种方案可以进行对抗,对抗思路如图:

3.2.1.1 HTTPS加密网络通道

小程序加密网络通道 | 微信开放文档

示例代码如下,开发者可以选择在游戏关键逻辑处,比如用户数据存档,游戏内活动等重要功能时对传输的数据加密,避免被攻击者直接阅读明文窃取重要信息。

const somedata = 'xxxxx'
const userCryptoManager = wx.getUserCryptoManager()
userCryptoManager.getLatestUserKey({
success({encryptKey, iv, version, expireTime}) {
const encryptedData = someAESEncryptMethod(encryptKey, iv, somedata)
wx.request({
data: encryptedData,
success(res) {
const decryptedData = someAESDEcryptMethod(encryptKey, iv, res.data)
console.log(decryptedData)
}
})
}
})

在开发者后台则通过后台接口来获取解密的密钥。

curl -X POST "https://api.weixin.qq.com/wxa/business/getuserencryptkey?access_token=ACCESS_TOKEN&openid=OPENID&signature=SIGNATURE&sig_method=hmac_sha256"

3.2.1.2 TCP协议

如果有进一步的要求,可直接在TCP层面实现,参考TCPSocket | 微信开放文档

3.2.1.3 Donuts网关方案

Donuts 提供了微信私有链路服务,可用于提升小游戏关键网络请求的安全性。

产品介绍

const gateway = wx.cloud.services.Gateway({
// 接入域名,可以在网关控制台中获取
domain: 'xxxxxxx.sh.wxcloudrun.com',
})
// 赋值到 cloud 对象上,方便后续调用
wx.cloud.gateway = gateway
 
wx.cloud.gateway.call({
// 去除域名后的接口路径,中文需要转译
path: `/api/save`,
// 请求方法
method: 'POST',
// 数据
data: 'data'
}).then(result => {
console.log('微信网关访问结果:', result)
})

3.2.2 内存挂对抗

内存挂本质上是一些数值被外部修改器给修改了,为什么会被修改呢?因为在v8引擎中,整数的数值是通过SMI结构来存储的,其最后一位数值是用来作为标记的,标识是否为堆栈对象或者Smi。因此,游戏的内存数值与游戏展示出来的数值存在两倍的关系,即:内存内数值 = 游戏内数值*2,不少外部经验贴也是这么写的:

如图,某游戏内的数值变化代码如下,对象里的数值可以直接被内存搜索修改

3.2.2.1 数值篡改感知

基于此,黑产制作了多款如秒杀、加血、加伤害等的外挂,其本质都是搜索关键数值,并直接修改,但是黑产的外挂工具并不能实现精确定位,毕竟大部分外挂使用者不懂得如何通过一步步的操作来筛选无用的内存值。因此黑产工具有一个特点,就是只搜一个或多个关键数值,然后一次性全部修改。

因此开发者可以考虑,针对关键的数值做双写校验逻辑,即同时写值和写入校验(如hash值)。然后定时、或者在关键业务逻辑处校验:一旦数值与校验值不匹配,则可以认为游戏被外挂攻击了,做进一步动作。

/////////////////////////////// 感知篡改需要用到的 hash 函数和 检查函数
function MyHash(val){
return val + 10;
}
 
function CheckCoin(obj){
if(obj.coin_hash !== MyHash(obj.coin)){
console.info("检测到外挂")
// 继续做其它事:退出游戏,或者上报等
}
}
 
///////////////////////////// 以下是原业务逻辑代码区
let obj = {}
 
// 写关键数值时, 也同步写一个校验值
obj.coin = 10;
obj.coin_hash = MyHash(obj.coin) // 写校验值
 
 
// 感知内存篡改方式1,定时每五秒检查一次
setInterval(CheckCoin, 5000, obj)
 
// 感知内存篡改方式2,在关键业务逻辑处检查
function uploadCoin(obj){
// 在上传云端金币时,先检查一次
CheckCoin(obj);
// 校查无问题,再做剩余逻辑
}

3.2.2.2 数值隐藏

Proxy 是 es6 引入的特性,其关键性质是,对一个代理对象上的属性读取或写入的动作,都可以被拦截转发到自定义逻辑。借此可以实现数据在内存中的隐藏, 从而隔断搜索,我们来看一个最简单的模型,即一个对象 obj,和对应的金币属性coin,示例代码如下:

let obj = {}
 
let p_obj = new Proxy(obj, {
get(target, key, receiver) {
// 如果为数值,则返回减7
if (typeof target[key] === 'number') {
return target[key] - 7
}
return Reflect.get(target, key ,receiver)
},
set(target, key, value, receiver) {
// 如果为数值,则写入加7
if (typeof value === 'number') {
value += 7
}
return Reflect.set(target, key, value, receiver)
}
 
});
 
// 后续所有的操作都针对代理对象
p_obj.coin = 10
 
// 不影响原有的逻辑
console.assert(p_obj.coin === 10, 'p_obj.coin === 10')
 

通过给原有的对象套上一层代理,从而实现游戏运行时,展示在界面上的数值,与实际存储在内存中的数值是不一样的。这就达到了阻断搜索的目标。注意 Proxy 是 es6 的特性,如果要兼容 es5,那么需要用 defineProperty 来等价实现,或者使用 es6 转 es5 工具支持。

 

3.3 其他需要注意的安全问题

3.3.1 游戏内禁止内置GM逻辑

游戏正式版发布的代码后禁止含有gm开关等逻辑,黑产可修改代码并重新打包替换,如下图是某游戏代码内置了gm的测试逻辑,导致被黑产利用:

3.3.2 保护游戏的自定义登录态

  • 重复使用wx.login code且未校验绑定关系

小游戏开发者应正确地使用 code,指引。如下图是某游戏存在的问题:

  1. 对外暴露了一个接口,可重复使用wx.login的code
  2. 开发者后台未实际校验code与openid的对应关系

开发者第一次使用wx.login获取到的code登录,获得用户的openid,第二次重复使用code+openid查询用户自定义登录态

但实际测试发现code可固定为黑产用户A的,同时可结合任意用户B的openid查询到用户B的自定义登录态(猜测开发者后台并未校验code和openid的绑定关系)

黑产可借此制作协议挂,实现修改任意用户的数据

  • 登录态接口禁止泄露

禁止后台直接暴露可通过游戏内uid获取自定义登录态的接口,黑产可遍历uid修改任意用户数据

  • 禁止将openid直接当自定义登录态

同上,黑产可遍历openid来修改任意用户的数据

3.3.3 邀请好友功能

游戏内邀请好友获得奖励的功能容易被外部黑产利用,如图:

针对上述黑产场景,可以考虑

  • 风险用户扫描,针对有可能存在恶意注册、营销作弊的黑产问题

参考安全风控接口指南 | 微信开放文档,针对风险等级>=3的用户,可以采取一些措施避免

  • 开发者后台需要区分微信/其他渠道进入

若没有区分渠道,黑产可通过分享卡片获得分享参数,绕开微信场景,伪造其他场景渠道进入

jenkins打包环境搭建的一些坑点

特色

最近为了提高版本更新的效率,搭建了jenkins环境,因为我们现在比较多的海外版本,更新频率越来越高。

导致我们前端花费了大量的时间在导UI和版本更新上,而且这些基本上都是一些重复性的工作。由于一些历史原因,每次策划的一些UI修改和调整都需要前端去导UI,然后提交SVN,测试和策划更新下来才能看到效果,非常麻烦。

所以特地花了2天的时间去做这件事,其实本身我们的框架导UI和打包都挺方便的,但是现在海外版本多,还是会比较复杂,而且人工操作容易出错。

jenkins相信做游戏的都不陌生,搭建起来其实也比较简单,去官网下载一个安装包即可,但是要适配自己的项目情况,配置一下参数是比较麻烦的,特别是要做一些自动化的操作,比如复制文件自动提交SVN等。

最麻烦的是在写批处理脚本的时候遇到的一些在jenkins上比较蛋疼的问题:

一、调用unity函数打AssetBundle的过程中发现老是会出现AB还没打完jenkins任务就执行完成了,也不是执行失败,就是提前结束了,这样导致C#里面一些自动拷贝文件的函数都没法执行下去,因为UI都没有完全导完。这个问题也是困扰了我很久,因为我特地试过脚本在BAT文件上执行是没问题的,但是就是在jenkins执行的时候出问题,后面实在是没办法就,写了一个循环每隔5秒钟去检测一次导出的LOG判断UI有没有导完,在C#导出完成的时候输入一段完成的LOG信息。

批处理调用U3D导AB的命令如下:start %UNITY_PATH% -projectPath %PROJ_PATH% -quit -batchmode -logFile %LOG_PATH% -executeMethod JenkinsExportManager.ExportHotFixOnlyJenkins

其中%LOG_PATH%即是导UI过程中unity输出的LOG路径(自己定义),在这个命令后执行循环检测:

:begin
@echo off
ping /n 5 127.0.0.1>nul
echo on
echo 正在导出,请稍后!
findstr /i /c:”export end” “%LOG_PATH%” >nul 2>nul && goto end || goto begin
:end

循环查找LOG中的“export end”字符串,如果有则UI导出完成了,可以继续往下执行,不然jenkins是无法判断导出进度的。

二、第2个坑点是我们想Jenkins上导出的UI复制到FTP共享盘上,这样策划和测试都可以用,因为Jenkins是运行在打包机上的,这台机器的配置也比较高,打包和导UI的效率比较高,他们可以使用Jenkins导出UI到FTP上,然后再从FTP复制到本地,查看UI的效果,确定没有问题了再提交SVN,这样既实现了人人可以用,又保证了安全性。

但是就在这个地方又卡了我好久,因为在Jenkins上死活识别不了共享盘的路径,明明已经做了共享盘的映射,但就是识别不了,我直接在CMD上执行是可以识别的,但是一放到Jenkins上就不行了,最后在Jenkins上执行了一次享盘映射的命令:net use X: \172.16.20.35\ftp /y 才识别上,真是奇葩。

三、第3个坑点是在Jenkins上是可以设置参数然后在命令中获取的,但是如果在设置的参数判断里面定义set命令然后给变量赋值是不起作用的,这就很奇怪了。本来是想利用不同的参数值设置不同的路径实现一些自动化操作,结果导致我只能把绝对路径全部写上。

比如在Jenkins上设置了一个region参数用来选择地区,然后在命令上做如下判断:

if "%region%"=="国内" (
   set NEWPATH=C:\ui
)

结果发现变量%NEWPATH%是空的,根本赋值不了,在if外面试可以赋值的。

对于这个问题,暂时还没有找到别的方法,只能是写死绝对路径不在Jenkins参数的判断里面去赋值。

四、第4个坑点和第一个类似,也是命令还没执行完成Jenkins就结束任务了,本来是想在导出UI完成的时候,复制一些文件到某个地方的,但是经常发现文件还没复制完成只复制了部分文件Jenkins就结束任务了。对于这个问题我也只能使用命令:

@echo off
ping /n 10 127.0.0.1>nul
echo on

来达到延迟的效果了,10代表延迟10秒才往后执行。

最终配置效果如下:

从此再也不用我们前端导UI啦!前端是万能的。

关于U3D谷歌AAB分包和打包

特色

最近在搞海外相关需求,借此机会记录一下谷歌出包和上架过程。

谷歌宣布从 2019 年 8 月 1 日 开始,在 Google Play 上发布的应用必现支持 64 位架构,这个意味着如果你要上谷歌平台就不能使用Mono的方式打包,只能选择IL2CPP,因为Mono是不支持64位的。

另外上架的应用要包含多份架构的 SO 包,导致应用大小增加,以前只有 armeabi-v7a 一种 CPU 架构,但是为支持 64 位,需要增加 arm64-v8a 架构的库。

所以谷歌宣布从2021年8月份开始,所有提交到Google Play商店的新应用必须采用AAB格式,因为AppBundle(AAB)是Google在2018年推出的动态打包方式,这种包格式以.aab为文件扩展名。

通过aab包发布到 Google Play 的应用,Google Play 能自动将 aab 根据不同维度分解为一个基础包和一系列的“拆分包”,这些拆分包其实就是一个个apk,其中有分为配置拆分包、PFD拆分包和PAD拆分包。

当用户下载应用时,Google Play 会根据用户设备的特性只下发基础包和对应的拆分包,从而减小用户下载应用的大小。

同时谷歌还要求AAB包下的Base目录大小不能超过150M不然上传不了谷歌商店,不过谷歌提供了其他目录用于存放资源和代码。

这些目录分别如下:

install-time:安装时分发

fast-follow:快速跟进式分发模式,在安装游戏完成后,自动下载。

on-demand:按需分发模式,通过google API下载

现在大部分游戏资源其实都是放在Base目录下的,也就是安卓工程的src/main/assets目录, 而且基本上是超过150M的,也就是说我们开发者需要更改自己原有的资源加载方式,把大部分资源移动到install-time,fast-follow,on-demand这3个目录中,谷歌有提供相关的API去这些目录中获取资源。

其中有专门针对Unity的API和工具:

文档地址:https://developer.android.google.cn/guide/app-bundle/asset-delivery/build-unity

如果你不想更改原来的加载方式,可以把资源全部放在install-time目录下,在这个目录下的资源不需要使用谷歌的API进行加载,而是在安装的时候会自动帮你安装到手机上。

如下,把Unity导出的工程划分为以下几个目录:

然后把src/main/assets下的资源分配到install-time,fast-follow,on-demand目录,但是要注意在这3个目录下的目录结构需要和src下的目录结构一样。

比如原来的资源存放在src\main\assets\res目录,现在你要把res下的部分资源移动到install-time目录下,那你需要在install-time目录下新建一个src\main\assets\res的目录结构,然后把src\main\assets\res的资源移动到install-time\src\main\assets\res目录下,这样就大功告成了。

只要保证原来src\main\assets目录的大小加上库文件的大小不超过150M那么打包出来AAB的base目录也不会超过150M。

AAB包的结构如下:

观察发现,base目录的大小主要是assets+lib的大小,而lib下是我们的库文件,这些是无法缩减和移动的。

其实从测试的角度上来说光打出AAB包还不算真正的结束,这个时候如果你想测试AAB包是不行的,因为AAB无法直接安装在你的手机上。

所以发行那边或者测试通常会向你要一个AAB包对应的APK包,幸好谷歌有提供相关的工具把AAB包反编译为APK。

首先要去Github下载这个工具:https://github.com/google/bundletool/releases

然后使用命令:java -jar 你下载的bundletool路径.jar build-apks –bundle=你的AAB包路径.aab –output=输出apks的路径.apks –ks=安卓签名文件路径 –ks-pass=pass:ks密码 –ks-key-alias=bf_lmzh_yn –key-pass=pass::key密码

执行后等待几分钟(根据每个人的电脑性能的不同编译时间有快有慢)会在你设置的输出目录下生成一个.apks的文件,使用解压软件打开它,standalones目录下的apk文件即是你AAB对应的APK文件,把APK和AAB一起发给发行和测试,到此出包工作才全部结束。

如何让Unity3D生成的安卓包启动更快

特色

Unity生成的游戏包启动确实会比其他引擎慢些,即使打包的时候勾选了Splash Image选项,如果包比较大的话,安卓启动还是会黑屏一段时间才能看到画面。

这种情况是没办法从U3D游戏这边去解决的了,因为这是原生安卓的问题了,需要从SDK这边去解决,网上看到有些资料是在主Activity的onCreate里创建一个view来覆盖Splash,其实完全没必要这么复杂。

只需两步就可以解决:
1.在AndroidManifest文件中设置:

<activity
    android:name=".ui.SplashActivity"
    android:theme="@style/Theme.Splash"
    android:screenOrientation="portrait">
    <intent-filter>
          <action android:name="android.intent.action.MAIN" />
          <category android:name="android.intent.category.LAUNCHER" />
     </intent-filter>
</activity>


2.在style文件中定义:

<style name="Theme.Splash" parent="android:Theme.Holo.Light.NoActionBar.Fullscreen">
    <item name="windowNoTitle">true</item>
    <item name="android:windowContentOverlay">@null</item>
    <item name="android:windowBackground">@drawable/splash</item>
    <item name="android:windowFullscreen">true</item>
</style>

然后在src/main/res/drawable下放一张名为splash.png的启动图即可。这张启动图会在你启动APP之后立马显示出来,然后再显示Unity设置的Splash图。

不过这么做的话这张图会有一个问题,那就是和手机的屏幕分辨率不匹配的问题,有可能会拉伸和挤压的情况出现,其实只要这张图中间放一张LOGO其他地方是纯黑色或者白色是看不出来的,也可以使用.9的方式设置图片拉伸区域。

大大缩短了安卓APP的启动时间,据发行反馈这样体验更好。

关于Unity游戏在安卓SDK弹出时从后台返回黑屏问题

特色

今天发行那边反馈过来一个问题,是之前我们都没发现过的,当在安卓SDK登录界面的时候切换到后台或者切换其他到其他应用,再次返回游戏的时候,发现SDK登录界面后面的游戏界面黑屏了。

我的初步判断是,游戏的View应该是从后台返回到前台的时候还没被激活,因为只要点击一下SDK的登录,游戏界面就显示出来了。

查询资料发现确实是这个原因。


一、当Unity所在的Activity之上没有其他Activity时候,生命周期的变化如下:

启动App
D/MainActivity: onCreate
D/MainActivity: onStart
D/MainActivity: onResume
D/MainActivity: onWindowFocusChanged true

点Home进入后台
D/MainActivity: onPause
D/MainActivity: onWindowFocusChanged false
D/MainActivity: onStop

从后台回来
D/MainActivity: onRestart
D/MainActivity: onStart
D/MainActivity: onResume
D/MainActivity: onWindowFocusChanged true

退出应用
D/MainActivity: onWindowFocusChanged false
D/MainActivity: onPause
D/MainActivity: onStop
D/MainActivity: onDestroy

二、当Unity所在的Activity之上有其他Activity时候,生命周期的变化如下

弹窗显示另外一个Activity
D/MainActivity: onPause
D/MainActivity: onWindowFocusChanged false

点Home进入后台
D/MainActivity: onStop

从后台回来
D/MainActivity: onRestart
D/MainActivity: onStart

关闭 弹窗Activity
D/MainActivity: onResume
D/MainActivity: onWindowFocusChanged true

三、对比两次的日志打印,我们可以发现:第二次从后台返回少了两个生命周期的调用

D/MainActivity: onResume
D/MainActivity: onWindowFocusChanged true

综上分析所得,要想做到从后台返回不黑屏,只需要在SDK的主Activity的 onStart里面调用onResume和onWindowFocusChanged方法即可。

public void onStart() {
    super.onStart();
    this.mUnityPlayer.resume();
    onWindowFocusChanged(true);
}

这样当Unity所在的Activity之上有其他Activity时候,即使切换到后台再返回游戏,也不会黑屏。

unity3D Shader变灰原理

特色

使用shader置灰一张图或者一个文字的代码很简单,但是之前一直不知道它的原理是啥。

今天通过查找资料发现,其实很简单,置灰的核心代码如下:

 fixed4 frag (v2f i) : SV_Target
            {
                // sample the texture
                fixed4 col = tex2D(_MainTex, i.uv);
                //-----------add-------------
                // gray
                float gray = dot(col.rgb, float3(0.299, 0.587, 0.114));
                col.rgb = float3(gray, gray, gray);
                //---------------------------
                return col;
            }
 float gray = dot(col.rgb, float3(0.299, 0.587, 0.114));
这一句是最主要的一句,其实就是把采样出来的颜色和一个固定的颜色值做一个点乘,但是为什么是这个颜色呢?为什么要和这个颜色值做点乘返回最终的片元着色器颜色?

原理:是因为人眼对绿色的敏感度最高,对红色的敏感度次之,对蓝色的敏感度最低,因此使用不同的权重将得到比较合理的灰度图像。实验和理论推导出来的结果是0.299、0.587、0.114。
所以为图片的RGB分别乘以权重然后加起来得到一个灰色值,并且将这个灰色值作为新的RGB值
返回给最终的颜色值。 

protobuf协议字段优化后的处理

特色

我们的游戏项目中使用了protobuf协议,但是由于协议优化了的缘故,当服务器发送字段值等于0 的时候,客户端协议解析出来后看不到这个值为0零的字段,但是如果直接用“.“去访问是可以访问的。于是问题就来了当我们数据需要更新的时候,如果在lua中 直接 for in pairs 去获得解析到的数据的键值,如果这个键的值是0 的话那么那么将会获取不到,也就是说这个时候无法判断这个字段是否是 0 ,还是根本服务器就没有发送过来。

于是我们就使用看 按位与的方式去检测和判断 服务器是否发了这个字段,对此,如果是需要更新的协议服务器都要多发一个mask 字段 这是一个 二进制转换后的十进制数,协议字段按照顺序,如果发了,,则这个位置为1,否则为0,如协议:

//申请入帮
message C2GSApplyJoinOrg {
    optional uint32 orgid = 1;
    optional uint32 flag = 2;
}

如果服务器发送了 orgid = 10 , flag = 0 过来,那么客户端只能看到 orgid = 10 这个字段,而看不到 flag 这个。

所以在客户端中我们就做了如下处理:

--dOri 是服务发送过来经过客户端解析后的数据
function CNetCtrl.DecodeMaskData(self, dOri)
	local d = {}
	local lKey = {"orgid","flag"}
	if lKey then
		local iMask = dOri.mask --服务发送时带一个二进制的mask字段
		if iMask then
			local right = 1
			for i=1, #lKey do
				right = right * 2
				if MathBit.andOp(iMask, right) ~= 0 then
					local key = lKey[i]
					d[key] = dOri[key]
				end
			end
			-- table.print(d, "CNetCtrl解析mask: "..sType)
			return d
		end
	end
	return dOri
end


function MathBit.__base(left, right, op)  
    if left < right then  
        left, right = right, left  
    end  
    local res = 0  
    local shift = 1  
    while left ~= 0 do  
        local ra = left % 2  
        local rb = right % 2  
        res = shift * op(ra,rb) + res
        shift = shift * 2
        left = math.modf( left / 2) --相当于右移一位
        right = math.modf( right / 2)
    end  
    return res
end
  
function MathBit.andOp(left, right)  
    return MathBit.__base(left, right, MathBit.__andBit)
end  

function MathBit.__andBit(left,right)  --按位相与
    return (left == 1 and right == 1) and 1 or 0      
end

这样处理完成之后的数据 客户端就是可以打印出来查看了。并且可以直接使用for in pairs 去打印他们为0的值。

Static Batching、Dynamic Batching 、GPU Instancing的对比

  1. 什么是 GPU Instancing?
    GPU Instancing(GPU 实例化)是一种现代图形渲染技术,允许你用一次 Draw Call在 GPU 上同时渲染大量相同 Mesh 和材质但变换(位置、旋转、缩放)不同的物体。

典型应用:大面积草地、森林、士兵、金币、粒子等。

  1. 原理简述
    传统渲染:每个物体一个 Draw Call,CPU 需要多次提交渲染指令,效率低。
    Instancing:CPU 只发一次 Draw Call,告诉 GPU:“这是同一个模型和材质,但有 N 个实例,每个实例有不同的变换矩阵(和可选参数)。”
    GPU 在渲染时自动为每个实例应用不同的变换和属性。
  2. Unity 中的 GPU Instancing
    开启方式:在材质面板勾选“Enable GPU Instancing”即可。
    代码方式:使用 Graphics.DrawMeshInstanced、DrawMeshInstancedIndirect 等 API。
    Shader 支持:Shader 里要支持 instancing(Unity 标准 Shader 已支持)。
  3. 优点
    极大减少 Draw Call 数量,大幅提升渲染效率。
    CPU 负担小,适合大量重复物体。
    支持每个实例不同的变换、颜色等属性(通过 instanced properties)。
  4. 局限与注意事项
    所有实例必须用同一个 Mesh 和材质(但可以通过 instanced properties 传递不同参数)。
    不支持完全不同的模型或材质。
    Shader 必须支持 Instancing。
    每次 Draw Call 的实例数量有上限(通常 500~1000,受平台和 API 限制)。

微信小游戏性能评测标准

为什么需要性能评测标准?

微信小游戏性能评测标准建立的初衷是希望能引导开发者优化相关性能数据,提升用户体验。 评测标准根据小游戏整体的性能数据表现,结合操作系统、机型分档、网络条件等多种维度建立。

开发者需要关注哪些性能指标

从小游戏的运行周期来看,主要由启动和运行两个阶段产生性能问题。

  • 启动阶段:启动时长,该数据将显著影响用户打开留存率
  • 运行阶段:内存峰值、内存Crash率、CPU占用、流畅度、网络等
  • 其他兼容性问题,包括JS异常、黑屏等严重问题

评测环境与方法

从游戏的生产过程来看,我们主要从开发与现网两个环境进行评测。

开发阶段:

  • 评测过程的客观环境更为稳定(比如固定的机型基线,网络环境等)
  • Profile数据更为详细,方便掌握性能细节

现网阶段:

  • 基于统计角度进行评测,从整体采样数据取反映游戏质量
  • 覆盖开发测试阶段无法预估的业务场景,比如网络异常、特定条件下的JS异常等

评测标准细则

品类评测标准

评测标准更新时间:2024-08-05(历史现网标准请查阅历史评测标准

评测标准依赖于现网真实玩家上报的性能数据,并结合游戏所属品类进行分类统计。

品类游戏玩法评测标准
大盘全部大盘评测标准
休闲消除、答题、模拟经营、塔防、捕鱼、益智休闲品类评测标准
角色卡牌、MMO、ARPG、回合、战争策略角色品类评测标准
棋牌棋类、牌类棋牌品类评测标准
动作跑酷、竞速、音乐舞蹈、益智、飞行射击、体育、对战动作品类评测标准
竞技MOBA、枪战、桌游、对战竞技品类评测标准
其他其他其他品类评测标准

评测方法

性能基线

评测小游戏性能首先需要确定性能基线, 即先确定机型设备条件,开发者可通过机型档位映射获取机型档位的参考机型。

现网阶段

性能报告

为了能够帮助开发者快速了解游戏整体的性能情况,平台通过对评测标准和游戏性能数据的整合,面向开发者提供一个较为全面的大盘性能监控系统,详细可通过 性能监控系统 进行了解和使用。

性能数据

开发者可通过 小游戏数据助手(数据-性能分析)或 MP-研发工具箱-性能数据 获取游戏的现网玩家的性能采集数据:

开发阶段

此阶段为当前小游戏新版本还未上线时进行评测的方法,开发者可以利用PerfDog 或小游戏云测试进行数据获取,并参照性能基线和云测性能标准进行性能验证。

云测试评测标准

评测标准更新时间:2024-08-05(历史云测试评测标准请查阅历史评测标准

评测工具
PerfDog

测试方法说明:
1)启动性能:采用录屏分帧方法获取,取10次测试平均值
2)运行性能:完成游戏主流程对局5~10min, PerfDog记录性能数据并上传,取平均值,每种机型测试3组数据再取平均,内存峰值取最大值

小游戏云测试

云测试服务是一套完整易用的在线测试服务,以帮助开发者更高效、更全面地进行自动化游戏性能测试、兼容性测试。更多详情可查阅小游戏云测试

影响性能的客观因素

机型分档

更新时间:2024-08-05(历史机型分档占比请查阅文档

微信小游戏的玩家所使用的机型设备也是千差万别,但机型设备又是极为影响性能评测的一个因素。因此,我们需要对众多的机型设备进行一致性分档,唯有此才能更好的定位出现性能瓶颈的设备。
目前我们的机型分档主要参考机型的CPU、GPU、内存等硬件因素进行分档,将设备分为高中低三档,涵盖>99%以上用户数据,开发者可通过如下途径获取平台用户设备的相关数据:

网络环境

目前微信小游戏网络主要为WiFi和5G类型,更多网络概况可通过《微信小游戏开发者技术手册-设备兼容篇》 进行了解。

微信版本与基础库版本

微信客户端iOS与Android以各自不同的迭代速度更新版本,基础库与客户端之间的关系可查阅《基础库》
公共库版本迭代节奏较快,每个版本都会带来新特性与已知BUG的修复。现网的版本分布可查阅《基础库版本分布》

没有公网IP如何实现内网服务器访问?

发现好久没写博客,准备把我的阿里云服务器降一下配置,因为使用率不高,只是挂一个博客而已,多出来的40GB数据盘可以释放掉了,这样每个月大概可以节省14块钱,虽然不多,但是以年为单位算的话累计起来还是挺多的。

后续打算在家里搭建一个内网服务器,使用FRP内网穿透实现外网访问内网。毕竟阿里云服务器有太多局限性,带宽、云盘、CPU、内存每一个都要单独算钱。

不过也不能完全不用,因为现在三大运营商的宽带都不给你公网IP地址了,没有公网IP地址意味着你无法把内网的服务器提供给外网使用,后续需要搭建服务器或者NAS都很困难。

最近几天打电话给电信说了很多就是不肯给公网IP,说现在开固定IP的宽带要拉专线,一个月700多块钱,我听了快吐血,普通家庭谁用得起一个月700的宽带,这不是变相的捆绑销售吗?

虽然说IPV4地址匮乏,但是也不至于这样来恶心我们。

还好作为一个程序员,这难不倒我,你不给我公网IP,那就另找方法,用阿里云的服务器搭建一个FRP服务就行了,这样远程桌面可以使用XTCP实现点对点通信,也不消耗阿里云的流量。

搭建完成后发现这个远程桌面比向日葵好用太多了还不会限速,还用什么第三方远程软件啊,真是香,后悔没有早点搭建。

接下来就是给家里搭建一台内网服务器+NAS,实现外网正常访问。

海明码的编码和校验方法

  海明码(也叫汉明码)具有一位纠错能力。本文以1010110这个二进制数为例解释海明码的编码和校验方法。

  编码

  确定校验码的位数x

  设数据有n位,校验码有x位。则校验码一共有2x种取值方式。其中需要一种取值方式表示数据正确,剩下2x-1种取值方式表示有一位数据出错。因为编码后的二进制串有n+x位,因此x应该满足

2x≥n+x+1   

  使不等式成立的x的最小值就是校验码的位数。在本例中,n=7,解得x=4。

  确定校验码的位置

  校验码在二进制串中的位置为2的整数幂。剩下的位置为数据。如图所示。

位置1234567891011
内容x1x21x3010x4110

  求出校验位的值

  以求x2的值为例。为了直观,将表格中的位置用二进制表示。

位置00010010001101000101011001111000100110101011
内容x1x21x3010x4110

  为了求出x2,要使所有位置的第二位是1的数据(即形如**1*的位置的数据)的异或值为0。即x2^1^1^0^1^0 = 0。因此x2 = 1。

  同理可得x1 = 0, x3 = 1, x4 = 0。

位置00010010001101000101011001111000100110101011
内容01110100110

  因此1010110的海明码为01110100110。

  校验

  假设位置为1011的数据由0变成了1,校验过程为:

  将所有位置形如***1, **1*, *1**, 1***的数据分别异或。

  ***1: 0^1^0^0^1^1 = 1

  **1*: 1^1^1^0^1^1 = 1

  *1**: 1^0^1^0 = 0 

  1***: 0^1^1^1 = 1

  以上四组中,如果一组异或值为1,说明该组中有数据出错了。***1 **1* 1***的异或都为1,说明出错数据的位置为1011。

Unity Shader模板测试用法记录

之前经常忘记模板测试的用法,现在把这些用法记录在博客上免得以后忘记了。

在Shader的Pass开头写Stencil{ }结构体,如果每个Pass都用,则可以提到外面。

Stencil {  
                Ref 2                     //参考值为2,stencilBuffer值默认为0  
                Comp always               //stencil比较方式是永远通过  
                Pass replace              //pass的处理是替换,就是拿2替换buffer 的值  
                ZFail decrWrap            //ZFail的处理是溢出型减1  
            }  

Ref 2:设置参考值。除了2,还可以设置0-255的任意数。

Comp equal:表示通过模板测试的条件。这里只有等于2的像素才算通过测试。除了equal,还有Greater、Less、Always、Never等,类似ZTest。

Pass keep:表示通过模板测试和Z测试(注意是都通过)的像素,怎么处置它的模板值,这里我们保留它的模板值。除了keep,还有Replace,IncrWrap(循环自增1,超过255为0),IncrSat(自增1,超过255还是255),DecrWrap,DecrSat等。

Fail decrWrap:表示没通过模板测试的像素, 怎么处置它的模板值。这里为循环自减。

ZFail keep:表示通过了模板测试但没通过Z测试的像素,怎么处置它的模板值。这里为保持不变。

除此之外,还有ReadMask和WriteMask语法,用来提取Ref值或模板缓存的某几位,默认是255,全部提取。

Unity中UGUI空格不自动换行处理

在UGUI的Text组件中,文字超出宽度会自动换行,如果有空格则会从空格处自动换行,在英文中这种方式是完全没有问题,但是某些语言比如泰文,是不需要在空格处换行的,那就需要特殊处理了,可以通过使用富文本的方式把空格替换掉,比如使用Replace(” “,”<color=#00000000>.</color>”)的方式把空格替换为一个透明的点。

如果不想使用富文本也可以使用unicode字符“\u00A0”,比如Replace(” “,”\u00A0”)替换我们常用的空格,或者使用全角空格替换半角空格。因为我们常用的空格是半角空格,这三种方式都可以实现替换空格后保留空格的效果并且不在空格处自动换行。

我们项目的发行商最近反馈泰文的换行问题,我暂时是使用的“\u00A0”替换字符的,感觉还不错,没出现其他问题。