我最近把一套已经在线上跑通的项目内埋点代码,抽成了一套可复用的 iOS SDK。原本我以为难点只是“把代码搬出去”,后来才发现,真正难的是哪些能力该留在 SDK、埋点上报发送请求到底要不要sdk来接管、日志怎么做才方便进行测试验证、文档和版本号怎么跟上。这篇文章分享的是这套 SDK 被真实接入反馈一步步打磨出来的过程。
最开始我以为,做这套埋点 SDK,就是把项目里那套已经跑通的代码抽出来就可以了,后来发现没那么简单。
真正麻烦的地方,是代码抽出去之后才出现的:
- 哪些能力应该放进SDK
- 哪些逻辑必须留在业务项目
- SDK 要不要负责埋点上报发送请求
- 日志到底是给开发看,还是给测试和产品验收使用
- 文档和版本号没跟上时,同事会不会直接集成失败
因为我后来确定了一件事:把埋点代码抽成 SDK,难点从来不是“把代码搬出去”,而是让它在接入、调试、验证、埋点上报这些环节里都真的可用。
一、为什么我会做这个 SDK
起因其实很简单,我手上有一个已经在线上跑通的 iOS APP 项目,里面已经有一套比较完整的埋点能力:
- 自动采集公共事件属性
- 自动补一组固定用户属性
- 统一时间格式
- 固定首次安装时间和安装时区
- 构建事件请求
- 构建用户属性请求
- 埋点上报发送请求
- 失败后自动重试
- 打调试日志
这套东西在项目里和线上使用都能正常工作,于是领导就给我提出一个需求:
让我封装一个埋点sdk,把固定用户属性和公共事件属性都封装在sdk中,让sdk内部自动获取这些属性值,iOS同事在其他项目中进行埋点上报的时候,就不需要再单独写一套固定用户属性和公共事件属性上报的代码了,直接使用sdk的能力就可以了。
所以这次的目标是把这些已经被项目验证过的能力,封装成一个其他 App 项目也能对接的 SDK。
这也是我这次封装 SDK 时反复确认的一件事:在项目里能跑通的埋点代码,不一定适合直接变成 SDK,为项目内代码可以依赖当前业务场景、历史逻辑和约定写法,但 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 只停留在“我本地能跑”,其实没什么特别值得写的。
真正让它有工程价值的,是iOS同事在他的项目中接入时遇到的SDK出现的各种问题。
1. distinct_id 和 account_id 应该怎么传
一开始最容易掉进去的坑,是想把这两个用户标识字段设计得很“完整”。
但后来对着真实接口和真实项目去看,反而发现规则应该更简单:
distinct_id必须有account_id可选- 只要两者不同时为空即可
这件事表面上像字段规则,实际上影响很大。因为一旦你把这两个字段的传值规则设计复杂了,SDK 就会越来越重,接入方也会越来越难理解。
所以后来我把规则收得很死:
distinct_id == device_id并且由接入方自己提供,SDK 不再默认生成account_id有就带,没有也不会影响埋点上报
2. 用户属性更新方式为什么要收口成枚举
一开始用户属性更新方式可以传字符串,这在设计上很灵活,但真实接入时很容易变成:
- 表面看起来统一
- 实际每个项目都可能传不同字符串
- 最后 SDK 很难保证大家传的是同一套规则
所以后来我只保留了两种明确的写法:
user_setuser_setOnce
这个改动看起来不大,但它背后的意思是:
SDK 不是只负责“让你能传”,还要尽量避免接入方传错、传乱。
3. 失败重试为什么不能只停在当前进程内
一开始 SDK 只有自动重试 2 次。
这对临时网络失败来说够用,但接入方很快会问一个问题:
如果这次重试两次都失败,下次 App 重启以后怎么办?
这个问题一出来,我就知道,这不再只是“重试几次”的问题,而是“第一次生成的请求内容要不要保存下来”的问题。
因为一旦你要支持 App 重启后继续重发,就意味着:
- 这次请求的 bodyData 不能丢
- 不能下次再重新组合一遍参数
- 否则字段和时间可能会和第一次不一致
所以后来这一块的核心原则就变成:
重试永远基于第一次生成的请求内容,而不是重新构建请求参数。
这也是我觉得很值得写出来的一条工程经验。
很多人会默认“失败了再组合一次参数”,但埋点这种东西,真这么做,最后发出去的内容就可能和第一次不一样了。
五、埋点日志系统是怎么一步一步优化和完善的
如果说前面这些问题还在解决“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
这个区别后来我专门写进了使用文档中,以免其他同事理解有误。
3. 固定用户属性自动补发又是一个特例
后面又出现了第三种情况。
SDK 会自动补发一组固定用户属性,比如:
countryinstall_ts_bjinstall_ts_utcinstall_ts_timeinstall_ts_time_timezone
这组属性不是业务方手动传的,但测试又希望在发起日志里直接看到它们。
所以最后我又单独给它做了一个特例:
- 普通发起日志:
params继续代表外部原始入参 - 固定用户属性自动补发:
params特例代表 SDK 这次自动补发的固定字段
这件事说明了一个问题:
同一个参数名,看起来一样,但在不同使用方式下,代表的内容可能不一样。
这也是 SDK 设计里很容易忽略、但真实接入时很容易暴露的问题。
七、文档、tag、Pod 接入,为什么也是 SDK 工程的一部分
如果只看代码,这套 SDK 其实已经“能用了”。
但我觉得,真正决定它能不能被其他同事真正的在项目中使用,不只是代码。
还有以下这些东西:
- 使用文档写得是不是够直白
- 示例代码是不是和真实接法一致
- 版本号和 tag 有没有同步
- 其他同事复制文档接入时,会不会直接编译报错
我这轮就真实踩到了几个这样的坑:
- 使用文档写了新能力,但 tag 还是旧版本,同事一装就报错
- 使用文档里某些词太偏内部表达,比如“快照模式”,接入同事不好理解
- 日志代理示例里,类型写法稍微不直白,同事就可能写成错误的双层类型名
这些问题不容小觑,是 SDK 工程化本身的一部分。
因为对接入方来说,他们真正关心的是:
- 我怎么接
- 我怎么调
- 我怎么验
- 我装下来的这个版本,到底是不是文档里写的那个版本
所以,SDK 不是“代码抽出来”就结束了,它还要能被别人低成本接上。
八、这轮工作最后让我确定的几条原则
最后,总结 6 个我这次做 SDK 后真正踩出来的经验。
1. 项目里能跑通的代码,不一定适合直接做成 SDK
很多时候,项目里的代码只是刚好满足当前业务,想让其他项目也能用,还需要重新拆清楚:哪些放进 SDK,哪些留在项目里。
2. 发送能力最好可选,不要默认 SDK 接管一切
不是所有项目都希望 SDK 直接发请求。让 SDK 同时支持“构建参数”和“直接发送”,接入成本会小很多。
3. 重试一定要基于第一次生成的请求内容
埋点怕的不是失败,而是失败后重新组参数,导致最后发出去的内容和第一次不一样。只要涉及重试,就尽量保存第一次生成的请求内容。
4. 日志要方便测试和产品检查参数,而不只是方便 SDK 开发者调试
对 SDK 开发者好用,不代表对测试好用。日志里能不能一眼看到 URL、参数、响应和成功、失败,才决定这套日志有没有价值。
5. 文档、版本号和 Pod 接入方式,也要一起维护
文档不准、tag 不同步、示例代码不对,都会让接入方直接踩坑。SDK 想让别人顺利接入,就不能只管代码。
6. SDK 是靠真实接入反馈一点点打磨出来的
我这轮最大的感受就是这个,很多一开始觉得“设计得挺好”的地方,最后都是在真实项目接入、同事反馈和测试检查参数时,才暴露出问题。
一个埋点 SDK 真正的完成度,不取决于它能不能发请求,而取决于它能不能被其他项目低成本接入、被测试高效验参、被版本稳定发布。
这可能也是我这轮工作里,最值得留下来的那部分。