8、卡片与Presenter:CardPresenter与ImageCardView的深度定制

做TV应用开发,卡片是绕不开的核心元素。你想想看,用户坐在三米外,拿着遥控器上下左右移动焦点,他们看到的是什么?就是一个个卡片。卡片做得好不好,直接决定了用户愿不愿意继续用你的应用。

我个人习惯把卡片比作「演员」,而Presenter就是「导演」。导演告诉演员什么时候出场、穿什么衣服、做什么动作。在Leanback体系里,CardPresenter就是这个导演,ImageCardView就是那个演员。今天我们就来深度定制这对搭档。

8.1 理解CardPresenter的工作机制

CardPresenter是Leanback库中ObjectAdapter和UI之间的桥梁。说白了,它负责两件事:创建卡片视图,以及把数据绑定到视图上。

先看一个最基础的实现:

public class MyCardPresenter extends Presenter {
    @Override
    public ViewHolder onCreateViewHolder(ViewGroup parent) {
        // 创建卡片视图
        ImageCardView cardView = new ImageCardView(parent.getContext());
        cardView.setFocusable(true);
        cardView.setFocusableInTouchMode(true);
        return new ViewHolder(cardView);
    }

    @Override
    public void onBindViewHolder(ViewHolder viewHolder, Object item) {
        Movie movie = (Movie) item;
        ImageCardView cardView = (ImageCardView) viewHolder.view;
        
        // 绑定标题和内容
        cardView.setTitleText(movie.getTitle());
        cardView.setContentText(movie.getDescription());
        
        // 加载图片
        cardView.setMainImage(movie.getImageUri());
    }

    @Override
    public void onUnbindViewHolder(ViewHolder viewHolder) {
        // 清理资源
        ImageCardView cardView = (ImageCardView) viewHolder.view;
        cardView.setBadgeImage(null);
        cardView.setMainImage(null);
    }
}

嗯,这里要注意一点:onUnbindViewHolder很多人会忽略。我曾经在一个项目中没做资源清理,结果卡片快速滑动时内存暴涨,最后OOM了。所以该释放的资源一定要释放。

8.2 ImageCardView的深度定制

ImageCardView默认提供了标题、内容文本和主图三个区域。但实际项目中,这些往往不够用。比如你需要显示评分、角标、播放进度条等。

我建议的做法是:继承ImageCardView,或者直接组合自定义布局。

核心思路:不要被ImageCardView的现有结构限制住。它本质上是一个FrameLayout,你可以在上面叠加任何View。

来看一个带评分和角标的定制示例:

public class CustomCardView extends ImageCardView {
    private TextView ratingText;
    private ImageView badgeView;
    private ProgressBar progressBar;

    public CustomCardView(Context context) {
        super(context);
        init();
    }

    private void init() {
        // 加载自定义布局
        LayoutInflater.from(getContext())
            .inflate(R.layout.custom_card_overlay, this, true);
        
        ratingText = findViewById(R.id.rating);
        badgeView = findViewById(R.id.badge);
        progressBar = findViewById(R.id.progress);
    }

    public void setRating(float rating) {
        ratingText.setText(String.format("%.1f", rating));
    }

    public void setBadge(int resId) {
        badgeView.setImageResource(resId);
    }

    public void setProgress(int progress) {
        progressBar.setProgress(progress);
        progressBar.setVisibility(progress > 0 ? VISIBLE : GONE);
    }
}

对应的布局文件 custom_card_overlay.xml

<merge xmlns:android="http://schemas.android.com/apk/res/android">
    <!-- 评分 -->
    <TextView
        android:id="@+id/rating"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="top|end"
        android:padding="8dp"
        android:textColor="#FFD700"
        android:textSize="16sp" />
    
    <!-- 角标 -->
    <ImageView
        android:id="@+id/badge"
        android:layout_width="32dp"
        android:layout_height="32dp"
        android:layout_gravity="top|start"
        android:padding="4dp" />
    
    <!-- 进度条 -->
    <ProgressBar
        android:id="@+id/progress"
        style="?android:attr/progressBarStyleHorizontal"
        android:layout_width="match_parent"
        android:layout_height="4dp"
        android:layout_gravity="bottom"
        android:progressTint="#FF4081" />
</merge>

小技巧:使用<merge>标签可以避免多余的布局嵌套。ImageCardView本身已经是FrameLayout,再套一层LinearLayout就浪费了。

