关于原生Label优化代码的若干问题

事情是这样的,我在V2.4.0的更新说明上看到:

原生平台上使用 FreeType 渲染 Label: 提升 Label 初始化速度,Android 提升 5 倍左右,iOS 提升 3 倍

原生平台上Label提升了这么多性能,不免让人很期待,于是我作了一个小Demo,界面如下:

4个按钮分别实例化4个Prefab,Prefab里面有三十多个Label,每个Prefab对Label设置不同的CacheMode:

  • 按钮1:使用系统字体,CacheMode为Char。
  • 按钮2:使用系统字体,CacheMode为None。
  • 按钮3:使用一个14M的TTF字体,CacheMode为Char。
  • 按钮4:使用一个14M的TTF字体,CacheMode为None。

其中只有按钮3会用到原生的Label优化,Prefab实例化为结点之后,点击结点会Destroy掉,然后点按钮可以重复实例化。

Prefab的内容如下:

我将Demo编译成APK后,运行起来看效果,本以为Prefab3会有很惊喜的性能表现,结果却让人遗憾:

  • 按钮1:第1次实例化为666ms,以后实例化为75ms。诡异的是和按钮2相比,显示效果有点虚。
  • 按钮2:第1次实例化为208ms,以后实例化为177ms。
  • 按钮3:第1次实例化为679ms,以后实例化为193ms;诡异的是每实例化几次,耗时就会回到600多毫秒,如此重复。
  • 按钮4:第1次实例化为190ms,以后实例化为183ms。

说好的提升5倍呢?实际结果却比None还要差。例子都是很简单的,不会有使用姿势不对的问题。

带着这个疑问我去翻看了C++层的源代码,位置在resources\cocos2d-x\cocos\2d里面。好家伙,不看不知道,一看发现了好几个问题。我把问题列在这里,引擎同学们好好看看,这就是一个游戏引擎该有的代码质量吗?

问题1:类名拼写错误

TTFLabelAtals,这个不用多说,修正它就是。

问题2:TTFLabelAtals重复调用init函数。

TTFLabelAtals在构造函数中会调用init:

TTFLabelAtals::TTFLabelAtals(const std::string &fontPath, float fontSize, LabelLayoutInfo *info)
        :_fontName(fontPath), _fontSize(fontSize), _info(info)
{
	init();
}

然后在TTFLabelAtlasCache::load中创建完TTFLabelAtals之后,又主动调用了一次init函数:

std::shared_ptr<TTFLabelAtals> TTFLabelAtlasCache::load(const std::string &font, float fontSizeF, LabelLayoutInfo *info)
    {
        ... ...
        if (!atlas)
        {
            atlas = std::make_shared<TTFLabelAtals>(font, fontSize, info);		// 构造函数调用init
            if(!atlas->init())		// 主动调用init
            {
                return nullptr;
            }
            atlasWeak = atlas;
        }
#else
        ...
#endif
        return atlas;
    }

如果init函数没做啥也就算了,问题是init里面会创建_ttfFont和_fontAtlas,而_ttfFont会调用loadFont,而loadFont会从文件加载上面说的14M的字体文件。可见init函数是一个很重的函数,每调用一次都是一次不小的性能开销。

上面重复调用init,会使_ttffont和_fontAtlas创建出来又被释放,然后又创建一次。代码里用的是share_ptr,把这个细节给隐藏掉了。

修正方法应该是构造函数中调用init去掉。

问题3:LabelRenderer重复调用genStringLayout

LabelRenderer::render会调用一次genStringLayout:

void LabelRenderer::render()
{
	std::string text = getString();
	std::string fontPath = getFontPath();
	if (!_effect || text.empty() || fontPath.empty()) return;
	genStringLayout();		// 第1次调用,会创建_stringLayout
	renderIfChange();
}

renderIfChange中又调用一次genStringLayout:

void LabelRenderer::renderIfChange()
{
	if (!_stringLayout) return;

	if (_cfg->updateFlags & UPDATE_FONT || _cfg->updateFlags & UPDATE_EFFECT)
	{
		// Label创建之后updateFlags应该是0xFFFFFFFF,所以这里第一次一定会调用进来。
		_stringLayout.reset();		// 然后就释放_stringLayout
		genStringLayout();			// 又调用一次

		doRender();            
	}
	... ...

	_cfg->updateFlags = 0;
}

