• 基于CoreText的排版引擎


    前言

    本人今年主要在负责猿题库iOS客户端的开发,本文旨在通过分享猿题库iOS客户端开发过程中的技术细节,达到总结和交流的目的。

    这是本技术分享系列文章的第三篇。本文涉及的技术细节是:基于CoreText的排版引擎。

    CoreText概述

    因为猿题库的做题和解析界面需要复杂的排版,所以我们基于CoreText实现了自己的富文本排版引擎。我们的排版引擎对公式、图片和链接有着良好支持,并且支持各种字体效果混排。对于内容中的图片,支持点击查看大图功能,对于内容中的链接,支持点击操作。

    下图是我们应用的一个截图,可以看到公式,图片与文字混排良好。

    对于富文本排版,除了可以用CoreText实现外,还可以用UIWebView实现。我以前写过一篇介绍如何用UIWebView进行复杂内容显示和交互的文章《关于UIWebView和PhoneGap的总结》,里面介绍了使用UIWebView如何处理参数传递,同步与异步等问题,感兴趣的同学也可以翻看。

    基于CoreText来实现和基于UIWebView来实现相比,前者有以下好处:

    1. CoreText占用的内存更少,UIWebView占用的内存更多。
    2. CoreText在渲染界面前就可以精确地获得显示内容的高度(只要有了CTFrame即可),而UIWebView只有渲染出内容后,才能获得内容的高度(而且还需要用javascript代码来获取)
    3. CoreText的CTFrame可以在后台线程渲染,UIWebView的内容只能在主线程(UI线程)渲染。
    4. 基于CoreText可以做更好的原生交互效果,交互效果可以更细腻。而UIWebView的交互效果都是用javascript来实现的,在交互效果上会有一些卡顿存在。例如,在UIWebView下,一个简单的按钮按下效果,都无法做到原生按钮的即时和细腻的按下效果。

    当然基于CoreText的方案也有一些劣势:

    1. CoreText渲染出来的内容不能像UIWebView那样方便地支持内容的复制。
    2. 基于CoreText来排版,需要自己处理图片排版相关的逻辑,也需要自己处理链接点击操作的支持。

    我们最初的猿题库行测第一版采用了基于UIWebView来实现,但是做出来发现一些小的交互细节无法做到精致。所以后来的第二版我们就全部转成用CoreText实现,虽然实现成本上增加了不少,但是应用的交互效果好多了。

    使用CoreText也为我们后来的iPad版提供了技术积累,因为iPad版的页面排版更加复杂,用UIWebView是完全无法完成相应的交互和排版需求的。

    关于如何基于CoreText来做一个排版引擎,我主要参考的是这篇教程:《Core Text Tutorial for iOS: Making a Magazine App》 以及Nimbus 中的NIAttributeLabel.m 的实现,在这里我就不重复教程中的内容了,我主要讲一些实现细节。

    实现细节

    服务端接口

    我们在后台实现了一个基于UBB 的富文本编译器。使用UBB的原因是:

    1. UBB相对于HTML来说,虽然功能较简单,但是能完全满足我们对于富文本排版的需求。
    2. 做一个UBB的语法解析器比较简单,便于我们将UBB渲染到各个平台上。

    为了简化iOS端的实现,我们将UBB的语法解析在服务器端完成。服务器端提供了接口,可以直接获得将UBB解析成类似HTML的文件对象模型(DOM) 的树型数据结构。有了这个树型数据结构,iOS端渲染就简单多了,无非就是递归遍历树型节点,将相关的内容转换成 NSAttributeString即可,之后将NSAttrubiteString转成CoreText的CTFrame即可用于界面的绘制。

    支持图文混排

    支持图文混排在教程:《Core Text Tutorial for iOS: Making a Magazine App》 中有介绍,我们在解析DOM树遇到图片节点时,则将该内容转成一个空格,随后设置该空格在绘制时,需要我们自己指定宽高相关信息,而宽高信息在图片节点中都有提供。这样,CoreText引擎在绘制时,就会把相关的图片位置留空,之后我们将图片异步下来下来后,使用CoreGraph相关的API将图片再画在界面上,就实现了图文混排功能。

    下面的相关的示例代码:

    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
    
    /* Callbacks */
    static void deallocCallback( void* ref ){
        [(id)ref release];
    }
    static CGFloat ascentCallback( void *ref ){
        CGFloat height = [(NSString*)[(NSDictionary*)ref objectForKey:@"height"] floatValue];
        return height/2 + [FrameParserConfig sharedInstance].baselineFromMid;
    }
    static CGFloat descentCallback( void *ref ){
        CGFloat height = [(NSString*)[(NSDictionary*)ref objectForKey:@"height"] floatValue];
        return height/2 - [FrameParserConfig sharedInstance].baselineFromMid;
    }
    static CGFloat widthCallback( void* ref ){
        return [(NSString*)[(NSDictionary*)ref objectForKey:@"width"] floatValue];
    }
    + (void)appendDelegateData:(NSDictionary *)delegateData ToString:(NSMutableAttributedString*)contentString {
        //render empty space for drawing the image in the text //1
        CTRunDelegateCallbacks callbacks;
        callbacks.version = kCTRunDelegateCurrentVersion;
        callbacks.getAscent = ascentCallback;
        callbacks.getDescent = descentCallback;
        callbacks.getWidth = widthCallback;
        callbacks.dealloc = deallocCallback;
        CTRunDelegateRef delegate = CTRunDelegateCreate(&callbacks, delegateData);
        [delegateData retain];
        // Character to use as recommended by kCTRunDelegateAttributeName documentation.
        // use " " will lead to wrong width in CTFramesetterSuggestFrameSizeWithConstraints
        unichar objectReplacementChar = 0xFFFC;
        NSString * objectReplacementString = [NSString stringWithCharacters:&objectReplacementChar length:1];
        NSDictionary * attributes = [self getAttributesWithStyleArray:nil];
        //try to apply linespacing attributes to this placeholder
        NSMutableAttributedString * space = [[NSMutableAttributedString alloc] initWithString:objectReplacementString attributes:attributes];
        CFAttributedStringSetAttribute((CFMutableAttributedStringRef)space, CFRangeMake(0, 1), kCTRunDelegateAttributeName, delegate);
        CFRelease(delegate);
        [contentString appendAttributedString:space];
        [space release];
    }
    

    这里需要注意的是,用来代替图片的占位符使用空格会带来排版上的异常,具体原因未知,我们猜测是CoreText的bug,参考Nimbus 的实现后,我们使用 0xFFFC作为占位符,就没有遇到问题了。

    支持链接

    支持链接点击的主要实现的方式是:

    1. 在解析DOM树的时候,记录下链接串在整个富文本中的位置信息(包括offset和length)。
    2. 在CoreText渲染到的view上,监听用户操作事件,使用 CTLineGetStringIndexForPosition函数来获得用户点击的位置对应 NSAttributedString 字符串上的位置信息(index) 3.判断第2步得到的index是否在第一步记录的各个链接的区间范围内,如果在范围内,则表示用户点击了某一个链接。

    这段逻辑的关键代码如下:

    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
    
    // test touch point is on link or not
    + (LinkData *)touchLinkInView:(UIView *)view atPoint:(CGPoint)point data:(CTTableViewCellData *)data {
        CTFrameRef textFrame = data.ctFrame;
        CFArrayRef lines = CTFrameGetLines(textFrame);
        if (!lines) return nil;
        CFIndex count = CFArrayGetCount(lines);
        LinkData *foundLink = nil;
        CGPoint origins[count];
        CTFrameGetLineOrigins(textFrame, CFRangeMake(0,0), origins);
        // CoreText context coordinates are the opposite to UIKit so we flip the bounds
        CGAffineTransform transform =  CGAffineTransformScale(CGAffineTransformMakeTranslation(0, view.bounds.size.height), 1.f, -1.f);
        for (int i = 0; i < count; i++) {
            CGPoint linePoint = origins[i];
            CTLineRef line = CFArrayGetValueAtIndex(lines, i);
            CGRect flippedRect = [self getLineBounds:line point:linePoint];
            CGRect rect = CGRectApplyAffineTransform(flippedRect, transform);
            if (CGRectContainsPoint(rect, point)) {
                CGPoint relativePoint = CGPointMake(point.x-CGRectGetMinX(rect),
                                                    point.y-CGRectGetMinY(rect));
                CFIndex idx = CTLineGetStringIndexForPosition(line, relativePoint);
                foundLink = [self linkAtIndex:idx linkArray:data.linkArray];
                return foundLink;
            }
        }
        return nil;
    }
    

    基于CoreText的内容省略

    我们在使用CoreText时,还遇到一个具体排版上的问题。正常情况下,在生成CTFrame之后,只需要调用:CTFrameDraw(self.data.ctFrame, context);即可完成界面的绘制。但是产品提出了一个需求,对于某些界面,当显示不下的时候,需要将多余内容用...来表示。这让我们的绘制逻辑需要特别处理,以下是具体的实现:

    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
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    
    static NSString* const kEllipsesCharacter = @"u2026";
    CGPathRef path = CTFrameGetPath(_data.ctFrame);
    CGRect rect = CGPathGetBoundingBox(path);
    CFArrayRef lines = CTFrameGetLines(_data.ctFrame);
    CFIndex lineCount = CFArrayGetCount(lines);
    NSInteger numberOfLines = MIN(_numberOfLines, lineCount);
    CGPoint lineOrigins[numberOfLines];
    CTFrameGetLineOrigins(_data.ctFrame, CFRangeMake(0, numberOfLines), lineOrigins);
    NSAttributedString *attributedString = _data.attributedString;
    for (CFIndex lineIndex = 0; lineIndex < numberOfLines; lineIndex++) {
        CGPoint lineOrigin = lineOrigins[lineIndex];
        lineOrigin.y =  self.frame.size.height + (lineOrigin.y - rect.size.height);
        CGContextSetTextPosition(context, lineOrigin.x, lineOrigin.y);
        CTLineRef line = CFArrayGetValueAtIndex(lines, lineIndex);
        BOOL shouldDrawLine = YES;
        if (lineIndex == numberOfLines - 1) {
            CFRange lastLineRange = CTLineGetStringRange(line);
            if (lastLineRange.location + lastLineRange.length < (CFIndex)attributedString.length) {
                CTLineTruncationType truncationType = kCTLineTruncationEnd;
                NSUInteger truncationAttributePosition = lastLineRange.location + lastLineRange.length - 1;
                NSDictionary *tokenAttributes = [attributedString attributesAtIndex:truncationAttributePosition
                                                                     effectiveRange:NULL];
                NSAttributedString *tokenString = [[NSAttributedString alloc] initWithString:kEllipsesCharacter
                                                                                  attributes:tokenAttributes];
                CTLineRef truncationToken = CTLineCreateWithAttributedString((__bridge CFAttributedStringRef)tokenString);
                NSMutableAttributedString *truncationString = [[attributedString attributedSubstringFromRange:NSMakeRange(lastLineRange.location, lastLineRange.length)] mutableCopy];
                if (lastLineRange.length > 0) {
                    // Remove any whitespace at the end of the line.
                    unichar lastCharacter = [[truncationString string] characterAtIndex:lastLineRange.length - 1];
                    if ([[NSCharacterSet whitespaceAndNewlineCharacterSet] characterIsMember:lastCharacter]) {
                        [truncationString deleteCharactersInRange:NSMakeRange(lastLineRange.length - 1, 1)];
                    }
                }
                [truncationString appendAttributedString:tokenString];
                CTLineRef truncationLine = CTLineCreateWithAttributedString((__bridge CFAttributedStringRef)truncationString);
                CTLineRef truncatedLine = CTLineCreateTruncatedLine(truncationLine, self.size.width, truncationType, truncationToken);
                if (!truncatedLine) {
                    // If the line is not as wide as the truncationToken, truncatedLine is NULL
                    truncatedLine = CFRetain(truncationToken);
                }
                CFRelease(truncationLine);
                CFRelease(truncationToken);
                CTLineDraw(truncatedLine, context);
                CFRelease(truncatedLine);
                shouldDrawLine = NO;
            }
        }
        if (shouldDrawLine) {
            CTLineDraw(line, context);
        }
    }
    

    后记

    以上源码很多都参考了Nimbus的实现,在此再一次表达一下对开源社区的感谢。

    在大约2年前,CoreText还是一个新玩意。那时候微博的界面都还是用控件组合得到的。慢慢的,大家都开始接受CoreText,很多应用都广泛地将CoreText应用于自己的界面中,做出来了更加复杂的排版、交互效果。在iOS7之后,苹果推出了更加易于使用的TextKit,使得富文本排版更加容易,相信以后的iOS应用界面会更加美观,交互更加绚丽。

    转自:http://blog.devtang.com/blog/2013/10/21/the-tech-detail-of-ape-client-3/

     Oct 21st, 2013  iOS

    原创文章,版权声明:自由转载-非商用-非衍生-保持署名 | Creative Commons BY-NC-ND 3.0

  • 相关阅读:
    推荐给新手gopher的一些书籍
    flask中路由处理
    flask中间件之请求扩展
    Chrome调试技巧
    iconfont 使用
    @font-face 使用过程
    SEO
    数据结构与算法2-4 队列
    数据结构与算法2-4 堆栈链式存储
    软件推荐--Sublime Text3常用快捷键查询(不断更新ing)
  • 原文地址:https://www.cnblogs.com/yingkong1987/p/3384997.html
Copyright © 2020-2023  润新知