记一次 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年结

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

阅读更多