Quick-cocos2d-x 视频播放

今天我们来聊聊 Quick-Cocos2d-x 中播放视频的那些事.

这篇文章来自于日常的笔记, 年代可能会有些久远, 加上当时最开始视频播放不是我来做的, 所以有些地方我的理解也不是很深刻. 若是有什么不对的地方, 还望大家不吝赐教.

一个命令行的 TexturePacker 拆解工具 (二)

距离第一版的 untp 发布已经有一年半的时间了, 在这个项目上我收获了很多的第一次:

第一次有一个项目的 star 数超过 50
第一次往 pypi 上上传项目
第一次如此认真的维护一个项目

这篇文章已经是关于 untp 的第三篇文章了, 所有的文章列表可以查看这里. 下面我来讲讲 untp 最近的几次更新以及后续的一个维护计划.

Quick-cocos2d-x utf8 支持

一. 需求

1. 计算玩家名字字符数

对于这个需求一般情况下 string.len 或 quick 自带的 string.utf8len 就能满足, 但是如果需求是:

对于像 中文/日文/韩文 这样的方块字一个占 2 个长度, 其他字符占 1 个长度.

该如何满足呢 ?

最近遇到的几个 Quick-cocos2d-x 真机崩溃(二)

大概是 8 月中旬的时候, 我们项目发生了一个很严重的线上事故. 在版本更新之后, 部分 Android 玩家反馈点击按钮开始游戏或活动按钮会闪退.

开始收到这个反馈时, 并没有太在意, 心想是不是机型适配有问题 ? 加上当时有别的工作在忙, 就没有去理会. 大概一个小时后, 玩家的邮件像雪花一样纷纷而至, 我才开始意识到, 更新出问题了.

最近搞 iOS 版遇到的一些问题和技巧 (三)

一. iOS 内购返回商品无效 invalid product

我使用 Quick-cocos2d-x 内置的 store 类请求商品信息时, 收到这样的错误:

nvalidProductIdentifiers [CCStore_obj]
[CCStore_obj] productsRequestDidReceiveResponse() invalid pid: com.xxx.xxx

首先, 检查你请求的商品 id 在 iTunes 后台是否创建, 是否拼写错误. 如果没有问题, 那么就不太好办了, 会有很多原因导致这个问题.

ZZB_Amoy 博客的这篇文章总结了下可能的原因, 如下:

  • 创建的App ID是否启用了IAP功能。
  • 商品信息是否配置到iTurn Connect,并到达“Ready to Submit”状态。
  • 在iTurn Connect中创建Test User,并收取邮件激活。之后登录到测试用手机的设置页面中(Store选项)。
  • App的Bundle Id是否和后台配置的App Id一致。
  • 是否创建相应的provisioning profile,并用此签名App。
  • iTurn Connect后台配置完商品信息后,是否等待若干小时生效。
  • SKProductsRequest请求的商品Id必须和iTurn Connect中配置的一致。(如:com.test.product.xxx)
  • iTunes Connect中配置的银行信息是否正确。
  • 是否先删除旧App,再重新编译生成新的。
  • 请不要使用越狱手机测试。

下面说说我两次遇到这个问题的解决方案:

  1. 如果游戏发布区域中没有手机中 App Store 当前区的话, 需要先登陆下对应区域创建的测试账号, 将商店切换到对应区域.
  2. 完善苹果开发者账号所能完善的信息, 如付款信息呀什么的, 然后莫名其妙就解决了.
  3. 商品 id 大小写错误, 在 chrome 搜索时是忽略大小的.

注:在测试阶段,可以不用上传APP软件包,但必须创建测试用Apple Id,并在手机设置中(store选项)登录。

二. iOS 运行崩溃 unrecognized selector sent to instance

运行游戏过程中收到如下错误:

[1515:710439] -[AppController window]: unrecognized selector sent to instance 0x2c85c00
libc++abi.dylib: terminate_handler unexpectedly threw an exception

这个在接入某一个平台 sdk 时遇到的问题, 于是便问了下他们的技术, 很快解决了问题.

1. 修改 AppController.h 中 window 变量的声明形式

2. 修改 AppController.mm

虽然问题解决了, 但是我并不明白各种缘由. Google 了下, 大概明白了, 原来如此. 从错误中我们可以看到这句 [AppController window] , 从语法来看, 这是要调用 AppController 的 window 函数, 但是在我们之前的写法中没有实现这个函数, 便出错了. 而使用 @property 这个东西, 会自动帮你实现一个 window/setWindow 函数, 这样就不会找不到这个函数了.

三. 游戏在低于 ios9 的系统启动崩溃

这个也是在接入第三方 sdk 时遇到的问题, 游戏一启动就会崩溃, 收到错误如下:

dyld: Symbol not found: _OBJCCLASS$_SFSafariViewController
Referenced from: /var/mobile/Applications/CF4146B4-3F79-4644-86CA-F19E52E64BAA/superarmoreddivision.app/superarmoreddivision
Expected in: /System/Library/Frameworks/SafariServices.framework/SafariServices
in /var/mobile/Applications/CF4146B4-3F79-4644-86CA-F19E52E64BAA/superarmoreddivision.app/superarmoreddivision

Google 了一下, 没有任何人遇到过这样的问题, 这就十分棘手了, 完全不知从何入手. 经过一番探索, 找到了几个有用的线索:

  1. SFSafariViewController 这个类是 ios 9 才引入的, 这和我们已知的信息相符.
  2. 所幸的是我们的游戏有多个 Scheme , 每个 Scheme 接入不同的 sdk . 其他的 Scheme 的都可以正常运行.

这就可以肯定是某个 sdk 中使用了 SFSafariViewController 这个类, 但是还是没有办法定位是那个 sdk . 我不知道是否有一个命令查找符号引用, 因此只能采用最笨的排除法了, 我将引入的 sdk 依次删除, 看是否能够运行.

最终定位到了某个广告统计 sdk , 在询问其 ios 技术人员后得到了解决方案. 原来他们 sdk 需要optional 的形式引入 SafariServices.framework.

都怪我没有仔细阅读文档, 白白耽误了一段时间, 下次一定要注意!

四. Facebook 登录崩溃

集成 Facebook sdk 时, 调用登录接口游戏就会崩溃, 这个问题 Google 一下就能解决, 解决方案也很简单, 在 Info.plist 中加入下面几行代码即可:

1
2
3
4
5
6
7
<key>LSApplicationQueriesSchemes</key>
<array>
<string>fbapi</string>
<string>fb-messenger-api</string>
<string>fbauth2</string>
<string>fbshareextension</string>
</array>

Stackoverflow 上的答案可以移步这里, Facebook 官网上也给了解答.

五. ios9 状态栏无法隐藏

隐藏状态栏在 ios9 上换了一种方式, 还是需要在 Info.plist 中进行配置:

1
2
3
4
<key>UIStatusBarHidden</key>
<true/>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>

Stackoverflow 上的答案可以移步这里.

六. showAlert 诡异崩溃

游戏内的一些弹框为了保证在游戏的最上层显示, 偷懒使用了 Quick-cocos2d-x 提供的 device.showAlert 接口. showAlert 内部使用 UIAlertView 实现, 运行一直良好, 有一天突然就不行了, 一调用就崩溃.

各种办法都试过了, 网上都说是线程安全问题, 我试了一下各种处理都不行, 打断点跟踪到最底层也无济于事. 几近绝望之时, @bin 的一句话提醒了我:

会不是屏幕方向的问题 ?

最终一番尝试, 删除了 RootViewController.mm 中几个屏幕方向相关的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
*/
// Override to allow orientations other than the default portrait orientation.
// This method is deprecated on ios6
- (BOOL)shouldAutorotateToInterfaceOrientation:(UIInterfaceOrientation)interfaceOrientation {

if (ConfigParser::getInstance()->isLanscape()) {
return UIInterfaceOrientationIsLandscape( interfaceOrientation );
}else{
return UIInterfaceOrientationIsPortrait( interfaceOrientation );
}

}

