打造基于Clang LibTooling的iOS自动打点系统CLAS(一)

1. 手动打点的弊端

在不少ios工程师的平常工做中,不但要对接产品提出的功能性需求,还会收到产品出于数据统计分析需求目的而提出的附带的隐形需求:统计打点。大多数公司的基础框架层都会对统计打点功能作高级封装,工程师只须要在某个操做被触发的时候在处理的方法内加入一行函数调用便可完成,例如:前端

- (void)btnCloseClicked:(id)sender {
    [MCCStatistic logEvent:@"详情页-关闭按钮-点击"];
    [self.navigationController popViewController];
}

这个看起来简单无比的工做,实际上作起来倒是无聊透顶,并且极易出错。常常会出现App上线后发现有些统计点没有打上、打错了地方或者打出了错别字。漏打点会形成数据分析失真,而打错点则不但会形成数据失真还会形成数据永久污染,而这些错误都须要从新发版才能解决。然而就算从新发版,仍然会有必定比例的用户不升级,由此形成在几个月甚至长达半年的时间里持续性地对数据分析的准确性产生影响。ios

这种手动方式还存在另一个更严重的问题:打点的代码散落分布在整个工程内,使得打点数据维护起来异常困难。好比若是想知道当前项目内实际打点状况的汇总,与产品的打点列表进行交叉比对,看是否有遗漏、不一致或者能够删除的统计点,这对于工程师来讲就是个异常头疼的难题了。大的项目常常不是由一两个工程师维护的,各个不一样的模块由不一样的团队分别负责,汇总这些打点会浪费工程师大量的时间和精力,并且仍然不能保证彻底准确,由于按照上面的方法,若是想找出项目内全部的打点,只能靠搜索打点函数调用的方式去进行,一个工程可能会有几千个统计点分布在上千个文件内,稍不留神就会遗漏或出错,由此产生的汇总的可信度也会大打折扣。编程

2. 手动打点的改进

可能有些同窗会想到用切面编程(AOP)的方式来处理打点。这种方案我在这里就不赘述了,网上有很多成熟的方案,有兴趣的同窗能够参考。AOP方案功能简单强大,可以将散落的打点代码聚合在一块儿方便维护,可是对于遗忘打点或者打点错误这种状况除了从新发版依然一筹莫展,毕竟打点的文案是在编译前就肯定的。固然这个问题也能够解决。把须要打点的文案所有集中在一个配置文件里,而后给每个统计点起一个独一无二的常量名字,在AOP的代码里只须要以查表的方式获取真正的文案,这样配置文件即可以从线上进行热更新。这种方案看起来很美好,然而操做起来同样很烦人。给每个页面或者事件起个名字同样是个耗时耗力无聊透顶的工做,一样也容易出错,这看起来和直接埋点没有什么本质区别,惟一的优势就是能够把项目或者模块内全部的点集中到一块儿来维护更直观些而已。框架

3. 自动打点方案C.L.A.S.

虽然上面的方案并不完美,可是它给了咱们很大的启发:有没有办法自动为每一个点击事件生成一个独一无二的名字呢?这个问题看起来很难,可是能够换一种思考方式,若是咱们有办法为特定的方法自动生成一个名字并插入打点代码,那刚才的AOP方案就已经很好了。为此咱们就须要借助Clang所提供的黑科技了。为了不最终方案过分复杂,咱们在这里进行了一些条件限定:函数

  1. 适应项目内80%的打点需求
  2. 对现有代码逻辑无侵入
  3. 对现有编译工具链无侵入

最初,咱们曾经考虑过直接建立一个Clang的Plugin,在内存中对AST直接进行修改,达到动态插入代码的目的。可是这条路困难重重,AST在Clang官方的定义是通过语法分析器处理后的不可变(immutable)的结果,动态修改AST会形成SourceLocation错乱,最终致使Codegen在生成IR的时候崩溃。咱们在尝试了数次以后最终放弃了这个看似“直接”的想法。工具

通过一番权衡后,咱们肯定了基于Clang的LibTooling建立的前端工具对OC源代码进行分析和插入的方案,将结果写入中间文件再发送给Clang进行编译。这个方案咱们后面称做CLAS,能够由下图描述:性能

enter image description here

输入的.m文件经由CLAS分析和重写到临时文件,再传入Clang进行正常的编译流程。由于全部对源代码的改动都发生在临时文件层面,源文件不会发生任何改动,同时咱们也没有对Clang的编译过程作任何干预,因此这个方案能够理解为一个对OC源代码进行特殊预处理的Preprocessor。有了如何插入代码的工具,那么为每个方法起一个响亮而惟一的名字就看起来很简单了。由于每遇到一个OC的方法,均可以使用OC的类名+扩展名(Category)+方法名(selector)的方式来得到一个惟一的标识,绝对不会重复,不然编译的时候Clang就会报语法错误。优化

