笔者最近阅读《第一行代码》中,想要总结一下 ListView 这个重要控件的用法。
本文写得很浅,算是初学者笔记
文章目录
- 1. 准备工作
- 1.1. 布局
- 1.2. 数据类型
- 2. 编写 XxxAdapter
- 2.1. 为什么要 Adapter
- 2.2. 简洁编写
- 2.3. 预览界面
- 3. 提高运行效率(重点)
- 3.1. 加载布局
- 3.2. 获取实例
- 4. 点击事件
- 4.1. 子项的
- 4.2. 控件的
1. 准备工作
子项两两间:布局相似、内容不同,是我觉得 ListView、RecyclerView 这类控件最大的特点。
因此很容易想到,所有子项都共用一个布局、而每个子项又都有独自的一个数据来源,也就是某个数据类型的对象。
为此,编写一个布局和一种数据类型是准备工作。本文以编写形如 TodoList app 的界面为例,如图
1.1. 布局
布局起名为 todo_item.xml 即可,如下
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<Button
android:id="@+id/todo_item_button"
android:layout_width="0dp"
android:focusable="false"
android:layout_height="wrap_content"
android:layout_weight="1" />
<TextView
android:id="@+id/todo_item_text_view"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="30dp"
android:layout_weight="7"
android:textSize="25sp" />
</LinearLayout>
效果
1.2. 数据类型
暂时使用一个 String 类型来填充 TextView 的内容即可。数据类型 Todo.java 如下
public class Todo {
private String title;
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public Todo(String title) {
this.title = title;
}
}
到此为止准备工作就结束了。整个应用的最后,应达到这样的效果:
2. 编写 XxxAdapter
2.1. 为什么要 Adapter
初学时我很疑惑,为什么非要 Adapter(适配器)?
既然 TextView 等基础控件能 tv.setText(str);
,
ListView 不能 lv.setInfo(list); lv.setLayout(R.layout.todo_item);
吗?
其实想想看就知道不行,自定义数据类型 Todo 里存放的数据和子项布局 todo_item.xml 的具体控件的对应关系很复杂。假如我于数据类型中存放了许多条 String, 布局中又有许多条 TextView(比如说 QQ 界面的子项就有用户名、最近一条消息的内容,还有日期),你不详细指定编译器怎么知道谁对应谁呢。
而且 Android 内置、给常用适配器 ArrayAdapter 的布局很单调,譬如 android.R.layout.simple_list_1 里面就只有一个 TextView. 要解析你的自定义布局、指定它与数据详细的对应关系,需要自己编写适配器。
也就是说,因为:
- 数组无法直接放入
- 内置布局太单调
所以,自定义 Adapter 必须做到这两点:
- 能够接收数组的数据并传给控件
- 指定子项的布局为自定义布局
2.2. 简洁编写
那么开始编写吧。首先,自定义 Adapter 要继承 ArrayAdapter<>, 泛型要指定为自己的数据类型 Todo.
public class TodoAdapter extends ArrayAdapter<Todo> {
其次,重写构造方法,日后这里将传入以下三个参数:
private final int RESOURCE_ID;
/**
* 构造方法
*
* @param context 上下文环境
* @param resource 布局 id
* @param objects 数据 list
*/
public TodoAdapter(@NonNull Context context, int resource, @NonNull List<Todo> objects) {
super(context, resource, objects);
RESOURCE_ID = resource;
}
然后重写最重要的 getView() 方法。这个方法会在每个子项滚动到屏幕内的时候调用。
它重要就重要在它实现了上面强调的两个功能:传递数据、确定布局。构造方法中保存布局 id 也是为了这里。
/**
* 当滑动到子项时调用,功能:确定子项布局、把适配器接收的数据传给子项的控件
*
* @param position 当前子项的序号
* @param convertView 缓存已经加载过的子项布局
* @param parent 父布局
* @return 当前子项的布局
*/
@NonNull
@Override
public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent) {
// 为子项加载布局
View itemView = LayoutInflater.from(parent.getContext()).inflate(RESOURCE_ID, parent, false);
// 把适配器接收的数据传给子项的控件
Todo data = getItem(position);
TextView textView = itemView.findViewById(R.id.todo_item_text_view);
Button button = itemView.findViewById(R.id.todo_item_button);
textView.setText(data.getTitle());
return itemView;
}
}
首先用一条语句为子项加载布局,看起来有点难懂,不过不用深究,记住这是固定写法即可。
然后用 getItem() 方法获取了当前位置的数据:一个 Todo 类型的对象,里面的字符串就是想赋给 TextView 的字符串。
用 findViewById() 方法获取布局中的 TextView 控件,setText().
就这样,我们为子项加载了布局、又把数据传递给了子项,设置了子项的点击事件。除去注释,代码量其实算是蛮少了。
2.3. 预览界面
上文总是提到子项布局、子项数据,但现在还没见到子项呢,快在测试活动里把数据传给子项。
public class TestActivity extends AppCompatActivity {
ListView lv;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_list_view_test);
lv = findViewById(R.id.lvt_list_view);
TodoAdapter adapter = new TodoAdapter(this, R.layout.todo_item, getTodoList());
lv.setAdapter(adapter);
}
/**
* ListView 数据来源
*
* @return List<Todo> 类型的对象
*/
private List<Todo> getTodoList() {
List<Todo> todoList = new ArrayList<>();
for (int i = 0; i < 30; i++) {
Todo todo = new Todo("事件" + i);
todoList.add(todo);
}
return todoList;
}
}
重点关注这一句:
TodoAdapter adapter = new TodoAdapter(this, R.layout.todo_item, getTodoList());
可以看到,构造方法所需的上下文环境、布局 id、数据集合都传进来了。(正式项目中数据会从数据库等地取得,这里姑且先用这个函数生成一长串数据吧)
运行效果如图:
已经达到期望了。
3. 提高运行效率(重点)
3.1. 加载布局
上文为子项加载布局时,仅用一条语句,但如果每次滚动到子项都将布局重新加载一遍,那么 ListView 快速滚动的时候,这条语句将会成为性能的瓶颈。
不过我们注意到 getView() 方法中还有一个 convertView 参数,它用于将之前加载好的布局缓存,以便之后重用,这没有理由不用啊。
因此为子项加载布局部分的代码从这一句
// 为子项加载布局
View itemView = LayoutInflater.from(parent.getContext()).inflate(RESOURCE_ID, parent, false);
改为
View itemView;
// 为子项加载布局
if (convertView == null) {
itemView = LayoutInflater.from(parent.getContext()).inflate(RESOURCE_ID, parent, false);
} else {
itemView = convertView;
}
只要 convertView 不为空,就使用它而不是重新加载。
3.2. 获取实例
好,现在已经不会再重复加载布局了。但是仍然会每次滚动到子项时,都使用 findViewById() 方法获取一次控件的实例。能否优化呢?
尴尬的是,getView() 方法的参数不够了,没有 convertButton convertTextView 给我们用。不过没事,可以自己新建一个类,来存放控件的实例啊。这个类一般叫:ViewHolder.
在 ViewHolder 内新建控件作为成员变量,在构造方法里接收 View、获取控件实例、保存给成员变量的控件们
/**
* 对控件的实例进行缓存
*/
static class ViewHolder {
View itemView;
Button button;
TextView textView;
public ViewHolder(View itemView) {
this.itemView = itemView;
button = itemView.findViewById(R.id.todo_item_button);
textView = itemView.findViewById(R.id.todo_item_text_view);
}
}
因为此时新建 ViewHolder 对象能获取实例、耗费性能,故在且只在必要时(convertView 不为空时)新建 holder 即可。
如何在不必要时不新建呢?保存 holder 到 itemView 里面,就可以通过 convertView 取出 itemView 间接取出 holder 了——新建 holder 之后用 setTag() 方法存到 itemView 里,这样一来之后还能用 getTag() 取出来。
修改 getView() 如下:
public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent) {
View itemView;
ViewHolder holder;
if (convertView == null) {
itemView = LayoutInflater.from(parent.getContext()).inflate(RESOURCE_ID, parent, false);
holder = new ViewHolder(itemView);
itemView.setTag(holder);
} else {
itemView = convertView;
holder = (ViewHolder) itemView.getTag();
}
Todo data = getItem(position);
holder.textView.setText(data.getTitle());
return itemView;
}
对比 “2.2. 简洁编写” 部分的代码可以发现,设置数据和点击事件这一步,原本是获取实例、直接 set, 现在变成了先创建 ViewHolder 对象,再从中拿出 TextView 实例来 set 了。
运行一下试试,界面完全没有改变。
就这样,利用 convertView 和 ViewHolder 的思路,我们成功提升了 ListView 的性能。
4. 点击事件
4.1. 子项的
给子项整体设置点击事件很容易,在测试活动中对 ListView 对象本身一如其它控件一样用 setOnClickListener(). 不过因为是点击子项,这里需要用 setOnItemClickListener().
lv.setOnItemClickListener(new AdapterView.OnItemClickListener() {
@Override
public void onItemClick(AdapterView<?> parent, View listView, int position, long id) {
Toast.makeText(TestActivity.this, "You clicked item" + position, Toast.LENGTH_SHORT).show();
}
});
}
效果如图(子项会有一个点击渐变效果)
4.2. 控件的
虽然网上有的帖子说不能给 ListView 的子项单独设置点击事件,但经过尝试是可以的,方法跟上文给 TextView setText() 如出一辙,在 TodoAdapter 里相同的位置设置即可。
@NonNull
@Override
public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent) {
View itemView;
ViewHolder holder;
if (convertView == null) {
itemView = LayoutInflater.from(parent.getContext()).inflate(RESOURCE_ID, parent, false);
holder = new ViewHolder(itemView);
itemView.setTag(holder);
} else {
itemView = convertView;
holder = (ViewHolder) itemView.getTag();
}
Todo data = getItem(position);
holder.textView.setText(data.getTitle());
holder.button.setOnClickListener(button -> ((Button) button).setText("√"));
// holder.itemView.setOnClickListener(view -> Toast.makeText(view.getContext(), "You clicked item" + position, Toast.LENGTH_SHORT).show());
return itemView;
}
}
这里设置了按钮点击后显示“√”
需要注意的有两点:
- 若设置 Button 的点击事件,必须把 Button 的 android:focusable 属性设置为 auto 或 false, 否则子项的点击事件会失效。
- 若用上面代码注释的那一句添加点击事件,会使得 setOnItemClickListener() 的事件失效,而这里的 setOnClickListener() 有效。
感觉,虽然书上说 ListView 在点击事件的设计上不如 RecyclerView, 但这一处也跟 RecyclerView 挺像的。只要采用 ViewHolder, 单独设计点击事件也不是很麻烦。(本来想写 ListView 和 RecyclerView 的对比文的qwq 时间原因先梳理 ListView 用法)