// For ios6, use supportedInterfaceOrientations & shouldAutorotate instead
- (NSUInteger) supportedInterfaceOrientations{
#ifdef __IPHONE_6_0
if (ConfigParser::getInstance()->isLanscape()) {
return UIInterfaceOrientationMaskLandscape;
}else{
return UIInterfaceOrientationMaskPortraitUpsideDown;
}
#endif
}

- (BOOL) shouldAutorotate {
if (ConfigParser::getInstance()->isLanscape()) {
return YES;
}else{
return NO;
}
}

这个 bug 出现之诡异, 解决方案之诡异, 在我遇到的 bug 中也算是很少见了.

更简洁的 lua 逻辑代码

爱因斯坦的质能方程 E=MC^2, 用在编程界同样适用 Error = More Code ^ 2. 代码越多, 出错的可能性就更大, 这个结论很正确呀. 那么我们如何使用更少的代码实现同样的需求呢 ?

一. 普通技

1. bool 值与 if 语句的择决

让我们来看一段代码:

1
2
3
4
5
6
local monthly_is_taken = app.player:getAttribute("monthly_is_taken")
if monthly_is_taken == true then
self._monthly_take:setButtonEnabled(false)
else
self._monthly_take:setButtonEnabled(true)
end

显然这个 if 语句是没有必要的, 我们可以直接使用 bool 进行函数参数传递:

1
2
local monthly_is_taken = app.player:getAttribute("monthly_is_taken")
self._monthly_take:setButtonEnabled(monthly_is_taken)

我们可以看到减少了 %60 的代码, 逻辑反而变得更清晰了.

2. 减少非必须的中间变量

我们都明白了中间变量的意义, 主要是为提高代码的可读性. 但是有时候中间变量的太多, 在增加码量的同时, 也会打断我们的我们的思路.

比如我们要算一个等差数列的和, 我们都知道使用公式 (首项+末项)*项数/2, 我们看一下这个实现:

1
2
3
4
5
local array = {1,3,5,7,9}
local array_len = #array
local first_element = array[1]
local last_element = array[array_len]
local sum = (first_element+last_element)*array_len/2

就不如下面这个实现:

