移动 H5 首屏秒开优化方案探讨

2017-8-14 评论(11) 分类:技术文章 Tags:

随着移动设备性能不断增强,web 页面的性能体验逐渐变得可以接受,又因为 web 开发模式的诸多好处(跨平台,动态更新,减体积,无限扩展),APP 客户端里出现越来越多内嵌 web 页面(为了配上当前流行的说法,以下把所有网页都称为 H5 页面,虽然可能跟 H5 没关系),很多 APP 把一些功能模块改成用 H5 实现。

虽然说 H5 页面性能变好了,但如果没针对性地做一些优化,体验还是很糟糕的,主要两部分体验:

  1. 页面启动白屏时间:打开一个 H5 页面需要做一系列处理,会有一段白屏时间,体验糟糕。
  2. 响应流畅度:由于 webkit 的渲染机制,单线程,历史包袱等原因,页面刷新/交互的性能体验不如原生。

本文先不讨论第二点,只讨论第一点,怎样减少白屏时间。对 APP 里的一些使用 H5 实现的功能模块,怎样加快它们的启动速度,让它们启动的体验接近原生。

(更多…)

iOS 动态更新方案对比:JSPatch vs React Native

2016-8-9 评论(7) 分类:技术文章 Tags:

JSPatch 是 iOS 平台上的一个开源库,只需接入极小的三个引擎文件,即可以用 JS 调用和替换任意 OC 方法,也就是说可以在 APP 上线后通过下发 JS 脚本,实时修改任意 OC 方法的实现,达到修复 bug 或动态运营的目的。目前 JSPatch 被大规模应用于热修复(hotfix),已有超过 2500 个 APP 接入。

虽然 JSPatch 目前大部分只用于热修复,但因为 JSPatch 可以调用任意 OC 方法,实际上它也可以做热更新的工作,也就是动态为 APP 添加功能模块,并对这些功能模块进行实时更新,可以起到跟 React Native 一样的作用。我们从学习成本、接入成本、开发效率、热更新能力和性能体验这几个方面来对比一下使用 React Native 和 JSPatch 做热更新的差异。

学习成本

React Native 是从 web 前端开发框架 React 延伸出来的解决方案,主要解决的问题是web页面在移动端性能低的问题,React Native 让开发者可以像开发web页面那样用 React 的方式开发功能,同时框架会通过 JS 与 OC 的通信让界面使用原生组件渲染,让开发出来的功能拥有原生APP的性能和体验。

这里会有一个学习成本的问题,大部分 iOS 开发者并不熟悉web前端开发,当他们需要一个动态化的方案去开发一个功能模块时,若使用React Native,就意味着他需要学习web前端的一整套开发技能,学习成本很高,所以目前一些使用 React Native 的团队里,这部分功能的开发是由前端开发者负责,而不是终端开发者负责。

但前端开发者负责这部分功能也会有一些学习成本的问题,因为 React Native 还未达到十分成熟的程度,出了bug或有性能问题需要深入 React Native 客户端代码去排查和优化。也就是说 React-Native 是跨 web 前端开发和终端开发的技术,要求使用者同时有这两方面能力才能使用得当,这不可避免带来学习成本的提高。

而 JSPatch 是从终端开发出发的一种方案,JSPatch 写出来的代码风格与 OC 原生开发一致,使用者不需要有 web 前端的知识和经验,只需要有 iOS 开发经验,再加上一点 JS 语法的了解,就可以很好地使用,对终端开发来说学习成本很低。

可以看一下同样实现一个简单的界面,React Native 和 JSPatch 代码的对比:

//React Native
class HelloWorld extends Component {
  render() {
    return (
      <View style={styles.btnArea}>
        <View style={styles.btnWrapper}>
          <TouchableHighlight underlayColor="#ED5F37” onPress={this.login}
              activeOpacity={0.7}>
            <Text style={styles.btn}>登录</Text>
          </TouchableHighlight>
        </View>
      </View>
    );
  }
  login(){

  };
}

var styles = StyleSheet.create({
  btnArea: {
    justifyContent: 'center',
    marginLeft: 20,
    marginRight: 20,
    marginTop: 100,
    flexDirection: 'row',
  },
  btnWrapper: {
    backgroundColor: '#FC6E50',
    borderRadius: 5,
    flex: 1
  },
  btn: {
    paddingTop: 10,
    paddingBottom: 10,
    color: '#ffffff',
    textAlign: 'center',
  },
});
//JSPatch
require('UIColor, UIScreen, UIButton')
defineClass('HelloWord : UIView', {
    initWithFrame: function(frame) {
        if(self = super.initWithFrame(frame)){
            var screenWidth = UIScreen.mainScreen().bounds().width
            var loginBtn = UIButton.alloc().initWithFrame({x: 20, y: 50, width: screenWidth - 40, height: 30});
            loginBtn.setBackgroundColor(UIColor.greenColor())
            loginBtn.setTitle_forState("Login", 0)
            loginBtn.layer().setCornerRadius(5)
            loginBtn.addTarget_action_forControlEvents(self, 'handleBtn', 1<<6);
            self.addSubview(loginBtn);
        }
        return self;
    },
    handleBtn: function() {
    }
})