8.3 焦点效果的精雕细琢

TV应用和手机应用最大的区别是什么?焦点。手机靠触摸,TV靠遥控器。焦点效果做不好,用户根本不知道当前选中的是哪个卡片。

我记得有一次做视频应用,测试人员反馈说「焦点框太小了,三米外根本看不清」。后来我把焦点放大比例从1.05调到了1.12,再加上阴影效果,问题就解决了。

来看一个完整的焦点动画实现:

public class FocusAnimator {
    private static final float SCALE_NORMAL = 1.0f;
    private static final float SCALE_FOCUSED = 1.12f;
    private static final int DURATION = 150;

    public static void animateFocus(View view, boolean hasFocus) {
        if (hasFocus) {
            // 放大 + 阴影
            view.animate()
                .scaleX(SCALE_FOCUSED)
                .scaleY(SCALE_FOCUSED)
                .setDuration(DURATION)
                .start();
            
            // 添加阴影
            ViewCompat.setElevation(view, 16f);
            
            // 边框高亮
            view.setBackgroundResource(R.drawable.focus_border);
        } else {
            // 恢复原状
            view.animate()
                .scaleX(SCALE_NORMAL)
                .scaleY(SCALE_NORMAL)
                .setDuration(DURATION)
                .start();
            
            ViewCompat.setElevation(view, 4f);
            view.setBackgroundResource(R.drawable.normal_border);
        }
    }
}

然后在Presenter中调用:

@Override
public void onBindViewHolder(ViewHolder viewHolder, Object item) {
    ImageCardView cardView = (ImageCardView) viewHolder.view;
    
    // 监听焦点变化
    cardView.setOnFocusChangeListener((v, hasFocus) -> {
        FocusAnimator.animateFocus(v, hasFocus);
    });
}

避坑指南:我曾经在焦点动画上踩过一个坑——动画执行时卡顿。原因是动画过程中触发了多次onBindViewHolder。解决方案是在动画开始时设置setHasTransientState(true),动画结束时恢复false,告诉RecyclerView不要重复绑定。

8.4 性能优化:卡片复用的正确姿势

TV应用通常要展示大量卡片,比如电影列表、电视剧集。如果每个卡片都重新创建,性能会非常差。Leanback的ObjectAdapter已经帮我们做了复用,但有些细节还是要注意。

我整理了几个关键点:

优化项 说明 优先级
图片加载 使用Glide或Coil,配合缩略图
布局扁平化 减少嵌套层级,控制在3层以内
动画复用 使用ViewPropertyAnimator,避免创建新对象
内存缓存 对图片和绘制结果做LRU缓存
预加载 提前加载下一屏的图片

来看一个优化后的图片加载示例:

public class ImageLoader {
    private static final int WIDTH = 320;
    private static final int HEIGHT = 180;

    public static void loadInto(Context context, String url, ImageView target) {
        Glide.with(context)
            .load(url)
            .override(WIDTH, HEIGHT)  // 限制尺寸
            .format(DecodeFormat.PREFER_RGB_565)  // 减少内存
            .skipMemoryCache(false)
            .diskCacheStrategy(DiskCacheStrategy.ALL)
            .into(target);
    }
}

个人经验:TV屏幕虽然大,但卡片本身不需要加载原图。320x180的缩略图在3米外看起来和原图没区别,但内存占用能减少80%。这个优化立竿见影。

8.5 知识体系总览

下面这张图总结了CardPresenter和ImageCardView定制的核心脉络:

CardPresenter 与 ImageCardView 定制体系 CardPresenter ① 视图创建 onCreateViewHolder 布局加载 / 初始化 ② 数据绑定 onBindViewHolder 文本 / 图片 / 状态 ③ 焦点动画 缩放 / 阴影 / 边框 onFocusChangeListener ④ 资源释放 onUnbindViewHolder 图片 / 动画 / 监听器 核心目标:流畅展示 + 清晰焦点 + 低内存

这张图把整个定制流程串起来了。从视图创建到数据绑定,再到焦点动画和资源释放,每一步都有讲究。你不需要一次性全部做完,但心里要有这个框架。

最后说一句:卡片定制没有标准答案。不同的应用场景、不同的设计风格,卡片的呈现方式都不一样。关键是理解原理,然后根据实际需求灵活调整。我在多个项目中反复迭代过卡片方案,每次都有新的收获。希望今天的分享能帮你少走一些弯路。


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