本文主要记录 Android 媒体库客户端(Kotlin + Jetpack Compose)在内容墙、详情页与登录恢复链路上的几类典型 UI / 状态问题:列表滚动跳动、卡片闪烁、返回后位置丢失、覆盖安装后「像被踢下线」,以及 TV 首页实验变更回退。结论面向同类 Compose + Paging 应用可复用,不绑定某一仓库路径。

以 Octans Android 客户端踩坑为素材,问题表面都是「滚着跳一下」「闪一下」「又要重新登录」,根因往往落在 Paging 数据窗口卡片高度稳定性UI 状态生命周期网络失败的状态机语义。下面按场景分诊,避免一上来只调 Coil 或只怪设备性能。

分诊总表

现象优先怀疑不宜先怪
海报墙滚到一半突然跳、触控区域被挤Paging placeholders / 初始 REFRESH 清缓存单张封面解码尺寸
位置几乎不动,但画面闪一下卡片文字区高度变化GPU 性能(无数据时勿下定论)
进详情再返回,墙回到顶部滚动状态未绑定 route服务端列表接口
覆盖装包后进登录页启动恢复时 DNS/网络失败被当成匿名安装清空了数据(需先查 PM 日志)
TV 首页实验包更卡未提交性能/焦点改动整体回退先写死「某一行代码是根因」

场景一:海报墙滚动跳动(Paging 窗口)

现象

向下滚动时,部分条目突然刷到可视区域前方,正在滑动的触控区被向后挤。多机可复现。卡片本身已用固定比例时,更不像「图加载撑高格子」。

排查

在 Paging 与 grid 两侧打诊断日志:loadTypeskip/takeitemCountloadState、首个可视项与 key。观察是否出现 itemCount 回落(例如 60→30、90→30)。

adb -s <serial> logcat -d -v time -s <WallPerfTag>

原因(可叠加)

  1. 关闭 Paging placeholders:Compose 只能看到当前真实窗口;RemoteMediator APPEND 写库触发 invalidation 后,窗口可能短暂收缩,index/key 对应关系变化。
  2. 进入页面默认 REFRESH 清空缓存再写第一页:本地已有较多缓存时,itemCount 瞬间掉到第一页大小,用户同时滚动就会「跳」。

处理口径

  • 打开 placeholders,并对 null 占位渲染同尺寸 skeleton,避免空项被跳过破坏网格几何。
  • RemoteMediator.initialize():本地已有缓存行且存在 remote key 时 SKIP_INITIAL_REFRESH;手动下拉刷新仍走完整刷新语义。
  • 用日志确认滚动过程中 itemCount 不再回落。

需要注意的是:placeholders 只稳定本地已知缓存行,不等于客户端已持有远端总数。

场景二:内容墙闪烁(卡片内部高度)

现象

滚到一半闪一下,海报位置几乎不变。与「itemCount 回落」的跳动不同,更像可视区内卡片被轻微挤压。

排查

先用日志排除场景一。再检查卡片 meta:字段从空变有值、标题换行、评分从一列变多列、缺失字段是否保留空行。

海报区固定 2:3 时,Coil 加载前后通常不应改变图片区域比例;文字区才是高度抖动的高发区。

处理口径

  • 内容墙文字单行 + ellipsis,不允许自然换行撑高卡片。
  • 缺失字段不渲染,不保留空行占位。
  • 显示项拆成细粒度开关(类型、年份、多评分源、原标题等),用设置控制信息密度。
  • 网格用 Adaptive 最小宽度控制海报大小,而不是死记固定列数却忽略不同机宽。

场景三:滚动位置丢失与下拉刷新

现象

海报墙滚到中段进详情,返回后回到顶部;详情页中段离开再进也不保留。

原因

列表数据可能由 Paging / Room 保持,但 LazyGridState / LazyListStateUI 状态。若只在页面函数里临时 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/refreshDNS / 网络 阶段失败;旧逻辑把失败展示成匿名 + 登录表单,语义上像「已登出」。网络恢复后同一设备可恢复成功。

状态边界(建议)

条件UI本地 token
恢复中Checking不变
成功Authenticated刷新
本地无 tokenAnonymous + 登录表
服务端明确会话失效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 / 性能实验是否可一键回退到已知好提交?

注意事项

  1. 设备序列号、内网 host、真实用户 ID 不要写进对外文档;日志示例已脱敏。
  2. 无系统帧率数据时,不要把闪烁写成确定的 GPU 瓶颈。
  3. 本文是排障与取舍记录,不是某一版本的功能承诺。
  4. 与播放内核、服务端会话问题交叉时,先确认层:UI 状态 / 客户端认证 / 服务端 IAM / 播放 session。

相关阅读

同系列还可对照:

来源

本文合并改写自 Octans 项目 usage 层多篇 Android UI 排障记录(海报墙跳动 / 字段闪烁 / 滚动与刷新 / 主演卡片 / 会话恢复 / TV 首页实验回退)。实现路径与包名已抽象;验证命令以通用 adb 形式给出。