接入成本

接入成本上,React Native 是比较大的框架,据统计目前核心代码里 OC 和 JS 代码加起来有4w行,接入后安装包体积增大 1.8M 左右。而 JSPatch 是微型框架,只有 3 个文件 2k 行代码,接入后增大 100K 左右。另外 React Native 需要搭建一套开发环境,有很多依赖的库,环境的搭建被称为一个痛点。而 JSPatch 无需搭建环境,只需要拖入三个文件到工程中即可使用。

React Native 是大框架,维护起来成本也会增大,在性能调优和 bug 查找时,必须深入了解整个框架的原理和执行流程,此外 React Native 目前还未达到稳定状态,升级时踩坑不可避免。相对来说 JSPatch 接入后的维护成本会低一些,因为 JSPatch 只是作为很薄的一层转接口,没有太多规则和框架,也就没有太多坑,本身代码量小,需要深入了解去调试 bug 或性能调优时成本也低。

开发效率

在 UI 层上目前 HTML + CSS 的方式开发效率是比手写布局高的,React Native 也是用近似 HTML+CSS 去绘制 UI,这方面开发效率相对 JSPatch 会高一些,但 JSPatch 也可以借助 iOS 一些成熟的库去提高效率,例如使用 Massory,让 UI 的开发效率不会相差太多。逻辑层方面的开发效率双方是一样的。

此外 React Native 在开发效率上的另一个优势是支持跨平台,React Native 本意是复用逻辑层代码,UI 层根据不同平台写不同的代码,但 UI 层目前也可以通过 ReactMix 之类的工具做到跨平台,所以UI层和逻辑层代码都能得到一定程度的复用。而 JSPatch 目前只能用于 iOS 平台,没有跨平台能力。

实际上跨平台有它适用和不适用的场景,跨平台有它的代价,就是需要兼顾每个平台的特性,导致效果不佳。

跨平台典型的适用场景是电商活动页面,以展示为主,重开发效率轻交互体验,但不适用于功能性的模块。对 Android 来说目前热更新方案十分成熟,Android 十分自由,可以直接用原生开发后生成diff包下发运行,这种无论是开发效率和效果都是最好的。所以若是重体验的功能模块,Android使用原生的热更新方案,iOS 使用 JSPatch 开发,会更适合。

JSPatch 也做了一些事情尝试提高开发效率,例如做了 XCode 代码提示插件 JSPatchX,让用 JS 调用 OC 代码时会出现代码提示,另外跟 React Native 一样有开发时可以实时刷新界面查看修改效果的功能,目前仍在继续做一些措施和工具提高开发效率。

热更新能力

React Native 和 JSPatch 都能对用其开发出来的功能模块进行热更新,这也是这种方案最大的好处。不过 React Native 在热更新时无法使用事先没有做过桥接的原生组件,例如需要加一个发送短信功能,需要用到原生 MessageUI.framework 的接口,若没有在编译时加上提供给 JS 的接口,是无法调用到的。而 JSPatch 可以调用到任意已在项目里的组件,以及任意原生 framework 接口,不需要事先做桥接,在热更新的能力上,相对来说 JSPatch 的能力和自由度会更高一些。

性能体验

使用 React Native 和 JSPatch 性能上会比原生差点,但都能得到比纯 H5 页面或 hybrid 更好的性能和体验。

JSPatch 的性能问题主要在于 JS 和 OC 的通信,每次调用 OC 方法都要通过 OC runtime 接口,并进行参数转换。runtime 接口调用带来的耗时一般不会成为瓶颈,参数转换则需要注意避免在 JS 和 OC 之间传递大的数据集合对象。JSPatch 在性能方面也针对开发功能做了不少优化,尽力减少了 JS 和 OC 的通信,github 项目主页上有完整的小 App demo,目前来看并没有碰到太多性能问题。

React Native 的性能问题会复杂一些,因为框架本身的模块初始化/react组件初始化/JS渲染逻辑等会消耗不少时间和内存,这些地方若使用或优化不当都会对性能和体验造成影响。JS 和 OC 的通信也是一个耗性能的点,不过这点上 React Native 优化得比较好,没有成为主要消耗点。

在性能和体验问题上,两者有不同的性能消耗点,从最终效果来看两者差别不大。

总结

学习成本
接入成本
热更新能力
开发效率
性能体验
JSPatch
中,不跨平台
React Native
高,跨平台
高 

总的来说,JSPatch在学习成本,接入成本,热更新能力上占优,而 React Native 在开发效率和跨平台能力上占优,大家可以根据需求的不同选用不同的热更新方案。JSPatch 目前仍在不断发展中,后续会致力于提高开发效率,完善周边支持,欢迎参与这个开源项目的开发。

(本文发表于《程序员》2016年8月刊)

JSPatch平台: http://jspatch.com

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 评论(61) 分类:技术文章 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-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 – 动态更新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这一系列的处理给出了很好的示例可供参考。