iOS富文本组件的实现—DTCoreText源码解析 数据篇

本文转载 http://blog.cnbang.net/tech/2630/

DTCoreText是个开源的iOS富文本组件,它可以解析HTML与CSS最终用CoreText绘制出来,通常用于在一些需要显示富文本的场景下代替低性能的UIWebView,来看看它是怎样解析和渲染HTML+CSS的,总体上分成两步:

  1. 数据解析—把HTML+CSS转换成NSAttributeString
  2. 渲染—用CoreText把NSAttributeString内容渲染出来,再加上图片等元素

本篇先介绍第一步,数据解析的实现。

概览

整体流程如图,HTML字符串传入DTHTMLAttributeStringBuilder,通过DTHTMLParser的回调解析后生成dom树,dom树的每个节点都是自定义的DTHTMLElement,通过DTCSSStylesheet解析每个元素对应的样式,这时每个DTHTMLElement已经包含了节点的内容和样式,最后从DTHTMLElement生成NSAttributeString。这一切都是在解析dom的过程中同步进行,为了分析方便,我们还是把它分为三个步骤:

  1. 解析HTML,生成dom树
  2. 解析CSS,合并得到每个dom节点对应的样式
  3. 生成NSAttributeString

接下来详细介绍这三个步骤的实现方式。

解析HTML

iOS/OSX自带了XML/HTML的解析引擎libxml,它提供了两种解析html的接口:

    1. DOM解析

直接根据HTML字符串在内存生成一颗dom树,使用者可以自由遍历这颗dom树。这个方法的优点是使用简单方便,缺点一是内存使用多,无论多大的html文件都会一次性生成dom树放在内存里,二是性能不高,它生成dom树时遍历了一遍,用户使用时又遍历了一遍。

    1. SAX解析

SAX的解析方式不会返回一个dom树,而是把解析过程都暴露给使用者,通过回调函数告诉调用者当前解析到了什么元素/内容,让使用者决定怎么处理。举个例子,对<p>content</p>这段html进行解析时,解析器找到<p>标签就会回调startElement方法,告诉使用者找到了一个标签的开始标志,tag是p。接着解析到content,会回调_characters,告诉使用者解析到文本内容,最后解析</p>回调endElement,告诉调用者遇到标签结束标志。

这种解析方式的优点一是占用内存少,它的解析是流式的,不需要一次性传入整个内容,也不生成占内存的dom树,二是性能高,相当于使用时边解析边处理,而不是解析完生成dom树后再遍历dom树进行处理,少了一步。缺点是使用复杂。

DTCoreText采用的是SAX解析方式,主要原因应该还是为了性能考虑。DTHTMLParser把libxml这种解析方式的c接口封装成OC接口,用delegate的方式通知回调用者各个解析事件,除此之外还做了几件事:

  1. 处理文本编码
  2. 转换数据格式,把dom节点的attribute转成NSDictionary,error换成NSError,bytes换成NSData。
  3. 因为libxml一次只解析一小部分字符串,如果dom的内容特别长,libxml会分多次回调_characters,DTHTMLParser做了数据拼合的工作,确保回调给delegate的内容数据是完整的。

DTHTMLAttributeStringBuilder接收DTHTMLParser的回调,生成dom树,节点是自定义的DTHTMLElement,有指向父节点的引用以及子节点数组,生成dom树的实现逻辑很简单:

  1. 实例变量_currentTag用于保存当正在解析的节点(回调了startElement未回调endElement的节点)。
  2. startElement回调时,假设当前回调里找到的节点是elem,把elem设为_currentTag的子节点,再把_currentTag变量设为elem。
  3. 找到文本内容foundCharacters回调时,内容作为一个节点,设为_currentTag的子节点。
  4. endElement回调时,_currentTag变量设为_currentTag的父节点。

简化代码:


01

02

03

04

05

06

07

08

09

10

11

12

13

14

15

16

17

18

19

- (void)startElement:(NSString *)elemName

{

     Element *elem = [Element elementWithName:elemName];

     if (_currentTag) {

          [_currentTag.childNodes addObject:elem];

          elem.parentNode = _currentTag;

     }

     _currentTag = elem;

}