1
2
local array = {1,3,5,7,9}
local sum = (array[1]+array[#array])*#array/2

这样的话, 我们上一个示例的代码可以进一步精简:

1
self._monthly_take:setButtonEnabled(app.player:getAttribute("monthly_is_taken"))

3. 使用 elseif 优化 if 语句

如果是逻辑相悖的判断条件, 我们可以使用 elseif 语句连接, 而不用多个 if 语句.

1
2
3
4
5
6
7
8
9
10
11
if self.item_id == "43" then
-- do some thing
end

if self.item_id == "69" then
-- do some thing
end

if self.item_id == "75" then
-- do some thing
end

我们可以修改为:

1
2
3
4
5
6
7
if self.item_id == "43" then
-- do some thing
elseif self.item_id == "69" then
-- do some thing
elseif self.item_id == "75" then
-- do some thing
end

这样修改后, 对逻辑的执行时间也优化哟, 因为一但有一个 if 语句命中, 后面的 elseif 都不会再去判断了.

4. 使用 config 优化 if-elseif 语句

如果一个逻辑中有大量的 if-elseif 语句, 我么就可以使用 config 的形式替换掉它, 使得逻辑更加简洁.

让我们看一个示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
if _data.type == GameEnum.MailType.MAIL_TYPE_SYSTEM then
self._title:setString(_data.content.content.subtype)
self._name:setString(TextEnum.CNReset.SYSTEM)
self.title = TextEnum.CNReset.SYSTEM_INFORMATION
elseif _data.type == GameEnum.MailType.MAIL_TYPE_ALLIANCE_KICK then
self._title:setString(TextEnum.CNReset.KICK)
self._name:setString(TextEnum.CNReset.SYSTEM)
self.title = TextEnum.CNReset.KICK
elseif _data.type == GameEnum.MailType.MAIL_TYPE_ALLIANCE_JOIN then
self._title:setString(TextEnum.CNReset.JOIN_IN)
self._name:setString(TextEnum.CNReset.SYSTEM)
self.title = TextEnum.CNReset.JOIN_IN
elseif _data.type == GameEnum.MailType.MAIL_TYPE_ALLIANCE_REJECT then
...

这是一段关于邮件标题的逻辑, 这里只节选出了 1/4 的代码, 真的是又臭又长. 我们可以这样子去优化它:

1
2
3
4
5
6
7
8
9
10
11
12
13
local CONFIG = {
[GameEnum.MailType.MAIL_TYPE_SYSTEM] = {title = XXX, name = XXX},
[GameEnum.MailType.MAIL_TYPE_ALLIANCE_KICK] = {title = XXX, name = XXX},
[GameEnum.MailType.MAIL_TYPE_ALLIANCE_JOIN] = {title = XXX, name = XXX},
[GameEnum.MailType.MAIL_TYPE_ALLIANCE_REJECT] = {title = XXX, name = XXX},
...
}

local config = CONFIG[_data.type]
if config then
self._title:setString(config.title)
self._name:setString(config.name)
end

因为只是代码节选, 所以上面修改是一段伪代码, 但是看起来超级清爽的有木有! 对于一开始无法确定的数据如何配置呢? 我们可以配置一个 function, 用的时候取出来调用就可以啦.

二. 黑科技

1. 数据默认值的设定

当我们拿到一段数据后, 总是要先预处理数据, 后面才是使用数据. 预处理阶段很重要的一步就是某些数据的默认值.

1
2
3
4
5
6
7
8
9
10
11
12
function sum3(_num1, _num2, _num3)
if not _num1 then
_num1 = 0
end
if not _num2 then
_num2 = 0
end
if not _num3 then
_num3 = 0
end
return _num1 + _num2 + _num3
end

很繁琐是不是, 这时候我们可以使用 and 和 or 来优化默认值的设置:

1
2
3
4
5
6
function sum3(_num1, _num2, _num3)
_num1 = _num1 or 0
_num2 = _num2 or 0
_num3 = _num3 or 0
return _num1 + _num2 + _num3
end

or 的前面部分是 nil 或者 false 的情况下, 返回这个表达式的值后面部分. 下面我列举一下常用类型的默认值用法:

1
2
3
4
5
6
7
8
9
10
-- number
a = a or 0
-- string
a = a or ""
-- function
a = a or function()end
-- table
a = a or {}
-- boolean
a = a == nil and true

这里值得一提的是 boolean 类型, 如果希望默认值是 false 话, 就不需要默认值, 因为 nil 和 false 对于判断来说以意义一致. 而如果希望默认值是 true 的话, 并不是 a = a or true, 而是 a == nil and true, 大家可以细想一下其中的含义.

2. table 中元素的初始化

比如我们要统计一个列表中, 每个元素出现的次数:

1
2
3
4
5
6
7
8
local list = {1,2,2,3,1,3}
local counter = {}
for i,v in ipairs(list) do
if not counter[v] then
counter[v] = 0
end
counter[v] = counter[v] + 1
end

因为 counter 不可能提前初始化好, 所以总是要判断存不存在这个元素, 我们也可以利用上面提到的技巧做这个事情:

1
2
3
4
5
local list = {1,2,2,3,1,3}
local counter = {}
for i,v in ipairs(list) do
counter[v] = (counter[v] or 0) + 1
end

是不是变得很简洁 ?

最近搞 iOS 版遇到的一些问题和技巧 (二)

一. XCode: Could not find Developer Disk Image

101138-1ab6ab96d37904bd.jpeg-4.5kB
解决方案:
http://www.jianshu.com/p/3930df903a44
这个问题可能是因为你 XCode 没有下载对应 iOS 的 SDK 导致, 一般情况需要同步更新 XCode.

二. XCode: 无法导出 Archive 的项目

这个问题有可能是你项目 Team 选择的是一个没有开发者资格的账号导致的, 虽然可以正常开发, 真机调试, 但是是不能发布的, 所以也无法 Export .
解决方案:

  1. 更换一个有开发者资格的账号重新 Archive 导出, 但是 Bundle ID 就得换一个了.
  2. 通过命令行工具导出.
    在 Organizer 中找到你想导出的 Archive, 右键选择在文件夹中显示, 复制路径, 打开终端:
    1
    xcodebuild -exportArchive -exportFormat ipa -archivePath your-archive-file-name.xcarchive -exportPath ~/Desktop/test.ipa

三. XCode: App Installation failed

Unknown.png-45.1kB
很可能是之前手机已经装过一个同 Bundle ID 的应用, 但是现在换了签名.
解决方案:
删掉手机已经安装的那个应用就可以啦.

四. XCode: Failed to code sign “xxxx”

QQ20160626-0.png-437.7kB
签名失败了, 这种情况一般发生在使用别人给的证书打包时. 这时候我们项目 Build Setting > Code Signing Identity 就不能选择 iOS Developer, 而是要选择导入的签名文件.

五. 内购: 无法连接到 iTunes Store

如果没有发布应用的话, 需要用沙盒测试账号来测试. 我们需要先在 设置>iTunes Store 和 App Store注销账号, 然后打开游戏, 开始购买, 这时候输入你的测试账号. 成功后如果有跳转 App Store 的话或者绑定付款方式的话, 不同理会, 再返回应用购买就可以了.

六. 崩溃: showAlert 崩溃

某一次突然, 一旦调用 Quick-cocos2d-x 提供的 device.showAlert 就会崩溃, 断点调试无果, 崩溃时提示的内容也不尽相同.
解决方案:
删除 RootViewController.mm 中所有和屏幕方向代码, 就解决啦.

1
2
3
4
- (BOOL)shouldAutorotateToInterfaceOrientation:(UIInterfaceOrientation)interfaceOrientation
- (NSUInteger) supportedInterfaceOrientations
- (BOOL) shouldAutorotate
- (void)didRotateFromInterfaceOrientation:(UIInterfaceOrientation)fromInterfaceOrientation

七. 崩溃: iOS 9.2+ 播放视频崩溃

感谢 @子龙山人 大神提供的解决方案, 点击这里查看.

UIVideoPlayer-ios.mm 文件 ~VideoPlayer() 函数中的 dealloc 修改为 release 即可.

1
2
-        [((UIVideoViewWrapperIos*)_videoView) dealloc];
+ [((UIVideoViewWrapperIos*)_videoView) release];

Quick-cocos2d-x 适配 IPV6

一. IPV6 是啥 ?

这两天一个运营的同事跑过来问我:

他: 咱们的游戏适配那啥 VIP6 了么?
我: ….

苹果商店在儿童节之后就不允许未适配 IPV6 的应用上架了, IPV6 是啥 ? 需要做些什么呢 ?

Quick-cocos2d-x 使用静态库加速 iOS 打包

Quick-Cocos2d-x 项目的 iOS 工程使用 Tgarget Dependencise 依赖 cocos2d_libcocos_lua_bindings 工程.

QQ20160529-0.png-27kB

这样子在 iOS Archive 时会重新编译这两个项目, 十分痛苦, 尤其是一次出七八个渠道的包, 好几个小时就耗在里面了.

为什么不用静态库, 编译出 .a , 使用时直接链接就可以了嘛.

一. 编译静态库

找了一下, 原来早已经有小伙伴想到了这点, 这篇文章 Build cocos2d-x fat static library 就特别棒. 从中我们可以发现一个特别有用的脚本 buildstaticlib.sh, 可以直接使用 xcode 工程编译出静态库.

不过这个脚本只能编译出 Release 版, 我修改下可以传入 configuration, 这样我们可以分别编译出 Debug 和 Release 版的静态库啦, 我修改后的文件在这里.

因为我们要编译出多个静态库, 所有又写了另一个脚本 build.sh 调用 buildstaticlib.sh , 内容如下:

1
2
3
4
5
6
7
./buildstaticlib.sh $QUICK_V3_ROOT/cocos/scripting/lua-bindings/proj.ios_mac/cocos2d_lua_bindings.xcodeproj "libluacocos2d iOS" "Release"

./buildstaticlib.sh $QUICK_V3_ROOT/build/cocos2d_libs.xcodeproj "libcocos2d iOS" "Release"

./buildstaticlib.sh $QUICK_V3_ROOT/cocos/scripting/lua-bindings/proj.ios_mac/cocos2d_lua_bindings.xcodeproj "libluacocos2d iOS" "Debug"

./buildstaticlib.sh $QUICK_V3_ROOT/build/cocos2d_libs.xcodeproj "libcocos2d iOS" "Debug"

运行成功后会在当前目录生成 4 个 .a 文件, 下一步中将会用到.

1
2
3
4
5
6
7
.
├── build.sh
├── buildstaticlib.sh
├── libcocos2d\ iOS-debug.a
├── libcocos2d\ iOS.a
├── libluacocos2d\ iOS-debug.a
└── libluacocos2d\ iOS.a

二. 使用静态库

使用 XCode 打开 proj.ios_mac 目录下的 xxx.xcodeproj 工程.

1. 移除 Tgarget Dependencise

首先移除对 cocos2d_libcocos_lua_bindings 工程的依赖, 右键点击 Delete 然后选择 Remove reference 就可以.

QQ20160529-1.png-49.2kB

2. 添加 Other Linker Flags

我们静态库的依赖是在这里添加的, 在 Debug 和 Release 选项中分别加入对应的静态库.
QQ20160529-3.png-117.8kB

这样就完成啦, 尝试一下 Archive 的速度吧 !

三. 其他

1. 调试环境与生产环境

我们改成静态库后, 调试 cocos 引擎的代码会多有不便, 而且一旦修改了 cocos 的代码, 就得重新生成静态库, 对于开发阶段太不友好了.

我们的解决方案, 就是再建立一个 debug 工程, 这个工程依旧使用依赖项目的方式编译 cocos , 调试流程和以前一致. 上线打包时则使用我们的静态库版本, 多渠道也做在这个工程中, 享受静态库带来的编译加速.

最终我们的目录结构是这个样子的:

1
2
3
4
5
6
7
8
9
10
11
12
.
├── runtime-src
│   ├── Classes
│   ├── proj.android
│   ├── proj.android_no_anysdk
│   ├── proj.android_studio
│   ├── proj.ios_mac
│   ├── proj.win32
│   └── proj.wp8-xaml
└── runtime-src-debug
├── Classes
└── proj.ios_mac

2. 生产环境工程瘦身

这一步可有可无, 我的代码洁癖又犯了, 所以顺手改了一下.

这时的生产环境除静态库外的内容和调试环境几乎一致, 然而有一些东西是我们用不到的:

  1. mac 平台对应的内容
  2. Classes/runtime 下的内容

删除这些时改动了 AppDelegate 中的东西, 这也上一步为什么从 runtimes-src 目录复制了一份.

3. 进一步加速编译

这一步我们目前还没有做, 只是一个想法.

修改完使用静态库后, 编译速度得到了很大的提升, 但还没有达到极致, 因为 quick 特有的 c++ 文件还是以文件形式存在于工程中的. 所有 Archive 的时候还是有一百多个源文件需要编译.

如果我们能进一步拆分, 新建一个 lib 工程, 将 quick 的源文件添加和依赖项目添加进去, 我们的游戏只依赖这样的一个静态库, 是否可以达到一个极致的编译速度 ?

4. 静态库文件的版本管理

在编译出 debug 版的静态库之前, 我还有想法将这几个静态库压缩上传到 git 上, 编译出 debug 版之后, 我就一个想法, ignore them !

所以我最终的策略 将这几个 .a 在 git 上忽略掉, 同时在那个目录保留了一个编译脚本, 谁要用到 iOS 项目的时候, 发现没有 .a , 自己运行脚本编译一份就可以啦 !

5. 编译脚本优化 ?

现在那个编译脚本会编译出一个 fat(armv7 armv7s arm64 i386 x86_64) 版的静态库, 内部实现其实是编译了好多次, 导致现在编译时间非常长.

思考:

  1. 是否有必要编出 i386 x86_64 版本 ?
  2. 看到虾神的一篇文章貌似说可以以 armv7+arm64, i386+x86_64 组合两次打出所有版本.

6. 最终 Archive 出的包会比使用源文件大 ?

看到网上有过这个说法, 我没有在修改前后分别 Archive 对比包体, 不太严谨.

但和我之前某一次的包相比, 只大了几百KB, 还不太确定是不是与使用静态库有关系, 大家在修改时可以注意对比一下.

cocos2d-x 优化游戏资源体积

一. 删除无用资源

在我们版本迭代的过程中, 总有一些图片被废弃掉, 如果当时忘记删除的话, 久而久之也就忘记了. 如果在上线前不做一次整理的话, 它们就会残存在你的资源中, 浪费包的体积.

为避免这种情况, 我们可以做的是:

1. 废弃的图片一定要及时删除

2. 编写废弃资源查找工具

可以用到的系统命令是 ack, 我们可以通过 brew install ack 安装, 使用的效果:

关于 ack 的更多用法请移步这里.

二. 使用替代对象

1. 使用9图

大家都知道图片在拉伸的过程中会失真, 那么如何避免这个情况呢? 使用9图.

注: 配图来自http://mux.baidu.com/?p=1506

这样我们就可以将一张很大的图缩小到很小, 然后使用9图拉伸, 起到节省资源的目的. 9图在 cocos 中的对象是 Scale9Sprite, 具体用法可以参考这篇文章.

2. 通过修改色调实现资源复用

知乎上有一篇问题讲的就是这个:
拳皇中的人物变色是如何实现的?
知乎日报上的这篇

cocos2d-x 版由 @偶尔e网事 大神实现, 对应的对象是 SpriteWithHue, 目前已经默认集成到了 cocos2d-x 中.

这样我们就可以将原来只是色调差异的图片用程序来实现啦~

3. 使用平铺

注: 配图来自http://bullteacher.com/7-textures.html

游戏中的有些图片完全可以通过平铺实现, 这样的话我们就可以让美术只出一个平铺单元的图片,在程序中去实现平铺.

首先, 平铺的这个功能是 opengl 层面就支持的, 详情大家可以移步这里, cocos2d-x 中实现平铺很简单:

1
2
3
4
5
6
-- 首先, 使用平铺单元图片创建一个精灵
local sprite = cc.Sprite:create("your_repeat_image.png")
-- 然后, 设置纹理参数
sprite:getTexture():setTexParameters(gl.LINEAR, gl.LINEAR, gl.REPEAT, gl.REPEAT)
-- 最后, 将这个精灵的纹理矩形设置为我们想要的大小
sprite:setTextureRect(cc.rect(0,0,1024,1024))

注意: 平铺单元图片的尺寸只能是2的幂

三. 压缩

1. 无损压缩

无损压缩还是十分值得推荐的, 它的原理知乎上这个答案讲的很清晰, 我节选其中的关键文字:

1.核心原理很简单,通俗的解释一下,就是由于PNG格式的灵活性,他可以有很多种方式表示同一张图片,不同方式有时就会导致文件大小不一样…
2.还有一点是PNG采用的是deflate算法,也非常的灵活,他的压缩率和encoder的实现有关,不同的encoder使用的时间,压缩出来的大小都不一样…
3.当然除了上面这两点是真正的无损压缩以外,还有减小PNG文件大小的方式就是去除一些对图片本身没有任何影响的metadata…

所以无损压缩纯粹是单方面的受益, 是一定要做的.

我们无损压缩主要用到的工具: ImageOptim

2. 有损压缩

有损压缩会损失一部分的图片质量, 但带来的受益还是十分可观的. 这是一个抉择的过程, 以最小的代价获取最大的受益, 甚至不能批量处理, 可能需要一张一张的人肉对比压缩.

我们有损压缩主要用到的工具: PP鸭

四. 选择正确的图片格式

1. 将无 alpha 通道的 png 图片存储为 jpg

2. 选用压缩率更高的图片格式

五. 其他

1. 圆形图片只使用 1/4

然后在程序中翻转3次,得到其他角度的图片. 一般会用在图片尺寸特别大的场景.

如上图, 我们游戏中一个全屏幕的雷达就是通过这个方案减少图片体积的.

2. 缩小图片

将展示精度不强的图片(比如: 游戏背景上的小装饰, 爆炸的序列帧)缩小, 在程序中放大.

3. 特殊方案分离png的透明通道

用jpg和黑白色png作为遮罩实现透明
用shader使图片背景透明
cocos2dx中使用JPG图和只带Alpha的PNG图合成渲染

我们之前曾经采取过其中的一个方案, 将一张 png 图片拆分为 jpg+alpha.png 的形式, 整体的包体小了近 25% , 不过也带来的其他的一些副作用.

建议大家使用这类黑科技前一定要做好调研和测试用例, 评估一下实际的收益.

Quick-cocos2d-x EditBox 几个小技巧

我们项目中的输入框使用的都是 EditBox , 但是 EditBox 还存在一些问题, 这里给大家分享一下我们的解决方案.

一. 字体过大

用过 EditBox 的同学都知道这样一个情况, EditBox 在创建时是无法传入字体大小的, 字体大小默认和 EditBox 的 size 一致. 如果要修改字体大小的话, 就必须有程序的参与, 十分讨厌.

而我们聪明的设计师 @大勇同学 则想到了一个非常棒的办法, 使用一张透明的9图来创建 EditBox, 后面再放置一个真实效果的 Scale9Sprite , 这样就可以实现字体比边框小很多的输入框了.

二. 多行输入

多行输入是一个很有必要的事情, 我们在写邮件, 军团公告等界面都有类似的需求, 然而 EditBox 并不能很好的支持多行输入, 不同平台间也存在差异, 一直很头疼这件事情.

然而团队中另外一位成员 @小齐同学 却用另一种十分脑洞的方案解决了这个问题, 着实让人佩服. 他的思路是这样子的:

创建一个和需求大小一致的 EditBox, 同时创建一个 LalbelTTF , 将 dimensions 属性设置为需求大小. 处理 EditBox 使之看不见, 但又能正常输入, 同时监听输入文字变化事件, 在事件中修改 LalbelTTF 的文字.

核心就是让 EditBox 承担只文字输入的功能, 而让另外一个 LalbelTTF 来承担文字显示的功能. 实现的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function EditBoxUtil.multiline(_editbox, _label, _params)
_params = _params or {}

-- 本来这个应该只是Android上的设置, 但是为了避免平台的差异性, 因此统一处理
_editbox:setCascadeOpacityEnabled(true)
_editbox:setOpacity(0)
if device.platform == "android" then
-- 避免文字过大导致Android系统崩溃
_editbox:setFont("Helvetica",2)
elseif device.platform == "ios" or device.platform == "mac" then
_editbox:setFont("Helvetica",0)
end

_editbox:registerScriptEditBoxHandler(function(event)
if event == "began" then
_editbox:setText(_label:getString())
elseif event == "changed" then
_label:setString(_editbox:getText())
elseif event == "return" then
_label:setString(_editbox:getText())
end
if _params.listener then
_params.listener(event, _editbox)
end
end)
end

这段代码和简单, 但背后所遇到的坑却不少, 且听我来道一道:

1. 为什么要调用 setFont

如果只是想设置字体大小, EditBox 明明有提供 setFontSize 接口, 为什么要调用 setFont ? 请看 setFontSize 实现:

1
2
3
4
5
6
7
8
void EditBox::setFontSize(int fontSize)
{
_fontSize = fontSize;
if (_editBoxImpl != nullptr && _fontName.length() > 0)
{
_editBoxImpl->setFont(_fontName.c_str(), _fontSize);
}
}

可以看到 setFontSize 在没有设置字体名称 _fontName 时是没有作用的.

2. 为什么要分平台来实现

在接入 android 前,我们是没有分平台实现的, 只是 setFont("Helvetica",0) , 在 iOS 上没有任何问题, 但是在 Android 上会 catch 到 divide by zero 崩溃, 估计是某一个地方用 fontsize 做被除数了吧 , 于是 Android 上改为设置透明度.

3. Android 输入过多文字后会崩溃(OOM)

崩溃在 Cocos2dxBitmap.javagetPixels 函数中:

1
2
3
4
5
6
7
8
9
10
11
private static byte[] getPixels(final Bitmap bitmap) {
if (bitmap != null) {
final byte[] pixels = new byte[bitmap.getWidth() * bitmap.getHeight() * 4];
final ByteBuffer buf = ByteBuffer.wrap(pixels);
buf.order(ByteOrder.nativeOrder());
bitmap.copyPixelsToBuffer(buf);
return pixels;
}

return null;
}

new byte 这里触发了报错的原因是 Out Of Memory 异常 ! 调试发现 bitmap 的宽高惊人的达到了 12000x600 !

经过 @bin 的提醒, 发现这里可能是因为 EditBox 字体过大的原因, 因为 EditBox 会默认设置字体字体大小和 Scale9Sprite 的 PreferredSize 一直, 就可能设置字体大小为 100+ , 文字一多尺寸当然就上去了! 所以便有了这么一行:

1
2
3
if device.platform == "android" then
-- 避免文字过大导致Android系统崩溃
_editbox:setFont("Helvetica",2)

二. 屏蔽 Emoji 输入

按照要求游戏中玩家可以输入文字的地方都是不能够输入 Emoji 表情的, 原因有两点:

  1. 不同系统间表现存在差异
  2. EditBox Android 版输入确认后会变乱码
  3. 后台搜索玩家时不太方便

因此, 我们需要屏蔽 Emoji 表情的输入, 我们有两种做法:

  1. 无法输入, 弹出键盘点击表情没有反应
  2. 输入完成后, 游戏内点击提交时提示非法

我们采用的是第二种方案, 这个无法通过纯 lua 代码实现, 需要分平台去做.

1. iOS

修改 UIEditBoxImpl-ios.mm 文件的 shouldChangeCharactersInRange 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
- (BOOL)textField:(UITextField *) textField shouldChangeCharactersInRange:(NSRange)range replacementString:(NSString *)string
{
__block BOOL returnValue = NO;
[string enumerateSubstringsInRange:NSMakeRange(0, [string length]) options:NSStringEnumerationByComposedCharacterSequences usingBlock:
^(NSString *substring, NSRange substringRange, NSRange enclosingRange, BOOL *stop) {

if ([substring rangeOfCharacterFromSet: [NSCharacterSet characterSetWithRange:NSMakeRange(0xFE00, 16)]].location != NSNotFound) {
returnValue = YES;
}

const unichar high = [substring characterAtIndex: 0];

// Surrogate pair (U+1D000-1F9FF)
if (0xD800 <= high && high <= 0xDBFF) {
const unichar low = [substring characterAtIndex: 1];
const int codepoint = ((high - 0xD800) * 0x400) + (low - 0xDC00) + 0x10000;

returnValue = (0x1D000 <= codepoint && codepoint <= 0x1F9FF);

// Not surrogate pair (U+2100-27BF)
} else {
returnValue = (0x2100 <= high && high <= 0x27BF);
}
}];

if (returnValue) {
return NO;
}


if (getEditBoxImplIOS()->getMaxLength() < 0)
{
return YES;
}

NSUInteger oldLength = [textField.text length];
NSUInteger replacementLength = [string length];
NSUInteger rangeLength = range.length;

NSUInteger newLength = oldLength - rangeLength + replacementLength;

return newLength <= getEditBoxImplIOS()->getMaxLength();
}

这段代码是我从 https://github.com/woxtu/NSString-RemoveEmoji 中提取出来的.

2. Android

Android 上的实现也很简单, 主要是需要创建一个新的 InputFilter 用来过滤 Emoji 表情. 需要修改 Cocos2dxEditBoxDialog.java 文件成员变量添加:

1
2
3
4
5
6
7
8
9
10
11
12
13
public static InputFilter EMOJI_FILTER = new InputFilter() {

@Override
public CharSequence filter(CharSequence source, int start, int end, Spanned dest, int dstart, int dend) {
for (int index = start; index < end; index++) {
int type = Character.getType(source.charAt(index));
if (type == Character.SURROGATE || type == Character.OTHER_SYMBOL || type == Character.PRIVATE_USE) {
return "";
}
}
return null;
}
};

修改 onCreate 函数 setFilters 处逻辑:

1
2
3
4
5
if (this.mMaxLength > 0) {
this.mInputEditText.setFilters(new InputFilter[] { new InputFilter.LengthFilter(this.mMaxLength), EMOJI_FILTER });
}else{
this.mInputEditText.setFilters(new InputFilter[] { EMOJI_FILTER });
}

关于 Emoji 的相关修改都已经推送到了 github 上, 点击这里查看.

最近遇到的几个 Quick-cocos2d-x 真机崩溃

最近这段时间遇到了两次比较严重的真机崩溃问题, 都是之前所没有遇到过的, 特此记录一下, 希望能帮助到遇到类似问题的朋友.

之所以强调真机, 是因为这些问题在 player 上或者 debug 版无法出现, 只有真正运行在手机上才可能遇到, 因为最后我们 Archive 出来的包都是 release 版的.

最近搞 iOS 版遇到的一些问题和技巧

一. 编译运行

1. 运行时提示 identity 无效

The identity used to sign the executable is no longer valid.
Please verify that your device’s clock is properly set, and that your signing certificate is not expired. (0xE8008018).

解决方案:

  1. 打开 Preferences > Accounts
  2. 选中项目对应的 Apple ID > 点击右下角 View Details...
  3. 点击弹出框左下角的 Download All > 等待完成后点击 Done 关闭
  4. 再次运行项目, 等待弹出框, 点击 reset.

2. iPhone5 上获得的设备尺寸为 960x640

表现出来是竖屏游戏上线有黑边, 跟踪发现获得的设备尺寸为 960x640, 而非 1136x640.

解决方案:

添加一张尺寸为 1136x640 名为 Default-568h@2x.png 的启动图即可.

参考资料:
http://discuss.cocos2d-x.org/t/getframesize-get-wrong-screen-size/7657

3. XCode issue 页面只显示错误, 不显示警告

cocos2d-x 在 XCode 中编译 warning 太多, 出错后 error 会被淹没在一大堆的警告中, 得拖动半天才能找得到.

解决方案:

在页面最下方有一个感叹号型按钮, 点击选中即可.

二. 上传应用

1. 多任务支持

XCode 7 error: “A launch storyboard or xib must be provided unless the app requires full screen”

解决方案:

我们勾上全屏即可:


目前就这些, 后面会持续更新 !

记一次 Quick-cocos2d-x 内存泄露排查

这周 @bin 告诉我项目有比较严重的内存泄露, 任意一个界面不停的打开关闭, 内存占用会一直往上涨, 直到被系统 kill 掉.

一. 确定问题

收到问题后, 我简单写了一段测试代码, 加载/移除界面 100 次, 对比内存变化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
local index = 0
local handler = nil
handler = scheduler.scheduleGlobal(function( ... )
-- 加载界面
app.sceneManager:pushLayer(require('app.scenes.PageShop').new())
self:performWithDelay(function( ... )
-- 移除界面
app.sceneManager:popLayer()
index = index + 1
if index >= 100 then
scheduler.unscheduleGlobal(handler)
end
end, 0.5)
end, 0.5)

为了方便, 我没有进行真机测试, 而是使用 xcode 启动 player 来进行测试, 在 Xcode 的 Debug Navigator/Memory Report 窗口查看结果.

在测试前的平稳内存为 194M , 测试后惊人的达到了 258M, 十分严重的内存泄露了!

二. 初步解决问题

由于项目是纯 lua 的, 所以不太可能是数据和逻辑的问题, 那么很有可能是视图(cocos2d-x 对象)存在内存泄露. 而每一个界面都存在问题, 那么很可能是某个通用组件存在问题.

经过一番努力, 最终成功找到了内存持续增加的原因, 一共两处:

1. lua 垃圾没有及时回收

lua 的垃圾是会自动回收的, 但我们有时候可能需要手动回收下, 比如切换场景时, 关闭界面时, 主动回收的代码很简单:

1
collectgarbage("collect")

我将这段代码加到了统一关闭界面的地方.

更多关于 lua 垃圾回收的具体问题大家可以参考这个两篇文章:

http://luatut.com/collectgarbage.html
http://www.codingnow.com/2000/download/lua_manual.html

2. 精灵变灰和高亮的 shader 创建后一直没有释放

1
2
3
4
5
6
7
8
9
10
11
12
function GameUtils.SetSpriteGrey(sprite,is_grey)
if sprite and sprite.setGLProgramState then
if is_grey then
local pProgram = cc.GLProgram:createWithByteArrays(ShaderData.vertDefaultSourceGrey, ShaderData.pszFragSourceGrey)
...
sprite:setGLProgram(pProgram)
else
local pProgram = cc.GLProgramState:getOrCreateWithGLProgram(cc.GLProgramCache:getInstance():getGLProgram("ShaderPositionTextureColor_noMVP"))
sprite:setGLProgramState(pProgram)
end
end
end

这段代码看起来没有任何问题, 我能想到只是没有用 GLProgramCache 缓存起来, 造成每次都会创建的效率问题, 应该不会导致泄露吗 ? 精灵被释放时难道不会自动释放所引用的 GLProgram 吗?

因为当时项目比较紧急, 我将这段代码使用 GLProgramCache 的形式修改了一下, 惊奇的发现内存泄露问题竟然解决了. 修改后的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function GameUtils.SetSpriteGrey(sprite,is_grey)
if sprite and sprite.setGLProgramState then
if is_grey then
local pProgram = cc.GLProgramCache:getInstance():getGLProgram("ShaderPositionTextureColor_Gray")
if not pProgram then
pProgram = cc.GLProgram:createWithByteArrays(ShaderData.vertDefaultSourceGrey, ShaderData.pszFragSourceGrey)
pProgram:bindAttribLocation(cc.ATTRIBUTE_NAME_POSITION, cc.VERTEX_ATTRIB_POSITION)
pProgram:bindAttribLocation(cc.ATTRIBUTE_NAME_COLOR, cc.VERTEX_ATTRIB_COLOR)
pProgram:bindAttribLocation(cc.ATTRIBUTE_NAME_TEX_COORD, cc.VERTEX_ATTRIB_FLAG_TEX_COORDS)
pProgram:link()
pProgram:updateUniforms()
cc.GLProgramCache:getInstance():addGLProgram(pProgram, "ShaderPositionTextureColor_Gray")
end
sprite:setGLProgram(pProgram)
else
sprite:setGLProgram(cc.GLProgramCache:getInstance():getGLProgram("ShaderPositionTextureColor_noMVP"))
end
end
end

三. 隐藏在背后的秘密

问题解决了, 那么是不是可以结束了呢 ? 并不能, 晚上回到家我就一直在思考这个问题, 倒是是什么原因导致了 GLProgram 内存没有释放, 而使用 GLProgramCache 就没有问题.

莫不是 cocos2d-x 的 bug ? cocos 对象是基于引用计数去自动释放内存的. 我排查了几处可疑的地方:

  1. GLProgram::createWithByteArrays 调用了 autorelease
  2. Node::setGLProgram 调用了 retain
  3. Node::~Node 调用了 release

貌似都没有问题. 那么我可以跟踪下引用计数的变化, 看看是哪一步出现的问题! 经过一番调试,最终定位到了问题, 大家请看:

1.Node::setGLProgram 进入到 GLProgramState::getOrCreateWithGLProgram, 这一步没有什么问题.

2.这里有一个新的缓存 GLProgramStateCache , 进入它到 getGLProgramState 函数.

3.这一步是 GLProgramStateCache 的核心代码了, 判断有无在缓存中, 没有则 insert 到末尾. 这里的 _glProgramStates 是一个 Map , 而这个 map 竟然是以传递进来的 glprogram
key , 而我们每次传递进来的 glprogram 都是新创建的, 所以在我们这个使用情况下缓存根本是无效的.

4.让我看一下, GLProgramStateinit 函数, 这下找到 retain 地方了.

这样的话, 就讲得通了, GLProgram 会在 GLProgramState 析构的时候 release 掉. 而 GLProgramState 只会在 GLProgramStateCache:removeAllGLProgramState 释放掉. 而 removeAllGLProgramState 只有在手动或者游戏退出的时候才会调用.

OK, 这下定位到了问题, 虽然我们使用有些问题, 但 GLProgramStateCache 设计确实有不合理的地方, 大家记得正确用法就好了, 就是 shader 一定要使用 GLProgramCache !


后记

通过这次解决问题, 我有一个特别大的收获. 就是做优化工作时一定不能去猜, 要有数据和逻辑的支持.

在 Quick-cocos2d-x 中使用 TableView

上篇文章说到使用 TableView 可以大幅提升界面的创建速度, 这篇文章我们来看看如何在 quick 中使用它.

一. 基本用法

1. 创建对象

首先, 创建一个 TableView 对象:

1
local view = cc.TableView:create(cc.size(480,320))

传入的那个 size 是 viewsize, 即可视区域的尺寸, 后期也可以通过 setViewSize 来调节.

2. 设置填充顺序

下面设置填充顺序, 默认是从下往上填充, 我们习惯了使用从上往下填充, 所以需要修改下:

1
view:setVerticalFillOrder(cc.TABLEVIEW_FILL_TOPDOWN)

其可选值如下:

1
2
cc.TABLEVIEW_FILL_TOPDOWN = 0  -- 从上自下
cc.TABLEVIEW_FILL_BOTTOMUP = 1 -- 从下自上

这个值最终会影响所有 cell 的顺序, 具体些是第 0 个元素在最上面还是在最下面.

3. 注册事件监听

TableView 提供了好多事件, 具体作用如下:

1
2
3
4
5
6
7
8
9
cc.SCROLLVIEW_SCRIPT_SCROLL = 0
cc.SCROLLVIEW_SCRIPT_ZOOM = 1
cc.TABLECELL_TOUCHED = 2
cc.TABLECELL_HIGH_LIGHT = 3
cc.TABLECELL_UNHIGH_LIGHT = 4
cc.TABLECELL_WILL_RECYCLE = 5
cc.TABLECELL_SIZE_FOR_INDEX = 6 -- 获取节点尺寸的回调
cc.TABLECELL_SIZE_AT_INDEX = 7 -- 获取对应位置节点的回调
cc.NUMBER_OF_CELLS_IN_TABLEVIEW = 8 -- 获取节点数量的回调

但其实真正有意义的就三个, 6,7,8. 其他的根本不会回调, 注册了也没有什么用. 注册监听使用 registerScriptHandler 函数.

1). 注册节点数量回调

1
2
3
view:registerScriptHandler(function()
return 10 -- 假如有10个节点
end, cc.NUMBER_OF_CELLS_IN_TABLEVIEW)

返回节点数量.

2). 注册获取节点尺寸的回调

