笔者最近阅读《第一行代码》中,想要总结一下 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 的界面为例,如图

Android 设置listView控件隐藏 android中listview_控件

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>

效果

Android 设置listView控件隐藏 android中listview_android studio_02

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;
    }
}

到此为止准备工作就结束了。整个应用的最后,应达到这样的效果:

Android 设置listView控件隐藏 android中listview_android studio_03

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、数据集合都传进来了。(正式项目中数据会从数据库等地取得,这里姑且先用这个函数生成一长串数据吧)

运行效果如图:

Android 设置listView控件隐藏 android中listview_点击事件_04

已经达到期望了。

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 了。

运行一下试试,界面完全没有改变。

Android 设置listView控件隐藏 android中listview_点击事件_05

就这样,利用 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();
            }
        });
    }

效果如图(子项会有一个点击渐变效果)

Android 设置listView控件隐藏 android中listview_android studio_06

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;
    }
}

这里设置了按钮点击后显示“√”

Android 设置listView控件隐藏 android中listview_控件_07

需要注意的有两点:

  • 若设置 Button 的点击事件,必须把 Button 的 android:focusable 属性设置为 auto 或 false, 否则子项的点击事件会失效。
  • 若用上面代码注释的那一句添加点击事件,会使得 setOnItemClickListener() 的事件失效,而这里的 setOnClickListener() 有效。

感觉,虽然书上说 ListView 在点击事件的设计上不如 RecyclerView, 但这一处也跟 RecyclerView 挺像的。只要采用 ViewHolder, 单独设计点击事件也不是很麻烦。(本来想写 ListView 和 RecyclerView 的对比文的qwq 时间原因先梳理 ListView 用法)