JSPatch 部署安全策略

2015-8-31 评论(26) 分类:技术文章

使用 JSPatch 有两个安全问题:

  1. 传输安全:JS 脚本可以调用任意 OC 方法,权限非常大,若被中间人攻击替换代码,会造成较大的危害。
  2. 执行安全:下发的 JS 脚本灵活度大,相当于一次小型更新,若未进行充分测试,可能会出现 crash 等情况对 APP 稳定性造成影响。

接下来说下这两个问题的解决方案。

传输安全

方案一:对称加密

若要让 JS 代码传输过程中不轻易被中间人截获替换,很容易想到的方式就是对代码进行加密,可以用 zip 的加密压缩,也可以用 AES 等加密算法。这个方案的优点是非常简单,缺点是安全性低,容易被破解。因为密钥是要保存在客户端的,只要客户端被人拿去反编译,把密码字段找出来,就完成破解了。

对此也有一些改进方案,例如:

1.可以把密码保存到 keychain 上,但这种方式也是不可靠的,只要随便找一台机器越狱装了这个 APP,用 hook 的方式在 APP 上添加一些代码,获得 keychain 里的密钥值,就可以用于其他所有机器的传输解密了。

2.给每个用户下发不同的密钥。但这样就非常繁琐,需要对下发密钥的请求做好保护,后台需要每次都对脚本进行不同密钥的加密操作,复杂性高了。

综上,对称加密安全性低,若要稍微提高点安全性,就会提升程序复杂度。 (更多…)

JSPatch实现原理详解<二>

2015-7-6 评论(15) 分类:技术文章 Tags:

注:本文较早撰写,随着 JSPatch 的改进,有些内容已与最新代码对不上,建议转看重新整理后的JSPatch实现原理详解


距离上次写的<JSPatch实现原理详解>有一个月的时间,在这段时间里 JSPatch 在不断地完善和改进,代码已经有很多变化,有一些修改值得写一下,作为上一篇的补充。

Special Struct

先说下 _objc_msgForward,在上一篇提到为了让替换的方法走 forwardInvocation,把它指向一个不存在的 IMP: class_getMethodImplementation(cls, @selector(__JPNONImplementSelector)),实际上这样实现是多余的,若 class_getMethodImplementation 找不到 class / selector 对应的 IMP,会返回 _objc_msgForward 这个 IMP,所以更直接的方式是把要替换的方法都指向 _objc_msgForward,省去查找方法的时间。

接着出现另一个问题,如果替换方法的返回值是某些 struct,使用 _objc_msgForward(或者之前的 @selector(__JPNONImplementSelector))会 crash。几经辗转,找到了解决方法:对于某些架构某些 struct,必须使用 _objc_msgForward_stret 代替 _objc_msgForward。为什么要用 _objc_msgForward_stret 呢,找到一篇说明 objc_msgSend_stretobjc_msgSend 区别的文章),说得比较清楚,原理是一样的,是C的一些底层机制的原因,简单复述一下:

大多数CPU在执行C函数时会把前几个参数放进寄存器里,对 obj_msgSend 来说前两个参数固定是 self / _cmd,它们会放在寄存器上,在最后执行完后返回值也会保存在寄存器上,取这个寄存器的值就是返回值:

-(int) method:(id)arg;
    r3 = self
    r4 = _cmd, @selector(method:)
    r5 = arg
    (on exit) r3 = returned int

普通的返回值(int/pointer)很小,放在寄存器上没问题,但有些 struct 是很大的,寄存器放不下,所以要用另一种方式,在一开始申请一段内存,把指针保存在寄存器上,返回值往这个指针指向的内存写数据,所以寄存器要腾出一个位置放这个指针,self / _cmd 在寄存器的位置就变了:

