01 / 要解决的问题
消息列表的难点在于消息来自多个来源、内容持续变化、历史需要分页、用户会在加载和滚动之间切换。
实时字幕先到
每次收到一小段增量,页面要及时更新,但不能每段都创建新气泡。
历史消息后到
服务端历史是正式来源,需要把本地字幕确认成同一条正式消息。
往上翻页
只读取上一页,不能重新整理全部记录,也不能让当前阅读位置跳走。
文字和图片占位
发送未确认时先显示占位,历史回来后按请求 ID更新原行。
切换和冷启动
切 Tab 保留窗口;重启只恢复正式消息,临时消息不伪装成历史。
长会话变慢
列表行很多、卡片很高、流式内容变化时,滚动不能被测量反复打断。
公开 AI 消息产品中哪些功能会影响列表设计
| 已查证能力 | 对通用列表的要求 | 依据 |
|---|---|---|
| 消息区混合文本、附件、工具活动、问题和审批卡片。 | 消息应包含类型化内容块,不只是一段字符串;操作失败在卡片内呈现。 | 官方消息说明 |
| 手机切走后按会话保留草稿。 | 草稿归会话,不随可见行销毁。 | 官方移动端说明 |
| 支持消息搜索并回到对应位置。 | 目标 ID 驱动查询、挂载、测量和定位;未加载目标需要数据层查找能力。 | 官方搜索说明 |
| 手机与桌面共享会话,隐藏 Bot 不删除历史。 | 目录可见状态与消息数据分离,切设备后按服务端正式历史恢复。 | 官方排障说明 |
上述是产品行为依据,不代表当前示例已实现工具审批、搜索、草稿或多设备实时同步;消息列表只预留可承载这些内容的边界。
02 / 总体设计:通用核心、可选适配和当前示例配置
消息列表由三层组成:统一消息模型是通用核心;字幕、发送占位、媒体和搜索是可选适配;数据库表名、页大小和重启策略属于当前示例配置。展示层只面对统一消息对象,辅助关系只负责帮助它更新。
通用核心、可选能力和当前示例配置
| 层次 | 必须解决什么 | 本文示例 |
|---|---|---|
| 通用核心 | 稳定消息身份、内容块、服务端版本、会话内顺序、分页游标、窗口快照、错误和滚动锚点。 | 所有 AI 消息列表都可复用。 |
| 可选适配 | 字幕追加/替换、发送占位、图片上传、工具卡片、搜索定位、编辑删除和多端同步。 | 有该能力才增加对应来源关系和动作入口。 |
| 当前示例配置 | 五张同名新表;每页 50 条加 1 条探测;当前进程 local;重启只展示 server;进入页面刷新最新历史。 | 这是 当前产品 当前取舍,需按产品要求重新确认。 |
责任边界
| 责任 | 负责者 | 关键结果 |
|---|---|---|
| 消息和分页数据 | 会话数据层 / 原生宿主 | 保存消息、发请求、更新游标、拒绝旧会话结果。 |
| 阅读状态 | 列表状态层 | 保存跟随最新、阅读历史、锚点、定位请求和加载状态。 |
| 手势和偏移执行 | 原生 ScrollView / 倒置列表 | 执行拖动、惯性、定位命令,反馈当前偏移和测量结果。 |
| 内容操作 | 消息卡片和业务能力 | 复制、预览、播放、停止、重试等动作按 ui_id + block_id 处理。 |
03 / 完整链路:来源、请求、数据库和 UI 如何交接
字幕到 UI
字幕事件按会话、角色和时间间隔归入本地分组,delta 拼成一条 local 消息,UI 看到这条消息继续增长。每个事件保存 event_id,之后和正式历史按事件对应。
历史到 UI
进入聊天或重新进入页面请求最新历史;正式消息按 message_id、version、timeline_index 保存。UI 从统一展示消息表读取,不能另存第二份展示正文。
历史确认字幕
历史消息带 live_source.provider_event_ids 时查找对应本地分组。完整一对一确认时,原 local 行改为 server,保留 ui_id;未覆盖的剩余字幕形成新的 local 行。
图片和文字占位
发送前生成 client_request_id,先写 local 消息;图片上传得到 asset_id或URL后写入 blocks。历史带相同 client_request_id时更新原 ui_id;图片 URL 只是内容地址。
上翻历史的完整闭环
最新刷新走同一条闭环但不带 before_cursor;搜索定位会带目标消息 ID或服务端查找条件;发送和上传属于可选扩展,返回结果仍由消息处理层按请求 ID确认。
部分确认的具体例子
本地字幕是 A+B,服务端历史只声明覆盖 A。事务结果是:正式消息 A 使用服务端身份;未确认的 B 继续保留为 local;A 不再复制成第二条。之后 B 被另一条正式消息确认时,再按同一规则原地更新对应行。
具体 case:从“今天”到正式消息
写入如何驱动 UI
由会话消息处理对象串行处理事件,校验通过后提交数据库事务;提交成功再提升本地变化版本并通知 UI。UI 获取一致的窗口快照,按 ui_id 复用未变对象。这是“数据变化驱动界面”,不要求数据库必须自动监听。当前实现使用通知加窗口快照;未来可改为按 ID 的增量通知,但不必额外持久化一份变更正文。
若写入失败,不推进游标、不标记来源已覆盖、不发成功通知;同一批重试必须可重复执行。较旧 server_version 不能覆盖新版本;同版本内容矛盾应报校验错误,不能静默覆盖。
04 / 统一消息模型:内容只有一份,状态随来源变化
同一模型的字段变化
第一行给出空内容模型;后续只列发生变化的字段。落库是保存动作,不是另外一套消息模型。
| 阶段 | JSON:首次模型 / 后续变化 | 含义 |
|---|---|---|
| 0. 创建模型 | {
"ui_id": "ui-001",
"conversation_id": "c-001",
"role": "assistant",
"origin": "local",
"blocks": [],
"local_kind": "live_transcript",
"group_id": "g-001",
"local_sequence": 1,
"server_message_id": null,
"server_version": null,
"server_timeline_index": null,
"client_request_id": null,
"content_state": "generating",
"updated_revision": 0
} | 创建于本次进程,不伪造正式消息身份。 |
| 1. e1 到达并提交 | {
"blocks": [{ "block_id": "t1", "type": "text", "text": "今天" }],
"updated_revision": 1
} | 事件 e1、合并后的展示行和关系一起提交;成功后通知 UI。 |
| 2. e2 到达并提交 | {
"blocks": [{ "block_id": "t1", "type": "text", "text": "今天天气不错" }],
"updated_revision": 2
} | 事件 e2 与合并结果再次一起提交,仍是同一 ui_id。 |
| 3. 字幕结束并提交 | {
"content_state": "ended",
"updated_revision": 3
} | 结束只表示不再增长,origin 仍为 local;当前示例每一步都可在提交成功后通知 UI。 |
| 4. 历史完整确认并提交 | {
"origin": "server",
"server_message_id": "m-701",
"server_version": 1,
"server_timeline_index": 701,
"local_kind": null,
"group_id": null,
"local_sequence": null,
"content_state": "completed",
"updated_revision": 4
} | ui_id=ui-001 不变;原字幕关系留在辅助表中。 |
服务端、数据库、UI 的形状怎样对应
{
"message_id": "m-701",
"conversation_id": "c-001",
"version": 1,
"timeline_index": 701,
"role": "assistant",
"blocks": [
{
"block_id": "t1",
"type": "text",
"text": "今天天气不错"
}
],
"live_source": {
"live_session_ref": "live-01",
"provider_event_ids": [
"e1",
"e2"
]
}
}上面是服务端历史的简化示例。入库时 message_id/version/timeline_index 映射到 server_message_id/server_version/server_timeline_index;UI 使用同含义的统一消息对象。数据库独有的账号隔离键、可见标记和索引不必传到 RN;blocks 在数据库可序列化为 JSON,交给 UI 时恢复为对象数组。
| 表 | 最小关键字段 | 保存什么 |
|---|---|---|
| conversation_messages | ui_id、origin、blocks、server_*、local_sequence、updated_revision | 列表直接读取的消息内容;正式正文只有一份。 |
| conversation_transcript_events | live_session_ref、event_id、delta、group_id | 收到的字幕原料,按事件去重。 |
| conversation_transcript_groups | group_id、ui_id、event_ids、content_state | 哪些事件组成哪条本地消息。 |
| conversation_message_sources | live_session_ref、event_id、server_message_refs | 哪个事件已由哪些正式消息确认。 |
| conversation_sync_state | conversation_id、before_cursor、has_more | 历史翻页进度。 |
逻辑模型和查询结果合同保持一致;当前示例落地选择两端同名新表,物理索引由 Realm/Room 的查询计划决定。辅助表可存原始字幕,但不构成第二份正式消息。updated_revision 是本地变化版本,server_version 是服务端消息版本,两者不能混用。
blocks 是内容块数组,可扩展 image、audio、file、tool、form 等类型;类型未知时显示兼容占位,避免一个新卡片让整页解析失败。status 的具体枚举由产品协议统一,不能把生成结束、发送成功和历史确认混成一个状态。
05 / 列表读取、分页、刷新和定位
列表读取
服务端需要提供稳定的会话内排序键、消息版本和分页游标。当前示例落地使用 server_timeline_index 与 ui_id 排序、每页 50 条加 1 条探测;其他产品可按接口能力和预算配置。向上翻页
接近已加载内容顶部时使用上一页游标请求更早消息。请求期间只显示轻量 loading;失败显示重试。成功后把消息放到窗口前面,保留当前可见消息身份和位置。
进入和重新进入
是否实时接收、是否在通话结束刷新,以及进入页面是否刷新,均由产品配置决定;当前示例落地选择进入/重进刷新,不增加轮询和通话结束自动刷新。
首屏定位
先准备目标消息附近的有限窗口,再测量定位;不能等待全部历史,也不能先显示明显错误位置再跳过去。
| 状态 | 触发 | 反馈 |
|---|---|---|
| 首次读取 | 进入聊天页或重新进入。 | 首屏准备指示,成功后显示。 |
| 向上加载 | 接近顶部且has_more=true。 | 顶部显示“正在加载”。 |
| 没有更多 | 游标为空或探测行不足。 | 内部结束,不强制显示文字。 |
| 失败 | 网络、接口、身份、校验或数据库失败。 | 保留已有消息,显示失败和Retry。 |
首次进入和首屏不足一屏
| 情况 | 系统动作 | 页面结果 |
|---|---|---|
| 有正式缓存 | 先显示缓存,同时后台刷新最新历史。 | 用户先看到已有消息,刷新成功后只更新变化行。 |
| 没有缓存 | 创建列表并请求最新历史。 | 显示首屏准备状态;成功确认无消息后才显示空态。 |
| 已有消息不足一屏 | 首屏布局完成、视口变大或一页去重后,都重新判断是否补页;最多补有限页数。 | 能滚动就继续准备上一页,游标耗尽或失败后停止。 |
回到最新
用户离开最新端后显示“有新消息 / 回到最新”入口;新消息只更新提示,不强行改滚动位置。点击后先准备最新窗口,再定位到最新端;成功后恢复跟随并清除提示。用户拖动期间,旧的异步判断不能把列表重新拉回最新。
为什么屏幕 20 行、数据库仍可以取 50 条
网络页、数据库页、已加载数据窗口和实际挂载窗口是四个尺度。一个屏幕假设 20 行,数据库取 50+1 条可以供当前屏幕和下一屏准备;真正挂载的仍只是附近行。当前示例落地用 50 条作为分页预算;通用方案只要求稳定游标和可配置窗口,不要求所有产品都使用 50。需要额外正文的消息按需补取,不能因此把整库载入。
从来没有同步历史,只有字幕
当前进程新增的字幕消息按 local_sequence 保留在已有会话缓存中,窗口可以按 50 条向前扩展;无需先请求历史才能上翻。本次进程重启后不再展示这些未确认 local,也不扫描旧 local 恢复。若纯字幕会话特别长,应约束内存窗口并保留本次创建的消息 ID,再按 ID 分页取正文;这属于后续容量优化,不增加 process_id。
分页并发和失效
同一会话的历史请求串行或复用同一个进行中请求;重复触发不发重复 HTTP。请求记录当前会话代次,返回时再次核对,旧页面或登出前的结果不能写进新页面。预加载结果写入缓存后,正式扩展窗口直接复用。游标未推进或多页只有重复数据时必须有停止上限及具体错误码,避免无限 loading。
正式区按服务端顺序排列,本地区按当前进程序号放在正式区之后;不发明可以比较两类消息的全局有序 ID。历史确认导致 local 移入正式区时,用 ui_id 和阅读锚点处理移动。跨重启是否展示未确认消息是产品选择;当前示例选择不展示,以避免本地和正式消息缺少全局顺序时产生歧义。
06 / 流畅性:少读、少算、少改位置
屏幕附近渲染
只挂载可见行和上下额外一屏,屏幕外用高度占位。
高度和位置缓存
先用估算高度,再测量并只修正受影响范围。
滚动期间不打断
用户拖动或减速时,不因测量和预加载立即改scroll offset。
阅读位置保持
只在插入上一页或前方高度变化时保留锚点。
有限预加载
接近顶部提前读一页或一个屏幕附近的数据,不整理一万条记录。
消息对象复用
同一ui_id且字段未变时复用对象,只有增长气泡替换对象。
原生 ScrollView、倒置列表和虚拟窗口是什么关系
原生 ScrollView 负责惯性、触摸和滚动偏移;InvertedList / TranscriptList 在它上面负责倒置坐标、高度估算、可见范围、占位、分页触发和定位。倒置只改变显示方向,不会自动减少挂载行数。iOS、Android 变换实现可以不同,对上层提供统一的“最新端 / 历史端”语义。普通列表优先复用成熟虚拟列表,自定义恢复实现需要验证坐标和原生位置保持的组合。
追底、阅读、定位三种控制状态
| 状态 | 新内容到达 | 退出条件 |
|---|---|---|
| 跟随最新 | 新回复增长时跟随最新端,避免反复启动滚动动画。 | 用户开始拖动即退出。 |
| 阅读历史 | 保持阅读行,提示有新消息;不因后台回复强制追底。 | 用户点击回到最新或回到阈值范围。 |
| 主动定位 | 先挂载目标附近并测量,再发定位命令;到达后才确认成功。 | 到达、失败或用户新拖动;超时不能当作成功。 |
阅读位置保持与“列表是否销毁”是两件事。即使实例一直保留,上一页插入、图片解码、字体变化仍可能改变前方高度;这时仍需保持阅读锚点。应只由一处统一决定位置修正,避免 JS 和原生同时纠偏。拖动代次用于丢弃旧的异步追底决定。
图片、卡片和键盘
图片尽早使用已知宽高比预留空间;图片加载后只重新测量该行。离屏复杂卡片暂停无用动画,正在编辑或播放的少量行可按稳定 ID 保留并设置上限;草稿与业务状态存在列表外部,不能依赖行一直挂载。键盘弹出改变可用视口,跟随最新时维持输入框和最新消息可见,阅读历史时保持原阅读行,避免键盘和列表两边重复补偿。
恢复版中的 400ms 新消息填充保持、1500ms 定位保护属于固定版本行为参考;同条消息的流式增量不重复延长保持时间。预取半屏、额外渲染一屏、缓存 600 条是 Demo/工程参数,需要测量后确定,不是通用默认值。
预加载附近突然停住:怎样判断原因
把手势/惯性、分页请求返回、数据库查询、合并、快照编码、UI 提交和位置命令放进同一时间线。如果停顿紧跟 offset 修正,先查位置控制冲突;如果没有滚动命令但 JS/UI 长时间无响应,再查全量计算、图片解码和大范围布局。不能见到滚动停下就一律称为“卡顿”。
07 / 主要场景
| 场景 | 处理流程 | 一句话方案 |
|---|---|---|
| 字幕连续到达 | 按会话和角色找活动分组;相邻delta合并;更新同一local行;事件和分组落库。 | 多个字幕增量拼成一条本地消息。 |
| 历史覆盖完整字幕 | 按provider_event_ids找分组;校验覆盖;原local行改为server。 | 确认消息更新原行,保留ui_id。 |
| 历史只覆盖部分字幕 | 正式行保存服务端身份;未覆盖事件继续形成未确认的剩余本地消息行。 | 按事件确认,余量继续显示。 |
| 历史先到、字幕后到 | 先保存正式消息;迟到事件发现已覆盖则不改正式行;新事件开启新分组。 | 正式消息优先,迟到字幕不能改回local。 |
| 主动发送文字 | 生成client_request_id和ui_id;先写local pending;历史用请求ID确认。 | 请求ID负责关联,文本只负责展示。 |
| 主动发送图片 | 显示本地图片占位;上传得到asset_id/URL;历史确认原行。 | 图片URL是资源地址,不能当消息ID。 |
| 重启 | 查询新表origin=server;旧local保留但不加入快照。 | 临时消息只在当前进程展示。 |
| 上一页失败 | 保留当前窗口、游标和已有消息;Retry只重做失败页。 | 失败不清空列表。 |
| 切Tab返回 | 保留列表实例或状态;重新进入刷新最新历史并复用窗口锚点。 | 页面重现不等于列表从零开始。 |
| 快速滚动 | 预加载结果检查会话代次;滚动中不立即修正offset。 | 异步结果只能更新数据,不能覆盖新操作。 |
| 图片加载后变高 | 预留宽高比,复测变化行;历史区保持阅读锚点,最新端按跟随状态处理。 | 改图片高度,不抢用户滚动。 |
| 跳到搜索结果 | 按目标消息 ID 查找其附近窗口;挂载测量后滚动,未找到则明确失败。 | 先拿到目标,再定位;超时不算成功。 |
| 卡片离屏又回来 | 草稿、播放和表单状态放会话数据中;仅保留少量活跃行,离屏暂停无用动画。 | 行可销毁,操作状态不能丢。 |
| 键盘弹出或旋转 | 重新读取可用视口;保留当前阅读行或最新端,不重复计算键盘补偿。 | 视口变化按当前阅读意图处理。 |
| 停止生成 | 保留已收到的 blocks,标记当前生成请求失效;迟到增量不能继续修改这条消息。 | 停止是生成控制,不是历史分页 Retry。 |
| 生成失败或重新生成 | 失败保留原消息和错误状态;重新生成创建新请求或新版本,并按稳定身份更新。 | 发送/生成重试与加载上一页重试分开。 |
08 / 消息上的操作交互
列表负责找到消息和保持阅读位置;具体动作由消息卡片或业务能力执行。所有操作都使用 ui_id + block_id,不使用当前数组下标;弹层关闭、媒体返回和异步结果到达后,继续使用原阅读锚点。
| 操作 | 触发与结果 | 失败或离屏处理 |
|---|---|---|
| 长按、选择和复制 | 长按消息或文本块打开菜单;复制完成后保留当前列表位置。 | 消息离屏时按稳定 ID重新定位,不依赖挂载实例。 |
| 图片预览 | 点击图片打开预览;关闭后回到原消息,图片尺寸变化只复测对应行。 | 加载失败显示图片级重试,不把整页置为加载失败。 |
| 文件、音频和工具卡片 | 点击交给对应能力;播放/表单/草稿状态存在列表外部,卡片只读状态。 | 卡片离屏可暂停动画;返回后按 ID恢复。 |
| 停止、失败和重新生成 | 停止保留已到内容并使旧请求失效;重新生成走新请求或新版本。 | 与历史分页 Retry 分开显示和记录。 |
| 引用、编辑、删除 | 作为可选业务动作以稳定消息 ID发起;正式结果回来后更新/删除原行。 | 删除或变更后用邻近行作为锚点,不按数组位置恢复。 |
| 无障碍和字体变化 | 按实际消息顺序提供可读标签;字号、宽度、旋转变化后重建受影响高度。 | 保持焦点所在 ui_id;加载反馈不能反复抢焦点。 |
09 / 错误、重试与生命周期
错误应归到具体动作:最新历史刷新、上一页、本地读取、正文补取或卡片操作。保留现有消息,结束对应 loading,Retry 只重复失败动作。没有更多数据是正常结束,不是错误。
| 首次进入状态 | 显示规则 |
|---|---|
| 有缓存 | 先显示缓存,后台刷新;刷新失败不遮住已有消息,只显示轻量提示和重试。 |
| 无缓存且正在请求 | 显示首屏准备状态,不显示空态。 |
| 请求成功且没有消息 | 确认服务端没有消息后显示空态。 |
| 无缓存且请求失败 | 显示错误和 Retry,不能显示成“没有消息”。 |
| 场景 | 处理机制 | 必须记录 |
|---|---|---|
| 顶部出现通用加载失败 | 区分刷新失败与分页失败;已有缓存仍可阅读,不清空列表。 | 失败阶段、错误类别、HTTP 状态、可重试性、请求关联号。 |
| 网络超时或断网 | 结束当前等待,按有限策略重试;用户主动 Retry 可重新尝试。 | 持续时间、超时/取消的区别。 |
| 消息版本、内容或来源冲突 | 原事务失败,保留此前一致数据;不能假装成功。 | 校验代码和受影响数量,不记正文。 |
| 切 Tab / 后台 / 退出 | 暂停观察与无用动画;保留同账号窗口;旧结果按代次失效。 | 操作与取消原因,不能把主动取消显示为失败。 |
| 登出 | 释放当前缓存与观察,数据库按账号隔离;后续迟到响应失效。 | 隔离是否生效,不暴露账号或凭据。 |
10 / iOS、Android 和 React Native 分工
| 层 | 职责 | 共同约定 |
|---|---|---|
| 服务端接口 | 返回正式消息、版本、顺序、来源事件和分页游标。 | message_id、version、timeline_index、client_request_id语义一致。 |
| 本地数据库 | 保存统一消息和字幕关系,事务内更新覆盖关系。 | 逻辑字段含义、空值规则和查询结果合同一致;当前示例落地选择两端同名新表,物理索引按数据库能力确定。 |
| 原生宿主 | 负责账号、网络、数据库线程和RN Bridge。 | 错误要保留阶段和错误码,不能全部变成同一句提示。 |
| React Native | 渲染窗口、滚动、加载、重试和消息卡片。 | 只消费统一ConversationRow,不依赖宿主内部字段。 |
| 列表组件 | 控制可见范围、预加载、估算高度和定位。 | 数据库和分页计算放后台,UI线程只做布局。 |
11 / 一句话方案速查
| 问题 | 一句话方案 |
|---|---|
| 消息如何统一? | 字幕、历史、文字占位和图片占位都使用同一展示消息模型,来源关系另存。 |
| 字幕如何合并? | 按会话、角色和时间窗口把多个delta合并为一条local消息。 |
| 如何确认正式消息? | 按服务端消息ID和来源事件确认,完整确认时原地更新为server。 |
| 是否保存字幕? | 保存原始事件和分组关系,避免靠全文猜测。 |
| 如何分页? | 服务端提供稳定顺序和游标;页大小、探测条数和预取距离按产品预算配置。 |
| 如何预加载? | 接近顶部提前读取一页或一个屏幕附近的数据。 |
| 如何保持滚动? | 用稳定ui_id做锚点,必要时有限修正,手势期间不抢控制权。 |
| 如何处理失败? | 保留已显示消息,区分首次加载、上一页加载和正文补取错误。 |
| 如何跨重启? | 跨重启恢复策略按产品选择;当前示例选择只恢复正式消息。 |
| 如何支持双端? | 统一字段和状态语义,Realm、Room和RN分别实现各自职责。 |
12 / 依据、取舍和当前示例边界
本方案针对“与 AI 对话的消息列表”,不设计首页 Bot 目录。公开资料检索日期为 2026-11-05。不同 AI 消息产品的功能和技术实现不能混为一谈。
| 依据层级 | 可确认的事实 | 不能据此推出什么 |
|---|---|---|
| 公开产品官方文档 | 同一消息区承载文字、附件、工具活动和交互卡片;手机草稿按会话保留;支持历史搜索后跳转。消息与协作、移动端。 | 官方未公开列表算法、页大小、缓存容量和底层数据库。 |
| 公开产品官方排障资料 | 聊天历史独立于云端电脑;手机和桌面共享会话;隐藏不删除历史;反馈可附请求 ID。排障说明。 | 不能据此证明它采用本方案的 local/server 字段或当前字幕确认规则。 |
| React Native 官方文档 | ScrollView 本身会创建全部子项;虚拟列表通过有限窗口及空白占位减少开销。ScrollView、VirtualizedList。 | 只换成 ScrollView 不会自动获得虚拟化,也不能保证滚动更快。 |
| 本次设计决定 | 统一消息模型、当前进程 local 展示、重启仅 server、50 条分页、未来发送字段支持。 | 这些是当前示例的选择,不宣称是公开产品的内部存储设计。 |
需要保留的取舍
当前进程 local 展示避免跨重启排序歧义,但代价是尚未确认内容在重启后暂不展示,离线重启也不能恢复这些气泡。这不是通用聊天产品的唯一选择;如果未来要求离线待发恢复,需要另行设计持久发送队列、重试和跨端顺序。
验收看行为,不只看构建
至少覆盖:空库首次加载、正式缓存冷启动、纯字幕上翻、完整与部分确认、正式先到、图片高度变化、流式中读历史、快滑遇预取、跨 Tab 返回、旧响应迟到、游标耗尽、每个加载环节失败及原地重试。记录数据库读取数、处理耗时、挂载行数、帧间隔和非主动滚动次数;测试和实机结果分别记录。