若是每个节点一致, 则可以返回一个固定尺寸; 若不一致, 可以根据 idx 去取出对应节点的尺寸.

1
2
3
4
5
6
7
8
9
10
view:registerScriptHandler(function(table, idx)
-- 默认尺寸
local size = self.defaultSize
local cell = view:cellAtIndex(idx)
if cell then
-- cell.view 属性是在线面创建 cell 时赋值的.
size = cell.view:getBoundingBox()
end
return size.height, size.width
end, cc.TABLECELL_SIZE_FOR_INDEX)

注意, 这里如果是纵向滚动的话,返回顺序是高,宽; 横向滚动的话则返回宽,高.

3). 获取对应位置节点的回调

这个回调的名称和上面那个很像, 有时候会分不清楚.

1
2
3
4
5
6
7
8
9
10
11
12
13
view:registerScriptHandler(function(table, idx)
local cell = table:dequeueCell()
if nil == cell then
cell = cc.TableViewCell:new()
cell.view = self.class.new()
:pos(self.class.designSize.width/2, self.class.designSize.height/2)
:addTo(cell)
end
-- 如果需要刷新的话, 可能需要自己去处理, 如不需要, 就可以不用下面的这个调用
cell.view:refresh()

return cell
end, cc.TABLECELL_SIZE_AT_INDEX)

