简介这是一份面向iOS开发者的用户停留浏览页面时间统计实现示例覆盖了从页面加载、事件监听、时间戳记录到上报的核心流程。示例中实现了滚动时刷新停留时间、每隔1分钟触发奖励判断、静止30秒后按间隔补记时长、离开页面即停止计时并保存数据等细节并配有红包激励图与服务端端点图方便对照理解。压缩包共6个文件包含XLCircleProgress和XLCircle的.h/.m源码共4个以及2张演示图片整体仅48KB代码精简可直接阅读或嵌入项目。已有403人学习下载。读者可获得完整的时长统计逻辑、圆形进度展示控件和数据对接思路适用于红包签到、用户时长分析、产品迭代评估等场景方便按需修改前端监听规则与后端上报字段适合iOS初中级开发者快速上手。1. 用户停留浏览页面的时间统计最像“最简单”的埋点为什么总对不上业务运营隔三差五就会来问一句“用户在这个页面到底待了多久为什么后台看到的停留时长和我们自己掐表感觉的差这么多” 用户停留浏览页面的时间统计说白了就是记录一个用户从进入页面到离开页面的时间窗口再换算成秒数上报。但真正动手做过的同学都知道这个“最简单”的埋点是数据质量事故的重灾区SPA路由切换没结算、切后台回来时间叠加错、卸载上报被浏览器拦截、用户挂着页面不动时长虚高每一个都能让最终报表变成玄学。这篇文章我从口径定义、前端采集、上报策略、SPA边界讲到数据校验照着做能拿到一套可复现、可解释、能自检的停留时长方案适合正在做用户行为分析、流量分析或者需要给运营交付页面粘性指标的团队。2. 先定义“一次停留”timeOrigin、可见性切换与有效时长的口径拆解停留时长这个指标难不在代码难在“一次停留”这个边界。同一个用户打开页面、切后台聊了十分钟微信、再切回来继续看你说这算一次还是两次用户把页面挂在后台一晚上没关第二天早上回来点了两下系统要不要记16个小时这些问题不先在口径层面定死后端的SQL写得再漂亮也是白搭。2.1 进入与离开的四种定义从时间戳到会话边界从业界常见做法来看停留时长的“进入”和“离开”有四种定义选哪种取决于你要回答什么业务问题。第一种是“页面加载完成到页面卸载”用 performance.timing.navigationStart 或 timeOrigin 作为起点unload/pagehide 作为终点。这是最传统的定义适合多页应用MPA但它在SPA里基本失效因为SPA不卸载页面。第二种是“用户可见到用户不可见”以 visibilitychange 为代表页面切到后台就结算当前区间切回前台就重新开一段计时。第三种是“路由进入到路由离开”在SPA里以推入新路由为边界精确到页面级适合做页面点击流分析。第四种是“会话级停留”把用户在 app 内的一段连续操作视为一个会话跨页面累加时间这是运营最常挂在嘴边的“人均使用时长”。我通常的做法是同时维护两套口径一套叫 wallTime墙钟时间从进入到离开的总时长一套叫 activeTime有效活跃时长过滤掉切后台、挂机和休眠。核心指标看 activeTimewallTime 只作为分母做质量评估。你如果只上报一个数后面想拆分维度就得重新埋点后悔药可不好吃。2.2 前后台切换是主时钟visibilitychange 的语义与兼容既然要统计“浏览”页面的时间那用户看不见页面的时候这时间就不该计入有效时长。主时钟用 document.visibilityState 和 visibilitychange 事件比一直跑 setInterval 去数秒要省电也准确。这里的语义要理解透visibilityState 只有两个稳定取值——visible 和 hidden但隐含有“页面被切换走”和“页面被遮挡”的区别。比如 PC 上用户把浏览器最小化、切到另一个应用窗口或者移动端 app 切后台document.hidden 都会变成 true而页面在后台标签页里hidden 也是 true。这些情况都应该视为“不可见”立刻结算当前区间。兼容性方面现代浏览器普遍支持 visibilitychange但有一个历史遗留问题早期 Safari尤其是 iOS 11 以下的版本在页面切换后台时触发的是 pagehide 而不是 visibilitychange而且触发时机普遍偏晚。通用做法是 visibilitychange 和 pagehide 双监听谁先来就按谁来后到的做幂等处理避免同一段时间被重复结算两次。提示不要在 visibilitychange 里直接调用 report 同步接口。它触发频繁而且切后台时网络请求可能被系统冻结。先累加到内存再决定何时上报。2.3 有效时长怎么算过滤抖动、挂机、休眠写埋点代码之前先把三条过滤规则定好这是避免数据被脏值污染的底线。第一是抖动过滤。用户误触链接、秒开秒退这类时长小于 3 秒我一般设 3000ms的区间不应该计入有效时长。你别小看这个阈值如果不过滤一次误触就能把页面平均停留时长拉低一个量级。第二是挂机过滤。用户打开页面放着不动没有鼠标移动、没有键盘输入、没有触摸滚动超过 5 分钟业务方通常能接受 5~10 分钟视为挂机之后的墙钟时间不再累计到 activeTime。第三是休眠过滤。设备锁屏、系统休眠会暂停 CPU 和 JS 定时器如果只用 Date.now() 差值恢复后会发现计算出的时长远大于真实间隔。这种情况应该用最后一次用户交互时间作为截断点或者在上报时附上“理论时间”和“活跃时间”两个字段由后端做归因。这三条规则不是拍脑袋定的。抖动阈值要和你页面内容的最小阅读时长匹配挂机阈值要看业务场景比如视频页 10 分钟没操作是正常的但图文资讯页 5 分钟不动大概率已经走神了。参数放到配置里别写死在代码里后面调的时候你就知道省心多少了。3. 用 Page Visibility API 采集活跃停留时长最小埋点代码与上报参数这一章给出可以直接抄的核心实现。我不追求造轮子代码以清晰、可改、能自解释为准尽量不依赖框架方便你移植到 React、Vue 或者原生项目里。3.1 最小可运行实现visibilitychange 增量计时// stay-tracker.js // 一个极简的停留时长采集器核心策略可见时累加隐藏时结算 class StayTracker { constructor(options {}) { this.options Object.assign({ idleTimeout: 5 * 60 * 1000, // 挂机阈值5分钟无交互视为挂机 minDuration: 3000, // 小于该值的区间不记入活跃时长ms maxIdleReportDelay: 10 * 1000 // 页面进入隐藏后最多延迟多久触发上报兜底 }, options); this.pageId this.generatePageId(); // 当前页面实例的唯一ID this.segmentStart 0; // 当前可见时间段的开始时间戳 this.activeDuration 0; // 已累计的有效活跃时长ms this.lastInteractionAt 0; // 最后一次用户交互时间戳 this.locked false; // 防重复结算锁 this.init(); } init() { // 页面初始可见性判断首帧可能取不到延迟一帧再判定 requestAnimationFrame(() { const start Date.now(); this.segmentStart start; this.lastInteractionAt start; }); document.addEventListener(visibilitychange, () { if (document.hidden) { this.settle(visibilitychange-hidden); } else { this.resume(visibilitychange-visible); } }); // 监听交互刷新活跃时间 [mousemove, mousedown, keydown, touchstart, scroll].forEach(eventName { document.addEventListener(eventName, () { this.lastInteractionAt Date.now(); }, { passive: true }); }); // 页面卸载前的兜底结算注意不要在这里发同步请求 window.addEventListener(pagehide, () { this.settle(pagehide); }); // 兜底上报如果页面一直可见但事件循环被长时间阻塞定期尝试上报 setInterval(() { if (!document.hidden) return; this.settle(interval); }, this.options.maxIdleReportDelay); } settle(reason) { if (this.locked) return; const now Date.now(); this.locked true; const intervalStart this.segmentStart || now; const rawDelta now - intervalStart; const lastActive this.lastInteractionAt || intervalStart; // 有效时长取“当前段开始”到“最后一次交互/当前时间”的较小值 const effective Math.min(rawDelta, now - lastActive); // 抖动过滤小于 minDuration 的区间直接丢弃 if (rawDelta this.options.minDuration effective 0) { // 挂机过滤超过 idleTimeout 的时间不累计 this.activeDuration Math.min(effective, this.options.idleTimeout); } this.segmentStart 0; this.lastInteractionAt 0; } resume(reason) { if (!this.locked) { // 说明文档可见性状态恢复时没有对应的 hidden 事件属于边缘情况 // 这里主动把上一段丢弃防止时间被重复计算 } this.locked false; const now Date.now(); this.segmentStart now; this.lastInteractionAt now; } generatePageId() { // 页面实例ID用于后端关联同一加载周期内的上报记录 return ${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}; } getPayload() { return { pageId: this.pageId, activeDuration: Math.round(this.activeDuration / 1000), // 秒为单位 reason: this.lastReason, url: location.href.split(?)[0], referrer: document.referrer, ua: navigator.userAgent }; } }上面这段代码的核心逻辑是“增量结算”页面可见时只记录 segmentStart 和 lastInteractionAt不产生上报一旦页面切到 hidden才把这段时间计算出来累加到 activeDuration 里。这样即使页面在后台被浏览器冻结定时器失效我们也不会丢数据因为时间戳已经固化在内存变量里了。参数选择上idleTimeout 设 5 分钟是个平衡点图文类内容页普遍能接受如果你做的是后台看板类的工具挂机阈值可以放宽到 15 分钟。minDuration 设 3000ms 是为了过滤掉误触和秒进秒出但如果你要统计“快速跳出率”这个单独指标这个值应该改为 0别复用同一套采集器。3.2 上报参数与字段设计pageId、duration、source、visibility状态采集器只是拿到了一个秒数真正让数据在后端可分析的是上报参数。先别急着把时间戳裸传上来字段设计从一开始就要想清楚不然后面扩展维度你会想骂人。我实际项目里用的上报结构是{ pageId: lz7k9p-8fk2m1, appId: web_mp, pagePath: /article/12345, pageTitle: xx产品2024夏季刊, sessionId: s-9s8d7f6g5h4j, uid: u_10293847, userIdHash: a1b2c3d4e5, enterTs: 1733047290000, leaveTs: 1733047325000, activeDuration: 32, wallDuration: 35, visibilityCount: 2, hiddenDuration: 3, idleDuration: 0, deviceType: mobile, platform: h5, os: ios, browser: safari, referrerPath: /list/recommend, source: visibilitychange-hidden }字段看着多其实分为四组身份字段pageId、sessionId、uid用来确定“谁”路径字段pagePath、enterTs、leaveTs、referrerPath用来还原“看了什么、从哪来”时间字段activeDuration、wallDuration、hiddenDuration、idleDuration是核心指标拆得越细后端越容易做聚合和排障环境字段deviceType、os、browser用来做分组对比比如排查“是不是某个浏览器特别容易丢埋点”。pagePath 这里要注意SPA页面下用 location.href.split(?)[0] 只能拿到当前URL如果你要根据路由参数区分列表页的第2页、第3页得把完整路径带上来过滤规则放到后端。visibilityCount 是页面在生命周期内被切后台/切回前台的次数这个数如果异常偏高说明用户注意力很分散后端可以通过它把“看一会就走”和“挂机”更准确地分桶。3.3 被动上报与卸载上报sendBeacon 的使用边界采集到了数据怎么送出去是个容易被浏览器坑的地方。用户关闭标签页、刷新页面、切走应用时传统的 fetch/ajax 请求很容易被浏览器取消因为页面上下文已经在销毁了。这里标准解是 navigator.sendBeacon它不关心页面状态请求会由浏览器高层接管尽力送达。// 在 visibilitychange 进入 hidden 时调用或者在 pagehide 里调用 function reportStay(payload) { const url /api/stay/collect; try { if (navigator.sendBeacon) { const blob new Blob([JSON.stringify(payload)], { type: application/json; charsetUTF-8 }); navigator.sendBeacon(url, blob); } else { // 老浏览器兜底用同步 XHR会阻塞页面一小会儿但保证送达 const xhr new XMLHttpRequest(); xhr.open(POST, url, false); xhr.setRequestHeader(Content-Type, application/json; charsetUTF-8); xhr.send(JSON.stringify(payload)); } } catch (err) { // 进入这里基本没救了只能等下次上报补数据 console.warn([stay-tracker] send failed, err); } }需要说明的是 sendBeacon 的两种限制。第一它只能发送 POST且大部分浏览器限制 payload 在 64KB 以内你如果把用户完整行为日志都塞进去大概率被浏览器静默丢弃。我把“停留时长”和“用户行为明细”拆成两条上报链路停留时长走 Beacon明细走批量接口互不拖累。第二sendBeacon 不保证到达它是尽力而为的。生产环境里移动端到达率普遍在 95% 左右剩下 5% 需要靠“下一次可见”时补报兜底。所以采集器里要维护一个 pendingReports 队列visibilitychange 触发 hidden 时把结算结果暂存等下一次变成 visible 时统一补报这样能覆盖大部分卸载丢失场景。4. SPA路由与组件切换下的停留统计hash、history、缓存三方博弈现在前端项目十个有八个是 SPA页面不刷新路由靠 JS 切换。如果只用 visibilitychange你会发现整个 app 只有一个“页面”永远算不出每个子页面分别停留了多久。这块需要单独处理路由边界和组件边界。4.1 捕获路由变化重写 history.pushState 与监听 popstateSPA 的页面切换分两类一类是调用 history.pushState/replaceState 推入新路由这类不会触发 popstate另一类是用户点击浏览器前进/后退这类会触发 popstate。要统计每次路由变化得同时盯住这两条路。// 在采集器初始化时执行 function patchHistory() { const originalPushState history.pushState; const originalReplaceState history.replaceState; history.pushState function (...args) { const url args[2]; // 在路由真正改变之前结算上个页面并生成新 pageId stayTracker.settleForRouteChange(url); const result originalPushState.apply(this, args); stayTracker.startRouteSegment(url); return result; }; history.replaceState function (...args) { const url args[2]; // replace 通常不产生新页面如清参数只更新身份不结算 const result originalReplaceState.apply(this, args); stayTracker.refreshRouteMeta(url); return result; }; window.addEventListener(popstate, () { stayTracker.settleForRouteChange(location.href); stayTracker.startRouteSegment(location.href); }); }这段代码的思路是“在想路由变化之前先结算上一段再开启新页面”。这里有一个关键细节如果在 visibilitychange 的 hidden 里也结算同时路由变化也结算会不会把同一段时间重复计算会。所以我要强调 settle 的幂等设计settleForRouteChange 里设置一个内部变量 routeChanged如果 300ms 内已经由 hidden 结算过当前段就直接复用上一次的结算结果不再累加。另一个细节是 pushState 的第三个参数可能是相对路径也可能是绝对 URL结合 location.pathname 拼接很容易出错我的做法是统一用 new URL(arg[2], location.href).pathname 来归一化避免页面上带 query 和 hash 时统计错乱。4.2 Tab切换与组件级停留以组件卸载时间戳为边界除了路由同一个路由里的 Tab 切换、手风琴展开、弹窗打开也可能需要单独统计。比如一个详情页里有“概览”“参数”“评价”三个 Tab运营想知道用户在哪个 Tab 花的时间多。这种场景不能用路由要在组件层面埋点。// React 示例用 useEffect 的清理函数模拟组件卸载结算 function useTabStay(tabName) { const startRef useRef(null); useEffect(() { startRef.current Date.now(); return () { // 组件卸载或依赖变化时结算 const duration Date.now() - startRef.current; reportTabStay({ tabName, durationMs: duration, pagePath: location.pathname, tabPrev: document.hidden ? hidden : visible }); }; }, [tabName]); return () { startRef.current Date.now(); }; }这个 hook 的思路很朴素组件挂载时记录开始时间组件卸载时用清理函数算出区间。坑在于 React 18 的 StrictMode 会在开发模式下故意执行两次挂载/卸载导致上报两次虚假的极短时长生产环境不会这样但你要在采集器里对 durationMs 500 的数据做过滤避免把开发环境的脏数据带进库。另一个坑是组件没卸载但 Tab 切走了这时候 useEffect 的依赖数组要加 tabName只有切 Tab 才触发重跑弹窗和卷起的情况得单独用事件驱动。4.3 bfcache与返回缓存pageshow 事件里补一次激活用户从当前页面点了个链接跳走浏览器把上一页放进了内存缓存bfcache用户再按返回键时页面不是重新加载而是从 bfcache 里瞬间恢复。这种情况下 load 事件不会触发visibilitychange 也不会触发——因为页面在缓存里一直是 hidden 状态恢复后直接变 visible但有些浏览器不会发 visibilitychange 事件。这时候要用 pageshow 事件并且读取 event.persisted 字段判断是否来自 bfcache。window.addEventListener(pageshow, (event) { if (event.persisted) { // 页面从 bfcache 恢复重新开启计时段 stayTracker.resume(bfcache-restore); } else { // 正常首次加载 stayTracker.startFreshPage(); } });不补这一刀的情况是用户浏览 A 页 → 跳去 B 页 → 按返回回到 A 页A 页的停留时间可能从“进 A”一直累加到“从 B 返回”中间包括在 B 页浏览的时间造成 A 页时长虚高。加了 pageshow 后可以在 persisted 时把之前的累计项强制结算新开一个 pageId 续计。此时上报的 enterTs 会晚于页面真实加载时间后端要支持按 pageId 聚合而不是按 URL 聚合这点务必注意。5. 停留时长统计避坑5个我踩过并修掉的数据质量翻车现场以下每一个问题都是我在生产环境真实遇到、并且修过的写出来省得你再趟一遍。5.1 移动端Safari的visibilitychange触发滞后现象iOS Safari 上用户按 Home 键切到桌面后点回浏览器继续看页面。日志里发现这次操作的 hidden 记录晚了几秒甚至十几秒导致时间区间重叠activeDuration 明显偏大。原因Safari 在页面被切换到后台时出于资源回收策略可能只在进程被冻结前的一刻才触发 visibilitychange 和 pagehide而不是在切换到后台的瞬间触发。触发滞后会造成计算时长的基准点偏移。解决监听 window 的 blur 事件作为补充信号。app 切换到后台时window 通常会先于 visibilitychange 触发 blur。在 blur 里做一次时间快照visibilitychange 真正发生时用快照时间而不是事件时间。let snapshotTs 0; window.addEventListener(blur, () { snapshotTs Date.now(); // 先记录但不立刻结算等 visibilitychange 兜底 }); document.addEventListener(visibilitychange, () { if (document.hidden) { const baseTs snapshotTs || Date.now(); tracker.settleWithBase(baseTs); } });这样即使 visibilitychange 滞后时间起点也已经固定在了 blur 时刻误差从“秒级”降到“毫秒级”。5.2 用户挂着页面不动时长虚高现象后端统计出来某些页面平均停留 20 分钟运营质疑说“用户根本不可能看这么久”。查日志发现用户把页面挂在后台标签页里过夜第二天早上回来点一下关闭系统记了 8 小时。原因只按 visibilitychange 累计用户切到后台时页面确实 hidden 了但如果他切回来看了一眼再挂起间歇性地保持了可见状态闲时没有被截断。这正是我在 2.3 里说的挂机过滤没生效。解决挂机判定的关键不是“可见”而是“用户有没有交互”。把 mousemove、keydown、touchstart、scroll 四类事件的时间戳重置为当前时间。在结算时取 segmentStart 到 lastInteractionAt 作为有效区间如果 lastInteractionAt 到结算时刻超过 idleTimeout就把多余部分计为 idleDuration从 activeDuration 里剔掉。5.3 时钟回拨与休眠导致时长变成负数现象某天报表里出现了负数时长、超大时长几十亿毫秒排查发现是用户设备休眠后唤醒系统时间被调整或者在 NTP 对时后时间往前跳。原因Date.now() 返回的是墙钟时间它可能被系统时钟调整影响。设备休眠时 JS 停止执行唤醒后下一次可见时结算起点时间戳可能还在休眠前的“旧时间”。解决统一用 performance.now() 来算相对时长它基于事件循环内部的高精度时钟不受系统时间调整影响只在上报时把 performance.now() 换算回 Date.now() 时基然后附上性能时钟的 elapsed 字段。如果后端发现 performance 时钟和墙钟时钟的差值超过 60 秒就打一条异常日志主动排查。5.4 重复上报与JSON截断上报链路的数据污染现象同一 pageId 在明细表里出现多条记录且 activeDuration 累加值比真实值翻倍。原因visibilitychange 和 pagehide 同时触发采集器没有幂等锁同一段时间被 settle 了两次。另外sendBeacon 发送大 JSON 时有些浏览器会截断 body导致 JSON 不完整但请求仍到达后端解析做容错后把部分字段存成了 NULL。解决settle 方法里加 locked 标志位取到锁才结算pagehide 和 visibilitychange 到达时先判断已经结算过就不再处理。JSON 截断的问题在采集端控制 payload 体积超过 16KB 就拆包后端对 content-type 为 beacon 的请求强制校验完整性解析失败返回 200 但不落库并在监控里记录一个解析失败计数。5.5 口径不一致灰度期先跑双写以埋点明细为准现象产品同学发现同一时间范围后端“人均停留时长”和前端上报的“活跃时长”对不上各团队都觉得自己没错。原因前端一个上报任务对应一次 visible 状态切换后端可能按天维度去重、按 session 去重或者按分钟粒度对齐去重口径不同导致数据被重复聚合或稀释。解决灰度排期的时候让前端同时在本地存储和请求头里写入一个 reportTs 和 pageId后端明细表保留一条原始数据聚合任务从明细表出发不做任何隐式去重。口径对齐只允许发生在“后端离线数仓加工”这一层前端永远不做会话合并。这样即使两边的统计结果不一致也能把明细捞出来比对定位是采集、传输还是加工的问题。别嫌这个流程重客流数据对不上互相甩锅的代价远大于先跑两周双写。6. 用“活性比”做质量校验抽一个小时日志核对你的埋点是否可信埋点上线后第一件事不是看报表数字而是验证采集链路本身。我习惯的做法是拿出一小时的真实日志用“活性比”这个指标来判断采集质量。活性比 activeDuration / wallDuration。对同一个会话如果页面一直保持可见且用户持续操作活性比应该接近 1如果用户经常切后台或挂机活性比会低于 0.6。我见过大量线上数据活性比长期在 0.2 以下说明埋点基本把挂机时间算成了活跃时间报表明显失真。验证方法分三步。第一步从明细表里随机抽取约 200 个会话把每条的 enterTs、leaveTs、visibilityCount、activeDuration、idleDuration 拉出来人工核对是否存在“activeDuration wallDuration”这种逻辑错误以及 visibilityCount 异常高比如超过 50的会话占比。第二步在测试机上手动模拟三组动作打开页面看 10 秒后切后台 30 秒再切回来看 20 秒重复两次连续点击页面 5 分钟后锁屏加载页面后放着不动 8 分钟再关闭。每组动作对照上报数据看 activeDuration 是否精确到秒级特别是第三组多出来的分钟数是否全部计入了 idleDuration。第三步用后端每小时跑一个质量分任务统计 activeDuration 落在 [1, 3600] 区间内的记录比例低于 95% 就触发告警说明采集端有溢出或负数。我自己踩过的教训是上线前一定要在灰度环境模拟弱网和锁屏别只在 Chrome DevTools 里切一下网络就行——移动端真实的分发链路里阉割版的 sendBeacon 和坏掉的时钟会让你第一版报表直接作废。把采集质量校验做成自动化任务之后再谈优化指标你会踏实很多。最后分享一个小习惯每个季度我都要重新读一遍最近三个月的埋点字段文档把已经废弃的字段主动摘掉。数据质量是一点点维护出来的不是上线那一刻定的。希望帮到你。本文还有配套的精品资源点击获取
