一个 iOS 埋点 SDK 从 0 到 1,再到真实项目接入打磨

17 阅读16分钟

我最近把一套已经在线上跑通的项目内埋点代码,抽成了一套可复用的 iOS SDK。原本我以为难点只是“把代码搬出去”,后来才发现,真正难的是哪些能力该留在 SDK、埋点上报发送请求到底要不要sdk来接管、日志怎么做才方便进行测试验证、文档和版本号怎么跟上。这篇文章分享的是这套 SDK 被真实接入反馈一步步打磨出来的过程。


最开始我以为,做这套埋点 SDK,就是把项目里那套已经跑通的代码抽出来就可以了,后来发现没那么简单。

真正麻烦的地方,是代码抽出去之后才出现的:

  1. 哪些能力应该放进SDK
  2. 哪些逻辑必须留在业务项目
  3. SDK 要不要负责埋点上报发送请求
  4. 日志到底是给开发看,还是给测试和产品验收使用
  5. 文档和版本号没跟上时,同事会不会直接集成失败

因为我后来确定了一件事:把埋点代码抽成 SDK,难点从来不是“把代码搬出去”,而是让它在接入、调试、验证、埋点上报这些环节里都真的可用。

一、为什么我会做这个 SDK

起因其实很简单,我手上有一个已经在线上跑通的 iOS APP 项目,里面已经有一套比较完整的埋点能力:

  1. 自动采集公共事件属性
  2. 自动补一组固定用户属性
  3. 统一时间格式
  4. 固定首次安装时间和安装时区
  5. 构建事件请求
  6. 构建用户属性请求
  7. 埋点上报发送请求
  8. 失败后自动重试
  9. 打调试日志

这套东西在项目里和线上使用都能正常工作,于是领导就给我提出一个需求:

让我封装一个埋点sdk,把固定用户属性和公共事件属性都封装在sdk中,让sdk内部自动获取这些属性值,iOS同事在其他项目中进行埋点上报的时候,就不需要再单独写一套固定用户属性和公共事件属性上报的代码了,直接使用sdk的能力就可以了。

所以这次的目标是把这些已经被项目验证过的能力,封装成一个其他 App 项目也能对接的 SDK。

这也是我这次封装 SDK 时反复确认的一件事:在项目里能跑通的埋点代码,不一定适合直接变成 SDK,为项目内代码可以依赖当前业务场景、历史逻辑和约定写法,但 SDK 面向的是其他项目,接入方不应该先理解原项目的背景,才能用好它。

二、原来那套代码为什么不适合直接复用

我最开始手上拥有的,是一套项目里我写好的埋点管理代码。

这种埋点管理类在业务项目里很常见,一开始也确实好用,因为它把所有事都接住了:

  1. 事件名
  2. 公共属性
  3. 用户属性
  4. 时间格式
  5. 请求参数构建
  6. 请求发送
  7. 失败重试
  8. 日志它

以上8点它全部都管。截一小段原来的调用入口,就能看出这种写法的特点:

func track(_ eventName: SC_MQ09EventName,
           properties: [String: Any] = [:],
           timestamp: Date? = nil) {
    let resolvedTimestamp = timestamp ?? Date()
    var payload = buildEventPayload(
        eventName: eventName,
        properties: properties,
        timestamp: resolvedTimestamp
    )

    guard JSONSerialization.isValidJSONObject(payload) else {
        SuperCoderNetLog("[MQ09] invalid payload for \(eventName.rawValue): \(payload)")
        return
    }

    do {
        let data = try JSONSerialization.data(withJSONObject: payload, options: [])
        routeEventPayload(
            payload: payload,
            payloadData: data,
            allowRetryStore: true,
            eventName: eventName.rawValue
        )
    } catch {
        SuperCoderNetLog("[MQ09] encode failed: \(error.localizedDescription)")
    }
}

这段代码本身没有错。问题在于,它已经同时在做几件事:决定事件时间、构建请求参数、校验 JSON、准备失败重试需要的数据、再把埋点上报请求发送出去。

在一个项目里,这样写能很快推进,但一旦你想复用到别的项目,就会发现它太像“项目现场代码”,而不是一层可以被其他 App 直接依赖的通用能力。

这种写法在“单项目快速推进”阶段没问题,但一旦你想跨项目复用,它马上就会暴露两个大问题。

第一,职责太杂。

它既有通用能力,又有业务语义。比如某些页面事件、某些业务字段、某些页面触发时机,这些本来只属于当前项目,但也被混进了同一层埋点管理代码。

第二,边界不清。

你很难回答一个问题:

到底哪些是“埋点 SDK 应该负责的”,哪些只是“当前这个业务项目碰巧这么写了”。

这也是我后来感受最强的一点:

项目里能跑通,不代表它已经具备跨项目复用条件。

