搜狐技术产品

Android 页面曝光埋点难点及方案

Image

1
曝光

曝光埋点分为两种:

  1. PV
  2. show
它俩都表示“展示”,但有如下不同:
  • 概念不同:PV = Page View,它特指页面维度的展示。对于 Android 平台来说,可以是一个 Activity 或 Fragment。而 show 可以是任何东西的展示,可以是页面,也可以是一个控件的展示。
  • 上报时机不同:PV 是在离开页面的时候上报,show 是在控件展示的时候上报。
  • 上报参数不同:PV 通常会上报页面停留时长。
  • 消费场景不同:在消费侧,“展示”通常用于形成页面转化率漏斗,PV 和 show 都可用于形成这样的漏斗。但 show 比 PV 更精细,因为可能 A 页面中有 N 个入口可以通往 B页面。

由于产品希望知道更精确的入口信息,遂新增埋点全都是 show。

2
现有 PV 上报组件
Activity PV
项目中引入了一个第三方库实现了 Activity PV 半自动化上报:
public interface PvTracker {
    String getPvEventId();// 生成事件ID
    Bundle getPvExtra();// 生成额外参数
    default boolean shouldReport() {return true;}
    default String getUniqueKey() {return null;}
}
该接口定义了如何生成曝光埋点的事件ID和额外参数。
当某 Activity 需要 PV 埋点时实现该接口:
class AvatarActivity : BaseActivity, PvTracker{
    override fun getPvEventId() = "avatar.pv"
    override fun getPvExtra() = Bundle()
}

然后该 pvtracker 库就会自动实现 Activity 的 PV 上报。

它通过如下方式对全局 Activity 生命周期做了监听:
class PvLifeCycleCallback implements Application.ActivityLifecycleCallbacks {
    @Override
    public void onActivityResumed(Activity activity) {
        String eventId = getEventId(activity);
        if (!TextUtils.isEmpty(eventId)) {
            onActivityVisibleChanged(activity, true); // activity 可见
        }
    }

    @Override
    public void onActivityPaused(Activity activity) {
        String eventId = getEventId(activity);
        if (!TextUtils.isEmpty(eventId)) {
            onActivityVisibleChanged(activity, false);// activity 不可见
        }
    }

    // 当 Activity 可见性发生变化
    private void onActivityVisibleChanged(Activity activity, boolean isVisible) {
        if (activity instanceof PvTracker) {
            PvTracker tracker = (PvTracker) activity;
            if (!tracker.shouldReport()) {
                return;
            }
            String eventId = tracker.getPvEventId();
            Bundle bundle = tracker.getPvExtra();

            if (TextUtils.isEmpty(eventId) || mActivityLoadType == null) {
                return;
            }
            String uniqueEventId = PageViewTracker.getUniqueId(activity, eventId);
            if (isVisible) {
                // 标记曝光开始
                PvManager.getInstance().triggerVisible(uniqueEventId, eventId, bundle, loadType);
            } else {
                // 标记曝光结束,统计曝光时间并上报PV
                PvManager.getInstance().triggerInvisible(uniqueEventId);
            }
        }
    }
}

PvLifeCycleCallback 是一个全局性的 Activity 生命周期监听器,它会在 Application 初始化的时候注册:
class MyApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        // 在 Application 中初始化
        registerActivityLifecycleCallbacks(PvLifeCycleCallback)
    }
}
这套方案实现了 Activity 层面半自动声明式埋点,即只需要编码埋点数据,不需要手动触发埋点。

Fragment PV