- (void)foundCharacters:(NSString *)ctn

{

     Element *elem = [[Element alloc] initWithString:ctn];

     [_currentTag.childNodes addObject:elem];

     elem.parentNode = _currentTag;

}

- (void)endElement:(NSString *)elemName

{

     _currentTag = _currentTag.parentNode;

}

这套逻辑循环下来,就生成了dom树。

其他细节

    1. 不同的DTHTMLElement子类。

在生成dom树节点时,DTHTMLElement会根据传入的标签名生成不同的子类,包括文本DTTextHTMLElement,超链接DTAnchorHTMLElement,列表DTListItemHTMLElement等,这些子类实现了各自特殊的样式和转换成NSAttributeString的逻辑,后面会提到。

    1. 特殊标签逻辑

DTHTMLAttributedStringBuilder在回调startElement和endElement里会对不同标签做一些特殊处理,例如<style>标签要解析里面的css内容,<link>标签要读取文件再解析css内容,<h1>标签要设置元素的headerLevel等。可能为了代码好看些,这些处理逻辑是放_tagStartHandlers/_tagEndHandlers这两个dictionary,key是标签名,value是处理的block,startElement/endElement时根据元素名调用相应的block。

    1. 多线程解析

为了解析的速度更快,DTHTMLAttributedStringBuilder生成了三个dispatch_queue,分别是
解析html的_dataParsingQueue,生成dom树的_treeBuildingQueue,以及组装NSAttributeString的_stringAssemblyQueue,把解析过程有序地分派到这三条线程里并行执行,并用dispatch_group_wait阻塞等到所有任务都完成时同步返回结果。

解析CSS

对样式CSS的解析大致流程是这样:css原数据->结构化NSDictionary->合并样式->DTHTMLElement属性。最终为每一个DTHTMLElement解析出这个元素的最终样式,接下来看看每一步是怎么做的。

1.结构化

css文本最终需要变成结构化的NSDictionary,便于为dom节点匹配选择器和处理,例如:


1

2

3

4

5

6

7

body {

    font-size:14px;

    background: #fff;

}

.hd {

    width:100px;

}

最终要转变成


1

2

3

4

5

6

7

@{

    @“body”: @{

        @“font-size”: @“14px”,

        @“background”: @“#fff”

    },

    @“.hd”: @{@“width”: @“100px”},

}

css数据的解析比较简单,不需要词法分析,只需要字符串匹配,分两步走:

A.css块解析,分离css选择器与内容