3. 获取/新增/修改/删除节点

这些操作分别对应 cellAtIndex/updateCellAtIndex/insertCellAtIndex/removeCellAtIndex . 这些接口很好理解, 但就多数情况而言, 他们可能需要和 reloadData 配合使用.

好了, 以上就是基本用法了. 在使用时还有一些需要注意的细节:

  1. 不能使用 setNodeEventEnable, 因为和 Node 的 registerScriptHandler 有冲突.
  2. id 从 0 开始, 而大家在 lua 这边准备的数据多是以 1 开始, 这样可能需要 +1 和 -1, 需要细心一些.

二. 一些异常的解决方案

1. lua 出错导致 player 崩溃

TableView 回调 tableCellAtIndex 在lua这边的实现一旦出错, 就会在 c++ 那边收到一个 NULL 的 cell, 因为没有判空, 下面对 cell 的操作就会导致 Plaer 崩溃. 对应修改如下:

1
2
3
4
5
6
7
8
     cell = _dataSource->tableCellAtIndex(this, idx);
- this->_setIndexForCell(idx, cell);
- this->_addCellIfNecessary(cell);
+ if(cell)
+ {
+ this->_setIndexForCell(idx, cell);
+ this->_addCellIfNecessary(cell);
+ }

这时候, 不会崩溃了, 但是也看不到 lua 那边出的什么错误, 经过一番追踪, 定位到了 LuaStack::executeFunction 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
        if (error)
{
if (traceCallback == 0)
{
CCLOG("[LUA ERROR] %s", lua_tostring(_state, - 1)); /* L: ... error */
lua_pop(_state, 1); // remove error message from stack
}
else /* L: ... G error */
{
+ CCLOG("[LUA ERROR] %s", lua_tostring(_state, - 2)); /* L: ... error */
lua_pop(_state, 2); // remove __G__TRACKBACK__ and error message from stack
}
return 0;
}

