基於CoreText的排版引擎

前言

本人今年主要在負責猿題庫iOS客戶端的開發,本文旨在經過分享猿題庫iOS客戶端開發過程當中的技術細節,達到總結和交流的目的。javascript

這是本技術分享系列文章的第三篇。本文涉及的技術細節是:基於CoreText的排版引擎。java

CoreText概述

由於猿題庫的作題和解析界面須要複雜的排版,因此咱們基於CoreText實現了本身的富文本排版引擎。咱們的排版引擎對公式、圖片和連接有着良好支持,而且支持各類字體效果混排。對於內容中的圖片,支持點擊查看大圖功能,對於內容中的連接,支持點擊操做。ios

下圖是咱們應用的一個截圖,能夠看到公式,圖片與文字混排良好。git

對於富文本排版,除了能夠用CoreText實現外,還能夠用UIWebView實現。我之前寫過一篇介紹如何用UIWebView進行復雜內容顯示和交互的文章《關於UIWebView和PhoneGap的總結》,裏面介紹了使用UIWebView如何處理參數傳遞,同步與異步等問題,感興趣的同窗也能夠翻看。github

基於CoreText來實現和基於UIWebView來實現相比,前者有如下好處:web

  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是徹底沒法完成相應的交互和排版需求的。app

關於如何基於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

相關文章
相關標籤/搜索