-(struct st) method:(id)arg;
    r3 = &amp;struct_var (in caller&#39;s stack frame)
    r4 = self
    r5 = _cmd, @selector(method:)
    r6 = arg
    (on exit) return value written into struct_var

objc_msgSend 不知道 self / _cmd 的位置变了,所以要用另一个方法 objc_msgSend_stret 代替。原理大概就是这样。

上面说某些架构某些 struct 有问题,那具体是哪些呢?iOS 架构中非 arm64 的都有这问题,而怎样的 struct 需要走上述流程用 xxx_stret 代替原方法则没有明确的规则,OC 也没有提供接口,只有在一个奇葩的接口上透露了这个天机,于是有这样一个神奇的判断:

if ([methodSignature.debugDescription rangeOfString:@&quot;is special struct return? YES&quot;].location != NSNotFound)

NSMethodSignaturedebugDescription 上打出了是否 special struct,只能通过这字符串判断。所以最终的处理是,在非 arm64 下,是 special struct 就走 _objc_msgForward_stret,否则走 _objc_msgForward

(更多…)

JSPatch实现原理详解

2015-6-2 评论(50) 分类:技术文章

注:本文较早撰写,随着 JSPatch 的改进,有些内容已与最新代码对不上,建议转看重新整理后的JSPatch实现原理详解


JSPatch以小巧的体积做到了让JS调用/替换任意OC方法,让iOS APP具备热更新的能力,在实现 JSPatch 过程中遇到过很多困难也踩过很多坑,有些还是挺值得分享的。本篇文章从基础原理、方法调用和方法替换三块内容介绍整个 JSPatch 的实现原理,并把实现过程中的想法和碰到的坑也尽可能记录下来。

基础原理

能做到通过JS调用和改写OC方法最根本的原因是 Objective-C 是动态语言,OC上所有方法的调用/类的生成都通过 Objective-C Runtime 在运行时进行,我们可以通过类名/方法名反射得到相应的类和方法:

Class class = NSClassFromString("UIViewController");
id viewController = [[class alloc] init];
SEL selector = NSSelectorFromString("viewDidLoad");
[viewController performSelector:selector];

也可以替换某个类的方法为新的实现:

static void newViewDidLoad(id slf, SEL sel) {}
class_replaceMethod(class, selector, newViewDidLoad, @"");

还可以新注册一个类,为类添加方法:

Class cls = objc_allocateClassPair(superCls, "JPObject", 0);
objc_registerClassPair(cls);
class_addMethod(cls, selector, implement, typedesc);

对于 Objective-C 对象模型和动态消息发送的原理已有很多文章阐述得很详细,例如这篇,这里就不详细阐述了。理论上你可以在运行时通过类名/方法名调用到任何OC方法,替换任何类的实现以及新增任意类。所以 JSPatch 的原理就是:JS传递字符串给OC,OC通过 Runtime 接口调用和替换OC方法。这是最基础的原理,实际实现过程还有很多怪要打,接下来看看具体是怎样实现的。
(更多…)

JSPatch – 动态更新iOS APP

2015-5-25 评论(51) 分类:作品 技术文章 Tags:

JSPatch是最近业余做的项目,只需在项目中引入极小的引擎,就可以使用JavaScript调用任何Objective-C的原生接口,获得脚本语言的能力:动态更新APP,替换项目原生代码修复bug。

用途

是否有过这样的经历:新版本上线后发现有个严重的bug,可能会导致crash率激增,可能会使网络请求无法发出,这时能做的只是赶紧修复bug然后提交等待漫长的AppStore审核,再盼望用户快点升级,付出巨大的人力和时间成本,才能完成此次bug的修复。

使用JSPatch可以解决这样的问题,只需在项目中引入JSPatch,就可以在发现bug时下发JS脚本补丁,替换原生方法,无需更新APP即时修复bug。

例子

@implementation JPTableViewController
...
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath
{
  NSString *content = self.dataSource[[indexPath row]];  //可能会超出数组范围导致crash
  JPViewController *ctrl = [[JPViewController alloc] initWithContent:content];
  [self.navigationController pushViewController:ctrl];
}
...
@end

上述代码中取数组元素处可能会超出数组范围导致crash。如果在项目里引用了JSPatch,就可以下发JS脚本修复这个bug:

#import “JPEngine.m"
@implementation AppDelegate
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
    [JPEngine startEngine];
    [NSURLConnection sendAsynchronousRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:@"http://cnbang.net/bugfix.JS"]] queue:[NSOperationQueue mainQueue] completionHandler:^(NSURLResponse *response, NSData *data, NSError *connectionError) {
    NSString *script = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
    if (script) {
      [JPEngine evaluateScript:script];
    }
}];
   ….
    return YES;
}
@end

//JS
defineClass("JPTableViewController", {
  //instance method definitions
  tableView_didSelectRowAtIndexPath: function(tableView, indexPath) {
    var row = indexPath.row()
    if (self.dataSource().length > row) {  //加上判断越界的逻辑
      var content = self.dataArr()[row];
      var ctrl = JPViewController.alloc().initWithContent(content);
      self.navigationController().pushViewController(ctrl);
    }
  }
}, {})

