《互联网产品的客户端架构·内容、社交与实时互动》2.3 刷新策略:下拉、自动、回到前台该不该拉

下拉、轮询、回到前台是三种不同意图的刷新,用同一个定时器处理只会在用户阅读时把列表顶走。本节用一段可运行的 TypeScript 策略模块给出逐触发的判定、刷新合并与节流、锚点位置保护,并复盘一次前后台切换风暴导致的三连请求与列表跳动,给出参数取值的依据。

会取数据,不等于知道何时取

2.1 解决了「下一页用什么参数」,2.2 解决了「取回来怎么合并」。这一节只回答剩下的问题:什么时候应该发起刷新。 它不讨论请求怎么发、游标怎么编码,也不讨论实时通道怎么选型与重连;这些分别属于本章前两节与后面的实时章节。

把三种刷新混成一个「重新加载」按钮,是客户端很常见的简化。下拉是用户明确表达「我要最新内容」,自动轮询是后台希望列表别太旧,回到前台是系统给的一次免费机会。三者的意图、成本和风险都不同,用同一套条件处理,必然有一类场景被牺牲:要么用户下拉没反应,要么读到一半列表突然跳到顶部。

三种触发的意图与代价

触发来源用户意图主要成本位置风险
下拉手势明确要最新重复请求、流量低,用户主动
轮询定时器无,后台保鲜电量、服务端 QPS高,可能打断阅读
回到前台生命周期无,顺带更新集中请求中,恢复上下文时跳

这张表解释了为什么不能共用一个 TTL。下拉的判据应该是「上次刷新过去多久」加一个很短的冷却,防止连点;轮询的判据应该是「距上次刷新是否超过轮询间隔」加「用户是否正在阅读」;回到前台的判据则应该先看「有没有已经到达但未合并的内容」,再看「列表是否已经过期」。

stale 判定需要四个输入

一个可靠的刷新决策至少需要:

  • lastRefreshAt:上一次成功应用刷新的时间,失败不更新。
  • now:当前时间,由调用方传入,便于测试。
  • scrolledScreens:用户已经滑过多少屏,用来判断是否正在阅读。
  • unseenCount:实时通道已经收到、但还没合并进列表的条目数。

只有 lastRefreshAt 时,你只能实现「定时刷新」;加上位置,才能避免打断阅读;加上未读数,才能在回到前台时优先合并已有内容,而不是再发一次请求。

注意 lastRefreshAt 的语义是「成功应用」,不是「发起请求」。请求失败、被取消、响应被判定为 stale,都不应该更新它,否则一次失败会让列表在接下来的一个完整周期里拒绝刷新。

逐触发的判据

下拉:意图优先,冷却兜底

用户下拉时,位置保护应该让路。哪怕已经滑到很深,下拉的语义就是「回到最新」,此时排队等待反而不符合预期。唯一的限制是冷却时间:一次手势可能触发多次事件,网络慢时用户也会连续下拉。冷却期内直接跳过,不排队,因为用户的意图已经被上一次刷新满足。

如果已经有一个刷新在飞行中,再次下拉会被合并成一次后续刷新。这比直接丢弃更符合用户预期:他看到加载动画,知道请求还会再来一次。

轮询:过期只是必要条件

轮询的第一道门是间隔。距上次成功刷新不足间隔时直接跳过,避免定时器与用户操作叠加。第二道门是位置:用户已经读进一屏以上时,自动刷新会把新内容插到顶部,导致正在阅读的卡片下移。此时应该排队,而不是发起。

排队不是取消。等用户停止滚动、回到顶部或主动下拉时,排队的刷新再启动。这样既不丢新鲜度,也不打断阅读。

回到前台:先看未读,再看过期

应用从后台回来时,实时连接可能已经断过,也可能在后台期间收到了推送但没渲染。此时如果 unseenCount > 0,应该立即刷新并合并,因为这些内容已经到达,只差落位;如果没有未读,则按 foregroundStale 判断,避免每次切换应用都打一次请求。

