V7投稿|只占一个drawcall的富文本

引言

cc.RichText功能丰富,支持图片等功能,但是drawcall非常高,大量富文本会导致界面卡顿。

游戏中经常遇到只需要颜色变化的富文本,如抽奖记录列表。使用cc.RichText仅仅显示几条记录,drawcall已经100多,滑动界面非常卡顿。
cc_rich
如何减少这种富文本的drawcall呢?

cc.RichText是通过一个个Label拼接成完成文本的,这种方式能支持多种节点混排,但是没有好的办法优化drawcall。

通过查看cc.Label的渲染代码,发现文本是通过canvas的文字绘制,生成texture来渲染的。

canvas文字绘制功能非常强大,可以用来做像word一样的富文本展示。

我们可以修改文本的canvas绘制过程,给文字加上颜色就可以了。

相关源码为:

//cocos2d/core/renderer/utils/label/ttf.js
_updateTexture () {
    _context.clearRect(0, 0, _canvas.width, _canvas.height);
    // use round for line join to avoid sharp intersect point
    _context.lineJoin = 'round';
    //Add a white background to avoid black edges.
    if (!_premultiply) {
        //TODO: it is best to add alphaTest to filter out the background color.
        let _fillColor = _outlineComp ? _outlineColor : _color;
        _context.fillStyle = `rgba(${_fillColor.r}, ${_fillColor.g}, ${_fillColor.b}, ${_invisibleAlpha})`;
        _context.fillRect(0, 0, _canvas.width, _canvas.height);
        _context.fillStyle = `rgba(${_color.r}, ${_color.g}, ${_color.b}, 1)`;
    } else {
        _context.fillStyle = `rgba(${_color.r}, ${_color.g}, ${_color.b}, ${_color.a / 255.0})`;
    }

    let startPosition = this._calculateFillTextStartPosition();
    let lineHeight = this._getLineHeight();
    let drawTextPosX = startPosition.x, drawTextPosY = 0;
    // draw shadow and underline
    this._drawTextEffect(startPosition, lineHeight);
    // draw text and outline
    for (let i = 0; i < _splitedStrings.length; ++i) {
        drawTextPosY = startPosition.y + i * lineHeight;
        if (_outlineComp) {
            _context.strokeText(_splitedStrings[i], drawTextPosX, drawTextPosY);
        }
        _context.fillText(_splitedStrings[i], drawTextPosX, drawTextPosY);
    }

    if (_shadowComp) {
        _context.shadowColor = 'transparent';
    }

    _texture.handleLoadedTexture();
}

这里用到很多引擎内置的方法和本地变量,如果要扩展该功能,需要自定义引擎,修改该类开放本地变量和方法,新增一个富文本渲染类继承该类,就可以完成功能。

但是这里我们不打算自定义引擎,通过使用一些hack的方法完成目标,最终只需一个组件就可以实现。完整代码:

//
class TextSegment {
    text: string;
    style?: { color: cc.Color };
    start: number;
    end: number;
}

let AssemblerClass = null;