虽然出错了, 但是 traceCallback 的值并不等于 0, 所以没有进入输出错误的逻辑, 具体用意并不明白. 我的添加了带 + 号的哪一行, 输出了下就可以看到 lua 的错误了.

2. 滚动后导致节点上按钮触摸失效

大家使用时可能会遇到这样的问题, 节点上有一个利用 quick 触摸机制实现的按钮, 在 TableView 滚动后触摸事件都会失效, 按钮无法被点击.

这个实际上是 quick 触摸机制的一个bug, 复现是很容易的. 大家创建一个按钮并调用 retain, 然后将这个按钮添加到父节点上, 在某个时候将按钮从父节点上移除 (removeFromParent) 并再次添加(addChild)上到父节点. 这时候按钮还在, 但是触摸事件已经没有了.

究其原因是我们一般会在对象的 ctor 函数中 setTouchEnable, 然后 quick 在收到 cleanup 事件后移除了对象触摸事件, 具体逻辑大家可以看 Node:EventDispatcher 函数. 而一个节点从父节点上移除时恰好会发送 cleanup 事件.

对应到 TableView 中来, TableViewCell 为了做到复用在 dequeueCell 时会调用 retain 函数, 并且在移出屏幕时会被从 Container 上移除掉的. 这样 TableViewCell 的所有子节点都会收到对应的 cleanup 事件.