Fragment 生命周期是件非常头痛的事情。
在FragmentManager.FragmentLifecycleCallbacks出现之前没有一个官方的解决方案,Fragment 生命周期处于一片混沌之中。
FragmentManager.FragmentLifecycleCallbacks 为开发者开了一扇窗(但这是一扇破窗):
public abstract static class FragmentLifecycleCallbacks {
    public void onFragmentPreAttached(@NonNull FragmentManager fm, @NonNull Fragment f,@NonNull Context context) {}
    public void onFragmentAttached(@NonNull FragmentManager fm, @NonNull Fragment f,@NonNull Context context) {}
    public void onFragmentPreCreated(@NonNull FragmentManager fm, @NonNull Fragment f,@Nullable Bundle savedInstanceState) {}
    public void onFragmentCreated(@NonNull FragmentManager fm, @NonNull Fragment f,@Nullable Bundle savedInstanceState) {}
    public void onFragmentActivityCreated(@NonNull FragmentManager fm, @NonNull Fragment f,@Nullable Bundle savedInstanceState) {}
    public void onFragmentViewCreated(@NonNull FragmentManager fm, @NonNull Fragment f,@NonNull View v, @Nullable Bundle savedInstanceState) {}
    public void onFragmentStarted(@NonNull FragmentManager fm, @NonNull Fragment f) {}
    public void onFragmentResumed(@NonNull FragmentManager fm, @NonNull Fragment f) {}
    public void onFragmentPaused(@NonNull FragmentManager fm, @NonNull Fragment f) {}
    public void onFragmentStopped(@NonNull FragmentManager fm, @NonNull Fragment f) {}
    public void onFragmentSaveInstanceState(@NonNull FragmentManager fm, @NonNull Fragment f,@NonNull Bundle outState) {}
    public void onFragmentViewDestroyed(@NonNull FragmentManager fm, @NonNull Fragment f) {}
    public void onFragmentDestroyed(@NonNull FragmentManager fm, @NonNull Fragment f) {}
    public void onFragmentDetached(@NonNull FragmentManager fm, @NonNull Fragment f) {}
}

可以通过观察者模式在 Fragment 实例以外的地方全局性地监听所有 Fragment 的生命周期。当其中的 onFragmentResumed() 回调时,意味着 Fragment 可见,而当 onFragmentPaused()回调时,意味着 Fragment 不可见。

但有如下例外情况:
  1. 调用 FragmentTransaction的 show()/hide() 方法时,不会走对应的 resume/pause 生命周期回调。(因为它只是隐藏了 Fragment 对应的 View,但 Fragment 还处于 resume 状态,详见FragmentTransaction.hide()- findings | by Nav Singh 🇨🇦 | Nerd For Tech | Medium)

    https://medium.com/nerd-for-tech/fragmenttransaction-hide-findings-5b998e959c82

  2. 当 Fragment 和 ViewPager/ViewPager2 共用时,resume/pause 生命周期回调失效。表现为没有展示的 Fragment 会回调 resume,而不可见的 Fragment 不会回调 pause。
pvTracker 的这个库在检测 Fragment 生命周期时也有上述问题。不过它也给出了解决方案:
  • 通过监听 ViewPager 页面切换来实现 Fragment + ViewPager 的可见性判断:在 ViewPager 初始化完毕后调用 PageViewTracker.getInstance().observePageChange(viewpager)
  • 如果 ViewPager + Fragment 嵌套在一个父 Fragment 还需在父 Fragment.onHiddenChanged() 方法里监听父 Fragment 的显示隐藏状态。

pvTracker 的解决方案是“把皮球踢给上层”,即上层手动调用一个方法来告知库当前 Fragment 的可见性。

3
全声明式 show 上报

pvtracker 是“半声明式 PV 上报”(Fragment 的可见性需要上层调方法)。

缺少一种“全声明式 show 上报”,即上层无需关注任何上报时机,只需生成埋点参数,就能自动实现 show 的上报。
Fragment 之所以会出现上述例外的情况,是因为 Fragment 的生命周期和其根视图的生命周期不同步。
是不是可以忘掉 Fragment,通过判定其根视图的可见性来表达 Fragment 的可见性?

所以需要一个控件维度全局可见性监听器,引用全网最优雅安卓控件可见性检测 中提供的解决方案:

https://juejin.cn/post/7165427955902971918

