目录
  1. 1. 一、AOSP 源码视角:页面曝光的定义与触发时机
    1. 1.1. 1.1 什么算「页面曝光」?
    2. 1.2. 1.2 Fragment 与 Activity 的生命周期回调顺序
  2. 2. 二、Activity 页面浏览全埋点
    1. 2.1. 2.1 核心追踪器
  3. 3. 三、Fragment 页面浏览全埋点
    1. 3.1. 3.1 FragmentLifecycleCallbacks 实现
    2. 3.2. 3.2 全局注册
  4. 4. 四、Dialog 页面追踪
    1. 4.1. 4.1 Dialog 的页面状态判定
  5. 5. 五、Jetpack Navigation 组件集成
  6. 6. 六、Jetpack Compose 页面追踪
  7. 7. 七、架构流程图
  8. 8. 八、页面路径设计最佳实践
  9. 9. 九、ProGuard/R8 规则
  10. 10. 面试常考问题
【全埋点方案系列】AppViewScreen全埋点

页面浏览(Screen View)是用户行为分析的基本维度。全埋点需要在无业务代码侵入的情况下,自动采集每个 Activity 和 Fragment 的页面曝光、离开事件,并计算页面停留时长。

一、AOSP 源码视角:页面曝光的定义与触发时机

1.1 什么算「页面曝光」?

从 AOSP 视角,一个页面(Screen)的完整生命周期贯穿其从创建到销毁的全过程:

页面进入 (Screen Enter)    → Activity.onResume / Fragment.onResume
页面可见 (Screen Visible) → onResume 后
页面交互 (Screen Interactive) → onResume 后 + View.post 完成首帧绘制
页面被覆盖 (Screen Covered) → onPause(被其他页面部分覆盖)
页面隐藏 (Screen Hidden) → onStop(完全不可见)
页面离开 (Screen Leave) → onDestroy

全埋点的关键时机onResume 是页面真正对用户可见的时刻,也是业界标准的「page_view」事件触发点。

1.2 Fragment 与 Activity 的生命周期回调顺序

Activity 与其内 Fragment 的回调顺序:

Activity.onCreate
→ Fragment.onAttach → onCreate → onCreateView → onViewCreated
→ Activity.onStart
→ Fragment.onStart
→ Activity.onResume ← Activity 的 screen_view 上报点
→ Fragment.onResume ← Fragment 的 screen_view 上报点

顺序关系:Activity 的回调先于其内部 Fragment 执行。在 onFragmentResumed 回调中,Activity 已经处于 Resumed 状态,可以安全地获取 Activity 信息。

二、Activity 页面浏览全埋点

2.1 核心追踪器

/**
* Activity 页面浏览追踪器
*
* 设计要点:
* 1. 维护页面栈,记录当前页面和上一个页面(用于 referrer)
* 2. 记录每个页面的进入时间戳,在页面离开时计算停留时长
* 3. 处理配置变更(屏幕旋转)避免重复上报
*/
class ActivityScreenTracker : Application.ActivityLifecycleCallbacks {

// ========== 页面状态管理 ==========

// 页面进入时间戳:key = Activity className, value = enterTimestamp
private val screenEnterTimestamps = mutableMapOf<String, Long>()

// 页面栈:记录页面间的跳转关系
private val screenStack = ArrayDeque<ScreenInfo>()

// 当前页面标识
private var currentScreenClass = ""

// Session 级别的配置:用于检测配置变更
private val configChangeTracker = ConfigChangeTracker()

// ========== Lifecycle 回调实现 ==========

override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
// 记录创建时的 Bundle 状态,用于后续判断是否为配置变更重建
configChangeTracker.onActivityCreated(activity, savedInstanceState)
}

override fun onActivityStarted(activity: Activity) {}