这个问题的解决方案恰好是我之前写的一篇文章, 为 quick-cocos2d-x 添加析构事件, 在这篇文章中, 已经修改为收到 destroy 事件后才去移除节点的触摸事件, 非常完美的解决了这个问题.

Update: 2016-04-24

3. 遮挡上层按钮触摸事件

经过这几天的使用, 我发现了一个十分严重的问题. 如下图所示, TableView 中的某个按钮拖出 ViewRect 范围后会不可见, 但如果其位置恰好在 TableView 外的另一个按钮范围内, 就会优先收到点击事件. 这就会引发十分奇怪的现象, TableView 外的按钮看得见点不着, TableView 内的按钮看不见却可以响应到, 十分影响体验.

因为有着以往 cocos 2.x 的悲惨经历, 我非常武断的认为这肯定是 TableView 的 bug, 开始着手阅读 TableView 的代码实现, 却一直未果. 然而团队中另外一位成员 @小齐同学 的意外发现, 让这个问题的谜底在无意中就被揭开了.

我们在项目中大量使用了 Quick-cocos2d-x 提供的一个控件 UIScrollView, 它是一个用 ClippingRegionNode 纯 lua 实现的 CCScrollView, 一直以来工作的十分良好. 但是在某一天 测试中, @齐少 意外的发现, 某个界面的 UIScrollView 出现了和 TableView 一模一样的问题, 导致上层按钮无法点击. 经过排查, 发现是因为 UIScrollView 的创建顺序被延后的原因, 如果一个按钮先于 UIScrollView 添加到父节点, 就会被 UIScrollView 中的按钮所屏蔽, 后添加则不会.

当 @齐少 告诉我这个结论后, 我立刻意识到, TableView 遇到的问题肯定也是这个原因, 查看代码后果然如此, TableView 是最后被创建的. 解决方案也十分简单, 将 TableView 外部按钮放到 TableView 之后去创建就可以啦.


恩, 以上就是我在使用 TableView 时遇到的所有问题了, 虽然解决了这些问题, 但是在使用上还是十分的繁琐. 若是在一两个界面上使用可能还可以接受, 但若是想大规模推广就很有些困难了, 代码会变得十分冗长.

在此基础上, 我们又对 TableView 进行了一次封装, 数据驱动, 使用时不用过分关注界面的逻辑, 中心更多的落在了数据的组织上, 真正的做到了 “开箱即用” , 等我们内部进行推广并稳定后可以再和大家分享下心得.

游戏性能优化 - 界面篇

最近几天都在做性能优化方面的事情, 关于优化, 之前的经验也都是泛泛而谈, 知道有几条路线可以走, 但一直没有去实践过. 所以刚开始搞的时候, 也是两眼一抹黑, 走了不少弯路, 最后也是受益匪浅, 这里记录一下我们的思路, 也是为下次优化提供一个可行的方案.

在测试的时候, 不少界面界面卡顿十分严重, 主要分为三个方面, 分别是 打开界面卡顿, 操作界面卡顿, 关闭界面卡顿 . 不开玩笑, 确实如此 !

经过分析, 这些界面多有许多共同的特征:

  1. 界面包含多个页签
  2. 界面含有 ScrollView

在看具体业务逻辑的时候, 发现了很多的问题. 下面就和大家细说一下优化方案.

打开界面

总结一下, 打开界面卡顿的主要原因是:

界面创建时做了太多的事情.

对于多页签界面, 为了不在点击切换标签时有卡顿的感觉, 打开界面时创建了所有标签页的内容. 如下图:

这个其实没有必要, 不这样做的话可以将加载时间分摊到每次点击标签的时候, 再优化下每个标签页的创建速度, 切换标签时就几乎感觉不到卡顿的. 然后我们缓存注这些界面, 下次切换就会更流畅了.

