XCode 代码补全插件 – JSPatchX 原理解析

2016-4-26 评论(2) 分类:技术文章 Tags:

JSPatchXJSPatch Xcode 代码自动补全插件,目前在 github 开源,效果见图:

做完一个开源项目照例写篇文章说明下实现原理,主要目的是让想对这个项目做贡献改进的人可以通过文章更容易地了解这个项目的由来,思路,核心原理和流程,降低参与这个项目开发的门槛。

由来

JSPatch 脚本一个不爽的地方就是没有代码补全,而调用 OC 方法时方法名又死长,写起来很不方便。

对此之前做了 JSPatch Convertor,可以自动把 OC 代码转为 JSPatch 脚本,这个工具的使用场景是用 JSPatch 做 hotfix 时,需要重写原 OC 的整个方法,这时用工具把这个方法的 OC 代码直接转为 JS 再进行修改,可以很大地降低工作量,缓解了这个问题。但若要用 JSPatch 开发新功能模块,就不会有 OC 代码可以去转换,这时提高编码效率的唯一方式就是做代码补全插件。

在寻找实现方案的时候得知公司内一牛人 louis 已经实现了 lua 的 XCode 代码补全插件,沟通后还很慷慨地给了源码,省去了很多研究 XCode 代码补全机制的功夫,于是参考他的编码,并且直接用了他 OC 头文件解析的代码,开发了 JSPatchX。所以这个项目算是我与 louis 联合开发的,在此感谢 louis~

插件入门

XCode 有个很坑爹的地方,就是它并不官方支持插件开发,官方没有文档,XCode 也没有开源,但由于 XCode 是 Objective-C 写的,OC 动态性太强大,导致在这么封闭的情况下民间还是可以做出各种插件,其核心开发方式就是:

  1. dump 出 Xcode 所有头文件,知道 Xcode 里有哪些类和接口。
  2. 通过头文件方法名猜测方法的作用,swizzle 这些方法,插入自己的代码实现插件逻辑。
  3. 通过 NSNotificationCenter 监听各种事件的发生。

更详细的开发教程网上有不少文章,有兴趣的自行搜索吧。

起步

对于实现 JS 代码补全这个功能来说,主要分三步:

  1. 在编辑 JS 文件时开启代码补全功能。
  2. 找到用户输入代码时的回调,按 Xcode 要求组装代码补全对象数组返回。
  3. 根据已输入文字对补全对象数组进行过滤

第一步是通过替换 DVTTextCompletionDataSource 类里的 -strategies 方法,在源文件是 JS 时生成一个 IDEIndexCompletionStrategy 对象返回,就可以针对 JS 文件走代码补全逻辑了。

第二步是替换 IDEIndexCompletionStrategy- completionItemsForDocumentLocation:context:highlyLikelyCompletionItems:areDefinitive: 方法,这个方法会在用户输入时被调用,在这里组装好应该出现的补全对象(IDEIndexCompletionItem) 列表返回,Xcode 就会自动应用返回的 items 对输入进行补全。

第三步是在 DVTTextCompletionSession-_setFilteringPrefix:forceFilter: 方法,针对第二步返回的 item 对象根据输入进行过滤。

要让自动补全插件程序跑通,只需实现上述三步。显然核心在第二步如何组装合适的补全对象 completionItem。代码里我们新增了一个 IDEIndexCompletionItem 的子类 JPCompletionItem 去表示,下面统一把这个补全对象称为 completionItem。接下来的问题就是怎样组装这些 completionItem。

实现

先看看我们需要哪些自动补全,概括起来有几种:

  1. 可能会被调用到的 OC 方法名
  2. JS 上新增的方法名,以及出现的类名
  3. JSPatch 自身的一些关键字接口,如 defineClass, require 等
  4. 当前 JS 文件里出现过的关键字