override fun onActivityResumed(activity: Activity) {
val screenClass = activity.javaClass.name
val screenName = resolveScreenName(activity)

// === 去重检查:配置变更(旋转)不重复上报 ===
if (configChangeTracker.isConfigChange(activity)) {
// 更新页面引用,但不上报新的 screen_view
currentScreenClass = screenClass
return
}

// === 如果切换到新页面,先结束上一个页面的计时 ===
if (currentScreenClass.isNotEmpty() && currentScreenClass != screenClass) {
endScreenTracking(currentScreenClass)
}

// === 开始新页面的追踪 ===
currentScreenClass = screenClass
screenEnterTimestamps[screenClass] = System.currentTimeMillis()

// === 获取上一个页面信息(referrer) ===
val referrer = screenStack.lastOrNull()?.className ?: ""

// === 获取页面启动参数 ===
val launchParams = extractLaunchParams(activity)

// === 推入页面栈 ===
screenStack.addLast(
ScreenInfo(
className = screenClass,
screenName = screenName,
enterTimestamp = System.currentTimeMillis()
)
)

// === 上报 screen_view 事件 ===
AnalyticsSDK.track("screen_view", buildMap {
put("screen_name", screenName)
put("screen_class", screenClass)
put("screen_title", activity.title?.toString() ?: "")
put("referrer", referrer)
put("is_new_page", true)
put("has_saved_instance_state", activity.intent?.extras != null)
put("intent_action", activity.intent?.action ?: "")
put("timestamp", System.currentTimeMillis())

// 页面来源参数
putAll(launchParams)

// 进程/任务信息
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
put("task_id", activity.taskId)
}
})
}

override fun onActivityPaused(activity: Activity) {
// 页面被部分覆盖(如弹出透明 Activity)
// 在某些分析需求中,这也算页面离开
}

override fun onActivityStopped(activity: Activity) {
val screenClass = activity.javaClass.name

// 如果这是被停止的当前页面,记录页面离开
if (currentScreenClass == screenClass && activity.isFinishing) {
endScreenTracking(screenClass)
currentScreenClass = ""
screenStack.removeLastOrNull()
}
}

override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {
// 可用于持久化页面状态
}

override fun onActivityDestroyed(activity: Activity) {
configChangeTracker.onActivityDestroyed(activity)

val screenClass = activity.javaClass.name
if (activity.isFinishing) {
endScreenTracking(screenClass)
// 如果屏幕栈为空,应用可能即将退出
}
}

// ========== 辅助方法 ==========

private fun resolveScreenName(activity: Activity): String {
// 优先使用 Activity 的 label(可在 AndroidManifest 中配置)
return try {
val info = activity.packageManager
.getActivityInfo(ComponentName(activity, activity.javaClass), 0)
info.loadLabel(activity.packageManager).toString()
} catch (e: PackageManager.NameNotFoundException) {
activity.javaClass.simpleName
}
}

private fun extractLaunchParams(activity: Activity): Map<String, Any?> {
val intent = activity.intent ?: return emptyMap()
return buildMap {
intent.extras?.keySet()?.forEach { key ->
val value = intent.extras?.get(key)
// 过滤掉非基本类型的 extras(如 Parcelable 对象)
if (value is String || value is Int || value is Long ||
value is Boolean || value is Double || value is Float) {
put("param_$key", value)
}
}
put("referrer_uri", activity.referrer?.toString() ?: "")
}
}

private fun endScreenTracking(screenClass: String) {
val enterTime = screenEnterTimestamps[screenClass] ?: return
val duration = System.currentTimeMillis() - enterTime

AnalyticsSDK.track("screen_leave", mapOf(
"screen_class" to screenClass,
"duration_ms" to duration,
"timestamp" to System.currentTimeMillis()
))

screenEnterTimestamps.remove(screenClass)
}

// ========== 数据类 ==========

data class ScreenInfo(
val className: String,
val screenName: String,
val enterTimestamp: Long
)
}

/**
* 配置变更检测器
* 用于识别屏幕旋转、语言切换等导致的 Activity 重建
*/
class ConfigChangeTracker {
// 记录最近销毁的 Activity:key = className, value = 销毁时间
private val recentlyDestroyed = LinkedHashMap<String, Long>()

// 销毁与重建的时间窗口(300ms 内视为配置变更)
private val windowMs = 300L

fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
// savedInstanceState != null 是配置变更的强信号
if (savedInstanceState != null) {
recentlyDestroyed[activity.javaClass.name] = System.currentTimeMillis()
}
}