三、我怎么划 SDK 和业务项目的边界

真正开始封装 SDK 之后,我先做的不是写代码,而是先把边界想清楚。

我想清楚了以下3点:

1. 必须放进 SDK 的,是稳定的基础能力

比如这些:

  1. 公共事件属性采集
  2. 固定用户属性采集
  3. 时间格式统一
  4. 安装时间与安装时区
  5. 事件请求参数构建
  6. 用户属性请求参数构建
  7. 可选的埋点上报发送能力
  8. 失败重试
  9. 日志输出

这些东西不依赖某个具体页面,也不属于某个特定业务,很适合收进 SDK。

2. 必须留在业务项目里的,是具体业务逻辑

比如:

  1. 某个页面的事件名
  2. 某个业务字段怎么算
  3. 哪个时机触发埋点
  4. 哪组字段是这个业务独有的

这部分如果硬塞进 SDK,SDK 很快就会变成“这个项目专用库”,复用价值就没了。

3. 埋点上报发送能力必须做成可选

我同事跟我说,一般SDK能够发送请求上报埋点,他们都会选择直接用SDK上报,但我在做的过程中,还是觉得做成可选吧,因为不一定所有项目都愿意把网络请求交给 SDK。

有的项目想要的是:

  1. SDK 帮我构建参数
  2. 我自己发请求

有的项目则希望:

  1. SDK 帮我构建参数
  2. SDK 直接把请求也发了

所以我最后没有把发送写死,而是保留了两条路:

  1. 标准 SDK 接法:直接 track / setUserProperties
  2. 直接发送完整请求参数:项目先把所有参数组合好,再交给 SDK 发

标准接法的入口最后被压得很薄:

public func track(
    eventName: String,
    properties: [String: Any] = [:],
    timestamp: Date? = nil,
    eventType: String = "track"
) {
    let rawParams = ZZHAnalyticsJSONSanitizer.dictionary(properties)
    let payload = makeEventPayload(
        eventName: eventName,
        properties: rawParams,
        timestamp: timestamp,
        eventType: eventType
    )
    sendPayloadIfPossible(
        payload,
        endpointType: .event,
        startLogContext: .event(eventName: eventName, params: rawParams)
    )
}

业务方只需要告诉 SDK:我要发哪个事件,带哪些业务参数。至于公共字段怎么补、时间怎么格式化、请求怎么发、日志怎么打,都留在 SDK 里面处理。

这个决定看起来只是 API 设计,实际上后来直接影响了后面埋点日志输出功能的业务逻辑。

四、真正让这个 SDK 变难的,不是封装,而是真实项目接入后的反馈

如果这套 SDK 只停留在“我本地能跑”,其实没什么特别值得写的。

真正让它有工程价值的,是iOS同事在他的项目中接入时遇到的SDK出现的各种问题。

1. distinct_idaccount_id 应该怎么传

一开始最容易掉进去的坑,是想把这两个用户标识字段设计得很“完整”。

但后来对着真实接口和真实项目去看,反而发现规则应该更简单:

  1. distinct_id 必须有
  2. account_id 可选
  3. 只要两者不同时为空即可

这件事表面上像字段规则,实际上影响很大。因为一旦你把这两个字段的传值规则设计复杂了,SDK 就会越来越重,接入方也会越来越难理解。

所以后来我把规则收得很死:

  1. distinct_id == device_id并且由接入方自己提供,SDK 不再默认生成
  2. account_id 有就带,没有也不会影响埋点上报

2. 用户属性更新方式为什么要收口成枚举

一开始用户属性更新方式可以传字符串,这在设计上很灵活,但真实接入时很容易变成:

  1. 表面看起来统一
  2. 实际每个项目都可能传不同字符串
  3. 最后 SDK 很难保证大家传的是同一套规则

所以后来我只保留了两种明确的写法:

  1. user_set
  2. user_setOnce

这个改动看起来不大,但它背后的意思是:

SDK 不是只负责“让你能传”,还要尽量避免接入方传错、传乱。

3. 失败重试为什么不能只停在当前进程内

一开始 SDK 只有自动重试 2 次。

这对临时网络失败来说够用,但接入方很快会问一个问题:

如果这次重试两次都失败,下次 App 重启以后怎么办?

这个问题一出来,我就知道,这不再只是“重试几次”的问题,而是“第一次生成的请求内容要不要保存下来”的问题。

因为一旦你要支持 App 重启后继续重发,就意味着:

  1. 这次请求的 bodyData 不能丢
  2. 不能下次再重新组合一遍参数
  3. 否则字段和时间可能会和第一次不一致

所以后来这一块的核心原则就变成:

重试永远基于第一次生成的请求内容,而不是重新构建请求参数。

