5、BrowseFragment实战:构建媒体浏览主页、Header与Row的定制
说实话,Android TV开发里最常用的组件就是BrowseFragment。它就像电视应用的骨架,撑起了整个媒体浏览体验。我最早接触TV开发时,以为它就是个简单的列表容器,后来才发现——嗯,这里面的门道还真不少。
今天我们就来手把手搭建一个媒体浏览主页。我会把我在项目中踩过的坑、总结的经验都抖出来。
5.1 BrowseFragment是什么?
简单说,BrowseFragment是Leanback库提供的一个Fragment。它帮你管理了TV屏幕上的分层导航结构。说白了,就是左边一列分类(Header),右边一大块内容(Row)。
你想想看,Netflix、YouTube TV这些应用的主页,是不是都是这种结构?左边是「电影」「电视剧」「纪录片」这样的分类,右边是每个分类下的内容列表。
我个人习惯把BrowseFragment理解成一个「容器管理器」。它不关心你放什么内容,只负责把Header和Row的布局关系处理好。
- Header:左侧导航栏,展示分类标题
- Row:右侧内容行,每行可以是一个横向滚动的列表
- Adapter:连接数据和UI的桥梁,我们主要和它打交道
5.2 快速搭建一个BrowseFragment
先别急着写代码,我们理一下步骤。其实就三步:创建Fragment、设置Adapter、绑定数据。
来看一个最基础的实现:
public class MediaBrowseFragment extends BrowseFragment {
@Override
public void onActivityCreated(Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
// 1. 设置UI风格
setHeadersState(HEADERS_ENABLED);
setHeadersTransitionOnBackEnabled(true);
setBrandColor(getResources().getColor(R.color.primary));
// 2. 设置Adapter
ArrayObjectAdapter adapter = new ArrayObjectAdapter(new HeaderPresenter());
setAdapter(adapter);
// 3. 加载数据
loadData(adapter);
}
private void loadData(ArrayObjectAdapter adapter) {
// 创建分类
HeaderItem header1 = new HeaderItem(0, "热门推荐");
HeaderItem header2 = new HeaderItem(1, "最新上架");
// 创建每行的内容Adapter
ArrayObjectAdapter rowAdapter1 = new ArrayObjectAdapter(new CardPresenter());
rowAdapter1.add(new MediaItem("电影A", R.drawable.poster_a));
rowAdapter1.add(new MediaItem("电影B", R.drawable.poster_b));
ArrayObjectAdapter rowAdapter2 = new ArrayObjectAdapter(new CardPresenter());
rowAdapter2.add(new MediaItem("新片C", R.drawable.poster_c));
// 组装成Row
adapter.add(new ListRow(header1, rowAdapter1));
adapter.add(new ListRow(header2, rowAdapter2));
}
}
这段代码看着简单,但我第一次写的时候犯了个低级错误——忘了调用setAdapter()。结果屏幕一片空白,查了半天才发现。所以提醒你一句:Adapter一定要在数据加载前设置好。
5.3 Header的定制
默认的Header样式比较朴素。就是一个白色文字加一个背景色块。但实际项目中,产品经理往往会提各种定制需求。
我记得有一次,产品要求Header要显示图标+文字,而且选中状态要有一个动画效果。当时我翻遍了Leanback的文档,发现默认的HeaderPresenter不支持这么玩。
怎么办?自己写一个Presenter。
public class CustomHeaderPresenter extends Presenter {
@Override
public ViewHolder onCreateViewHolder(ViewGroup parent) {
// 自定义布局
LayoutInflater inflater = LayoutInflater.from(parent.getContext());
View view = inflater.inflate(R.layout.custom_header, parent, false);
return new ViewHolder(view);
}
@Override
public void onBindViewHolder(ViewHolder viewHolder, Object item) {
HeaderItem headerItem = (HeaderItem) item;
ViewHolder vh = (ViewHolder) viewHolder;
// 设置图标和文字
ImageView icon = vh.view.findViewById(R.id.header_icon);
TextView label = vh.view.findViewById(R.id.header_label);
icon.setImageResource(getIconForHeader(headerItem.getId()));
label.setText(headerItem.getName());
// 选中状态处理
vh.view.setOnFocusChangeListener((v, hasFocus) -> {
if (hasFocus) {
// 放大效果
v.animate().scaleX(1.1f).scaleY(1.1f).setDuration(200).start();
} else {
v.animate().scaleX(1.0f).scaleY(1.0f).setDuration(200).start();
}
});
}
private int getIconForHeader(long id) {
// 根据Header ID返回对应的图标资源
switch ((int) id) {
case 0: return R.drawable.ic_hot;
case 1: return R.drawable.ic_new;
default: return R.drawable.ic_default;
}
}
}
onRowSelected方法,配合setHeadersState(HEADERS_DISABLED)自己实现导航逻辑。
5.4 Row的定制
Row的定制比Header更常见。因为每行展示的内容形式可能完全不同。比如第一行是「大图推荐」,第二行是「小图列表」,第三行是「文字标签」。
我做过一个项目,需要在一行里展示「视频+标题+描述+评分」这种复杂卡片。默认的CardPresenter显然不够用。
解决方案还是写自定义Presenter:
public class MediaCardPresenter extends Presenter {
@Override
public ViewHolder onCreateViewHolder(ViewGroup parent) {
View view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.media_card, parent, false);
return new ViewHolder(view);
}
@Override
public void onBindViewHolder(ViewHolder viewHolder, Object item) {
MediaItem media = (MediaItem) item;
ViewHolder vh = (ViewHolder) viewHolder;
// 绑定数据
ImageView poster = vh.view.findViewById(R.id.card_poster);
TextView title = vh.view.findViewById(R.id.card_title);
TextView rating = vh.view.findViewById(R.id.card_rating);
// 加载图片(建议用Glide)
Glide.with(vh.view.getContext())
.load(media.getImageUrl())
.into(poster);
title.setText(media.getTitle());
rating.setText(String.valueOf(media.getRating()));
// 焦点效果
vh.view.setFocusable(true);
vh.view.setOnFocusChangeListener((v, hasFocus) -> {
if (hasFocus) {
v.setElevation(8f);
v.setZ(1f);
} else {
v.setElevation(0f);
v.setZ(0f);
}
});
}
}
setFocusable(true)。我曾经因为忘记设置,导致遥控器按了半天都没反应。
5.5 数据加载与分页
真实项目中的数据不会像示例里那样写死。一般是从网络加载,而且需要分页。
我习惯用ArrayObjectAdapter配合AsyncListUtil来做分页加载。但这里有个坑——ArrayObjectAdapter不是线程安全的。如果你在子线程更新数据,必须切回主线程。
// 分页加载示例
private void loadMoreData(int page) {
new Thread(() -> {
List<MediaItem> items = fetchFromNetwork(page);
// 切回主线程更新UI
getActivity().runOnUiThread(() -> {
for (MediaItem item : items) {
currentRowAdapter.add(item);
}
});
}).start();
}
其实更好的做法是用RxJava或者Coroutines来管理异步。但如果你项目还没引入这些库,用Handler也行。别为了用新技术而引入不必要的依赖。
5.6 实战中的避坑指南
最后分享几个我踩过的坑:
- 内存泄漏:BrowseFragment持有Activity引用,如果数据加载慢,Fragment被销毁了还在回调,就会泄漏。记得在
onDestroyView()里取消异步任务。 - 焦点丢失:当数据刷新时,当前焦点可能会丢失。可以在刷新后调用
requestFocus()恢复焦点位置。 - 图片加载:TV屏幕大,图片分辨率要高。但加载大图容易OOM。建议用Glide的
override()方法限制图片尺寸。 - Header文字截断:中文在Header里容易显示不全。给Header的TextView设置
maxWidth和ellipsize属性。
嗯,以上就是BrowseFragment实战的核心内容。从基础搭建到Header和Row的定制,再到数据加载和避坑,基本覆盖了日常开发的主要场景。你动手试试看,遇到问题随时可以翻回这一章对照。
总结要点:
- BrowseFragment = Header(左侧导航)+ Row(右侧内容)
- 定制Header和Row的核心是写自定义Presenter
- 焦点管理是TV开发的重中之重
- 数据加载注意线程安全和内存泄漏