fun onActivityDestroyed(activity: Activity) {
if (activity.isChangingConfigurations) {
recentlyDestroyed[activity.javaClass.name] = System.currentTimeMillis()
}
// 清理过期记录
val now = System.currentTimeMillis()
recentlyDestroyed.entries.removeAll { (_, time) -> now - time > 5000 }
}

fun isConfigChange(activity: Activity): Boolean {
val lastDestroyTime = recentlyDestroyed[activity.javaClass.name] ?: return false
val elapsed = System.currentTimeMillis() - lastDestroyTime
return elapsed < windowMs
}
}

三、Fragment 页面浏览全埋点

3.1 FragmentLifecycleCallbacks 实现

/**
* Fragment 页面浏览追踪器
*
* 设计要点:
* 1. 处理 ViewPager 预加载导致的假曝光
* 2. 处理嵌套 Fragment(ChildFragmentManager)
* 3. 区分 Fragment 的显示/隐藏(show/hide)vs 添加/移除(add/remove)
*/
class FragmentScreenTracker : FragmentManager.FragmentLifecycleCallbacks() {

// Fragment 进入时间戳:key = fragmentHashCode_className
private val fragmentEnterTimestamps = mutableMapOf<String, Long>()

// 记录已上报过 screen_view 的 Fragment(防止重复上报)
private val reportedFragments = WeakHashMap<Fragment, Boolean>()

// 当前所在的 Activity(用于构建完整 page_path)
private var currentActivity: Activity? = null

override fun onFragmentPreAttached(fm: FragmentManager, f: Fragment, context: Context) {
// 在 Fragment 附加到 Activity 之前
}

override fun onFragmentCreated(fm: FragmentManager, f: Fragment, savedInstanceState: Bundle?) {
// Fragment onCreate,此时 View 尚未创建
}

override fun onFragmentViewCreated(
fm: FragmentManager,
f: Fragment,
v: View,
savedInstanceState: Bundle?
) {
// View 创建完成,可以开始设置埋点监听
// 注册 ViewTreeObserver 监听首帧绘制(用于计算页面加载耗时)
}

override fun onFragmentStarted(fm: FragmentManager, f: Fragment) {
// Fragment 对用户可见但对交互尚未就绪
}

override fun onFragmentResumed(fm: FragmentManager, f: Fragment) {
// === 页面曝光判定 ===

// Step 1: 去重检查
if (reportedFragments[f] == true) return

// Step 2: 检查 Fragment 是否真正可见
if (!isFragmentTrulyVisible(f)) {
// Fragment 被预加载(ViewPager)或 hide() 隐藏
return
}

// Step 3: 构建页面标识
val screenName = f.javaClass.simpleName
val screenClass = f.javaClass.name

// Step 4: 获取宿主 Activity 构建完整 page_path
val hostActivity = f.requireActivity()
val activityName = hostActivity.javaClass.simpleName
val pagePath = "$activityName/${screenName}"

val key = "${f.hashCode()}_$screenClass"
fragmentEnterTimestamps[key] = System.currentTimeMillis()

// Step 5: 上报 screen_view
AnalyticsSDK.track("screen_view", buildMap {
put("screen_name", screenName)
put("screen_class", screenClass)
put("page_path", pagePath)
put("is_fragment", true)
put("parent_activity", activityName)
put("parent_activity_class", hostActivity.javaClass.name)
put("fragment_tag", f.tag ?: "")
put("fragment_id", f.id)
put("is_hidden", f.isHidden)
put("is_in_viewpager", isInViewPager(f))
put("timestamp", System.currentTimeMillis())

// Fragment 参数
f.arguments?.let { args ->
val params = mutableMapOf<String, Any?>()
args.keySet().forEach { key ->
val value = args.get(key)
if (value is String || value is Int || value is Long) {
params["param_$key"] = value
}
}
put("fragment_arguments", params)
}
})

reportedFragments[f] = true
}

override fun onFragmentPaused(fm: FragmentManager, f: Fragment) {
// Fragment 不再处于 resumed 状态
val key = "${f.hashCode()}_${f.javaClass.name}"
val enterTime = fragmentEnterTimestamps[key] ?: return

val duration = System.currentTimeMillis() - enterTime
AnalyticsSDK.track("screen_leave", mapOf(
"screen_name" to f.javaClass.simpleName,
"screen_class" to f.javaClass.name,
"is_fragment" to true,
"duration_ms" to duration,
"parent_activity" to (f.activity?.javaClass?.simpleName ?: "")
))

fragmentEnterTimestamps.remove(key)
}

override fun onFragmentDestroyed(fm: FragmentManager, f: Fragment) {
reportedFragments.remove(f)
}

// ========== 可见性判断 ==========

/**
* 判断 Fragment 是否真正对用户可见
*
* 排除以下情况:
* 1. ViewPager 预加载的 Fragment(未滑到当前页)
* 2. 被 hide() 隐藏的 Fragment
* 3. 父 Fragment 被隐藏导致子 Fragment 不可见
*/
private fun isFragmentTrulyVisible(fragment: Fragment): Boolean {
// Fragment 自身可见性
val isFragmentVisible = fragment.isVisible && !fragment.isHidden

// 父 Fragment 的可见性(递归检查)
val parentFragment = fragment.parentFragment
val isParentVisible = parentFragment == null || isFragmentTrulyVisible(parentFragment)

// ViewPager 可见性
val isViewPagerPrimary = isViewPagerPrimaryItem(fragment)

return isFragmentVisible && isParentVisible && isViewPagerPrimary
}

private fun isInViewPager(fragment: Fragment): Boolean {
val parent = fragment.parentFragment ?: return false
val parentView = parent.view ?: return false
// 检查父 View 是否包含 ViewPager
return findViewPagerInView(parentView) != null
}

private fun isViewPagerPrimaryItem(fragment: Fragment): Boolean {
val parent = fragment.parentFragment ?: return true // 无父 Fragment,视为可见
val parentView = parent.view ?: return true

val viewPager = findViewPagerInView(parentView) ?: return true // 无 ViewPager

// 检查当前 Fragment 是否为 ViewPager 的 primary item
val currentItem = viewPager.currentItem
val adapter = viewPager.adapter ?: return true

return try {
// 比较 fragment 的 tag 或直接比较引用
val primaryFragment = parent.childFragmentManager
.findFragmentByTag("android:switcher:${viewPager.id}:$currentItem")
primaryFragment == fragment
} catch (_: Exception) {
true // 保守策略:无法判断时视为可见
}
}

private fun findViewPagerInView(view: View): ViewPager? {
if (view is ViewPager) return view
if (view is ViewGroup) {
for (i in 0 until view.childCount) {
findViewPagerInView(view.getChildAt(i))?.let { return it }
}
}
return null
}
}