前后台的切换可能非常频繁:通知中心拉一下、切到相机再回来、系统弹窗消失,都会触发焦点或可见性变化。没有过期阈值时,这些事件会变成请求风暴。

合并与节流:一个在飞,一个排队

刷新编排只需要四个字段:

interface RefreshRuntime {
  inFlight: boolean;
  queuedTrigger: "pull" | "poll" | "foreground" | null;
  lastRefreshAt: number | null;
  epoch: number;
}

规则只有三条:

  1. 有请求在飞时,任何新触发都只写入 queuedTrigger,不并发发请求。
  2. 队列只保留一个触发。触发是「意图」,不是「命令」,重复的意图没有叠加价值。
  3. 当前请求完成时,如果有排队触发,立即启动一次后续刷新,并递增 epoch;没有则回到空闲。

epoch 与 2.2 的窗口配合:后续刷新会拿到新的 epoch,旧响应即使后到也会被丢弃。这样「合并」与「乱序」两件事各自有明确的负责人,不需要在刷新策略里比较响应时间。

位置保护:刷新前记锚点,刷新后还原

刷新会改变列表头部,滚动位置必须显式保护。流程是:

  1. 刷新发起前,取视口顶部条目,记录 { id, offset }。
  2. 响应应用后,在新数组里按 id 查找。
  3. 找到就恢复到「该条目 + 原偏移」;找不到则退化到提示下标,并标记 found: false。
  4. 如果用户正在阅读且新内容很多,不自动滚动,只显示「有 N 条新内容」的提示。

第 4 条是产品判断:自动滚到顶部会把用户从正在看的内容上拽走,即使用户没有丢失位置,体验也是被打断。位置保护的目标是「用户看的那条还在原处」,不是「列表必须展示最新一条」。

一个可运行的策略模块

下面的模块属于 L1:时间由参数传入,没有定时器、没有网络、没有共享状态。它只做判定与状态迁移,真正的调度(setInterval、生命周期监听)留给宿主。

// file: src/feed/refresh-policy.ts
// tier: L1
// verify: node --test test/feed/refresh-policy.test.ts
export type RefreshTrigger = "pull" | "poll" | "foreground";
export type RefreshAction = "start" | "queue" | "skip";

export interface RefreshState {
  trigger: RefreshTrigger;
  now: number;
  lastRefreshAt: number | null;
  inFlight: boolean;
  scrolledScreens: number;
  unseenCount: number;
}

export interface RefreshPolicy {
  pollInterval: number;
  foregroundStale: number;
  pullCooldown: number;
  readingScreens: number;
}

export const DEFAULT_REFRESH_POLICY: RefreshPolicy = {
  pollInterval: 30_000,
  foregroundStale: 5 * 60_000,
  pullCooldown: 2_000,
  readingScreens: 1,
};

export function decideRefresh(
  state: RefreshState,
  policy: RefreshPolicy = DEFAULT_REFRESH_POLICY,
): RefreshAction {
  if (state.inFlight) return "queue";
  const since = state.lastRefreshAt === null ? Number.POSITIVE_INFINITY : state.now - state.lastRefreshAt;
  if (state.trigger === "pull") {
    // 用户意图优先于位置保护,但仍要挡住连点造成的重复请求。
    return since < policy.pullCooldown ? "skip" : "start";
  }
  if (state.trigger === "poll") {
    if (since < policy.pollInterval) return "skip";
    // 已经读进去一屏以上时,自动刷新会打断阅读,改为排队。
    return state.scrolledScreens >= policy.readingScreens ? "queue" : "start";
  }
  // 回到前台:有实时未读数就立即合并,否则只在过期后刷新。
  if (state.unseenCount > 0) return "start";
  return since < policy.foregroundStale ? "skip" : "start";
}

export interface RefreshRuntime {
  inFlight: boolean;
  queuedTrigger: RefreshTrigger | null;
  lastRefreshAt: number | null;
  epoch: number;
}

export const IDLE_RUNTIME: RefreshRuntime = {
  inFlight: false,
  queuedTrigger: null,
  lastRefreshAt: null,
  epoch: 0,
};

