页面浏览(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 核心追踪器 class ActivityScreenTracker : Application.ActivityLifecycleCallbacks { private val screenEnterTimestamps = mutableMapOf<String, Long >() private val screenStack = ArrayDeque<ScreenInfo>() private var currentScreenClass = "" private val configChangeTracker = ConfigChangeTracker() override fun onActivityCreated (activity: Activity , savedInstanceState: 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)) { currentScreenClass = screenClass return } if (currentScreenClass.isNotEmpty() && currentScreenClass != screenClass) { endScreenTracking(currentScreenClass) } currentScreenClass = screenClass screenEnterTimestamps[screenClass] = System.currentTimeMillis() val referrer = screenStack.lastOrNull()?.className ?: "" val launchParams = extractLaunchParams(activity) screenStack.addLast( ScreenInfo( className = screenClass, screenName = screenName, enterTimestamp = System.currentTimeMillis() ) ) 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 ) { } 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 { 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) 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 ) } class ConfigChangeTracker { private val recentlyDestroyed = LinkedHashMap<String, Long >() private val windowMs = 300L fun onActivityCreated (activity: Activity , savedInstanceState: Bundle ?) { 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 实现 class FragmentScreenTracker : FragmentManager.FragmentLifecycleCallbacks () { private val fragmentEnterTimestamps = mutableMapOf<String, Long >() private val reportedFragments = WeakHashMap<Fragment, Boolean >() private var currentActivity: Activity? = null override fun onFragmentPreAttached (fm: FragmentManager , f: Fragment , context: Context ) { } override fun onFragmentCreated (fm: FragmentManager , f: Fragment , savedInstanceState: Bundle ?) { } override fun onFragmentViewCreated ( fm: FragmentManager , f: Fragment , v: View , savedInstanceState: Bundle ? ) { } override fun onFragmentStarted (fm: FragmentManager , f: Fragment ) { } override fun onFragmentResumed (fm: FragmentManager , f: Fragment ) { if (reportedFragments[f] == true ) return if (!isFragmentTrulyVisible(f)) { return } val screenName = f.javaClass.simpleName val screenClass = f.javaClass.name val hostActivity = f.requireActivity() val activityName = hostActivity.javaClass.simpleName val pagePath = "$activityName /${screenName} " val key = "${f.hashCode()} _$screenClass " fragmentEnterTimestamps[key] = System.currentTimeMillis() 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()) 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 ) { 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) } private fun isFragmentTrulyVisible (fragment: Fragment ) : Boolean { val isFragmentVisible = fragment.isVisible && !fragment.isHidden val parentFragment = fragment.parentFragment val isParentVisible = parentFragment == null || isFragmentTrulyVisible(parentFragment) 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 return findViewPagerInView(parentView) != null } private fun isViewPagerPrimaryItem (fragment: Fragment ) : Boolean { val parent = fragment.parentFragment ?: return true val parentView = parent.view ?: return true val viewPager = findViewPagerInView(parentView) ?: return true val currentItem = viewPager.currentItem val adapter = viewPager.adapter ?: return true return try { 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 全局注册 class TrackingApplication : Application () { override fun onCreate () { super .onCreate() registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks { override fun onActivityCreated (activity: Activity , savedInstanceState: Bundle ?) { val activityTracker = ActivityScreenTracker() if (activity is FragmentActivity) { activity.supportFragmentManager .registerFragmentLifecycleCallbacks( FragmentScreenTracker(), true ) } } 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 的页面状态判定 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 } } fun Dialog.enableScreenTracking () { val tracker = DialogScreenTracker() setOnShowListener { tracker.onDialogShown(this ) } setOnDismissListener { tracker.onDialogDismissed(this ) } }
五、Jetpack Navigation 组件集成 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 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() )) } } class MainActivity : AppCompatActivity () { private val navTracker = NavigationScreenTracker() override fun onCreate (savedInstanceState: Bundle ?) { super .onCreate(savedInstanceState) navController.addOnDestinationChangedListener(navTracker) } }
六、Jetpack Compose 页面追踪 @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 │ └───────────────────────┘
八、页面路径设计最佳实践 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("/" ) } 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)使用 FragmentPagerAdapter 或 FragmentStateAdapter 时,通过 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())。