3.2 全局注册

/**
* 在 Application 中全局注册 Fragment 生命周期监听
*/
class TrackingApplication : Application() {
override fun onCreate() {
super.onCreate()

registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks {

override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
// 注册 Activity 页面追踪
val activityTracker = ActivityScreenTracker()
// ...

// 注册 Fragment 页面追踪
if (activity is FragmentActivity) {
activity.supportFragmentManager
.registerFragmentLifecycleCallbacks(
FragmentScreenTracker(),
true // recursive: 监听嵌套 Fragment
)
}
}

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) {}
})
}
}

四、Dialog 页面追踪

4.1 Dialog 的页面状态判定

/**
* Dialog 页面追踪器
*
* 问题:Dialog 覆盖在 Activity 上,此时 Activity 处于 onPause 状态。
* 如果埋点逻辑在 onPause 时结束当前页面计时,
* Dialog 的弹窗将被误判为页面离开。
*
* 方案:
* 1. 判断 onPause 的原因是否为 Dialog 覆盖
* 2. 若是,不结束当前页面计时,而是追加一条 overlay_screen_view
*/
class DialogScreenTracker {

private val activeDialogs = WeakHashMap<Dialog, Long>()

fun onDialogShown(dialog: Dialog) {
val dialogClass = dialog.javaClass.name
val activityClass = getHostActivity(dialog)?.javaClass?.simpleName ?: ""

activeDialogs[dialog] = System.currentTimeMillis()

AnalyticsSDK.track("screen_view", mapOf(
"screen_name" to dialogClass.substringAfterLast('.'),
"screen_class" to dialogClass,
"screen_type" to "overlay",
"parent_activity" to activityClass,
"is_dialog" to true,
"timestamp" to System.currentTimeMillis()
))
}

fun onDialogDismissed(dialog: Dialog) {
val enterTime = activeDialogs.remove(dialog) ?: return
val duration = System.currentTimeMillis() - enterTime

AnalyticsSDK.track("screen_leave", mapOf(
"screen_name" to dialog.javaClass.name.substringAfterLast('.'),
"screen_class" to dialog.javaClass.name,
"screen_type" to "overlay",
"duration_ms" to duration
))
}

private fun getHostActivity(dialog: Dialog): Activity? {
val context = dialog.context
var ctx = context
while (ctx is ContextWrapper) {
if (ctx is Activity) return ctx
ctx = ctx.baseContext
}
return null
}
}

