Android 短视频项目首页开发实战:基于 MVVM、DataBinding、Retrofit、RecyclerView、XBanner 与 NestedScrollView 完成广场页轮播广告、图文网格列表及发现页分类播单、话题广场的数据驱动渲染
前言
首页型页面往往不是单一列表,而是广告轮播、网格卡片、分类入口、横向播单和重叠话题区的组合。要把这一类页面真正搭起来,关键不只是把控件摆出来,还要把接口结构、DataBinding、列表类型切换、轮播数据转换、刷新状态收口和页面滚动冲突一起理顺。
这篇文章按实际实现顺序展开:先拆广场页的结构和数据来源,再完成布局、XBanner 轮播、列表绑定与网络请求;随后切到发现页,继续把分类、主题播单和话题广场的数据驱动链路补齐。读完以后,可以直接复盘一个首页型短视频页面从静态界面到接口渲染的完整落地过程。

目录
- 短视频项目首页实战:从广场页广告轮播与网格列表,到发现页分类、播单与话题广场的数据驱动实现
- 前言
- 目录
- [1. 广场页的界面拆分、接口结构与滚动容器选型](#1. 广场页的界面拆分、接口结构与滚动容器选型)
- [2. 广场页基础布局、基类接入与列表容器初始化](#2. 广场页基础布局、基类接入与列表容器初始化)
- [3. 使用 XBanner 搭建广场页顶部轮播区](#3. 使用 XBanner 搭建广场页顶部轮播区)
- [4. 通过 DataBinding 绑定广场页列表条目](#4. 通过 DataBinding 绑定广场页列表条目)
- [5. 打通广场页的数据请求链路](#5. 打通广场页的数据请求链路)
- [6. 将图文列表数据渲染到广场页网格区](#6. 将图文列表数据渲染到广场页网格区)
- [7. 将 Banner 数据转换为轮播模型并处理刷新状态](#7. 将 Banner 数据转换为轮播模型并处理刷新状态)
- [8. 发现页的模块划分与响应数据拆解](#8. 发现页的模块划分与响应数据拆解)
- [9. 加载发现页数据:从入口类到页面布局的完整链路](#9. 加载发现页数据:从入口类到页面布局的完整链路)
- [9.1 Application 初始化入口](#9.1 Application 初始化入口)
- [9.2 ApiService 与 Provider 封装](#9.2 ApiService 与 Provider 封装)
- [9.3 发现页实体类设计](#9.3 发现页实体类设计)
- [9.4 Model 层的数据请求转发](#9.4 Model 层的数据请求转发)
- [9.5 ViewModel 的数据分发](#9.5 ViewModel 的数据分发)
- [9.6 View 层入口绑定](#9.6 View 层入口绑定)
- [9.7 发现页 XML 结构与 NestedScrollView 处理](#9.7 发现页 XML 结构与 NestedScrollView 处理)
- [10. 分类标签的数据绑定与三列网格渲染](#10. 分类标签的数据绑定与三列网格渲染)
- [11. 主题播单与话题广场的数据渲染](#11. 主题播单与话题广场的数据渲染)
- [11.1 主题播单横向列表与话题广场层叠效果](#11.1 主题播单横向列表与话题广场层叠效果)
- [12. 相关代码附录](#12. 相关代码附录)
- [12.1 广场页入口与刷新控制](#12.1 广场页入口与刷新控制)
- [12.2 广场页适配器与 Banner 子布局](#12.2 广场页适配器与 Banner 子布局)
- [12.3 广场页的数据请求与实体模型](#12.3 广场页的数据请求与实体模型)
- [12.4 发现页的数据加载入口](#12.4 发现页的数据加载入口)
- [12.5 发现页适配器与布局片段](#12.5 发现页适配器与布局片段)
1. 广场页的界面拆分、接口结构与滚动容器选型
先看最终要实现的广场页形态。这个页面不是单一列表,而是"顶部广告轮播 + 下方双列内容卡片"的组合结构,因此一开始就要同时考虑顶部区域的独占布局和下方列表的网格排布。

从视觉上看,顶部是一组可左右切换的广告位,下半部分则是网格化的 RecyclerView。要把页面真正接起来,还需要先确认服务端接口如何划分这两类数据。
广场页接口如下:

把返回结果按 JSON 结构展开以后,可以更直观看到数据分层:

接口里的图片资源通过 http 提供,因此在应用清单里必须先打开明文流量,否则轮播图和列表封面都拿不到资源。这里直接在 application 节点上补 android:usesCleartextTraffic="true",并保留网络权限声明:
xml
<uses-permission android:name="android.permission.INTERNET"/>
<application
android:name=".MyApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@style/Theme.LsxbugVideo"
tools:targetApi="31">
项目内路径:LsxbugVideo/app/src/main/AndroidManifest.xml
回到接口数据本身,data 中的 type = image 对应首页顶部轮播区域,type = set_waterfall_metro_list 对应下半部分的网格内容。这一步先把服务端返回的类型和 UI 区块一一对上,后续写适配器时就不会把数据职责混在一起。

当前服务端返回的数据量不大,所以这里不做分页加载,而是把整批数据一次性取回后直接渲染到界面上。这样既能减少首页初始化链路里的复杂度,也更符合现阶段的数据规模。
从页面结构拆分的角度看,广场页大致可以分成顶部操作栏、广告轮播区和下方内容列表三个层次:

整个页面需要支持整体纵向滚动,同时还要保留刷新能力,因此外层容器更适合交给 SmartRefreshLayout 处理。下方列表区内部再交给 RecyclerView,就能在一个页面里同时完成刷新和多类型条目渲染。
另外,顶部广告区还带有"一屏露出多页"的视觉要求,左右滑动时会同时露出上一页和下一页的一部分。普通 ViewPager 很难直接做出这个效果,所以这里更适合换成支持多页展示的轮播控件,并配合定时切换能力来实现自动播放。

2. 广场页基础布局、基类接入与列表容器初始化
页面开始编码之前,先把公共图标下沉到基础库里,避免广场页和后续页面重复维护同一套资源:

广场页本身的布局重点有两处:顶部三枚图标单独占位,下方通过 include 引入公共的刷新 + 列表容器。这样可以保证页面结构清晰,也方便后续替换列表内容而不动外层框架。
xml
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/iv_search"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginStart="14dp"
android:layout_marginTop="2dp"
android:src="@mipmap/icon_search"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/iv_logo"
android:layout_width="112.91dp"
android:layout_height="15.52dp"
android:layout_marginTop="5.5dp"
android:src="@mipmap/icon_logo"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/iv_notify"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginTop="2dp"
android:layout_marginEnd="14dp"
android:src="@mipmap/icon_notify"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<include
android:id="@+id/layout_recycler"
layout="@layout/layout_srt_recyclerview"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="18dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/iv_logo" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
项目内路径:LsxbugVideo/feature_plaza/src/main/res/layout/layout_fragment_plaza.xml
因为这一页不做标准分页,所以 PlazaFragment 不需要继承 BaseListFragment。这里直接复用公共的 layout_srt_recyclerview,只拿它提供的刷新头、刷新尾和 RecyclerView 容器即可:
xml
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.scwang.smart.refresh.layout.SmartRefreshLayout
android:id="@+id/smart_refresh_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<com.scwang.smart.refresh.header.BezierRadarHeader
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<com.scwang.smart.refresh.footer.BallPulseFooter
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</com.scwang.smart.refresh.layout.SmartRefreshLayout>
<!-- <include-->
<!-- android:id="@+id/layout_status_view"-->
<!-- layout="@layout/layout_status_view" />-->
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
项目内路径:LsxbugVideo/library_base/src/main/res/layout/layout_srt_recyclerview.xml
页面骨架先从最精简的 BaseFragment 子类开始,只把布局资源、ViewModel 和初始化方法的入口留出来。这样做的目的是先把广场页的页面生命周期挂起来,再逐步把列表和数据补进去。
java
@Route(path = ARouterPath.Plaza.FRAGMENT_PLAZA)
public class PlazaFragment extends BaseFragment {
@Override
protected BaseViewModel getViewModel() {
return null;
}
@Override
protected int getLayoutResId() {
return R.layout.layout_fragment_plaza;
}
@Override
protected int getBindingVariableId() {
return 0;
}
@Override
protected void initView() {
}
@Override
protected void initData() {
}
}
项目内路径:LsxbugVideo/feature_plaza/src/main/java/com/ls/feature_plaza/fragment/plaza/PlazaFragment.java
广场页的 ViewModel 先继承 BaseViewModel。这一层当前虽然还没放具体业务逻辑,但先复用基类里的错误码通道,后面接网络请求时就能直接沿用。
java
public class PlazaViewModel extends BaseViewModel{
}
项目内路径:LsxbugVideo/feature_plaza/src/main/java/com/ls/feature_plaza/fragment/plaza/PlazaViewModel.java
BaseViewModel 的职责很简单,就是把通用错误态抽成统一的 LiveData,让不同业务页都能共用这一套异常分发方式:
java
public class BaseViewModel extends ViewModel {
//错误码
public MutableLiveData<Integer> mErrorCode = new MutableLiveData<>();
public MutableLiveData<Integer> getErrorCode() {
return mErrorCode;
}
}
项目内路径:LsxbugVideo/library_base/src/main/java/com/ls/libbase/base/BaseViewModel.java
等到 DataBinding 和业务 ViewModel 确定以后,再把 PlazaFragment 的泛型补齐。这里的关键点有三个:通过 ViewModelProvider 获取 PlazaViewModel,返回广场页布局资源 id,以及保留 initView / initData 作为后续列表与数据的接入点。
java
@Route(path = ARouterPath.Plaza.FRAGMENT_PLAZA)
public class PlazaFragment extends BaseFragment<LayoutFragmentPlazaBinding, PlazaViewModel> {
private static final String TAG = "PlazaFragment";
private PlazaApater mAapter;
@Override
protected PlazaViewModel getViewModel() {
return new ViewModelProvider(this).get(PlazaViewModel.class);
}
@Override
protected int getLayoutResId() {
return R.layout.layout_fragment_plaza;
}
@Override
protected int getBindingVariableId() {
return 0;
}
@Override
protected void initView() {
}
@Override
protected void initData() {
}
}
项目内路径:LsxbugVideo/feature_plaza/src/main/java/com/ls/feature_plaza/fragment/plaza/PlazaFragment.java
接下来先把列表容器跑起来:
- 用
GridLayoutManager把整体列表定义成两列 - 通过
setSpanSizeLookup让第一个条目独占两列,保证顶部轮播区仍然以一整行的形式出现,普通内容条目则保持一行两个卡片; - 与此同时,
SmartRefreshLayout只保留刷新能力,不需要加载更多,因为这一页的数据不是分页返回的。
java
@Override
protected void initView() {
Log.i(TAG, "initView");
RecyclerView recyclerView = mDataBinding.layoutRecycler.recyclerView;
GridLayoutManager layoutManager = new GridLayoutManager(getContext(), 2);
layoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
@Override
public int getSpanSize(int position) {
if (position == 0) {
return 2;//如果是第一行,那就独占1行
} else {
return 1;//如果是普通的item类型,那就2个item占1行
}
}
});
recyclerView.setLayoutManager(layoutManager);
SmartRefreshLayout smartRefreshLayout = mDataBinding.layoutRecycler.smartRefreshLayout;
//不需要加载更多
smartRefreshLayout.setEnableLoadMore(false);
//刷新监听
smartRefreshLayout.setOnRefreshListener(new OnRefreshListener() {
@Override
public void onRefresh(@NonNull RefreshLayout refreshLayout) {
}
});
}
项目内路径:LsxbugVideo/feature_plaza/src/main/java/com/ls/feature_plaza/fragment/plaza/PlazaFragment.java
3. 使用 XBanner 搭建广场页顶部轮播区
顶部广告区和普通图文卡片不是同一种条目类型,所以适配器一开始就要按多类型列表来设计。在 feature_plaza.adapter 下先创建 PlazaApater,用两个 ViewHolder 分别承接 Banner 和普通图片条目。

适配器的最初版本只保留必要的重写方法,并声明 BannerViewHolder、ImageViewHolder。
这一阶段的目标不是立刻渲染数据,而是先把多类型列表的骨架搭起来:
- 继承
RecyclerView.Adapter<RecyclerView.ViewHolder>重写方法; - 创建 BannerViewHolder、ImageViewHolder,继承 RecyclerView.ViewHolder,表示广场页面中的第二部分广告 Item和第四部分图片 item;
java
public class PlazaAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
@NonNull
@Override
public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
return null;
}
@Override
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
}
@Override
public int getItemCount() {
return 0;
}
public class ImageViewHolder extends RecyclerView.ViewHolder {
public ImageViewHolder(@NonNull View itemView) {
super(itemView);
}
}
public class BannerViewHolder extends RecyclerView.ViewHolder {
public BannerViewHolder(@NonNull View itemView) {
super(itemView);
}
}
}
项目内路径:LsxbugVideo/feature_plaza/src/main/java/com/ls/feature_plaza/adapter/PlazaAdapter.java
广告区需要支持一屏多页和自动轮播,因此这里直接引入第三方库 XBanner。相比普通 ViewPager,它更容易做出左右露出相邻页面的视觉效果。

Banner 对应的条目形态,本质上就是 XBanner 的一屏多页模式:

首先,需要在清单文件中检查是否声明网络权限;
接入库之前,先在项目根目录补好 jitpack 仓库。老项目可能会把仓库声明写在 build.gradle,这里使用的是 settings.gradle 的统一仓库配置方式:
groovy
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
maven { url "https://jitpack.io" }
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
maven { url "https://jitpack.io" }
}
}
项目内路径:LsxbugVideo/settings.gradle
广场模块依赖则直接写在模块级 build.gradle。因为版本号是 androidx_v1.2.6 这种带下划线的形式,不适合再抽到 gradle/libs.versions.toml 里统一管理。
xml
implementation 'com.github.xiaohaibin:XBanner:androidx_v1.2.6'
项目内路径:LsxbugVideo/feature_plaza/build.gradle
Banner 条目布局 item_banner.xml 负责两部分内容:顶部 XBanner 轮播区,以及下方"了解更多 + 当前页数"的辅助信息区。viewpagerMargin 用来制造页与页之间的留白,pointsVisibility="false" 则关闭默认指示器,改用自定义的右下角数字指示器。

xml
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.stx.xhb.androidx.XBanner
android:id="@+id/xbanner"
android:layout_width="match_parent"
android:layout_height="188dp"
app:AutoPlayTime="3000"
app:isAutoPlay="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:pageChangeDuration="800"
app:pointsVisibility="false"
app:viewpagerMargin="2dp" />
<TextView
android:id="@+id/textView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/margin_start_14dp"
android:layout_marginTop="6.5dp"
android:text="@string/learn_more"
android:textColor="@color/black"
android:textSize="@dimen/font_size_11sp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/xbanner" />
<ImageView
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginStart="8dp"
android:src="@mipmap/icon_arrow"
app:layout_constraintBottom_toBottomOf="@id/textView"
app:layout_constraintStart_toEndOf="@+id/textView"
app:layout_constraintTop_toTopOf="@id/textView" />
<TextView
android:id="@+id/tv_indicator"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="14dp"
android:background="@drawable/bg_banner_indicator"
android:gravity="center"
android:text="1"
android:textColor="@color/black"
android:textSize="@dimen/font_size_11sp"
app:layout_constraintBottom_toBottomOf="@+id/textView"
app:layout_constraintEnd_toEndOf="@+id/xbanner"
app:layout_constraintTop_toTopOf="@id/textView" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="6.5dp"
android:background="#33000000"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/textView" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
项目内路径:LsxbugVideo/feature_plaza/src/main/res/layout/item_banner.xml
普通图文卡片则交给 item_image.xml。这里直接把服务器返回的封面、标题、作者头像和作者名映射到条目布局上,后面只要在 ViewHolder 里把实体对象塞进 binding,XML 就能自行完成字段绑定。

xml
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tool="http://schemas.android.com/tools">
<data>
<variable
name="data"
type="com.ls.feature_plaza.bean.ResPlaza.PlazaDetail" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/white"
android:paddingTop="21.5dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<ImageView
android:id="@+id/iv_cover"
android:layout_width="173dp"
android:layout_height="173dp"
android:scaleType="center"
imageUrl="@{data.cover}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tool:src="#000000" />
<TextView
android:id="@+id/tv_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginLeft="10dp"
android:layout_marginTop="12dp"
android:ellipsize="end"
android:text="@{data.title}"
android:maxLines="1"
android:textColor="#ff444444"
android:textSize="@dimen/font_size_11sp"
app:layout_constraintEnd_toEndOf="@id/iv_cover"
app:layout_constraintStart_toStartOf="@id/iv_cover"
app:layout_constraintTop_toBottomOf="@id/iv_cover"
tool:text="title" />
<ImageView
android:id="@+id/iv_avatar"
android:layout_width="14dp"
android:layout_height="14dp"
android:layout_marginTop="28dp"
imageCircleUrl="@{data.avatar}"
app:layout_constraintStart_toStartOf="@id/tv_title"
app:layout_constraintTop_toBottomOf="@id/tv_title"
tool:src="@mipmap/ic_launcher_round" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="4dp"
android:textColor="#ffbbbbbb"
android:textSize="10sp"
android:text="@{data.author}"
app:layout_constraintBottom_toBottomOf="@id/iv_avatar"
app:layout_constraintStart_toEndOf="@id/iv_avatar"
app:layout_constraintTop_toTopOf="@id/iv_avatar"
tool:text="author" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
项目内路径:LsxbugVideo/feature_plaza/src/main/res/layout/item_image.xml
4. 通过 DataBinding 绑定广场页列表条目
条目布局确定以后,下一步是让 ViewHolder 真正持有对应的 DataBinding。
- 修改两种 ViewHolder 的构造方法,需要将两种 item 的 DataBinding 作为参数传入;
- 根据 DataBinding 找到根布局,将根布局作为调用父类构造方法的参数,再将 DataBinding 声明为两种 ViewHolder 的成员变量:
java
public class ImageViewHolder extends RecyclerView.ViewHolder {
private final ItemImageBinding binding;
public ImageViewHolder(ItemImageBinding binding) {
super(binding.getRoot());
this.binding = binding;
}
}
public class BannerViewHolder extends RecyclerView.ViewHolder {
private final ItemBannerBinding bannerBinding;
public BannerViewHolder(ItemBannerBinding binding) {
super(binding.getRoot());
bannerBinding = binding;
}
}
项目内路径:LsxbugVideo/feature_plaza/src/main/java/com/ls/feature_plaza/adapter/PlazaAdapter.java
这样做有两个直接收益:
- 一是
ViewHolder可以直接拿到根布局; - 二是后续绑定实体对象时,不需要再通过一堆
findViewById手工赋值。
PlazaAdapter 接着完成三件事:
- 在
onCreateViewHolder里分别加载item_banner.xml和item_image.xml两种条目的 DataBinding,再以 DataBinding 作为参数实例化对应的 ImageViewHolder/BannerViewHolder - 通过
getItemViewType判断当前位置是 Banner 还是普通卡片; - 在
getItemCount里先返回一个固定值,方便快速看到页面结构是否已经跑通。
java
public class PlazaAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
private static final int ITEM_TYPE_BANNER = 1;//banner
private static final int ITEM_TYPE_IMAGE = 2;//常规item类型
@NonNull
@Override
public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext());
if (ITEM_TYPE_BANNER == viewType) {
ItemBannerBinding bannerBinding = ItemBannerBinding.inflate(layoutInflater, parent, false);
BannerViewHolder viewHolder = new BannerViewHolder(bannerBinding);
return viewHolder;
} else {
ItemImageBinding imageBinding = ItemImageBinding.inflate(layoutInflater, parent, false);
ImageViewHolder viewHolder = new ImageViewHolder(imageBinding);
return viewHolder;
}
}
@Override
public int getItemViewType(int position) {
return position == 0 ? ITEM_TYPE_BANNER : ITEM_TYPE_IMAGE;
}
@Override
public int getItemCount() {
return 10;
}
}
项目内路径:LsxbugVideo/feature_plaza/src/main/java/com/ls/feature_plaza/adapter/PlazaAdapter.java
Fragment 只需要把适配器实例化后挂到 RecyclerView 上,就能先把静态页面骨架跑起来。这里的顺序也很重要:先设布局管理器,再设适配器,最后继续处理 SmartRefreshLayout。
java
recyclerView.setLayoutManager(layoutManager);
mAapter = new PlazaAdapter();
recyclerView.setAdapter(mAapter);
SmartRefreshLayout smartRefreshLayout = mDataBinding.layoutRecycler.smartRefreshLayout;
项目内路径:LsxbugVideo/feature_plaza/src/main/java/com/ls/feature_plaza/fragment/plaza/PlazaFragment.java
当前运行效果如下。到这一步,说明顶部 Banner 条目和下方卡片条目已经被 RecyclerView 正确组织进同一个双列网格里了。

5. 打通广场页的数据请求链路
静态布局跑通以后,下一步就是把广场页和服务端接口接起来。这里沿用首页模块已经有的网络封装思路,在 feature_plaza.api 包下分别准备接口定义和服务提供者,保证页面层不用直接接触 Retrofit 的创建细节。
先写 PlazaApiServiceProvider。它的职责非常单一:统一持有 PlazaApiService 单例,后续不管是 Model 还是别的业务入口,都从这里拿接口实例。
java
/**
* Plaza模块中的PlazaApiService统一在这里获取,以便统一管理
*/
public class PlazaApiServiceProvider {
private static PlazaApiService mApiService;
//单例
public static PlazaApiService getApiService() {
if (mApiService == null) {
Retrofit retrofit = RetrofitProvider.provide();
mApiService = retrofit.create(PlazaApiService.class);
}
return mApiService;
}
}
项目内路径:LsxbugVideo/feature_plaza/src/main/java/com/ls/feature_plaza/api/PlazaApiServiceProvider.java
接口返回体需要先映射成实体类。让顶部轮播区和下方图文卡片共用同一套 ResPlaza.PlazaDetail 结构,从而在后续适配器里根据 type 选择不同的展示方式。

java
public class ResPlaza {
private String type;
private List<PlazaDetail> lists;
// get、set
public static class PlazaDetail {
/**
* id : 29
* name : 搞笑
* image : url
* icon : url
* description : 哈哈哈哈哈哈哈哈哈
* url : /cms/29.html
* fullurl : url
*/
private int id;
private String name;
private String image;
private String icon;
private String description;
private String url;
private String fullurl;
/**
* title : 和老孙一起去看青山流水
* images : ["url"]
* author : 李白
* avatar : url
* cover : url
*/
private String title;
private String author;
private String avatar;
private String cover;
private List<String> images;
// get、set
}
}
项目内路径:LsxbugVideo/feature_plaza/src/main/java/com/ls/feature_plaza/bean/ResPlaza.java
接口定义本身保持足够薄,只声明广场页首页数据的 GET 路径和返回值类型,把真正的错误处理留给统一的网络层去做。
java
/**
* 这里存放plaza模块的api
*/
public interface PlazaApiService {
/**
* 广场首页数据
*
* @return 服务端返回的数据类型
*/
@GET("addons/cms/api.eye/square")
Call<ResBase<List<ResPlaza>>> getPlaza();
}
项目内路径:LsxbugVideo/feature_plaza/src/main/java/com/ls/feature_plaza/api/PlazaApiService.java
有了接口以后,Model 层先把请求逻辑写起来。第一版代码先把请求成功和失败的回调接口放在 PlazaModel 内部,方便观察整条链路需要哪些入参与返回值:
java
public class PlazaModel {
public void requestDatas() {
// 获取 call
Call<ResBase<List<ResPlaza>>> call = PlazaApiServiceProvider.getApiService().getPlaza();
ApiCall.enqueue(call, new ApiCall.ApiCallback<ResBase<List<ResPlaza>>>() {
@Override
public void onSuccess(ResBase<List<ResPlaza>> result) {
}
@Override
public void onError(int errorCode, String meesage) {
}
});
}
public interface IRequestCallback<T> {
void onLoadFinish(T datas);
void onLoadFailure(int errorCode);
}
}
项目内路径:LsxbugVideo/feature_plaza/src/main/java/com/ls/feature_plaza/fragment/plaza/PlazaModel.java
因为 Model 和 ViewModel 之间会频繁通过接口回调交互,所以回调接口最终应该抽到基础层,避免每个模块都重复声明一次。这里把 IRequestCallback<T> 提到 libbase.base 下,统一给所有业务模块复用。
使用泛型,表示请求成功的回调参数类型:
java
/**
* model和ViewModel通讯的接口回调
*
* @param <T>
*/
public interface IRequestCallback<T> {
void onLoadFinish(T datas);
void onLoadFailure(int errorCode);
}
项目内路径:LsxbugVideo/library_base/src/main/java/com/ls/libbase/base/IRequestCallback.java
回调接口抽出以后,PlazaModel 的职责就更明确了:接收 IRequestCallback<List<ResPlaza>> 作为参数,请求成功后把 result.getData() 交给上层,请求失败则把错误码向上传递。
java
public class PlazaModel {
public void requestDatas(IRequestCallback<List<ResPlaza>> callback) {
//获取call
Call<ResBase<List<ResPlaza>>> call = PlazaApiServiceProvider.getApiService().getPlaza();
ApiCall.enqueue(call, new ApiCall.ApiCallback<ResBase<List<ResPlaza>>>() {
@Override
public void onSuccess(ResBase<List<ResPlaza>> result) {
callback.onLoadFinish(result.getData());
}
@Override
public void onError(int errorCode, String meesage) {
callback.onLoadFailure(errorCode);
}
});
}
}
项目内路径:LsxbugVideo/feature_plaza/src/main/java/com/ls/feature_plaza/fragment/plaza/PlazaModel.java
PlazaViewModel 则同时承担两件事:
- 一是实现
IRequestCallback<List<ResPlaza>>,让自己可以直接作为Model的回调对象; - 二是把成功结果写入
mDatas,把失败结果写回BaseViewModel的错误码通道。
这样 Fragment 侧只关心观察 LiveData,不需要碰网络细节。实现如下:
- PlazaViewModel 继承 BaseViewModel,可以更新内部定义的错误状态码,同时实现
IRequestCallback<List<ResPlaza>>接口; - 在构造方法中创建好 PlazaModel 对象;
- 在 requestDatas 方法中,通过 PlazaModel 对象调用请求服务器方法,同时传入 PlazaViewModel 对象作为参数,因为已经实现了 IRequestCallback,所以传入 this 表示 IRequestCallback 的调用方;
- 重写 IRequestCallback 接口的方法,请求成功,将请求的结果设置到 mDatas,方便 view 更新 UI,请求失败,设置 BaseViewModel 中的错误码的值;
java
public class PlazaViewModel extends BaseViewModel implements IRequestCallback<List<ResPlaza>> {
private final PlazaModel mModel;
private MutableLiveData<List<ResPlaza>> mDatas = new MutableLiveData<>();
public PlazaViewModel() {
mModel = new PlazaModel();
}
/**
* 请求广场的数据
*/
public void requestDatas() {
mModel.requestDatas(this);
}
@Override
public void onLoadFinish(List<ResPlaza> datas) {
mDatas.setValue(datas);
}
@Override
public void onLoadFailure(int errorCode) {
getErrorCode().setValue(errorCode);
}
public MutableLiveData<List<ResPlaza>> getDatas() {
return mDatas;
}
}
项目内路径:LsxbugVideo/feature_plaza/src/main/java/com/ls/feature_plaza/fragment/plaza/PlazaViewModel.java
Fragment 层接到 ViewModel 以后,逻辑就分成两段:
initView里把下拉刷新动作转发给mViewModel.requestDatas();initData里观察mDatas,一旦结果到达就把数据交给适配器,同时在页面首次进入时主动触发一次请求。
java
@Override
protected void initView() {
// .....
//刷新监听
smartRefreshLayout.setOnRefreshListener(new OnRefreshListener() {
@Override
public void onRefresh(@NonNull RefreshLayout refreshLayout) {
mViewModel.requestDatas();//请求数据
}
});
}
@Override
protected void initData() {
mViewModel.getDatas().observe(getViewLifecycleOwner(), new Observer<List<ResPlaza>>() {
@Override
public void onChanged(List<ResPlaza> data) {
// 数据请求成功
mAapter.setDatas(data);
}
});
mViewModel.requestDatas(); // 请求数据
}
项目内路径:LsxbugVideo/feature_plaza/src/main/java/com/ls/feature_plaza/fragment/plaza/PlazaFragment.java
这时已经可以在 PlazaModel 的成功、失败回调里打断点,或者直接看日志,确认接口有没有真正拿到数据。

如果请求返回后发现明明是业务成功却仍然走到了错误分支,就要回头检查统一网络层的成功判定条件。这里和服务端的约定不是 code == 200,而是响应体里的 code == 1,因此 ApiCall 的 enqueue / enqueueLists 判断条件都要跟着调整。

6. 将图文列表数据渲染到广场页网格区
接口通了以后,先处理下半部分的图文列表。适配器拿到整批数据后,并不会直接平铺使用,而是先从返回数组里拆出 Banner 和普通图文列表两个部分。这里默认把下标 0 作为 Banner 数据,把下标 1 作为网格卡片数据,然后再刷新列表:
java
public void setDatas(List<ResPlaza> data) {
if (data != null && data.size() >= 2) {
ResPlaza bannerData = data.get(0);
mBannerDatas = converXBannerDatas(bannerData);
ResPlaza imageData = data.get(1);
mLists = imageData.getLists();
//刷新
notifyDataSetChanged();
}
}
项目内路径:LsxbugVideo/feature_plaza/src/main/java/com/ls/feature_plaza/adapter/PlazaAdapter.java
接着补齐 getItemCount。这里的计算方式要和页面结构保持一致:普通图文卡片按 mLists.size() 计数,Banner 只占一个顶部条目,因此 mBannerDatas 只要有数据,总数就额外加 1。
java
private List<ResPlaza.PlazaDetail> mLists;
private ArrayList<PlazaXBannerData> mBannerDatas;//banner数据
@Override
public int getItemCount() {
int count = 0;
if (mBannerDatas != null && mBannerDatas.size() > 0) {
count += 1;
}
if (mLists != null && mLists.size() > 0) {
count += mLists.size();
}
return count;
}
项目内路径:LsxbugVideo/feature_plaza/src/main/java/com/ls/feature_plaza/adapter/PlazaAdapter.java
普通图文条目的绑定逻辑也要和这个计数规则对齐。因为位置 0 被 Banner 占掉了,真正的网格数据从位置 1 才开始,所以在绑定图片条目时要使用 mLists.get(position - 1),否则索引会整体错位。
- 在滑动列表时,从
List<ResPlaza.PlazaDetail>中获取当前 position 的元素; - 将该元素设置到 ImageViewHolder 的 binding 上;
- 通过 binding,在 xml 文件中通过 @{} ,将控件属性和列表元素对象字段值关联:
mLists.get(position - 1)注意,获取的元素位置下标要 -1,因为 position = getItemCount() ,count = mlist.size + 1,表示 image item 个数 + bannner 个数,此时判断 item 类型为 image,需要拿 position - 1 的位置的 mList,避免下标越界;
java
@Override
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
int viewType = getItemViewType(position);
if (viewType == ITEM_TYPE_BANNER) {
// 顶部的第一行 banner
} else {
// ITEM_TYPE_IMAGE
ImageViewHolder viewHolder = (ImageViewHolder) holder;
ResPlaza.PlazaDetail detail = mLists.get(position - 1);
viewHolder.binding.setData(detail);
}
}
@Override
public int getItemViewType(int position) {
return position == 0 ? ITEM_TYPE_BANNER : ITEM_TYPE_IMAGE;
}
项目内路径:LsxbugVideo/feature_plaza/src/main/java/com/ls/feature_plaza/adapter/PlazaAdapter.java
布局层的绑定也要同步打开。item_image.xml 里通过 <variable name="data" /> 声明实体类,再用 @{} 把 cover、title、avatar、author 这些字段直接绑定到控件属性上。这样 onBindViewHolder 只负责传对象,不再负责逐个控件赋值。
xml
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tool="http://schemas.android.com/tools">
<data>
<variable
name="data"
type="com.ls.feature_plaza.bean.ResPlaza.PlazaDetail" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/white"
android:paddingTop="21.5dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<ImageView
android:id="@+id/iv_cover"
android:layout_width="173dp"
android:layout_height="173dp"
android:scaleType="center"
imageUrl="@{data.cover}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tool:src="#000000" />
<TextView
android:id="@+id/tv_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginLeft="10dp"
android:layout_marginTop="12dp"
android:ellipsize="end"
android:text="@{data.title}"
android:maxLines="1"
android:textColor="#ff444444"
android:textSize="@dimen/font_size_11sp"
app:layout_constraintEnd_toEndOf="@id/iv_cover"
app:layout_constraintStart_toStartOf="@id/iv_cover"
app:layout_constraintTop_toBottomOf="@id/iv_cover"
tool:text="title" />
<ImageView
android:id="@+id/iv_avatar"
android:layout_width="14dp"
android:layout_height="14dp"
android:layout_marginTop="28dp"
imageCircleUrl="@{data.avatar}"
app:layout_constraintStart_toStartOf="@id/tv_title"
app:layout_constraintTop_toBottomOf="@id/tv_title"
tool:src="@mipmap/ic_launcher_round" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="4dp"
android:textColor="#ffbbbbbb"
android:textSize="10sp"
android:text="@{data.author}"
app:layout_constraintBottom_toBottomOf="@id/iv_avatar"
app:layout_constraintStart_toEndOf="@id/iv_avatar"
app:layout_constraintTop_toTopOf="@id/iv_avatar"
tool:text="author" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
项目内路径:LsxbugVideo/feature_plaza/src/main/res/layout/item_image.xml
效果如下。到这里,广场页的普通内容区已经可以按照接口返回结果稳定显示成双列卡片列表了。

7. 将 Banner 数据转换为轮播模型并处理刷新状态
顶部 Banner 的处理和普通图文卡片不同,因为 XBanner 需要自己的数据模型和子布局。首先在 item_banner.xml 里保留轮播控件本身,后续所有 Banner 初始化都会围绕这套布局展开:
xml
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.stx.xhb.androidx.XBanner
android:id="@+id/xbanner"
android:layout_width="match_parent"
android:layout_height="188dp"
app:AutoPlayTime="3000"
app:isAutoPlay="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:pageChangeDuration="800"
app:pointsVisibility="false"
app:viewpagerMargin="2dp" />
<TextView
android:id="@+id/textView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/margin_start_14dp"
android:layout_marginTop="6.5dp"
android:text="@string/learn_more"
android:textColor="@color/black"
android:textSize="@dimen/font_size_11sp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/xbanner" />
<ImageView
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginStart="8dp"
android:src="@mipmap/icon_arrow"
app:layout_constraintBottom_toBottomOf="@id/textView"
app:layout_constraintStart_toEndOf="@+id/textView"
app:layout_constraintTop_toTopOf="@id/textView" />
<TextView
android:id="@+id/tv_indicator"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="14dp"
android:background="@drawable/bg_banner_indicator"
android:gravity="center"
android:text="1"
android:textColor="@color/black"
android:textSize="@dimen/font_size_11sp"
app:layout_constraintBottom_toBottomOf="@+id/textView"
app:layout_constraintEnd_toEndOf="@+id/xbanner"
app:layout_constraintTop_toTopOf="@id/textView" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="6.5dp"
android:background="#33000000"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/textView" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
项目内路径:LsxbugVideo/feature_plaza/src/main/res/layout/item_banner.xml
真正进入绑定阶段以后,onBindViewHolder 里先做基础初始化:拿到 BannerViewHolder,设置占位图,打开一屏多页模式,并把 mBannerDatas 和自定义子布局 item_banner_child 交给 XBanner。

PlazaAdapter 实现 onBindViewHolder 方法,初始化 XBanner
- 如果 item 类型是 XBanner,找到 XBanner item 对应的 ViewHolder,从 ViewHolder 中找到 item 对应的 bannerBinding
- 可以为 XBanner 设置占位图,便于快速观察页面效果,以及占位图的在 XBanner 的填充类型 ScaleType
- 为了让 XBanner 实现一屏显示多页的效果,需要将 setIsClipChildrenMode 设置为 true
- 设置 XBanner 的数据源以及具体的 XBanner 布局文件 setBannerData,注意,R.layout.item_banner_child 为 XBanner 布局文件,因为 XBanner 不单单有一张图片,图片上还有标题、描述等文本,需要再实现一个布局;
java
@Override
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
int viewType = getItemViewType(position);
if (viewType == ITEM_TYPE_BANNER) {
//顶部的第一行 banner
BannerViewHolder viewHolder = (BannerViewHolder) holder;
ItemBannerBinding binding = viewHolder.bannerBinding;
//设置占位图
binding.xbanner.setBannerPlaceholderImg(R.mipmap.ic_launcher, ImageView.ScaleType.CENTER_CROP);
//一屏多页
binding.xbanner.setIsClipChildrenMode(true);
//设置banner数据以及自定义banner每页的布局
binding.xbanner.setBannerData(R.layout.item_banner_child, mBannerDatas);
} else {
//ITEM_TYPE_IMAGE
ImageViewHolder viewHolder = (ImageViewHolder) holder;
ResPlaza.PlazaDetail detail = mLists.get(position - 1);
viewHolder.binding.setData(detail);
}
}
项目内路径:LsxbugVideo/feature_plaza/src/main/java/com/ls/feature_plaza/adapter/PlazaAdapter.java
为了让 XBanner 正确消费数据,还需要准备一个专门的 PlazaXBannerData。
这个类实现 BaseBannerInfo,既把图片地址 imgUrl 和标题 title 暴露给 XBanner,也额外保留描述字段 description,方便子布局里的文案展示。
java
public class PlazaXBannerData implements BaseBannerInfo {
private String imgUrl;
private String title;
private String description;
public PlazaXBannerData(String imgUrl, String title, String description) {
this.imgUrl = imgUrl;
this.title = title;
this.description = description;
}
@Override
public String getXBannerUrl() {
return imgUrl;
}
@Override
public String getXBannerTitle() {
return title;
}
public String getDescription() {
return description;
}
}
项目内路径:LsxbugVideo/feature_plaza/src/main/java/com/ls/feature_plaza/bean/PlazaXBannerData.java
接下来,将服务器获取的响应结果数据,转换为 PlazaXBannerData 对象;
由于服务端返回的是 ResPlaza,适配器还需要把它转换成 ArrayList<PlazaXBannerData>。这里的转换逻辑不能省:先拿到 Banner 那一组 lists,再逐个取出 image、name、description 组装成 PlazaXBannerData,最后返回给成员变量 mBannerDatas。
在 PlazaAdapter 中,将数据源数据转换为 PlazaXBannerData 类型:
- 获取数据源列表第一个元素,这个元素是一个列表,转换为 PlazaXBannerData 类型,需要实现 converXBannerDatas 方法;
- converXBannerDatas() 需要返回
ArrayList<PlazaXBannerData>类型; - 如果数据源有数据,从数据中取出列表,并且对列表元素进行遍历,将列表元素各个字段设置到 PlazaXBannerData 实体类字段中;
- 将所有设置好的 PlazaXBannerData 实体类元素存到列表,并返回;
- 使用成员变量 mBannerDatas 接收返回的 PlazaXBannerData 列表;
java
private ArrayList<PlazaXBannerData> mBannerDatas;//banner数据
public void setDatas(List<ResPlaza> data) {
if (data != null && data.size() >= 2) {
ResPlaza bannerData = data.get(0);
mBannerDatas = converXBannerDatas(bannerData);
ResPlaza imageData = data.get(1);
mLists = imageData.getLists();
//刷新
notifyDataSetChanged();
}
}
/**
* 因为xbanner需要接受特定的数据类型,所以要把服务端返回的数据转成xbanner可以接受的数据类型
*
* @param data
* @return
*/
private ArrayList<PlazaXBannerData> converXBannerDatas(ResPlaza data) {
List<ResPlaza.PlazaDetail> lists = data.getLists();
if (lists != null && lists.size() > 0) {
ArrayList<PlazaXBannerData> xBannerDatas = new ArrayList<>();
for (int i = 0; i < lists.size(); i++) {
ResPlaza.PlazaDetail detail = lists.get(i);
PlazaXBannerData bannerData = new PlazaXBannerData(detail.getImage(),
detail.getName(), detail.getDescription());
xBannerDatas.add(bannerData);
}
return xBannerDatas;
}
return null;
}
项目内路径:LsxbugVideo/feature_plaza/src/main/java/com/ls/feature_plaza/adapter/PlazaAdapter.java
Banner 不是固定存在的,所以 getItemCount() 还要再次依赖 mBannerDatas 的长度,来决定顶部条目是否应该占位。只有轮播数据不为空时,整个 RecyclerView 才需要额外渲染那一个 Banner 条目。
java
@Override
public int getItemCount() {
int count = 0;
if (mBannerDatas != null && mBannerDatas.size() > 0) {
count += 1;
}
if (mLists != null && mLists.size() > 0) {
count += mLists.size();
}
return count;
}
项目内路径:LsxbugVideo/feature_plaza/src/main/java/com/ls/feature_plaza/adapter/PlazaAdapter.java
Banner 真正的渲染细节则继续写在 onBindViewHolder 里。这里有几件事必须逐项完成:
通过 loadImage() 回调拿到子布局里的控件,读取当前页对应的 PlazaXBannerData,用项目里的 GlideUtils 加载图片,再把页码变化实时同步到右下角的 tvIndicator。
实现 onBindViewHolder 方法:
- xbanner.loadImage(),loadImage() 方法回传的 View,根据自定义布局设置的 id 找到相应的控件;
- 获取当前 position 对应的 xbanner 列表元素,将元素字段值设置到控件属性上;
- 调用当前项目已经封装好的 glide 库工具类,将图片 url 加载为图片,并且设置到通过 id 找到的 imageview 控件上;
- xbanner.setOnPageChangeListener() 设置翻页监听,在翻页时,获取当前页的页数,实时更新到右下角页面控件 tvIndicator 上;
java
if (viewType == ITEM_TYPE_BANNER) {
//顶部的第一行 banner
BannerViewHolder viewHolder = (BannerViewHolder) holder;
ItemBannerBinding binding = viewHolder.bannerBinding;
//设置占位图
binding.xbanner.setBannerPlaceholderImg(R.mipmap.ic_launcher, ImageView.ScaleType.CENTER_CROP);
//一屏多页
binding.xbanner.setIsClipChildrenMode(true);
//设置banner数据以及自定义banner每页的布局
binding.xbanner.setBannerData(R.layout.item_banner_child, mBannerDatas);
binding.xbanner.loadImage(new XBanner.XBannerAdapter() {
@Override
public void loadBanner(XBanner banner, Object model, View view, int position) {
ImageView imgeView = view.findViewById(R.id.image_wiew);
TextView tvTitle = view.findViewById(R.id.tv_title);
TextView tvLabel = view.findViewById(R.id.tv_label);
PlazaXBannerData data = mBannerDatas.get(position);
GlideUtils.loadImage(data.getXBannerUrl(), imgeView);
tvTitle.setText(data.getXBannerTitle());
tvLabel.setText(data.getDescription());
}
});
binding.xbanner.setOnPageChangeListener(new ViewPager.OnPageChangeListener() {
@Override
public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
}
@Override
public void onPageSelected(int position) {
binding.tvIndicator.setText(String.valueOf(position + 1));
}
@Override
public void onPageScrollStateChanged(int state) {
}
});
}
项目内路径:LsxbugVideo/feature_plaza/src/main/java/com/ls/feature_plaza/adapter/PlazaAdapter.java
运行效果如下,顶部轮播区已经可以一屏多页展示,并且会随着翻页更新右下角的数字提示。

这时再做下拉刷新,会发现刷新动画一直不结束。原因很直接:虽然数据请求已经返回,但 SmartRefreshLayout 没有在数据成功或失败后主动调用 finishRefresh()。

所以最后还要回到 PlazaFragment,在监听 Datas 和 ErrorCode 时同时检查刷新状态。如果当前正在刷新,就立即关闭动画。这样无论是成功回包还是失败回调,刷新控件都能正确收口。
java
@Override
protected void initData() {
mViewModel.getDatas().observe(getViewLifecycleOwner(), new Observer<List<ResPlaza>>() {
@Override
public void onChanged(List<ResPlaza> data) {
if (mDataBinding.layoutRecycler.smartRefreshLayout.isRefreshing()) {
mDataBinding.layoutRecycler.smartRefreshLayout.finishRefresh();
}
//数据请求成功
mAapter.setDatas(data);
}
});
mViewModel.getErrorCode().observe(getViewLifecycleOwner(), new Observer<Integer>() {
@Override
public void onChanged(Integer integer) {
if (mDataBinding.layoutRecycler.smartRefreshLayout.isRefreshing()) {
mDataBinding.layoutRecycler.smartRefreshLayout.finishRefresh();
}
}
});
mViewModel.requestDatas();//请求数据
}
项目内路径:LsxbugVideo/feature_plaza/src/main/java/com/ls/feature_plaza/fragment/plaza/PlazaFragment.java
8. 发现页的模块划分与响应数据拆解
广场页完成以后,继续处理发现页。这个页面的组合结构比广场页更复杂,至少包含搜索栏、分类入口、主题播单和话题广场四块内容,因此必须先把每块区域的布局职责拆清楚。
页面效果如下:
- 顶部通过
EditText和右侧通知图标组成搜索区域。 - 分类模块先做单个分类条目,再交给三列网格的
RecyclerView去平铺。 - 主题播单使用横向
RecyclerView,保持一排可滑动的卡片效果。 - 话题广场不是列表,而是几张图片叠放后的视觉组合。

接口文档如下,发现页的数据入口和广场页分开定义:

把响应体层级展开以后,可以看到发现页的数据天然分成三组:category、anchor、topic。后续页面渲染时,也正是按照这三块分别绑定分类、主题播单和话题广场。

9. 加载发现页数据:从入口类到页面布局的完整链路
9.1 Application 初始化入口
发现页对应的应用入口类保持足够轻量,直接继承 BaseApplication 即可。这样既能复用基础组件初始化逻辑,也为后续单模块独立运行保留入口。
java
public class FindApplication extends BaseApplication {
}
项目内路径:LsxbugVideo/feature_find/src/main/java/com/ls/feature_find/FindApplication.java
9.2 ApiService 与 Provider 封装
和广场页一样,发现页也先从服务提供者开始。FindApiServiceProvider 负责统一持有 FindApiService,避免页面层直接感知 Retrofit 的实例化细节。
java
public class FindApiServiceProvider {
private static FindApiService mApiService;
//单例
public static FindApiService getApiService() {
if (mApiService == null) {
Retrofit retrofit = RetrofitProvider.provide();
mApiService = retrofit.create(FindApiService.class);
}
return mApiService;
}
}
项目内路径:LsxbugVideo/feature_find/src/main/java/com/ls/feature_find/api/FindApiServiceProvider.java
接口定义只保留发现页首页数据的路径和返回值,让请求协议保持集中、稳定:
java
/**
* 这里存放find模块的api
*/
public interface FindApiService {
/**
* 发现首页数据
*
* @return 服务端返回的数据类型
*/
@GET("addons/cms/api.eye/find")
Call<ResBase<ResFind>> getFindData();
}
项目内路径:LsxbugVideo/feature_find/src/main/java/com/ls/feature_find/api/FindApiService.java
9.3 发现页实体类设计
发现页的实体层按接口返回的三块结构分别建模。总实体 ResFind 只负责收纳三组列表,具体字段再交给各自的子实体去描述。
java
public class ResFind {
private List<ResFindCategory> category;//分类数据列表
private List<ResFindAnchor> anchor;//主题播单
private List<ResFindTopic> topic;//话题广场
// get、set
}
项目内路径:LsxbugVideo/feature_find/src/main/java/com/ls/feature_find/bean/ResFind.java
主题播单的数据结构只关心播单 id、标题、类型和封面图,字段保持最小够用即可:
java
public class ResFindAnchor {
/**
* id : 1
* title : 「日系广告」拿捏东亚人的 8 大潜规则
* type : image
* image: url
*/
private int id;
private String title;
private String type;
private String image;
}
项目内路径:LsxbugVideo/feature_find/src/main/java/com/ls/feature_find/bean/ResFindAnchor.java
分类入口的数据需要图标、名称和说明,因此 ResFindCategory 的字段会比播单实体更完整一些:
java
/**
* 分类数据实体类
*/
public class ResFindCategory {
/**
* id : 25
* name : 广告
* image : url
* icon : url
* description : 为广告人的精彩创意点赞
* url : /cms/25.html
* fullurl : url
*/
private int id;//id
private String name;//名字
private String image;//封面图
private String icon;//图标
private String description;//描述
private String url;
private String fullurl;
// get、set
}
项目内路径:LsxbugVideo/feature_find/src/main/java/com/ls/feature_find/bean/ResFindCategory.java
话题广场当前只需要标题和图片,因此实体也保持相对精简:
java
public class ResFindTopic {
/**
* id : 1
* title : 冬日旅行收藏夹
* type : image
* image : url
*/
private int id;
private String title;
private String type;
private String image;
// get、set
}
项目内路径:LsxbugVideo/feature_find/src/main/java/com/ls/feature_find/bean/ResFindTopic.java
9.4 Model 层的数据请求转发
Model 层继续沿用统一的 ApiCall.enqueue。这里的重点是把泛型从广场页替换成 ResFind,并在成功回调里把 result.getData() 继续转给 ViewModel:
- 调用封装好的 ApiCall.enqueue,传入的回调对象,注意修改传递的泛型
ApiCall.ApiCallback<ResBase<ResFind>> - 修改 onSuccess 回调接口参数类型
ResBase<ResFind>
java
public class FindModel {
public FindModel() {
}
/**
* 加载发现页数据
*/
public void loadFindData(IRequestCallback<ResFind> callback) {
FindApiService apiService = FindApiServiceProvider.getApiService();
Call<ResBase<ResFind>> call = apiService.getFindData();
ApiCall.enqueue(call, new ApiCall.ApiCallback<ResBase<ResFind>>() {
@Override
public void onSuccess(ResBase<ResFind> result) {
callback.onLoadFinish(result.getData());
}
@Override
public void onError(int errorCode, String meesage) {
callback.onLoadFailure(errorCode);
}
});
}
}
项目内路径:LsxbugVideo/feature_find/src/main/java/com/ls/feature_find/fragment/find/FindModel.java
9.5 ViewModel 的数据分发
FindViewModel 的职责比广场页更重一些,因为它要同时维护三组列表 mCategory、mAnchor、mTopic。请求成功后,这三组数据分别写进对应的 LiveData,页面层就可以按模块观察、按模块刷新。
实现:
- 继承 BaseViewModel,更新错误状态码;
- 实现
IRequestCallback<ResFind>,方便调用 model 中的方法时,FindViewModel 可以作为回调对象; - 在构造方法中定义好 model
- 写一个 loadFindData 方法,方便 View 调用,方法内部调用 model 的请求方法,将 FindViewModel 作为回调对象;
- 重写
IRequestCallback接口的方法,在 onLoadFinish 解析响应体的对象,将字段赋值到被 LiveData 观测的成员变量中;在 onLoadFailure 中打印错误码日志即可;
java
public class FindViewModel extends BaseViewModel implements IRequestCallback<ResFind> {
private static final String TAG = "FindViewModel";
private final FindModel mModel;
private MutableLiveData<List<ResFindCategory>> mCategory = new MutableLiveData<>();
private MutableLiveData<List<ResFindAnchor>> mAnchor = new MutableLiveData<>();
private MutableLiveData<List<ResFindTopic>> mTopic = new MutableLiveData<>();
public FindViewModel() {
mModel = new FindModel();
}
public void loadFindData() {
mModel.loadFindData(this);
Log.i(TAG, "loadFindData: 请求发现页数据");
}
@Override
public void onLoadFinish(ResFind datas) {
Log.i(TAG, "onLoadFinish:" + datas.getCategory().size());
//获取到数据,更新到mCategory
mCategory.setValue(datas.getCategory());
mAnchor.setValue(datas.getAnchor());
mTopic.setValue(datas.getTopic());
}
@Override
public void onLoadFailure(int errorCode) {
Log.i(TAG, "onLoadFailure: errorCode = " + errorCode);
}
// get
}
项目内路径:LsxbugVideo/feature_find/src/main/java/com/ls/feature_find/fragment/find/FindViewModel.java
9.6 View 层入口绑定
View 层仍然延续 BaseFragment<Binding, ViewModel> 这一套泛型模式。只要把 FindViewModel、页面布局 id 和 DataBinding 对应的 BR.viewModel 返回给基类,页面就已经具备了和 XML 直接绑定的前提。
java
@Route(path = ARouterPath.Find.FRAGMENT_FIND)
public class FindFragment extends BaseFragment<LayoutFragmentFindBinding, FindViewModel> {
@Override
protected FindViewModel getViewModel() {
return new ViewModelProvider(this).get(FindViewModel.class);
}
@Override
protected int getLayoutResId() {
return R.layout.layout_fragment_find;
}
@Override
protected int getBindingVariableId() {
return BR.viewModel;
}
}
项目内路径:LsxbugVideo/feature_find/src/main/java/com/ls/feature_find/fragment/find/FindFragment.java
切到发现页以后,可以直接通过日志确认请求是否已经发出、接口是否已经回包。只要 loadFindData() 和 onLoadFinish() 的日志都能打出来,说明这条请求链路已经连通。

9.7 发现页 XML 结构与 NestedScrollView 处理
发现页的 XML 是整个页面结构的关键。因为页面内部既有整体纵向滚动,又有主题播单的横向滚动,所以这里不能再用普通 ScrollView,而是要交给 NestedScrollView 处理嵌套滑动冲突。页面上半部分先放搜索区域,下半部分再依次摆分类区、主题播单和话题广场。
xml
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<variable
name="viewModel"
type="com.ls.feature_find.fragment.find.FindViewModel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#FBFBFB">
<EditText
android:id="@+id/et_search"
android:layout_width="300dp"
android:layout_height="32dp"
android:layout_marginStart="@dimen/margin_start_14dp"
android:background="@drawable/bg_search"
android:hint="搜索视频,作者,用户及标签"
android:paddingStart="16dp"
android:textColor="@color/black"
android:textSize="@dimen/font_size_11sp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/iv_notify"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginTop="2dp"
android:layout_marginEnd="14dp"
android:src="@mipmap/icon_notify"
app:layout_constraintBottom_toBottomOf="@+id/et_search"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/et_search" />
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/et_search">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/tv_label_category"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
android:layout_marginTop="20dp"
android:text="分类"
android:textColor="@color/black"
android:textSize="16sp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_category"
android:layout_width="match_parent"
android:layout_height="236dp"
android:layout_marginStart="14dp"
android:layout_marginTop="20dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/tv_label_category" />
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/white"
android:paddingBottom="18dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/rv_category">
<TextView
android:id="@+id/tv_label_anchor"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
android:layout_marginTop="18dp"
android:text="主题播单"
android:textColor="@color/black"
android:textSize="16sp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginEnd="@dimen/margin_start_14dp"
android:src="@mipmap/icon_arrow"
app:layout_constraintBottom_toBottomOf="@id/tv_label_anchor"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/tv_label_anchor" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_anchor"
android:layout_width="match_parent"
android:layout_height="200dp"
android:layout_marginStart="14dp"
android:layout_marginTop="16dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/tv_label_anchor" />
<TextView
android:id="@+id/tv_label_topic"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
android:text="话题广场"
android:textColor="@color/black"
android:textSize="16sp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/rv_anchor" />
<ImageView
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginEnd="@dimen/margin_start_14dp"
android:src="@mipmap/icon_arrow"
app:layout_constraintBottom_toBottomOf="@id/tv_label_topic"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/tv_label_topic" />
<ImageView
android:id="@+id/iv_topic1"
android:layout_width="267.3dp"
android:layout_height="142dp"
android:layout_marginStart="14dp"
android:scaleType="centerCrop"
android:layout_marginEnd="14dp"
app:layout_constraintBottom_toBottomOf="@id/iv_topic2"
imageUrl="@{viewModel.topic.get(2).image}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/iv_topic2"
tools:background="#1082ff" />
<ImageView
android:id="@+id/iv_topic2"
android:layout_width="297dp"
android:layout_height="157.5dp"
android:layout_marginStart="14dp"
android:layout_marginEnd="22dp"
android:scaleType="centerCrop"
imageUrl="@{viewModel.topic.get(1).image}"
app:layout_constraintBottom_toBottomOf="@id/iv_topic3"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/iv_topic3"
tools:background="#ff1493" />
<ImageView
android:id="@+id/iv_topic3"
android:layout_width="330dp"
android:layout_height="175dp"
android:layout_marginStart="14dp"
android:layout_marginTop="20dp"
imageUrl="@{viewModel.topic.get(0).image}"
android:scaleType="centerCrop"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/tv_label_topic"
tools:background="#00FFFF" />
<TextView
android:id="@+id/tv_topic"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="12dp"
android:text="@{viewModel.topic.get(0).title}"
android:textColor="#ffffffff"
android:textSize="14sp"
app:layout_constraintStart_toStartOf="@+id/iv_topic3"
app:layout_constraintTop_toTopOf="@+id/iv_topic3" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.core.widget.NestedScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
项目内路径:LsxbugVideo/feature_find/src/main/res/layout/layout_fragment_find.xml
页面整体跑起来以后,可以先确认分类区、播单区和话题广场的大体层级是否已经和设计一致:

话题广场的层叠效果则会集中体现在页面底部:

主题播单条目 item_anchor.xml 负责横向卡片里的封面图和标题。这个布局会直接被横向 RecyclerView 重复复用,因此结构保持尽量简洁。
xml
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tool="http://schemas.android.com/tools">
<data>
<variable
name="data"
type="com.ls.feature_find.bean.ResFindAnchor" />
</data>
<LinearLayout
android:layout_width="146dp"
android:layout_height="wrap_content"
android:layout_marginEnd="12dp"
android:paddingBottom="22dp"
android:gravity="center"
android:orientation="vertical">
<ImageView
android:id="@+id/iv_icon"
imageUrl="@{data.image}"
android:layout_width="146dp"
android:layout_height="138dp"
android:scaleType="centerCrop"
tool:src="@mipmap/icon_notify" />
<TextView
android:id="@+id/tv_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="11dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="11dp"
android:ellipsize="end"
android:maxLines="2"
android:text="@{data.title}"
android:textColor="#ff444444"
android:textSize="11sp"
tool:text="label" />
</LinearLayout>
</layout>
项目内路径:LsxbugVideo/feature_find/src/main/res/layout/item_anchor.xml
分类条目 item_category.xml 则使用 ResFindCategory 作为绑定对象,把图标和名称直接映射到单个分类入口上:
xml
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<data>
<variable
name="data"
type="com.ls.feature_find.bean.ResFindCategory" />
</data>
<LinearLayout
android:layout_width="108dp"
android:layout_marginBottom="8dp"
android:layout_height="32dp"
android:background="@drawable/bg_category"
android:gravity="center">
<ImageView
android:id="@+id/iv_icon"
android:layout_width="20dp"
android:layout_height="20dp"
imageUrl="@{data.icon}"
tools:src="@mipmap/icon_notify" />
<TextView
android:id="@+id/tv_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:textColor="#ff444444"
android:text="@{data.name}"
android:textSize="14sp"
tools:text="广告" />
</LinearLayout>
</layout>
项目内路径:LsxbugVideo/feature_find/src/main/res/layout/item_category.xml
10. 分类标签的数据绑定与三列网格渲染
分类区先从适配器开始。CategoryAdapter 的关键是三步:在 onCreateViewHolder 里把 item_category.xml 转成 ItemCategoryBinding,在 ViewHolder 里保存这个 binding,然后在 onBindViewHolder 里把当前位置的 ResFindCategory 塞进去。
实现:
CategoryAdapter
- 继承
RecyclerView.Adapter<CategoryAdapter.ViewHolder>,指定 ViewHolder 泛型; - 实现 onCreateViewHolder,先从参数 ViewGroup 获取 LayoutInflater 对象,将 LayoutInflater 对象转为 ItemCategoryBinding 对象,返回一个
new ViewHolder(ItemCategoryBinding) - 实现 ViewHolder ,构造方法参数修改为 ItemCategoryBinding,使用
binding.getRoot()参数调用父类构造方法,将 ItemCategoryBinding 设置到成员变量中; - 定义数据源列表
List<ResFindCategory>,实现对数据源的 set 方法; - 实现 onBindViewHolder,获取数据源列表对应 position 的元素;通过 ViewHolder 的 DataBinding ,找到 XML 中声明的 variable name="data" 的,ResFindCategory 类型的 BR;在 XML 中会解析该 BR 对象对应的字段,使用 @{} 赋值到控件的属性中;
java
public class CategoryAdapter extends RecyclerView.Adapter<CategoryAdapter.ViewHolder> {
private List<ResFindCategory> mDatas;
@NonNull
@Override
public CategoryAdapter.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
LayoutInflater inflater = LayoutInflater.from(parent.getContext());
ItemCategoryBinding binding = ItemCategoryBinding.inflate(inflater, parent, false);
return new ViewHolder(binding);
}
@Override
public void onBindViewHolder(@NonNull CategoryAdapter.ViewHolder holder, int position) {
ResFindCategory category = mDatas.get(position);
holder.binding.setData(category);
}
@Override
public int getItemCount() {
return mDatas == null ? 0 : mDatas.size();
}
public void setDatas(List<ResFindCategory> datas) {
this.mDatas = datas;
notifyDataSetChanged();
}
public class ViewHolder extends RecyclerView.ViewHolder {
private final ItemCategoryBinding binding;
public ViewHolder(ItemCategoryBinding binding) {
super(binding.getRoot());
this.binding = binding;
}
}
}
项目内路径:LsxbugVideo/feature_find/src/main/java/com/ls/feature_find/fragment/find/CategoryAdapter.java
Fragment 侧先把分类列表的布局管理器设成三列网格,再把 CategoryAdapter 绑定到 rvCategory 上。这样分类区就具备了把服务端数组按三列平铺展示的能力。
java
private CategoryAdapter mCategoryAdapter;
@Override
protected void initView() {
Log.i(TAG, "initView");
GridLayoutManager layoutManager = new GridLayoutManager(getContext(), 3);
mDataBinding.rvCategory.setLayoutManager(layoutManager);
mCategoryAdapter = new CategoryAdapter();
mDataBinding.rvCategory.setAdapter(mCategoryAdapter);
}
项目内路径:LsxbugVideo/feature_find/src/main/java/com/ls/feature_find/fragment/find/FindFragment.java
数据回来以后,再通过观察者把 mCategory 交给适配器。这里和广场页一样,页面层并不解析数据内容,只负责在 LiveData 变化时触发 UI 更新。
java
@Override
protected void initData() {
mViewModel.loadFindData();//请求数据
mViewModel.getCategory().observe(getViewLifecycleOwner(), new Observer<List<ResFindCategory>>() {
@Override
public void onChanged(List<ResFindCategory> category) {
mCategoryAdapter.setDatas(category);
}
});
}
项目内路径:LsxbugVideo/feature_find/src/main/java/com/ls/feature_find/fragment/find/FindFragment.java
适配器内部真正触发刷新的是 notifyDataSetChanged()。只要 setDatas() 被调用,分类区就会按最新结果重绘。
java
public void setDatas(List<ResFindCategory> datas) {
this.mDatas = datas;
notifyDataSetChanged();
}
项目内路径:LsxbugVideo/feature_find/src/main/java/com/ls/feature_find/fragment/find/CategoryAdapter.java
11. 主题播单与话题广场的数据渲染
11.1 主题播单横向列表与话题广场层叠效果
主题播单的适配器和分类区写法类似,但承载的是横向卡片列表。AnchorAdapter 负责把 ResFindAnchor 绑定到 item_anchor.xml,这样播单标题和封面图都能通过 DataBinding 自动落到布局上。
实现 AnchorAdapter
- 实现
RecyclerView.Adapter<AnchorAdapter.ViewHolder>,注意泛型类型; - onCreateViewHolder 获取布局,通过 ItemAnchorBinding.inflate 将布局转为 ItemAnchorBinding 类型对象,将该对象作为构造函数参数,实例 ViewHolder
java
public class AnchorAdapter extends RecyclerView.Adapter<AnchorAdapter.ViewHolder> {
private List<ResFindAnchor> mDatas;
@NonNull
@Override
public AnchorAdapter.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
LayoutInflater inflater = LayoutInflater.from(parent.getContext());
ItemAnchorBinding binding = ItemAnchorBinding.inflate(inflater, parent, false);
return new AnchorAdapter.ViewHolder(binding);
}
@Override
public void onBindViewHolder(@NonNull AnchorAdapter.ViewHolder holder, int position) {
ResFindAnchor anchor = mDatas.get(position);
holder.binding.setData(anchor);
}
@Override
public int getItemCount() {
return mDatas == null ? 0 : mDatas.size();
}
public void setDatas(List<ResFindAnchor> datas) {
this.mDatas = datas;
notifyDataSetChanged();
}
public class ViewHolder extends RecyclerView.ViewHolder {
private final ItemAnchorBinding binding;
public ViewHolder(ItemAnchorBinding binding) {
super(binding.getRoot());
this.binding = binding;
}
}
}
项目内路径:LsxbugVideo/feature_find/src/main/java/com/ls/feature_find/fragment/find/AnchorAdapter.java
页面层需要继续观察 ViewModel 的 Anchor 字段。一旦播单列表到达,就立即把它交给 AnchorAdapter,由适配器负责刷新横向列表。
java
@Override
protected void initData() {
mViewModel.loadFindData();//请求数据
mViewModel.getCategory().observe(getViewLifecycleOwner(), new Observer<List<ResFindCategory>>() {
@Override
public void onChanged(List<ResFindCategory> category) {
mCategoryAdapter.setDatas(category);
}
});
mViewModel.getAnchor().observe(getViewLifecycleOwner(), new Observer<List<ResFindAnchor>>() {
@Override
public void onChanged(List<ResFindAnchor> anchors) {
mAnchorAdapter.setDatas(anchors);
}
});
}
项目内路径:LsxbugVideo/feature_find/src/main/java/com/ls/feature_find/fragment/find/FindFragment.java
播单区之所以能横向滑动,靠的就是 LinearLayoutManager.HORIZONTAL。这一步必须放在 initView() 里提前完成,否则即使数据到了,RecyclerView 也只会按默认的纵向方式渲染。
java
@Override
protected void initView() {
Log.i(TAG, "initView");
GridLayoutManager layoutManager = new GridLayoutManager(getContext(), 3);
mDataBinding.rvCategory.setLayoutManager(layoutManager);
mCategoryAdapter = new CategoryAdapter();
mDataBinding.rvCategory.setAdapter(mCategoryAdapter);
LinearLayoutManager linearLayoutManager = new LinearLayoutManager(getContext());
linearLayoutManager.setOrientation(LinearLayoutManager.HORIZONTAL);//指定为横向
mDataBinding.rvAnchor.setLayoutManager(linearLayoutManager);
mAnchorAdapter = new AnchorAdapter();
mDataBinding.rvAnchor.setAdapter(mAnchorAdapter);
}
项目内路径:LsxbugVideo/feature_find/src/main/java/com/ls/feature_find/fragment/find/FindFragment.java
重新运行以后,主题播单已经可以横向滑动:

话题广场则不需要额外写适配器,而是直接在 layout_fragment_find.xml 里通过 DataBinding 读取 viewModel.topic。这里的关键不是简单把图片摆上去,而是按照 topic[2]、topic[1]、topic[0] 的顺序设置三张不同尺寸的 ImageView,再用约束关系叠出层次感。
xml
<data>
<variable
name="viewModel"
type="com.ls.feature_find.fragment.find.FindViewModel" />
</data>
<ImageView
android:id="@+id/iv_topic1"
android:layout_width="267.3dp"
android:layout_height="142dp"
android:layout_marginStart="14dp"
android:scaleType="centerCrop"
android:layout_marginEnd="14dp"
app:layout_constraintBottom_toBottomOf="@id/iv_topic2"
imageUrl="@{viewModel.topic.get(2).image}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/iv_topic2"
tools:background="#1082ff" />
<ImageView
android:id="@+id/iv_topic2"
android:layout_width="297dp"
android:layout_height="157.5dp"
android:layout_marginStart="14dp"
android:layout_marginEnd="22dp"
android:scaleType="centerCrop"
imageUrl="@{viewModel.topic.get(1).image}"
app:layout_constraintBottom_toBottomOf="@id/iv_topic3"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/iv_topic3"
tools:background="#ff1493" />
<ImageView
android:id="@+id/iv_topic3"
android:layout_width="330dp"
android:layout_height="175dp"
android:layout_marginStart="14dp"
android:layout_marginTop="20dp"
imageUrl="@{viewModel.topic.get(0).image}"
android:scaleType="centerCrop"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/tv_label_topic"
tools:background="#00FFFF" />
<TextView
android:id="@+id/tv_topic"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="12dp"
android:text="@{viewModel.topic.get(0).title}"
android:textColor="#ffffffff"
android:textSize="14sp"
app:layout_constraintStart_toStartOf="@+id/iv_topic3"
app:layout_constraintTop_toTopOf="@+id/iv_topic3" />
项目内路径:LsxbugVideo/feature_find/src/main/res/layout/layout_fragment_find.xml
为了让三张图片都能完整铺满各自的容器,同时维持叠放后的视觉质量,这里必须给每张图都补 android:scaleType="centerCrop"。否则图片会出现留白或拉伸,层叠效果也会被破坏。
运行效果如下:

12. 相关代码附录
12.1 广场页入口与刷新控制
下面这段是真正落在工程里的 PlazaFragment。它把网格布局、首项跨列、刷新监听、成功回包刷新列表以及错误态结束刷新全部串了起来,是广场页的实际入口代码。
java
public class PlazaFragment extends BaseFragment<LayoutFragmentPlazaBinding, PlazaViewModel> {
private static final String TAG = "PlazaFragment";
private PlazaAdapter mAapter;
@Override
protected PlazaViewModel getViewModel() {
return new ViewModelProvider(this).get(PlazaViewModel.class);
}
@Override
protected int getLayoutResId() {
return R.layout.layout_fragment_plaza;
}
@Override
protected int getBindingVariableId() {
return 0;
}
@Override
protected void initView() {
Log.i(TAG, "initView");
RecyclerView recyclerView = mDataBinding.layoutRecycler.recyclerView;
GridLayoutManager layoutManager = new GridLayoutManager(getContext(), 2);
layoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
@Override
public int getSpanSize(int position) {
if (position == 0) {
return 2;//如果是第一行,那就独占1行
} else {
return 1;//如果是普通的item类型,那就2个item占1行
}
}
});
recyclerView.setLayoutManager(layoutManager);
mAapter = new PlazaAdapter();
recyclerView.setAdapter(mAapter);
SmartRefreshLayout smartRefreshLayout = mDataBinding.layoutRecycler.smartRefreshLayout;
//不需要加载更多
smartRefreshLayout.setEnableLoadMore(false);
//刷新监听
smartRefreshLayout.setOnRefreshListener(new OnRefreshListener() {
@Override
public void onRefresh(@NonNull RefreshLayout refreshLayout) {
mViewModel.requestDatas();//请求数据
}
});
}
@Override
protected void initData() {
mViewModel.getDatas().observe(getViewLifecycleOwner(), new Observer<List<ResPlaza>>() {
@Override
public void onChanged(List<ResPlaza> data) {
if (mDataBinding.layoutRecycler.smartRefreshLayout.isRefreshing()) {
mDataBinding.layoutRecycler.smartRefreshLayout.finishRefresh();
}
//数据请求成功
mAapter.setDatas(data);
}
});
mViewModel.getErrorCode().observe(getViewLifecycleOwner(), new Observer<Integer>() {
@Override
public void onChanged(Integer integer) {
if (mDataBinding.layoutRecycler.smartRefreshLayout.isRefreshing()) {
mDataBinding.layoutRecycler.smartRefreshLayout.finishRefresh();
}
}
});
mViewModel.requestDatas();//请求数据
}
}
项目内路径:LsxbugVideo/feature_plaza/src/main/java/com/ls/feature_plaza/fragment/plaza/PlazaFragment.java
12.2 广场页适配器与 Banner 子布局
PlazaAdapter 是广场页最核心的业务适配器。它同时处理 Banner 和普通图文卡片两种类型,并在内部完成 Banner 数据转换、列表计数和 XBanner 翻页指示器更新。
java
public class PlazaAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
private static final int ITEM_TYPE_BANNER = 1;//banner
private static final int ITEM_TYPE_IMAGE = 2;//常规item类型
private List<ResPlaza.PlazaDetail> mLists;
private ArrayList<PlazaXBannerData> mBannerDatas;//banner数据
@NonNull
@Override
public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext());
if (ITEM_TYPE_BANNER == viewType) {
ItemBannerBinding bannerBinding = ItemBannerBinding.inflate(layoutInflater, parent, false);
BannerViewHolder viewHolder = new BannerViewHolder(bannerBinding);
return viewHolder;
} else {
ItemImageBinding imageBinding = ItemImageBinding.inflate(layoutInflater, parent, false);
ImageViewHolder viewHolder = new ImageViewHolder(imageBinding);
return viewHolder;
}
}
@Override
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
int viewType = getItemViewType(position);
if (viewType == ITEM_TYPE_BANNER) {
//顶部的第一行 banner
BannerViewHolder viewHolder = (BannerViewHolder) holder;
ItemBannerBinding binding = viewHolder.bannerBinding;
//设置占位图
binding.xbanner.setBannerPlaceholderImg(R.mipmap.ic_launcher, ImageView.ScaleType.CENTER_CROP);
//一屏多页
binding.xbanner.setIsClipChildrenMode(true);
//设置banner数据以及自定义banner每页的布局
binding.xbanner.setBannerData(R.layout.item_banner_child, mBannerDatas);
binding.xbanner.loadImage(new XBanner.XBannerAdapter() {
@Override
public void loadBanner(XBanner banner, Object model, View view, int position) {
ImageView imgeView = view.findViewById(R.id.image_wiew);
TextView tvTitle = view.findViewById(R.id.tv_title);
TextView tvLabel = view.findViewById(R.id.tv_label);
PlazaXBannerData data = mBannerDatas.get(position);
GlideUtils.loadImage(data.getXBannerUrl(), imgeView);
tvTitle.setText(data.getXBannerTitle());
tvLabel.setText(data.getDescription());
}
});
binding.xbanner.setOnPageChangeListener(new ViewPager.OnPageChangeListener() {
@Override
public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
}
@Override
public void onPageSelected(int position) {
binding.tvIndicator.setText(String.valueOf(position + 1));
}
@Override
public void onPageScrollStateChanged(int state) {
}
});
} else {
//ITEM_TYPE_IMAGE
ImageViewHolder viewHolder = (ImageViewHolder) holder;
ResPlaza.PlazaDetail detail = mLists.get(position - 1);
viewHolder.binding.setData(detail);
}
}
@Override
public int getItemViewType(int position) {
return position == 0 ? ITEM_TYPE_BANNER : ITEM_TYPE_IMAGE;
}
@Override
public int getItemCount() {
int count = 0;
if (mBannerDatas != null && mBannerDatas.size() > 0) {
count += 1;
}
if (mLists != null && mLists.size() > 0) {
count += mLists.size();
}
return count;
}
public void setDatas(List<ResPlaza> data) {
if (data != null && data.size() >= 2) {
ResPlaza bannerData = data.get(0);
mBannerDatas = converXBannerDatas(bannerData);
ResPlaza imageData = data.get(1);
mLists = imageData.getLists();
//刷新
notifyDataSetChanged();
}
}
}
项目内路径:LsxbugVideo/feature_plaza/src/main/java/com/ls/feature_plaza/adapter/PlazaAdapter.java
Banner 的实际单页样式则落在 item_banner_child.xml。这部分布局决定了图片、标题和标签在单页里的层级关系:
xml
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tool="http://schemas.android.com/tools">
<data>
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/image_wiew"
android:layout_width="match_parent"
android:layout_height="188dp"
android:scaleType="centerCrop"
tool:src="@mipmap/ic_launcher"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/tv_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="14dp"
tool:text="用镜头记录你的第 61秒,获取专属你的畅销书籍"
android:textColor="#ffffffff"
android:textSize="10sp"
app:layout_constraintBottom_toTopOf="@id/tv_label"
app:layout_constraintStart_toStartOf="parent" />
<TextView
android:id="@+id/tv_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="14dp"
tool:text="#赠书活动 | 寻找第 61 秒"
android:textColor="#ffffffff"
android:textSize="13sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="@id/tv_title" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
项目内路径:LsxbugVideo/feature_plaza/src/main/res/layout/item_banner_child.xml
12.3 广场页的数据请求与实体模型
广场页的数据链路主要由 PlazaModel、PlazaViewModel 和 ResPlaza 这三部分组成。前两者负责把网络结果推到页面层,后者负责承接接口返回的轮播和图文卡片字段。
java
public class PlazaModel {
public void requestDatas(IRequestCallback<List<ResPlaza>> callback) {
//获取call
Call<ResBase<List<ResPlaza>>> call = PlazaApiServiceProvider.getApiService().getPlaza();
ApiCall.enqueue(call, new ApiCall.ApiCallback<ResBase<List<ResPlaza>>>() {
@Override
public void onSuccess(ResBase<List<ResPlaza>> result) {
callback.onLoadFinish(result.getData());
}
@Override
public void onError(int errorCode, String meesage) {
callback.onLoadFailure(errorCode);
}
});
}
}
项目内路径:LsxbugVideo/feature_plaza/src/main/java/com/ls/feature_plaza/fragment/plaza/PlazaModel.java
java
public class PlazaViewModel extends BaseViewModel implements IRequestCallback<List<ResPlaza>> {
private final PlazaModel mModel;
private MutableLiveData<List<ResPlaza>> mDatas = new MutableLiveData<>();
public PlazaViewModel() {
mModel = new PlazaModel();
}
/**
* 请求广场的数据
*/
public void requestDatas() {
mModel.requestDatas(this);
}
@Override
public void onLoadFinish(List<ResPlaza> datas) {
mDatas.setValue(datas);
}
@Override
public void onLoadFailure(int errorCode) {
getErrorCode().setValue(errorCode);
}
public MutableLiveData<List<ResPlaza>> getDatas() {
return mDatas;
}
}
项目内路径:LsxbugVideo/feature_plaza/src/main/java/com/ls/feature_plaza/fragment/plaza/PlazaViewModel.java
java
public class ResPlaza {
private String type;
private List<PlazaDetail> lists;
public List<PlazaDetail> getLists() {
return lists;
}
public void setLists(List<PlazaDetail> lists) {
this.lists = lists;
}
public static class PlazaDetail {
private int id;
private String name;
private String image;
private String icon;
private String description;
private String url;
private String fullurl;
private String title;
private String author;
private String avatar;
private String cover;
private List<String> images;
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getImage() {
return image;
}
}
}
项目内路径:LsxbugVideo/feature_plaza/src/main/java/com/ls/feature_plaza/bean/ResPlaza.java
12.4 发现页的数据加载入口
发现页真正参与页面调度的入口代码集中在 FindFragment、FindModel 和 FindViewModel。这三部分分别承担页面绑定、网络请求和数据分发职责。
java
public class FindFragment extends BaseFragment<LayoutFragmentFindBinding, FindViewModel> {
private static final String TAG = "FindFragment";
private CategoryAdapter mCategoryAdapter;
private AnchorAdapter mAnchorAdapter;
@Override
protected FindViewModel getViewModel() {
return new ViewModelProvider(this).get(FindViewModel.class);
}
@Override
protected int getLayoutResId() {
return R.layout.layout_fragment_find;
}
@Override
protected int getBindingVariableId() {
return BR.viewModel;
}
@Override
protected void initView() {
Log.i(TAG, "initView");
GridLayoutManager layoutManager = new GridLayoutManager(getContext(), 3);
mDataBinding.rvCategory.setLayoutManager(layoutManager);
mCategoryAdapter = new CategoryAdapter();
mDataBinding.rvCategory.setAdapter(mCategoryAdapter);
LinearLayoutManager linearLayoutManager = new LinearLayoutManager(getContext());
linearLayoutManager.setOrientation(LinearLayoutManager.HORIZONTAL);//指定为横向
mDataBinding.rvAnchor.setLayoutManager(linearLayoutManager);
mAnchorAdapter = new AnchorAdapter();
mDataBinding.rvAnchor.setAdapter(mAnchorAdapter);
}
}
项目内路径:LsxbugVideo/feature_find/src/main/java/com/ls/feature_find/fragment/find/FindFragment.java
java
public class FindModel {
public FindModel() {
}
/**
* 加载发现页数据
*/
public void loadFindData(IRequestCallback<ResFind> callback) {
FindApiService apiService = FindApiServiceProvider.getApiService();
Call<ResBase<ResFind>> call = apiService.getFindData();
ApiCall.enqueue(call, new ApiCall.ApiCallback<ResBase<ResFind>>() {
@Override
public void onSuccess(ResBase<ResFind> result) {
callback.onLoadFinish(result.getData());
}
@Override
public void onError(int errorCode, String meesage) {
callback.onLoadFailure(errorCode);
}
});
}
}
项目内路径:LsxbugVideo/feature_find/src/main/java/com/ls/feature_find/fragment/find/FindModel.java
java
public class FindViewModel extends BaseViewModel implements IRequestCallback<ResFind> {
private static final String TAG = "FindViewModel";
private final FindModel mModel;
private MutableLiveData<List<ResFindCategory>> mCategory = new MutableLiveData<>();
private MutableLiveData<List<ResFindAnchor>> mAnchor = new MutableLiveData<>();
private MutableLiveData<List<ResFindTopic>> mTopic = new MutableLiveData<>();
public FindViewModel() {
mModel = new FindModel();
}
public void loadFindData() {
mModel.loadFindData(this);
Log.i(TAG, "loadFindData: 请求发现页数据");
}
@Override
public void onLoadFinish(ResFind datas) {
Log.i(TAG, "onLoadFinish:" + datas.getCategory().size());
mCategory.setValue(datas.getCategory());
mAnchor.setValue(datas.getAnchor());
mTopic.setValue(datas.getTopic());
}
}
项目内路径:LsxbugVideo/feature_find/src/main/java/com/ls/feature_find/fragment/find/FindViewModel.java
12.5 发现页适配器与布局片段
最后把发现页里真正负责列表渲染和局部展示的代码集中放在一起,便于对照分类区、播单区和话题区的职责边界。
java
public class CategoryAdapter extends RecyclerView.Adapter<CategoryAdapter.ViewHolder> {
private List<ResFindCategory> mDatas;
@NonNull
@Override
public CategoryAdapter.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
LayoutInflater inflater = LayoutInflater.from(parent.getContext());
ItemCategoryBinding binding = ItemCategoryBinding.inflate(inflater, parent, false);
return new ViewHolder(binding);
}
@Override
public void onBindViewHolder(@NonNull CategoryAdapter.ViewHolder holder, int position) {
ResFindCategory category = mDatas.get(position);
holder.binding.setData(category);
}
@Override
public int getItemCount() {
return mDatas == null ? 0 : mDatas.size();
}
public void setDatas(List<ResFindCategory> datas) {
this.mDatas = datas;
notifyDataSetChanged();
}
}
项目内路径:LsxbugVideo/feature_find/src/main/java/com/ls/feature_find/fragment/find/CategoryAdapter.java
java
public class AnchorAdapter extends RecyclerView.Adapter<AnchorAdapter.ViewHolder> {
private List<ResFindAnchor> mDatas;
@NonNull
@Override
public AnchorAdapter.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
LayoutInflater inflater = LayoutInflater.from(parent.getContext());
ItemAnchorBinding binding = ItemAnchorBinding.inflate(inflater, parent, false);
return new AnchorAdapter.ViewHolder(binding);
}
@Override
public void onBindViewHolder(@NonNull AnchorAdapter.ViewHolder holder, int position) {
ResFindAnchor anchor = mDatas.get(position);
holder.binding.setData(anchor);
}
@Override
public int getItemCount() {
return mDatas == null ? 0 : mDatas.size();
}
public void setDatas(List<ResFindAnchor> datas) {
this.mDatas = datas;
notifyDataSetChanged();
}
}
项目内路径:LsxbugVideo/feature_find/src/main/java/com/ls/feature_find/fragment/find/AnchorAdapter.java
xml
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/et_search">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_category"
android:layout_width="match_parent"
android:layout_height="236dp"
android:layout_marginStart="14dp"
android:layout_marginTop="20dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/tv_label_category" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_anchor"
android:layout_width="match_parent"
android:layout_height="200dp"
android:layout_marginStart="14dp"
android:layout_marginTop="16dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/tv_label_anchor" />
<ImageView
android:id="@+id/iv_topic3"
android:layout_width="330dp"
android:layout_height="175dp"
android:layout_marginStart="14dp"
android:layout_marginTop="20dp"
imageUrl="@{viewModel.topic.get(0).image}"
android:scaleType="centerCrop"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/tv_label_topic"
tools:background="#00FFFF" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.core.widget.NestedScrollView>
项目内路径:LsxbugVideo/feature_find/src/main/res/layout/layout_fragment_find.xml