前三点应该没有异议,第四点要解释一下,实际上若要做得精细,应该加上 JS 语言本身自带的 API 和关键字(var / Math 函数 / String 函数等),以及JS 当前作用域上的变量的补全,但这样做一是 API 太多,二是实现复杂,所以用 “当前 JS 文件里出现过的关键字” 代替这两点,只要文件里出现过的单词就会有补全提示,也就是说一些关键字和变量第一次输入时没有提示,但在同个文件第二次输入就有补全提示了,sublime 默认就是这样的补全规则,实际使用效果很好,所以选择用这种简单的方式满足需求。

具体实现上,分三步走,一是解析 OC 头文件,二是解析 JS 文件,三是对解析后的数据进行缓存和组装 completionItem。

解析 OC 头文件

JPObjcFile 负责解析 OC 头文件,因为这里可以认为外部可以调用的 OC 接口都在头文件里,所以只需要解析头文件,这样处理比较简单,解析效率也很高。louis写了个 OC 头文件解析器,把头文件里的 class / protocol / import 解析出来,最终每个头文件都会解析成对应的 JPObjcFile 对象,这个对象保存着文件里 class / protocol 对应的方法的 completionItems,可以按需求直接输出。

解析 JS 文件

JPJSFile 负责解析 JS 文件,这里的解析比较简单,没有用词法语法解析器,而是直接通过正则匹配取出需要的内容,这里通过正则提取了:

  1. require() 里的 className
  2. defineClass() 里的 className
  3. defineClass() 里所有的方法名
  4. 文件里所有 keyword

同样每个 JS 文件都会生成一个 JPJSFile 对象,包含了上述提取的元素,并生成和保存了方法名和keyword对应的 completionItems列表。

组装和缓存

解决了单个 OC / JS 文件的解析,接着就是决定解析哪些文件,以什么样的形式缓存和组装返回给XCode。

JPObjcIndex

先看看 OC 的解析,JPObjcIndex 负责 OC 头文件的解析,JS 可能调用到的 OC 代码只存在于两个地方,一是系统framework,二是项目里的代码,对这些 OC 头文件要以什么样的方式解析呢?这里有两个选择:

  1. 只解析与当前编辑的 JS 文件相关的 OC 头文件
  2. 一次性把所有文件都解析好,再进行筛选

若要用方案1,只解析与当前编辑的 JS 文件相关的文件,则需要知道 JS 文件引用到了哪些 OC 文件,需要像 OC 代码那样有 #import 其他文件的规则,而 JSPatch 的规则是调用 OC 代码时不需要 import OC 文件,只需要通过 require(‘className') 接口引入类,所以线索只有 require() 里的类名,而在还没解析时是不清楚类名和 OC 文件的对应关系的,无法知道当前 JS 文件依赖了哪些 OC 头文件,所以这里只能选择一次性把所有 OC 头文件都解析好。

JPObjcIndex 默认扫描了 Foundation 和 UIKit 这两个 framework 里的所有头文件,以及当前项目里的所有 OC 头文件,在 JPObjcIndex 里以 className 为 key 进行缓存,对外提供通过 className 去取这些类相应 completionItem 的接口。

JPJSIndex

对于 JS 文件,为了简单起见,同样采用了一次性解析全部文件的方式。JPJSIndex 做了以下这些事:

  1. 解析所有 JS 文件,生成一个个对应的 JPJSFile 对象,缓存起来。
  2. 取出每个 JPJSFile 里解析的 require() 以及 defineClass() 的 className,去 JPObjcIndex 取这些 Class 对应的 completionItems。
  3. 取出每个 JPJSFile 解析好的 method completionItems。
  4. 取出每个 JPJSFile 解析好的 keyword completionItems。
  5. 本地 keyword.plist 定义了 JSPatch 常用的一些自动补全关键字,例如 defineClass, CGRect 等,在这里取出这些数据并生成 completionItems。
  6. 把 2-5 步里的 completionItems 分成两种类型,keyword 类型和 method 类型,缓存起来并返回给 XCode。
  7. 当有 JS 文件保存时,重新对这个文件生成 JPJSFile 对象,并重做 2-6 步。

