iOS客户端埋点的前世今生

3785次阅读  |  发布于3年以前

零、简介

本篇文章将结合狐友iOS客户端埋点的实践,给大家介绍不同的埋点实现方案及一些问题总结。

主要包括以下内容:

一、认识埋点

所谓埋点是数据领域的专业术语,也是互联网应用里面的一个俗称。埋点的学名应该是事件追踪,对应的英文是Event Tracking;它是对特定用户行为或事件进行捕获,然后进行处理、发送的技术及其实施过程的统称。

埋点分类

服务端埋点

在服务器端做数据收集,在用户请求服务器的关键业务处添加埋点。

优点:实时收集数据,数据准确、不存在延时上报问题;埋点发生在服务器端,因此埋点需求改变时只需要在服务端更改就好了,能够实时满足埋点需求变更的要求。

缺点:如果用户的行为及事件不涉及到服务端网络请求,则服务端收集不到数据;如果客户端没有联网,则服务端也收集不到数据。

客户端埋点

由手机客户端或者Web网页端收集、记录用户的行为数据并进行上报。

优点:由于客户端是直接面向用户的,所以能够比较直接、全面的收集用户的行为数据,可以收集不涉及服务器请求的数据。

缺点:收集的数据需要通过网络上传至服务器,需要处理各种网络异常情况,容易造成数据漏报;更改、新增埋点需求则需要发布新版本客户端。

经过简单的介绍,相信大家对什么是埋点有个初步的了解;下面以狐友iOS客户端埋点的实践为例,给大家介绍一下客户端埋点的实现及遇到的一些问题的解决办法。

二、客户端埋点实现

埋点实现的三板斧

目前,iOS 开发中常见的埋点方式,主要包括代码埋点、无侵入埋点和可视化埋点这三种;

在上面的埋点方式中,可视化埋点和无埋点,都属于是无侵入的埋点方案,因为它们都不需要在工程代码中写入埋点代码。所以,采用这样的无侵入埋点方案,既可以做到埋点被统一维护,又可以实现和工程代码的解耦(有好有坏、后面会有介绍)。

现在我们对客户端的埋点方式有了初步了解,下面我们以相对简单、直接的代码埋点为例进行简单实现;在着手实现之前,我们根据常见的业务需求,将埋点上报的数据按需进行分类。

埋点数据分类

埋点数据的分类应该根据实际的需求而定,但一般需要统计的数据都包括页面的曝光、页面中某个元素的曝光及用户的点击事件曝光等,所以这里我们将埋点数据进行如下分类:

PageView:页面曝光类型埋点

针对页面曝光,当APP内容从当前页面进入到新的页面时,则针对新的页面进行埋点上报;

上报时机:当进入新的页面就上报这个埋点

上报数据内容(根据实际情况,不一定每一项数据都需要上报):

pageId(当前页面id),
contentId(当前页面展示的内容id),
sourcePage(来源页面即上一个页面),
sourceClick(上一个页面的点击位置),
....

ViewElement:视图元素类型埋点

针对页面中某个视图的曝光,比如功能页的某个引导气泡、视图刷新次数、列表页的cell展示曝光等;

上报时机:

普通页面元素,上报时机为目标视图元素出现的时候;如果是列表中cell曝光,则可能是退出当前列表页、加载新数据、进入到新页面的时候上报这些数据;

上报数据内容(根据实际情况,不一定每一项数据都需要上报):

ViewElementId(当前视图id),
contentId(当前视图展示的内容id),
activityIds(展示过的每个活动id),
....

ClickPositon:点击类型埋点

这类埋点比较容易理解,就是当用户点击了某个按钮或某个视图的时候触发的埋点上报;

上报时机:发生即上报;

上报数据内容(根据实际情况,不一定每一项数据都需要上报):

ClickPositonId(放生点击按钮或者视图id),
contentId(当前视图展示的内容id),
sourcePage(当前按钮所在的页面),
sourceClick(当前按钮所在页面中的位置),
....

基础数据结构的创建

基于前面的埋点数据分类,下面通过Swift实现基础组件的搭建;我们主要处理PageView、ViewElement、ClickPosition这三类埋点的上报,结合Swift中Enum的特性,我们有下面的实现:

public enum Events {
    ///页面曝光埋点
    case pageView(key: PageKey, properties: [PageKey.Property])
    ///页面元素曝光埋点
    case viewElement(key: ViewElementKey, properties: [ViewElementKey.Property])
    ///按钮点击事件曝光埋点
    case clickPosition(key: ClickPositionKey, properties: [ClickPositionKey.Property])
}

//MARK: -PageView 页面埋点的事件及对应的参数
public extension Events {
    // 用来标记不同的页面
    enum PageKey: Int32 {
        case Other = 0
        case page1 = 1
        case page2 = 2
        // ...

        public enum Property {
            // 在前一个页面点击哪个位置进入的当前页面
            case sourceClick(id: SourceClick)
            // 从哪个页面进入的当前页面
            case sourcePage(id: SourcePage)
            case content(_ content: String)
            // ...
        }
    }
}

//MARK: -ViewElement 页面元素刷新埋点的事件及对应的参数
public extension Events {
    // 用来区分需要上报的不同的页面元素
    enum ViewElementKey: Int32 {
        case Other = 0
        case element1 = 1
        case element2 = 2
        // ...

        public enum Property {
            case feedidList(_ feedIds: [String])
            case beUids(_ ids: [String])
            case activityIds(_ ids: [String])
            // ...
        }
    }
}

//MARK: -ClickPosition 按钮点击事件的埋点及参数
public  extension Events {
    // 用来区分不同的点击位置
    enum ClickPositionKey: Int32 {
        case Other = 0
        case position1 = 1
        case position2 = 2
        // ...

        public enum Property {
            case followName(_ flowName: FlowName)
            case circleName(_ circleName: String)
            case status(_ statu: Status)
            // ...
        }
    }
}

public extension Events {
    enum SourcePage: Int32 {
        case Other = 0
        case page1 = 1
        case page2 = 2
        // ...
    }

    enum SourceClick: Int32 {
        case Other = 0
        case source1 = 1
        case source2 = 2
        // ...
    }

    enum Status: Int32 {
        case Other = 0
        /// 1
        case Success = 1
        /// 2
        case Fail = 2
    }
    // ...
}

需要上报的基础数据结构我们已经建立完成,下面我们就可以上报这些数据了,为了更加的Swift化,我们通过协议来封装我们的接口及实际上报内容的数据组成实现;

/// 埋点上报的协议
/// 遵守协议后可在遵守了该协议类型的类方法或实例方法中直接调用trackEvent(:)、或track(:)方法上报埋点
/// 这两个方法在协议的扩展内提供了默认实现
public protocol EventsProtocol {
    ///该方法已经提供默认实现,不需要遵守者自己实现, 同样的还提供了一个实例方法
    static func trackEvent(_ types: Events)

    ///该方法已经提供默认实现,不需要遵守者自己实现, 同样的还提供了一个static方法
    func track(_ event: Events)
}

public extension EventsProtocol {
    func track(_ event: Events) {
        Self.processTrack(event: event)
    }

    static func trackEvent(_ types: Events) {
        processTrack(event: types)
    }
}

private extension EventsProtocol {
    // 处理获得的上报数据
    static func processTrack(event: Events) {
        switch event {
        case let .pageView(key: pageId, properties: properties):
            // processPageViewTrack(pageId, with: properties)

        case let .viewElement(key: viewElementId, properties: properties):
            // processViewElementTrack(viewElementId, with: properties)

        case let .clickPosition(key: clickPositionId, properties: properties):
            // processClickPositionTrack(clickPositionId, with: properties)
        }
    }
}

应用举例

有了上面这些骨架,实际上我们就可以在工程中进行埋点应用了,以添加一个详情页面的PageView及ClickPosition类型埋点为例:

❝注意:在类方法和实例方法需要分别调用相对应的方法进行埋点

