普通视图

发现新文章,点击刷新页面。
昨天以前掘金 iOS

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

2026年4月23日 18:56

我最近把一套已经在线上跑通的项目内埋点代码,抽成了一套可复用的 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。

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

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

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

  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 设计,实际上后来直接影响了后面埋点日志输出功能的业务逻辑。

我也没有直接把旧的上报方式整条推翻,这轮更稳的做法其实是:

  1. 先让 SDK 在原来已经在跑的那条上报路径旁边,并行对照一段时间
  2. 先看 SDK 组合出来的参数,和项目里原来那套上报逻辑是不是一致
  3. 确认没问题以后,再正式切到只走 SDK 这一条上报路径

这件事现在回头看也特别值得记下来。

因为复杂项目里,真正危险的不是代码抽得慢,而是你一上来就直接替换项目里正在使用的那条上报路径,这样一旦 SDK 和旧逻辑有没对齐的地方,往往要到真实验收时才会暴露出来。

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

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

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

1. distinct_idaccount_id 应该怎么传

一开始最容易掉进去的坑,是想把这两个用户标识字段设计得很“完整”,但后来对着真实项目接进去时,我才发现这不只是字段怎么传的问题。

distinct_id 这条比较清楚:

  1. distinct_id == device_id
  2. 这个值必须由接入方自己提供
  3. SDK 不再内部默认生成

真正麻烦的是 account_id

一开始我把它理解成“有就带,没有也不影响上报”,代码里也确实是这么处理的。

但真实接入时,很快就暴露出一个更具体的问题:

很多 App 都是先初始化 SDK,后面才从 Adjust SDK 异步拿到 adid

也就是说,问题不只是“account_id 要不要传”,而是:

SDK 初始化完成的时候,account_id 很可能还拿不到。

后来产品把规则也确认得更明确了:

  1. distinct_id 必须有
  2. account_id 的值就是外部拿到的 adjustid
  3. account_id 不是初始化时必须就有,但外部拿到 adjustid 后要立刻传给 SDK
  4. 传进去以后,后续标准事件上报和用户属性上报都要自动传参 account_id
  5. account_id 还要作为用户属性,再主动补报一次 user_setOnce

一开始我以为这是字段传值规则的问题,后来接入时才发现,真正麻烦的是 SDK 已经初始化好了,adjustid 却还没回来。

所以后面真正的修改方式,不是继续讨论 account_id 到底算“可选”还是“不可选”,而是把 SDK 补成初始化完成后也能继续更新 account_id

最后 SDK 对外多补充了一个明确方法:

Adjust.adid { adid in
    guard let adid, adid.isEmpty == false else { return }
    ZZHAnalyticsSDK.shared.setAccountID(adid)
}

也就是说,这件事最后真正定下来的,不是一句“account_id 可选”,而是:

  1. distinct_id 一开始就由项目传进来
  2. account_id 等 Adjust 返回后,再立刻传给 SDK
  3. 标准上报路径后续自动传参 account_id
  4. SDK 主动补一次 user_setOnce 用户属性 account_id

这比我一开始那种“先把规则写简单一点再说”的理解,要更接近真实项目接入。

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

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

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

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

  1. user_set
  2. user_setOnce

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

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

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

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

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

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

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

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

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

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

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

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

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

而且这件事后面我越看越觉得不能偷懒,因为变化的根本不只是接口的参数 time

如果你让 SDK 失败后重新组一次参数,可能一起变掉的还有:

  1. 当时的网络状态
  2. 当时的权限状态
  3. 当时的安装相关字段
  4. 那次请求真正想表达的时间点

所以后来我对这条原则的理解就更明确了:

埋点请求一旦生成,就应该尽量把它当成那一刻的快照。

4. ta_app_install 事件上报的时间后来为什么还要单独修改

这也是产品验收时发现的一个问题。

一开始我会默认觉得:

  1. 普通事件上报接口传参 time 用当前时间
  2. 这是很自然的做法

这对绝大多数事件都没问题。

ta_app_install 不一样。

因为产品验收时看的不只是传参的 time,还会一起看:

  1. #install_time
  2. install_ts_bj
  3. install_ts_utc
  4. install_ts_time

这几个时间字段本质上都应该指向同一个安装时间点。

当前项目之所以一直没出问题,是因为业务层本来就主动把安装时间传给了这条事件。

但 SDK 标准接法如果只是:

ZZHAnalyticsSDK.shared.track(eventName: "ta_app_install")

旧逻辑会直接把发起这次上报时的当前时间,写进这个事件的 time 字段。

这样一来,这个事件里的 time,和安装相关字段就不是同一个时间来源了。

这个问题特别能说明一件事:

把代码封装成 SDK,只是第一步,等产品真的开始逐个字段检查时,你才会知道还有哪些地方没处理对。

后来的修法也很克制:

  1. 普通事件还是继续用当前时间
  2. 只有 ta_app_install 且外部没传 timestamp 时,才默认改用 SDK 保存的安装时间

这件事让我后面更确定,SDK 真正难的不是“第一版怎么设计”,而是:

当产品拿着真实字段来验的时候,你能不能只改那一个真正有问题的地方,而不是顺手把整套时间逻辑都推倒。

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

如果说前面这些问题还在解决“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

而当前我这个项目,真实主路径其实更接近这一种。

也就是说,当前 SuperCoder / MQ09 不是一开始就完全走标准 track / setUserProperties,而是更多时候先在项目里把完整请求参数组装好,再交给 SDK 发送上报请求。

这也是为什么我后面会专门把这一点写进接入文档里。因为如果不把“当前项目接法”和“标准 SDK 接法”拆开讲,同事看到日志里的 params,会很容易误以为 SDK 自己把业务参数改掉了。

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

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 不是“代码抽出来”就结束了,它还要能被别人低成本接上。

而且到后面我还发现,低成本接上这件事,其实也分两层。

第一层是:

  1. README 写清楚
  2. tag 发对
  3. Pod 依赖能装上

第二层是:

  1. 同事能不能只写一个包名
  2. pod install 的时候会不会还要处理私有仓库认证

这轮我其实只把第一层基本收住了。

也就是说,SDK 代码和接入文档已经比一开始成熟很多了,但分发体验还没有完全走到最理想的形态。现在依然更接近“私有 git + tag”的方式,而不是那种更标准、更省心的私有 Specs 仓接法。

这也让我后面更确定一件事:

SDK 的工程化,不只是代码和 README,还包括分发基础设施到底有没有跟上。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

❌
❌