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

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

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

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

接口里的图片资源通过 http 提供,因此在应用清单里必须先打开明文流量,否则轮播图和列表封面都拿不到资源。这里直接在 application 节点上补 android:usesCleartextTraffic="true",并保留网络权限声明:
<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 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 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 和初始化方法的入口留出来。这样做的目的是先把广场页的页面生命周期挂起来,再逐步把列表和数据补进去。
@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。这一层当前虽然还没放具体业务逻辑,但先复用基类里的错误码通道,后面接网络请求时就能直接沿用。
public class PlazaViewModel extends BaseViewModel{
}
项目内路径:LsxbugVideo/feature_plaza/src/main/java/com/ls/feature_plaza/fragment/plaza/PlazaViewModel.java
BaseViewModel 的职责很简单,就是把通用错误态抽成统一的 LiveData,让不同业务页都能共用这一套异常分发方式:
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 作为后续列表与数据的接入点。
@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 只保留刷新能力,不需要加载更多,因为这一页的数据不是分页返回的。
@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;
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 的统一仓库配置方式:
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 里统一管理。
implementation 'com.github.xiaohaibin:XBanner:androidx_v1.2.6'
项目内路径:LsxbugVideo/feature_plaza/build.gradle
Banner 条目布局 item_banner.xml 负责两部分内容:顶部 XBanner 轮播区,以及下方“了解更多 + 当前页数”的辅助信息区。viewpagerMargin 用来制造页与页之间的留白,pointsVisibility="false" 则关闭默认指示器,改用自定义的右下角数字指示器。

<?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 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 的成员变量:
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 里先返回一个固定值,方便快速看到页面结构是否已经跑通。
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。
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 还是别的业务入口,都从这里拿接口实例。
/**
* 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 选择不同的展示方式。

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 路径和返回值类型,把真正的错误处理留给统一的网络层去做。
/**
* 这里存放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 内部,方便观察整条链路需要哪些入参与返回值:
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 下,统一给所有业务模块复用。
使用泛型,表示请求成功的回调参数类型:
/**
* 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() 交给上层,请求失败则把错误码向上传递。
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 中的错误码的值;
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,一旦结果到达就把数据交给适配器,同时在页面首次进入时主动触发一次请求。
@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 作为网格卡片数据,然后再刷新列表:
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。
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,避免下标越界;
@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 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 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 不单单有一张图片,图片上还有标题、描述等文本,需要再实现一个布局;
@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,方便子布局里的文案展示。
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 列表;
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 条目。
@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 上;
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 时同时检查刷新状态。如果当前正在刷新,就立即关闭动画。这样无论是成功回包还是失败回调,刷新控件都能正确收口。
@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. 加载发现页数据:从入口类到页面布局的完整链路
<h