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的桥梁,我们主要和它打交道
BrowseFragment 布局结构 Header 区域 分类1 分类2 分类3 分类4 分类5 Row 内容区域 Row 1: 热门推荐(横向滚动列表) Row 2: 最新上架(横向滚动列表) Row 3: 经典回顾(横向滚动列表) ← 焦点可左右移动 → ← 每行可横向滚动 →

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;
        }
    }
}
小技巧:Header的焦点移动默认是垂直方向。如果你想让Header支持横向滚动(比如分类太多),可以重写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);
            }
        });
    }
}
注意:TV上的焦点效果和手机不同。手机点击就行,TV必须依赖焦点。所以每个可聚焦的View都要设置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设置maxWidthellipsize属性。

嗯,以上就是BrowseFragment实战的核心内容。从基础搭建到Header和Row的定制,再到数据加载和避坑,基本覆盖了日常开发的主要场景。你动手试试看,遇到问题随时可以翻回这一章对照。

总结要点:

  1. BrowseFragment = Header(左侧导航)+ Row(右侧内容)
  2. 定制Header和Row的核心是写自定义Presenter
  3. 焦点管理是TV开发的重中之重
  4. 数据加载注意线程安全和内存泄漏

公众号:蓝海资料掘金营,微信deep3321