广告位 · 页头(后台-模块管理-新建模块后替换此区域)

macOS Core Text排版性能如何优化?CTFramesetter与CTLine绘制、字形缓存与离屏渲染实践

长文档渲染在macOS上并不简单。如果每一帧都重新创建CTFramesetter、重新计算所有CTLine和CTRun,AppKit或自绘视图很快就会被排版和测量拖慢。Core Text本身在设计上已经做了不少缓存,但直接用C API绘制时,很多重复工作仍会落到每帧的drawRect调用里。性能优化的核心,是把排版结果尽量留在内存里复用,同时避免在滚动过程中重复执行高成本的位图合成。

macOS Core Text排版性能如何优化?CTFramesetter与CTLine绘制、字形缓存与离屏渲染实践

这里不讨论CTFramesetter是否线程安全等老问题,而是从绘制管线、字形缓存和离屏渲染三个角度说明如何降低CPU与GPU负担。下文给出的代码基于Objective-C和Core Text C API,Swift项目可以通过桥接直接使用。

一、重新审视CTFramesetter与CTLine的绘制成本

CTFramesetter拿到的输入是NSAttributedString。它内部会把属性串转换成排版引擎可用的字形与段落样式,再根据约束路径生成CTFrame。CTFrame里包含一组CTLine,每个CTLine代表一行文本;CTLine再拆成CTRun,每个CTRun对应同一属性区间。调用CTFrameDraw时,Core Text会遍历所有行执行测量和绘制。这个过程并不是零成本:如果字符串很长,framesetter创建和首帧排版可能耗时数十毫秒甚至上百毫秒。

很多优化建议会先想到缓存framesetter。确实,同一个NSAttributedString如果内容不变,framesetter可以复用。但framesetter不是线程安全的,也不能跨线程随意访问。更灵活的方案是:内容变化时才重建framesetter,布局宽度变化时只重建CTFrame,而CTLine如果宽度不变则继续复用。CTLine的创建依赖于宽度与换行策略,宽度不变时缓存CTLine比缓存整个CTFrame更细粒度。

绘制时还要注意坐标系。Core Text默认使用左下角为原点的文本坐标系,而macOS视图的drawRect使用左上角原点。不翻转坐标直接绘制会导致文字上下颠倒。常用的做法是保存上下文状态,设置文本矩阵为单位矩阵,再平移和缩放。下面的代码展示了最小绘制路径:

#include <CoreText/CoreText.h>
- (void)drawText:(NSAttributedString *)attributedString
        inContext:(CGContextRef)context
             rect:(CGRect)rect {
    CGContextSaveGState(context);
    // 重置文本矩阵并翻转坐标系
    CGContextSetTextMatrix(context, CGAffineTransformIdentity);
    CGContextTranslateCTM(context, 0, rect.size.height);
    CGContextScaleCTM(context, 1.0, -1.0);
    CGMutablePathRef path = CGPathCreateMutable();
    CGPathAddRect(path, NULL, rect);
    CTFramesetterRef framesetter =
        CTFramesetterCreateWithAttributedString((__bridge CFAttributedStringRef)attributedString);
    CTFrameRef frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, 0), path, NULL);
    CTFrameDraw(frame, context);
    CFRelease(frame);
    CFRelease(framesetter);
    CFRelease(path);
    CGContextRestoreGState(context);
}

这段代码正确但不可频繁使用,尤其不要在滚动视图的每一帧里重复执行。framesetter和frame的创建开销会随着文本量线性增长。优化方向很明确:把framesetter保存为成员变量,内容不变时跳过创建;把frame的创建延迟到宽度真正变化时;更极端的做法是把CTLine直接缓存下来,避免后续重复触发换行计算。

二、字形缓存:把CTLine和CTRun变成可复用资源