function createAssembler() {
    if (AssemblerClass) {
        return new AssemblerClass();
    }

    //@ts-ignore
    let WebglTTFAssembler = cc.Label.__assembler__?.TTF;
    if (!WebglTTFAssembler) {
        return null;
    }

    let _context = null;
    let _canvas = null;
    let _texture = null;
    let _splitedStrings: string[] = [];
    let _nodeContentSize = cc.Size.ZERO;
    let _string = '';
    let _fontDesc = '';
    let _color = null;
    let _hAlign = 0;
    let _textSegmentList: TextSegment[] = [];

    //@ts-ignore
    let textUtils = cc.textUtils;

    class RichColorTextAssembler extends WebglTTFAssembler {

        _updateProperties(comp: RichColorText) {
            super._updateProperties(comp);
            
            //@ts-ignore
            let assemblerData = comp._assemblerData;
            _context = assemblerData.context;
            _canvas = assemblerData.canvas;
            //@ts-ignore
            _texture = comp._frame._original ? comp._frame._original._texture : comp._frame._texture;
            _nodeContentSize = comp.node.getContentSize();
            _color = comp.node.color;
            _hAlign = comp.horizontalAlign;
            _string = comp.string.toString();
            //@ts-ignore
            _fontDesc = this._getFontDesc();

            _textSegmentList = comp.textSegmentList;
        }

        _calculateLabelFont() {
            let paragraphedStrings = _string.split('\n');
            _splitedStrings = paragraphedStrings;
            super._calculateLabelFont();
        }

        _calculateWrapText(paragraphedStrings) {
            super._calculateWrapText(paragraphedStrings);
            _splitedStrings = [];
            let canvasWidthNoMargin = _nodeContentSize.width;
            //@ts-ignore
            _fontDesc = this._getFontDesc();
            for (let i = 0; i < paragraphedStrings.length; ++i) {
                let allWidth = textUtils.safeMeasureText(_context, paragraphedStrings[i], _fontDesc);
                
                let textFragment = textUtils.fragmentText(paragraphedStrings[i],
                    allWidth,
                    canvasWidthNoMargin,
                    //@ts-ignore
                    this._measureText(_context, _fontDesc));
                _splitedStrings = _splitedStrings.concat(textFragment);
            }
        }

        _updateTexture() {
            _context.clearRect(0, 0, _canvas.width, _canvas.height);
            // use round for line join to avoid sharp intersect point
            _context.lineJoin = 'round';

            //@ts-ignore
            let startPosition = this._calculateFillTextStartPosition();
            //@ts-ignore
            let lineHeight = this._getLineHeight();
            let drawTextPosX = startPosition.x, drawTextPosY = 0;

            let splitStringsWidth: number[] = [];
            for (let i = 0; i < _splitedStrings.length; ++i) {
                let curStr = _splitedStrings[i];
                drawTextPosY = startPosition.y + i * lineHeight;
                let curStrWidth = textUtils.safeMeasureText(_context, curStr, _fontDesc);
                splitStringsWidth.push(curStrWidth);
            }

            // draw text
            let startCharIndex = 0;
            let startSegmentIndex = 0;
            for (let i = 0; i < _splitedStrings.length; ++i) {
                drawTextPosY = startPosition.y + i * lineHeight;

                let curStr = _splitedStrings[i];
                let curStrCount = curStr.length;
                let curDrawTextPosX = drawTextPosX;
                let curTotalWidth = 0;

                let curStrWidth = splitStringsWidth[i];
                if (_hAlign === cc.macro.TextAlignment.RIGHT) {
                    curDrawTextPosX = drawTextPosX - curStrWidth;
                } else if (_hAlign === cc.macro.TextAlignment.CENTER) {
                    curDrawTextPosX = drawTextPosX - curStrWidth / 2;
                } 

                let curStrStart = 0;
                for (let i = startSegmentIndex; i < _textSegmentList.length; ++i) {
                    let segment = _textSegmentList[i];
                    let colorStart = segment.start;
                    let colorEnd = segment.end;
                    let colorRange = colorEnd - startCharIndex - curStrStart;

                    if (startCharIndex + curStrStart < colorStart) {
                        continue;
                    }

                    let color = segment.style?.color || _color;
                    _context.fillStyle = `rgba(${color.r}, ${color.g}, ${color.b}, 1)`;

                    let strCount = 0;
                    let lastStr = false;
                    if (colorRange >= curStr.length - curStrStart) {
                        strCount = curStr.length - curStrStart;
                        lastStr = true;
                    } else {
                        strCount = colorEnd - startCharIndex - curStrStart;
                    }

                    let str = curStr.substring(curStrStart, curStrStart + strCount);
                    let width = 0;
                    if (lastStr) {
                        width = curStrWidth - curTotalWidth;
                    } else {
                        width = textUtils.safeMeasureText(_context, str, _fontDesc);
                    }
                    
                    let strPosX = 0;
                    if (_hAlign === cc.macro.TextAlignment.RIGHT) {
                        strPosX = width;
                    } else if (_hAlign === cc.macro.TextAlignment.CENTER) {
                        strPosX = width / 2;
                    }

                    _context.fillText(str, curDrawTextPosX + curTotalWidth + strPosX, drawTextPosY);
                    curTotalWidth += width;
                    curStrStart += strCount;
                    
                    if (!lastStr) {
                        startSegmentIndex++;
                    } else {
                        break;
                    }
                }

                startCharIndex += curStrCount;
            }

            _texture.handleLoadedTexture();
        }
    }

    AssemblerClass = RichColorTextAssembler;
    return new AssemblerClass();
}