// 扩展函数:启用 Dialog 追踪
fun Dialog.enableScreenTracking() {
val tracker = DialogScreenTracker()
setOnShowListener { tracker.onDialogShown(this) }
setOnDismissListener { tracker.onDialogDismissed(this) }
}

五、Jetpack Navigation 组件集成

/**
* Navigation Component 的页面浏览追踪
*
* Navigation 使用 NavController 管理 Fragment 跳转,
* 可以通过 NavController.OnDestinationChangedListener 监听页面变化。
*/
class NavigationScreenTracker : NavController.OnDestinationChangedListener {

override fun onDestinationChanged(
controller: NavController,
destination: NavDestination,
arguments: Bundle?
) {
val screenName = destination.label?.toString()
?: destination.displayName.substringAfterLast('/')
val screenClass = destination.displayName

// 构建 page_path(包含导航图信息)
val graphName = controller.graph.displayName.substringAfterLast('/')
val pagePath = "$graphName/$screenName"

val referrer = destination.parent?.displayName?.substringAfterLast('/') ?: ""

AnalyticsSDK.track("screen_view", mapOf(
"screen_name" to screenName,
"screen_class" to screenClass,
"page_path" to pagePath,
"referrer" to referrer,
"navigation_graph" to graphName,
"destination_id" to destination.id,
"is_navigation" to true,
"timestamp" to System.currentTimeMillis(),
"arguments" to arguments?.keySet()?.joinToString()
))
}
}

// 在 Activity 中注册
class MainActivity : AppCompatActivity() {
private val navTracker = NavigationScreenTracker()

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// ... setup navigation
navController.addOnDestinationChangedListener(navTracker)
}
}

六、Jetpack Compose 页面追踪

/**
* Compose 的页面浏览追踪
*
* Compose 使用 Navigation Compose 管理路由,
* 可以通过 NavController 的 currentBackStackEntry 变化监听
*/
@Composable
fun TrackedNavHost(
navController: NavHostController,
startDestination: String,
builder: NavGraphBuilder.() -> Unit
) {
// 监听当前路由变化
val currentBackStackEntry by navController.currentBackStackEntryAsState()

LaunchedEffect(currentBackStackEntry) {
currentBackStackEntry?.let { entry ->
val route = entry.destination.route ?: return@let
val arguments = entry.arguments

AnalyticsSDK.track("screen_view", mapOf(
"screen_name" to route,
"screen_class" to route,
"is_compose" to true,
"arguments" to arguments?.keySet()?.joinToString(),
"timestamp" to System.currentTimeMillis()
))
}
}

NavHost(
navController = navController,
startDestination = startDestination,
builder = builder
)
}

