我最近把一套已经在线上跑通的项目内埋点代码,抽成了一套可复用的 iOS SDK。原本我以为难点只是“把代码搬出去”,后来才发现,真正难的是哪些能力该留在 SDK、埋点上报发送请求到底要不要sdk来接管、日志怎么做才方便进行测试验证、文档和版本号怎么跟上。这篇文章分享的是这套 SDK 被真实接入反馈一步步打磨出来的过程。
最开始我以为,做这套埋点 SDK,就是把项目里那套已经跑通的代码抽出来就可以了,后来发现没那么简单。
真正麻烦的地方,是代码抽出去之后才出现的:
- 哪些能力应该放进SDK
- 哪些逻辑必须留在业务项目
- SDK 要不要负责埋点上报发送请求
- 日志到底是给开发看,还是给测试和产品验收使用
- 文档和版本号没跟上时,同事会不会直接集成失败
因为我后来确定了一件事:把埋点代码抽成 SDK,难点从来不是“把代码搬出去”,而是让它在接入、调试、验证、埋点上报这些环节里都真的可用。
一、为什么我会做这个 SDK
起因其实很简单,我手上有一个已经在线上跑通的 iOS APP 项目,里面已经有一套比较完整的埋点能力:
- 自动采集公共事件属性
- 自动补一组固定用户属性
- 统一时间格式
- 固定首次安装时间和安装时区
- 构建事件请求
- 构建用户属性请求
- 埋点上报发送请求
- 失败后自动重试
- 打调试日志
于是,领导就给我提出一个需求:让我封装一个埋点sdk,把固定用户属性和公共事件属性都封装在sdk中,让sdk内部自动获取这些属性值,iOS同事在其他项目中进行埋点上报的时候,就不需要再单独写一套固定用户属性和公共事件属性上报的代码了,直接使用sdk的能力就可以了。
所以,这次的目标很明确,是把这些已经被项目验证过的能力,封装成一个其他 App 项目也能对接的 SDK。
二、原来那套代码为什么不适合直接复用
我最开始手上拥有的,是一套项目里我写好的埋点管理代码。
这种埋点管理类在业务项目里很常见,一开始也确实好用,因为它把所有事都接住了:
- 事件名
- 公共属性
- 用户属性
- 时间格式
- 请求参数构建
- 请求发送
- 失败重试
- 日志它
以上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 的,是稳定的基础能力
比如这些:
- 公共事件属性采集
- 固定用户属性采集
- 时间格式统一
- 安装时间与安装时区
- 事件请求参数构建
- 用户属性请求参数构建
- 可选的埋点上报发送能力
- 失败重试
- 日志输出
这些东西不依赖某个具体页面,也不属于某个特定业务,很适合收进 SDK。
2. 必须留在业务项目里的,是具体业务逻辑
比如:
- 某个页面的事件名
- 某个业务字段怎么算
- 哪个时机触发埋点
- 哪组字段是这个业务独有的
这部分如果硬塞进 SDK,SDK 很快就会变成“这个项目专用库”,复用价值就没了。
3. 埋点上报发送能力必须做成可选
我同事跟我说,一般SDK能够发送请求上报埋点,他们都会选择直接用SDK上报,但我在做的过程中,还是觉得做成可选吧,因为不一定所有项目都愿意把网络请求交给 SDK。
有的项目想要的是:
- SDK 帮我构建参数
- 我自己发请求
有的项目则希望:
- SDK 帮我构建参数
- SDK 直接把请求也发了
所以我最后没有把发送写死,而是保留了两条路:
- 标准 SDK 接法:直接
track / setUserProperties
- 直接发送完整请求参数:项目先把所有参数组合好,再交给 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 组合出来的参数,和项目里原来那套上报逻辑是不是一致
- 确认没问题以后,再正式切到只走 SDK 这一条上报路径
这件事现在回头看也特别值得记下来。
因为复杂项目里,真正危险的不是代码抽得慢,而是你一上来就直接替换项目里正在使用的那条上报路径,这样一旦 SDK 和旧逻辑有没对齐的地方,往往要到真实验收时才会暴露出来。
四、真正让这个 SDK 变难的,不是封装,而是真实项目接入后的反馈
如果这套 SDK 只停留在“我本地能跑”,其实没什么特别值得写的。
真正让它有工程价值的,是iOS同事在他的项目中接入时遇到的SDK出现的各种问题。
1. distinct_id 和 account_id 应该怎么传
一开始最容易掉进去的坑,是想把这两个用户标识字段设计得很“完整”,但后来对着真实项目接进去时,我才发现这不只是字段怎么传的问题。
distinct_id 这条比较清楚:
distinct_id == device_id
- 这个值必须由接入方自己提供
- SDK 不再内部默认生成
真正麻烦的是 account_id。
一开始我把它理解成“有就带,没有也不影响上报”,代码里也确实是这么处理的。
但真实接入时,很快就暴露出一个更具体的问题:
很多 App 都是先初始化 SDK,后面才从 Adjust SDK 异步拿到 adid。
也就是说,问题不只是“account_id 要不要传”,而是:
SDK 初始化完成的时候,account_id 很可能还拿不到。
后来产品把规则也确认得更明确了:
-
distinct_id 必须有
-
account_id 的值就是外部拿到的 adjustid
-
account_id 不是初始化时必须就有,但外部拿到 adjustid 后要立刻传给 SDK
- 传进去以后,后续标准事件上报和用户属性上报都要自动传参
account_id
-
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 可选”,而是:
-
distinct_id 一开始就由项目传进来
-
account_id 等 Adjust 返回后,再立刻传给 SDK
- 标准上报路径后续自动传参
account_id
- SDK 主动补一次
user_setOnce 用户属性 account_id
这比我一开始那种“先把规则写简单一点再说”的理解,要更接近真实项目接入。
2. 用户属性更新方式为什么要改成枚举
一开始用户属性更新方式可以传字符串,这在设计上很灵活,但真实接入时很容易变成:
- 表面看起来统一
- 实际每个项目都可能传不同字符串
- 最后 SDK 很难保证大家传的是同一套规则
所以后来我只保留了两种明确的写法:
user_set
user_setOnce
这个改动看起来不大,但它背后的意思是:
SDK 不是只负责“让你能传”,还要尽量避免接入方传错、传乱。
3. 失败重试为什么不能只停在当前进程内
一开始 SDK 只有自动重试 2 次。
这对临时网络失败来说够用,但接入方很快会问一个问题:
如果这次重试两次都失败,下次 App 重启以后怎么办?
这个问题一出来,我就知道,这不再只是“重试几次”的问题,而是“第一次生成的请求内容要不要保存下来”的问题。
因为一旦你要支持 App 重启后继续重发,就意味着:
- 这次请求的 bodyData 不能丢
- 不能下次再重新组合一遍参数
- 否则字段和时间可能会和第一次不一致
所以后来这一块的核心原则就变成:
重试永远基于第一次生成的请求内容,而不是重新构建请求参数。
这也是我觉得很值得写出来的一条工程经验。
很多人会默认“失败了再组合一次参数”,但埋点这种东西,真这么做,最后发出去的内容就可能和第一次不一样了。
而且这件事后面我越看越觉得不能偷懒,因为变化的根本不只是接口的参数 time。
如果你让 SDK 失败后重新组一次参数,可能一起变掉的还有:
- 当时的网络状态
- 当时的权限状态
- 当时的安装相关字段
- 那次请求真正想表达的时间点
所以后来我对这条原则的理解就更明确了:
埋点请求一旦生成,就应该尽量把它当成那一刻的快照。
4. ta_app_install 事件上报的时间后来为什么还要单独修改
这也是产品验收时发现的一个问题。
一开始我会默认觉得:
- 普通事件上报接口传参
time 用当前时间
- 这是很自然的做法
这对绝大多数事件都没问题。
但 ta_app_install 不一样。
因为产品验收时看的不只是传参的 time,还会一起看:
#install_time
install_ts_bj
install_ts_utc
install_ts_time
这几个时间字段本质上都应该指向同一个安装时间点。
当前项目之所以一直没出问题,是因为业务层本来就主动把安装时间传给了这条事件。
但 SDK 标准接法如果只是:
ZZHAnalyticsSDK.shared.track(eventName: "ta_app_install")
旧逻辑会直接把发起这次上报时的当前时间,写进这个事件的 time 字段。
这样一来,这个事件里的 time,和安装相关字段就不是同一个时间来源了。
这个问题特别能说明一件事:
把代码封装成 SDK,只是第一步,等产品真的开始逐个字段检查时,你才会知道还有哪些地方没处理对。
后来的修法也很克制:
- 普通事件还是继续用当前时间
- 只有
ta_app_install 且外部没传 timestamp 时,才默认改用 SDK 保存的安装时间
这件事让我后面更确定,SDK 真正难的不是“第一版怎么设计”,而是:
当产品拿着真实字段来验的时候,你能不能只改那一个真正有问题的地方,而不是顺手把整套时间逻辑都推倒。
五、埋点日志系统是怎么一步一步优化和完善的
如果说前面这些问题还在解决“SDK 能不能用”,那埋点日志系统解决的是另一件事:
SDK 能不能帮助测试和产品快速确认埋点参数有没有传对。
1. 最开始的日志,其实只对 SDK 开发者有用
最开始 SDK 里的日志更像网络请求调试日志:
- 打印发送过程
- 打印状态码
- 打印成功失败
但这类日志有个问题:
做 SDK 的人能看懂,测试拿去检查埋点参数就很难受。
因为测试真正关心的不是网络请求内部过程,而是:
- 这次到底发到哪个 URL
- 请求头是什么
- 请求参数是什么
- 服务端响应了什么
- 到底成功还是失败
所以后来日志被拆成了两类:
- 发起日志
- 结果日志
代码里也尽量保持这个拆分方式:
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. 发起日志解决“准备发什么”的问题
发起日志最终打印的是:
- 时间
- 事件名或者用户属性更新方式
- URL
- Headers
- Params
它解决的问题很明确:
它能够让开发者、产品、测试清晰地知道埋点是否有正确发起上报。
3. 结果日志解决“最终发得对不对”的问题
结果日志则继续保留:
- URL
- Headers
- Params
- StatusCode
- Response
- Success
它解决的是另一层问题:
埋点上报请求最终到底成功没有,服务端返回了什么。
4. 为什么后来还要加埋点日志系统的代理方法
做到这里,我以为已经完成任务了。
后来同事那边又提了一个很真实的诉求:
他们项目里本来就有一个悬浮日志窗口,测试和产品会直接在 App 里看埋点日志,如果 SDK 内部只把日志打到 Xcode 控制台,对他们来说是远远不够的。
这时我才意识到,日志不只是“打印出来”,还得“送出去”。
于是后来又补了一层日志代理:
- SDK 在 Xcode 打什么
- 代理方法就原样返回什么
- 接入方拿到以后,直接塞进自己的日志窗口
最终对外暴露的协议是这样的:
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,在不同接法下代表的内容不一样
这是我在真实项目接入时遇到的一个具体问题。
一开始我把发起日志代理设计成:
-
message:完整日志原文
-
params:给接入方自己打印一条简洁发起日志
看起来很合理,但接入后我发现,同样叫 params,在不同接法下代表的内容并不一样。
1. 标准 SDK 接法
如果你走的是:
track(eventName:properties:)
setUserProperties(...)
那 params 很好理解,就是业务方最开始传进来的参数。
2. 直接发送完整请求参数
如果你走的是:
sendEventPayloadSnapshot(payload)
sendUserPropertyPayloadSnapshot(payload)
那 SDK 拿到的已经是完整的请求参数了。
这时 SDK 内部根本没法再判断:
- 哪些是页面最开始传进来的业务参数
- 哪些是 SDK 自动获取的公共、固定字段
所以这时发起日志里的 params,默认只能是请求参数里现成的 properties
而当前我这个项目,真实主路径其实更接近这一种。
也就是说,当前 SuperCoder / MQ09 不是一开始就完全走标准 track / setUserProperties,而是更多时候先在项目里把完整请求参数组装好,再交给 SDK 发送上报请求。
这也是为什么我后面会专门把这一点写进接入文档里。因为如果不把“当前项目接法”和“标准 SDK 接法”拆开讲,同事看到日志里的 params,会很容易误以为 SDK 自己把业务参数改掉了。
这个区别后来我专门写进了使用文档中,以免iOS同事理解有误。
3. 固定用户属性自动补发又是一个特例
后面又出现了第三种情况。
SDK 会自动补发一组固定用户属性,比如:
country
install_ts_bj
install_ts_utc
install_ts_time
install_ts_time_timezone
这组属性不是业务方手动传的,但测试又希望在发起日志里直接看到它们。
所以最后我又单独给它做了一个特例:
- 普通发起日志:
params 继续代表外部原始入参
- 固定用户属性自动补发:
params 特例代表 SDK 这次自动补发的固定字段
这件事说明了一个问题:
同一个参数名,看起来一样,但在不同使用方式下,代表的内容可能不一样。
这也是 SDK 设计里很容易忽略、但真实接入时很容易暴露的问题。
七、文档、tag、Pod 接入,为什么也是 SDK 工程的一部分
如果只看代码,这套 SDK 其实已经“能用了”。
但我觉得,真正决定它能不能被其他同事真正的在项目中使用,不只是代码。
还有以下这些东西:
- 使用文档写得是不是够直白
- 示例代码是不是和真实接法一致
- 版本号和 tag 有没有同步
- 其他同事复制文档接入时,会不会直接编译报错
我这轮就真实踩到了几个这样的坑:
- 使用文档写了新能力,但 tag 还是旧版本,同事一装就报错
- 使用文档里某些词太偏内部表达,比如“快照模式”,接入同事不好理解
- 日志代理示例里,类型写法稍微不直白,同事就可能写成错误的双层类型名
这些问题不容小觑,是 SDK 工程化本身的一部分。
因为对接入方来说,他们真正关心的是:
- 我怎么接
- 我怎么调
- 我怎么验
- 我装下来的这个版本,到底是不是文档里写的那个版本
所以,SDK 不是“代码抽出来”就结束了,它还要能被别人低成本接上。
而且到后面我还发现,低成本接上这件事,其实也分两层。
第一层是:
- README 写清楚
- tag 发对
- Pod 依赖能装上
第二层是:
- 同事能不能只写一个包名
-
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 真正的完成度,不取决于它能不能发请求,而取决于它能不能被其他项目低成本接入、被测试高效验参、被版本稳定发布。
这可能也是我这轮工作里,最值得留下来的那部分。