class DetailController: UIViewController, EventsProtocol {
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        // 上报页面曝光埋点
        track(.pageView(key: .page1, properties: [.content("某某详情页曝光了"), .sourcePage(id: .page2)]))
    }

    // 关注按钮点击
    @objc func followingButtonClick() {
        track(.clickPosition(key: .position1, properties: [.content("关注用户id:userId"), .sourcePage(id: .page1)]))
    }

    // 类方法发生埋点上报
    static func otherViewClick() {
        trackEvent(.clickPosition(id: .position1, properties: [.content("其他点击事件")]))
    }
}

现在有一个问题,在我们的PageView类型埋点中,需要上报的参数sourcePage(来源页面)是需要从前一个页面传递过来的,类似这种需要从前一个页面传递到当前页面,从而上报的数据是一个我们需要解决的问题;为此,我们设计了新的通过单向链表结构传递数据的方式,来解决这个问题。

通过单向链表结构传递数据

基本实现

从前一个页面向当前页面传递数据办法有很多,比如在初始化方法中添加传递数据的参数、单独增加一个字典属性用来传递参数等等办法。

下面给大家介绍一种通过单向链表式结构传递数据的方式TrackChain;

图片思路:如上图所示,我们创建一个Chain,它有一个property用来存储需要传递到下一个页面的属性,同时有一个parentChain也是一个Chain用来指向parent,以便在需要的时候获得parentChainproperty

实现代码如下:

class Chain: NSObject {
    var property: [AnyHashable: Any]?
    var parent: Chain?

    init(property: [AnyHashable: Any]? = nil, parent: Chain? = nil) {
        self.property = property
        self.parent = parent
    }

    func add(property: [AnyHashable: Any]) {
        guard var _ = self.property else {
            self.property = property
            return
        }
        for (key,value) in property {
            self.property?.updateValue(value, forKey: key)
        }
    }
}

extension Chain {
    static var responsibilityChainTable = [ObjectIdentifier: Chain]()
}

有了我们的数据链表,下面我们对链表进行管理:

protocol Trackable : class {   
}

extension Trackable {
// 初始化Chain的方法,每个页面的每个id对应一个chain,存储在 responsibilityChainTable 中
   func setupTrackableChain(trackedProperties: [AnyHashable: Any] = [:], parent: Trackable? = nil) {

       let setupClosure: () -> Void = {
           var parentChain: Chain? = nil
           if let parentId = parent?.uniqueIdentifier,
              let chain = Chain.responsibilityChainTable[parentId] {
               parentChain = chain
           }

           let identifier = self.uniqueIdentifier
           // 已经存在更新原有的,没有则创建新的并进行存储
           if let existedChain = Chain.responsibilityChainTable[identifier] {
               existedChain.parent = parentChain
               existedChain.add(property: trackedProperties)
           }else {
               let newChain = Chain(property: trackedProperties, parent: parentChain)
               Chain.responsibilityChainTable[identifier] = newChain
           }
       }

       if Thread.isMainThread {
           setupClosure()
       } else {
           DispatchQueue.main.async {
               setupClosure()
           }
       }
   }
   // 向已有的chain中追加属性,如果不存当前id不存在对应的chain,则会顺便创建新的chain
   func add(trackable properties: [AnyHashable: Any]) {
       let identifier = self.uniqueIdentifier
       // 已经存在更新原有的,没有则创建新的并进行存储
       if let existedChain = Chain.responsibilityChainTable[identifier] {
           existedChain.add(property: properties)
       }else {
           let newChain = Chain(property: properties, parent: nil)
           Chain.responsibilityChainTable[identifier] = newChain
       }
   }
   // 从当前chain 的 parentChain 中获取从父向子传递的 property
   func getPropertyFromParentChain(for key: AnyHashable) -> Any?{
       let identifier = self.uniqueIdentifier
       guard let existedChain = Chain.responsibilityChainTable[identifier] else {
           return nil
       }
       return existedChain.parent?.property?[key]
   }

   // 必要时候清理关联数据
// 比如页面销毁的时候需要清理掉当前页面存储在 responsibilityChainTable 中的数据
   func cleanTrackable() {
       let identifier = self.uniqueIdentifier
       guard let existedChain = Chain.responsibilityChainTable[identifier] else {
           return
       }
       existedChain.parent = nil
       existedChain.property = nil
       Chain.responsibilityChainTable.removeValue(forKey: identifier)
   }
}