export interface RefreshStep {
  runtime: RefreshRuntime;
  action: RefreshAction;
}

export function requestRefresh(
  runtime: RefreshRuntime,
  state: RefreshState,
  policy: RefreshPolicy = DEFAULT_REFRESH_POLICY,
): RefreshStep {
  const action = decideRefresh({ ...state, inFlight: runtime.inFlight }, policy);
  if (action === "skip") return { runtime, action };
  if (action === "queue") {
    // 多次排队只保留最新一次触发,合并成一次后续刷新。
    return { runtime: { ...runtime, queuedTrigger: state.trigger }, action };
  }
  return { runtime: { ...runtime, inFlight: true, epoch: runtime.epoch + 1 }, action };
}

// 阅读停下来后,把排队的刷新真正启动。
export function startQueued(runtime: RefreshRuntime): RefreshStep {
  if (runtime.inFlight || runtime.queuedTrigger === null) {
    return { runtime, action: "skip" };
  }
  return {
    runtime: { ...runtime, inFlight: true, queuedTrigger: null, epoch: runtime.epoch + 1 },
    action: "start",
  };
}

export interface CompleteStep {
  runtime: RefreshRuntime;
  followUp: RefreshTrigger | null;
}

export function completeRefresh(runtime: RefreshRuntime, now: number): CompleteStep {
  const followUp = runtime.queuedTrigger;
  if (followUp === null) {
    return { runtime: { ...runtime, inFlight: false, lastRefreshAt: now }, followUp };
  }
  return {
    runtime: { ...runtime, inFlight: true, queuedTrigger: null, lastRefreshAt: now, epoch: runtime.epoch + 1 },
    followUp,
  };
}

export interface Anchor {
  id: string | null;
  offset: number;
}

export interface AnchorResolution {
  index: number;
  offset: number;
  found: boolean;
}

export function captureAnchor(
  items: ReadonlyArray<{ id: string }>,
  index: number,
  offset: number,
): Anchor {
  const item = items[index];
  return { id: item?.id ?? null, offset };
}

export function resolveAnchor(
  items: ReadonlyArray<{ id: string }>,
  anchor: Anchor,
  hintIndex = 0,
): AnchorResolution {
  if (anchor.id !== null) {
    const index = items.findIndex((item) => item.id === anchor.id);
    if (index !== -1) return { index, offset: anchor.offset, found: true };
  }
  const capped = Math.max(0, Math.min(hintIndex, Math.max(0, items.length - 1)));
  return { index: capped, offset: anchor.offset, found: false };
}

四个关键决策

下拉不受位置限制

如果把「正在阅读」也应用到下拉,用户滑到第三屏后主动下拉会得到「已排队」的反馈,这与他刚才的手势直接矛盾。主动意图应该优先,自动行为才需要让路。

轮询排队而不是取消

直接跳过会丢失新鲜度:用户读了十分钟,列表一直没有更新,回到顶部才发现内容很旧。排队保留了这次刷新,只是延后到不打断阅读的时机。取消与排队的区别,是「这次更新还要不要」,而不是「现在更不更新」。

回到前台先看未读

未读数大于零意味着内容已经到达端上,只是没有合并。此时立即刷新最划算:不需要额外网络请求就能让列表变新。没有未读时再按时间判断,避免每次切应用都打请求。

队列只留一个触发

queuedTrigger 是标量而不是数组,这是刻意的。两个排队刷新合并成一个,结果没有区别,但数组会带来「完成一个又启动一个」的连锁请求。保留最新一次触发还有一个好处:前台触发通常比轮询携带更多上下文,覆盖旧触发更合理。

测试与真实输出

本机运行结果如下:

$ node --test test/feed/refresh-policy.test.ts
✔ 下拉:从未刷新立即开始,冷却期内连点被跳过 (0.505917ms)
✔ 自动轮询未到间隔时跳过 (0.067583ms)
✔ 用户已读进一屏时轮询改为排队 (0.540958ms)
✔ 回到前台时有未读内容就立即刷新 (0.120917ms)
✔ 回到前台且无未读时按过期阈值判断 (0.083666ms)
✔ 刷新期间的多次触发合并成一次后续刷新 (0.144417ms)
✔ 排队中的刷新在阅读停止后启动 (0.079292ms)
✔ 锚点按 id 恢复位置,锚点消失时回退到提示下标 (0.559042ms)
ℹ tests 8
ℹ suites 0
ℹ pass 8
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 139.396417

八条用例覆盖三种触发、合并、排队启动与锚点恢复。它们不验证真实定时器与生命周期事件,因为那需要浏览器或设备环境;这一层只保证「给定时间和状态,决策是确定的」。

失败复盘:前后台切换风暴

第一版「回声」在 visibilitychange 与 focus 上都绑定了刷新,并且在每次回到前台时无条件请求。测试机上没问题,真机上很快暴露:用户从通知中心回来、从相机返回、系统权限弹窗消失,都会触发一次刷新。日志里能看到三次请求几乎同时发出,响应乱序返回,最后一次旧响应把列表顶回了几秒前的状态。

修复分三层:回到前台先判断 foregroundStale,未过期且没有未读就跳过;请求在飞时只记录一个排队触发,不并发;窗口用 epoch 丢弃旧响应。三层各自解决一个问题,缺一层都会留下缺口:只有阈值时,快速切换仍可能在上次刷新后立刻再发;只有合并时,长时间后台回来仍可能因为阈值判断错误而漏刷。

第二个问题是轮询打断阅读。早期每 15 秒自动刷新一次,用户读到一半,新内容插入顶部,正在看的卡片下移。改成「轮询到点且已读进一屏就排队」后,列表不再自己跳;用户主动下拉或停止滚动一段时间后,排队的刷新才启动。产品侧同时加了「有 N 条新内容」的提示,把「更新了但没跳」这件事显式告诉用户。

参数怎么定

模块里的默认值只是起点,不是结论:

  • pollInterval: 30_000:由服务端 QPS 预算与活跃用户数反推。信息流越大、越实时,间隔越短,但要先算清峰值请求量。
  • foregroundStale: 5 * 60_000:由产品对「旧」的定义决定。新闻类可以更短,工具类可以更长。
  • pullCooldown: 2_000:由手势事件频率与网络往返决定,目标是挡住连点,不挡住正常的下一次下拉。
  • readingScreens: 1:滑过一屏就视为正在阅读。列表很密的产品可以调大,短视频流可以调小。

判断参数是否合理,看三个指标:刷新请求量、重复内容比例、列表跳动相关反馈。只调参数不看指标,等于把产品判断交给默认值。

小结

三种刷新对应三种意图:下拉是主动要最新,轮询是后台保鲜,回到前台是顺带更新。判据要分开写:下拉看冷却,轮询看间隔与阅读位置,前台看未读与过期。请求在飞时只留一个排队触发,完成后启动一次后续刷新;刷新前后用锚点 id 保护位置,阅读中不自动跳顶。

到这里,信息流的三件事——取哪一段、怎么合并、何时再取——已经闭环。下一节离开列表结构,进入正文渲染:为什么正文应该被当成数据,而不是一段 HTML 字符串。

阅读导航:上一节:2.2 去重、插入与位置保持 · 下一节:3.1 结构化内容:把正文当成数据而不是 HTML 字符串 。

常见错误与边界

  • 三种刷新共用一个「重新加载」,下拉没反应或轮询打断阅读。
  • 用请求发起时间更新 lastRefreshAt,失败后整个周期拒绝刷新。
  • 回到前台无条件请求,通知中心与相机切换造成请求风暴。
  • 轮询到点就刷新,不看用户是否正在阅读。
  • 在飞时并发发请求,响应乱序后旧数据覆盖新数据。
  • 排队触发用数组保存,完成后连锁启动多次刷新。
  • 刷新后直接滚到顶部,用户正在看的内容被拽走。
  • 把锚点像素偏移当成绝对位置,图片加载后仍然跳。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查