上面例子的css,需要先分离选择器和内容,解析成@{@”body”: @“font-size:14px;\nbackground: #fff;”, @“.hd”: @”width:100px;”},怎么做?

DTCSSStylesheet的方法是遍历每个字符,定一个标志位置braceMarker,找到’{‘,就把braceMaker到这个’{‘字符间的字符串提取出来,就是选择器,braceMaker重设为’{‘的下一个位置,继续找下一个字符,直到找到’}’,把braceMaker到这个’}’间的字符串提取出来,就是选择器对应的内容,css块解析就完成了。简化的代码:


01

02

03

04

05

06

07

08

09

10

11

12

13

14

15

16

- (void)parseStyleBlock:(NSString *)css

{

     int braceMarker = 0;

     NSString* selector;

     for (int i = 0; i < css.length; i ++) {

          if (c == ‘{‘) {

               selector = [ css substringWithRange:NSMakeRange(braceMarker, i-braceMarker)];

               braceMarker = i + 1;

          }

          if (c == ‘}‘) {

               NSString *rule = [ css substringWithRange:NSMakeRange(braceMarker, i-braceMarker)];

               [self addRule:rule withSelector:selector];

               braceMarker = i + 1;

          }

     }

}

举个例子,上述css中,解析body{}这个块,起始标志位置braceMarker=0,开始遍历字符串,找到’{’,位置idx=5,从braceMaker开始到’{‘的前一个字符就是key,于是subString(0,4),也就是’body’就是选择器,接着把braceMaker设为’{’的下一个位置,braceMaker=6,继续往下找,找到’}’,位置idx=35,于是subString(6,35-1)就是css内容。

DTCSSStylesheet的实现里考虑了注释的去除,还考虑了css内容里出现’{‘’}’字符的情况,搜索过程通过braceLevel确保第一层{}block才解析。不过DTCSSStylesheet没有考虑@import,@chartset等特殊css字段。

B.解析内容

对简单的css内容(类似font-size:14px;background: #fff;)的解析可以很简单,用;号分割,再用:号分割就行了,但这样无法应对一些异常,例如内容中间加个注释就挂了。DTCoreText的实现是用NSScaner按顺序扫关键字,先找selector,再找冒号’:’,接着处理值,具体实现在NSScanner+HTML.m

2.合并样式

影响一个dom节点css样式的内容分布在四个地方,一是全局默认样式,二是HTML里<link>标签外联的css文件,三是HTML里<style>标签里的内容,四是dom节点style属性(例<a style=“color:white”>)。

DTHTMLAttributeStringBuilder持有一个_globalStyleSheet,在初始化时就加载并解析了全局默认样式。接着在解析HTML生成dom树的过程中,如果遇到<style>标签,会对标签里的css内容进行解析,然后合并入_globalStyleSheet,<link>标签也一样,会根据文件路径读取css文件内容并解析合并,自此上述前三个点都合并在_globalStyleSheet里了。接着要解析一个dom节点的样式时,调用_globalStyleSheet的mergedStyleDictionaryForElement:方法,把节点传进来,用节点的tagName/class/id等属性在_globalStyleSheet表里匹配到相应的css样式,接着解析节点自身的style属性合并,就得到了这个节点里所有的css样式。

3.转化为DTHTMLElement属性

DTHTMLElement的applyStyleDictionary:方法会把css属性转换为DTHTMLElement自身的属性,方法就是一个个属性去处理了,十分繁琐。

影响一个dom节点样式的其实还有两个点,一是dom里某些属性(例<p align=“left”>),二是从父节点继承下来的样式。分别在DTHTMLElement的两个方法inheritAttributesFromElement:和interpretAttribute里处理了,从父节点继承下来的不是css样式,而是处理好的DTHTMLElement属性。因为解析HTML是顺序解析,在解析子节点时父节点的样式一定已经解析完成,所以可以直接从父节点继承解析好的DTHTMLElement属性。这两点都是直接操作DTHTMLElement属性,不涉及CSS。

其他细节

    1. 派生选择器

DTCSSStylesheet的选择器是支持派生选择器的,类似li a{},只匹配在<li>节点下的<a>节点,其他<a>不匹配。实现方式跟浏览器实现原理一样,拿到要匹配的DTHTMLElement节点,先把_globalStyleSheet里的所有派生选择器找出来,从右开始匹配,匹配到就遍历元素的父节点看是否匹配左边的选择器。例如li a{},先看节点是否<a>,若是,遍历节点的父节点,若找到有一个父节点是<li>,则匹配成功,否则匹配不成功,继续寻找下一个。具体实现在matchingComplexCascadingSelectorsForElement:方法里。

    1. 选择器权重

DTCSSStylesheet的选择器是有权重的,内联样式>id选择器>class选择器>派生选择器>tag选择器,权重高的会覆盖权重低的样式,若权重相等,则按书写的位置排,位置在后面的覆盖前面的。DTCSSStylesheet给每个selector定了权重值,id为100,class为10,其他为1,派生选择器的权重为各个selector类型权重的相加,在解析css时把选择器的权重和出现的顺序都保存起来,匹配时按权重和顺序值决定覆盖的规则。

    1. 缩写

一些css属性是有缩写的方式的,例如font:10px  bold;就包括了font-size和font-weight,margin:10px 0;就包括了margin的四个方向,在对一个DTHTMLElement寻找匹配属性时,会把这些这些缩写全部处理展开,方便DTHTMLElement再进一步处理。处理的实现在_uncompressShorthands:里。

自此dom树上每个节点以及它们的样式都解析完成,只差最后一步转为NSAttributeString。

生成NSAttributeString

经过上述两大步骤后,dom树上的各DTHTMLElement节点都保存了各自完整的内容和样式,每个DTHTMLElement都可以完整地转换成NSAttributeString然后进行渲染。DTHTMLElement有个attributedString方法,负责生成对应的NSAttributeString,实现方式是把所有子节点的attributedString拼起来返回,递归调用直到叶子节点。

叶子节点有多种类型,包括文本DTTextHTMLElement,超链接DTAnchorHTMLElement,列表DTListItemHTMLElement等,它们都会根据attributesForAttributedStringRepresentation方法把DTHTMLElement的属性转化为CoreText认得的样式表,应用在自身内容上,生成NSAttributeString返回。只有叶子节点才会真正生成NSAttributeString内容,其他节点只会把所有子节点的内容拼起来。简化代码:


01

02

03

04

05

06

07

08

09

10

11

12

13

14

15

16

//普通节点:

- (NSAttributeString *)attributedString

{

  NSMutableAttributedString *tmpString = [[NSMutableAttributedString alloc] init];

  for (Element *elem in self.childNodes) {

    [tmpString appendString:[elem attributedString]];

  }

  return tmpString;

}

//叶子节点(文本内容节点DTTextHTMLElement为例):

- (NSAttributeString *)attributedString

{

  NSDictionary *attributes = [self attributesForAttributedStringRepresentation];

  return [[NSAttributedString alloc] initWithString:_text attributes:attributes];

}

因为调用一个DTHTMLElement的attributedString方法就可以得到它所有子节点拼合的NSAttributeString,所以只要调用body节点的attributeString,就可以获得最终的NSAttributeString。但是很多时候使用者传进来的只是一个html片段而不是一个完整的页面,很可能没有body节点,DTHTMLAttributeStringBuilder里处理了这种情况,不直接使用body的attributedString,body节点/没有父节点的节点/父节点是body的节点都会调用一次attributedString方法生成NSAttributeString,在DTHTMLAttributeStringBuilder里拼合成最终结果。

多媒体

Coretext只能渲染文字,那多媒体元素像图片/视频等是怎样渲染的?首先在多媒体出现的地方,会在NSAttributeString里插入一个占位符,这个占位符的attribute属性里包含了多媒体对象,渲染到这个占位符时我们可以取出attribute里的多媒体对象,再通过addSubView之类的方式渲染上去。在我们渲染多媒体对象前还需要让CoreText知道这个多媒体占多大空间,让CoreText渲染文字时留出空白,实现方式是在占位符的attribute里加上kCTRunDelegateAttributeName,CoreText在渲染时会先回调attribute上这个键对应的callback,在callback里通过多媒体对象告诉CoreText需要留多大空位就行了。

其他细节

  1. display=none的节点不输出NSAttributeString。
  2. display=block的节点(例如<p><div><h1>),会在内容后再加个换行符,根据css规则,后面的元素不能跟它同行,除非是float,目前不支持float属性。
  3. 上一个元素是display=inline(例如<a><strong><span>),当前元素是display=block时,需要在内容前面添加换行。inline是不换行的,block又要要单独一行,所以需要做这个判断。
  4. dom节点上的一些属性也会加入NSAttributeString的attribute。

最终成果


01

02

03

04

05

06

07

08

09

10

11

12

13

14

15

<html>

    <style>

        .fl {

            font:15px;

        }

        .fl strong {

            color:red;

        }

    </style>

    <body>

        <h1>第六章</h1>

        <p class="fl">我将来要当一名麦田里的守望者。有那么一群孩子在一大块麦田里玩。<strong>几千几万的小孩子</strong>,附近没有一个大人,我是说—除了我。我呢。就在那混帐的悬崖边。我的职务就是在那守望。要是有哪个孩子往悬崖边来,我就把他捉住—我是说孩子们都是在狂奔,也不知道自己是在往哪儿跑。我得从什么地方出来,把他们捉住。我整天就干这样的事,我只想做个麦田里的守望者。</p>

        <p><img src="./catcher.png" /></p>

    </body>

</html>

这个html经过这三步转换,变成了以下NSAttributeString:


01

02

03

04

05

06

07

08

09

10

11

12

13

14

15

16

第六章

{CTForegroundColor = "<CGColor 0x7fcf22d17a00>...";DTHeaderLevel = 1;NSFont = "<UICTFont: 0x7fcf25050ed0> font-family: \"Times New Roman\"; font-weight: normal; font-style: normal; font-size: 12.00pt";NSParagraphStyle = "<CTParagraphStyle: 0x7fcf22d18ca0>{...}";

}

我将来要当一名麦田里的守望者。有那么一群孩子在一大块麦田里玩。

{CTForegroundColor = "<CGColor 0x7fcf22d17a00>...";NSFont = "<UICTFont: 0x7fcf25050ed0> font-family: \"Times New Roman\"; font-weight: normal; font-style: normal; font-size: 12.00pt";NSParagraphStyle = "<CTParagraphStyle: 0x7fcf2504e5f0>{...}";}

几千几万的小孩子

{CTForegroundColor = "<CGColor 0x7fcf22d17a00>...";NSFont = "<UICTFont: 0x7fcf25050ed0> font-family: \"Times New Roman\"; font-weight: normal; font-style: normal; font-size: 12.00pt";NSParagraphStyle = "<CTParagraphStyle: 0x7fcf22d2e530>{...}";}

,附近没有一个大人,我是说—除了我。我呢。就在那混帐的悬崖边。我的职务就是在那守望。要是有哪个孩子往悬崖边来,我就把他捉住—我是说孩子们都是在狂奔,也不知道自己是在往哪儿跑。我得从什么地方出来,把他们捉住。我整天就干这样的事,我只想做个麦田里的守望者。

{CTForegroundColor = "<CGColor 0x7fcf22d17a00>...";NSFont = "<UICTFont: 0x7fcf25050ed0> font-family: \"Times New Roman\"; font-weight: normal; font-style: normal; font-size: 12.00pt";NSParagraphStyle = "<CTParagraphStyle: 0x7fcf2504e5f0>{...}";}

?

{CTForegroundColor = "<CGColor 0x7fcf22d17a00>...";CTRunDelegate = "<CTRunDelegate 0x7fcf22d18400 [0x103d7cef0]>";DTAttachmentParagraphSpacing = 0;

NSAttachmentAttributeName = "<DTImageTextAttachment: 0x7fcf22d241b0>";NSFont = "<UICTFont: 0x7fcf25050ed0> font-family: \"Times New Roman\"; font-weight: normal; font-style: normal; font-size: 12.00pt";NSParagraphStyle = "<CTParagraphStyle: 0x7fcf25044470>{...}";

接下来就可以拿这个NSAttributeString用CoreText渲染了。

时间: 2024-10-11 18:27:15

iOS富文本组件的实现—DTCoreText源码解析 数据篇的相关文章

iOS富文本组件的实现—DTCoreText源码解析 渲染篇

本文转载至 http://blog.cnbang.net/tech/2729/ 上一篇介绍了DTCoreText怎样把HTML+CSS解析转换成NSAttributeString,本篇接着看看怎样把NSAttributeString渲染出来. CoreText 先简单介绍下CoreText,CoreText是iOS/OSX里的文字渲染引擎,在iOS/OSX上看到的所有文字在底层都是由CoreText去渲染. CoreText会把一行里连在一起相同属性的文字合在一起作为一个CTRun,每一行是一个

iOS Socket 整理以及CocoaAsyncSocket、SRWebSocket源码解析(一)

写在准备动手的时候: Socket通讯在iOS中也是很常见,自己最近也一直在学习Telegram这个开源项目,Telegram就是在Socket的基础上做的即时通讯,这个相信了解这个开源项目的也都知道,希望自己能慢慢的了解一下它的这个MtProtoKit开源协议,即时通讯这一块的东西我以前写过一篇<iOS 即时通讯 + 仿微信聊天框架 + 源码>,从点击量看的出来真的这一块的东西我们的需求量还是很大,<iOS 即时通讯 + 仿微信聊天框架 + 源码>这篇文章由于自己去年也是能力有限

iOS 富文本组件

iOS6 之后,UILabel加入了attributedText属性,为UILabel的文本显示添加了巨大的变化, attributedText使用coreText绘制,支持文本域字体.颜色等设置. 在iOS6 之前,UILabel 只能按照font,textColor属性设置的字体和颜色显示文本,不能对文本中一段文本进行单独的字体和颜色的设置,也不支持url识别,下划线添加,字体加粗等功能.在实际开发中对富文本组件的需求是非常高的,所以出现了很多第三方的富文本组件. OHAttributedL

图解DevExpress RichEditControl富文本的使用,附源码及官方API

9点半了,刚写到1.2.   该回家了,明天继续写完. 大家还需要什么操作,留言说一下,没有的我明天继续加. 好久没有玩DevExpress了,今天下载了一个玩玩,发现竟然更新到14.2.5了..我去. 本章主题: 创建项目,窗体搭建 给RichEditControl添加工具条 创建自定义的工具条按钮项 图片解析操作 修正文档字体 内容搜索功能 导入导出功能(文本/HTML/Word) 官方相关API 废话不多说,进入正题. 一.创建项目,窗体搭建 1.新建C#.NET WinForms应用程序

element ui源码解析 -- Pagination篇( 副:列表页进入详情页再返回后,如何保持当前页码正确 )

之所以看Pagination源码,由于一个问题: 需求:从列表页进入详情页后再返回,需求方希望保持在当前的页码而不是重新定位到第一页 问题:按照element ui文档设置current-page后请求的数据正常,但pagination的页码显示却不正确,不管current-page值等于多少,永远显示第一页 解决方案: 1. 改 :current-page="currentPage" 为 :current-page.sync="currentPage" 2.查询接

jQuery2.x源码解析(缓存篇)

缓存是jQuery中的又一核心设计,jQuery自身的很多东西都依赖于缓存,比如事件.一些中间变量.动画等.同时他还为用户提供接口了使用缓存的接口,方便用户在元素节点上保存自己的数据,并且帮助用户解决直接把数据保存到DOM元素是可能引起的内存泄漏.命名冲突等问题. 同时,html5提出了一种通过属性缓存元素数据的功能,就是data-*属性,他可以以字符串的形式保存数据,并且不会和元素固有属性冲突.jQuery的缓存提供了访问data-*的接口,与html5标准结合更加紧密,更加规范. 提问:jQ

iOS开发- 以图搜图功能实现 (源码+解析)

以图搜图这个功能相当实用, 之前在实现这个功能的时候, 有一些笔记, 今天就整合成博文, 分享给大家. 这个demo主要实现的功能包括: 自定义拍照界面 图像识别 以图搜图 信息获取(通过识别出的图像, 获取对应信息) 下面是一个简单的演示,  如下:       那么如何实现这样的功能呢? 如果自己去完成图像识别, 显然不现实. 最早我研究的是谷歌API, 不过谷歌在天朝, 大家都懂得... 然后是百度了,尝试了下,  效果还不错. 另外, 百度也有自己"以图搜图"对应的App.不过

Pytorch1.3源码解析-第一篇

pytorch$ tree -L 1 . ├── android ├── aten ├── benchmarks ├── binaries ├── c10 ├── caffe2 ├── CITATION ├── cmake ├── CMakeLists.txt ├── CODEOWNERS ├── CONTRIBUTING.md ├── docker ├── docs ├── ios ├── LICENSE ├── Makefile ├── modules ├── mypy-files.txt

Apktool源码解析——第一篇

著名的apktool是android逆向界用的最普遍的一个工具,这个项目的原始地址在这里http://code.google.com/p/android-apktool/,但是你们都懂的在天朝谷歌是无法访问的,所以直接上github的 https://github.com/brutall/brut.apktool. 在brut.apktool路径是主要代码所在,当然还有brut.apktool.smali是反编译smali的目录,目测还有brut.j.common,brut.j.dir,brut