A. 网络相关知识
一、TCP
1、面向数据流。可靠。能保证消息到达顺序。
2、滑动窗口。控制发送量,发送方只能发送窗口内大小的数据包。防止发送方发送的数据过多,接收方无法处理的情况。
3、Nagle算法。默认tcp发送包会将数据包合并到一起发送。好处是性能更好。坏处是会有延迟发送(100ms)的可能。在实时游戏的应用,应该禁用Nagle算法,消息及时发送。
4、三次握手。
第一次握手,客户端发送连接请求(SYN)。
第二次握手,服务器回应客户端的连接请求(ACK+SYN)。
第三次握手,客户端回应服务器(ACK)。第三次握手主要是为了避免服务器收到过期的连接请求,建立无效连接,导致资源浪费。只有经过客户端回应的,明确有消息要发送的,才会真正建立连接。
5、四次挥手
第一次挥手,客户端请求释放连接(FIN)。此后客户端不再向服务器发送数据,但是可以接收到服务器的数据。
第二次挥手,服务器回应客户端释放连接的请求,把剩余数据发送完毕(ACK)。
第三次挥手,服务器告诉客户端已经可以断开连接了。(FIN+ACK)
第四次挥手,客户端回应服务器,之后正式断开连接。(ACK)
6、建立连接的时候,第二次握手其实包含了两个操作,对客户端的应答和通知客户端可以建立连接。
而断开连接的时候,因为有数据要处理,所以先对客户端进行应答,处理数据。然后再通知客户端可以断开连接。
所以握手是三次,而挥手是四次。
7、优雅的跟服务器断开连接
a、我们在对socket的封装会有一个缓存,发送数据包不是理解丢给socket,而是拷贝到缓存里面,每帧发送数据。这么做的好处是有利于io。
所以在关闭连接的时候,客户端首先要保证所有的数据都交给socket发送,并且确认服务器收到了,然后才可以关闭socket。
b、关闭过程,先进行 socket.Shutdown。这个接口有个参数可以控制是关闭双向连接,还是只关闭接收连接。我们是简单处理为直接关闭双向连接。
c、调用 socket.Close。释放句柄。之前我们关闭socket的时候只调用了Close而没有Shutdown。这可能会导致服务器没有收到断开连接的消息。
8、现在判断网络连接是否断开我们是使用 socket.Connected 属性来做判定的。不过这个并不准确。只要正常连上,这个属性就为true。但是并不意味着可以正常发送消息。
另外一个判定网络连接的方法是通过socket.Poll 来取数据,能取到数据才证明网络连通。
二、UDP
1、面向数据包。不可靠。不能保证数据包的到达顺序。速度快。
2、KCP。来实现可靠的UDP协议。通过重发策略,用多30%流量的代价,换来成倍的性能提升。
三、Https
1、Http、Https、Get、Post
1.1、我们跟游戏服务器的连接都是TCP长连接,但是跟平台登录认证服务器的连接是Http短连接。像登录认证这种一次性的请求,没有必要使用长连接,长连接会占据socket句柄。
http并不保证安全,可能会被抓包,伪造数据,或者是运营商CDN拦截等等。而https就安全很多。所以我们正常对外都应该使用https。苹果和Android陆续都有政策,禁止http的访问。
1.2、Http.Get和Http.Post是两种请求方式。具体差异可以多参考网上的文章。我们只需要简单的理解为Get就是参数都在Url里面发送给服务器。而Post的参数都放在Body里面。
Get的参数在Url里面,玩家在浏览器或者抓包的时候就非常容易看出参数的格式和内容。而且参数大小也是有限制的。
而Post的参数放在Body里面,就解决了上面的问题。所以涉及到账号密码传递,或者给服务器上传一个图片数据、iOS充值的收据数据等等,都应该使用Post。
1.3、URL Encoding
正常Url格式是:
https://www.xxxx.com/aa?player=123&level=456
有一些字符是保留字符,可以理解为关键字,是不能在url中使用的。比如空格。
URLEncoding所做的工作,就是把这些字符编码为可使用的字符。比如空格会被编码为 "%20"。
其他一些保留字符,比如 "?"-->"%3F"、"/" --> "%2F"、"&"-->"%26"、"="-->"%3D"等。所以我们在Url中经常会看到 %20 这样形式的字符。
1.4、Base64编码
Base64编码常用于网络传输二进制数据。像图片数据、iOS收据等,都是经过Base64编码再传递给服务器。
很多协议其实是纯文本协议,比如SMTP协议。如果直接传递二进制数据,可能会遇到控制字符,导致传输失败。这种情况下需要将二进制数据转换为字符形式,然后再发送给服务器。
1.5、Unity下现在进行Https通信,使用的是 UnitWebRequest。原来的WWW被废弃了。
2、Https下载
Https下载是游戏的基础功能,有几个细节点,单独提出来说一下。
2.1、我们下载补丁也是 Https 连接CDN进行下载。内网可能没有配置合法的Https证书。这种情况下我们应该禁用证书检查。
ServicePointManager.ServerCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) => { return true; };
2.2、正常CDN会有缓存机制,有的时候不止CDN,运营商(比如电信运营商)也会有缓存。如果我们替换了一个文件,比如 patchconfig.txt,有的时候CDN返回的会是旧的文件。
解决方法是在请求的url地址后面加个 ?v=123 这样的参数。
https://www.xxx.com/pathconfig.txt?v=123。
重点是123这个参数,根据版本变化。这样就可以保证我们更新版本之后,取到的文件都是新的。
类似的,我们是禁止替换已经发布到CDN上的zip补丁包的。真有严重问题,需要替换的话,需要运维强制刷新CDN。不过这个只能保证CDN是新的,而运营商也可能会有缓存导致在某些地区取到的还是旧的文件。
如果有一个文件我们期望禁止缓存,每次都重新取最新的,那么后面的参数改成随机数+时间戳就行。
2.3、我们游戏内下载补丁是基于 HttpWebRequest 封装了一个FileDownloader。没有使用 UnityWebRequest 是因为,这个api在进行http通信的时候比较方便,但是它提供的监控下载进度的回调接口是在主线程的,如果用这个接口会因为频繁的磁盘io导致主线程卡顿。
如果是下载整包apk,可以使用Java原生的下载库,比如Okdownload。它的好处是可以后台下载。甚至可以开服务,游戏进程杀掉,依然可以正常下载。
四、关于游戏通信对Tcp、Udp、Http的选择
1、早期卡牌游戏可以使用Http。它是短连接,只能客户端向服务器发起请求。服务器很难主动通知到客户端。如果有一些事件是等待服务器执行完毕,那么就只能通过客户端轮询。
好处是不用关心断线重连。服务器不维护socket连接,也就不会担心因为socket句柄数量限制了一台机器的在线人数。
http协议头比较大,速度慢,不太适合对及时性要求比较高的游戏。
2、现在正常游戏都是使用tcp。tcp是长连接。客户端和服务器之间通过网络消息进行通信。tcp能够保证消息的顺序。如果有丢包,会进行重传。
3、部分对及时性要求非常高的游戏,比如王者荣耀这样的MOBA游戏,或者联网的格斗游戏,可以使用udp。
udp是不可靠的,但是速度非常快。因为它不用关心是否有丢包,也不会重传,也不会像tcp一样,某个包一直无法发送成功会阻塞其他包。
4、一般使用udp的游戏,都会在上层在维护一个包重传机制,这里推荐的是kcp。它是一个可靠的udp协议。
五、消息协议
1、tcp是数据流,我们收到的数据可能包含不止一条消息,也可能存在不完整的消息。我们需要定义好消息头,并且根据消息头解析出完整的消息。消息头最核心的是 消息长度+消息Id 。通过这两个信息就能够确定哪些是一条消息,是什么消息。网上常说的粘包处理就是这个过程。
2、使用protobuf做消息协议。好处是:
2.1、客户端和服务器用的协议是一致的。避免人为修改协议导致消息结构不一致。如果是C++的话,可以自动生成读写的代码。
2.2、protobuf自带哈夫曼压缩。可以减少消息大小。压缩后是二进制格式,也可以增加工作室破解难度。(补充一下,proto编译的pb文件,一定要再做层加密,否则几乎跟明文一样,很容易就被反编译出消息结构了)
2.3、protobuf是向前兼容的。只要不修改消息序号,添加新的消息字段不影响之前的客户端解析。
2.4、解析速度很快,比json要快。
3、我们Lua里使用的解析protobuf的库是pbc。没有使用sproto是因为sproto只有lua的接口。而我们还有很多C++解析的需求,很多服务器内部通信或者执行非常频繁的消息,都是C++直接处理的。
4、修改pbc的源码,decode的时候解析全部字段。默认pbc只会解析一层结构。其他嵌套的结构使用一个字符串保存,等访问到的时候再展开解析。当我们定义的消息结构中有Vector3这样频繁使用的结构的时候,这么设计反而是性能低下的。使用全解析,把所有的结构都展开。
5、客户端增加消息缓存。频繁使用的消息,在发送或者接收之后,保存起来,等用到的时候重置下参数,然后交给pbc使用。这样可以很大程度上减少lua的gc。
6、还有一种通信方式是RPC。它可以使用函数的方式完成客户端和服务器,或者服务器内部的通信。传递的数据就是函数的参数。一般是没有返回值的。
一般服务器内部通信适用于RPC,多个进程之间以函数调用的方式进行通信,写起来非常方便,不用额外定义或解析消息协议。
客户端和服务器如果是一套框架,最好是静态编译语言,也可以使用RPC。比如UE4中客户端和服务器通信就是使用的RPC。同样不需要定义协议。
但是如果是脚本语言,或者客户端使用Python,服务器使用C++,这样框架不同的架构,就不太适合使用RPC。参数改了,出现不一致了,也很难发现错误。脚本语言是弱类型的,也很难知道参数的具体类型或者结构是什么。只能依赖注释。而注释又不具备强制性,万一注释错了,也不会导致编译报错。总之这种情况下,使用RPC会降低开发效率,增大出Bug的可能。
六、Socket的同步/异步、阻塞/非阻塞
1、同步模型,调用Send接口,直接得到发送结果。而异步模型是在回调里面得到发送结果。
2、阻塞模型,调用Send接口,线程卡死,直到发送完毕。非阻塞模型,则是发送多少直接返回,不会有卡死线程等待的过程。
3、我们之前做端游的时候,用过同步非阻塞的socket模型。好处是所有的调用都是在主线程执行。主线程不停Update处理消息,不用关心多线程同步,由于是非阻塞模型,也不会卡住主线程。
4、在C#中,我们常用的模型是异步阻塞模型。其他用法都或多或少存在一些问题,不太好用。
核心接口是 BeginSend和BeginReceive。
在主线程调用BeginSend,它会产生一个子线程,socket.EndReceive 函数会挂起子线程,直到发送完毕。
使用异步模型,要处理好子线程和主线程的同步。简单来说就是对成员变量的buffer和size进行操作的时候一定要加锁。
5、BeginSend这个接口存在一个性能问题。它回调中产生 IAsyncResult 对象没有缓存池机制,所以会产生一定的GC Alloc。所以,更加推荐的替代接口是 SendAsync和ReceiveAsync。
七、实际项目的经验
1、善用缓存buffer。发送和接收数据都有相关的缓存buffer。比如发送数据不是调用Send接口就直接发送给socket底层,而是拷贝到sendBuffer上,每帧统一发送数据。这么处理有利于减少io操作。
2、使用异步socket。我们使用的是BeginReceive接口。还有ReceiveAsync接口。其实更加推荐后面这个,因为它可以避免 IAsyncResult 对象的频繁创建,有利于gc。
还有个同步非阻塞的接口。不过C#中并不好用。
3、异步接口的回调是在子线程中执行的。所以一方面要处理好线程同步,拷贝buffer的时候要加锁。另外一方面在回调线程中不能调用Unity和lua的任何东西。都要统一在主线程处理。
4、发送数据 sendBuffer 的处理相对简单一些。lua中有消息发送就把数据拷贝到buffer上。每帧Flush。发送完毕就把已发送的数据移除。
5、接收数据有几个buffer。一个是socket用的接收数据缓存 receiveBuffer。在回调中会把数据从 receiveBuffer 拷贝到 msgBuffer 上。在主线程会解析处理 msgBuffer。解析到的每个消息会拷贝到 msgData 进而传递给lua。
6、socket.Connect 是没有超时机制的,这个要在上层自己做。超过一定时间还没有回应,则判定超时。
7、禁用nagle算法。socket.NoDelay=true
8、域名解析的时候,优先使用ipv4的,如果没有ipv4的,则使用ipv6的地址。ipv6主要用于苹果审核。苹果审核需要游戏支持ipv6 only的环境,可以用Mac系统模拟对应环境,socket的连接地址不能直接使用ip地址。需要用域名做兼容。如果不用域名,那么就需要同时提供ipv4和ipv6的地址。
9、接收到的一条消息的bytes是如何传递给lua的。这个是最常见和底层的需求。
9.1、最早我们是直接用 LuaFunction.Call 这个接口。这个接口是有装箱操作的接口,并不推荐使用。
9.2、使用xlua提供的Action接口。这个是泛型接口,避免的装箱和拆箱的操作。其实我们所有的对Lua的函数调用,都应该使用 Action 接口。如果 Action 接口参数不匹配,就扩展下泛型参数。更进一步的,对于常用的类型,可以补充对应类型的 Action 接口实现,这样连泛型匹配类型的操作都可以省去。
9.3、xlua处理bytes类型的时候,是把整个bytes都传递给lua。所以在回调Lua的时候,不能把缓存池直接传递给Lua,而应该new一份新的bytes,否则明明很小的一条消息,都需要把256k的缓存池传递给lua,产生对应的lua内存,显然是不合理的。这部分可以参考 LuaDll.cs中的 lua_pushstring 函数实现。
9.4、针对以上问题,我们最终的实现是,参考Action,实现一个 CallWithMsg 的接口。可以指定传递给lua的字节长度。这样就可以直接使用缓存buffer,避免了很多无意义的GC Alloc。
这里唯一内存开销,是 lua_pushlstring 会在lua状态机里面产生对应的string拷贝。当然这个是无法避免的。
B.算法相关知识
(实在不熟,此篇请忽略)
1、快速排序实现原理 时间复杂度 n LogN
2、红黑树实现原理
2.1、节点是红色或者黑色。
2.2、根节点是黑色。
2.3、每个叶子节点都是黑色的空节点。
2.4、每个红色节点的两个子节点都是黑色。
2.5、从任意节点到其每个叶子的所有路径都包含相同的黑色节点。
3、广度优先搜索 深度优先搜索 A*
广度优先搜索(Breadth First Search,BFS)。队列实现。最短路径。A*搜索。先搜索自身周围的格子,然后再进一步搜索更外围的格子,知道最后所有格子都走到。
深度优先搜索(Depth First Search,DFS)。堆栈实现。沿着一条路走到底,然后再走另外的路。
4、动态规划
5、屏蔽词库的实现算法
DFA算法(Deterministic Finite Automaton,确定有穷自动机)。
构建一个屏蔽词树。有效减少需要比对的字符。
6、红点树。有效处理父子节点的红点显示关系。
7、二叉树的前序遍历:对于一个节点,先输出节点自身,再输出左节点,最后输出右节点。
中序遍历:先输出左节点,再输出自身节点,最后输出右节点。
后序遍历:先输出左节点,在输出右节点,最后输出自身节点。
C. 平台相关知识
一、Android
1、如何编写 Android Native代码。
1.1、如果是C/C++代码的话,交叉编译成不同cpu架构的.so。放到 Plugins/Android/libs/arm64-v8a
armeabi-v7a x86等目录。推荐直接在Mac下编译就行。C#的调用方式就是正常的C#调用C++的代码。
1.2、如果是Java代码的话,可以使用Android Studio,将 Java工程编译成.aar的代码。.aar就相当于.jar+资源。注意,编译的sdk版本,build tools版本都应该跟游戏工程保持一致。
1.3、CMake可以通过CMakeLists.txt的配置,生成各个平台IDE的编译工程。比如Windows下是VS工程,Linux下是makefile,Mac下是xcode工程。它省去了人们去维护不同工程的代码、依赖、编译选项等等复杂繁琐的过程。
xlua就是通过CMake编译各个平台的动态库的。
2、AndroidManifest.xml关键内容说明
2.1、uses-permission 配置权限。比如访问摄像机、访问麦克风等等。Android 6以上是运行时权限管理。在这里配置了,真正访问的时候还是要弹出提示框让玩家允许。为了防止玩家拒绝,很多游戏或者应用在弹权限授权窗口之前,会先弹一个提示说明框,告诉玩家后面申请的权限是用来做什么的,这样用户更大的概率会允许权限。
Android 6以下是在安装的时候提示用户。用户只能选择是允许安装还是禁止安装。现在普遍应用商店都需要 Android 6 以上的支持了。
2.2、android:targetSdkVersion 这个是个很关键的属性。它代表当前打包的app的目标系统版本是多少。比如,即便我用最新版本的sdk打包。如果targetSdkVersion设置为21,那么我们的app也是不支持运行时权限管理的。
apiLevel=23 对应 Android 6。最大的变化是支持运行时权限管理。
apiLevel=27 对应 Android 8.1。最大的变化是Notification 推送通知,增加了 Channel机制。一些列通知可以放在一个Channel下。
apiLevel=28 对应 Android 9。最大的变化是官方sdk支持了异形屏相关接口。之前都是不同手机厂商自己做的功能支持,接口也千奇百怪。
3、兼容异形屏
3.1、AndroidManifest.xml 中设置 max_aspect
在android9之前,不同的手机厂商也有自己的设置参数。
3.3、在Android 9之前,不同厂商有自己的异形屏适配方案。比如华为就是设置 android.notch_support 为 true。
同样,判定是否是异形屏也是不同厂商有不同的判定接口。比如华为的判定接口是
* com.huawei.android.util.HwNotchSizeUtil
* public static boolean hasNotchInScreen
这些由于不是官方sdk的api,所以应该通过反射进行调用
4、Gradle
4.1、Android的app打包,使用的是Gradle。它可以理解为是Java界的CMake。处理依赖包,编译,打包。语法是 Groovy。在Gradle出现之前,人们使用的是Ant和Maven。不过Ant配置繁琐(使用xml)。Maven支持从网络上下载依赖包。而Gradle则结合了两者的优点,既能够管理依赖,又能够构建项目。所以现在Java的构建工具普遍都是用Gradle。
4.2、Unity打包会有一个默认的gradle配置。如果需要自己做一些配置,比如增加一些依赖库,可以将Unity默认的配置模板复制到 Plugins/Android/mainTemplate.gradle。
4.3、一些关键性的修改。比如添加阿里源。这个可以避免网络稳定导致打包时间过长或者失败。
repositories {
maven {url 'http://maven.aliyun.com/nexus/content/repositories/central/'}
jcenter {url 'https://maven.aliyun.com/repository/jcenter'}
}
4.4、配置ab包不压缩
aaptOptions {
noCompress '.unity3d', '.ress', '.resource', '.obb', '.ab', '.db', '.mp4', 'android'
additionalParameters "--no-version-vectors"
}
5、Android Support库
5.1、我们在高版本sdk上进行开发,用到了高版本的特性,低版本系统上不具备相关特性或者是接口。如何保证我编写的app能够正常的在低版本的系统上运行。
为了解决这个问题,Android设计了support库。用来处理对低版本系统的兼容支持。
比如我们用到了运行时权限的api。如果使用 ActivityCompact 中的接口,那么它就会做判定,在apiLevel < 23 的设备上,就直接返回 true。>=23的设备上才会触发权限请求。
5.2、因为之前的 android.support.v4/v7/v13 等库设计的过于凌乱。所以Google退出了新的 AndroidX,它是之前support库的替代品。在 gradle.propertise 中配置 android.useAndroidX=true。可以使用AndroidX。
5.3、我们接的很多渠道sdk,里面就包含了support v4的库。如果我们自己游戏里面就有用到support v4,那么这个一般是可以删掉渠道sdk里面的v4库。多个不同版本的support v4库可能会产生冲突。导致编译报错或者是运行报错。
6、Android下的dex函数数量限制
6.1、classes.dex是有最大函数数量的限制。65535个。当应用做的比较大的时候,这个很容易就超出了。
6.2、为了解决上述问题,Google提供了 MultiDex 的功能。dex可以拆分为两个文件。
6.3、一般情况下我们用Unity打包游戏Java函数就几千个,很少会超出限制。不过如果我们接了渠道sdk,他们很可能会引入support v4/v7的库。Java函数一下子可能就变成几万个,就可能超出数量限制了。
6.4、如果个别发行商或者渠道不能使用 MultiDex。那么我们就需要裁减函数。使用 proguard可以混淆或者裁剪代码。Plugins/Android/proguard-user.txt,在 Player Settings 里面设置好这个文件。具体配置格式可以参考网上的 proguard 教程。需要留意的是,大多数我们自己写的代码都是需要保留的,都要配置上 keep。只有系统sdk的内容是可以裁剪的。
7、Android的推送通知和本地通知
7.1、暂时没有太多可以说明的。推送通知一般使用极光推送这样的第三方服务。
7.2、本地通知。通过 AlarmUtils.addAlarm 开始计时,时间到了会响应 OnReceive 接口。在这个接口里面使用 NotificationManager 注册通知。点击通知的处理为打开游戏。
注意,这里需要判定游戏是否运行在前台,如果运行在前台,则不应该弹出通知。
8、Android原生的下载实现
8.1、我们正常下载补丁是游戏内通过 C#的 HttpWebRequest来实现的。而如果是下载apk这样比较大的文件的话,还可以使用Android原生下载。推荐的开源库是 okdownload。
8.2、原生库,一方面是经过验证的高效稳定的实现。另外更关键的是支持后台下载。也就是游戏切后台也可以继续下载安装包。玩家此时可以做别的操作。更进一步的,可以开个下载服务,即便游戏进程杀掉,也可以继续下载。像头条、抖音中下载推广的游戏,都是开了一个服务来下载的。
9、obb 和 app bundle
9.1、Google Play的应用商店上传限制100MB大小。所以很多游戏会把资源拆分到obb包里面。obb包可以是任意格式。游戏自己维护加载。
下载的扩展文件会保存到 /Android/obb/
删除游戏后,obb也会一起删除。
9.2、国内应用商店没有大小限制,所以国内的包普遍都没有用过obb。
不过apk包本身有个2G的大小限制,所以当包体积超过2G,还是要拆分资源。多余的资源,可以运行时下载,就跟微端或者更新包差不多。
9.3、app bundle可以类比为iOS的bitcode。用户上传app bundle。Google Play会根据机型、设备,构建不同的apk,既可以减少包体积,又有利于针对性的性能优化。
Unity和Unreal都支持app bundle。不过国内的应用商店还都不支持 app bundle。所以现在还没有大规模使用。
10、渠道二次打包
10.1、国内渠道繁多,一般首发游戏都需要面对十几个渠道,每个渠道都有自己的sdk。所以对接渠道sdk也是很耗时的工作。
10.2、为了减少程序的开发量,快速对接sdk。就出现了棱镜Sdk、AnySdk等All in one的Sdk。开发者只需要接入AnySdk的包。其他的渠道AnySdk会自动对接。
10.3、渠道sdk涉及到登录留存数据、充值数据等机密内容,交给第三方服务总归会有些不妥当。所以我们参考U8SDK自己实现了一套渠道二次打包的流程。
10.4、游戏接入空的框架 jar,包含登录、充值等接口。只不过没有实现具体功能。框架去对接各个渠道的sdk。
打包流程是我们先生成一个客户端母包apk。然后使用二次打包工具基于母包apk打各个渠道的apk。在此过程中,可以定义包名、游戏名、游戏图标、签名文件等等内容。
打包的原理是使用apktool解包apk,替换框架jar为实际渠道jar,然后再次使用apktool打包和签名,得到渠道版的apk。
10.5、有个别渠道商店,会在我们上传apk后,做类似的操作,替换签名文件。所以如果我们使用了一些加固服务,做了禁止更换签名的限制,在上这些渠道的时候会有问题。
二、iOS
1、充值
1.1、充值的基本流程是,向平台请求订单号,然后调用addPayment进行充值,监听iOS的回调,获取充值状态。如果充值成功,则发送收据给平台进行校验。平台校验通过之后,通知服务器发货。
在充值过程中,只有明确收到平台的回应,才会结束交易。这样万一充值过程中客户端闪退,重启之后,系统会再次发起充值流程,直到结束交易。
1.2、代充的问题。之前我们出现很多第三方代充、退款。后面通过一些技术手段解决了这个问题。
SKProduct有一个locale对象,可以获取到商品的国家地区。如果是非法地区,比如新加坡的退款政策比较开放,那么就禁止充值。
小额充值限制。每天6元钱只能充值30笔,防止小额代充。
1.3、掉单的问题。主要有几方面的原因:
主要原因是网络问题,平台校验失败。我们后面增加了服务器重传机制,会对没有校验成功的商品重复发起校验请求。
业务层主要的掉单原因是平台的orderId无效。这里有bug原因,也有网络异常原因,或者极端情况下苹果系统异常。我们之前想把一个orderId和一个苹果的交易id一一对应。后面发现没有太大意义。只要苹果系统通知交易成功,就会有合法的收据和交易id,玩家都是付过钱的。这个时候没有orderId就向平台再请求一个就好了。如果这里直接因为orderId不存在就判定失败,多数情况下就会掉单。
极端情况下,一次点击行为可能产生多次成功的交易,玩家也真正的付过两次钱。这种情况就肯定会出现orderId不存在的情况。
2、如何编写iOS的Native代码
2.1、在 Plugins/iOS 目录下,放一个 XXX.m 或者 XXX.mm 的文件。里面可以写Objective-c的代码。
extern "C" 的函数,可以暴露给C#访问。这里就是普通的C#调用C++的函数的过程。
2.2、同样可以用 swift 来实现对应的代码,或多一层swift接口导出的过程。原理一样。
2.3、.cpp 的源代码也可以放到这个目录下,Unity打包的时候会把这些文件加入到xcode工程中。最终编译到可执行文件中。
2.4、.mm的代码中,通过 UnitySendMessage("go对象名", "函数名", "字符串参数") 回调给C#。注意最后的参数要是一份copy的内存,而且不能为nil。否则会导致闪退。
3、如何判断异形屏
3.1、一开始直接通过iPhone的设备版本来判断。SystemInfo.deviceModel。"iPhone10,3" 就是iPhoneX,是异形屏。
3.2、随着异形屏的设备越来越多。改为通过判定 safeArea 来判定
CGFloat height = [[UIApplication sharedApplication] delegate].window.safeAreaInsets.left;
height > 0 则为异形屏。
4、上传到AppStore
4.1、可以导出ipa,然后用Transporter上传(原来是Application Loader不过后面废弃了)。
4.2、可以在Jenkins下用命令行上传。最新的上传命令是 xcrun altool --upload-app。
4.3、手动在Xcode--Organizer 中上传。不过不太推荐,速度慢,不稳定,还没有进度详情。
三、Windows
(一)、怎么打Windows平台的安装包
1、Inno Setup 使用起来比较方便,使用Pascal做脚本语言。
2、我们使用的是 NSIS。它是自定义的脚本格式。选用这个主要是因为NSIS有个duilib的插件,可以做出自绘的安装程序,可以参考镇魔曲手游桌面版的安装包样式。
3、可执行文件要进行签名。包括自己编译的的dll(如xlua.dll)、游戏可执行文件、其他可执行文件(比如Cef渲染进程文件)。最后安装包也要签名。
4、安装过程中 d3dcompiler_47.dll、msvcp80.dll等文件解压过程会被360拦截报警。我们打包的时候这些文件先移除dll的扩展名,安装完毕之后再把这些dll的扩展名恢复。用这种方法绕过了360报警。
(二)、Windows下如何禁止多开
1、Unity BuildPlayer里面就有一个SingleInstance的选项。不过做的比较简单,它是根据文件路径做信号量的。拷贝游戏到一个新的目录,就可以多开了。
2、我们在游戏启动的时候,检测 Mutex (信号量),如果发现冲突,则证明已有游戏进程在运行,此时直接退出游戏。信号量是可以绕过的。
3、监控进程列表,查找有没有当前 productName 的进程,如果有的话,则退出游戏。这个可以通过修改进程名绕过。
4、遍历窗口标题,发现跟productName一样的窗口名,则退出。这个会更加保险一些。不过依然可以通过一些hook机制绕过。
5、以上这些方法,其实都不能百分百保证禁止多开。工作室可以通过虚拟机、沙盒环境等等方式来多开。技术强的工作室,甚至可以修改驱动。
(三)、Windows下的一些特殊处理
下面这些操作主要从方便开发和测试效率角度考虑做的修改,修改后很多操作都比较方便。
1、可写目录统一放到可执行文件同级的 Data 目录下。如果是多开的话会自动根据进程数量创建新的 Data2、Data3等目录。游戏中使用封装的 Prefs 读写配置,更新补丁也是放在这个目录下,日志也是放在这个目录下。persistentDataPath 几乎不再使用。
这么设计的原因,一方面是方便开发的时候,清理更新包、查看或者删除配置项,或者查看日志。另外一方面是方便多开。多开的时候账号是记录在不同的Data目录下的,同一个目录下的游戏进程多开互不影响。
2、监控日志 Application.logMessageReceived。把日志输出到文件流。每条日志打印的时候加上时间。游戏关闭的时候再把 persistentDataPath 下的 output_log 拷贝到日志目录下。这么做是为了方便查看日志。而 output_log 包含更多的底层日志。比如崩溃日志。
3、帧率控制。Win端开发版本,非激活窗口限制15帧,如果是最小化窗口限制5帧。前台激活窗口保持30帧。这么做可以方便测试多开,提高开发效率,一台电脑八九开不是问题。
四、如何获取设备的唯一码(设备唯一标识)
每个平台都有获取唯一码的需求,实现不同,且各有各的坑。所以单独提出来统一说明。
1、Android获取唯一码
1.1、Android下我们直接使用 SystemInfo.deviceUniqueIdentifier 获取唯一码。它是根据一系列唯一值,如 ANDROID_ID、IMSI等计算出来的。结果是个md5字符串。一般是32位。
1.2、这个值全局唯一且卸载游戏也不会发生改变。唯一的问题是模拟器或者Root后的设备,设备信息可以伪造。也就是说,这个唯一码是可以被修改的。但是由于也找不到其他更好的方案,所以最终还是用这个做唯一码。
1.3、IMEI、MEID。很多广告商,尤其是网页上下载的包。都喜欢用IMEI做用户追踪的唯一码。IMEI和MEID差不多,IMEI是纯数字格式的,MEID里面包含字母。用同样的获取IMEI的接口,有的手机会返回IMEI,有的会返回MEID。大概跟手机是电信手机还是移动手机相关。
这个是需要用户授权的。用户可以拒绝玩家访问IMEI。如果拒绝访问,则返回“0000”的字符串或者是空串。
如果是双卡双待的手机,IMEI可以获取到两个。每个卡槽对应一个IMEI。
2、iOS如何获取UDID
2.1、出于隐私角度考虑。UDID已经是私有api,不再允许开发者调用。
2.2、苹果给的新的替代方案是 IDFV。这个是应用开发商标识符。
com.aaa.xxxx,和com.aaa.yyyy取到的是同一个值。
缺点是,当用户设备上com.aaa的所有应用都卸载了(如果只装了某个公司的一个游戏,那么就是游戏卸载时),这个值就会被清理掉。再安装的话,取的是不同的值。
2.3、IDFA。这个是广告标识符。同一个设备上的素有App都会取到相同的值。是苹果专门提供给广告商来追踪用户的。不过用户可以禁止访问这个值,也可以还原这个值。也就是说用户可以随时在设置中改变这个值。获取这个值之前应该先判断下用户是否禁用了广告追踪。
2.4、现在取唯一码的方案是,取IDFV,然后存放到KeyChain中。下次再取,优先从KeyChain中获取。这样即便游戏卸载了,下次再取到的值,还是原来的值,保证其跟设备完全一致。
2.5、由于SystemInfo.deviceUniqueIdentifier是直接取的IDFV,卸载游戏后,这个值就会发生变化,所以不能直接使用这个接口获取唯一码。
3、Windows平台获取唯一码
3.1、Windows上获取唯一码没有特别好的方案。SystemInfo.deviceUniqueIdentifier 是根据cpu、硬盘等设备信息计算的唯一码,是40位的哈希值。这个接口的问题是,它非常有可能因为硬盘信息的改变而生成不同的值。且硬件信息更加容易伪造。
3.2、为了应对唯一码可能会改变,我们把唯一码存放在了三个地方。安装目录的Data目录下、Application.persistentDataPath 路径、PlayerPrefs注册表。存三个地方是防止游戏卸载导致唯一码改变,也可以防止用户篡改唯一码。当然如果工作室知道规则(很容易知道)那么还是比较容易伪造唯一码的。
3.3、最开始还是用 deviceUniqueIdentifier 获取唯一码,只不过通过备份,防止其修改。不过这个接口获取的值问题很大。很有可能两台不同的设备,其唯一码是一样的,所以我们后来修改为自己生成唯一码。算法是 Guid + 随机数 + 时间戳,这三个得到的字符串进行一次 md5。得到的就是唯一码。然后再存起来,防止后面再用的时候发生变化。
4、唯一码的用途
4.2、统计分析。数据打点。
4.3、游客登录,用作账号。
五、游戏内置浏览器
1、很多游戏都会有内置浏览器的需求。比如王者荣耀的玩家社区。Android和iOS平台,我们直接用的是 UniWebView。Windows平台我们使用的是Embedded Browser 插件。
2、Android平台就是封装了 WebView 控件。我们做了一些修改,主要是运行时权限管理,浏览器重定向(比如我们不期望玩家点击bilibli的视频就跳转到bilibili客户端)。
3、iOS平台是对WkWebView的封装。这个是iOS7推出的替代UIWebView的网页控件。优势是速度快,内存占用低。AppStore现在审核的时候会检测有没有使用UIWebView这个废弃的API,如果有的话会审核被拒。
因为没有源代码,这里还是踩了很多的坑。比如对iOS9不兼容会闪退,没有开放safeArea的api导致浏览器显示白边等等。这些都随着插件的版本升级逐步改进。
4、上面说的 UIWebView 再多啰嗦两句。Unity2017.4.33之前的版本在 URLUtility.mm 中处理游戏内打开浏览器的功能,用到了UIWebView。所以用之前的版本打出来的包,上传到AppStore,就会有 UIWebView 的警告。
如果能升级Unity的话,把Unity升级到最新就能够解决这个问题。
如果不能升级Unity的话,可以把 URLUtility.mm 修改重新编译一下,删除里面的UIWebView相关的内容。把编译后的URLUtility.o 重新打入到 libiPhone-lib-il2cpp.a 这个静态库。覆盖Unity安装目录下的对应库文件。这样之后打包就没有问题了。如果不方便直接修改Unity安装目录的话,可以导出xcode之后,覆盖工程下的libiPhone-lib.a。
具体操作步骤可以参考网上的相关文章。
5、Windows平台使用 Embedded Browser 这个插件。它是对CEF进行了修改。Chromium Embedded Framework 是基于Chromium的WebBrowser控件。可以理解为嵌入版的Chromium。
一开始想尝试用开源的C#版本的CEF封装来做的。不过因为Unity插件的机制问题,很不稳定。Unity的插件是一旦启用,内存就不会释放,而我们游戏会在重复的Play。此时就会有严重的内存问题,可能会导致Unity卡死或者闪退。另外,CEF是多进程架构,网页的渲染是在一个独立进程上执行的,Unity和进程之间的通信处理起来也比较麻烦。
Embedded Browser 解决了这个问题,它集成了CEF库,实现了Unity上的渲染、输入、鼠标等功能。html5test跑分有450分左右,兼容性良好。运行起来也比较稳定。它没有让Unity管理dll,而是通过 LoadLibrary自己管理和释放dll,用这个方式解决了共享内存的问题。
缺点是CEF的通病,CEF太庞大,一个dll就有80MB。不过考虑到是PC平台,似乎也不用特别在意这点包体积。另外就是没有源代码,一些改进也很难处理,比如网页上对MP4视频的支持。因为MP3/MP4都是有专利的,所以CEF默认是不支持MP3/MP4播放的,需要修改工程选项,重新编译才能够支持,不过CEF的代码无比庞大,编译时间可能会有几个小时。
补充说明的是,我们之前尝试过直接使用系统控件的方案。插件是 In-App Web Browser。优点是插件很小,不像CEF会让包体积增大100MB。不过缺点是控件表现受IE浏览器版本影响很大。而PC平台,可能有的玩家是IE7,有的玩家是IE12。兼容性和效果表现都有很大问题。比如有的网页莫名其妙出现滚动条,而有的人没事。如果表现有异常的话,这个方案就不可行了。