这样 JPTableViewController 里的 -tableView:didSelectRowAtIndexPath: 就替换成了这个JS脚本里的实现,在用户无感知的情况下修复了这个bug。

更多的使用文档和demo请参考github项目主页

原理

JSPatch用iOS内置的JavaScriptCore.framework作为JS引擎,但没有用它JSExport的特性进行JS-OC函数互调,而是通过Objective-C Runtime,从JS传递要调用的类名函数名到Objective-C,再使用NSInvocation动态调用对应的OC方法。详细的实现原理以及实现过程中遇到的各种坑和hack方法会另有文章介绍。

方案对比

目前已经有一些方案可以实现动态打补丁,例如WaxPatch,可以用Lua调用OC方法,相对于WaxPatch,JSPatch的优势是:

1.JS语言
JS比Lua在应用开发领域有更广泛的应用,目前前端开发和终端开发有融合的趋势,作为扩展的脚本语言,JS是不二之选。

2.符合Apple规则
JSPatch更符合Apple的规则。iOS Developer Program License Agreement里3.3.2提到不可动态下发可执行代码,但通过苹果JavaScriptCore.framework或WebKit执行的代码除外,JS正是通过JavaScriptCore.framework执行的。

3.小巧
使用系统内置的JavaScriptCore.framework,无需内嵌脚本引擎,体积小巧。

4.支持block
wax在几年前就停止了开发和维护,不支持Objective-C里block跟Lua程序的互传,虽然一些第三方已经实现block,但使用时参数上也有比较多的限制。

相对于WaxPatch,JSPatch劣势在于不支持iOS6,因为需要引入JavaScriptCore.framework。另外目前内存的使用上会高于wax,持续改进中。

风险

JSPatch让脚本语言获得调用所有原生OC方法的能力,不像web前端把能力局限在浏览器,使用上会有一些安全风险:

1.若在网络传输过程中下发明文JS,可能会被中间人篡改JS脚本,执行任意方法,盗取APP里的相关信息。可以对传输过程进行加密,或用直接使用https解决。

2.若下载完后的JS保存在本地没有加密,在未越狱的机器上用户也可以手动替换或篡改脚本。这点危害没有第一点大,因为操作者是手机拥有者,不存在APP内相关信息被盗用的风险。若要避免用户修改代码影响APP运行,可以选择简单的加密存储。

其他用途

JSPatch可以动态打补丁,自由修改APP里的代码,理论上还可以完全用JSPatch实现一个业务模块,甚至整个APP,跟wax一样,但不推荐这么做,因为:

  1. JSPatch和wax一样都是通过Objective-C Runtime的接口通过字符串反射找到对应的类和方法进行调用,这中间的字符串处理会损耗一定的性能,另外两种语言间的类型转换也有性能损耗,若用来做一个完整的业务模块,大量的频繁来回互调,可能有性能问题。
  2. 开发过程中需要用OC的思维写JS/Lua,丧失了脚本语言自己的特性。
  3. JSPatch和wax都没有IDE支持,开发效率低。

