打点纪

每一个技术变化都要从PM的一个需求讲起。对于一个ios应用来说,当第一版的功能完成得差不多以后,我们就必不可少地要为应用中用户的各种行为记录log或track。市面上有不少的应用统计第三方库,类似:umeng,GA,mixpannel & etc.. 当用户在信息页点击拨打电话按钮时,记录一下拨打事件。我们也许会这样实现:

// InfoViewController.m

- (void)onCallButtonPressed:(UIButton *)button
{
    [self call];
    [Track event:eventCall];
} 

这个需求就这样轻松搞定了。可是PM又说:我要的不止是点击拨打按钮,我还需要点击购买按钮,发送短信按钮,私聊按钮,发布按钮,设置按钮,求购按钮……

一开始,你或许会尝试在PM所说的每一个buttonPress方法中加上[Track event:kSomeEventYouDefined];这行代码。这时候一部分优秀的程序员已经开始抓狂了,因为到处散落着相似的代码。另一部分或许会有些不安,但还是勉强接受了这种写法。

可是最近有一天碰到这样一件事。PM一拍脑袋说:我们这个设计已经落伍了,这个版本我们要给所有的UI换上新衣服。另外,以前记的许多log都不需要了,并且增加了新的log。

苦逼的程序员这下崩溃了。他发现不仅要在新的UI中加入track,还需要找到并删除旧的track。然而旧的track散落在程序的各个角落,他不得不ctrl+F一个个搜到再看下是否有用。这种情况下,漏删与少加很可能发生

为什么会产生这样的情况,还是最初的设计出了问题。如果一开始我们能找到一种合适的架构,使得这些log在同一处被记上,就不会发生这样的情况。此时我们引入 Aspect Oriented Programming (AOP)。


面向切面编程

通过预编译方式和运行期动态代理实现在不修改源代码的情况下给程序动态统一添加功能的一种技术。

在 Objective-C 的世界里,这意味着使用运行时的特性来为 切面 增加适合的代码。通过切面增加的行为可以是:

  • 在类的特定方法调用前运行特定的代码

  • 在类的特定方法调用后运行特定的代码

  • 增加代码来替代原来的类的方法的实现

iOS 可以使用 Pete Steinberger 开发的 Aspects 这个库,大致原理是在 runtime 层,封装了 swizzle method 替换或增加一些方法来实现的。

Aspect只有两个API:

+ (id<AspectToken>)aspect_hookSelector:(SEL)selector
                      withOptions:(AspectOptions)options
                       usingBlock:(id)block
                            error:(NSError **)error;
- (id<AspectToken>)aspect_hookSelector:(SEL)selector
                      withOptions:(AspectOptions)options
                       usingBlock:(id)block
                            error:(NSError **)error;

用AOP来实现前文的例子,可以写成这样:

// InfoViewController.m

- (void)onCallButtonPressed:(UIButton *)button
{
    [self call];
}

// AppDelegate+Logging.m
- (void)setupAnalytics
{
    [InfoViewController aspect_hookSelector:@selector(onCallButtonPressed:) withOptions:AspectPositionAfter usingBlock:^(id<AspectInfo> aspectInfo, BOOL animated) {
        [Track event:eventCall];
    } error:NULL];
}

这样统计代码就从业务代码中剥离出来了。但是又产生了一个新问题,多个 Button Event,岂不是要写很多行这样的代码?重复 这样的事情,作为一个程序员怎么能忍。简单,让我们构造一个方法:

- (void)trackEventWithClass:(Class)klass selector:(SEL)selector event:(NSString *)event
{
    [klass aspect_hookSelector:@selector(selector) withOptions:AspectPositionAfter
    usingBlock:^(id<AspectInfo> aspectInfo, BOOL animated) {
        [Track event:someEvent];
    } error:NULL];
}

不过,不同的buttonEvent在不同的类中方法名未必一致,而且我们也不能保证别人的代码里方法名是如何写的。所以我们最好定义一个config dictionary。标准的做法如下:

- (void)setupLogging {
    NSDictionary *eventConfigs = @{
                 @"BXLContactActionCell": @{
                         @"BXLLoggingPageImpression": @"Contact Action - Contact",
                         @"BXLLoggingTrackedEvents": @[
                                 @{
                                     @"BXLLoggingEventName": @"Phone Call",
                                     @"BXLLoggingEventSelectorName": @"dialPressed:",
                                     @"BXLLoggingEventHandlerBlock": ^(id<AspectInfo> aspectInfo) {
                                         [Track event:EVENT_PHONE];
                                     },
                                 },
                                 @{
                                     @"BXLLoggingEventName": @"Email Send",
                                     @"BXLLoggingEventSelectorName": @"emailPressed:",
                                     @"BXLLoggingEventHandlerBlock": ^(id<AspectInfo> aspectInfo) {
                                         [Track event:EVENT_EMAIL];
                                     },
                                 }
                             ],
                         },
                 @"BXLUserSettingViewController": ....
                 ....
    };
}