七、架构流程图

                    ┌──────────────────────────────┐
│ Application 全局注册 │
│ ActivityLifecycleCallbacks │
└──────────────┬───────────────┘

┌───────────────────────┼───────────────────────┐
│ │ │
▼ ▼ ▼
┌─────────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ ActivityScreenTracker│ │ FragmentScreen- │ │ DialogScreen- │
│ onActivityResumed │ │ Tracker │ │ Tracker │
│ │ │ onFragmentResumed│ │ onDialogShown │
│ → screen_view │ │ → screen_view │ │ → screen_view │
│ → screen_stack push │ │ → visibility │ │ (screen_type= │
│ → referrer 构建 │ │ check (VP等) │ │ overlay) │
│ → configChange 去重 │ │ → page_path 构建 │ │ │
└──────────┬──────────┘ └────────┬─────────┘ └────────┬─────────┘
│ │ │
└─────────────────────┼────────────────────┘

┌───────────▼───────────┐
│ 同一会话页面跳转去重 │
│ - 同名页面连续出现? │
│ - 是否配置变更? │
│ - 停留时长阈值过滤 │
└───────────┬───────────┘

┌───────────▼───────────┐
│ AnalyticsSDK.track() │
│ → screen_view │
│ → screen_leave │
└───────────────────────┘

八、页面路径设计最佳实践

/**
* 页面路径(page_path)设计规范
*
* 目标:构建一个可读、可分析的层级化页面路径
*
* 格式:{Activity}/{Fragment}[/{ChildFragment}]
* 示例:
* MainActivity/HomeFragment
* MainActivity/ProfileFragment/SettingsFragment
* MainActivity/ShopFragment?category=electronics
*/
object PagePathBuilder {

fun build(activity: Activity, fragment: Fragment? = null): String {
return buildString {
append(activity.javaClass.simpleName)
if (fragment != null) {
append("/")
append(buildFragmentPath(fragment))
}
}
}

private fun buildFragmentPath(fragment: Fragment): String {
val parts = mutableListOf<String>()
var current: Fragment? = fragment
while (current != null) {
parts.add(0, current.javaClass.simpleName)
current = current.parentFragment
}
return parts.joinToString("/")
}

/**
* 附加查询参数(如 Tab 索引、分类 ID 等)
*/
fun withParams(path: String, params: Map<String, Any?>): String {
if (params.isEmpty()) return path
val queryString = params.entries.joinToString("&") { (k, v) -> "$k=$v" }
return "$path?$queryString"
}
}

九、ProGuard/R8 规则

-keep class com.example.tracking.screen.ActivityScreenTracker { *; }
-keep class com.example.tracking.screen.FragmentScreenTracker { *; }
-keep class com.example.tracking.screen.DialogScreenTracker { *; }
-keep class com.example.tracking.screen.ConfigChangeTracker { *; }

面试常考问题

Q1:Fragment 的 onResume 和 Activity 的 onResume 顺序是什么?如何避免重复上报?

顺序:Fragment.onResume 在 Activity.onResume 触发之前执行(对于已添加在 Activity 中的 Fragment)。完整顺序为:Fragment.onStart → Activity.onStart → Fragment.onResume → Activity.onResume。注意:Fragment 的 onResume 在 Activity 的 onResume 之前。避免重复上报的策略:(1)Activity 的 screen_view 上报在 onActivityResumed 中,Fragment 的 screen_view 上报在 onFragmentResumed 中;(2)两者使用不同的 page_path 层级——Activity 上报 screen_name = "MainActivity",Fragment 上报 screen_name = "HomeFragment" + parent_activity = "MainActivity";(3)在 Fragment 的 screen_view 中记录 is_fragment = true,方便后端区分和去重;(4)如果 Activity 只作为容器(没有独立的内容),可以选择只上报 Fragment 的 screen_view,跳过 Activity 的。

Q2:如何处理屏幕旋转导致的重复 screen_view?

