Mobile Development 15 min read

A Robust Approach to Android UI Component Exposure Tracking Using Custom View Lifecycle Management

This article analyzes the limitations of traditional Android exposure tracking methods that rely on parent container lifecycles and introduces a reusable, low-intrusion solution using a custom layout that leverages core View lifecycle callbacks and drawing listeners to accurately detect component visibility and duration.

Snowball Engineer Team
Snowball Engineer Team
Snowball Engineer Team
A Robust Approach to Android UI Component Exposure Tracking Using Custom View Lifecycle Management

When an internet product reaches a certain scale, tracking content exposure frequency and duration becomes essential for optimizing user experience. Android client-side tracking generally falls into three categories: code-based tracking, visual tracking, and codeless tracking. While page views and click events are straightforward, content exposure tracking is complex due to its dependency on dynamic positions and parent component lifecycles, often requiring manual code implementation.

In typical implementations like the Xueqiu App, exposure tracking for RecyclerView relies on scroll listeners and fragment lifecycle callbacks. This approach suffers from poor reusability, as different containers (RecyclerView, ViewPager) require distinct listeners, and complex logic due to nested lifecycle dependencies. Determining true visibility in nested Activity-Fragment-ViewPager structures becomes highly error-prone.

The proposed solution converges exposure logic into the component itself by leveraging the Android View lifecycle. Key methods analyzed include onAttachedToWindow/onDetachedFromWindow, onWindowFocusChanged, onVisibilityAggregated, ViewTreeObserver.OnPreDrawListener, getLocalVisibleRect, and isShown. By combining these, developers can accurately determine when a view is attached, focused, visible, and actually rendered on screen.

The implementation defines a custom ExposureLayout that delegates logic to an ExposureHandler. The handler monitors attachment state, window focus, aggregated visibility, and pre-draw events. It calculates the visible area ratio and tracks exposure duration, triggering callbacks only when configurable thresholds for area and time are met.

class ExposureLayout : FrameLayout {
   private val mExposureHandler by lazy { ExposureHandler(this) }
   constructor(context: Context) : super(context)
   constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
   constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
   override fun onAttachedToWindow() { super.onAttachedToWindow(); mExposureHandler.onAttachedToWindow() }
   override fun onDetachedFromWindow() { super.onDetachedFromWindow(); mExposureHandler.onDetachedFromWindow() }
   override fun onWindowFocusChanged(hasWindowFocus: Boolean) { super.onWindowFocusChanged(hasWindowFocus); mExposureHandler.onWindowFocusChanged(hasWindowFocus) }
   override fun onVisibilityAggregated(isVisible: Boolean) { super.onVisibilityAggregated(isVisible); mExposureHandler.onVisibilityAggregated(isVisible) }
   fun setExposureCallback(callback: IExposureCallback) { mExposureHandler.setExposureCallback(callback) }
   fun setShowRatio(ratio: Float) { mExposureHandler.setShowArea(ratio) }
   fun setTimeLimit(timeLimit: Int) { mExposureHandler.setTimeLimit(timeLimit) }
}

The ExposureHandler manages state flags for attachment, focus, and visibility. During onPreDraw, it checks getLocalVisibleRect and isShown to verify actual screen presence. If visibility thresholds are met, it starts tracking; if conditions fail, it stops tracking and evaluates if the minimum duration requirement was satisfied before triggering the exposure callback.

class ExposureHandler(private val view: View) : ViewTreeObserver.OnPreDrawListener {
   private var mAttachedToWindow = false
   private var mHasWindowFocus = true
   private var mVisibilityAggregated = true
   private var mExposure = false
   private var mExposureCallback: IExposureCallback? = null
   private var mStartExposureTime: Long = 0L
   private var mShowRatio: Float = 0f
   private var mTimeLimit: Int = 0
   private val mRect = Rect()
   fun onAttachedToWindow() { mAttachedToWindow = true; view.viewTreeObserver.addOnPreDrawListener(this) }
   fun onDetachedFromWindow() { mAttachedToWindow = false; view.viewTreeObserver.removeOnPreDrawListener(this); tryStopExposure() }
   fun onWindowFocusChanged(hasWindowFocus: Boolean) { mHasWindowFocus = hasWindowFocus; tryStopExposure() }
   fun onVisibilityAggregated(isVisible: Boolean) { mVisibilityAggregated = isVisible; tryStopExposure() }
   override fun onPreDraw(): Boolean {
       val visible = view.getLocalVisibleRect(mRect) && view.isShown
       if (!visible) { tryStopExposure(); return true }
       if (mShowRatio > 0) {
           if (kotlin.math.abs(mRect.bottom - mRect.top) > view.height * mShowRatio && kotlin.math.abs(mRect.right - mRect.left) > view.width * mShowRatio) { tryExposure() } else { tryStopExposure() }
       } else { tryExposure() }
       return true
   }
   fun setExposureCallback(callback: IExposureCallback) { mExposureCallback = callback }
   fun setShowArea(area: Float) { mShowRatio = area }
   fun setTimeLimit(index: Int) { this.mTimeLimit = index }
   private fun tryExposure() {
       if (mAttachedToWindow && mHasWindowFocus && mVisibilityAggregated && !mExposure) {
           mExposure = true; mStartExposureTime = System.currentTimeMillis()
           if (mTimeLimit == 0) { mExposureCallback?.show() }
       }
   }
   private fun tryStopExposure() {
       if ((!mAttachedToWindow || !mHasWindowFocus || !mVisibilityAggregated) && mExposure) {
           mExposure = false
           if (mTimeLimit > 0 && System.currentTimeMillis() - mStartExposureTime > mTimeLimit) { mExposureCallback?.show() }
       }
   }
}

Integration into a RecyclerView adapter becomes straightforward. Developers simply wrap item views with ExposureLayout, configure the visibility ratio and time limit, and attach a callback. This eliminates the need for manual scroll or lifecycle listeners in the adapter or fragment.

class TimeLineAdapter : RecyclerView.Adapter
() {
   override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
       holder.exposureLayout.run {
           setShowRatio(0.5f)
           setTimeLimit(2000)
           setExposureCallback(object : IExposureCallback {
               override fun show() { /* Log exposure */ }
           })
       }
   }
   class TimeLineViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
       val exposureLayout: ExposureLayout = itemView.findViewById(R.id.layout_exposure)
   }
}

This architecture significantly improves code reusability, reduces intrusion into business logic, and simplifies maintenance. By decoupling exposure tracking from parent containers and relying on fundamental View lifecycle events, developers can implement accurate, configurable, and highly maintainable exposure tracking across diverse Android UI components.

KotlinAndroid DevelopmentRecyclerViewCustom ViewExposure AnalyticsUI TrackingView Lifecycle
Snowball Engineer Team
Written by

Snowball Engineer Team

Proactivity, efficiency, professionalism, and empathy are the core values of the Snowball Engineer Team; curiosity, passion, and sharing of technology drive their continuous progress.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.