一. 引言

在前几章中,我们实现了发现页,能够从网络获取推荐内容并展示给用户。而书架页则有着不同的特点:它主要展示用户已经保存或观看过的剧集数据,数据来源是本地数据库,而不是网络请求。

本章的目标是带大家从零实现书架页的核心功能,包括:

  • 使用 RecyclerView + GridLayoutManager 展示剧集网格
  • 通过 Adapter + ViewHolder 绑定数据
  • 利用 ViewModel + Room 管理本地数据
  • 实现空状态提示,当书架为空时给出友好提示

通过这篇文章,你将能够掌握如何结合 RecyclerView 与本地数据库 来构建一个实用的书架页,为用户提供直观、流畅的内容展示体验。

二. 页面布局设计(XML 部分)

书架页的整体布局使用 ConstraintLayout,包括三个核心部分:渐变背景、顶部导航栏(Toolbar)、以及主要的内容区(RecyclerView)。此外,我们还为书架为空的情况准备了一个 TextView 来提示用户“暂无内容”。 

2.1 整体结构
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns: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">

    <!-- 渐变背景 -->
    <View
        android: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.MaterialToolbar
        android: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.RecyclerView
        android: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" />

    <!-- 空状态提示 -->
    <TextView
        android: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 主要包括两部分:

  1. 上半部分:封面图 + 标题覆盖
  2. 下半部分:集数 + 更多按钮
<FrameLayout ...>
    <LinearLayout
        android:orientation="vertical"
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <!-- 封面图 + 标题 -->
        <androidx.constraintlayout.widget.ConstraintLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content">

            <androidx.cardview.widget.CardView
                android: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">

                <ImageView
                    android:id="@+id/coverImageView"
                    android:layout_width="match_parent"
                    android:layout_height="match_parent"
                    android:scaleType="centerCrop"/>
            </androidx.cardview.widget.CardView>

            <TextView
                android: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>

        <!-- 底部:集数 + 更多按钮 -->
        <LinearLayout
            android:orientation="horizontal"
            android:layout_width="match_parent"
            android:layout_height="50dp"
            android:gravity="center_vertical"
            android:paddingHorizontal="8dp">

            <TextView
                android: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"/>

            <View
                android:layout_width="0dp"
                android:layout_height="0dp"
                android:layout_weight="1"/>

            <ImageView
                android: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 = newItems
        notifyDataSetChanged() // 通知 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.title
        episodeTextView.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 / spanCount
        outRect.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()
    }
}

流程说明:

  1. 打开页面 → ViewModel 调用 fetchDramas()。
  2. Repository 从 Room 获取本地剧集列表。
  3. ViewModel 更新 _dramas → UI 收集到最新数据。
  4. Adapter 调用 updateItems() 刷新 RecyclerView。
  5. 如果书架为空,显示空状态提示。

六. 书架页完整运行流程

结合前面几节内容,我们把书架页从页面打开到数据展示的整个流程梳理清楚,让大家能够对实际运行逻辑有完整的理解。

6.1 页面初始化

Fragment 被创建时,首先加载布局:

override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        // Inflate the layout for this fragment
        return inflater.inflate(R.layout.fragment_library, container, false)
    }

初始化 RecyclerView:

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))

此时,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 总体流程总结

整个书架页的运行流程可以概括为:

  1. 页面初始化 → RecyclerView 和 Adapter 准备就绪
  2. ViewModel 从本地数据库读取数据
  3. 数据更新 → Adapter 刷新 UI
  4. 空状态显示/隐藏
  5. 用户点击 → 响应交互

通过这种架构,书架页实现了 UI 与数据分离、数据本地化、交互灵活可扩展 的目标。

七. 总结与扩展

本章我们实现了书架页的核心功能:

  1. 使用 RecyclerView + GridLayoutManager 展示剧集网格
  2. 通过 Adapter + ViewHolder 绑定数据并处理点击事件
  3. 利用 ViewModel + Room 从本地数据库读取数据
  4. 实现空状态提示,当书架为空时显示“暂无内容”

扩展思路:

  1. 增加分页或排序功能,让书架内容更灵活
  2. 点击封面跳转到剧集详情页
  3. 更多按钮实现删除、移动或收藏操作

通过本章学习,你已经掌握了一个完整的本地数据书架页实现流程,为后续功能扩展打下了坚实基础。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