class HtmlTextParser {
    private _resultObjectArray: TextSegment[] = [];
    private _stack = [];
    private _curCharCount = 0;
    private _resultContent = "";

    public parse(htmlString: string) {
        this._resultObjectArray = [];
        this._stack = [];
        this._curCharCount = 0;
        this._resultContent = "";

        let startIndex = 0;
        let length = htmlString.length;
        while (startIndex < length) {
            let tagEndIndex = htmlString.indexOf('>', startIndex);
            let tagBeginIndex = -1;
            if (tagEndIndex >= 0) {
                tagBeginIndex = htmlString.lastIndexOf('<', tagEndIndex);
                let noTagBegin = tagBeginIndex < (startIndex - 1);

                if (noTagBegin) {
                    tagBeginIndex = htmlString.indexOf('<', tagEndIndex + 1);
                    tagEndIndex = htmlString.indexOf('>', tagBeginIndex + 1);
                }
            }

            if (tagBeginIndex < 0) {
                this._stack.pop();
                this._processResult(htmlString.substring(startIndex));
                startIndex = length;
            } else {
                let newStr = htmlString.substring(startIndex, tagBeginIndex);
                let tagStr = htmlString.substring(tagBeginIndex + 1, tagEndIndex);
                if (tagStr === "") newStr = htmlString.substring(startIndex, tagEndIndex + 1);
                this._processResult(newStr);
                if (tagEndIndex === -1) {
                    // cc.error('The HTML tag is invalid!');
                    tagEndIndex = tagBeginIndex;
                } else if (htmlString.charAt(tagBeginIndex + 1) === '\/') {
                    this._stack.pop();
                } else {
                    this._addToStack(tagStr);
                }
                startIndex = tagEndIndex + 1;
            }
        }

        return { arr: this._resultObjectArray, content: this._resultContent };
    }

    private _attributeToObject(attribute: string) {
        attribute = attribute.trim();

        let obj: any = {};
        let header = attribute.match(/^(color|size)(\s)*=/);
        let tagName;
        let nextSpace;
        if (header) {
            tagName = header[0];
            attribute = attribute.substring(tagName.length).trim();
            if (attribute === "") return obj;

            //parse color
            nextSpace = attribute.indexOf(' ');
            switch (tagName[0]) {
                case 'c':
                    if (nextSpace > -1) {
                        obj.color = new cc.Color().fromHEX(attribute.substring(0, nextSpace).trim());
                    } else {
                        obj.color = new cc.Color().fromHEX(attribute);
                    }
                    break;
            }

            return obj;
        }

        return obj;
    }

    private _addToStack(attribute: string) {
        let obj = this._attributeToObject(attribute);

        if (this._stack.length === 0) {
            this._stack.push(obj);
        } else {
            //for nested tags
            let previousTagObj = this._stack[this._stack.length - 1];
            for (let key in previousTagObj) {
                if (!(obj[key])) {
                    obj[key] = previousTagObj[key];
                }
            }
            this._stack.push(obj);
        }
    }

