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

2015-3-23 评论(30) 分类:技术文章 Tags:

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

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

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

概览

DTCoretext

整体流程如图,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解析
  2. 直接根据HTML字符串在内存生成一颗dom树,使用者可以自由遍历这颗dom树。这个方法的优点是使用简单方便,缺点一是内存使用多,无论多大的html文件都会一次性生成dom树放在内存里,二是性能不高,它生成dom树时遍历了一遍,用户使用时又遍历了一遍。

  3. SAX解析
  4. 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的父节点。

简化代码:

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

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

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

解析CSS

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

1.结构化

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

body {
    font-size:14px;
    background: #fff;
}
.hd {
    width:100px;
}

最终要转变成

@{
    @“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块解析就完成了。简化的代码:

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

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

  5. 缩写
  6. 一些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内容,其他节点只会把所有子节点的内容拼起来。简化代码:

//普通节点:
- (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。

最终成果

<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:

第六章
{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渲染了。

iOS图片加载速度极限优化—FastImageCache解析

2015-2-9 评论(24) 分类:技术文章 Tags:

FastImageCache是Path团队开发的一个开源库,用于提升图片的加载和渲染速度,让基于图片的列表滑动起来更顺畅,来看看它是怎么做的。

优化点

iOS从磁盘加载一张图片,使用UIImageVIew显示在屏幕上,需要经过以下步骤:

  1. 从磁盘拷贝数据到内核缓冲区
  2. 从内核缓冲区复制数据到用户空间
  3. 生成UIImageView,把图像数据赋值给UIImageView
  4. 如果图像数据为未解码的PNG/JPG,解码为位图数据
  5. CATransaction捕获到UIImageView layer树的变化
  6. 主线程Runloop提交CATransaction,开始进行图像渲染
    • 6.1 如果数据没有字节对齐,Core Animation会再拷贝一份数据,进行字节对齐。
    • 6.2 GPU处理位图数据,进行渲染。

FastImageCache分别优化了2,4,6.1三个步骤:

  1. 使用mmap内存映射,省去了上述第2步数据从内核空间拷贝到用户空间的操作。
  2. 缓存解码后的位图数据到磁盘,下次从磁盘读取时省去第4步解码的操作。
  3. 生成字节对齐的数据,防止上述第6.1步CoreAnimation在渲染时再拷贝一份数据。

接下来具体介绍这三个优化点以及它的实现。

内存映射

平常我们读取磁盘上的一个文件,上层API调用到最后会使用系统方法read()读取数据,内核把磁盘数据读入内核缓冲区,用户再从内核缓冲区读取数据复制到用户内存空间,这里有一次内存拷贝的时间消耗,并且读取后整个文件数据就已经存在于用户内存中,占用了进程的内存空间。

FastImageCache采用了另一种读写文件的方法,就是用mmap把文件映射到用户空间里的虚拟内存,文件中的位置在虚拟内存中有了对应的地址,可以像操作内存一样操作这个文件,相当于已经把整个文件放入内存,但在真正使用到这些数据前却不会消耗物理内存,也不会有读写磁盘的操作,只有真正使用这些数据时,也就是图像准备渲染在屏幕上时,虚拟内存管理系统VMS才根据缺页加载的机制从磁盘加载对应的数据块到物理内存,再进行渲染。这样的文件读写文件方式少了数据从内核缓存到用户空间的拷贝,效率很高。

解码图像

一般我们使用的图像是JPG/PNG,这些图像数据不是位图,而是是经过编码压缩后的数据,使用它渲染到屏幕之前需要进行解码转成位图数据,这个解码操作是比较耗时的,并且没有GPU硬解码,只能通过CPU,iOS默认会在主线程对图像进行解码。很多库都解决了图像解码的问题,不过由于解码后的图像太大,一般不会缓存到磁盘,SDWebImage的做法是把解码操作从主线程移到子线程,让耗时的解码操作不占用主线程的时间。

FastImageCache也是在子线程解码图像,不同的是它会缓存解码后的图像到磁盘。因为解码后的图像体积很大,FastImageCache对这些图像数据做了系列缓存管理,详见下文实现部分。另外缓存的图像体积大也是使用内存映射读取文件的原因,小文件使用内存映射无优势,内存拷贝的量少,拷贝后占用用户内存也不高,文件越大内存映射优势越大。

字节对齐

Core Animation在图像数据非字节对齐的情况下渲染前会先拷贝一份图像数据,官方文档没有对这次拷贝行为作说明,模拟器和Instrument里有高亮显示“copied images”的功能,但似乎它有bug,即使某张图片没有被高亮显示出渲染时被copy,从调用堆栈上也还是能看到调用了CA::Render::copy_image方法:

fastImageCache1

那什么是字节对齐呢,按我的理解,为了性能,底层渲染图像时不是一个像素一个像素渲染,而是一块一块渲染,数据是一块块地取,就可能遇到这一块连续的内存数据里结尾的数据不是图像的内容,是内存里其他的数据,可能越界读取导致一些奇怪的东西混入,所以在渲染之前CoreAnimation要把数据拷贝一份进行处理,确保每一块都是图像数据,对于不足一块的数据置空。大致图示:(pixel是图像像素数据,data是内存里其他数据)

fastImageCache2

块的大小应该是跟CPU cache line有关,ARMv7是32byte,A9是64byte,在A9下CoreAnimation应该是按64byte作为一块数据去读取和渲染,让图像数据对齐64byte就可以避免CoreAnimation再拷贝一份数据进行修补。FastImageCache做的字节对齐就是这个事情。

实现

FastImageCache把同个类型和尺寸的图像都放在一个文件里,根据文件偏移取单张图片,类似web的css雪碧图,这里称为ImageTable。这样做主要是为了方便统一管理图片缓存,控制缓存的大小,整个FastImageCache就是在管理一个个ImageTable的数据。整体实现的数据结构如图:

fastImageCache3

一些补充和说明:

ImageTable

  1. 一个ImageFormat对应一个ImageTable,ImageFormat指定了ImageTable里图像渲染格式/大小等信息,ImageTable里的图像数据都由ImageFormat规定了统一的尺寸,每张图像大小都是一样的。
  2. 一个ImageTable一个实体文件,并有另一个文件保存这个ImageTable的meta信息。
  3. 图像使用entityUUID作为唯一标示符,由用户定义,通常是图像url的hash值。ImageTable Meta的indexMap记录了entityUUID->entryIndex的映射,通过indexMap就可以用图像的entityUUID找到缓存数据在ImageTable对应的位置。

ImageTableEntry

  1. ImageTable的实体数据是ImageTableEntry,每个entry有两部分数据,一部分是对齐后的图像数据,另一部分是meta信息,meta保存这张图像的UUID和原图UUID,用于校验图像数据的正确性。
  2. Entry数据是按内存分页大小对齐的,数据大小是内存分页大小的整数倍,这样可以保证虚拟内存缺页加载时使用最少的内存页加载一张图像。
  3. 图像数据做了字节对齐处理,CoreAnimation使用时无需再处理拷贝。具体做法是CGBitmapContextCreate创建位图画布时bytesPerRow参数传64倍数。

Chunk

  • ImageTable和实体数据Entry间多了层Chunk,Chunk是逻辑上的数据划分,N个Entry作为一个Chunk,内存映射mmap操作是以chunk为单位的,每一个chunk执行一次mmap把这个chunk的内容映射到虚拟内存。为什么要多一层chunk呢,按我的理解,这样做是为了灵活控制mmap的大小和调用次数,若对整个ImageTable执行mmap,载入虚拟内存的文件过大,若对每个Entry做mmap,调用次数会太多。

缓存管理

  • 用户可以定义整个ImageTable里最大缓存的图像数量,在有新图像需要缓存时,如果缓存没有超过限制,会以chunk为单位扩展文件大小,顺序写下去。如果已超过最大缓存限制,会把最少使用的缓存替换掉,实现方法是每次使用图像都会把UUID插入到MRUEntries数组的开头,MRUEntries按最近使用顺序排列了图像UUID,数组里最后一个图像就是最少使用的。被替换掉的图片下次需要再使用时,再走一次取原图—解压—存储的流程。

使用

FastImageCache适合用于tableView里缓存每个cell上同样规格的图像,优点是能极大加快第一次从磁盘加载这些图像的速度。但它有两个明显的缺点:一是占空间大。因为缓存了解码后的位图到磁盘,位图是很大的,宽高100*100的图像在2x的高清屏设备下就需要200*200*4byte/pixel=156KB,这也是为什么FastImageCache要大费周章限制缓存大小。二是接口不友好,需预定义好缓存的图像尺寸。FastImageCache无法像SDWebImage那样无缝接入UIImageView,使用它需要配置ImageTable,定义好尺寸,手动提供的原图,每种实体图像要定义一个FICEntity模型,使逻辑变复杂。

FastImageCache已经属于极限优化,做图像加载/渲染优化时应该优先考虑一些低代价高回报的优化点,例如CALayer代替UIImageVIew,减少GPU计算(去透明/像素对齐),图像子线程解码,避免Offscreen-Render等。在其他优化都做到位,图像的渲染还是有性能问题的前提下才考虑使用FastImageCache进一步提升首次加载的性能,不过字节对齐的优化倒是可以脱离FastImageCache直接运用在项目上,只需要在解码图像时bitmap画布的bytesPerRow设为64的倍数即可。

iOS可执行文件瘦身方法

2015-1-31 评论(14) 分类:技术文章 Tags:

缩减iOS安装包大小是很多中大型APP都要做的事,一般首先会对资源文件下手,压缩图片/音频,去除不必要的资源。这些资源优化做完后,我们还可以尝试对可执行文件进行瘦身,项目越大,可执行文件占用的体积越大,又因为AppStore会对可执行文件加密,导致可执行文件的压缩率低,压缩后可执行文件占整个APP安装包的体积比例大约有80%~90%,还是挺值得优化的。下面介绍一下在研究可执行文件过程中发现的可以优化的点。研究的过程使用了linkmap,linkmap的介绍跟生成可以参考另一篇文章—iOS可执行文件的组成

编译选项

1.编译器优化级别

Build Settings->Optimization Level有几个编译优化选项,release版应该选择Fastest, Smalllest,这个选项会开启那些不增加代码大小的全部优化,并让可执行文件尽可能小。

2.去除符号信息

Strip Linked Product / Deployment Postprocessing / Symbols Hidden by Default 在release版本应该设为yes,可以去除不必要的调试符号。Symbols Hidden by Default会把所有符号都定义成”private extern”,详细信息见官方文档

这些选项目前都是XCode里release的默认选项,但旧版XCode生成的项目可能不是,可以检查一下。其他优化还可以参考官方文档—CodeFootprint.pdf

第三方库统计

项目里会引入很多第三方静态库,如果能知道这些第三方库在可执行文件里占用的大小,就可以评估是否值得去找替代方案去掉这个第三方库。我们可以从linkmap中统计出这个信息,对此写了个node.js脚本,可以通过linkmap统计每个.o目标文件占用的体积和每个.a静态库占用的体积,详见这里(需翻墙)。

ARC->MRC

有人提出用ARC写的代码编译出来的可执行文件是会比用MRC大的,原因大致是ARC代码会在某些情况多出一些retain和release的指令,例如调用一个方法,它返回的对象会被retain,退出作用域后会被release,MRC就不需要,汇编指令变多,机器码变多,可执行文件就变大了。还有其他细节实现的区别,先不纠结了。

那用ARC究竟会增大多少体积?我觉得从汇编指令的增多减少去算是很难算准确的,这东西涉及细节太多,还是得从统计的角度计算。做了几个对比试验,统计了几个同时支持ARC/MRC的开源项目在开启/关闭ARC的情况下__TEXT代码段的大小对比。只对比__TEXT代码段是因为:

  1. ARC对可执行文件大小的影响几乎都是在代码段
  2. 可执行文件会进行某种对齐,例如有些段在不足32K的时候填充0直到对齐32K,若用可执行文件大小对比结果可能是对齐后的,不准确。

实验数据:

MBProgressHUD

SDWebImage

FMDB

开启ARC

19532

24424

29056

关闭ARC

17648

22575

25848

对比

↓9.6%

↓7.5%

↓11%

结果是ARC大概会使代码段增加10%的size,考虑代码段占可执行文件大约有80%,估计对整个可执行文件的影响会是8%。

可以评估一下8%的体积下降是不是值得把项目里某些模块改成MRC,这样程序的维护成本上升了,一般不到特殊情况不建议这么做。

无用代码

在项目里新建一个类,给它添加几个方法,但不要在任何地方import它,build完项目后观察linkmap,你会发现这个类还是被编译进可执行文件了。

按C++的经验,没有被使用到的类和方法编译器都会优化掉,不会编进最终的可执行文件,但object-c不一样,因为object-c的动态特性,它可以通过类和方法名反射获得这个类和方法进行调用,所以就算在代码里某个类没被使用到,编译器也没法保证这个类不会在运行时通过反射去调用,所以只要是在项目里的文件,无论是否又被使用到都会被编译进可执行文件。

对此我们可以通过脚本,遍历整个项目的文件,找出所有没有被引用的类文件和没有被调用的方法,在保证没有其他地方动态调用的情况下把它们去掉。如果整个项目历时很长,历时代码遗留较多,这个清理对可执行文件省出的空间还是挺可观的。

类/方法名长度

观察linkmap可以发现每个类和方法名都在__cstring段里都存了相应的字符串值,所以类和方法名的长短也是对可执行文件大小是有影响的,原因还是object-c的动态特性,因为需要通过类/方法名反射找到这个类/方法进行调用,object-c对象模型会把类/方法名字符串都保存下来。

对此我们可以考虑在编译前把所有类和方法名进行混淆,跟压缩js一样,把长名字替换成短名字,这样做的好处除了缩小体积外,还对安全性有很大提升,别人拿到可执行文件对它class-dump出来的结果都是混淆后的类和方法名,就无法从类和方法名中猜出某个方法是做什么的,就难以挂钩子进行hack。不过这样做有个缺点,就是crash堆栈反解出来的堆栈方法名会是混淆后的,需要再加一层混淆->原名的转换,实现和使用成本有点高。

实际上这部分占用的长度比较小,中型项目也就几百K,对安全性要求高的情况可以试试。

冗余字符串

代码上定义的所有静态字符串都会记录在在可执行文件的__cstring段,如果项目里Log非常多,这个空间占用也是可观的,也有几百K的大小,可以考虑清理所有冗余的字符串。另外如果有特别长的字符串,建议抽离保存成静态文件,因为AppStore对可执行文件加密导致压缩率低,特别长的字符串抽离成静态资源文件后压缩率会比在可执行文件里高很多。

CheckList

最后把缩减iOS安装包大小的各种方法列出来做了张CheckList图:

安装包大小优化