extension Trackable {
// 给遵守Trackable的类默认生成唯一标识id
   var uniqueIdentifier: ObjectIdentifier {
       return ObjectIdentifier(self)
   }
}

应用举例

下面就是具体的应用了,重新回到我们的页面曝光埋点我们进行如下更改:

class ListController: UIViewController {
    deinit {
        // 当控制器销毁后,清理存储在 responsibilityChainTable 中的数据
        cleanTrackable()
    }
    func pushToDetailController() {
        // 添加属性到current chain
        add(trackable: ["sourcePage": "sourcePage1"])

        let detail = DetailController()
        // 初始 detail chain  并赋值
        detail.setupTrackableChain(parent: self)

        navigationController?.pushViewController(detail, animated: true)
    }
}
// 遵守协议
extension ListController: Trackable { }

class DetailController: UIViewController, EventsProtocol {
    deinit {
        // 当控制器销毁后,清理存储在 responsibilityChainTable 中的数据
        cleanTrackable()
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)

        let sourcePage = getPropertyFromParentChain(for: "sourcePage")
        // 上报页面曝光埋点
        track(.pageView(key: .page1, properties: [.content("\(sourcePage)"), .sourcePage(id: .page2)]))
    }

    // 关注按钮点击
    @objc func followingButtonClick() {
        track(.clickPosition(key: .position1, properties: [.content("关注用户id:userId"), .sourcePage(id: .page1)]))
    }
}

上面只是简单的实现,更进一步可以将Chain.property中存储的数据和埋点需要上报的参数类型关联起来,这样存取属性的时候会更加的方便;这里只是提供一种页面间传递数据的思路供大家参考,还需要具体情况具体分析;

遇到的问题

理想是丰满的,现实是骨感的,我们设计了单向链表Chain来解决数据的跨页面传递问题,但是在实际的应用中发现了一些问题,下面就遇到的问题简单举例:

1、复杂页面中存储数据到对应的Chain中的问题

假设页面A页面B传递数据,整个过程如下:

如果一切按照预想的话,整个流程如上所述;

但是当我们的页面A是一个比较复杂的页面,且需要存储到ChainA中的数据发生在不同子视图时,这时候我们一定要注意不同子视图调用的ChainA一定要是同一个(即页面A本身创建的ChainA),不然容易造成数据的缺失且问题不容易排查 。

2、页面销毁时数据清理问题,即调用cleanTrackable()的问题

为了清理数据,我们需要在页面销毁的时候需要调用cleanTrackable()方法,通过当前页面的uniqueIdentifier清理responsibilityChainTable中存储的对应的数据;

续接上面的问题,当页面A的子视图销毁时不能销毁页面A对应的ChainA,只有在页面A销毁时,才能去销毁对应的ChainA。如果不注意这里容易产生问题,特别是页面A存在ChildViewController的情况下。

经过上面的步骤之后,我以代码埋点的方式实现了埋点需求。但在我们满足了产品的需求后,我们可能发现我们自己并没有得到满足,因为听说还有无侵入埋点(全埋点)可视化埋点

三、无侵入埋点的实现探究

运行时方法替换方式实现无侵入埋点

无侵入埋点的实现思路,就是通过运行时方式Hook关键方法,在被我们Hook的方法中,添加我们原本应该写在业务代码中的埋点代码。

前面我们已经了解了我们的埋点需求,大多数埋点都会被添加在固定的方法中,这些固定的方法就是我们Hook处理的目标。

下面我们先写一个运行时替换方法的工具类,以便于我们用来Hook目标方法:

#import "MyHook.h"
#import <objc/runtime.h>

