一种无需为ListView设置ViewHolder的写法

【译】原文: https://www.bignerdranch.com/blog/customizing-android-listview-rows-subclassing/

每个Android开发者都会碰到自定义ListView中item布局,并填充数据的情况,而你首先想到的应该就是ViewHolder模式吧。但是ViewHolder模式使用起来太过于死板,实际上我们可以做得更好。在本文中,我们会探索另外一种可供选择的方式:使用RelativeLayout的子类来封装完成定制化的工作。

目标

为了说明目的,我们首先创建了一个典型自定义的ListView,它的每一行包括一个ImageView和两个排列在它旁边的TextView,每个item的父布局为RelativeLayout,如下图所示。你可以在Github上查看它的代码。

Custom ListView

当为ListView中的item创建自定义view的时候,我们有下面的需求:

  • 使用自定义布局管理子控件的排列方式
  • 当滚动时,重复利用已创建的view
  • 高效识别子视图,并为其填充数据

ViewHolder模式所带来的问题

我们在网上看到的一种实现都大同小异,这就是ViewHolder模式。简而言之,它需要下面的步骤:

  • 为ListView中item创建一个布局
  • 创建ViewHolder类,存储item中子控件
  • 对于ListView中的每个item,创建一个ViewHolder的实例,并通过findViewById()与ViewHolder中成员绑定
  • 使用setTag(),将item与ViewHolder绑定
  • 对重复使用的item重用ViewHolder对象

因为下面的原因,我并不喜欢这个模式:

  • 它把太多的职责放在了Adapter的getView()方法中
  • ViewHolder类过于公式化
  • 每个视图需要根据tag强转为合适的ViewHolder类型
  • ViewHolder类需要知道list中item内部细节,违反了封装的特点

因此,我决定提供一种可供选择的方式——子类化,而不是一直抱怨。

使用子类实现自定义

我会使用RelativeLayout的子类命名为ItemView作为我们是定义布局的根视图,而不是创建一个一般的视图——ViewHolder类与Adapter类的实现,它们了解太多内部实现的细节。Item是呈现数据的模型对象,它里面有三个属性:图片的url,标题以及描述。

1
2
3
4
5
6
7
public class Item {
private String mImageUrl;
private String mTitle;
private String mDescription;

// constructor, getters and setters elided
}

ItemView有责任将Item中数据展示在屏幕上。作为ItemView的使用者(在Adapter的子类中),我想我的职责越简单越好,我真的只需要做好下面两件事:

  • 为ListView的每一个item创建或者重用ItemView
  • 将当前行的数据Item与ItemView关联起来

我们可以在ItemAdapter类中查看相关的API:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class ItemAdapter extends ArrayAdapter<Item> {

public ItemAdapter(Context c, List<Item> items) {
super(c, 0, items);
}

@Override
public View getView(int position, View convertView, ViewGroup parent){
ItemView itemView = (ItemView)convertView;
if (null == itemView)
itemView = ItemView.inflate(parent);
itemView.setItem(getItem(position));
return itemView;
}
}

这里有两行代码很有意思,首先,如果我们没有可重用的view,那么就调用ItemView.inflate(ViewGroup)这个静态方法获取ItemView的实例。其次,我们使用setItem(Item)方法为当前ItemView提供要展示的数据。所有这些细节都封装在ItemVIew内部。

ItemView通过成员变量存储对子视图的引用,把自己作为自己的ViewHolder。

1
2
3
4
5
6
7
public class ItemView extends RelativeLayout {
private TextView mTitleTextView;
private TextView mDescriptionTextView;
private ImageView mImageView;

...
}

inflate(ViewGroup)这个静态方法使创建并正确配置ItemView变得非常简单,同时使用XML文件完成自定义布局的配置。

1
2
3
4
5
public static ItemView inflate(ViewGroup parent) {
ItemView itemView = (ItemView)LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_view, parent, false);
return itemView;
}

它使用参数中的parent来获取context,然后将布局文件映射为视图并返回。布局文件如下所示:

1
2
3
4
5
<com.bignerdranch.android.listitemviewdemo.ItemView
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="5dp" />

该布局文件只包含一个ItemView,在ItemView中声明了边距。要特别注意的是,它并没有子节点,那么我们需要的ImageView以及两个TexeView在哪呢?实际上,在ItemView的构造方法中,我们完成了对子视图的创建。

1
2
3
4
5
public ItemView(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
LayoutInflater.from(context).inflate(R.layout.item_view_children, this, true);
setupChildren();
}

该构造方法最终会在inflate包含ItemView视图的时候调用,它首先会调用父类的构造方法,然后infalte ItemView中的子视图。子视图的布局文件如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<?xml version="1.0" encoding="utf-8"?>
<merge
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content" >
<ImageView
android:id="@+id/item_imageView"
android:background="@android:color/darker_gray"
android:layout_width="60dp"
android:layout_height="60dp"
android:layout_margin="5dp"
android:contentDescription="@string/item_imageView_contentDescription"
/>
<TextView
android:id="@+id/item_titleTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_toRightOf="@id/item_imageView"
android:text="title text"
/>
<TextView
android:id="@+id/item_descriptionTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_toRightOf="@id/item_imageView"
android:layout_below="@id/item_titleTextView"
android:layout_marginTop="5dp"
android:text="description text"
/>
</merge>

根节点为merge标签,它表明在inflation的过程中,merge标签下的所有子控件都会被添加到parent参数中,并传递到构造方法中的inflate(...)方法。之后我们调用setupChildren()方法,通过findViewById(int)完成查找控件的工作,这样就可以把控件与成员变量关联起来了。

1
2
3
4
5
private void setupChildren() {
mTitleTextView = (TextView) findViewById(R.id.item_titleTextView);
mDescriptionTextView = (TextView) findViewById(R.id.item_descriptionTextView);
mImageView = (ImageView) findViewById(R.id.item_imageView);
}

到此,ItemView就已经实现了传统的VIewHolder的功能,能通过自己的成员变量将子视图的引用缓存起来。

为了让大家信服,我同时提供了setItem(Item)方法让调用者把Item与每个子视图绑定起来。

1
2
3
4
5
public void setItem(Item item) {
mTitleTextView.setText(item.getTitle());
mDescriptionTextView.setText(item.getDescription());
// TODO: set up image URL
}

这就是我们实现了一种新的模式。尽管我们需要创建多创建一个布局文件,并且为了inflation多实现了一个方法,但是优点是显而易见的:

  • Adapter类的实现被大大的简化了
  • 能够很容易的通过xml文件或者代码创建ItemView
  • 任何扩展都能够在ItemView以及布局文件内部来完成
  • 不再需要创建多余的ViewHolder类

Subclassing works

下一次想到ViewHolder模式的时候,尝试用一下本文提到的新的模式。该模式良好的封装很容易让你充分自定义ItemView而不再需要考虑那些令人烦扰的细节。在视图重用的过程中,我敢说它跟ViewHolder模式一样好。