genStringLayout又是一个很重型的函数,里面又会去调用TTFLabelAtals::init函数,所以第1次创建一个带TTF的Char模式的Label时,你知道做了多少重复的事情吗?

问题3:CC_TTF_LABELATLAS_ENABLE_GC宏

这个宏会使TTFLabelAtlasCache用std::weak_ptr去缓存TTFLabelAtals,当我实例化Prefab又将其Destroy之后,TTFLabelAtals会在Label GC时自动从缓存中去掉。然后我再次实例化Prefab,它又会重新走加载字体文件,创建FreeType库等流程。

这就是为什么:每实例化几次,耗时就会回到600多毫秒,如此重复

一般一个游戏用到的字体样式是不会很多的,完全不用担心缓存过多TTFLabelAtals的,所以是建议把这个宏去掉,或默认改为0。

问题4:TTFLabelAtals中_ttfFont的问题

从代码上看,一个TTFLabelAtals代表一个字体样式:字体名,字体大小,描边等。

TTFLabelAtals保存着一个_ttfFont成员(FontFreeType),它在创建时会初始FreeType库,再加载字体文件,然后创建一个Face出来。问题在于每种不同的样式,都要重复创建FreeType库和加载字体文件,如果字体文件很大,这也会是一个性能开销。

我认为这也是值得思考怎么优化的一个点。

问题5:LabelRenderer的getString和getFontPath

这涉及到更细致的性能支节了,但我认为既然要把代码搬到C++层,那应该对C++的内存管理有深入的认识。

getString和getFontPath都是从JS层取到字符串,然后从函数返回。这个过程会涉及到多少次内存的分配和释放?而在每次Render调用时都会调用好几次这些函数。

那么是不是应该优化一下,因为通过updateFlags就可以知道字体或内容的变化,那预先从JS层取出缓存起来,等到updateFlags提示有变化时再重新去取,这样就避免每次Render都从JS取值的这个过程。

再退一步,因为某些原因不能做缓存,那么从getFontPath取出来值之后,应该通过函数传参的方式传递,而不应该每个函数重新调用getFontPath等。具体的函数是:render, renderIfChange, genStringLayout

虽然上面这些问题没法解释为何性能没超过None模式,但举一反三,仔细检查FontAtlas等类,一定能找出问题所在。

说好了要受喷,其实我还是很温柔的,希望写C++层的代码要多多斟酌,毕竟要驾驭好C++,写出真正性能高超的代码并不是那么容易的,特别是用了很多C++11的特性之后,把内存管理的细节隐藏得死死的,内存泄露是解决了,但性能问题也可能更严重。相对来说这一点,引擎的JS层代码要让人放心一些。

我把Demo工程上传了,希望引擎同学们好好检查一下,另外没有带TTF的Char模式和None模式的显示效果不一样,Char模式显示出来是糊的,这个也检查一下吧。下面是两张效果图:

没有带TTF的Char模式

没有带TTF的None模式

NewProject.zip

17赞

有理有据的打脸。。。

目的不在于打脸,而在于希望写C++层的引擎代码时仔细一些,多做测试。

@panda
@jare

还是希望官方把c++层把关好,毕竟让开发者动c++层越少越好:grin: 。我们在js层做再多骚操作,终究还是比不上c++层的修复

感谢大佬赐教!

强!!! :3:

你们看看怎么改吧,这个结果相当于原生的Char模式没法用了。

强,mark

label大佬,顺便帮看看这个问题 TTF Label历史创建数量过多,会崩溃 :joy:

我是菜鸟,但感觉有理有据的,引擎大大们的代码写的这么LOW吗。。。。

问:label性能5倍是什么情况下能做到
官方:到二仙桥

1赞

怪不得人家laya,白鹭总是说性能是你的x倍呢,,,,,:joy:

一个Label就发现这么多问题,值得深思、值得深思、值得深思!!!!!!

1赞

有理有据…官方接锅…顺便膜拜大佬…

就喜欢这种有理有据的喷 膜拜大佬

感谢各位大佬的督促. Label 原生的代码是我提交的, 抱歉给各位大佬带来困惑.

