前言
ListView 在之前是 Android 中最常用的列表组件之一,理解它的工作原理对掌握列表数据的展示非常重要。虽然现代 Android 开发中,更推荐使用 RecyclerView
列表,但 ListView 中的适配器和视图复用的概念仍需掌握。因为手机屏幕空间有限,为了能显示更多的内容,就要借助 ListView 这样的滚动列表来实现。
ListView 允许用户通过手指上下滑动的方式将屏幕外的数据滚动到屏幕内,同时屏幕上原有的数据会滚动出屏幕。其实你最了解这个过程,你每天翻的聊天记录就用到了它。
简单用法
前置工作: 创建一个名为 ListViewTest
的 Empty Views Activity 项目,其他采用默认值即可。
创建好后,往布局中添加一个 ListView 控件,修改 activity_main.xml 布局文件,代码如下所示:
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="match_parent"
android:orientation="vertical">
<ListView
android:id="@+id/listView"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>
我们给控件指定了id,并且将其宽度和高度都设为了 match_parent
,这样控件会占满屏幕。
预览效果图:
接下来,我们在 MainActivity 中给 ListView 控件提供数据,让它展示我们提供的数据。
kotlin
class MainActivity : AppCompatActivity() {
private lateinit var viewBinding: ActivityMainBinding
// 提供的数据集合
private val data = listOf(
"Apple", "Banana", "Orange", "Watermelon",
"Pear", "Grape", "Pineapple", "Strawberry", "Cherry", "Mango",
"Apple", "Banana", "Orange", "Watermelon", "Pear", "Grape",
"Pineapple", "Strawberry", "Cherry", "Mango"
)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 使用 ViewBinding 完成视图绑定
viewBinding = ActivityMainBinding.inflate(layoutInflater)
setContentView(viewBinding.root)
// 构建适配器
val adapter = ArrayAdapter<String>(this, android.R.layout.simple_list_item_1, data)
viewBinding.listView.adapter = adapter // 使用适配器
}
}
我们使用集合来装载数据,其中保存了很多水果的名称,不过集合中的数据是无法直接传递给 ListView 控件的,我们还需要借助适配器来完成。
适配器有很多实现类,这里我们使用 ArrayAdapter,因为它可以通过泛型来指定要适配的数据类型,每个列表项对应的数据类型,然后在其构造函数中将要适配的数据传入即可。ArrayAdapter 的构造函数中依次传递的分别是:Activity的实例(this
)、ListView 子项布局的id、数据源(List集合)。其中 android.R.layout.simple_list_item_1
是 Android 内置的布局文件,其中就只有一个文本控件,用于最简单地显示一段文字。
这样适配器就构建好了。最后,还需要将适配器对象通过 ListView.setAdapter()
方法传递到 ListView 控件中,这样它们之间才能建立联系。
运行效果:
我们可以通过手指滑动来滚动列表以查看屏幕外的数据:
定制ListView的界面
仅仅只能显示文字的 ListView 还是太单调了,我们来定制 ListView,让它能同时展示图片和文字。
前置工作: 准备好一组图片资源,对应着上述列表中的每一种水果。
图片资源下载地址:传送门
接着定义一个 Fruit 实体类,作为 ListView 适配器的适配类型,代码如下:
kotlin
class Fruit(val name: String, val imageId: Int)
其中 name
字段代表水果名称,iamgeId
字段代表水果对应的图片资源id。
然后我们还要自定义列表项的布局,新建 fruit_item.xml
布局文件,代码:
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="60dp"
android:orientation="horizontal">
<ImageView
android:id="@+id/fruitImage"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_gravity="center_vertical"
android:layout_marginStart="10dp" />
<TextView
android:id="@+id/fruitName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_marginStart="10dp" />
</LinearLayout>
列表项的布局是左侧一个 ImageView 控件用于展示水果图片,右侧一个 TextView 用于展示水果名称。
因为适配类型是自定义的 Fruit
实体类,所以接下来需要自定义适配器,这个适配器继承自 ArrayAdapter
,泛型指定为 Fruit。新建 FruitAdapter 适配器类:
kotlin
class FruitAdapter(context: Context, resourceId: Int, data: List<Fruit>) :
ArrayAdapter<Fruit>(context, resourceId, data) {
@SuppressLint("ViewHolder")
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
// ViewBinding 视图绑定
val viewBinding = FruitItemBinding.inflate(LayoutInflater.from(context), parent, false)
// 获取当前列表项的 Fruit 实例
val fruit = getItem(position)
// 数据不为空,就填充到列表项中
if (fruit != null) {
// 通过视图绑定对象直接访问控件实例
viewBinding.fruitImage.setImageResource(fruit.imageId)
viewBinding.fruitName.text = fruit.name
}
return viewBinding.root
}
}
我们通过主构造函数将 Activity 的实例、自定义的列表项布局id、以及数据源都传递了进来,并且重写了 getView()
方法,这个方法会在子项滚动到屏幕内时被调用,用于获取子项的视图。
为什么我们在
getView
方法中是通过 ViewBinding 直接加载布局的,也需要在构造函数中传入列表项布局id?这是因为
ArrayAdapter
的父类构造函数需要传入一个布局资源id,并且这样做还可以让我们一眼就能看出这个适配器对应的列表项布局。
在 getView
方法上添加 @SuppressLint("ViewHolder")
注解,是因为当前还没有使用 ViewHolder 模式进行优化会有警告,先使用注解消除警告,不管它。
在 getView
方法中,我们首先使用了 FruitItemBinding.inflate
方法来动态加载布局并获取其绑定对象。方法接收三个参数:
-
第一个参数是 LayoutInflater 对象,我们通过
LayoutInflater.from
方法构建了 LayoutInflater 对象并传入; -
第二个参数是父布局,我们传入通过
getView
方法传递来的parent
参数; -
第三个参数(
attachToRoot
)表示是否将当前 View 视图附加到父布局中,我们填入false,因为适配器机制要求getView()
方法返回一个尚未附加到最终父容器(即ListView
)的独立视图,之后ListView
会负责在合适的时机将这个返回的视图添加到其自身。
然后视图绑定对象会持有对布局中 fruitImage
和 fruitName
控件的引用,我们可以直接访问这些控件,并且将数据填入了控件中,最后将布局返回,这样自定义适配器就完成了。
最后在 MainActivity 中,使用我们自定义的适配器,并且初始化数据源:
kotlin
class MainActivity : AppCompatActivity() {
private lateinit var viewBinding: ActivityMainBinding
// 提供的数据集合
private val fruitList = ArrayList<Fruit>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 使用 ViewBinding 完成视图绑定
viewBinding = ActivityMainBinding.inflate(layoutInflater)
setContentView(viewBinding.root)
initFruits() // 初始化水果数据
// 构建适配器,使用自定义列表项布局id
val adapter = FruitAdapter(this, R.layout.fruit_item, fruitList)
viewBinding.listView.adapter = adapter // 使用适配器
}
private fun initFruits() {
repeat(2) {
fruitList.apply {
add(Fruit("Apple", R.drawable.apple_pic))
add(Fruit("Banana", R.drawable.banana_pic))
add(Fruit("Orange", R.drawable.orange_pic))
add(Fruit("Watermelon", R.drawable.watermelon_pic))
add(Fruit("Pear", R.drawable.pear_pic))
add(Fruit("Grape", R.drawable.grape_pic))
add(Fruit("Pineapple", R.drawable.pineapple_pic))
add(Fruit("Strawberry", R.drawable.strawberry_pic))
add(Fruit("Cherry", R.drawable.cherry_pic))
add(Fruit("Mango", R.drawable.mango_pic))
}
}
}
}
可以看到我们添加了 initFruits()
方法来初始化所有的水果数据,并且我们通过 repeat
函数将水果数据添加了两遍。repeat
函数是 Kotlin 中一个非常常用的标准函数,它允许你传入一个整型n,然后会把Lambda表达式中的代码重复执行n遍。
接着在 onCreate()
方法中创建了 FruitAdapter 适配器对象,并将它作为 ListView 的适配器,这样定制就完成了。
运行效果:
只要你修改子项的布局,就可以定制出更复杂的界面。
提升 ListView 的运行效率
现在 ListView 的运行效率还不算高,因为适配器中的 getView
方法,每次都会通过 FruitItemBinding.inflate
重新加载布局。导致 ListView 快速滚动时,性能较低。
但我们可以借助 getView
方法的 convertView
参数进行优化,这个参数会缓存之前加载好的视图(滑出屏幕外的列表项视图),当存在可复用的缓存视图时,我们便可以复用这个旧视图,否则才创建新的视图。
为什么要复用视图?
因为频繁加载布局会导致滚动卡顿,并且创建大量视图对象会占用大量内存,增加垃圾回收的压力。
我们利用 convertView
参数来优化,优化后的代码:
kotlin
class FruitAdapter(context: Context, resourceId: Int, data: List<Fruit>) :
ArrayAdapter<Fruit>(context, resourceId, data) {
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val currentView: View
val viewBinding: FruitItemBinding // 局部绑定变量
if (convertView == null){// 没有可复用的视图
// 加载新的布局,并获取绑定对象
viewBinding = FruitItemBinding.inflate(LayoutInflater.from(context), parent, false)
// 获取根视图
currentView = viewBinding.root
}else{
// 直接将 convertView 作为根视图
currentView = convertView
// 并且为这个已存在的视图创建绑定对象
viewBinding = FruitItemBinding.bind(currentView)
}
// 获取当前列表项的数据
val fruit = getItem(position)
// 数据不为空,就填充到列表项中
// 使用绑定对象来更新视图内容
if (fruit != null) {
viewBinding.fruitImage.setImageResource(fruit.imageId)
viewBinding.fruitName.text = fruit.name
}
return currentView
}
}
可以看到我们在 getView()
方法中,当有可用的旧视图时,我们直接对 convertView
进行复用,并通过FruitItemBinding.bind(currentView)
为这个复用的视图创建一个绑定对象。这样不需要每次都创建新视图对象,提高了运行效率。
不过,我们还可以继续优化。因为每次复用视图时,都会创建新的视图绑定对象,也是耗时的操作。频繁创建绑定对象时,也会导致滚动卡顿、不流畅。我们可以借助 ViewHolder 模式来对性能进行优化:
kotlin
class FruitAdapter(context: Context, resourceId: Int, data: List<Fruit>) :
ArrayAdapter<Fruit>(context, resourceId, data) {
// 定义一个 ViewHolder 类来持有视图绑定对象
private inner class ViewHolder(val binding: FruitItemBinding)
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val currentView: View
val holder: ViewHolder
if (convertView == null) {
// 如果 convertView 为空,说明没有可复用的视图,需要创建一个新的
val binding = FruitItemBinding.inflate(LayoutInflater.from(context), parent, false)
// 获取根视图
currentView = binding.root
// 创建 ViewHolder 实例,并将绑定对象存入
holder = ViewHolder(binding)
// 将 ViewHolder 对象存储在视图的 tag 中,以便后续复用
currentView.tag = holder
} else {
// 如果 convertView 不为空,说明有可复用的视图
currentView = convertView
// 从 tag 中取出之前存储的 ViewHolder 对象
holder = currentView.tag as ViewHolder
}
// 获取当前列表项的 Fruit 实例
val fruit = getItem(position)
// 数据不为空,就使用 ViewHolder 中的绑定对象来填充到列表项中
if (fruit != null) {
holder.binding.fruitImage.setImageResource(fruit.imageId)
holder.binding.fruitName.text = fruit.name
}
return currentView // 返回经过数据填充的视图
}
}
我们新增了一个内部类 ViewHolder
,用于缓存视图绑定对象。当 convertView
为 null
时,我们加载布局,创建 FruitItemBinding
视图绑定实例,然后创建一个 ViewHolder
对象,并将FruitItemBinding
视图绑定实例存放在 ViewHolder
里,最后调用 View.setTag()
方法,将 ViewHolder
对象存储在 View
的 tag
中,以便后续进行复用。当 convertView
不为 null
时,则调用 View. getTag()
方法,把之前存储的 ViewHolder
重新取出。
这样,每个列表项视图所对应的 FruitItemBinding
视图绑定对象以及 ViewHolder
就被缓存进了 View
的 tag
中了,这样当视图进行复用时,没有必要每次都创建新的视图绑定对象从而获取控件实例了,可以直接获取这个绑定对象,现在 ListView 的运行效率已经大大提高了。
ListView的点击事件
最后,ListView 可滚动只是"可远观而不可亵玩焉",我们还需要它的子项可响应用户的点击事件。
我们来到 MainActivity 的 onCreate()
方法中,添加列表项的点击监听器:
kotlin
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 使用 ViewBinding 完成视图绑定
viewBinding = ActivityMainBinding.inflate(layoutInflater)
setContentView(viewBinding.root)
initFruits() // 初始化水果数据
// 构建适配器
val adapter = FruitAdapter(this, R.layout.fruit_item, fruitList)
viewBinding.listView.adapter = adapter // 使用适配器
viewBinding.listView.setOnItemClickListener { parent, view, position, id ->
// 当前列表项对应的数据
val fruit = fruitList[position]
Toast.makeText(this, fruit.name, Toast.LENGTH_SHORT).show()
}
}
可以看到,我们使用 setOnItemClickListener()
方法来为列表项注册了点击监听器,这样当用户点击了 ListView 中的任何一个子项时,就会执行 Lambda 表达式中的代码。
重新运行程序,点击任意一个水果(如 Banana),运行效果:
有时,Lambda 表达式并不会自动列出其参数列表。这时,需要我们自己去看。
点进去 setOnItemClickListener()
方法的源码:
java
public void setOnItemClickListener(@Nullable OnItemClickListener listener) {
mOnItemClickListener = listener;
}
再进入 OnItemClickListener
,很明显它就是一个 Java 单抽象方法接口,它的定义:
java
public interface OnItemClickListener {
/**
* Callback method to be invoked when an item in this AdapterView has
* been clicked.
* <p>
* Implementers can call getItemAtPosition(position) if they need
* to access the data associated with the selected item.
*
* @param parent The AdapterView where the click happened.
* @param view The view within the AdapterView that was clicked (this
* will be a view provided by the adapter)
* @param position The position of the view in the adapter.
* @param id The row id of the item that was clicked.
*/
void onItemClick(AdapterView<?> parent, View view, int position, long id);
}
可以看到 Lambda 表达式接收 4 个参数,其所代表的意思可以通过注释得知。
另外你会发现,在之前的代码中,我们仅仅使用到了 position
这一个参数,别的参数是有警告的(Parameter 'Xxx' is never used, could be renamed to _ )。因为 Kotlin 允许我们将没有用到的参数使用下划线 _
来替代,避免错误使用到无需用到的参数。
所以之前的代码可以改为:
kotlin
viewBinding.listView.setOnItemClickListener { _, _, position, _ ->
// 当前列表项对应的数据
val fruit = fruitList[position]
Toast.makeText(this, fruit.name, Toast.LENGTH_SHORT).show()
}