fun View.onVisibilityChange(
    viewGroups: List<ViewGroup> = emptyList()
, // 会被插入 Fragment 的容器集合
    needScrollListener: Boolean = true,
    block: (view: View, isVisible: Boolean) -> Unit
) {
    val KEY_VISIBILITY = "KEY_VISIBILITY".hashCode()
    val KEY_HAS_LISTENER = "KEY_HAS_LISTENER".hashCode()
    // 若当前控件已监听可见性,则返回
    if (getTag(KEY_HAS_LISTENER) == true) return

    // 检测可见性
    val checkVisibility = {
        // 获取上一次可见性
        val lastVisibility = getTag(KEY_VISIBILITY) as? Boolean
        // 判断控件是否出现在屏幕中
        val isInScreen = this.isInScreen
        // 首次可见性变更
        if (lastVisibility == null) {
            if (isInScreen) {
                block(this, true)
                setTag(KEY_VISIBILITY, true)
            }
        } 
        // 非首次可见性变更
        else if (lastVisibility != isInScreen) {
            block(this, isInScreen)
            setTag(KEY_VISIBILITY, isInScreen)
        }
    }

    // 全局重绘监听器
    class LayoutListener : ViewTreeObserver.OnGlobalLayoutListener {
        // 标记位用于区别是否是遮挡case
        var addedView: View? = null
        override fun onGlobalLayout() {
            // 遮挡 case
            if (addedView != null) {
                // 插入视图矩形区域
                val addedRect = Rect().also { addedView?.getGlobalVisibleRect(it) }
                // 当前视图矩形区域
                val rect = Rect().also { this@onVisibilityChange.getGlobalVisibleRect(it) }
                // 如果插入视图矩形区域包含当前视图矩形区域,则视为当前控件不可见
                if (addedRect.contains(rect)) {
                    block(this@onVisibilityChange, false)
                    setTag(KEY_VISIBILITY, false)
                } else {
                    block(this@onVisibilityChange, true)
                    setTag(KEY_VISIBILITY, true)
                }
            } 
            // 非遮挡 case
            else {
                checkVisibility()
            }
        }
    }

    val layoutListener = LayoutListener()
    // 编辑容器监听其插入视图时机
    viewGroups.forEachIndexed { index, viewGroup ->
        viewGroup.setOnHierarchyChangeListener(object : ViewGroup.OnHierarchyChangeListener {
            override fun onChildViewAdded(parent: View?, child: View?) {
                // 当控件插入,则置标记位
                layoutListener.addedView = child
            }

            override fun onChildViewRemoved(parent: View?, child: View?) {
                // 当控件移除,则置标记位
                layoutListener.addedView = null
            }
        })
    }
    viewTreeObserver.addOnGlobalLayoutListener(layoutListener)
    // 全局滚动监听器
    var scrollListener:ViewTreeObserver.OnScrollChangedListener? = null
    if (needScrollListener) {
         scrollListener = ViewTreeObserver.OnScrollChangedListener { checkVisibility() }
        viewTreeObserver.addOnScrollChangedListener(scrollListener)
    }
    // 全局焦点变化监听器
    val focusChangeListener = ViewTreeObserver.OnWindowFocusChangeListener { hasFocus ->
        val lastVisibility = getTag(KEY_VISIBILITY) as? Boolean
        val isInScreen = this.isInScreen
        if (hasFocus) {
            if (lastVisibility != isInScreen) {
                block(this, isInScreen)
                setTag(KEY_VISIBILITY, isInScreen)
            }
        } else {
            if (lastVisibility == true) {
                block(this, false)
                setTag(KEY_VISIBILITY, false)
            }
        }
    }
    viewTreeObserver.addOnWindowFocusChangeListener(focusChangeListener)
    // 为避免内存泄漏,当视图被移出的同时反注册监听器
    addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {
        override fun onViewAttachedToWindow(v: View?) {
        }

        override fun onViewDetachedFromWindow(v: View?) {
            v ?: return
            // 有时候 View detach 后,还会执行全局重绘,为此退后反注册
            post {
                try {
                    v.viewTreeObserver.removeOnGlobalLayoutListener(layoutListener)
                } catch (_: java.lang.Exception) {
                    v.viewTreeObserver.removeGlobalOnLayoutListener(layoutListener)
                }
                v.viewTreeObserver.removeOnWindowFocusChangeListener(focusChangeListener)
                if(scrollListener !=null) v.viewTreeObserver.removeOnScrollChangedListener(scrollListener)
                viewGroups.forEach { it.setOnHierarchyChangeListener(null) }
            }
            removeOnAttachStateChangeListener(this)
        }
    })
    // 标记已设置监听器
    setTag(KEY_HAS_LISTENER, true)
}

有了这个扩展方法,就可以在项目中的 BaseFragment 中进行全局 Fragment 的可见性监听了:

