如何使用 BrowseFragment 实现电影分类浏览界面?
解读
在国内 Android TV 面试中,90% 的“电影分类浏览”场景都会落到 Leanback Support Library 的 BrowseFragment 上。面试官真正想确认的是:
- 你是否知道 TV 与手机在焦点、布局、遥控器事件上的差异;
- 能否把“分类”与“内容”两级数据映射到 HeadersFragment 与 RowsFragment,并保证 5 向导航体验;
- 是否理解 Presenter 的复用机制、内存与性能优化;
- 是否熟悉国内常见的“海报墙”视觉规范(16:9 封面、角标、评分、VIP 标签);
- 能否在 1080p/4K 双分辨率、折叠屏、投影仪等异构终端上做适配。
一句话:把 BrowseFragment 当成“两级 RecyclerView + 焦点管理器”来用,但要兼顾 TV 的“十字导航”与“无触摸”场景。
知识点
- Leanback 结构:BrowseFragment = HeadersFragment(左侧分类)+ RowsFragment(右侧行)
- PresenterSelector:根据数据类型返回不同 Presenter,实现“横向混合行”
- ListRow/ArrayObjectAdapter:一行数据一个 ListRow,HeaderItem 对应分类名
- BackgroundManager:随着焦点移动做 200ms 渐变背景替换,降低 GPU 负载
- 焦点策略:setOnItemViewSelectedListener 里调用 setSelectedPosition 保证十字导航不丢焦
- 国内合规:海报图必须异步加载并带“先审后播”角标;敏感影片需动态马赛克
- 性能:行级预加载 3 屏,行内缓存 20 张海报;Glide 复用 BitmapPool,防止 4K 爆内存
- 埋点:在 Presenter 的 onCreateViewHolder 里注入曝光埋点;在 onItemClicked 里注入点击埋点,统一走公司大数据 SDK
- 无障碍:setAccessibilityListener 读出“第几行第几个”供视障用户使用
- 适配:1080p 用 240dp 行高,4K 用 360dp;使用 dimens-sw540dp 与 drawable-xxhdpi 做资源隔离
答案
步骤 1:依赖与主题
在 build.gradle 中引入 leanback 1.2.0-alpha02(国内镜像源可用阿里 cloud),主题继承 Theme.Leanback,关闭 Title 区域:
BrowseFragment.setHeadersState(BrowseFragment.HEADERS_ENABLED)
setTitle(getString(R.string.app_name)) // 隐藏顶部 Title
步骤 2:数据层
class MovieCategory(val id: String, val name: String)
class Movie(val title: String, val coverUrl: String, val score: Float, val vip: Boolean)
使用 Retrofit+Kotlin Coroutines 拉取后端 /category/list 与 /movie/list?categoryId=xx,返回 Flow<PagingData>,本地缓存到 Room,离线时直接展示缓存。
步骤 3:Presenter 与 Selector
class CardPresenter : Presenter() {
override fun onCreateViewHolder(parent: ViewGroup): ViewHolder {
val card = ImageCardView(parent.context).apply {
setMainImageDimensions(320, 180) // 16:9
setMainImageScaleType(ImageView.ScaleType.CENTER_CROP)
}
return ViewHolder(card)
}
override fun onBindViewHolder(viewHolder: ViewHolder, item: Any) {
val movie = item as Movie
val card = viewHolder.view as ImageCardView
card.titleText = movie.title
card.contentText = "${movie.score} 分"
Glide.with(viewHolder.view.context)
.load(movie.coverUrl)
.centerCrop()
.into(card.mainImageView)
if (movie.vip) card.badgeText = "VIP"
}
}
class Selector : PresenterSelector() {
private val cardPresenter = CardPresenter()
override fun getPresenter(item: Any) = cardPresenter
}
步骤 4:组装 Rows
val rowsAdapter = ArrayObjectAdapter(Selector())
categories.forEach { category ->
val header = HeaderItem(category.id.toLong(), category.name)
val listAdapter = ArrayObjectAdapter(CardPresenter())
moviesMap[category.id]?.forEach { listAdapter.add(it) }
rowsAdapter.add(ListRow(header, listAdapter))
}
adapter = rowsAdapter
步骤 5:焦点与背景
setOnItemViewSelectedListener { _, item, _, _ ->
if (item is Movie) {
BackgroundManager.getInstance(activity).drawable =
Glide.with(activity).asDrawable().load(item.coverUrl).submit().get()
}
}
setOnItemViewClickedListener { _, item, _, _ ->
if (item is Movie) startActivity<DetailActivity>("movie" to item)
}
步骤 6:国内合规与性能
- 海报图域名走 CDN 备案域名,Glide 加签名参数 ?x-oss-process=image/format,webp
- 4K 终端下行带宽 ≥ 50 Mbps,预加载下一行 5 张图;1080p 终端预加载 3 张
- 使用 RecyclerView.RecycledViewPool 共享,行内复用 20 个 VH,防止 GC 抖动
- 敏感影片根据 CMS 返回的 isMasked 字段,在 onBindViewHolder 里对 ImageView 加高斯模糊,模糊半径 25px,耗时 < 2 ms
步骤 7:测试与上线
- 使用 Leanback 自带的 androidx.leanback.test.BrowseFragmentTest 做单元测试
- 真机覆盖小米电视 6、华为智慧屏 SE、创维 4K 投影仪,连续十字导航 30 分钟无丢焦
- 通过 Google Play 预审核与国内华为、小米、OPPO 三家渠道审核,AAB 包启用资源优化,webp 图片占比 > 90%,整包 < 15 MB
拓展思考
- 当分类超过 30 个时,HeadersFragment 会触发“长列表焦点漂移”,可自定义 FastLane 模式,把分类横向置顶,类似腾讯视频极速版
- 如果业务要求“无限瀑布流”,可把 RowsFragment 替换为自定义 VerticalGridFragment,但需重写 FocusHighlighter,否则 5 向导航会错位
- 国内厂商遥控器普遍存在“返回键拦截”差异,需在 Activity 的 onKeyDown 里统一分发,防止 BrowseFragment 被意外 finish
- 未来 TV 3.0 会强制隐私沙盒,广告 ID 拿不到,曝光埋点需改用 Topics API,提前在 Presenter 里埋好开关
- 折叠屏 TV(海信可卷曲激光电视)展开后分辨率 7680×2160,行高需动态计算:rowHeight = displayHeight / 5,防止海报被拉伸