这也是我觉得很值得写出来的一条工程经验。

很多人会默认“失败了再组合一次参数”,但埋点这种东西,真这么做,最后发出去的内容就可能和第一次不一样了。

五、埋点日志系统是怎么一步一步优化和完善的

如果说前面这些问题还在解决“SDK 能不能用”,那埋点日志系统解决的是另一件事:

SDK 能不能帮助测试和产品快速确认埋点参数有没有传对。

1. 最开始的日志,其实只对 SDK 开发者有用

最开始 SDK 里的日志更像网络请求调试日志:

  1. 打印发送过程
  2. 打印状态码
  3. 打印成功失败

但这类日志有个问题:

做 SDK 的人能看懂,测试拿去检查埋点参数就很难受。

因为测试真正关心的不是网络请求内部过程,而是:

  1. 这次到底发到哪个 URL
  2. 请求头是什么
  3. 请求参数是什么
  4. 服务端响应了什么
  5. 到底成功还是失败

所以后来日志被拆成了两类:

  1. 发起日志
  2. 结果日志

代码里也尽量保持这个拆分方式:

public func send(snapshot: ZZHAnalyticsRequestSnapshot,
                 completion: @escaping (Bool) -> Void) {
    var request = URLRequest(url: snapshot.url)
    request.httpMethod = "POST"
    request.httpBody = snapshot.bodyData

    #if DEBUG
    ZZHAnalyticsDebugStartLog(Self.startLog(for: snapshot), snapshot: snapshot)
    #endif

    URLSession.shared.dataTask(with: request) { data, response, error in
        if let error {
            #if DEBUG
            ZZHAnalyticsDebugLog(Self.failureLog(for: snapshot, error: error))
            #endif
            completion(false)
            return
        }

        guard let httpResponse = response as? HTTPURLResponse,
              (200...299).contains(httpResponse.statusCode) else {
            #if DEBUG
            ZZHAnalyticsDebugLog(
                Self.responseLog(for: snapshot, response: response, data: data, success: false)
            )
            #endif
            completion(false)
            return
        }

        #if DEBUG
        ZZHAnalyticsDebugLog(
            Self.responseLog(for: snapshot, response: httpResponse, data: data, success: true)
        )
        #endif
        completion(true)
    }.resume()
}

这段代码的重点,不是“加了几行日志”,而是把日志按使用场景拆开:请求发出去之前,先打印一条发起日志,让人能看到这次准备发什么;请求回来之后,再打印一条结果日志,让人能看到这次到底有没有成功。

2. 发起日志解决“准备发什么”的问题

发起日志最终打印的是:

  1. 时间
  2. 事件名或者用户属性更新方式
  3. URL
  4. Headers
  5. Params

它解决的问题很明确:

它能够让开发者、产品、测试清晰地知道埋点是否有正确发起上报。

3. 结果日志解决“最终发得对不对”的问题

结果日志则继续保留:

  1. URL
  2. Headers
  3. Params
  4. StatusCode
  5. Response
  6. Success

它解决的是另一层问题:

埋点上报请求最终到底成功没有,服务端返回了什么。

4. 为什么后来还要加埋点日志系统的代理方法

做到这里,我以为已经完成任务了。

后来同事那边又提了一个很真实的诉求:

他们项目里本来就有一个悬浮日志窗口,测试和产品会直接在 App 里看埋点日志,如果 SDK 内部只把日志打到 Xcode 控制台,对他们来说是远远不够的。

这时我才意识到,日志不只是“打印出来”,还得“送出去”。

于是后来又补了一层日志代理:

  1. SDK 在 Xcode 打什么
  2. 代理方法就原样返回什么
  3. 接入方拿到以后,直接塞进自己的日志窗口

最终对外暴露的协议是这样的:

public protocol ZZHAnalyticsLogDelegate: AnyObject {
    func analyticsSDK(
        _ sdk: ZZHAnalyticsSDK,
        didReceiveEventStartLog message: String,
        eventName: String,
        params: [String: Any]
    )

    func analyticsSDK(
        _ sdk: ZZHAnalyticsSDK,
        didReceiveUserPropertyStartLog message: String,
        updateType: String,
        params: [String: Any]
    )

    func analyticsSDK(_ sdk: ZZHAnalyticsSDK,
                      didReceiveEventResultLog message: String)

    func analyticsSDK(_ sdk: ZZHAnalyticsSDK,
                      didReceiveUserPropertyResultLog message: String)
}

这个代理方法不复杂,但它把两件事拆清楚了:一边是 SDK 原样日志 message,另一边是业务自己可能想再打印一条简洁日志用的 eventName / updateType / params

这一层能力看起来很小,但它把日志从“开发调试工具”变成了“测试和产品也能直接用的快速确认埋点参数有没有传对的工具”。

