Android入门到实战(九):实现书架页——RecyclerView + GridLayoutManager + 本地数据库
一. 引言
在前几章中,我们实现了发现页,能够从网络获取推荐内容并展示给用户。而书架页则有着不同的特点:它主要展示用户已经保存或观看过的剧集数据,数据来源是本地数据库,而不是网络请求。
本章的目标是带大家从零实现书架页的核心功能,包括:
- 使用 RecyclerView + GridLayoutManager 展示剧集网格
- 通过 Adapter + ViewHolder 绑定数据
- 利用 ViewModel + Room 管理本地数据
- 实现空状态提示,当书架为空时给出友好提示
通过这篇文章,你将能够掌握如何结合 RecyclerView 与本地数据库 来构建一个实用的书架页,为用户提供直观、流畅的内容展示体验。
二. 页面布局设计(XML 部分)
书架页的整体布局使用 ConstraintLayout,包括三个核心部分:渐变背景、顶部导航栏(Toolbar)、以及主要的内容区(RecyclerView)。此外,我们还为书架为空的情况准备了一个 TextView 来提示用户“暂无内容”。
2.1 整体结构
<androidx.constraintlayout.widget.ConstraintLayoutxmlns:android="http://schemas.android.com/apk/res/android"xmlns:app="http://schemas.android.com/apk/res-auto"android:layout_width="match_parent"android:layout_height="match_parent"><!-- 渐变背景 --><Viewandroid:id="@+id/header_bg"android:layout_width="match_parent"android:layout_height="match_parent"android:background="@drawable/library_gradient_bg" /><!-- 顶部导航栏 --><com.google.android.material.appbar.MaterialToolbarandroid:id="@+id/toolbar"android:layout_width="0dp"android:layout_height="?attr/actionBarSize"android:background="@android:color/transparent"android:titleTextColor="@android:color/black"app:layout_constraintTop_toTopOf="parent"app:layout_constraintStart_toStartOf="parent"app:layout_constraintEnd_toEndOf="parent"/><!-- 书架内容列表 --><androidx.recyclerview.widget.RecyclerViewandroid:id="@+id/recyclerView"android:layout_width="match_parent"android:layout_height="0dp"android:paddingStart="24dp"android:paddingEnd="24dp"android:clipToPadding="false"android:clipChildren="false"app:layout_constraintTop_toBottomOf="@id/toolbar"app:layout_constraintBottom_toBottomOf="parent" /><!-- 空状态提示 --><TextViewandroid:id="@+id/tip_view"android:layout_width="wrap_content"android:layout_height="wrap_content"android:text="暂无内容"android:textSize="16sp"android:textColor="@android:color/darker_gray"app:layout_constraintTop_toTopOf="parent"app:layout_constraintBottom_toBottomOf="parent"app:layout_constraintStart_toStartOf="parent"app:layout_constraintEnd_toEndOf="parent" /></androidx.constraintlayout.widget.ConstraintLayout>
这段布局代码中,我们通过 ConstraintLayout 让 RecyclerView 占据除了 Toolbar 外的整个内容区,同时在书架为空时显示 tip_view。
2.2 RecyclerView 配置
在 Fragment 或 Activity 中,我们需要给 RecyclerView 设置 GridLayoutManager,让书架呈现两列网格的效果:
val recyclerView = binding.recyclerView
recyclerView.layoutManager = GridLayoutManager(context, 2)
recyclerView.adapter = libraryAdapter// 设置网格间距
val spacing = resources.getDimensionPixelSize(R.dimen.grid_spacing)
recyclerView.addItemDecoration(GridSpacingItemDecoration(2, spacing))
这样,每个 item 都会根据列数均匀分配左右间距,视觉效果更整齐。
2.3 Item 布局设计
书架每个 item 主要包括两部分:
- 上半部分:封面图 + 标题覆盖
- 下半部分:集数 + 更多按钮
<FrameLayout ...><LinearLayoutandroid:orientation="vertical"android:layout_width="match_parent"android:layout_height="wrap_content"><!-- 封面图 + 标题 --><androidx.constraintlayout.widget.ConstraintLayoutandroid:layout_width="match_parent"android:layout_height="wrap_content"><androidx.cardview.widget.CardViewandroid:layout_width="0dp"android:layout_height="0dp"app:layout_constraintStart_toStartOf="parent"app:layout_constraintEnd_toEndOf="parent"app:layout_constraintTop_toTopOf="parent"app:layout_constraintDimensionRatio="210:297"app:cardCornerRadius="4dp"app:cardElevation="0dp"><ImageViewandroid:id="@+id/coverImageView"android:layout_width="match_parent"android:layout_height="match_parent"android:scaleType="centerCrop"/></androidx.cardview.widget.CardView><TextViewandroid:id="@+id/titleTextView"android:layout_width="wrap_content"android:layout_height="wrap_content"android:padding="6dp"android:background="#66000000"android:text="剧名"android:textColor="@android:color/white"android:textSize="16sp"android:textStyle="bold"app:layout_constraintTop_toTopOf="@id/coverCard"app:layout_constraintBottom_toBottomOf="@id/coverCard"app:layout_constraintStart_toStartOf="@id/coverCard"app:layout_constraintEnd_toEndOf="@id/coverCard"/></androidx.constraintlayout.widget.ConstraintLayout><!-- 底部:集数 + 更多按钮 --><LinearLayoutandroid:orientation="horizontal"android:layout_width="match_parent"android:layout_height="50dp"android:gravity="center_vertical"android:paddingHorizontal="8dp"><TextViewandroid:id="@+id/episodeCountTextView"android:layout_width="wrap_content"android:layout_height="wrap_content"android:text="10集"android:textColor="@android:color/darker_gray"android:textSize="14sp"/><Viewandroid:layout_width="0dp"android:layout_height="0dp"android:layout_weight="1"/><ImageViewandroid:id="@+id/moreButton"android:layout_width="24dp"android:layout_height="24dp"android:src="@drawable/ic_more_hori" /></LinearLayout></LinearLayout>
</FrameLayout>
这个布局保证了每个剧集封面整齐显示,并在封面上叠加标题,同时下方展示集数信息和操作按钮,便于后续扩展操作(比如跳转详情、管理书架等)。
三. Adapter 与 ViewHolder 实现
在书架页中,RecyclerView 的展示效果依赖于 Adapter 和 ViewHolder。Adapter 负责把数据绑定到对应的 item 上,而 ViewHolder 则封装 item 的各个视图控件,提高 RecyclerView 的性能。
3.1 LibraryAdapter
LibraryAdapter 是书架页的核心 Adapter 类,它接收剧集列表,并通过回调处理 item 点击事件:
class LibraryAdapter(private val context: Context,private val onItemClick: (DramaWithEpisodeCount) -> Unit
) : RecyclerView.Adapter<LibraryAdapter.LibraryViewHolder>() {private var items: List<DramaWithEpisodeCount> = emptyList()private val fileHelper = FileHelper()/** 更新剧列表 */fun updateItems(newItems: List<DramaWithEpisodeCount>) {items = newItemsnotifyDataSetChanged() // 通知 RecyclerView 刷新}override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LibraryViewHolder {val view = View.inflate(parent.context, R.layout.library_item_view, null)return LibraryViewHolder(view)}override fun onBindViewHolder(holder: LibraryViewHolder, position: Int) {val item = items[position]holder.bind(item)// 设置点击事件holder.itemView.setOnClickListener {onItemClick(item)}// 加载封面图片val cover = fileHelper.getDramaCoverImagePath(context, item.drama)Glide.with(context).load(cover).into(holder.coverImageView)}override fun getItemCount(): Int = items.size
}
重点说明:
- updateItems:当书架数据更新时调用,用来刷新 RecyclerView。
- onItemClick:通过 Lambda 回调处理点击事件,可以用来跳转到详情页。
- Glide:加载本地封面图,保证图片显示流畅且节省内存。
3.2 LibraryViewHolder
LibraryViewHolder 封装了 item 中的视图控件,并提供 bind 方法将数据渲染到界面上:
class LibraryViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {val coverImageView: ImageView = itemView.findViewById(R.id.coverImageView)val titleTextView: TextView = itemView.findViewById(R.id.titleTextView)val episodeTextView: TextView = itemView.findViewById(R.id.episodeCountTextView)val moreTextView: ImageView = itemView.findViewById(R.id.moreButton)fun bind(item: DramaWithEpisodeCount) {titleTextView.text = item.drama.titleepisodeTextView.text = "${item.episodeCount}集"}
}
说明:
- coverImageView 和 titleTextView:显示剧集封面和标题。
- episodeTextView:显示剧集集数。
- moreTextView:可以用于扩展操作(如管理书架、收藏等)。
- bind 方法:将 DramaWithEpisodeCount 数据绑定到 UI 元素上。
3.3 点击与扩展
通过 Adapter 提供的点击回调,我们可以轻松实现:
- 点击封面或标题 → 跳转到剧集详情页
- 点击更多按钮 → 弹出操作菜单(如删除、移动等)
这种结构让书架页的逻辑清晰且可扩展,后续功能可以在 onItemClick 或 moreButton 中增加,而不影响核心显示逻辑。
四. 网格间距控制(ItemDecoration)
在书架页中,每个剧集 item 都在 RecyclerView 的网格中排列。如果不加任何间距,item 之间会紧贴在一起,看起来比较拥挤。为了解决这个问题,我们使用了自定义的 ItemDecoration 来统一管理每个 item 的左右间距。
4.1 GridSpacingItemDecoration 实现
class GridSpacingItemDecoration(private val spanCount: Int,private val spacing: Int
) : RecyclerView.ItemDecoration() {override fun getItemOffsets(outRect: android.graphics.Rect,view: android.view.View,parent: RecyclerView,state: RecyclerView.State) {val position = parent.getChildAdapterPosition(view) // item 索引val column = position % spanCount // item 所在列// 左右间距均分outRect.left = spacing * column / spanCountoutRect.right = spacing - (column + 1) * spacing / spanCount}
}
说明:
- spanCount:网格列数(例如两列)。
- spacing:item 之间的总间距(单位:像素)。
- 通过计算列的位置,将左右间距均匀分配,使网格左右边缘对齐,并且中间间距一致。
4.2 RecyclerView 中使用
在初始化 RecyclerView 时,添加 ItemDecoration:
val spacing = resources.getDimensionPixelSize(R.dimen.grid_spacing)
recyclerView.addItemDecoration(GridSpacingItemDecoration(2, spacing))
这样,每个剧集 item 的左右间距会自动计算并应用到视图中,整体布局更加美观。
五. 数据层设计:ViewModel + Room
书架页的数据来源不同于发现页,它直接从 本地数据库(Room) 中读取,而不是调用网络接口。为了实现数据与 UI 的解耦,我们使用 ViewModel 配合 StateFlow 来管理剧集数据。
5.1 数据模型 Drama
数据库表 drama 映射到 Kotlin 数据类 Drama:
@Entity(tableName = "drama")
data class Drama(@PrimaryKey val id: String,@ColumnInfo(name = "cover_file_name") var coverFileName: String?,@ColumnInfo(name = "index") var index: Long,@ColumnInfo(name = "pay_start_episode") var payStartEpisode: Int,@ColumnInfo(name = "product_id") var productId: String?,@ColumnInfo(name = "title") var title: String?,@ColumnInfo(name = "type") var type: Long
) : Serializable
字段说明:
- id:主键,用于唯一标识剧集。
- coverFileName:封面图片文件名。
- index:排序字段,控制在书架中的显示顺序。
- payStartEpisode:从哪一集开始付费。
- title:剧集标题。
- 其他字段可根据业务扩展。
5.2 LibraryViewModel
ViewModel 负责读取数据库数据并通知 UI 更新:
class LibraryViewModel : ViewModel() {private val repository by lazy { DramaRepository() }private val _dramas = MutableStateFlow<List<DramaWithEpisodeCount>>(emptyList())val dramas: StateFlow<List<DramaWithEpisodeCount>> = _dramas/** 从数据库获取剧集列表 */fun fetchDramas() {viewModelScope.launch {val dramaList = withContext(Dispatchers.IO) {repository.getAllDramasWithEpisodeCount()}_dramas.value = dramaList}}
}
说明:
- _dramas:私有可变 StateFlow,存储书架页的剧集数据。
- dramas:暴露给 UI 的只读 StateFlow。
- fetchDramas():从 Repository 异步获取数据,保证不会阻塞主线程。
5.3 数据与 UI 的绑定
在 Fragment 中,使用 Kotlin 的 Flow 收集数据,并更新 Adapter:
lifecycleScope.launchWhenStarted {viewModel.dramas.collect { dramaList ->libraryAdapter.updateItems(dramaList)tipView.isVisible = dramaList.isEmpty()}
}
流程说明:
- 打开页面 → ViewModel 调用 fetchDramas()。
- Repository 从 Room 获取本地剧集列表。
- ViewModel 更新 _dramas → UI 收集到最新数据。
- Adapter 调用 updateItems() 刷新 RecyclerView。
- 如果书架为空,显示空状态提示。
六. 书架页完整运行流程
结合前面几节内容,我们把书架页从页面打开到数据展示的整个流程梳理清楚,让大家能够对实际运行逻辑有完整的理解。
6.1 页面初始化
Fragment 被创建时,首先加载布局:
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,savedInstanceState: Bundle?): View? {// Inflate the layout for this fragmentreturn inflater.inflate(R.layout.fragment_library, container, false)}
初始化 RecyclerView:
val recyclerView = binding.recyclerView
recyclerView.layoutManager = GridLayoutManager(context, 2)
recyclerView.adapter = libraryAdapterval spacing = resources.getDimensionPixelSize(R.dimen.grid_spacing)
recyclerView.addItemDecoration(GridSpacingItemDecoration(2, spacing))
此时,RecyclerView 已经准备好显示两列网格的书架内容。
6.2 ViewModel 数据加载
创建 LibraryViewModel 实例并调用 fetchDramas():
viewModel.fetchDramas()
ViewModel 在后台线程从 Room 数据库读取所有剧集,并更新 StateFlow:
viewModelScope.launch {val dramaList = withContext(Dispatchers.IO) {repository.getAllDramasWithEpisodeCount()}_dramas.value = dramaList
}
6.3 UI 绑定与刷新
Fragment 监听 ViewModel 中的 dramas Flow,并将数据绑定到 Adapter:
lifecycleScope.launchWhenStarted {viewModel.dramas.collect { dramaList ->libraryAdapter.updateItems(dramaList)binding.tipView.isVisible = dramaList.isEmpty()}
}
解释:
- Adapter 的 updateItems() 方法会调用 notifyDataSetChanged(),让 RecyclerView 刷新显示最新内容。
- 如果数据库为空,tipView 显示“暂无内容”,保证空状态友好提示。
6.4 点击事件与交互
在 LibraryAdapter 中,每个 item 的点击事件回调如下:
holder.itemView.setOnClickListener {onItemClick(item)
}
- 点击封面或标题可以进入剧集详情页。
- 更多按钮可以扩展成删除、收藏或移动书架等操作。
这种方式使得书架页的交互逻辑简单清晰,且容易扩展。
6.5 总体流程总结
整个书架页的运行流程可以概括为:
- 页面初始化 → RecyclerView 和 Adapter 准备就绪
- ViewModel 从本地数据库读取数据
- 数据更新 → Adapter 刷新 UI
- 空状态显示/隐藏
- 用户点击 → 响应交互
通过这种架构,书架页实现了 UI 与数据分离、数据本地化、交互灵活可扩展 的目标。
七. 总结与扩展
本章我们实现了书架页的核心功能:
- 使用 RecyclerView + GridLayoutManager 展示剧集网格
- 通过 Adapter + ViewHolder 绑定数据并处理点击事件
- 利用 ViewModel + Room 从本地数据库读取数据
- 实现空状态提示,当书架为空时显示“暂无内容”
扩展思路:
- 增加分页或排序功能,让书架内容更灵活
- 点击封面跳转到剧集详情页
- 更多按钮实现删除、移动或收藏操作
通过本章学习,你已经掌握了一个完整的本地数据书架页实现流程,为后续功能扩展打下了坚实基础。