发现页不是换皮的信息流
发现页乍看也是一列内容卡片,于是很多团队直接复用了信息流的窗口、分页和刷新逻辑。上线后问题集中爆发:运营配置的焦点图没下发时,下面的榜单整体上移,用户点错位置;同一篇内容既出现在焦点图又在信息流里;曝光报表里同一个位置被记了两次。根因不是列表组件写得不好,而是把几种契约完全不同的内容源当成了同一种。
本节只讨论客户端如何组合多个来源:哪些位置是固定的、来源之间如何去重、曝光事件以什么为身份、信息流取尽后页面怎么收尾。它不讨论推荐算法如何打分,也不讨论广告投放与计费,更不讨论搜索结果的分页与筛选(12.2)。我们假定每个来源都已经按自己的规则返回了一段有序内容。
四类来源,四种契约
| 来源 | 顺序由谁决定 | 更新频率 | 为空时意味着什么 | 位置约束 |
|---|---|---|---|---|
信息流 feed | 推荐或时间排序 | 高,可持续翻页 | 页面可以提前结束 | 填充剩余位置 |
运营位 promotion | 运营配置 | 低,按活动切换 | 活动未开始或已结束 | 固定在声明位置 |
榜单 ranking | 榜单规则与统计周期 | 中,按小时或天 | 统计未产出 | 固定块,通常连续多格 |
专题 topic | 编辑编排 | 低,人工维护 | 专题下线 | 固定块 |
四类来源的「空」含义不同。信息流为空只说明这次没取到更多,页面可以结束;运营位为空是一个需要被记录的业务状态,不能悄悄让后面的内容顶上来;榜单为空可能只是统计任务还没跑完,界面应该保留榜单骨架或占位;专题为空则通常意味着配置被撤下。
如果客户端把它们都归一成 ContentItem[],再 flatMap 成一个数组,位置约束和空语义会同时丢失。正确做法是保留来源信息,在组合层决定谁占哪个位置。
固定槽位:位置是产品契约
运营位和榜单的位置不是渲染细节,而是产品契约。运营同学按「首屏第一张」「榜单第三位」售卖或配置资源;用户也按固定位置形成肌肉记忆。位置一旦因为上游返回为空而塌陷,后面的卡片整体上移,点击率和曝光归因都会失真。
所以固定槽位要「先占位,再取内容」:布局先声明 position 与 limit,组合器先把这些位置标记为已保留,再尝试填内容。来源不足时,缺的位置输出 item: null 的占位,而不是把后面的信息流拉上来。
interface FixedSlot {
slotId: string;
source: "promotion" | "ranking" | "topic";
position: number;
limit: number;
}
占位不是「渲染一张空白卡片」这么简单。它至少要满足三件事:位置仍然被占用,曝光系统知道这里没有内容,业务监控能统计到「运营位为空」的次数。把 null 直接过滤掉,等于把业务异常藏进了渲染层。
跨源去重:优先级必须显式
同一篇内容完全可能同时出现在运营位、榜单和信息流里。运营把一篇爆款放上焦点图,推荐系统也会把它推给用户。如果不去重,用户会在同一屏看到两次;如果去重但优先级不明确,焦点图可能被信息流「抢走」,运营配置失效。
去重键仍然用服务端 id,不用标题、作者或内容哈希。优先级按位置显式定义:位置靠前的固定槽位先取,信息流最后取。固定槽位之间按 position 升序处理,同一个来源被多个槽位消费时共享一个游标,避免两个榜单块取到同一批内容。
被跳过的内容要计数,而不是静默丢弃。duplicates 指标能回答两个问题:运营配置与推荐结果的重合度有多高,以及某个来源是否在重复下发同一批内容。没有这个计数,去重逻辑出问题时只能靠用户投诉发现。
曝光语义:key 不能带位置
曝光上报需要一个稳定身份。常见的错误 key 有两种:
- 只用内容 id:同一篇内容在榜单和信息流里各曝光一次,报表无法区分是哪个位置带来的。
- 用数组下标:榜单从第 2 位换到第 3 位,同一个位置被记成两次曝光,内容本身却没变。
正确的 key 由「槽位 id + 内容 id」组成,例如 top-rank:r1 或 feed:f1。位置变化不影响 key,槽位变化才影响 key。这样排行榜内部换序不会重复计数,而同一篇内容从榜单进入信息流时,会被视为一次新的、不同场景的曝光。
占位没有曝光 key。它应该上报的是「槽位为空」的业务事件,而不是一条内容曝光。把两者混在同一个字段里,报表会出现「内容 id 为空但曝光量很高」的脏数据。
组合多个来源而不串位
组合器的职责可以拆成四步:
- 合并同一来源的多个分页,保留来源标签。
- 按
position处理固定槽位,先占位,再按优先级取内容。 - 用剩余位置填充信息流,跳过已使用的 id。
- 按
position排序输出,渲染层按位置放置,不按数组下标猜位置。
第四步经常被忽略。组合结果可能因为信息流提前取尽而出现「位置不连续」:固定槽位在 0 和 6,信息流只够填到 2,位置 6 的专题仍然应该输出。渲染层如果按数组下标渲染,会把专题放到第 3 格;按 position 渲染,才能保持声明的位置。
这也解释了为什么组合结果不适合直接塞进虚拟列表的「连续数组」假设。虚拟列表可以按位置建索引,但空位和固定槽位要作为显式条目存在,而不是被压缩掉。
一个可运行的发现页组合器
下面的 discover.ts 属于 L1:输入是布局声明和各来源的条目,输出是带位置、槽位与曝光 key 的条目列表。它不关心网络与渲染,只保证固定位置、跨源去重和空槽位语义。
// file: src/search/discover.ts
// tier: L1
// verify: node --test test/search/discover.test.ts
import type { ContentItem } from "../core/content-model.ts";
export type DiscoverSource = "feed" | "promotion" | "ranking" | "topic";
export type FixedSource = Exclude<DiscoverSource, "feed">;
export interface SourcePage {
source: DiscoverSource;
items: ContentItem[];
}
export interface FixedSlot {
slotId: string;
source: FixedSource;
position: number;
limit: number;
}
export interface DiscoverLayout {
total: number;
fixed: FixedSlot[];
}
export interface DiscoverEntry {
position: number;
slotId: string;
source: DiscoverSource;
item: ContentItem | null;
impressionKey: string | null;
}
export interface DiscoverResult {
entries: DiscoverEntry[];
duplicates: number;
placeholders: number;
}
export function exposureKey(slotId: string, itemId: string): string {
return `${slotId}:${itemId}`;
}
export function validateLayout(layout: DiscoverLayout): void {
if (!Number.isInteger(layout.total) || layout.total < 1) throw new Error("total 必须是正整数");
const seen = new Set<string>();
const slots = [...layout.fixed].sort((a, b) => a.position - b.position);
let previousEnd = 0;
for (const slot of slots) {
if (slot.slotId.trim() === "") throw new Error("slotId 不能为空");
if (seen.has(slot.slotId)) throw new Error(`slotId 重复:${slot.slotId}`);
seen.add(slot.slotId);
if (!Number.isInteger(slot.position) || slot.position < 0) throw new Error(`槽位 ${slot.slotId} 的 position 非法`);
if (!Number.isInteger(slot.limit) || slot.limit < 1) throw new Error(`槽位 ${slot.slotId} 的 limit 非法`);
if (slot.position + slot.limit > layout.total) throw new Error(`槽位 ${slot.slotId} 超出页面范围`);
if (slot.position < previousEnd) throw new Error(`槽位 ${slot.slotId} 与前一个槽位重叠`);
previousEnd = slot.position + slot.limit;
}
}
export function composeDiscover(layout: DiscoverLayout, pages: SourcePage[]): DiscoverResult {
validateLayout(layout);
const bySource = new Map<DiscoverSource, ContentItem[]>();
for (const page of pages) {
const list = bySource.get(page.source) ?? [];
list.push(...page.items);
bySource.set(page.source, list);
}
const used = new Set<string>();
const cursors = new Map<DiscoverSource, number>();
let duplicates = 0;
let placeholders = 0;
const take = (source: DiscoverSource, limit: number): ContentItem[] => {
const list = bySource.get(source) ?? [];
let index = cursors.get(source) ?? 0;
const picked: ContentItem[] = [];
while (picked.length < limit && index < list.length) {
const item = list[index];
index += 1;
if (used.has(item.id)) {
duplicates += 1;
continue;
}
used.add(item.id);
picked.push(item);
}
cursors.set(source, index);
return picked;
};
const reserved = new Set<number>();
const entriesByPosition = new Map<number, DiscoverEntry>();
const fixed = [...layout.fixed].sort((a, b) => a.position - b.position);
for (const slot of fixed) {
for (let offset = 0; offset < slot.limit; offset += 1) {
const position = slot.position + offset;
reserved.add(position);
const [item] = take(slot.source, 1);
if (!item) {
placeholders += 1;
entriesByPosition.set(position, {
position,
slotId: slot.slotId,
source: slot.source,
item: null,
impressionKey: null,
});
continue;
}
entriesByPosition.set(position, {
position,
slotId: slot.slotId,
source: slot.source,
item,
impressionKey: exposureKey(slot.slotId, item.id),
});
}
}
for (let position = 0; position < layout.total; position += 1) {
if (reserved.has(position)) continue;
const [item] = take("feed", 1);
// 信息流取尽后页面到此结束;固定槽位仍按声明位置保留。
if (!item) break;
entriesByPosition.set(position, {
position,
slotId: "feed",
source: "feed",
item,
impressionKey: exposureKey("feed", item.id),
});
}
return {
entries: [...entriesByPosition.values()].sort((a, b) => a.position - b.position),
duplicates,
placeholders,
};
}
四个关键决策
先保留位置,再填内容
固定槽位的第一件事是把 position 加入 reserved,然后才调用 take。顺序反过来,信息流就可能先占掉运营位,之后再用占位覆盖,白白消耗一条内容并制造重复计数。先保留位置,信息流的循环天然只会看非保留位置。
同一来源共享游标
两个榜单块如果各自从索引 0 开始取,第二块会把第一块的内容当成重复跳过,最后全是占位。共享 cursors 后,第二个块从第一个块停下的位置继续取,来源内部的顺序和分页语义都得以保留。
信息流取尽不等于整页结束
固定槽位是独立来源,不应该被信息流的长度绑架。信息流取尽后,组合器停止填充普通位置,但已经声明的固定槽位仍然输出。页面可能在视觉上出现空档,这比让专题块漂移到错误位置更可接受;如果产品不接受空档,应该在布局层收缩 total,而不是在组合层压缩位置。
曝光 key 与位置解耦
exposureKey(slotId, itemId) 只接收两个身份,不接收下标。这个签名本身就是约束:调用方无法把位置偷偷塞进 key。位置变化、排序变化、分页变化都不应改变曝光身份;只有内容或槽位变化才应该改变。
测试与真实输出
本机运行结果如下:
$ node --test --test-concurrency=1 test/search/discover.test.ts
✔ 固定槽位内容不足时留占位,后续槽位位置不变 (0.997041ms)
✔ 跨源去重时位置靠前的槽位优先,信息流跳过已用内容 (0.136791ms)
✔ 信息流只填充未被固定槽位占用的位置 (0.468084ms)
✔ 同一来源内的重复 id 只取一次并计入去重 (0.1115ms)
✔ 曝光 key 与位置无关,与槽位和内容有关 (0.109416ms)
✔ 布局校验拒绝重叠、越界与重复槽位 (0.275417ms)
✔ 信息流取尽后页面结束,后面的固定槽位仍按声明位置输出 (0.080708ms)
ℹ tests 7
ℹ suites 0
ℹ pass 7
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 128.837833
七条用例覆盖了占位、跨源去重、保留位置、来源内去重、曝光 key、布局校验和稀疏页面。它们没有覆盖真实曝光上报链路,也没有覆盖运营后台的配置变更;那些需要端到端联调。组合器证明的是「给定来源,位置与身份如何计算」,不证明上游数据一定及时。
失败复盘:运营位空了一天,信息流整体上移
「回声」的发现页早期把四个来源合并成一个数组,过滤掉空值后直接渲染。某天运营活动在凌晨结束,焦点图接口返回空数组。组合结果从 21 条变成 20 条,所有位置前移一格:原本在第二位的榜单块跑到了第一位,榜单徽标被渲染到一条普通信息流卡片上;原本第三位的专题入口跑到了第二位,用户点进去发现是另一篇内容。
更麻烦的是曝光报表。上报 key 用的是数组下标 position:0、position:1,位置整体前移后,前一天与后一天的同一位置对应了不同内容,运营看到的「焦点图曝光」其实混进了榜单和信息流。产品、运营、数据三个团队花了一天才对齐口径。
修复分三步。第一,固定槽位先占位,空来源输出 item: null,让信息流不再上移。第二,曝光 key 改为 slotId:itemId,位置变化不再影响身份。第三,跨源去重按位置优先级执行,同一个 id 只保留最靠前的槽位,并在日志里记录 duplicates。
第二个事故来自去重缺失。一篇爆款同时被运营放上焦点图、被榜单收录、又被推荐进信息流,用户在首屏看到三次。修复后焦点图优先,榜单与信息流依次跳过,曝光只记一次。去重不是为了「少显示」,而是为了让每个位置的业务含义唯一。
小结
发现页的组合层要保留来源,而不是过早归一成数组。固定槽位先占位再取内容,来源不足时宁可留空也不塌陷;跨源去重按位置优先级显式执行,并记录重复计数;曝光 key 用槽位与内容组成,不带下标;信息流取尽后页面可以结束,但固定槽位仍按声明位置输出。
搜索与发现的三个问题到这里收口:输入阶段解决竞态,结果阶段解决状态归属,发现阶段解决多来源组合。下一章不再引入新概念,而是把发内容、互动、实时回复串成一条端到端链路,检验这些判断组合起来是否仍然成立。
阅读导航:上一节:12.2 结果分页与筛选状态的保持 · 下一节:13.1 端到端场景:发内容、互动、实时回复 。
常见错误与边界
- 把运营位、榜单、专题和信息流合并成一个数组,位置约束彻底丢失。
- 空来源直接过滤,后续内容整体上移,点击与曝光归因失真。
- 跨源去重没有显式优先级,运营配置被推荐结果挤掉。
- 用标题或作者去重,改标题后同一内容重复出现。
- 曝光 key 使用数组下标,换序后同一位置被重复计数。
- 曝光 key 只含内容 id,无法区分榜单曝光与信息流曝光。
- 同一来源的多个槽位各自从索引 0 取,第二块全是重复与占位。
- 信息流提前取尽时压缩固定槽位,专题块漂移到错误位置。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。