@implementation MyHook
+ (void)hookClass:(Class)classObject fromSelector:(SEL)fromSelector toSelector:(SEL)toSelector {
    Class class = classObject;
    // 得到被替换类的实例方法
    Method fromMethod = class_getInstanceMethod(class, fromSelector);
    // 得到替换类的实例方法
    Method toMethod = class_getInstanceMethod(class, toSelect or);

    // class_addMethod 返回成功表示被替换的方法没实现,然后会通过 class_addMethod 方法先实现;
    // 返回失败则表示被替换方法已存在,可以直接进行 IMP 指针交换
    if(class_addMethod(class, fromSelector, method_getImplementation(toMethod), method_getTypeEncoding(toMethod))) {
      // 进行方法的替换
        class_replaceMethod(class, toSelector, method_getImplementation(fromMethod), method_getTypeEncoding(fromMethod));
    } else {
      // 交换 IMP 指针
        method_exchangeImplementations(fromMethod, toMethod);
    }
}

@end

页面曝光无侵入埋点实现

前面我们在UIViewControllerviewWillAppear:方法中添加页面曝光埋点,现在我们做无侵入埋点;

首先我们Hook UIViewControllerviewWillAppear:方法,然后在我们的hook_viewWillAppear:方法中进行埋点上报;

@implementation UIViewController (report)
+ (void)load {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        // 通过 @selector 获得被替换和替换方法的 SEL
        // 作为 MyHook:hookClass:fromeSelector:toSelector 的参数传入
        SEL fromSelectorAppear = @selector(viewWillAppear:);
        SEL toSelectorAppear = @selector(hook_viewWillAppear:);
        [MyHook hookClass:self fromSelector:fromSelectorAppear toSelector:toSelectorAppear];
    });
}

- (void)hook_viewWillAppear:(BOOL)animated {
    // 先执行插入代码,再执行原 viewWillAppear 方法
    [self reportViewWillAppear];
    [self hook_viewWillAppear:animated];
}

- (void)reportViewWillAppear {
  // 识别具体controller 进行pageView的埋点上报
}

@end

上面就是我们无侵入方式上报PageView类型埋点的实现;

点击类型事件的无侵入埋点实现

按钮点击、cell点击、视图点击等ClickPosition类型的埋点我们也可以通过Hook方法来实现无侵入埋点;

同样的思路我们可以对以下方法进行Hook处理(部分方法,更多可以参考DiDiPrism的实现):

@implementation UIControl (report)
+ (void)load {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        [MyHook hookClass:[self class] fromSelector:@selector(sendAction:to:forEvent:) toSelector:@selector(hook_sendAction:to:forEvent:)];
    });
}
@end

@implementation UIView (report)
+ (void)load {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        [MyHook hookClass:[self class] fromSelector:@selector(touchesEnded:withEvent:) toSelector:@selector(hook_touchesEnded:withEvent:)];
    });
}
@end

上面我们通过Hook方法完成对应的埋点事件上报,除了 UIViewController、UIButton 控件以外,Cocoa 框架的其他控件都可以使用这种方法来进行无侵入埋点。以 Cocoa 框架中最复杂的 UITableView 控件为例,可以使用 hook setDelegate 方法来实现无侵入埋点。另外,对于 Cocoa 框架中的手势事件(Gesture Event),我们也可以通过 hook initWithTarget:action: 方法来实现无侵入埋点;

无侵入埋点的实际应用

我们已经了解到无侵入埋点的实现思路,现在我们尝试用无侵入埋点替换我们的全代码埋点;

以PageView类型埋点为例,我们需要Hook特定方法,然后在Hook方法中进行埋点上报。

Hook方法除了用上面介绍过的自己实现外,还可以通过成熟的三方库来实现,这里我们通过Aspects完成Hook工作:

@implementation AspectHandle
- (void)setup {
    [[UIViewController class]
     aspect_hookSelector: @selector(viewWillAppear:)
     withOptions: AspectPositionBefore
     usingBlock: ^(id<AspectInfo> aspectInfo) {
        id controller = aspectInfo.instance;
        id arguments = aspectInfo.arguments;
        // 1 更进一步处理

    } error:NULL];
}
@end

目标方法Hook完成后,我们就可以在1处做进一步的识别对象、上报数据等处理了。Aspects能Hook系统方法外,也同样能Hook开发者自定义的方法,具体方法同上;

除此之外Aspects也能Hook Swift方法,但如果是自定义的Swift类及方法需要做一些特别的处理:

