本文主要记录 Android 媒体库客户端(Kotlin + Jetpack Compose)在内容墙、详情页与登录恢复链路上的几类典型 UI / 状态问题:列表滚动跳动、卡片闪烁、返回后位置丢失、覆盖安装后「像被踢下线」,以及 TV 首页实验变更回退。结论面向同类 Compose + Paging 应用可复用,不绑定某一仓库路径。
以 Octans Android 客户端踩坑为素材,问题表面都是「滚着跳一下」「闪一下」「又要重新登录」,根因往往落在 Paging 数据窗口、卡片高度稳定性、UI 状态生命周期 与 网络失败的状态机语义。下面按场景分诊,避免一上来只调 Coil 或只怪设备性能。
分诊总表
| 现象 | 优先怀疑 | 不宜先怪 |
|---|---|---|
| 海报墙滚到一半突然跳、触控区域被挤 | Paging placeholders / 初始 REFRESH 清缓存 | 单张封面解码尺寸 |
| 位置几乎不动,但画面闪一下 | 卡片文字区高度变化 | GPU 性能(无数据时勿下定论) |
| 进详情再返回,墙回到顶部 | 滚动状态未绑定 route | 服务端列表接口 |
| 覆盖装包后进登录页 | 启动恢复时 DNS/网络失败被当成匿名 | 安装清空了数据(需先查 PM 日志) |
| TV 首页实验包更卡 | 未提交性能/焦点改动整体回退 | 先写死「某一行代码是根因」 |
场景一:海报墙滚动跳动(Paging 窗口)
现象
向下滚动时,部分条目突然刷到可视区域前方,正在滑动的触控区被向后挤。多机可复现。卡片本身已用固定比例时,更不像「图加载撑高格子」。
排查
在 Paging 与 grid 两侧打诊断日志:loadType、skip/take、itemCount、loadState、首个可视项与 key。观察是否出现 itemCount 回落(例如 60→30、90→30)。
adb -s <serial> logcat -d -v time -s <WallPerfTag>
原因(可叠加)
- 关闭 Paging placeholders:Compose 只能看到当前真实窗口;RemoteMediator APPEND 写库触发 invalidation 后,窗口可能短暂收缩,index/key 对应关系变化。
- 进入页面默认 REFRESH 清空缓存再写第一页:本地已有较多缓存时,
itemCount瞬间掉到第一页大小,用户同时滚动就会「跳」。
处理口径
- 打开 placeholders,并对
null占位渲染同尺寸 skeleton,避免空项被跳过破坏网格几何。 RemoteMediator.initialize():本地已有缓存行且存在 remote key 时 SKIP_INITIAL_REFRESH;手动下拉刷新仍走完整刷新语义。- 用日志确认滚动过程中
itemCount不再回落。
需要注意的是:placeholders 只稳定本地已知缓存行,不等于客户端已持有远端总数。
场景二:内容墙闪烁(卡片内部高度)
现象
滚到一半闪一下,海报位置几乎不变。与「itemCount 回落」的跳动不同,更像可视区内卡片被轻微挤压。
排查
先用日志排除场景一。再检查卡片 meta:字段从空变有值、标题换行、评分从一列变多列、缺失字段是否保留空行。
海报区固定 2:3 时,Coil 加载前后通常不应改变图片区域比例;文字区才是高度抖动的高发区。
处理口径
- 内容墙文字单行 + ellipsis,不允许自然换行撑高卡片。
- 缺失字段不渲染,不保留空行占位。
- 显示项拆成细粒度开关(类型、年份、多评分源、原标题等),用设置控制信息密度。
- 网格用
Adaptive最小宽度控制海报大小,而不是死记固定列数却忽略不同机宽。
场景三:滚动位置丢失与下拉刷新
现象
海报墙滚到中段进详情,返回后回到顶部;详情页中段离开再进也不保留。
原因
列表数据可能由 Paging / Room 保持,但 LazyGridState / LazyListState 是 UI 状态。若只在页面函数里临时 remember,destination 重建、返回或配置变更后会丢 first visible item。
处理口径
- 在 shell 级维护按 route key 保存的滚动仓库(index + offset),配合
rememberSaveable跨配置变更。 - 墙与详情分别用绑定 route 的
LazyGridState/LazyListState工厂。 - 下拉刷新语义:通常在顶部触发;刷新后滚顶并刷新即可,不必做复杂「找回附近 item」。
- 分栏(左墙右详情)下快速重选 tab:有详情 anchor 时只刷新并对齐,不要用普通滚顶覆盖详情定位。
人工验收建议:墙中段 → 详情 → 返回应落在附近;详情中段离开再进位置仍在;下拉刷新后到顶。
场景四:详情页主演区导致下方 section 跳动
现象
电影详情滚到「主要演员」时,名字/角色换行会把下方合集区上下挤。
处理口径
- 卡片外层固定宽高;头像固定比例;meta 区固定高度预算。
- 演员名 / 角色名设 maxLines;角色缺失不占行。
- 长角色名进弹窗完整查看,不在横向列表卡片里无限展开。
- 无提交动作的弹窗优先系统返回 / 点外部关闭,避免多余关闭按钮占布局。
原则:卡片稳定浏览,长文本走二级容器;任何会影响 section 高度的字段都必须有上限。
场景五:覆盖安装后「被退出登录」
现象
覆盖安装新 APK 后进登录页,用户以为装包清了数据。PackageManager 却显示 Retain data and using new。
原因(一次真实案例)
客户端读到本地 refresh token 并 restoreSession,但 /auth/refresh 在 DNS / 网络 阶段失败;旧逻辑把失败展示成匿名 + 登录表单,语义上像「已登出」。网络恢复后同一设备可恢复成功。
状态边界(建议)
| 条件 | UI | 本地 token |
|---|---|---|
| 恢复中 | Checking | 不变 |
| 成功 | Authenticated | 刷新 |
| 本地无 token | Anonymous + 登录表 | 无 |
| 服务端明确会话失效 | Anonymous + 登录表 | 清理 |
| DNS / 网络 / 临时不可达 | RestoreFailed + 重试 | 保留 |
排查
# 是否保留数据
adb -s <serial> logcat -d -v threadtime \
| rg 'PackageManager|Retain data|<package>'
# 认证生命周期
adb -s <serial> logcat -d -v threadtime \
| rg 'AuthLifecycle|AuthNetwork|UnknownHostException|restoreSession|showAnonymous|showRestoreFailed'
服务端账号禁用、批量登出、改密等另走 IAM 排障,不要和客户端启动恢复混为一谈。
场景六:TV 首页实验更卡——观察级回退
现象
同一设备上,已提交基线包滚动更顺;同日未提交的「性能 / 焦点」工作区实验更卡。回退后按同一 commit 重装,手感回到基线附近。
口径(重要)
- 这是整包对照 + 用户体感,不是单变量二分后的根因定论。
- 未证明「某一行改动」必然导致卡顿;只证明那批实验整体不值得保留。
- 不要把竞品 Leanback 或「Compose 本身」直接写成这次卡顿原因。
- 实验失败应完整回退,并记录涉及路径,避免未提交 diff 再次混入发布线。
后续若再优化 TV 滚动,应小步提交、单变量对照、固定片库与焦点路径,再谈帧时与掉帧。
可复用检查清单
- 列表异常是否先分「数据窗口」还是「卡片高度」?
- Paging 是否 placeholders + 同尺寸 skeleton?
- 进页是否无意义的自动 REFRESH 清缓存?
- 滚动状态是否按 route 保存在 shell 生命周期内?
- 详情 section 是否固定高度预算与 maxLines?
- 认证失败是否区分「会话失效」与「临时网络」?
- TV / 性能实验是否可一键回退到已知好提交?
注意事项
- 设备序列号、内网 host、真实用户 ID 不要写进对外文档;日志示例已脱敏。
- 无系统帧率数据时,不要把闪烁写成确定的 GPU 瓶颈。
- 本文是排障与取舍记录,不是某一版本的功能承诺。
- 与播放内核、服务端会话问题交叉时,先确认层:UI 状态 / 客户端认证 / 服务端 IAM / 播放 session。
相关阅读
同系列还可对照:
- Octans Android 播放内核演进:从 libmpv 单内核到双内核实验:播放路径与 UI shell 边界分开。
- 媒体内容产品 + 管理后台混合前端的 UI 基座选型:Web 端自有 UI 边界;多端是原生各自 UI。
- 媒体库播放排障:从会话创建到 HLS / DirectPlay / 字幕链路:服务端播放会话,与客户端登录恢复不是同一层。
来源
本文合并改写自 Octans 项目 usage 层多篇 Android UI 排障记录(海报墙跳动 / 字段闪烁 / 滚动与刷新 / 主演卡片 / 会话恢复 / TV 首页实验回退)。实现路径与包名已抽象;验证命令以通用 adb 形式给出。