第6步分出来的两种类型应用于两种场景,method 类型会在 JS 输入 . 要进行方法调用时出现,这个类型里所有的 completionItem 都是方法,包括 OC 头文件定义的方法以及 JS 里解析的方法。keyword 类型则是其他的像类名/语句关键字等这些非方法,在平常输入中出现。

在没有 JS 文件保存时,用户编辑 JS 代码每一次输入走到补全逻辑时,JPJSIndex 都是直接返回内存里已组装好的 completionItems 列表,没有其他操作,提高操作性能。第7步虽然在有文件保存时重新做了 2-6 步对数据进行重新组装,但这个过程不涉及文件解析,只需要取内存里解析好的数据进行组装,并且文件保存不会那么频繁,所以性能上没有太大问题。

整个流程就是这样,实际上很简单,总结起来就是解析所有 OC 头文件,解析所有 JS 文件,组装并缓存好 completionItem 返回。

不足

做这个项目的想法是先用最简单的方式快速做出来,满足80%的需求,导致会有一些不足,例如

  1. 没有做 JS 语法解析,没有做细致的筛选规则,粗暴地全部提示。
  2. 没有补全 include 的其他 JS 文件里的全局变量。
  3. defineClass 里写定义方法时,若要覆盖 OC 原有方法,没有方法名补全(因为方法名只有在 . 后才有补全)
  4. 没有加上除了 Foundation 和 UIKit 以外的 framework。

欢迎一起改进 JSPatchX,完善这些不足~

JSPatch更新:完善开发功能模块的能力

2016-4-6 评论(7) 分类:技术文章 Tags:

JSPatch 开源以来大部分被用于 hotfix,替换原生方法修复线上bug,但实际上 JSPatch 一直拥有动态添加功能模块的能力,因为 JSPatch 可以创建和调用任意 OC 类和方法,完全可以用 JSPatch 写功能模块,然后动态下发加载。只是之前在性能和开发体验上有些问题,还没有太多这方面的应用。这次 JSPatch 做了较大的更新,扫除这些问题,让用纯 JS 写功能模块变得实用。这里有个用 JS 写的 Dribbble 客户端 Demo,可以体验下效果。

来看看这次更新做了什么。

性能优化

通过工具可以看到使用 JSPatch 写功能模块时,耗时较多的点在于 JS 和 OC 的通信,以及通信过程中参数的转换,于是在这块寻找优化点。写功能时需要新增很多类和方法,例如:

defineClass('JPDribbbleView:UIView', {
  renderItem: function(item) {
    ...
  },
})

defineClass('JPDribbbleViewController:UIViewController', {
  render: function(){
    var view = JPDribbbleView.alloc().init();
    view.renderItem(item);
  }
});

上面两个都是新增的类,两个方法也是新增的,按之前的流程,这里的定义会传入 OC,在 OC 生成这两个类,并在这个类上添加这里定义的方法,调用时进入 OC 寻找这些方法调用。

(更多…)

iOS 组件化方案探索

2016-3-18 评论(58) 分类:技术文章 Tags:

看了 Limboy(文章1 文章2) 和 Casa (文章) 对 iOS 组件化方案的讨论,写篇文章梳理下思路。

首先我觉得”组件”在这里不太合适,因为按我理解组件是指比较小的功能块,这些组件不需要多少组件间通信,没什么依赖,也就不需要做什么其他处理,面向对象就能搞定。而这里提到的是较大粒度的业务功能,我们习惯称为”模块”。为了方便表述,下面模块和组件代表同一个意思,都是指较大粒度的业务模块。

