原生实现富文本展示踩坑指南

原生实现富文本展示踩坑指南

#knowledge/Blog

在过去的一年中维护的业务中,碰到了不少展示富文本的坑

因为是自己封装的,所以 CK 是不能够自己计算出这个组件的高度的,所以我们需要重写 computeLayoutThatFits 方法并返回这个组件正确的高度,鉴于这个组件是用来展示富文本的,所以计算组件高度的问题就转为成了在固定宽度下计算富文本高度的问题:

//计算高度并返回
- (CKComponentLayout)computeLayoutThatFits:(CKSizeRange)constrainedSize {
    CGSize computerSize = [self sizeLabelToFitToSize:constrainedSize.max numberLines:self.numOfLine font:self.normalFont attributeString:self.attributeString];
    return {
        self,
        constrainedSize.clamp({
            CKCeilPixelValue(computerSize.width),
            CKCeilPixelValue(computerSize.height)
        }),
    };
}

- (CGSize)sizeLabelToFitToSize:(CGSize)size numberLines:(NSInteger)numberLines font:(UIFont *)font {
    UILabel *temLabel = [[UILabel alloc] initWithFrame:CGRectMake(0, 0, size.width, size.height)];
    temLabel.text = self;
    temLabel.font = font;
    temLabel.numberOfLines = numberLines;
    [temLabel sizeToFit];
    CGSize attributeSize = temLabel.frame.size;
    return CGSizeMake(ceil(attributeSize.width), ceil(attributeSize.height));
}

看起来一切都很完美,结果上线以后发现计算高度的地方有一定概率会崩溃,原因是在非主线程调用了 UI 相关的代码,的确如此,因为我们在计算高度的时候用到了 UILabel,而 CK 为了提高渲染效率,component 的生成和计算都是在非主线程完成的,所以导致了这个问题。

于是我改成了用 TextKit 的方式来计算:

- (CGSize)sizeLabelToFitToSize:(CGSize)size numberLines:(NSInteger)numberLines font:(UIFont *)font attributeString:(NSAttributedString *)attributeString {
    // 替换成 TextKit 计算, TextKit 可以后台调用
    NSTextStorage *storage = [[NSTextStorage alloc] initWithAttributedString:attributeString];
    NSLayoutManager *layoutManager = [[NSLayoutManager alloc] init];
    NSTextContainer *textContainer = [[NSTextContainer alloc] initWithSize:size];
    textContainer.maximumNumberOfLines = numberLines;
    textContainer.lineFragmentPadding = 0;
    textContainer.lineBreakMode = NSLineBreakByWordWrapping;
    [layoutManager addTextContainer:textContainer];
    [storage addLayoutManager:layoutManager];
    [layoutManager ensureLayoutForTextContainer:textContainer];
    CGRect rect = [layoutManager usedRectForTextContainer:textContainer];
    CGSize attributeSize = rect.size;
    return CGSizeMake(ceil(attributeSize.width), ceil(attributeSize.height));
}

结果发现在字体比较大的情况下,TextKit 的方式计算不准确(并不知道为什么),最后参考了 TTT 的源码使用了 coreText 的方式,才最终把问题解决了。

- (CGSize)sizeLabelToFitToSize:(CGSize)size numberLines:(NSInteger)numberOfLines font:(UIFont *)font {
    CGFloat ZHFLOAT_MAX = 100000;
    CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((__bridge CFAttributedStringRef)self);
    
    CFRange rangeToSize = CFRangeMake(0, (CFIndex)[self length]);
    CGSize constraints = CGSizeMake(size.width, ZHFLOAT_MAX);
    
    if (numberOfLines == 1 || size.width < 0 ) {
        // If there is one line, the size that fits is the full width of the line
        constraints = CGSizeMake(ZHFLOAT_MAX, ZHFLOAT_MAX);
    } else if (numberOfLines > 0) {
        // If the line count of the label more than 1, limit the range to size to the number of lines that have been set
        CGMutablePathRef path = CGPathCreateMutable();
        CGPathAddRect(path, NULL, CGRectMake(0.0f, 0.0f, constraints.width, ZHFLOAT_MAX));
        CTFrameRef frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, 0), path, NULL);
        CFArrayRef lines = CTFrameGetLines(frame);
        
        if (CFArrayGetCount(lines) > 0) {
            NSInteger lastVisibleLineIndex = MIN((CFIndex)numberOfLines, CFArrayGetCount(lines)) - 1;
            CTLineRef lastVisibleLine = CFArrayGetValueAtIndex(lines, lastVisibleLineIndex);
            
            CFRange rangeToLayout = CTLineGetStringRange(lastVisibleLine);
            rangeToSize = CFRangeMake(0, rangeToLayout.location + rangeToLayout.length);
        }
        
        CFRelease(frame);
        CFRelease(path);
    }
    
    CGSize suggestedSize = CTFramesetterSuggestFrameSizeWithConstraints(framesetter, rangeToSize, NULL, constraints, NULL);
    CFRelease(framesetter);
    return CGSizeMake(ceil(suggestedSize.width), ceil(suggestedSize.height));
} 

虽然这是系统行为导致的,但是用户才不管那么多呢,就觉得这是个 bug。 所以为了更好的用户体验,我还是尝试解决了一下这个问题: 1、实现一个 CTTextview 继承自 UITextView 2、重写 setAttributedText 方法,在 set 的同时将文本中链接的 url 和链接在文本中的范围(NSRange)一一对应的存储一份:

- (void)setAttributedText:(NSAttributedString *)attributedText {    
    [super setAttributedText:attributedText];
    self.linkRanges = @{}.mutableCopy;
    self.classNameRanges = @{}.mutableCopy;
    @weakify(self)
    [attributedText enumerateAttribute:NSLinkAttributeName
                               inRange:NSMakeRange(0, [attributedText length])
                               options:0
                            usingBlock:^(id value, NSRange range, BOOL *stop) {
                                @strongify(self)
                                if (value) {
                                    [self.linkRanges setObject:value forKey:[NSValue valueWithRange:range]];
                                }
                            }];
}

3、重写 touchesEnded 方法,将手指触摸点转换为在文本中的位置(CGPoint),再检查这个位置是否在链接所在的区域之中,也就是步骤 2 存储的那些个 NSRange 中:

- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    CGPoint point = [[touches anyObject] locationInView:self];
    @weakify(self)
    [self.linkRanges enumerateKeysAndObjectsUsingBlock:^(NSValue * _Nonnull key, NSString * _Nonnull URLStr, BOOL * _Nonnull stop) {
        @strongify(self)
        NSRange range = key.rangeValue;
        UITextPosition *beginning = self.beginningOfDocument;
        UITextPosition *start = [self positionFromPosition:beginning offset:range.location];
        UITextPosition *end = [self positionFromPosition:start offset:range.length];
        UITextRange *textRange = [self textRangeFromPosition:start toPosition:end];
        CGRect rect = [self firstRectForRange:textRange];

        if (CGRectContainsPoint(rect, point)) {
            // 如果点击处在链接区域内
            [self.delegate didTapLink:self URL:[NSURL URLWithString:URLStr]];
        }
    }];
}

这里只是简单的实现了点击链接能够跳转,点击的效果等都没有涉及到。

CTTextview 类存在的本意是为了使用户轻触也能让链接跳转,但是真正使用起来会发现当触摸的力度比轻触再用力一些些,有一定概率会使链接跳转两次,一次是 CTTextview 内部的逻辑触发的,另一次是 UITextview 触发的,所以以上说到的这种解决方案还是会存在问题,而且我到现在也没有找到好的解决办法…