透明层方案是全埋点中最”暴力”但最彻底的一种:在所有界面顶层叠加一个透明的 View(Overlay),由它先捕获所有触摸事件,识别出被点击的真实目标 View 并完成埋点,再将事件透传给下层。
一、AOSP 事件分发机制回顾
1.1 ViewGroup.dispatchTouchEvent 源码分析
在 frameworks/base/core/java/android/view/ViewGroup.java 中,事件分发遵循以下规则:
@Override public boolean dispatchTouchEvent(MotionEvent ev) { boolean handled = false; final int actionMasked = ev.getActionMasked();
final boolean intercepted; if (actionMasked == MotionEvent.ACTION_DOWN || mFirstTouchTarget != null) { intercepted = onInterceptTouchEvent(ev); } else { intercepted = true; }
if (!intercepted) { for (int i = childrenCount - 1; i >= 0; i--) { final View child = children[i]; if (!child.canReceivePointerEvents() || !isTransformedTouchPointInView(...)) { continue; } if (child.dispatchTouchEvent(ev)) { mFirstTouchTarget = child; handled = true; break; } } }
if (mFirstTouchTarget == null) { handled = super.dispatchTouchEvent(ev); } return handled; }
|
透明层方案的关键:如果透明层 View 在 DecorView 的子 View 列表的最末尾(即最顶层),它的 dispatchTouchEvent 会最先被调用。
1.2 事件穿透的条件
要让透明层捕获事件但不消费它,需要满足两个条件:
- **
onTouchEvent() 返回 false**:表示不消费事件。
- **
isClickable = false 且 isFocusable = false**:防止 ViewGroup 的分发流程在此中断(否则 mFirstTouchTarget 会被设为透明层,后续子 View 收不到事件)。
二、完整实现
2.1 TrackingOverlay 实现
class TrackingOverlay( context: Context, private val config: OverlayConfig = OverlayConfig() ) : View(context) {
private val touchSlop by lazy { ViewConfiguration.get(context).scaledTouchSlop } private var downX = 0f private var downY = 0f private var downRawX = 0f private var downRawY = 0f private var downTime = 0L private var gestureState = GestureState.UNKNOWN
private var cachedTargetView: WeakReference<View>? = null
private var onViewClicked: ((View, MotionEvent) -> Unit)? = null
init { isClickable = false isFocusable = false isFocusableInTouchMode = false alpha = 0f importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_NO
if (BuildConfig.DEBUG) { alpha = 0.02f setTag("TrackingOverlay") } }
override fun onTouchEvent(event: MotionEvent): Boolean { handleTouchEvent(event) return false }
private fun handleTouchEvent(event: MotionEvent) { when (event.actionMasked) { MotionEvent.ACTION_DOWN -> handleDown(event) MotionEvent.ACTION_MOVE -> handleMove(event) MotionEvent.ACTION_UP -> handleUp(event) MotionEvent.ACTION_CANCEL -> handleCancel() } }
private fun handleDown(event: MotionEvent) { downX = event.x downY = event.y downRawX = event.rawX downRawY = event.rawY downTime = SystemClock.elapsedRealtime() gestureState = GestureState.UNKNOWN cachedTargetView = null }
private fun handleMove(event: MotionEvent) { if (gestureState == GestureState.UNKNOWN) { val dx = event.rawX - downRawX val dy = event.rawY - downRawY if (sqrt(dx * dx + dy * dy) > touchSlop) { gestureState = GestureState.SCROLL } } }
private fun handleUp(event: MotionEvent) { if (gestureState != GestureState.UNKNOWN) return
val elapsed = SystemClock.elapsedRealtime() - downTime
if (elapsed >= ViewConfiguration.getLongPressTimeout()) { gestureState = GestureState.LONG_PRESS return }
gestureState = GestureState.TAP
val targetView = findTargetView(event) if (targetView != null && config.shouldTrack(targetView)) { onViewClicked?.invoke(targetView, event) } }
private fun handleCancel() { gestureState = GestureState.CANCELLED cachedTargetView = null }
private fun findTargetView(event: MotionEvent): View? { cachedTargetView?.get()?.let { return it }
val decorView = (parent as? View)?.rootView as? ViewGroup ?: return null val result = findDeepestView(decorView, event.rawX.toInt(), event.rawY.toInt())
if (result == this) return null
cachedTargetView = result?.let { WeakReference(it) } return result }
private fun findDeepestView(parent: ViewGroup, rawX: Int, rawY: Int): View? { for (i in parent.childCount - 1 downTo 0) { val child = parent.getChildAt(i) ?: continue
if (child == this) continue
if (child.visibility != View.VISIBLE) continue if (child.alpha == 0f && child !is ViewGroup) continue
val loc = IntArray(2) child.getLocationOnScreen(loc)
val left = loc[0] val top = loc[1] val right = left + child.width val bottom = top + child.height
if (rawX in left..right && rawY in top..bottom) { if (child is ViewGroup && child.childCount > 0) { return findDeepestView(child, rawX, rawY) ?: child } return child } }
return parent }
fun setOnViewClickedListener(listener: (View, MotionEvent) -> Unit) { this.onViewClicked = listener }
fun updateConfig(newConfig: OverlayConfig) { }
private enum class GestureState { UNKNOWN, TAP, SCROLL, LONG_PRESS, CANCELLED } }
|
2.2 OverlayConfig 配置
data class OverlayConfig( val excludeViewIds: Set<Int> = emptySet(), val excludeViewTypes: Set<String> = setOf("WebView", "TextureView", "SurfaceView"), val collectText: Boolean = true, val textMaxLength: Int = 100, val coordinateCacheSize: Int = 20, val trackLongPress: Boolean = false, val trackGestures: Boolean = false, val scrollReportIntervalMs: Long = 0 ) { fun shouldTrack(view: View): Boolean { return when { view.id in excludeViewIds -> false view.javaClass.simpleName in excludeViewTypes -> false else -> true } } }
|
2.3 信息采集器
class OverlayClickCollector {
fun collect(targetView: View, event: MotionEvent): Map<String, Any?> { return buildMap { put("view_class", targetView.javaClass.name) put("view_simple_name", targetView.javaClass.simpleName) put("view_id", targetView.id) put("view_id_name", resolveResourceName(targetView))
put("view_text", extractText(targetView)) put("content_description", (targetView.contentDescription?.toString() ?: "").take(100))
put("click_x", event.rawX) put("click_y", event.rawY) put("view_width", targetView.width) put("view_height", targetView.height)
val viewLoc = IntArray(2) targetView.getLocationOnScreen(viewLoc) put("click_x_in_view", event.rawX - viewLoc[0]) put("click_y_in_view", event.rawY - viewLoc[1])
put("view_path", buildViewPath(targetView))
put("event_time", event.eventTime) put("click_timestamp", System.currentTimeMillis())
val activity = getActivity(targetView.context) put("page_name", activity?.javaClass?.simpleName ?: "unknown") put("page_class", activity?.javaClass?.name ?: "") put("page_title", activity?.title?.toString() ?: "")
put("tracking_method", "overlay") } }
private fun resolveResourceName(view: View): String { return try { if (view.id != View.NO_ID) view.resources.getResourceEntryName(view.id) else "" } catch (_: Resources.NotFoundException) { "" } }
private fun extractText(view: View): String { return when (view) { is EditText -> "" is TextView -> view.text?.toString()?.take(100) ?: "" else -> "" } }
private fun buildViewPath(view: View): String { val parts = mutableListOf<String>() var current: View? = view while (current != null && current !is DecorView) { val segment = "${current.javaClass.simpleName}${ if (current.id != View.NO_ID) "[${resolveResourceName(current)}]" else "" }" parts.add(0, segment) current = (current.parent as? View) } return parts.joinToString("/") }
private fun getActivity(context: Context): Activity? { var ctx = context while (ctx is ContextWrapper) { if (ctx is Activity) return ctx ctx = ctx.baseContext } return null } }
|
三、全局注入方案
3.1 自动添加透明层
class OverlayLifecycleInjector : Application.ActivityLifecycleCallbacks {
private val collector = OverlayClickCollector() private val deduplicator = OverlayClickDeduplicator() private val config = OverlayConfig()
private val overlays = WeakHashMap<Activity, TrackingOverlay>()
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { addOverlayToActivity(activity) }
override fun onActivityStarted(activity: Activity) {} override fun onActivityResumed(activity: Activity) {} override fun onActivityPaused(activity: Activity) {} override fun onActivityStopped(activity: Activity) {} override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {} override fun onActivityDestroyed(activity: Activity) { overlays.remove(activity) }
private fun addOverlayToActivity(activity: Activity) { val decorView = activity.window.decorView as? ViewGroup ?: return
if (overlays.containsKey(activity)) return
val overlay = TrackingOverlay(activity, config).apply { setOnViewClickedListener { view, event -> handleViewClicked(view, event) } }
decorView.post { val params = ViewGroup.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT ) decorView.addView(overlay, params) overlay.bringToFront() }
overlays[activity] = overlay }
private fun handleViewClicked(view: View, event: MotionEvent) { if (!deduplicator.shouldTrack(view, event)) return
val info = collector.collect(view, event)
TrackerExecutor.execute { AnalyticsSDK.track("app_click", info) } } }
class OverlayClickDeduplicator { private val recentClicks = LruCache<Long, Boolean>(maxSize = 100)
fun shouldTrack(view: View, event: MotionEvent): Boolean { val key = ((event.eventTime / 100) * 31 + view.hashCode()) if (recentClicks.get(key) == true) return false recentClicks.put(key, true) return true } }
|
3.2 Application 注册
class TrackingApplication : Application() { override fun onCreate() { super.onCreate() registerActivityLifecycleCallbacks(OverlayLifecycleInjector()) } }
|
四、架构流程图
┌──────────────────────────────────────────────────────┐ │ DecorView │ │ ┌────────────────────────────────────────────────┐ │ │ │ StatusBar / ActionBar / Content │ │ │ │ ┌──────────────────────────────────────┐ │ │ │ │ │ 业务布局层 (XML / 代码创建的 View) │ │ │ │ │ │ Button TextView RecyclerView ... │ │ │ │ │ └──────────────────────────────────────┘ │ │ │ └────────────────────────────────────────────────┘ │ │ ┌────────────────────────────────────────────────┐ │ │ │ TrackingOverlay (透明覆盖层) ← 最顶层 │ │ │ │ - alpha = 0 │ │ │ │ - isClickable = false │ │ │ │ - onTouchEvent → 识别目标View → return false │ │ │ └────────────────────────────────────────────────┘ │ └──────────────────────────────────────────────────────┘
触摸事件流: Finger Touch (x, y) │ ▼ DecorView.dispatchTouchEvent() │ ▼ 遍历子 View (Z-order 从高到低) │ ▼ ┌─────────────────────────────┐ │ TrackingOverlay │ ← 最先收到事件(最顶层) │ dispatchTouchEvent → false │ └─────────────────────────────┘ │ (未被消费,继续向下) ▼ 遍历下一个子 View │ ├── StatusBar/TitleBar (如果存在) │ ▼ ┌─────────────────────────────┐ │ 业务 View (Button, etc.) │ ← 最终消费者 │ dispatchTouchEvent → true │ └─────────────────────────────┘
异步处理: TrackingOverlay.ACTION_UP → findDeepestView() (通过坐标查找目标View) → OverlayClickCollector (提取View信息) → OverlayClickDeduplicator(去重) → AnalyticsSDK.track() (异步上报)
|
五、高级场景处理
5.1 RecyclerView 快速滚动优化
class RecyclerViewAwareOverlay(context: Context) : TrackingOverlay(context) {
private var isRecyclerViewScrolling = false private val scrollEndRunnable = Runnable { isRecyclerViewScrolling = false }
fun bindToRecyclerView(recyclerView: RecyclerView) { recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { isRecyclerViewScrolling = newState != RecyclerView.SCROLL_STATE_IDLE if (newState == RecyclerView.SCROLL_STATE_SETTLING || newState == RecyclerView.SCROLL_STATE_DRAGGING) { handler.removeCallbacks(scrollEndRunnable) } else if (newState == RecyclerView.SCROLL_STATE_IDLE) { handler.postDelayed(scrollEndRunnable, 150) } } }) }
override fun onTouchEvent(event: MotionEvent): Boolean { if (isRecyclerViewScrolling && event.actionMasked == MotionEvent.ACTION_UP) { return false } return super.onTouchEvent(event) } }
|
object DialogOverlayInjector {
private val injectedDialogs = WeakHashMap<Dialog, TrackingOverlay>()
fun inject(dialog: Dialog) { dialog.setOnShowListener { val decorView = dialog.window?.decorView as? ViewGroup ?: return@setOnShowListener if (injectedDialogs.containsKey(dialog)) return@setOnShowListener
val overlay = TrackingOverlay(dialog.context) overlay.setOnViewClickedListener { view, event -> }
val params = ViewGroup.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT ) decorView.addView(overlay, params) injectedDialogs[dialog] = overlay } } }
fun Dialog.enableOverlayTracking() = DialogOverlayInjector.inject(this)
|
5.3 分屏 / 多窗口模式适配
fun TrackingOverlay.prepareForMultiWindow(activity: Activity) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { activity.addOnMultiWindowModeChangedListener { isInMultiWindowMode -> if (isInMultiWindowMode) { val decorView = parent as? ViewGroup ?: return@addOnMultiWindowModeChangedListener layoutParams?.apply { width = decorView.width height = decorView.height } requestLayout() } } } }
|
六、性能优化
6.1 坐标查找优化
class OptimizedViewFinder( private val decorView: ViewGroup ) { private val cache = LruCache<String, WeakReference<View>>(maxSize = 100)
fun findView(rawX: Int, rawY: Int): View? { val bucketKey = "${rawX / 20}_${rawY / 20}" cache.get(bucketKey)?.get()?.let { return it }
val view = findDeepestView(decorView, rawX, rawY) view?.let { cache.put(bucketKey, WeakReference(it)) } return view } }
|
七、与手势导航栏的冲突处理
Android 10+ 的手势导航栏会在屏幕边缘添加系统手势区域,透明层可能与之冲突。
class GestureAwareOverlay(context: Context) : TrackingOverlay(context) {
private val gestureInsets: WindowInsets? = null
override fun onApplyWindowInsets(insets: WindowInsets?): WindowInsets { val systemGestures = insets?.getInsets(WindowInsets.Type.systemGestures()) setPadding( systemGestures?.left ?: 0, systemGestures?.top ?: 0, systemGestures?.right ?: 0, systemGestures?.bottom ?: 0 ) return super.onApplyWindowInsets(insets) }
override fun onTouchEvent(event: MotionEvent): Boolean { if (isInSystemGestureArea(event)) { return false } return super.onTouchEvent(event) }
private fun isInSystemGestureArea(event: MotionEvent): Boolean { return event.x < paddingLeft || event.x > width - paddingRight || event.y < paddingTop || event.y > height - paddingBottom } }
|
八、与其他方案组合
透明层方案常与其他方案组合使用,形成多层防御的埋点体系:
| 层级 |
方案 |
覆盖场景 |
| 第一层 |
透明层 (Overlay) |
所有触摸事件的兜底捕获 |
| 第二层 |
Window.Callback 代理 |
精确的事件分发时序控制 |
| 第三层 |
OnClickListener 代理 |
最详细的 View 属性采集 |
| 补充层 |
AccessibilityDelegate |
无障碍场景的额外覆盖 |
九、ProGuard/R8 规则
-keep class com.example.tracking.overlay.TrackingOverlay { *; } -keep class com.example.tracking.overlay.OverlayConfig { *; } -keep class com.example.tracking.overlay.OverlayClickCollector { *; } -keep class com.example.tracking.overlay.OverlayLifecycleInjector { *; }
|
面试常考问题
Q1:透明层方案是否会阻挡底层 View 的点击响应?
不会。透明层重写 onTouchEvent 返回 false,表示不消费事件,系统会继续将事件传递给下层 View。同时 isClickable = false 确保了触摸事件在 ViewGroup.dispatchTouchEvent 的分发阶段不会在此层停止(不会将 mFirstTouchTarget 设为本层)。关键在于理解 ViewGroup 的分发逻辑:onInterceptTouchEvent 返回 false + onTouchEvent 返回 false,事件会继续分发给下一个兄弟 View。
Q2:透明层能否识别被裁剪或半透明覆盖的 View?
只能识别透明层下方”坐标范围内”的最深层可见 View。坐标查找基于 View.getLocationOnScreen() 和 View.isShown(),按 Z-order 从高到低遍历。如果目标 View 的上方有一个半透明遮罩(比如一个 alpha=0.5 的 Dialog 背景),坐标查找会命中上层的遮罩 View 而非目标 View,这是坐标查找的固有局限。解决途径:(1)检查命中 View 的 alpha 和 background 属性,对半透明 View 继续向下查找;(2)在埋点信息中标记命中的 View 是否是预期的交互控件(通过检查 isClickable / hasOnClickListeners())。
Q3:如何处理 RecyclerView 快速滚动时的性能问题?
透明层添加触摸事件阈值过滤(如位移 < touchSlop 才判定为点击),避免在滚动过程中频繁执行坐标遍历。优化层级:(1)在 ACTION_DOWN 时不做全量查找(仅记录坐标);(2)在 ACTION_MOVE 时检测是否超过 touchSlop,超过则标记为滚动,后续 MOVE/UP 不再查找;(3)将坐标查找延后到 ACTION_UP 时一次性执行;(4)绑定 RecyclerView 的 OnScrollListener,在 SCROLL_STATE_SETTLING 和 SCROLL_STATE_DRAGGING 期间完全禁用查找;(5)使用坐标分桶缓存提高重复坐标的查找效率。AOSP 事件分发流程参见 ViewGroup.dispatchTouchEvent() 中的 onInterceptTouchEvent() 和子 View 遍历逻辑(frameworks/base/core/java/android/view/ViewGroup.java)。
Q4:透明层方案在 Jetpack Compose 中能工作吗?
不能直接工作。Compose 使用自己的渲染和事件系统,不走 Android ViewGroup 的 dispatchTouchEvent。Compose 的 UI 树由 AndroidComposeView(一个 ViewGroup)承载,所有 Compose 组件都在它的 Canvas 上绘制。透明层方案只能看到 AndroidComposeView 这一层,无法区分其内部的不同 Composable 函数。Compose 的全埋点需要专门的方案:(1)在 Compose 的 Modifier.clickable { } 中插入埋点;(2)使用 Compose 的 CompositionLocals 传递埋点上下文;(3)在 Compose 的 Semantics 树中注入追踪信息。
Q5:透明层方案与 GrowingIO 的实现有什么关系?
GrowingIO 是 Android 全埋点领域中最早使用透明层方案的公司之一。GrowingIO 的 Android SDK 在 ViewTreeObserver.OnGlobalLayoutListener 中为每个 Activity 动态添加透明层,通过坐标查找实现无埋点点击追踪。其核心实现原理与本文描述一致,但在工程化方面做了更多工作:(1)多窗口支持(Dialog/PopupWindow/Toast);(2)Hybrid 支持(WebView 内的事件通过 JS Bridge 上报);(3)圈选功能(可视化埋点配置,使透明层在圈选模式下变为半透明可视化,显示每个 View 的可追踪信息)。与之相比,神策数据的 Android SDK 使用的是 Window.Callback 代理方案。