SuperAPI AI 工具集

queue-index-vs-shuffle-space

测试与质量

查看项目

更新于 10-17 16:16

技能介绍

该页面讨论的是媒体播放器开发中一个常见的索引错乱问题:播放引擎内部按添加顺序(时间线顺序)保存队列条目,开启随机播放后,实际播放顺序是另一个置换顺序。引擎报告的“当前索引”属于时间线空间,而界面上显示的列表行属于播放顺序空间。两者仅在随机播放关闭时相等,因此该缺陷容易在上线后暴露。

修复方案分为两部分,均应在播放器层实现,而非在列表组件中处理: 第一,从曲目切换回调中发布当前索引,而不仅仅在队列变更时发布。 第二,暴露置换的两个方向,按位置转换,而不是按ID匹配。

文章给出了一个接口示例: interface MediaPlayerInterface { // play order -> timeline: "the user tapped row N of what they see" fun getUnshuffledIndex(shuffledIndex: Int): Int // timeline -> play order: "which row do I highlight" fun getShuffledIndex(timelineIndex: Int): Int } 每个适配器已经维护了用于回答上一首/下一首的置换,两个方向各是一次对已有数组的查找。

文章还列出了多个陷阱:

  • 索引状态由每个变更操作写入,唯独漏掉了曲目切换回调。通常一个MutableStateFlow<Int>会在moveMediaItem、removeMediaItem、moveItemUp/Down和公开setter中被赋值,但不在曲目切换回调中。结果流永远持有最后一次编辑的索引,标记只在用户拖动行时移动,歌曲结束时不动。验证方法是grep所有对后备字段的写入,并检查切换回调是否在列表中: grep -n "_currentSongIndex" HandlerImpl.kt # every write site grep -n "onMediaItemTransition" HandlerImpl.kt # is it among them? 一个没有调用者的公开setter通常意味着有人打算接线但没完成。

  • 索引和它索引的列表位于不同空间。player.currentMediaItemIndex是时间线空间;从引擎时间线重新推导出的UI列表是播放顺序。随机播放关闭时传递正确,开启时静默错误。验证方法:开启随机播放,队列长度超过屏幕,检查高亮行是否是可听见的那一行。

  • 通过匹配ID来解析正在播放的行在重复项上会失败。常见补丁是: // wrong: every copy of a repeated track matches queue.listTracks.indexOfLast { it.trackId == player.currentMediaItem?.mediaId } 队列可以合法地包含同一曲目两次(用户再次排队,或续页重新提供)。两个副本都满足谓词,indexOfFirst和indexOfLast都会高亮错误的一个。ID标识内容,位置标识队列条目。正在播放的条目是一个位置,只有位置能回答。优先使用播放器已经报告的位置(它自己的顺序索引),但在信任之前用ID验证,不一致时回退到ID搜索;在队列重建期间,列表和播放器时间线可能短暂不同步,未验证的位置可能指向其他曲目。

  • 映射通常只存在一个方向。适配器先实现播放顺序→时间线,因为点击行需要它:player.seekTo(getUnshuffledIndex(tappedRow), 0)。直到绘制标记才需要反向,所以反向缺失,于是出现ID匹配 hack。验证:grep播放器接口中的索引转换函数,数一数方向。

  • 比较的ID可能不是存储的ID。引擎经常收到装饰过的媒体ID,比如标记多个版本中哪一个被加载的前缀,或缓存键。对普通音频有效的比较在装饰条目上会静默失败: it.trackId == player.currentMediaItem?.mediaId?.removePrefix(VIDEO_MARKER) removePrefix是关键。任何基于位置的修复都会完全消除这个需要,这是选对修复的好迹象。

  • 随机播放置换在队列编辑中不稳定。插入、删除和重新随机播放都会产生新的置换,因此缓存在view model中的索引会超出其含义。每次使用时从播放器重新读取,或从改变队列的回调中重新发布。

  • 在列表composable中修复看起来有效。在行的isPlaying检查中去重(比较composable自己计算的索引)会让可见症状在测试的一个屏幕中消失,但会留在队列面板、迷你播放器和封面轮播中,每个都计算自己的索引。任何需要“哪个条目正在播放”的组件都是播放器层应发布的一个值的消费者。

  • 从切片子列表重建绝对索引是第三个索引空间,看起来像算术而不是状态。只渲染即将播放曲目的队列视图先切片完整列表,然后在点击时将切片自身的偏移量加回本地位置,得到播放器理解的索引: // adapted — names generalized; wrong: index is local to upcoming, offset is read again separately val upcoming = queue.drop(offset) itemsIndexed(upcoming) { index, track -> onClick = { actions.onSeekToQueueIndex(offset + index) } // reconstructed, not carried } 同一队列的兄弟视图渲染完整列表则无需重建——itemsIndexed已经给出绝对索引——所以它从不显示此故障,这是一个屏幕处理点击不当而孪生屏幕正常的第一个线索。在切片前将绝对索引附加到每个元素,使其作为数据在切片后保留,而不是从第二次读取的值重建: // adapted — names generalized; the key is simplified (the real one concatenates index and track id) val upcoming = queue.withIndex().drop(offset) // index attached first, from the full list itemsIndexed(upcoming, key = { _, item -> item.index }) { _, item -> onClick = { actions.onSeekToQueueIndex(item.index) } // carried, not reconstructed } 陈旧或错误的偏移量仍可能在错误位置切割切片;携带索引意味着它不再能让可见行跳转到与其显示不同的曲目。

验证方法:构建一个包含同一曲目两次的队列,开始播放,开启随机播放。然后按顺序检查:标记跟随自然曲目结束;标记跟随跳过;点击行播放该行(而不是其重复项);拖动行保持标记在可听曲目上。所有四项读取同一个值,所以只通过部分项的修复仍然在错误的索引空间。