对于 ScrollView , 如果子节点数量很多的话, 就会奇卡无比, 因为会在初始化时创建所有的子节点! 对于这个我们做了两点优化:

1. 优化子节点 node 数量

我们一个子节点可能有多个互斥的状态, 美术拼界面时会把所有的状态都拼在界面里, 程序再根据具体状态 setVisible , 这样就非常的浪费. 为此我们实现了在 ccb 中标记节点状态的功能, 所有不属于基本 ui 状态的节点树都不会被创建, 用到时候手动 init , 这个实现对性能的提升非常显著.

2. 针对性选择使用 TableView

我一般情况下不建议使用 TableView, 因为它的创建方式十分复杂, 使用起来也诸多不便, 更重要的是会破坏串行的ui创建逻辑. 但不得不承认, 某些情况下它对性能的提升非常有帮助, 尤其是在子节点的数量达到 20 个以上的时候, 简直是质的飞跃.

但是基于 TableView 十分难用, 加上 lua 这边逻辑出错后并不会抛出, debug 十分痛苦. 为此我们修改了一些 TableView 的源码, 同时在 lua 这边有封装了一层, 做到了数据和界面的分离.

当然 quick-3.3 的 TableView 坑不止这些, 感觉不做些修改基本没法使用. 具体细节, 我会在下一篇文章中和大家细说.


对于其他情况, 若是界面需要加载的东西特别多, 点击后会有几近卡死的状态, 同时因为顶点数量居多, 若是同时渲染背景场景和该界面, 会进一步加剧卡顿效果.

顶点数量巨多的界面加 loading 动画

这个 loading 不同于切换场景的 loading, 可以做的非常轻, 非常简单, 用来避免同时渲染两个界面导致帧率剧降的情况. 可以是一个门, 门关上时, 前一个界面就不渲染了, 这时在门的后面加载显示新的界面, 隐藏(自动剔除)旧的界面, 门打开, 这时只有新的界面会被渲染出来了.

可能同时渲染两个界面感觉不到这么明显的变化, 但是我们的前一个界面是大基地, 有将近 4000 个顶点, 150 多渲染批次, 这样就会十分明显了.

操作界面

操作界面卡顿主要发生在含有 ScrollView 的界面, 我看了下逻辑, 在数据发生变化时, 会将 ScrollView 的所有节点都移除掉, 重新创建. 这样完全就是一个偷懒的做法, 应当是哪里有数据变化就刷新哪里, 那些数据删除了, 就删除掉对应的子节点, 那些数据是新增的, 就创建一些新的节点加入进去.

这个问题高端机可能觉察不到, 一到 Android 渣机就卡成狗, 完全没法玩. 这里得出的经验就是, 平时测试的时候一定要找一个渣机.

关闭界面

关闭界面时的主要问题是动画不流畅, 代码并没有太大的问题. 我们的关闭界面有一个动画, 整个层淡出并向下移动. 这样就会在动画的过程中露出下面的基地界面, 导致同时渲染顶点数剧增, 帧率骤降, 表现出来的现象就是 “三帧-咔咔咔”.

解决方案也很简单, 去掉这个动画就, 改成直接移除. 如果大家观察一下其他游戏, 在关闭界面时也多是没有动画的.


以上就是我们针对界面做的优化, 没有上代码, 多是理论思路和操作方法. 同时我针对内部小伙伴也写了一篇文章, 内容多是类似, 但是会有一些具体代码上讲解. 大家若是有兴趣的话, 也可以点击这里阅读, 若是有什么疑问及见解, 欢迎指出.

将 untp 发布到 Pypi 上

上一篇年结的时候有提到 untp 这个小工具, 它是我在 github 收获 star 数最多的一个项目. 这个项目本是无心之举, 既然受大家欢迎, 那么一定要好好维护下去 ! 对于它后续的发展, 我打算从两个方面入手:

  1. 更便捷的安装
  2. 支持更多的格式

手机游戏攻防(三) 网络游戏

这篇文章好早就写了, 一直放在草稿箱, 今天整理的时候发现了, 于是修改了一下就发布了出来 !


前段时间我们的游戏公测了下, 在安全这方面遇到了不少问题, 和大家分享一下.

1. 道具负数卖出

卖出道具是多数游戏都有的一个功能, 那么如果卖出一个负数量的道具呢? 卖出道具的逻辑可能是这样的:

1
2
3
function sell(_id, _count)
item_data[_id].count = item_data[_id].count - _count
end

可以看到, 如果不做任何保护的话, 一旦 _count 是负数, 实际会变成加法. 我们遇到的第一个问题就是这个, 玩家通过八门神器或烧饼助手修改出售数量为负数, 这样就会变成增加道具. 一般情况下, 游戏后端会拦截住, 返回卖出失败的结果, 但是不巧, 他们也没有做类似的判断.

解决方案:

对于前端来说, 可以粗暴的断言一下, 因为正常肯定不会出现这个情况, 也可以温和的 Alert 一个提示. 如下:

1
2
3
4
5
6
7
function sell(_id, _count)
if _count < 1 then
Alert.new("卖出道具数量不能小于1")
return
end
item_data[_id].count = item_data[_id].count - _count
end

但这种限制肯定是服务端做的, 毕竟客户端这里只是第一道防线, 中间还有太多的手脚可做 !

2. 游戏加速

烧饼助手有一个游戏加速的功能, 类似于变速齿轮. 可以将游戏加速到十倍甚至百倍的速度, 一般对于网游来说, 真实的时间是服务端来计算的, 客户端的计时只是一个表现. 但是由于我们的战斗模块基本上是离线的, 所以这里在加速的情况下出了问题.

我们战斗有一个特色就是主公技, 是一系列非常强大的技能. 因此在战斗中会严格控制释放次数, 通过消耗能量冷却时间来实现. 正常情况下, 战斗中加速的话整个战斗的节奏就会加快, 因此不会有问题.

但是不知为何, 我们的人物动作加速到一定倍数后就会停止, 而主公技的能量回复和冷却速度却会无限增大, 因此表现出来的就是主公技可以无限释放, 玩家可以轻松挑战数倍强于自己的敌人!

发现这个问题后, 我和小伙伴们都惊呆了, 原来还可以这样搞!!!

解决方案:

现在我们的解决方案是将主公技的能量回复速度与人物动作联系起来, 这样不至于太变态. 同时加入了战斗的时间限制, 如果加速到10倍以上的, 时间很快会耗尽, 战斗失败!

但是我想到了一个更好的办法, 能从源头解决这个问题, 就是对比系统的时间流逝与游戏内的时间流逝速度. 一般, 变速齿轮只会改你当前游戏的时间, 没法改动系统的时间. 只要我么通过 native 的函数获取原生系统的时间, 一对比就可以发现有木有使用加速了.

但是目前还没有去实现这个想法, 有机会吧!

2. socket 抓包/发包

当看到群里有玩家讨论抓包的时候, 我们都目瞪口呆, 这年头作弊都需要高科技了! 玩家通过截取客户端发送的数据包, 重复发送, 这样可以轻松避开客户端的各种限制, 从而达到作弊的目的.

解决方案:

最终的解决方案还是得靠服务端严谨的逻辑来避免, 各种检查参数, 检查返回值! 也可以通过设计来避免, 比如每次发送数据包都附加一个 id 属性, 这个 id 会以某种规则增长, 服务端哪里也有一个 id , 对比之后就可以判断是否玩家作弊了 !


哈哈, 综上所述, 一个有经验的游戏服务端是多麽的重要.

继续进步的 2015

之前的几次年结:

2013年结
2014年结

因为自己非常严重的拖延症, 之前几次年结都拖的好晚 (五月) 才写, 这次终于克服了一下, 下次一定争取元旦写, 毕竟每过一年, 都要有进步的嘛. 这次年结还是按照以往的惯例来写, 从工作, 技术, 生活这几个方面来切入来写.