一个 APP 有多个模块,模块之间会通信,互相调用,例如微信读书有 书籍详情 想法列表 阅读器 发现卡片 等等模块,这些模块会互相调用,例如 书籍详情要调起阅读器和想法列表,阅读器要调起想法列表和书籍详情,等等,一般我们是怎样调用呢,以阅读器为例,会这样写:

(更多…)

JSPatch 近期新特性解析

2016-3-14 评论(6) 分类:技术文章 Tags:

JSPatch 在社区的推动下不断在优化改善,这篇文章总结下这几个月以来 JSPatch 的一些新特性,以及它们的实现原理。包括脱离锁的 performSelectorInOC 接口,支持可变参数方法调用,给新增方法指定类型的 defineProtocol 接口,支持重写 dealloc 方法,以及两个扩展 JPCleaner 和 JPLoader。

performSelectorInOC

JavaScript 语言是单线程的,在 OC 使用 JavaScriptCore 引擎执行 JS 代码时,会对 JS 代码块加锁,保证同个 JSContext 下的 JS 代码都是顺序执行。所以调用 JSPatch 替换的方法,以及在 JSPatch 里调用 OC 方法,都会在这个锁里执行,这导致三个问题:

  1. JSPatch替换的方法无法并行执行,如果如果主线程和子线程同时运行了 JSPatch 替换的方法,这些方法的执行都会顺序排队,主线程会等待子线程的方法执行完后再执行,如果子线程方法耗时长,主线程会等很久,卡住主线程。
  2. 某种情况下,JavaScriptCore 的锁与 OC 代码上的锁混合时,会产生死锁。
  3. UIWebView 的初始化会与 JavaScriptCore 冲突。若在 JavaScriptCore 的锁里(第一次)初始化 UIWebView 会导致 webview 无法解析页面。

(更多…)

JSPatch Convertor 实现原理详解

2015-10-13 评论(7) 分类:技术文章 Tags:

简介

JSPatch Convertor 可以自动把 Objective-C 代码转为 JSPatch 脚本。

JSPatch 是以方法为单位进行代码替换的,若 OC 上某个方法里有一行出了bug,就需要把这个方法用 JS 重写一遍才能进行替换,这就需要很多人工把 Objective-C 代码翻译成 JS 的过程,而这种代码转换的过程遵循着固定的模式,应该是可以做到自动完成的,于是想尝试实现这样的代码自动转换工具,从 Objective-C 自动转为 JSPatch 脚本。

方案 / Antlr

做这样的代码转换工具,最简单的实现方式是什么?最初考虑是否能用正则表达式搞定,如果可以那是最简单的,后来发现像 方法声明 / get property / NSArray / NSString 等这些是可以用正则处理的,但需要匹配括号的像 block / 方法调用 /set property 这些难以用正则处理,于是只能转向其他途径。

Antlr

接下来的思路是对 Objective-C 进行词法语法解析,再遍历语法树生成对应的 JS 代码。Objective-C 词法语法解析 clang 可以做到 ,但在找方案过程中发现了 antlr 这个神器,以及为 antlr 定制的几乎所有语言的语法描述文件,更符合我的需求。antlr 可以根据语法描述文件生成对应的词法语法解析程序,生成的程序可以是 Java / Python / C# / JavaScript 这四种之一。

也就是说,我们拿 ObjC.g4 这个语法文件,就可以通过 antlr 生成 Objective-C 语法解析程序,程序语言可以在上述四种语言中任挑,我挑选的是 JavaScript,生成的程序可以在 [这里] 看到。官方文档有生成的流程和使用方法,可以自己试下。
(更多…)

JSPatch 部署安全策略

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

使用 JSPatch 有两个安全问题:

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

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

传输安全

方案一:对称加密

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

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

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

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

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

JSPatch实现原理详解<二>

2015-7-6 评论(14) 分类:技术文章 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 评论(43) 分类:技术文章

注:本文较早撰写,随着 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 评论(38) 分类:作品 技术文章 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这一系列的处理给出了很好的示例可供参考。