    private _processResult(value: string) {
        if (value === "") {
            return;
        }

        let start = this._curCharCount;
        let end = start + value.replace(/\n/g, "").length;
        this._curCharCount = end;
        this._resultContent += value;
        if (this._stack.length > 0) {
            this._resultObjectArray.push({ text: value, style: this._stack[this._stack.length - 1], start, end });
        } else {
            this._resultObjectArray.push({ text: value, start, end });
        }
    }
}

const htmlTextParser = new HtmlTextParser();

const { ccclass, property } = cc._decorator

@ccclass
export default class RichColorText extends cc.Label {

    @property
    private _richText: string = "";

    @property({ multiline: true })
    get richText() {
        return this._richText;
    }

    set richText(val: string) {
        this._richText = val;
        let result = htmlTextParser.parse(val);
        this._textSegmentList = result.arr;
        this.string = result.content;
    }

    private _textSegmentList: TextSegment[] = [];

    get textSegmentList() {
        if (this.string != "" && this._richText != "" && this._textSegmentList.length == 0) {
            let result = htmlTextParser.parse(this._richText);
            this._textSegmentList = result.arr;
        }

        return this._textSegmentList;
    }

    protected _resetAssembler() {
        let assembler = createAssembler();
        if (!assembler) {
            //@ts-ignore
            super._resetAssembler();
            return;
        }

        //@ts-ignore
        this._assembler = assembler;
        //@ts-ignore
        this.setVertsDirty();
        assembler.init(this);
    }
}

最终效果每个富文本仅仅占一个drawcall:
new_rich

测试中使用的富文本内容:


<color=#FFFFFF>10连抽获得</color><color=#ec81ff>大环刀武器包</color><color=#FFFFFF> 1个</color><color=#FFFFFF>、</color><color=#5aff5a>紫金刀碎片包</color><color=#FFFFFF> 1个</color><color=#FFFFFF>、</color><color=#ffd956>碧水剑碎片包</color><color=#FFFFFF> 1个</color><color=#FFFFFF>、</color><color=#5ee3ff>铁笛碎片包</color><color=#FFFFFF> 1个</color><color=#FFFFFF>、</color><color=#ffd956>白虹剑碎片包</color><color=#FFFFFF> 1个</color><color=#FFFFFF>、</color><color=#ec81ff>大环刀碎片包</color><color=#FFFFFF> 1个</color><color=#FFFFFF>、</color><color=#5ee3ff>金钱镖碎片包</color><color=#FFFFFF> 1个</color><color=#FFFFFF>、</color><color=#5aff5a>鬼头刀碎片包</color><color=#FFFFFF> 1个</color><color=#FFFFFF>、</color><color=#ffd956>屠龙刀碎片包</color><color=#FFFFFF> 1个</color><color=#FFFFFF>、</color><color=#ffd956>匕首碎片包</color><color=#FFFFFF> 1个</color>

用法

该组件继承自cc.Label,新增richText字段,设置richText内容,文字内容自动变色。

richText内容使用cc.RichText的xml语法的color标签。

文字支持整体加粗、斜体。

注意:

  1. 只有设置richText才支持富文本,设置cc.Label的string不生效。

  2. 不支持文字大小变化,不支持文字描边、投影、下划线,不支持插入图片。

  3. 不支持文字缓存模式BITMAP和CHAR,不支持bmfont字体和艺术字。

  4. 只支持颜色标签,不支持其他任何标签,换行请直接使用’\n’,不要使用xml的br标签。

  5. 仅仅测试过h5端,原生没有测试。3.x版本请大家自行参考修改,如有完成的,欢迎分享到回帖。

11赞

太优秀了老哥,不知道原生端支不支持

原生是用 freetype 实现的,估计有点难

请问你的这个想法哪里来的。还是你的旧项目用到了。

思路很好, 用于只设置颜色、不设置大小的高频任务场景, 必然不支持原生~

顶顶顶顶顶顶顶顶顶顶顶顶顶顶顶