首先是更新说明我没说清楚.
之前原生 Label 有一个比较明显的问题: 通过平台的接口获取 TTF 字体的纹理. 在 Android 就意味着从 JS -> C++ -> Java(JNI) 这样的巨大调用代价. 改动后, Android 性能的提升主要是优化调用路径获得的, 而 iOS 平台的提升是通过 C++ 替换无 JIT 的 JS 代码实现的.

文本越大提升越明显. 如果文本比较少的话, 本身系统调用就少, 所以提升就不明显. 所以准确点的更新说明应该是

原生平台上使用 FreeType CHAR 模式渲染大段文本的 Label: 提升 Label 初始化速度,Android 提升 5 倍左右,iOS 提升 3 倍

更多的讨论参照 https://github.com/cocos-creator/cocos2d-x-lite/issues/2068

可以对比 examplecases 里的测试例 LargetSystemFontTextLargetTTFText, 感受到性能的提升.

大佬提到的其他问题:

说好的 提升5倍 呢?实际结果却比None还要差。例子都是很简单的,不会有使用姿势不对的问题。

这里提升 提升5倍 是相对于改动前的 CHAR 模式(大文本的情况下), 不是和 None 对比. 二者目标场景不同.

我使用附件工程测试在windows上测试, 新的实现 和 旧的实现 运行时间差异不大, ±5ms/310ms. 这应该是各个 Label 文本较少的缘故.

  • 问题1:类名拼写错误
  • 问题2:TTFLabelAtals重复调用init函数。
  • 问题3:LabelRenderer重复调用genStringLayout

确实存在冗余的调用, 修复后在win32上 提升 +30ms/310ms. :+1:

  • 问题3:CC_TTF_LABELATLAS_ENABLE_GC
  • 问题4:TTFLabelAtals_ttfFont的问题

这个两个问题的核心是字体纹理缓存的管理是交给 GC 还是交由开发者. 如果由开发者管理, 就需要提供额外的接口, 也只有开发者清楚什么时候释放字体纹理. 这会增加开发者的负担. 目前考虑是交给GC.
多数情况下, 字体纹理是能得到复用不会立即被释放的 (GC不总是立即的). 如果有开发者有明确的需求, 未来可以增加接口或者临时禁用这个宏 CC_TTF_LABELATLAS_ENABLE_GC.

不过 FT_Face 的缓存应该是可行的, 字体文件不像 Label, 数目相对有限, 可以考虑不释放. 导致的问题就是内存占用增加.

  • 问题5:LabelRenderergetString getFontPath

再退一步,因为某些原因不能做缓存,那么从getFontPath取出来值之后,应该通过函数传参的方式传递,而不应该每个函数重新调用getFontPath等。具体的函数是:render, renderIfChange, genStringLayout。

目前的 Label 已经在 JS 层和 原生层 都做了 flags 管理. 只有在文本/样式等方式变化时才会调用原生 render 的接口, 次数不多. 再多一层缓存的提升有限.

虽然上面这些问题没法解释为何性能没超过None模式,但举一反三,仔细检查FontAtlas等类,一定能找出问题所在。

CHAR 模式在文本较少的情况下 不优于 NONE.
CHAR 模式优势在, 没有最大字符/纹理的限制, 能够复用单字符的纹理.

说好了要受喷,其实我还是很温柔的,希望写C++层的代码要多多斟酌,毕竟要驾驭好C++,写出真正性能高超的代码并不是那么容易的

欢迎各位大佬多多指出问题, 这样引擎才能更快完善:+1:

总(自)结(责)一下

原生Label 中

  • 冗余调用
  • typo
  • 更新说明表述不够明确, 误导各位开发大佬

修复在 https://github.com/cocos-creator/cocos2d-x-lite/pull/2874

@colinsusie

5赞

好的我看看修改

另外没有带TTF的Char模式和None模式的显示效果不一样,Char模式显示出来是糊的,这个也检查一下吧

这个只有在真机上才能看出来,看找时间看看吧。

TTFLabelAtals 名字错误不改了吗,看着怪怪的。

官网的 2.4.0 更新说明,已依据上面的表述进行调整,谢谢大家反馈~