对于固定宽度下的长文本,最耗时的部分往往不是最终绘制,而是排版计算。Core Text在创建CTFrame时,会根据路径宽度、字体、字间距、段落样式等决定每一行的断行位置。断行是一个贪心过程,涉及大量字形宽度查询。如果滚动列表的行宽不变,完全可以只做一次排版,把CTLine数组保存下来,后续绘制直接使用已有CTLine。

CTLine本身是Core Foundation对象,可以安全地放在NSArray或NSDictionary中。只要底层NSAttributedString和字体对象不被释放,CTLine就能保持有效。缓存CTLine时,建议同时记录每一行的排版原点、行宽、上行高度和下行高度,后续绘制不用再调用CTFrameGetLineOrigins或CTLineGetTypographicBounds。下面是一个布局并缓存行的示例:

- (void)layoutAndCacheLines:(NSAttributedString *)text
            constrainedWidth:(CGFloat)width {
    CTFramesetterRef framesetter =
        CTFramesetterCreateWithAttributedString((__bridge CFAttributedStringRef)text);
    CGPathRef path = CGPathCreateWithRect(CGRectMake(0, 0, width, CGFLOAT_MAX), NULL);
    CTFrameRef frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, 0), path, NULL);
    NSArray *lines = (__bridge NSArray *)CTFrameGetLines(frame);
    NSUInteger lineCount = lines.count;
    CGPoint lineOrigins[lineCount];
    CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), lineOrigins);
    NSMutableArray *cache = [NSMutableArray arrayWithCapacity:lineCount];
    for (NSUInteger i = 0; i < lineCount; i++) {
        CTLineRef line = (__bridge CTLineRef)lines[i];
        CGFloat ascent = 0.0;
        CGFloat descent = 0.0;
        CGFloat leading = 0.0;
        double lineWidth = CTLineGetTypographicBounds(line, &ascent, &descent, &leading);
        NSMutableDictionary *info = [NSMutableDictionary dictionary];
        info[@"line"] = (__bridge id)line;
        info[@"width"] = @(lineWidth);
        info[@"originX"] = @(lineOrigins[i].x);
        info[@"originY"] = @(lineOrigins[i].y);
        info[@"ascent"] = @(ascent);
        info[@"descent"] = @(descent);
        info[@"leading"] = @(leading);
        [cache addObject:info];
    }
    self.lineCache = cache;
    self.layoutWidth = width;
    CFRelease(frame);
    CFRelease(path);
    CFRelease(framesetter);
}

缓存建立后,绘制阶段只需要翻转坐标系,然后遍历lineCache,从字典中取出CTLine,调用CTLineDraw即可。这样避免了每帧重复创建framesetter和frame,也避免了重复断行。值得注意的是,CTLine虽然可以复用,但不要把它跨越不同字体度量或文本内容变更时继续使用。内容一旦改动,必须失效缓存并重新布局。

如果需要更细粒度的字形缓存,可以继续缓存CTRun以及CTRunGetGlyphs、CTRunGetPositions的结果。不过CTRun的直接绘制接口并不常用,大多数场景下缓存CTLine已经足够。真正进入字形级别的缓存优化,往往是为了自定义绘制,比如逐字形渲染高亮或者做文字动画。此时可以用CTRunGetGlyphs拿到CGGlyph数组,再配合CTFontGetGlyphsForCharacters提前建立字符到字形的映射。这个映射可以持久化在NSCache中,减少后续查找。

三、离屏渲染:用位图缓存减少重复绘制

如果一行文本或一段文本长时间不变,但需要频繁合成到不同背景上,每次都调用CTLineDraw仍然会产生一定的CPU开销。离屏渲染的思路是:先把文本行或文本块绘制到一个CGBitmapContext中,生成CGImageRef,之后只做CGContextDrawImage。位图合成比逐字形绘制快很多,尤其在图层较多、需要多次复用时更明显。

离屏上下文需要注意Retina缩放。屏幕scale为2时,位图像素尺寸应当是逻辑尺寸乘以2,否则图像会模糊。创建位图上下文时还要指定合适的颜色空间和Alpha信息。位图上下文默认原点在左下角,与Core Text一致,因此绘制CTLine时通常不需要额外翻转,但要按需设置文本矩阵。下面演示把一个CTLine渲染成透明背景的CGImage:

- (CGImageRef)renderLineToImage:(CTLineRef)line
                           size:(CGSize)size
                          scale:(CGFloat)scale {
    size_t pixelWidth = (size_t)(size.width * scale);
    size_t pixelHeight = (size_t)(size.height * scale);
    CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
    CGContextRef bitmapContext = CGBitmapContextCreate(NULL,
                                                        pixelWidth,
                                                        pixelHeight,
                                                        8,
                                                        pixelWidth * 4,
                                                        colorSpace,
                                                        kCGImageAlphaPremultipliedLast);
    CGColorSpaceRelease(colorSpace);
    if (bitmapContext == NULL) {
        return NULL;
    }
    // 位图上下文默认以左下角为原点,Core Text正好匹配
    CGContextSetTextMatrix(bitmapContext, CGAffineTransformIdentity);
    CGContextSetTextPosition(bitmapContext, 0, 0);
    CTLineDraw(line, bitmapContext);
    CGImageRef image = CGBitmapContextCreateImage(bitmapContext);
    CGContextRelease(bitmapContext);
    return image;
}

生成CGImage后,可以缓存到字典或NSCache里,key可以用行的哈希或行索引。绘制到主上下文时,使用CGContextDrawImage把位图放回正确位置。注意CGContextDrawImage在macOS的CGContext中,位图绘制通常遵循目标上下文坐标系,需要根据实际上下文设置正确的翻转。这里如果目标上下文已经做了Core Text的Y轴翻转,位图绘制可能需要再次调整位置。一个更稳妥的办法是统一在绘制函数中维护好坐标系,避免混用。

离屏渲染并非没有代价。位图上下文占用内存,像素宽度乘以高度再乘以4字节,如果不加限制会迅速消耗几MB到几十MB。因此离屏缓存更适合静态文本块或行数较少的场景。对于超长文档,不要一股脑把所有行都离屏渲染,可以只对可视区域附近的行做位图缓存,或者使用NSCache让系统在内存紧张时自动淘汰。

四、三种策略的性能差异与落地建议

为了方便判断,可以做一个简单对比。假设一个5000行的日志文本,行宽固定为800点。策略A每次drawRect都完整创建framesetter和frame;策略B缓存framesetter和frame,但每次绘制仍遍历所有行;策略C缓存CTLine数组,并按需绘制可见行。策略A在快速滚动时CPU占用可能达到80%以上,策略B会降到40%左右,策略C通常能把文本绘制消耗控制在10%以内。实际数据受字体、字重、段落样式影响,但趋势很明确:缓存越靠近最终绘制的对象,重复计算越少。

落地方案可以分三步推进。第一步,把framesetter从drawRect中移出,内容变更时重建。第二步,为固定宽度布局建立CTLine缓存,记录每行度量信息。第三步,在需要反复合成的地方引入离屏位图缓存,并设置大小上限。对于NSTextView类系统控件,无法直接替换内部绘制,但可以通过启用TextKit 2或调整layoutManager的委托来避免不必要的重排。对于自绘文本视图,上述优化可以直接实施。

还要注意不要在主线程做首次全量排版。如果文本来自文件或网络,可以先在后台线程用相同宽度生成CTFrame和CTLine缓存,再把结果切回主线程显示。Core Text对象在创建后可以在其他线程读取,只要不同时修改底层属性串。配合NSOperationQueue或GCD,能显著改善首屏加载体验。

最后提醒两点:一是所有Core Foundation对象都要手动管理引用计数,使用桥接时避免过早释放;二是坐标系错误会导致文字镜像或位置偏移,调试时优先检查CTM和文本矩阵。把这几条做好,Core Text在macOS上的长文本渲染完全能保持流畅。

广告位 · 文章底部(后台-模块管理替换此区域)