插入的打点代码原则上要保证对性能尽量小的损耗,全局会维护一张Hash表,用来维护名字 --- 打点文案之间的映射关系。这样作能够用尽量小的内存大幅提升查询时间,由于绝大部分名字并无对应的打点文案。这张Hash表由App内置一份,每次发版前由开发人员内置到Bundle内,同时每次App启动也会尝试更新这张Hash表支持动态更新映射关系。而插入代码的具体位置,定位在方法的左大括号后面,与大括号保持同一行,并使用{}进行包围。这样能够保证不破坏下面所提的Debug信息的行数,避免须要从新生成Debug信息的工做量。例如:设计

- (int)calWithA:(int)a andB:(int)b {     {/*插入代码的位置*/}
    a = a * 2 + b - 3;
    return a;
}

这个方案最大的难题在于在哪些方法上插入代码。全量插入固然是最简单粗暴的方法,将项目内的.m文件内全部方法所有打点。这样作好处很明显,若是漏打了哪一个方法,能够经过线上更新的方式补打,可是同时这样作的坏处也很明显,不少方法永远不会须要打点却被插入了一段毫无用处的代码影响执行效率,由于被插入代码的方法,每次执行时都要先去查表看看当前的名字是否有映射的打点文案,若是有则发送打点,不然忽略,虽然查Hash表理论上是个很快的操做,可是若是发生在一些频繁调用的方法上依然会对系统性能产生负面的影响。为了不这个问题,咱们能够规定须要打点的方法只能出如今ViewController、View以及Manager(若是你用MVVM也能够是ViewModel)里面,而且排除不太可能须要打点的方法(例如ViewWillLayoutSubviews等),这种规范能够经过代码审核来约束工程师。固然命名规范原本也应该在成熟的项目内强制实施,保证代码可读性和质量。若是有些方法写在了ViewController里面却被频繁调用而且不须要打点,为了避免影响性能,能够在方法起始处经过指定__attribute__((clas_ignore))属性进行强制跳过。这种方式与Clang的__attribute__((always_inline))类似。例如:code

__attribute__((clas_ignore))
- (void)func {
    ....
}

有了这些咱们能够大幅缩小插入代码的范围,减小插入代码对App性能所形成的影响。

4. CLAS的缺点

就像任何一种方案都有缺点同样,CLAS也存在着一些明显的缺点:

没法适用于条件打点
插入的代码可能会形成编译失败
插入范围过大
编译出的文件包含与源文件不符的Debug信息
插入代码致使二进制体积变大

条件打点通常会出如今逻辑复杂或者内容动态的界面上,好比一个按钮的点击事件,在某些状况下是A,另一些状况是B,又或者打点的触发取决于当时场景的条件判断,这样动态变化的打点是没法经过CLAS来完成的。打点的事件不跟随条件变化的打点咱们称之为_静态打点_。App内大约80%的打点的场景是属于这种静态打点的场景,CLAS也是为静态打点设计和服务的。

插入范围过大咱们在3里面已经讨论过了,并有了一些优化的方法。插入代码可能形成编译失败是由于插入的代码可能须要引用一些在当前.m文件里没有引用的其余头文件致使编译过程失败,这个能够经过配置CLAS插入用户指定的#include#import来解决。Debug信息不符的问题比较棘手,由于.m被修改为临时文件并经过Clang编译出.o文件,生成的Debug Symbols是与临时文件(.clas.m)的信息相符的,与源文件并不相符,这个就须要咱们在生成dSYMs的时候,把全部的临时文件信息替换为原始文件信息,为了达到这个目的,咱们须要修改LLVM的dsymutil替换系统原生的dsymutil。咱们会在接下来的文章里详细讲解咱们如何构建一套完整的CLAS工具链的。

由于插入了大量代码,编译后的二进制体积必然会有所增大,因此原则上插入的代码应该是功能内聚的,一到两条语句为佳,避免在插入代码里直接构造含有复杂逻辑和功能的语句。例如:

{ [MCStatistik logEvent:@"%__FUNC_NAME__%"]; }

这里出现了一个%__FUNC_NAME__%看似的怪异名字,这是CLAS所支持的变量替换,以%开始和结束,在插入代码的时候会自动替换为对应的值。例如%__FUNC_NAME__%在插入代码的时候会自动替换为当前插入位置的函数名。

待续...

在接下来的几篇文章里,咱们会详细介绍如何从零开始一步一步地构建一个基于Clang LibTooling的编译器前端工具CLAS,敬请期待!

相关文章
相关标签/搜索