// 抽象 Fragment
abstract class BaseFragment:Fragment() {
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        if(detectVisibility){
            view.onVisibilityChange { view, isVisible ->
                onFragmentVisibilityChange(isVisible)
            }
        }
    }
    // 抽象属性:是否检测当前 fragment 的可见性
    abstract val detectVisibility: Boolean 
    open fun onFragmentVisibilityChange(show: Boolean) {}
}
其子类必须实现抽象属性detectVisibility,表示是否监听当前Fragment的可见性:
class FragmentA: BaseFragment() {
    override val detectVisibility: Boolean
        get() = true
    override fun onFragmentVisibilityChange(show: Boolean) {
        if(show) ... else ...
    }
}
为了让 show 上报不入侵基类,选择了一种可拔插的方案,先定义一个接口:
interface ExposureParam {
    val eventId: String
    fun getExtra(): Map<String, String?> = emptyMap()
    fun isForce():Boolean = false
}
该接口用于生成 show 上报的参数。任何需要上报 show 的页面都可以实现该接口:
class MaterialFragment : BaseFragment(), ExposureParam {
    abstract val tabName: String
    abstract val type: Int

    override val eventId: String
        get() = "material.show"

    override fun getExtra(): Map<String, String?> {
        return mapOf(
            "tab_name" to tabName,
            "type" to type.toString()
        )
    }
}

再自定义一个 Activity 生命周期监听器:
class PageVisibilityListener : Application.ActivityLifecycleCallbacks {
    // 页面可见性变化回调
    var onPageVisibilityChange: ((page: Any, isVisible: Boolean) -> Unit)? = null

    private val fragmentLifecycleCallbacks by lazy(LazyThreadSafetyMode.NONE) {
        object : FragmentManager.FragmentLifecycleCallbacks() {
            override fun onFragmentViewCreated(fm: FragmentManager, f: Fragment, v: View, savedInstanceState: Bundle?) {
                // 注册 Fragment 根视图可见性监听器
                if (f is ExposureParam) {
                    v.onVisibilityChange { view, isVisible ->
                        onPageVisibilityChange?.invoke(f, isVisible)
                    }
                }
            }
        }
    }

    override fun onActivityCreated(activity: Activity, p1: Bundle?) {
        // 注册 Fragment 生命周期监听器
        (activity as? FragmentActivity)?.supportFragmentManager?.registerFragmentLifecycleCallbacks(fragmentLifecycleCallbacks, true)
    }

    override fun onActivityDestroyed(activity: Activity) {
         // 注销 Fragment 生命周期监听器
        (activity as? FragmentActivity)?.supportFragmentManager?.unregisterFragmentLifecycleCallbacks(fragmentLifecycleCallbacks)
    }

    override fun onActivityStarted(p0: Activity) {
    }

    override fun onActivityResumed(activity: Activity) {
        // activity 可见
        if (activity is ExposureParam) onPageVisibilityChange?.invoke(activity, true)
    }

    override fun onActivityPaused(activity: Activity) {
        // activity 不可见
        if (activity is ExposureParam) onPageVisibilityChange?.invoke(activity, false)
    }

    override fun onActivityStopped(p0: Activity) {
    }

    override fun onActivitySaveInstanceState(p0: Activity, p1: Bundle) {
    }

}

该监听器同时监听了 Activity 和 Fragment 的可见性变化。其中 Activity 的可见性变化是借助于 ActivityLifecycleCallbacks,而 Fragment 的可见性变化是借助于其视图的可见性。

Activity 和 Fragment 的可见性监听使用同一个onPageVisibilityChange进行回调。
然后在 Application 中页面可见性监听器:
open class MyApplication : Application(){
    private val fragmentVisibilityListener by lazy(LazyThreadSafetyMode.NONE) {
        PageVisibilityListener().apply {
            onPageVisibilityChange = { page, isVisible ->
                // 当页面可见时,上报 show
                if (isVisible) {
                    (page as? ExposureParam)?.also { param ->
                        ReportUtil.reportShow(param.isForce(), param.eventId, param.getExtra())
                    }
                }
            }
        }
    }

    override fun onCreate() {
        super.onCreate()
        registerActivityLifecycleCallbacks(fragmentVisibilityListener)
    }

这样一来,上报时机已经完全自动化,只需要在上报的页面通过 ExposureParam 声明上报参数即可。


最后推荐一下我做的网站,玩Android: wanandroid.com ,包含详尽的知识体系、好用的工具,还有本公众号文章合集,欢迎体验和收藏!

推荐阅读:

Flow 转 LiveData 后数据丢了,肿么回事?
掌握这17张图,掌握RecyclerView中的“黑科技”预加载
Android 日志打印还能这么玩?