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定制的核心脉络:
这张图把整个定制流程串起来了。从视图创建到数据绑定,再到焦点动画和资源释放,每一步都有讲究。你不需要一次性全部做完,但心里要有这个框架。
最后说一句:卡片定制没有标准答案。不同的应用场景、不同的设计风格,卡片的呈现方式都不一样。关键是理解原理,然后根据实际需求灵活调整。我在多个项目中反复迭代过卡片方案,每次都有新的收获。希望今天的分享能帮你少走一些弯路。
公众号:蓝海资料掘金营,微信deep3321