旋转时 Activity 重建,onPause → onDestroy → onCreate → onResume 会再次触发。解决方案:(1)通过 Activity.isChangingConfigurations() 在 onDestroy 中识别是否为配置变更;(2)使用 ConfigChangeTracker 记录最近销毁的 Activity,在 onResume 时检查是否在时间窗口内重建——若在 300ms 内重建,则是配置变更,跳过 screen_view 上报;(3)通过 savedInstanceState != null 判断是否为重建,但需要注意进程被杀后重建也会有非 null 的 savedInstanceState;(4)通过 ViewModel 在配置变更期间的存活特性来标记已上报状态——ViewModel 在配置变更时存活但进程重建时销毁,可准确区分两者。

Q3:Dialog/DialogFragment 算独立页面吗?

取决于分析需求。Dialog 通常不视为独立页面,但要单独统计曝光。标准做法:(1)弹窗类型的 Dialog(AlertDialog、BottomSheetDialog)作为 screen_type=overlay 上报,不影响宿主页面的停留时长计算;(2)全屏 Dialog 或 DialogFragment(填满整个屏幕)可能被视为独立页面,作为 screen_type=fullscreen_dialog 上报;(3)DialogFragment 通过 FragmentManager.FragmentLifecycleCallbacks 可以自动跟踪(它也是 Fragment),但要区分它与普通 Fragment 的不同——可以使用 dialog?.window?.attributes 的窗口大小来判断是否为全屏;(4)关键:当 Dialog 覆盖 Activity 时,宿主 Activity 的 onPause 被调用,此时应检测 Activity 是否被 Dialog 覆盖(通过 hasWindowFocus() 或检查 DecorView 的子 View),若是则不上报宿主页面的 screen_leave。

Q4:ViewPager + TabLayout 组合时,如何正确识别当前可见的 Fragment?

ViewPager 通过 setOffscreenPageLimit() 预加载相邻页面,默认预加载 1 页。预加载的 Fragment 也会经历 onResume,导致埋点上报了用户并未看到的页面。解决方案:(1)通过 ViewPager.getCurrentItem() 获取当前可见的页面索引;(2)使用 FragmentPagerAdapterFragmentStateAdapter 时,通过 tag "android:switcher:${viewPager.id}:${position}"FragmentManager 中查找对应 Fragment;(3)在 onFragmentResumed 中检查 Fragment 是否为 ViewPager 的 primary item,若不是则不上报;(4)对于 ViewPager2,可以使用 viewPager2.getChildAt(0) 作为 RecyclerView,再通过 RecyclerView.getLayoutManager().findFirstCompletelyVisibleItemPosition() 获取完全可见的页面;(5)当用户从 Tab A 滑到 Tab B 时,A 的 Fragment 在 onPause 中结束页面计时,B 的 Fragment 在 onResume 中开始新的页面计时。

Q5:App 中有多个 Activity 和 Fragment,如何设计统一的 page_path 规范?

设计统一的 page_path 规范应遵循以下原则:(1)层级结构{Activity简单类名}/{Fragment层级路径},如 MainActivity/HomeFragment/NewsListFragment;(2)一致性:Activity 和 Fragment 都使用简单类名(不含包名),保持路径简洁;(3)可扩展:支持用 ? 附加参数,如 ShopActivity/ProductFragment?product_id=123;(4)兼容性:对 Dialog 类型使用 screen_type=overlay 标记,对 Compose 页面使用 is_compose=true 标记;(5)自动化:通过 PagePathBuilder 统一构建路径,业务方无需手动拼接;(6)国际化:优先使用 AndroidManifest 中定义的 android:label 作为页面名称(可本地化),降级使用类名。神策数据、友盟等 SDK 通常也遵循类似的 page_path 设计。AOSP 源码:Activity 生命周期回调通过 Application.ActivityLifecycleCallbacks 接口定义,实际回调由 ActivityThread 触发(frameworks/base/core/java/android/app/ActivityThread.java 中的 performResumeActivity() / performPauseActivity())。

打赏
  • 微信
  • 支付宝

评论