第2章 埋点状态定义与类型
2.1 状态的边界定义
在 Jetpack Compose 埋点体系中,埋点状态是对 UI 交互行为的原子化建模。与其将每一次埋点视为一次离散的网络请求,不如将状态视为 UI 事件的持久化快照。三种核心状态——曝光、点击、停留——构成了用户行为数据流的源头。
| 状态类型 | 语义 | 触发时机 | 生命周期特征 | 典型场景 |
|---|---|---|---|---|
| 曝光 | 可见性报告 | 组件首次进入视口且稳定存在 ≥ 300ms | 一次性触发,不可逆 | 首页推荐流卡片、广告位、列表 item |
| 点击 | 意图确认 | 用户手指抬起且仍在组件边界内 | 瞬时事件,携带组件元数据 | 按钮、跳转链接、切换开关 |
| 停留 | 注意力时长 | 组件从可见变为不可见,或主动上报结束 | 有开始和结束两个子状态,持续累积 | 阅读文章、视频播放页、表单填写 |
2.2 状态的生命周期与转换
每一种埋点状态从诞生到消费,都经历三个核心阶段:检测、确认、上报。在 Compose 声明式框架下,状态的检测依赖于组合键(key)与视觉树(LayoutCoordinates)的联合计算。
- 检测阶段:通过
Modifier.onGloballyPositioned获取组件实际边界,结合isVisible与isFullyVisible判断曝光条件。 - 确认阶段:引入防抖窗口(Debounce),避免快速滚动导致的假曝光;点击必须区分手势时长与移动距离,排除误触。
- 上报阶段:将状态序列化为埋点协议对象,通过信任链路发送至数据分析平台,同时更新本地状态缓存。
状态转换约束:曝光必须先于点击发生;停留的开始必须在曝光确认之后;不允许出现无曝光基础的停留或点击。
2.3 组件职责分离
在架构上,三种状态不应由同一个 Composable 函数内联处理。职责应按照“状态机”与“埋点协议”两层分离:
- 状态机层:只负责状态检测与生命周期管理,输出布尔型或枚举型结果(如
isExposed、onClick、stayDuration)。 - 埋点协议层:接收状态机的输出,拼接业务元数据(页面名、组件ID、扩展参数),组装成标准协议格式。
// 状态定义协议示例
sealed class TrackEvent {
data class Exposure(
val pageId: String,
val componentId: String,
val timestamp: Long
) : TrackEvent()
data class Click(
val pageId: String,
val componentId: String,
val targetUrl: String?,
val timestamp: Long
) : TrackEvent()
data class Stay(
val pageId: String,
val componentId: String,
val durationMs: Long,
val startTime: Long,
val endTime: Long
) : TrackEvent()
}
最佳实践:状态机的输出应与 Composable 的重组解耦。推荐将 TrackEvent 的构建放在 ViewModel 层完成,Composable 只负责传递生命周期事件的原始信号。
2.4 风险点与选型权衡
定义埋点状态时,最常见的风险集中在以下三个维度:
| 风险点 | 具体表现 | 缓解方案 |
|---|---|---|
| 状态遗漏 | 快速滑动导致曝光未被检测到;点击事件被系统手势拦截 | 引入可见性阈值与手势冲突判断;使用 pointerInput 自定义手势 |
| 内存泄漏 | 停留开始后组件被回收但未上报结束 | 绑定 DisposableEffect 在组件生命周期结束时强制上报 |
| 重复上报 | 状态因重组被多次触发 | 为每个状态生成唯一 UUID;上报后更新本地缓存避免二次写入 |
2.5 落地步骤与验收标准
将三种状态定义落实为可执行的代码模块,建议按以下步骤推进:
- 定协议:确定
TrackEvent的数据结构,包含必选字段与扩展字段。 - 写检测器:为曝光、点击、停留分别创建
Modifier扩展函数,内嵌状态机。 - 接存储:在 ViewModel 中维护一个
MutableStateFlow<TrackEvent>,用于汇集所有上报。 - 联调验证:使用埋点测试工具(如自定义日志面板)验证三种状态是否按预期触发。
// 停留状态检测器示例 Modifier
fun Modifier.trackStay(
pageId: String,
componentId: String,
onStayComplete: (TrackEvent.Stay) -> Unit
): Modifier = composed {
var startTime by remember { mutableLongStateOf(0L) }
var isVisible by remember { mutableStateOf(false) }
DisposableEffect(Unit) {
onDispose {
if (isVisible && startTime > 0) {
val endTime = System.currentTimeMillis()
onStayComplete(
TrackEvent.Stay(
pageId = pageId,
componentId = componentId,
durationMs = endTime - startTime,
startTime = startTime,
endTime = endTime
)
)
}
}
}
this.onGloballyPositioned { coordinates ->
isVisible = coordinates.isVisible()
if (isVisible && startTime == 0L) {
startTime = System.currentTimeMillis()
}
}
}
验收标准案例:
- 快速滚动列表时,只有停留超过 300ms 的 item 才会触发曝光;
- 点击按钮后,埋点日志中同一组件 ID 的曝光与点击事件先后顺序严格一致;
- 用户退出页面时,停留状态自动结束并上报,无悬空状态。
2.6 选型权衡与信任链路
在状态定义层做选型时,需要在 编译期强校验 与 运行期灵活性 之间找到平衡:
- 枚举合一:将曝光、点击、停留定义为同一 sealed class 的子类,便于在 Collector 端统一处理,但新增状态类型需要修改多处 pattern match。
- 独立接口:每个状态单独抽象为接口(如
ExposureTracker、ClickTracker、StayTracker),扩展性好但上层组合变复杂。