做到这一步,我自己的总结是:

很多 SDK 日志的问题,是日志只对 SDK 开发者有用,对测试和产品没有用。

六、同一个 params,在不同接法下代表的内容不一样

这是我在真实项目接入时遇到的一个具体问题。

一开始我把发起日志代理设计成:

  1. message:完整日志原文
  2. params:给接入方自己打印一条简洁发起日志

看起来很合理,但接入后我发现,同样叫 params,在不同接法下代表的内容并不一样。

1. 标准 SDK 接法

如果你走的是:

track(eventName:properties:)
setUserProperties(...)

params 很好理解,就是业务方最开始传进来的参数。

2. 直接发送完整请求参数

如果你走的是:

sendEventPayloadSnapshot(payload)
sendUserPropertyPayloadSnapshot(payload)

那 SDK 拿到的已经是完整的请求参数了。

这时 SDK 内部根本没法再判断:

  1. 哪些是页面最开始传进来的业务参数
  2. 哪些是 SDK 自动获取的公共、固定字段

所以这时发起日志里的 params,默认只能是请求参数里现成的 properties

这个区别后来我专门写进了使用文档中,以免其他同事理解有误。

3. 固定用户属性自动补发又是一个特例

后面又出现了第三种情况。

SDK 会自动补发一组固定用户属性,比如:

  1. country
  2. install_ts_bj
  3. install_ts_utc
  4. install_ts_time
  5. install_ts_time_timezone

这组属性不是业务方手动传的,但测试又希望在发起日志里直接看到它们。

所以最后我又单独给它做了一个特例:

  1. 普通发起日志:params 继续代表外部原始入参
  2. 固定用户属性自动补发:params 特例代表 SDK 这次自动补发的固定字段

这件事说明了一个问题:

同一个参数名,看起来一样,但在不同使用方式下,代表的内容可能不一样。

这也是 SDK 设计里很容易忽略、但真实接入时很容易暴露的问题。

七、文档、tag、Pod 接入,为什么也是 SDK 工程的一部分

如果只看代码,这套 SDK 其实已经“能用了”。

但我觉得,真正决定它能不能被其他同事真正的在项目中使用,不只是代码。

还有以下这些东西:

  1. 使用文档写得是不是够直白
  2. 示例代码是不是和真实接法一致
  3. 版本号和 tag 有没有同步
  4. 其他同事复制文档接入时,会不会直接编译报错

我这轮就真实踩到了几个这样的坑:

  1. 使用文档写了新能力,但 tag 还是旧版本,同事一装就报错
  2. 使用文档里某些词太偏内部表达,比如“快照模式”,接入同事不好理解
  3. 日志代理示例里,类型写法稍微不直白,同事就可能写成错误的双层类型名

这些问题不容小觑,是 SDK 工程化本身的一部分。

因为对接入方来说,他们真正关心的是:

  1. 我怎么接
  2. 我怎么调
  3. 我怎么验
  4. 我装下来的这个版本,到底是不是文档里写的那个版本

所以,SDK 不是“代码抽出来”就结束了,它还要能被别人低成本接上。

八、这轮工作最后让我确定的几条原则

最后,总结 6 个我这次做 SDK 后真正踩出来的经验。

1. 项目里能跑通的代码,不一定适合直接做成 SDK

很多时候,项目里的代码只是刚好满足当前业务,想让其他项目也能用,还需要重新拆清楚:哪些放进 SDK,哪些留在项目里。

2. 发送能力最好可选,不要默认 SDK 接管一切

不是所有项目都希望 SDK 直接发请求。让 SDK 同时支持“构建参数”和“直接发送”,接入成本会小很多。

3. 重试一定要基于第一次生成的请求内容

埋点怕的不是失败,而是失败后重新组参数,导致最后发出去的内容和第一次不一样。只要涉及重试,就尽量保存第一次生成的请求内容。

4. 日志要方便测试和产品检查参数,而不只是方便 SDK 开发者调试

对 SDK 开发者好用,不代表对测试好用。日志里能不能一眼看到 URL、参数、响应和成功、失败,才决定这套日志有没有价值。

5. 文档、版本号和 Pod 接入方式,也要一起维护

文档不准、tag 不同步、示例代码不对,都会让接入方直接踩坑。SDK 想让别人顺利接入,就不能只管代码。

6. SDK 是靠真实接入反馈一点点打磨出来的

我这轮最大的感受就是这个,很多一开始觉得“设计得挺好”的地方,最后都是在真实项目接入、同事反馈和测试检查参数时,才暴露出问题。

一个埋点 SDK 真正的完成度,不取决于它能不能发请求,而取决于它能不能被其他项目低成本接入、被测试高效验参、被版本稳定发布。

这可能也是我这轮工作里,最值得留下来的那部分。