上面就是Hook自定义Swift方法时的一些注意事项,就不做代码举例了;

经过一段时间的实践后,发现无侵入埋点远没有想象中的那么完美,下面就无侵入埋点实践的过程中碰到的问题做一个简单的总结;

实践过程中遇到的问题

1、怎样去识别当前控制器是哪一个控制器,以便上报不同的PageId?

答: 在Hook方法中,我们可以获取到当前的控制器对象,但是识别出当前控制器具体是哪个业务控制器是一个重点及难点,因为我们需要根据不同的业务控制器上报不同的埋点数据;

现在业界比较成熟的做法是,按照一定规则生成一个尽可能唯一的标识来识别控制器,比如我们可以按照下面的策略生成一个控制器的标识:

id = navigationController类名(nullable)+parentController类名(nullable)+controller类名+title

最终每个控制器都会有一个类似的ID:

id = "BaseNavigationController_DetailController_用户详情页"

根据上面的办法我们应该能识别绝大多数的控制器页面,如果有特殊的情况不能识别的我们可能需要进一步的完善我们的标识生成策略,以便让最终的id尽可能的唯一;

2、怎样去识别是哪个按钮或者视图?

答:解决这个问题的方案同前面识别控制器的方案类似,同样的我们需要给Button等视图生成一个能够识别的唯一标识,现在比较成熟的方案通过获得视图所处的树形结构及本身属性等生成标识:

id = UIWindow_SuperSuperView···_SuperView_brotherCount_currentViewIndex_action选择器名_视图类名_title

3、怎样获得和业务相关的上报参数?比如DetailController上报PageView的埋点时,同时需要上报 sourcePage等参数;

答:目前来说这类问题在无侵入埋点方案中没有好的解决办法,这里我们通过借助前面介绍的跨页面传递参数Chain机制来协助解决这个问题。

我们将需要的参数存储到Chain中,然后在hook_viewWillAppear方法中取出;

class ListController: UIViewController {
    func pushToDetailController() {
        // 添加属性到current chain
        add(trackable: ["sourcePage": "sourcePage1"])
        let detail = DetailController()
        // 初始 detail chain  并赋值
        detail.setupTrackableChain(parent: self)
        navigationController?.pushViewController(detail, animated: true)
    }
}

....
func hook_ViewWillAppear {
  // 根据标识,识别具体controller获得pageid

  // 通过chain 获得detailId
   detailId = getPropertyFromParentChain(for: "获得detailId")
   track(.pageView(key: pageid, properties: [.content("\(detailId)"), .sourcePage(id: .page2)]))
}

上面是一些伪代码,主要是给大家提供一个解决问题的思路。虽然问题得到了解决,但是现在也已经不能算是无侵入埋点了,因为需要我们在必要的地方插入代码进行数据的传递;

4、如果我们Hook了自定义的方法,那么如果这个方法名字被更改了怎么办?

这类问题在实践过程中,没有找到一个能一定解决或者避免的办法,但是可以通过下面的方法做到尽量避免:

5、无侵入埋点开发过程中,多名开发同学之间怎样相互配合?

这类问题的解决也没有一定之规,只能是去尽量避免,下面是一些小的建议:

6、无侵入埋点方案能够完全满足我们实际的埋点需求吗?

在实际的开发过程中我们会发现,埋点需求的复杂度会随着业务复杂度的增加而增加,就可能会碰到无侵入埋点方案无法解决的问题。这时我们逼不得已就会做一些妥协,在代码中直接插入埋点代码,或者插入一些辅助代码,无侵入埋点在这个时候就破功了。

五、总结

恭喜,文章到此结束

以上就是狐友iOS客户端埋点功能的实现及一些问题的总结,希望能够给同样有埋点需求的同学一些启发和借鉴。

希望阅读完本篇文章后,您能够对埋点概念及分类有初步的了解、对客户端埋点的不同实现方案有了较深入的认识;

客户端埋点实现方案有多种,每种方案各有利弊;在选择实现方案时需要根据实际情况、结合需求综合判断,没有一定之规。

在合适的位置用合适的办法,道路千万条、适用第一条。

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8