解释一下:在这个配置dictionary里面,我们存了每个类所需要记录的点击事件。每个类作为config的一个dictionary,里面又存了三种类型:事件名,方法名和触发的代码段。当我们调用的时候,可以像下面这样:

//AppDelegate+Logging.m
// Hook Events
for (NSString *className in eventConfigs) {
    Class clazz = NSClassFromString(className);
    NSDictionary *config = configs[className];
    if (config[@"BXLLoggingTrackedEvents"]) {
        for (NSDictionary *event in config[@"BXLLoggingTrackedEvents"]) {
            SEL selector = NSSelectorFromString(event[@"BXLLoggingEventSelectorName"]);
            AspectHandlerBlock block = event[@"BXLLoggingEventHandlerBlock"];
            [clazz aspect_hookSelector:selector
                           withOptions:AspectPositionAfter
                            usingBlock:^(id<AspectInfo> aspectInfo) {
                                block(aspectInfo);
                               } error:NULL];
        }
    }
}

这样我们每次要增加或删除什么track事件,只需要去AppDelegate+Logging这个文件里增加或删除一个dictionary。除了事件记录,PV也是一个非常重要的环节,即页面展示。我们可以很方便地记录所有的页面展示,像下面这样:

// Hook Page Impression
[UIViewController aspect_hookSelector:@selector(viewDidAppear:)
                          withOptions:AspectPositionAfter
                           usingBlock:^(id<AspectInfo> aspectInfo) {
                               NSString *className = NSStringFromClass([[aspectInfo instance] class]);
                               [Track pv:className];
                              } error:NULL];

这里aspect默认api已经把方法viewDidAppear所在的类aspectInfo传递给我们了。我们只需读取它的类名。 最好的当然还是把以上代码配置在服务器上,这样我们不需要发版本也能够动态改变已有的记录。而且也不容易漏记,解放客户端劳动力,每次去后台读取即可。


故事写到这里似乎已经圆满了,产品经理也得到了他所要的动态性和数据。然而我们是精益求精的程序员呀,他们可是永不满足的PM呀。脑洞一开,有没有一种更加直观的验证方式,当产品经理可以在后台看到所有会发送Event的按钮。就像下面这样:

image  这件事情分为两部分:一部分是显示已配置的区域,另一部分是可视化动态设置。

此时,我们想得更远。如果这一切可以变成一个框架,不需要我们自己手写代码,而是产品、运营对着屏幕自己配置。那该有多好呀!假如我们要实现这样一个后台动态配置的event track的工具,思路大概是这样的:

  • 获取当前页面的整个View结构,将这一帧渲染在网页端
  • 右键某一个UIControl, 设置这个事件的类型和名称
  • 记录一个数据结构,包含当前UIControl所在路径
  • 将该json格式记录在服务器配置页
  • 部署到所有的手机上

image


是不是有点眼熟,加上动态添加的动画效果的话,活脱脱就是一个mixpannel!

Mixpannel

image

Mixpanel是一个Web服务,让开发者跟踪用户的使用习惯,并提供实时分析。Mixpanel提供的“人物”功能,可以让你根据用户在应用程序内采取的行为对其发出推送通知。Mixpanel API是一个RESTful API,以JSON格式返回响应。

预置一个Mixpanel的sdk在你的应用里,每次打开都会触发去它提供的后台读取你的配置。你也可以自己写一个数据分析后台,因为它的sdk是开源的。然而Mixpannel的后台非常强大。它甚至可以让产品经理自己对着应用配置他需要的track event,而且体验与交互如丝般顺滑。

原理

  • 用Method Swizzling替换了 UIControl的didMoveToWindow和didMoveToSuperView
  • 在这两个方法里加了hook,让UIControl多了额外的target(Java里叫listener)
  • 在target中,一旦监听到对应的事件发生,把定义好的事件收集起来

局限

  • 只能记录UIContol及其子类中特定方法的事件

综上所述

记track一开始就千万不要散落在代码的方方面面,你可以全都放在AppDelegate+Logging.m这样的文件里,也可以放在服务器端读取配置策略。更为简单就是使用mixpanel。它是一款商业app,但有免费版本。

如果我们能结合自动和手动track,就能避免自动的局限,又获得更高效、更清晰的track记录。