若想动态为APP添加模块,目前React Native给出了很好的方案,解决了上述三个问题:

  1. JS/OC不会频繁通信,会在事件触发时批量传递,提高效率。(详见React Native通信机制详解
  2. 开发过程无需考虑OC的感受,遵从React框架的思想进行纯JS开发就行,剩下的事情React Native帮你处理好了。
  3. React Native连IDE都准备好了。

所以动态添加业务模块目前还是推荐尝试React Native,但React Native并不会提供原生OC接口的反射调用和方法替换,无法做到修改原生代码,JSPatch以小巧的引擎补足这个缺口,配合React Native用统一的JS语言让一个原生APP时刻处于可扩展可修改的状态。

目前JSPatch处于开发阶段,稳定性和功能还存在一些问题,欢迎大家提建议/bug/PR,一起来做这个项目。

(更多…)

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

2015-4-14 评论(13) 分类:技术文章 Tags:

上一篇介绍了DTCoreText怎样把HTML+CSS解析转换成NSAttributeString,本篇接着看看怎样把NSAttributeString渲染出来。

CoreText

先简单介绍下CoreText,CoreText是iOS/OSX里的文字渲染引擎,在iOS/OSX上看到的所有文字在底层都是由CoreText去渲染。

dt1

CoreText会把一行里连在一起相同属性的文字合在一起作为一个CTRun,每一行是一个CTLine,多行合在一起组成CTFrame。如上图,第一行的文字有两种样式,第一部分是加粗,第二部分是斜体,因为样式不同所以分成了两个CTRun,CTLine包含了这两个CTRun,CTFrame包含了所有CTLine。

dt2

一个NSAttributeString可以通过CoreText提供的方法生成CTFramesetter,CTFramesetter是用于创建CTFrame的工厂,给CTFramesetter一个CGPath,或者简单理解为给他一个框框,它就会通过它持有的CTTypesetter生成CTFrame,CTFrame生成时里面包含的CTLine和CTRun就全部生成好了,可以直接绘制到画布上。CTFrame/CTLine/CTRun都提供了渲染接口,但前两者是封装,最后实际都是调用到CTRun的渲染接口去绘制。

如果要用CoreText渲染NSAttributeString,可以简单生成CTFramesetter,再生成CTFrame,在UIView的drawRect方法里直接把CTFrame绘制到当前画布上:

- (void) drawRect:(CGRect)rect
{
     UIBezierPath *path = [UIBezierPath bezierPathWithRect:CGRectMake(0, 0, 320, 400)];
     CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((__bridge CFAttributedStringRef)content);
     CTFrame frame = CTFramesetterCreateFrame(framesetter,CFRangeMake(0, 0), [path CGPath] , NULL);
     CGContextRef ctx = UIGraphicsGetCurrentContext();
     CTFrameDraw(frame, ctx);
}

CoreText会按NSAttributeString里的样式属性把文字渲染出来。这种是最简单的粗粒度的渲染方式,但如果需要对文字渲染再做进一步处理,例如添加背景色等这些CoreText没有支持的属性,或者要在文字中间插入图片,就不能简单绘制CTFrame,需要逐行或逐个CTRun处理。

概览

DTCoreText需要处理穿插在文字里的各类Attachment,并支持文字背景色,段缩进等CoreText不支持的属性,不能简单把NSAttributeString扔给CoreText渲染了事,需要做更细致的处理。DTCoreText分了几层,整体结构图:

dt7

最上层是使用者,可以是Controller,例如项目里示例的DemoTextViewController,也可以是某视图类。接着是DTCoreText封装好的各个控件,自带的有Label,TextView和Cell,这些控件的文字渲染都由DTAttributedTextContentView负责,非文字部分例如图片/视频等元素会在上层使用者那里通过delegate传给DTAttributedTextContentView。DTCoreTextLayouter / DTCoreTextLayoutFrame / DTCoreTextLayoutLine / DTCoreTextGlyphRun这四个类分别对应CoreText里的CTFramesetter / CTFrame / CTLine / CTRun,模仿了CoreText的模式,功能和作用一样,只是在它们基础上添加了功能。接下来看看每一个类具体做了什么事情。

DTAttributedTextContentView

DTAttributedTextContentView继承自UIView,作为DTCoreTextLayoutFrame和上层控件的中间层,负责按需求绘制内容,大致做了以下几件事:

1.支持CATiledLayer分段渲染

把UIView的layerClass设为CATiledLayer就能实现分区域渲染,即只渲染显示在屏幕上的区域,类似那些地图APP的效果,主要用于像TextView这样可能内容很长的控件,避免一次性把全部内容渲染出来,只渲染能看到的部分,提高性能。使用CATiledLayer后,在-drawLayer:inContext:方法里用CGContextGetClipBoundingBox通过context取得当前显示的区域,DTCoreTextLayoutFrame只渲染这个区域的内容就行了。

2.生成DTCoreTextLayoutFrame并绘制

通过上层传进来的NSAttributeString生成DTCoreTextLayouter和DTCoreTextLayoutFrame,进行各种配置后用DTCoreTextLayoutFrame渲染文字到当前layer上,这些配置包括 是否显示图片链接/限定行数/断行规则等。

3.处理Attachment和Link

-layoutSubviewsInRect:方法里遍历DTCoreTextLayoutFrame里的每一个DTCoreTextGlyphRun,找出有附件和链接的Run进行处理,附件包括图片/视频等,创建这些附件对应的view,把这些view按DTCoreTextGlyphRun计算好的位置添加到专门存放附件和链接的customViews上完事。

实际上这些附件view的创建是在上层使用者那里,DTAttributedTextContentView通过delegate把每个附件的内容和对应的frame传到上层生成相应的view再给回来,这样做估计是因为对附件的处理每个使用者的需求都不一样,不应该直接写死在底层,例如有些使用者要求图片需要点击后放大,视频需要用自己的控件等。

DTCoreTextLayouter

DTCoreTextLayouter负责生成和缓存DTCoreTextLayoutFrame,相当于CTFramesetter和CTFrame的关系,做的事很简单,就是通过NSAttributeString生成CTFramesetter,再根据不同的rect生成DTCoreTextLayoutFrame,并缓存这些frame。

DTCoreTextLayoutFrame

DTCoreTextLayoutFrame是最重要的一个类,负责渲染文字,主要做了两件事:生成行和渲染每一行。

生成DTCoreTextLayoutLine

-_buildLinesWithTypesetter:会创建出当前frame范围内可见的每一行DTCoreTextLayoutLine,创建过程中做的处理包括:

1.支持整段缩进

从NSAttributeString里取出当前行是否有表示缩进的DTTextBlock,如果需要缩进,要计算出当前行缩进后的宽度和位置。

2.支持截断加省略号

上层像Label/TextView这样的控件是限制了宽高的,如果内容超出了宽高,就需要对最后一行进行处理,在合适的位置加”…”。

这里有个问题,就是必须在渲染到超出宽高的那一行时,才知道要处理的最后一行是什么。例如一个TextView高40,文字每行高15,在渲染第三行时高已经到45,发现已经超出了TextView的高度,这时知道只能渲染到第二行,但当前已经处理到第三行了,需要把第二行拿出来截断加”…”。

另外除了超出高度,在超出外部传进来的numberOfLines时也要截断,为了统一流程,这里的做法是在渲染超出高度时记录总共可以渲染多少行(_numberLinesFitInFrame),然后全部重新来,从头到尾再生成每一行,这时已经知道总共有多少行,在生成最后一行时处理就行了。这样做优点是简单粗暴避免重复代码,缺点是浪费性能,前面所有行都要重新排一遍。

3.支持hyphen

hyphen是连字符号,就是让英文单词在合适的位置换行并加上破折号”-“。CoreText原生不支持hyphen,断行方式只有按单词断行和安字母断行。这里hyphen的实现方式是:在所有英文单词里可以加破折号的位置全部加上占位符0x00AD,例如location->lo-ca-tion->lo0x00ADca0x00ADtion。0x00AD是不可见字符,CoreText不会渲染这个字符,但在这个字符的位置是可以断行的,CoreText不再认为location是一个单词,会在占位符处换行。DTCoreText做的处理就是如果发现换行处是占位符0x00AD,就替换成破折号”-“,所以要支持hyphen,传进来的内容就必须是所有单词都写好占位符的,否则无效。

4.计算每一行在当前frame的位置

在生成每一行时是不知道这一行在当前frame的位置的,需要自己手动计算。每一行的x坐标容易确定,但y坐标的计算就要费一番功夫。要考虑的因素有当前行高,上一行位置,行距,段间距,padding,baseline等。

dt5

如图,每一行以baseline为基准,需要计算出这一行的baseline在当前frame的Y坐标值,asent与descent是CoreText给出的值,asent+descent就是行高。推算当前行baseline位置的流程是:

  • A.计算上一行的行末位置,即baseline+descent
  • B.计算上一行行间距的一半,例如1.5倍行间距,就是 ((1.5 – 1)*asent+descent)/2
  • C.计算当前行行间距的一半,算法同上,只是这一行的行间距不一定与上一行一致。这里两行各算一半也是为了不同行间距的中和。
  • D.上述计算结果相加,再加上当前行asent值,就得到当前行的baseline Y坐标值。

除了上述主流程,还针对首行,段首段尾,DTTextBlock的留白和附件Attachment做了处理,计算的逻辑在-_algorithmWebKit_BaselineOriginToPositionLine

5.处理对齐

要对每一种对齐方式进行处理,右对齐和居中对齐需要计算出行的x坐标值,两端对齐需要通过CTLineCreateJustifiedLine方法重新创建出一个两端对齐的行,针对两端对齐这里还要了两件事,一是段末不做两端对齐,二是若内容长度不够(默认是不足行宽的60%)也不做两端对齐,避免文字间距拉伸得太厉害效果差。

6.封装成DTCoreTextLayoutLine

经过上述处理,每一行的CTLine对象以及这一行的位置信息都有了,把这些封装成DTCoreTextLayoutLine保存起来,任务就完成了。

渲染

DTCoreTextLayoutFrame对外提供了-drawInContext:options:方法,用于把上述生成的每一行都渲染到传进来的context画布上。做的处理包括:

1.绘制DTTextBlock样式

DTCoreText支持段落加背景色,在这里会先找出所有DTTextBlock,通过一系列麻烦的方法取到这些block的坐标和大小,把它们对应的背景色画出来。

2.绘制附件

实现了DTTextAttachmentDrawing接口的附件可以在这里跟文字一起绘制出来,在DTCoreText里图片附件就是实现了DTTextAttachmentDrawing接口,可以直接把图片在这里绘制出来。实际上图片附件的渲染DTCoreText提供了两种方式,上面介绍DTAttributedTextContentView时说图片附件也可以在上层让用户自行添加,若要在上层自行添加,可以传参数告诉DTCoreTextLayoutFrame绘制时不要处理图片附件。

3.绘制文字和阴影

最后就是再遍历每一行DTCoreTextLayoutLine以及行里的每一个DTCoreTextGlyphRun,调用它的-drawInContext:方法逐个run绘制到画布上。绘制时需要算好每个Run的位置,调用CGContextSetTextPosition定位到指定位置绘制文字。绘制文字同时还处理了阴影效果,CoreText不直接支持文字阴影效果,但可以用CoreGraphic的接口在绘制时加上阴影,这里还支持同时存在多个shadow -_-!

DTCoreTextLayoutLine

DTCoreTextLayoutLine封装了CTLine,做的事包括:

1.生成GlyphRun

通过CTLine可以取出所这一行里的CTRun,计算每个CTRun的位置,封装生成DTCoreTextGlyphRun。

2.计算属性和提供辅助方法

计算并保存了这一行asent/descent/lineHeight等属性,提供各种辅助方法方便获取这一行里的信息,包括通过stringIndex获取对应文字的坐标等,CTLine相关的几个方法例如CTLineGetOffsetForStringIndex() / CTLineGetStringIndexForPosition()也有相应的封装。

DTCoreTextGlyphRun

DTCoreTextGlyphRun里做的事跟DTCoreTextLayoutLine差不多,只是在渲染方法里额外做了一些事,首先支持文字背景色,这是CoreText原生不支持的,如果Attribute里有背景色的属性,这里会绘制出来。然后支持iOS6以下文字的下划线和删除线,iOS6以前CoreText是不支持下划线和删除线的,这里自己做了处理把它画上去。

总结

整个流程最核心的就是DTCoreTextLayoutFrame生成行和渲染的实现,相当于把CoreText原生的CTFramesetterCreateFrame / CTFrameDraw再自己实现了一遍,在实现的过程加上自己特殊的需求,从中我们也可以大致了解到CTFrame/CTLine内部大致实现是怎样的。CoreText已经提供了足够细粒度的接口让使用者可以按自己意愿去随意排版,DTCoreText这一系列的处理给出了很好的示例可供参考。

React Native通信机制详解

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

React Native是facebook刚开源的框架,可以用javascript直接开发原生APP,先不说这个框架后续是否能得到大众认可,单从源码来说,这个框架源码里有非常多的设计思想和实现方式值得学习,本篇先来看看它最基础的JavaScript-ObjectC通信机制(以下简称JS/OC)。

概览

React Native用iOS自带的JavaScriptCore作为JS的解析引擎,但并没有用到JavaScriptCore提供的一些可以让JS与OC互调的特性,而是自己实现了一套机制,这套机制可以通用于所有JS引擎上,在没有JavaScriptCore的情况下也可以用webview代替,实际上项目里就已经有了用webview作为解析引擎的实现,应该是用于兼容iOS7以下没有JavascriptCore的版本。

普通的JS-OC通信实际上很简单,OC向JS传信息有现成的接口,像webview提供的-stringByEvaluatingJavaScriptFromString方法可以直接在当前context上执行一段JS脚本,并且可以获取执行后的返回值,这个返回值就相当于JS向OC传递信息。React Native也是以此为基础,通过各种手段,实现了在OC定义一个模块方法,JS可以直接调用这个模块方法并还可以无缝衔接回调。

举个例子,OC定义了一个模块RCTSQLManager,里面有个方法-query:successCallback:,JS可以直接调用RCTSQLManager.query并通过回调获取执行结果。:

//OC:
@implement RCTSQLManager
- (void)query:(NSString *)queryData successCallback:(RCTResponseSenderBlOCk)responseSender
{
     RCT_EXPORT();
     NSString *ret = @"ret"
     responseSender(ret);
}
@end
//JS:
RCTSQLManager.query("SELECT * FROM table", function(result) {
     //result == "ret";
});

接下来看看它是怎样实现的。

模块配置表

首先OC要告诉JS它有什么模块,模块里有什么方法,JS才知道有这些方法后才有可能去调用这些方法。这里的实现是OC生成一份模块配置表传给JS,配置表里包括了所有模块和模块里方法的信息。例:

{
    "remoteModuleConfig": {
        "RCTSQLManager": {
            "methods": {
                "query": {
                    "type": "remote",
                    "methodID": 0
                }
            },
            "moduleID": 4
        },
        ...
     },
}

OC端和JS端分别各有一个bridge,两个bridge都保存了同样一份模块配置表,JS调用OC模块方法时,通过bridge里的配置表把模块方法转为模块ID和方法ID传给OC,OC通过bridge的模块配置表找到对应的方法执行之,以上述代码为例,流程大概是这样(先不考虑callback):

ReactNative1

在了解这个调用流程之前,我们先来看看OC的模块配置表式怎么来的。我们在新建一个OC模块时,JS和OC都不需要为新的模块手动去某个地方添加一些配置,模块配置表是自动生成的,只要项目里有一个模块,就会把这个模块加到配置表上,那这个模块配置表是怎样自动生成的呢?分两个步骤:

1.取所有模块类

每个模块类都实现了RCTBridgeModule接口,可以通过runtime接口objc_getClassList或objc_copyClassList取出项目里所有类,然后逐个判断是否实现了RCTBridgeModule接口,就可以找到所有模块类,实现在RCTBridgeModuleClassesByModuleID()方法里。

2.取模块里暴露给JS的方法

一个模块里可以有很多方法,一些是可以暴露给JS直接调用的,一些是私有的不想暴露给JS,怎样做到提取这些暴露的方法呢?我能想到的方法是对要暴露的方法名制定一些规则,比如用RCTExport_作为前缀,然后用runtime方法class_getInstanceMethod取出所有方法名字,提取以RCTExport_为前缀的方法,但这样做恶心的地方是每个方法必须加前缀。React Native用了另一种黑魔法似的方法解决这个问题:编译属性__attribute__。

在上述例子中我们看到模块方法里有句代码:RCT_EXPORT(),模块里的方法加上这个宏就可以实现暴露给JS,无需其他规则,那这个宏做了什么呢?来看看它的定义:

#define RCT_EXPORT(JS_name) __attribute__((used, section("__DATA,RCTExport" \
))) static const char *__rct_export_entry__[] = { __func__, #JS_name }

这个宏的作用是用编译属性__attribute__给二进制文件新建一个section,属于__DATA数据段,名字为RCTExport,并在这个段里加入当前方法名。编译器在编译时会找到__attribute__进行处理,为生成的可执行文件加入相应的内容。效果可以从linkmap看出来:

# Sections:
# Address Size Segment Section
0x100001670 0x000C0180 __TEXT __text
...
0x10011EFA0 0x00000330 __DATA RCTExport
0x10011F2D0 0x00000010 __DATA __common
0x10011F2E0 0x000003B8 __DATA __bss
...

0x10011EFA0 0x00000010 [ 4] -[RCTStatusBarManager setStyle:animated:].__rct_export_entry__
0x10011EFB0 0x00000010 [ 4] -[RCTStatusBarManager setHidden:withAnimation:].__rct_export_entry__
0x10011EFC0 0x00000010 [ 5] -[RCTSourceCode getScriptText:failureCallback:].__rct_export_entry__
0x10011EFD0 0x00000010 [ 7] -[RCTAlertManager alertWithArgs:callback:].__rct_export_entry__
...

可以看到可执行文件数据段多了个RCTExport段,内容就是各个要暴露给JS的方法。这些内容是可以在运行时获取到的,在RCTBridge.m的RCTExportedMethodsByModuleID()方法里获取这些内容,提取每个方法的类名和方法名,就完成了提取模块里暴露给JS方法的工作。

整体的模块类/方法提取实现在RCTRemoteModulesConfig()方法里。

调用流程

接下来看看JS调用OC模块方法的详细流程,包括callback回调。这时需要细化一下上述的调用流程图:

ReactNative2

看起来有点复杂,不过一步步说明,应该很容易弄清楚整个流程,图中每个流程都标了序号,从发起调用到执行回调总共有11个步骤,详细说明下这些步骤:

1.JS端调用某个OC模块暴露出来的方法。

2.把上一步的调用分解为ModuleName,MethodName,arguments,再扔给MessageQueue处理。

在初始化时模块配置表上的每一个模块都生成了对应的remoteModule对象,对象里也生成了跟模块配置表里一一对应的方法,这些方法里可以拿到自身的模块名,方法名,并对callback进行一些处理,再移交给MessageQueue。具体实现在BatchedBridgeFactory.js的_createBridgedModule里,整个实现区区24行代码,感受下JS的魔力吧。

3.在这一步把JS的callback函数缓存在MessageQueue的一个成员变量里,用CallbackID代表callback。在通过保存在MessageQueue的模块配置表把上一步传进来的ModuleName和MethodName转为ModuleID和MethodID。

4.把上述步骤得到的ModuleID,MethodId,CallbackID和其他参数argus传给OC。至于具体是怎么传的,后面再说。

5.OC接收到消息,通过模块配置表拿到对应的模块和方法。

实际上模块配置表已经经过处理了,跟JS一样,在初始化时OC也对模块配置表上的每一个模块生成了对应的实例并缓存起来,模块上的每一个方法也都生成了对应的RCTModuleMethod对象,这里通过ModuleID和MethodID取到对应的Module实例和RCTModuleMethod实例进行调用。具体实现在_handleRequestNumber:moduleID:methodID:params:。

6.RCTModuleMethod对JS传过来的每一个参数进行处理。

RCTModuleMethod可以拿到OC要调用的目标方法的每个参数类型,处理JS类型到目标类型的转换,所有JS传过来的数字都是NSNumber,这里会转成对应的int/long/double等类型,更重要的是会为block类型参数的生成一个block。

例如-(void)select:(int)index response:(RCTResponseSenderBlock)callback 这个方法,拿到两个参数的类型为int,block,JS传过来的两个参数类型是NSNumber,NSString(CallbackID),这时会把NSNumber转为int,NSString(CallbackID)转为一个block,block的内容是把回调的值和CallbackID传回给JS。

这些参数组装完毕后,通过NSInvocation动态调用相应的OC模块方法。

7.OC模块方法调用完,执行block回调。

8.调用到第6步说明的RCTModuleMethod生成的block。

9.block里带着CallbackID和block传过来的参数去调JS里MessageQueue的方法invokeCallbackAndReturnFlushedQueue。

10.MessageQueue通过CallbackID找到相应的JS callback方法。

11.调用callback方法,并把OC带过来的参数一起传过去,完成回调。

整个流程就是这样,简单概括下,差不多就是:JS函数调用转ModuleID/MethodID -> callback转CallbackID -> OC根据ID拿到方法 -> 处理参数 -> 调用OC方法 -> 回调CallbackID -> JS通过CallbackID拿到callback执行

事件响应

上述第4步留下一个问题,JS是怎样把数据传给OC,让OC去调相应方法的?

答案是通过返回值。JS不会主动传递数据给OC,在调OC方法时,会在上述第4步把ModuleID,MethodID等数据加到一个队列里,等OC过来调JS的任意方法时,再把这个队列返回给OC,此时OC再执行这个队列里要调用的方法。

一开始不明白,设计成JS无法直接调用OC,需要在OC去调JS时才通过返回值触发调用,整个程序还能跑得通吗。后来想想纯native开发里的事件响应机制,就有点理解了。native开发里,什么时候会执行代码?只在有事件触发的时候,这个事件可以是启动事件,触摸事件,timer事件,系统事件,回调事件。而在React Native里,这些事件发生时OC都会调用JS相应的模块方法去处理,处理完这些事件后再执行JS想让OC执行的方法,而没有事件发生的时候,是不会执行任何代码的,这跟native开发里事件响应机制是一致的。

说到OC调用JS,再补充一下,实际上模块配置表除了有上述OC的模块remoteModules外,还保存了JS模块localModules,OC调JS某些模块的方法时,也是通过传递ModuleID和MethodID去调用的,都会走到-enqueueJSCall:args:方法把两个ID和参数传给JS的BatchedBridge.callFunctionReturnFlushedQueue,跟JS调OC原理差不多,就不再赘述了。

总结

整个React Native的JS-OC通信机制大致就是这样了,关键点在于:模块化,模块配置表,传递ID,封装调用,事件响应,其设计思想和实现方法很值得学习借鉴。

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

2015-3-23 评论(34) 分类:技术文章 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 评论(28) 分类:技术文章 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 评论(15) 分类:技术文章 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图:

安装包大小优化

QQ邮箱APP漂流瓶开发记录

2014-12-26 评论(6) 分类:技术文章

QQ邮箱4.0增加了漂流瓶功能,记录一下做漂流瓶过程中碰到的问题。
(更多…)