Android 短视频播放详情页实战:从播放器模块拆分、Media3 播放器接入、详情页主布局与 ViewPager 分页容器搭建,到简介页/评论页/推荐列表联动、FlowHelper 布局组织、自适应高度修正、详情接口请求分发与 OkHttp 调试链路完整落地
前言
短视频详情页看起来只是一个播放器页面,真正落地时却会同时牵出模块边界、播放器接入、分页容器、高度自适应、列表复用和详情数据分发几条实现链路。只要其中一环处理得不稳,页面结构、交互切换和数据展示就会彼此牵连。
本文按完整的工程推进顺序展开,先明确视频播放能力应该落在哪个模块,再搭起详情页的 UI 骨架,接着把简介页、评论页和底部推荐列表串起来,最后收束到详情接口请求和 OkHttp 调试链路。读完以后,可以直接把这一套短视频详情页从结构搭建推进到数据接通。

目录
- [短视频播放详情页实战:从播放器模块拆分、Media3 与 FlowHelper 接入,到 ViewPager 高度适配和详情数据联动](#短视频播放详情页实战:从播放器模块拆分、Media3 与 FlowHelper 接入,到 ViewPager 高度适配和详情数据联动)
- [1. 视频播放模块的职责拆分与运行形态配置](#1. 视频播放模块的职责拆分与运行形态配置)
- [1.1 从公共能力设想到独立业务模块的落点](#1.1 从公共能力设想到独立业务模块的落点)
- [1.2 配置模块依赖与双清单入口](#1.2 配置模块依赖与双清单入口)
- [2. 视频详情页的基础 UI 与分页容器搭建](#2. 视频详情页的基础 UI 与分页容器搭建)
- [2.1 搭起详情页主布局](#2.1 搭起详情页主布局)
- [2.2 先把页面实现链路拆开](#2.2 先把页面实现链路拆开)
- [2.3 接入 Media3 播放依赖](#2.3 接入 Media3 播放依赖)
- [2.4 使用 FlowHelper 组织简介与评论切换](#2.4 使用 FlowHelper 组织简介与评论切换)
- [2.5 完成简介页布局](#2.5 完成简介页布局)
- [2.6 完成评论页布局](#2.6 完成评论页布局)
- [2.7 将首页视频列表能力迁入视频模块](#2.7 将首页视频列表能力迁入视频模块)
- [2.8 在详情页挂载简介页、评论页与 Tab](#2.8 在详情页挂载简介页、评论页与 Tab)
- [3. 在简介页中嵌入视频推荐列表并适配深色样式](#3. 在简介页中嵌入视频推荐列表并适配深色样式)
- [3.1 先把简介页接成可承载子列表的容器](#3.1 先把简介页接成可承载子列表的容器)
- [3.2 传入样式参数,解决深色背景下的文字可读性](#3.2 传入样式参数,解决深色背景下的文字可读性)
- [4. 让简介页与评论页跟随内容自适应高度](#4. 让简介页与评论页跟随内容自适应高度)
- [4.1 在 ViewPager 切换时触发高度重算](#4.1 在 ViewPager 切换时触发高度重算)
- [4.2 预加载方案为什么没有真正解决问题](#4.2 预加载方案为什么没有真正解决问题)
- [4.3 改为延迟重算,等待页面渲染完成](#4.3 改为延迟重算,等待页面渲染完成)
- [5. 修复点击 Tab 切换时的内容缺失](#5. 修复点击 Tab 切换时的内容缺失)
- [5.1 问题根源:点击切换没有触发高度重绘](#5.1 问题根源:点击切换没有触发高度重绘)
- [5.2 调整简介页布局层级,避免列表挤压头部信息](#5.2 调整简介页布局层级,避免列表挤压头部信息)
- [5.3 兼容 BaseFragment 的加载样式并补齐简介页高度修正](#5.3 兼容 BaseFragment 的加载样式并补齐简介页高度修正)
- [6. 拉取视频详情数据并分发到简介页、评论页](#6. 拉取视频详情数据并分发到简介页、评论页)
- [6.1 明确接口入参、响应结构与 token 约束](#6.1 明确接口入参、响应结构与 token 约束)
- [6.2 定义详情接口与实体类](#6.2 定义详情接口与实体类)
- [6.3 在 Model 中封装请求](#6.3 在 Model 中封装请求)
- [6.4 在 ViewModel 中整理详情与评论数据](#6.4 在 ViewModel 中整理详情与评论数据)
- [6.5 在 Activity 中触发请求并分发结果](#6.5 在 Activity 中触发请求并分发结果)
- [7. 在工程中接入 OkHttp 日志拦截器](#7. 在工程中接入 OkHttp 日志拦截器)
- [8. 小结](#8. 小结)
- [9. 相关代码附录](#9. 相关代码附录)
- [9.1 VideoDetailActivity:串起 Tab、ViewPager 与详情分发](#9.1 VideoDetailActivity:串起 Tab、ViewPager 与详情分发)
- [9.2 IntroduceFragment:嵌入推荐列表并延迟修正高度](#9.2 IntroduceFragment:嵌入推荐列表并延迟修正高度)
- [9.3 VideoCommentFragment:评论页高度重算与输入框交互](#9.3 VideoCommentFragment:评论页高度重算与输入框交互)
- [9.4 详情请求链路:MediaApiService、Model 与 ViewModel](#9.4 详情请求链路:MediaApiService、Model 与 ViewModel)
- [9.5 BaseFragment:根据根布局类型挂载加载控件](#9.5 BaseFragment:根据根布局类型挂载加载控件)
1. 视频播放模块的职责拆分与运行形态配置
1.1 从公共能力设想到独立业务模块的落点
要把短视频播放做进现有工程,第一件事不是先写播放器,而是先把这块能力应该落在哪一层定下来。页面最终要同时承载播放器、作者信息、简介与评论切换、底部视频列表,因此模块划分如果一开始就模糊,后面页面、接口和列表能力都会互相牵连。
当前要实现的视频播放页形态如下:

网络能力已经下沉到了 lib_network,按同样的思路,音视频播放能力也完全可以继续下沉到一个公共库中统一维护。这样做的优势是能力集中,公共逻辑容易复用;但代价也很直接,一旦播放器页面的 UI 和交互开始带上明显的业务形态,公共库就会被迫承担越来越多本该属于业务模块的页面职责。

如果不同业务模块拥有各自不同的视频播放页,那么把纯播放器能力放到公共库,再由各模块自己决定页面长相,会是更稳妥的做法。但当前工程里的视频播放页其实已经收敛到了同一套展示结构,没有出现多套完全不同的视频详情页形态,此时继续把页面也塞进公共库,抽象收益并不高。

因此这里最终没有沿着 lib_video_player 的方向继续扩展,而是把播放器相关页面直接提升为一个与其他功能模块平级的 feature_mediaPlayer。这样做的好处是,详情页、评论页、简介页、视频列表页、视频接口和路由常量都能自然聚合在同一个模块内,后续维护不会把业务页面反向压回公共层。

1.2 配置模块依赖与双清单入口
模块职责确定以后,先把 feature_mediaPlayer 自身的运行条件配齐。这里需要同时满足两种场景:
- 作为业务子模块接入整工程时,只声明页面入口即可。
- 作为独立模块单独运行时,还要补上
Application、网络权限和基础主题配置。
先在模块构建脚本里引入公共配置,并声明当前模块需要的基础依赖:
groovy
apply from: "../featureConfig.build.gradle"
android {
namespace 'com.ls.mediaplayer'
defaultConfig {
if (rootProject.ext.isModule) {
applicationId "com.ls.mediaplayer"
}
}
}
dependencies {
implementation libs.appcompat
implementation libs.material
testImplementation libs.junit
androidTestImplementation libs.ext.junit
androidTestImplementation libs.espresso.core
}
项目路径:
LsxbugVideo/feature_mediaPlayer/build.gradle
在被整工程依赖的场景下,清单只保留当前模块需要暴露的页面即可:
xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name=".ui.videodetail.VideoDetailActivity"
android:exported="true" />
</application>
</manifest>
项目路径:
LsxbugVideo/feature_mediaPlayer/src/main/AndroidManifest.xml
如果当前模块需要独立运行,就要把运行时所需的权限、应用入口和屏幕适配元数据一起补上:
xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".MediaPlayerApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.LsxbugVideo"
android:usesCleartextTraffic="true">
<meta-data
android:name="design_width_in_dp"
android:value="375" />
<meta-data
android:name="design_height_in_dp"
android:value="832" />
<activity
android:name=".ui.videodetail.VideoDetailActivity"
android:exported="true" />
</application>
</manifest>
项目路径:
LsxbugVideo/feature_mediaPlayer/src/main/alone/AndroidManifest.xml
模块运行形态由公共的 featureConfig.build.gradle 决定,子模块运行时走 src/main/AndroidManifest.xml,独立运行时切到 src/main/alone/AndroidManifest.xml。这样既不会让业务接入时额外带入独立运行配置,也不会影响模块单独调试。

2. 视频详情页的基础 UI 与分页容器搭建
2.1 搭起详情页主布局
模块入口准备好以后,先把视频详情页的整体结构搭出来。这一页不是简单的播放器容器,它还要承担顶部作者信息、播放器区域、Tab 切换和可变高度的内容区,所以布局骨架必须一次性把这些位置留出来。
这一版布局包含三层关键结构:
- 顶部区域放返回按钮、作者头像和昵称。
- 中间使用
PlayerView承载视频播放区域。 - 播放器下方放
TabVpFlowLayout和ViewPager2,后续让简介页与评论页都挂在这里。
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.mediaplayer.ui.videodetail.VideoDeatilViewModel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/black"
tools:context=".ui.videodetail.VideoDetailActivity">
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/iv_back"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginStart="14dp"
android:layout_marginTop="2dp"
android:elevation="10dp"
android:src="@mipmap/icon_white_back"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/iv_avatar"
android:layout_width="28dp"
android:layout_height="28dp"
android:layout_marginStart="42dp"
android:scaleType="centerCrop"
android:src="@mipmap/icon_default_avatar"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/tv_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_marginTop="6dp"
android:text="nickName"
android:textColor="@color/white"
android:textSize="11sp"
app:layout_constraintStart_toEndOf="@id/iv_avatar"
app:layout_constraintTop_toTopOf="parent" />
<androidx.media3.ui.PlayerView
android:id="@+id/play_view"
android:layout_width="match_parent"
android:layout_height="198dp"
android:layout_marginTop="12dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/iv_avatar" />
<com.zhengsr.tablib.view.flow.TabVpFlowLayout
android:id="@+id/tab_layout"
android:layout_width="wrap_content"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/play_view"
android:textSize="18sp"
app:tab_color="@color/white"
app:tab_type="rect"
app:tab_height="1dp"
app:tab_margin_l="40dp"
app:tab_orientation="horizontal"
app:tab_text_select_color="@color/white"
app:tab_text_unselect_color="@color/white"
android:layout_marginTop="5dp"
android:layout_height="wrap_content"/>
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/view_pager"
android:layout_width="match_parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/tab_layout"
android:layout_height="match_parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.core.widget.NestedScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
项目路径:
LsxbugVideo/feature_mediaPlayer/src/main/res/layout/activity_video_detail.xml
2.2 先把页面实现链路拆开
详情页后面的实现并不是一段代码就能收住,它实际由播放器接入、分页容器、简介页、评论页、底部推荐列表和详情数据分发几条子链路组合而成。先把实现链路拆开,后续每一步的代码归属和调试顺序才会更清楚。

2.3 接入 Media3 播放依赖
播放器区域最终落到 PlayerView 上,因此依赖层需要先把 Media3 的核心播放库和 UI 组件库补齐。这里通过版本目录统一管理版本,再由视频模块显式引入。
toml
[versions]
media3 = "1.0.0"
[libraries]
#一些android官方库
media3ExoPlayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
media3Ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" }
项目路径:
LsxbugVideo/gradle/libs.versions.toml
其中 media3ExoPlayer 负责底层播放能力,media3Ui 负责直接可用的播放器视图。把这两个依赖放进版本目录以后,再在 feature_mediaPlayer 中接入即可。
javascript
dependencies {
implementation libs.appcompat
implementation libs.material
implementation libs.activity
implementation libs.constraintlayout
implementation libs.media3ExoPlayer//media3播放库
implementation libs.media3Ui//media3ui组件库
implementation libs.flowHelper//指示器库
testImplementation libs.junit
androidTestImplementation libs.ext.junit
androidTestImplementation libs.espresso.core
implementation project(":library_base")
annotationProcessor libs.arouterCompiler
}
项目路径:
LsxbugVideo/feature_mediaPlayer/build.gradle
2.4 使用 FlowHelper 组织简介与评论切换
播放器下方的简介与评论区域并不是简单的两个按钮切换,它需要和 ViewPager2 保持同步,因此这里直接接入FlowHelper 来承担 Tab 指示器能力。这样可以把页面切换和指示条状态统一交给同一套组件处理。

接入这个库之前,仓库源里要先补上 maven { url "https://jitpack.io" } :
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
再把版本号和坐标放进版本目录,避免后续硬编码依赖:
toml
[versions]
flowHelper = "v2.3"
[libraries]
#一些第三方库
flowHelper = { group = "com.github.LillteZheng", name = "FlowHelper", version.ref = "flowHelper" }
项目路径:
LsxbugVideo/gradle/libs.versions.toml
视频模块中只需要补这一条依赖即可:
javascript
implementation libs.flowHelper//指示器库
项目路径:
LsxbugVideo/feature_mediaPlayer/build.gradle
布局层要同时放入 TabVpFlowLayout 和 ViewPager2,前者负责标题与指示样式,后者负责实际承载两个分页内容:
xml
<com.zhengsr.tablib.view.flow.TabVpFlowLayout
android:id="@+id/tab_layout"
android:layout_width="wrap_content"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/play_view"
android:textSize="18sp"
app:tab_color="@color/white"
app:tab_type="rect"
app:tab_height="1dp"
app:tab_margin_l="40dp"
app:tab_orientation="horizontal" />
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/view_pager"
android:layout_width="match_parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/tab_layout"
android:layout_height="match_parent"/>
项目路径:
LsxbugVideo/feature_mediaPlayer/src/main/res/layout/activity_video_detail.xml
接入以后,播放器下方就有了一个明确的分页容器,后续只需要把简介页和评论页塞进 ViewPager2 即可,而 Tab 栏切换页面,底部白线由 FlowHelper 实现。

这里的 ViewPager2 会承载简介页和评论页,而简介页底部还要继续挂一个视频列表页,作为详情页里的推荐视频区域。也就是说,这个页面不是单层分页,而是"详情页 -> 简介页 -> 推荐列表"的嵌套结构。

2.5 完成简介页布局
简介页本身除了标题、频道、描述和底部操作区,还要在最底部留出一个 FragmentContainerView,后续把推荐视频列表挂进来。因此这一页不是纯静态文案页,而是一个既展示详情信息、又能继续承载子 Fragment 的容器页。
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.mediaplayer.ui.introduce.IntroduceViewModel" />
</data>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".ui.introduce.IntroduceFragment">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/tv_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="15dp"
android:layout_marginTop="24dp"
android:text="@{viewModel.archivesInfo.title}"
android:textColor="#ffffffff"
android:textSize="14sp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/tv_channel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="15dp"
android:layout_marginTop="7dp"
android:text="@{viewModel.channel}"
android:textColor="#ffffffff"
android:textSize="11sp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/tv_title" />
<TextView
android:id="@+id/tv_description"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="15dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="15dp"
android:gravity="start"
android:text="@{viewModel.archivesInfo.description}"
android:textColor="#ffffffff"
android:textSize="13sp"
app:layout_constrainedWidth="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/tv_channel" />
<ImageView
android:id="@+id/iv_like"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginStart="32dp"
android:layout_marginTop="24dp"
android:drawableStart="@drawable/icon_likes"
android:onClick="@{()->viewModel.onLikeClick()}"
android:src="@{viewModel.isLikes?@drawable/icon_yet_likes:@drawable/icon_likes}"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/tv_description" />
<TextView
android:id="@+id/tv_like"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="3dp"
android:text="@{viewModel.archivesInfo.strLikes}"
android:textColor="#ffffffff"
android:textSize="11sp"
app:layout_constraintEnd_toEndOf="@id/iv_like"
app:layout_constraintStart_toStartOf="@id/iv_like"
app:layout_constraintTop_toBottomOf="@id/iv_like" />
<!-- <CheckBox-->
<!-- android:id="@+id/iv_collection"-->
<!-- android:layout_width="20dp"-->
<!-- android:layout_height="20dp"-->
<!-- android:layout_marginStart="129.5dp"-->
<!-- android:layout_marginTop="24dp"-->
<!-- android:button="@null"-->
<!-- android:checked="@={viewModel.isCollection}"-->
<!-- android:drawableStart="@drawable/icon_collection"-->
<!-- app:layout_constraintStart_toStartOf="parent"-->
<!-- app:layout_constraintTop_toBottomOf="@id/tv_description" />-->
<ImageView
android:id="@+id/iv_collection"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginStart="129.5dp"
android:layout_marginTop="24dp"
android:onClick="@{()->viewModel.onCollectionClick()}"
android:src="@{viewModel.isCollection?@drawable/icon_yet_collection:@drawable/icon_collection}"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/tv_description" />
<TextView
android:id="@+id/tv_collection"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="3dp"
android:text="@{viewModel.archivesInfo.strCollection}"
android:textColor="#ffffffff"
android:textSize="11sp"
app:layout_constraintEnd_toEndOf="@id/iv_collection"
app:layout_constraintStart_toStartOf="@id/iv_collection"
app:layout_constraintTop_toBottomOf="@id/iv_collection" />
<ImageView
android:id="@+id/iv_comments"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginStart="226.5dp"
android:layout_marginTop="24dp"
android:src="@mipmap/icon_comments"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/tv_description" />
<TextView
android:id="@+id/tv_comment"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="3dp"
android:text="@{viewModel.archivesInfo.strComment}"
android:textColor="#ffffffff"
android:textSize="11sp"
app:layout_constraintEnd_toEndOf="@id/iv_comments"
app:layout_constraintStart_toStartOf="@id/iv_comments"
app:layout_constraintTop_toBottomOf="@id/iv_comments" />
<ImageView
android:id="@+id/iv_arrow"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginStart="323dp"
android:layout_marginTop="24dp"
android:src="@mipmap/icon_report"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/tv_description" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="3dp"
android:text="@{viewModel.archivesInfo.strViews}"
android:textColor="#ffffffff"
android:textSize="11sp"
app:layout_constraintEnd_toEndOf="@id/iv_arrow"
app:layout_constraintStart_toStartOf="@id/iv_arrow"
app:layout_constraintTop_toBottomOf="@id/iv_arrow" />
<View
android:id="@+id/barrier"
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="43dp"
android:background="#33ffffff"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/iv_arrow" />
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.fragment.app.FragmentContainerView
android:id="@+id/fcv"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="24dp" />
</LinearLayout>
</layout>
项目路径:
LsxbugVideo/feature_mediaPlayer/src/main/res/layout/fragment_introduce.xml
2.6 完成评论页布局
评论页要解决的是另一类问题:上半部分需要可刷新列表,下半部分需要固定的评论输入区。因此这里用 SmartRefreshLayout 包住 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>
<variable
name="viewModel"
type="com.ls.mediaplayer.ui.videodetail.VideoDeatilViewModel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/black"
android:fitsSystemWindows="false">
<!-- Title TextView -->
<TextView
android:id="@+id/tv_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:text="最热评论"
android:textColor="#ffffffff"
android:textSize="14sp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<!-- SmartRefreshLayout and RecyclerView -->
<com.scwang.smart.refresh.layout.SmartRefreshLayout
android:id="@+id/smart_refresh_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="17dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/tv_title">
<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="wrap_content"
android:minHeight="440dp" />
<com.scwang.smart.refresh.footer.BallPulseFooter
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</com.scwang.smart.refresh.layout.SmartRefreshLayout>
<!-- Comment Section (cl_comment) -->
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/cl_comment"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#ff313131"
android:paddingTop="22dp"
android:paddingBottom="22dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent">
<!-- EditText for Comment -->
<EditText
android:id="@+id/et_chat"
android:layout_width="172dp"
android:layout_height="32dp"
android:layout_marginStart="16dp"
android:background="@drawable/bg_edittext"
android:gravity="center|start"
android:hint="聊一聊..."
android:imeOptions="actionSend"
android:inputType="text"
android:paddingStart="16dp"
android:textColor="@color/white"
android:textColorHint="@color/white"
android:textSize="11sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<!-- Likes Icon and Text -->
<ImageView
android:id="@+id/iv_likes"
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginStart="28dp"
android:src="@drawable/icon_likes"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@id/et_chat"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/tv_likes"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:text="0"
android:textColor="#ffffffff"
android:textSize="11sp"
app:layout_constraintBottom_toBottomOf="@id/iv_likes"
app:layout_constraintStart_toEndOf="@id/iv_likes"
app:layout_constraintTop_toTopOf="@id/iv_likes" />
<!-- Collection Icon and Text -->
<ImageView
android:id="@+id/iv_collection"
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginStart="86.5dp"
android:src="@drawable/icon_collection"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@id/et_chat"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/tv_collection"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:text="0"
android:textColor="#ffffffff"
android:textSize="11sp"
app:layout_constraintBottom_toBottomOf="@id/iv_collection"
app:layout_constraintStart_toEndOf="@id/iv_collection"
app:layout_constraintTop_toTopOf="@id/iv_collection" />
<!-- Comments Icon and Text -->
<!-- <ImageView-->
<!-- android:id="@+id/iv_comments"-->
<!-- android:layout_width="16dp"-->
<!-- android:layout_height="16dp"-->
<!-- android:layout_marginStart="145dp"-->
<!-- android:src="@mipmap/icon_comments"-->
<!-- app:layout_constraintBottom_toBottomOf="parent"-->
<!-- app:layout_constraintStart_toEndOf="@id/et_chat"-->
<!-- app:layout_constraintTop_toTopOf="parent" />-->
<!-- <TextView-->
<!-- android:id="@+id/tv_comment"-->
<!-- android:layout_width="wrap_content"-->
<!-- android:layout_height="wrap_content"-->
<!-- android:layout_marginStart="4dp"-->
<!-- android:text="0"-->
<!-- android:textColor="#ffffffff"-->
<!-- android:textSize="11sp"-->
<!-- app:layout_constraintBottom_toBottomOf="@id/iv_comments"-->
<!-- app:layout_constraintStart_toEndOf="@id/iv_comments"-->
<!-- app:layout_constraintTop_toTopOf="@id/iv_comments" />-->
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
项目路径:
LsxbugVideo/feature_mediaPlayer/src/main/res/layout/fragment_video_comment.xml
2.7 将首页视频列表能力迁入视频模块
简介页底部还要再挂一个视频列表页,这意味着视频列表不再只属于首页。如果它继续留在 feature_home,那么视频详情页想复用这块能力时,就会出现业务模块反向依赖首页模块的问题,结构会越来越别扭。
因为简介页底部要承载一个列表区域,所以先在布局中预留出 FragmentContainerView:

随后把原来留在首页模块 feature_home 中的视频列表相关文件,整体迁入 feature_mediaPlayer,让所有和视频相关的页面、实体、接口与适配器都落在同一处维护。

迁移完成以后,页面结构就变成了"视频详情页 -> 简介页 -> 视频列表页"的组合关系:

原来放在首页模块里的分页常量只对首页视频列表有效,一旦列表页迁出,这些配置也必须跟着下沉,否则新的列表路由拿不到正确的类型参数。
java
/**
* 用于存放home模块的一些配置
*/
public class HomeConfig {
public static final String KEY_VIDEO_LIST_TYPE = "KEY_VIDEO_LIST_TYPE";
// 视频列表-推荐页
public static final int VIDEO_LIST_FRAGMENT_RECOMMEND = 0;
// 视频列表-日报页
public static final int VIDEO_LIST_FRAGMENT_DAILY = 1;
}
项目路径:
LsxbugVideo/feature_home/src/main/java/com/ls/feature_home/config/HomeConfig.java
这些常量最终统一移动到 library_base 的路由配置里,让首页模块和视频模块都能通过同一套键和值协作:
java
public class ARouterPath {
// ....
public static class Video {
private static final String VIDEO = "/video";
public static final String ACTIVITY_VIDEODETAIL = VIDEO+"/VideoDetailActivity";
//视频详情页接收视频id的key
public static final String KEY_VIDEO_ID = "KEY_VIDEO_ID";
public static final String FRAGMENT_COMMENT = VIDEO+"/VideoCommentFragment";
public static final String FRAGMENT_INTRODUCE = VIDEO+"/IntroduceFragment";
public static final String FRAGMENT_VIDEO_LIST = VIDEO + "/videoListFragment";
public static final String KEY_VIDEO_LIST_TYPE = "KEY_VIDEO_LIST_TYPE";
//如果style为true,表示需要把列表页的item文本颜色改成白色
public static final String KEY_VIDEO_LIST_STYLE = "KEY_VIDEO_LIST_STYLE";
//视频列表-推荐页
public static final int VIDEO_LIST_FRAGMENT_RECOMMEND = 0;
//视频列表-日报页
public static final int VIDEO_LIST_FRAGMENT_DAILY = 1;
}
}
项目路径:
LsxbugVideo/library_base/src/main/java/com/ls/libbase/config/ARouterPath.java
路由常量迁移以后,首页页签再去拿推荐页和日报页时,就不需要直接依赖视频列表的具体类型,只要通过 ARouter 获取 Fragment 实例即可。这样首页依然能正常工作,同时也不会把视频列表实现重新绑回首页模块。
java
private Fragment mRecommendFragment;
@Override
protected void initView() {
StatusBarUtils.addStatusBarHeight2RootView(mDataBinding.getRoot());
Log.i(TAG, "initView");
mRecommendFragment = (Fragment) ARouter.getInstance()
.build(ARouterPath.Video.FRAGMENT_VIDEO_LIST)
.withInt(ARouterPath.Video.KEY_VIDEO_LIST_TYPE, ARouterPath.Video.VIDEO_LIST_FRAGMENT_RECOMMEND)
.navigation();
Fragment dailyFragment = (Fragment) ARouter.getInstance()
.build(ARouterPath.Video.FRAGMENT_VIDEO_LIST)
.withInt(ARouterPath.Video.KEY_VIDEO_LIST_TYPE, ARouterPath.Video.VIDEO_LIST_FRAGMENT_DAILY)
.navigation();
// ....
}
项目路径:
LsxbugVideo/feature_home/src/main/java/com/ls/feature_home/HomeFragment.java
实体类也要一并迁移,否则视频模块内的列表、详情和接口就还会继续引用首页模块下的数据对象;
将 feature_home 中,bean 的 ResVideo 实体类逻辑,移到 feature_mediaPlayer 的 bean 中:
java
public class ResVideo {
private int id;
private String title;
private String image;
private String video_file;
private String description;
private String duration;
private int user_id;
private String author;
private String avatar;
// get、set
}
项目路径:
LsxbugVideo/feature_mediaPlayer/src/main/java/com/ls/mediaplayer/bean/ResVideo.java
列表 item 布局里的数据绑定类型也要同步改到新包名,否则布局编译仍然会指向旧模块:
xml
<data>
<variable
name="video"
type="com.ls.mediaplayer.bean.ResVideo" />
<variable
name="white"
type="Boolean" />
</data>
项目路径:
LsxbugVideo/feature_mediaPlayer/src/main/res/layout/item_video.xml
除了列表页、适配器和实体类,相关图片资源以及视频接口 HomeApiService 也要一起移动。这样做的目的只有一个:所有跟视频播放相关的 UI、数据和交互链路都放到 feature_mediaPlayer 内,避免页面已经迁走,接口和资源却仍然散落在别的模块中。
2.8 在详情页挂载简介页、评论页与 Tab
列表能力就位以后,再回到视频详情页本身。这里需要把 VideoDetailActivity 作为总入口,统一负责三件事:
- 初始化自身的
ViewModel和数据绑定。 - 通过
ARouter组装简介页与评论页。 - 把
TabVpFlowLayout和ViewPager2关联起来。
java
@Route(path = ARouterPath.Video.ACTIVITY_VIDEODETAIL)
public class VideoDetailActivity extends BaseActivity<ActivityVideoDetailBinding, VideoDeatilViewModel> {
private static final String TAG = "VideoDetailActivity";
@Autowired(name = ARouterPath.Video.KEY_VIDEO_ID)
public int mVideoId;
private IntroduceFragment mIntroduceFragment;
private VideoCommentFragment mCommentFragment;
@Override
protected VideoDeatilViewModel getViewModel() {
return new ViewModelProvider(this).get(VideoDeatilViewModel.class);
}
@Override
protected int getLayoutResId() {
return R.layout.activity_video_detail;
}
@Override
protected int getBindingVariableId() {
return BR.viewModel;
}
@Override
protected void initView() {
StatusBarUtils.addStatusBarHeight2RootView(mDataBinding.getRoot());
initViewPager();
initTab();
}
}
项目路径:
LsxbugVideo/feature_mediaPlayer/src/main/java/com/ls/mediaplayer/ui/videodetail/VideoDetailActivity.java
initViewPager() 的作用是把两个子页面和 ViewPager2 串起来:
- 这里没有直接在 XML 中静态声明 Fragment,而是通过
ARouter动态拿到实例; - 再交给
FragmentStateAdapter统一管理,后续 Tab 切换和高度适配都会在这条链路上继续展开。
java
private void initViewPager() {
mIntroduceFragment = (IntroduceFragment) ARouter.getInstance()
.build(ARouterPath.Video.FRAGMENT_INTRODUCE)
.navigation();
mCommentFragment = (VideoCommentFragment) ARouter.getInstance()
.build(ARouterPath.Video.FRAGMENT_COMMENT)
.navigation();
ArrayList<Fragment> fragments = new ArrayList<>();
fragments.add(mIntroduceFragment);
fragments.add(mCommentFragment);
mDataBinding.viewPager.setAdapter(new FragmentStateAdapter(this) {
@NonNull
@Override
public Fragment createFragment(int position) {
return fragments.get(position);
}
@Override
public int getItemCount() {
return fragments == null ? 0 : fragments.size();
}
});
}
项目路径:
LsxbugVideo/feature_mediaPlayer/src/main/java/com/ls/mediaplayer/ui/videodetail/VideoDetailActivity.java
视频列表页和详情页都会依赖新的媒体接口,因此接口提供器也顺势迁到视频模块中,由 MediaApiServiceProvider 统一产出 MediaApiService 实例:
java
public class MediaApiServiceProvider {
private static MediaApiService mApiService;
//单例
public static MediaApiService getApiService() {
if (mApiService == null) {
Retrofit retrofit = RetrofitProvider.provide();
mApiService = retrofit.create(MediaApiService.class);
}
return mApiService;
}
}
项目路径:
LsxbugVideo/feature_mediaPlayer/src/main/java/com/ls/mediaplayer/api/MediaApiServiceProvider.java
VideoListModel 的服务引用也要改成 MediaApiServiceProvider。这样视频列表和视频详情使用的都是同一个视频模块接口入口,不再依赖首页模块下的旧服务。
Tab 这边则只负责把标题和 ViewPager2 做绑定。标题列表顺序必须和 Fragment 列表完全一致,否则点击和滑动时就会出现标题与页面错位。
java
@Override
protected void initView() {
StatusBarUtils.addStatusBarHeight2RootView(mDataBinding.getRoot());
initViewPager();
initTab();
}
private void initTab() {
mDataBinding.tabLayout.setViewPager(mDataBinding.viewPager);
ArrayList titles = new ArrayList();
titles.add("简介");
titles.add("评论");
mDataBinding.tabLayout.setAdapter(new TabFlowAdapter(titles));
}
项目路径:
LsxbugVideo/feature_mediaPlayer/src/main/java/com/ls/mediaplayer/ui/videodetail/VideoDetailActivity.java
3. 在简介页中嵌入视频推荐列表并适配深色样式
3.1 先把简介页接成可承载子列表的容器
当详情页的两层分页结构搭好以后,下一步就是把底部推荐视频真正塞进简介页。这样做的目标很明确:详情页里不仅要播放当前视频,还要在简介下方继续承接推荐内容,把浏览链路延长下去。

先把 IntroduceFragment 本身继承到 BaseFragment,把绑定类和 ViewModel 准备好,这样它后面既能接收 Activity 分发下来的视频详情数据,也能在自己的布局中继续挂子列表页。
java
@Route(path = ARouterPath.Video.FRAGMENT_INTRODUCE)
public class IntroduceFragment extends BaseFragment<FragmentIntroduceBinding, IntroduceViewModel> {
private static final String TAG = "IntroduceFragment";
@Override
protected IntroduceViewModel getViewModel() {
return new ViewModelProvider(this).get(IntroduceViewModel.class);
}
@Override
protected int getLayoutResId() {
return R.layout.fragment_introduce;
}
@Override
protected int getBindingVariableId() {
return BR.viewModel;
}
@Override
protected void initView() {
}
@Override
protected void initData() {
}
}
项目路径:
LsxbugVideo/feature_mediaPlayer/src/main/java/com/ls/mediaplayer/ui/introduce/IntroduceFragment.java
真正承载推荐列表页面 VideoListFragment 的位置,就是简介页底部这个 FragmentContainerView:
xml
<androidx.fragment.app.FragmentContainerView
android:id="@+id/fcv"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="24dp" />
项目路径:
LsxbugVideo/feature_mediaPlayer/src/main/res/layout/fragment_introduce.xml

有了容器以后,就可以在 IntroduceFragment 内部通过子 FragmentManager 把视频列表页挂进来。这里的处理顺序是固定的:
- 通过 ARouter ,获取到视频列表页 VideoListFragment 的实例对象;
- 传递的页面类型 KEY_VIDEO_LIST_TYPE ,为推荐页 FRAGMENT_RECOMMEND,表示请求日报页的接口数据;
- 当前已经处于 Fragment 下,获取 ChildFragmentManager 布局管理器,开启事务,将 FragmentContainerView、VideoListFragment 对象进行关联,提交事务;
java
@Override
protected void initView() {
VideoListFragment fragment = (VideoListFragment) ARouter.getInstance().build(ARouterPath.Video.FRAGMENT_VIDEO_LIST)
.withInt(ARouterPath.Video.KEY_VIDEO_LIST_TYPE, ARouterPath.Video.VIDEO_LIST_FRAGMENT_RECOMMEND)
.navigation();
getChildFragmentManager().beginTransaction().add(mDataBinding.fcv.getId(), fragment).commit();
}
项目路径:
LsxbugVideo/feature_mediaPlayer/src/main/java/com/ls/mediaplayer/ui/introduce/IntroduceFragment.java
这样处理以后,推荐视频列表就会直接出现在简介内容下方:

不过列表一接进来,新的显示问题也马上出现了。简介页整体背景是黑色,而原来的首页视频列表 item 默认按浅色背景设计;
item 中的作者昵称和描述文本放到黑底上以后,可读性会立刻下降。

3.2 传入样式参数,解决深色背景下的文字可读性
列表本身已经可以复用,但它在首页和详情页中的背景色并不相同,因此不能直接把一套固定文本颜色,硬套到所有场景里。更稳妥的做法,是在路由参数里再带一个样式开关,让同一套列表 item 根据上下文决定文字颜色。
先在跳转到视频列表页时补一个 KEY_VIDEO_LIST_STYLE 参数,表示当前列表需要按深色背景样式渲染:
java
@Override
protected void initView() {
VideoListFragment fragment = (VideoListFragment) ARouter.getInstance().build(ARouterPath.Video.FRAGMENT_VIDEO_LIST)
.withInt(ARouterPath.Video.KEY_VIDEO_LIST_TYPE, ARouterPath.Video.VIDEO_LIST_FRAGMENT_RECOMMEND)
.withBoolean(ARouterPath.Video.KEY_VIDEO_LIST_STYLE, true)
.navigation();
getChildFragmentManager().beginTransaction().add(mDataBinding.fcv.getId(), fragment).commit();
}
项目路径:
LsxbugVideo/feature_mediaPlayer/src/main/java/com/ls/mediaplayer/ui/introduce/IntroduceFragment.java
这个开关的键统一定义在 ARouterPath.Video 里,保证调用方和接收方使用的是同一套常量:
java
//如果style为true,表示需要把列表页的item文本颜色改成白色
public static final String KEY_VIDEO_LIST_STYLE = "KEY_VIDEO_LIST_STYLE";
视频列表页接收这个布尔参数以后,就可以在适配器初始化阶段决定要不要切换成白色文字:
java
@Autowired(name = ARouterPath.Video.KEY_VIDEO_LIST_STYLE)
public boolean mStyle;//是否需要把item中的文本改成白色
适配器创建时,如果 mStyle 为 true,就把白色样式状态传下去:
java
@Override
protected RecyclerView.Adapter getAdapter() {
mAdapter = new VideoAdapter(this);
if (mStyle) {
mAdapter.setItemWhite(true);
}
return mAdapter;
}
项目路径:
LsxbugVideo/feature_mediaPlayer/src/main/java/com/ls/mediaplayer/ui/videolist/VideoListFragment.java
VideoAdapter 再把这个状态通过数据绑定分发到具体 item 上,让布局自己决定应该使用哪一套颜色资源:
- 通过 setItemWhite() 设置 mItemWhite 具体值,表示是否要把字体改成白色
- 在 onBindViewHolder 中,
binding.setWhite(mItemWhite)到 item 布局对应的控件中,设置属性;
java
private boolean mItemWhite;//是否要把字体改成白色
public void setItemWhite(boolean b) {
mItemWhite = b;
}
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
ResVideo video = mVideos.get(position);
holder.binding.setVideo(video);
holder.binding.setWhite(mItemWhite);
holder.binding.executePendingBindings();//实时更新数据
// GlideUtils.loadImage(video.getImage(), holder.binding.ivBackground);
// GlideUtils.loadCircleImage(video.getAvatar(), holder.binding.ivAvatar);
}
项目路径:
LsxbugVideo/feature_mediaPlayer/src/main/java/com/ls/mediaplayer/adapter/VideoAdapter.java
布局层只需要声明额外的 white 变量,再把标题和时长颜色改成表达式即可:
xml
<data>
<variable
name="video"
type="com.ls.mediaplayer.bean.ResVideo" />
<variable
name="white"
type="Boolean" />
</data>
android:textColor="@{white?@color/white:@color/black}"
android:textColor="@{white?@color/white:@color/ff444444}"
项目路径:
LsxbugVideo/feature_mediaPlayer/src/main/res/layout/item_video.xml
像 #ff444444 这样的颜色值也最好抽成资源,这样表达式里使用的都是稳定资源名,而不是分散在布局中的硬编码颜色:
xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ff444444">#ff444444</color>
</resources>
项目路径:
LsxbugVideo/feature_mediaPlayer/src/main/res/values/colors.xml
这样一来,同一套视频列表 item 就可以同时适配首页白底和详情页黑底,不需要为不同场景重复维护两份列表布局。
4. 让简介页与评论页跟随内容自适应高度
简介页和评论页都接到 ViewPager2 以后,新的问题开始暴露出来:两个页面的内容高度完全不同,但 ViewPager2 在默认情况下并不会主动替每个页面单独维护高度。结果就是,哪个页面先被测量,另一个页面就容易继承它的高度。
所以,在视频详情页 VideoDetailActivity 中,通过 TabVpFlowLayout 的 ViewPager ,接入简介 IntroduceFragment、评论 VideoCommentFragment ;
在 IntroduceFragment 底部,接入 VideoListFragment 视频列表页,此时 TabVpFlowLayout 的页面高度,会被 VideoListFragment 撑起来,导致 VideoCommentFragment 在完全没有评论内容时,页面高度跟随 VideoListFragment 的高度撑了起来;

下来,我们需要实现 TabVpFlowLayout 接入的两个页面,跟随内容自适应高度,而不会因为其中一个页面的高度,影响另一个页面;
fragment_video_comment.xml 布局文件代码,在上文已经给出,我们再来简单分析其页面结构:

所以,评论页布局在前面已经给出,这里再把页面结构拿出来,是为了看清楚它为什么会和简介页的高度产生冲突:评论页自身内容很短,但它被放进 ViewPager2 以后,外层高度却经常沿用了简介页的测量结果。
4.1 在 ViewPager 切换时触发高度重算
要解决这个问题,第一反应通常都是在页面切换时重新计算当前页高度。于是这里先在 ViewPager2 的页切换回调里,根据位置分别通知简介页和评论页执行高度刷新。
java
private void initViewPager() {
// .....
mDataBinding.viewPager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() {
@Override
public void onPageSelected(int position) {
super.onPageSelected(position);
//在切换fragment的时候动态计算对应fragment的高度
if (position == 0) {
mIntroduceFragment.updateFragmentHeight();
} else if (position == 1) {
mCommentFragment.updateFragmentHeight();
}
}
});
}
项目路径:
LsxbugVideo/feature_mediaPlayer/src/main/java/com/ls/mediaplayer/ui/videodetail/VideoDetailActivity.java
简介页和评论页最初都采用最直接的方案:拿到根布局以后调用 requestLayout(),把重新测量请求向父布局一路向上传递。
在 IntroduceFragment 中,实现 updateFragmentHeight():
java
public void updateFragmentHeight() {
Log.i(TAG, "updateFragmentHeight: " + mDataBinding.getRoot().getHeight());
mDataBinding.getRoot().requestLayout();
}
在 VideoCommentFragment 中,实现 updateFragmentHeight():
java
public void updateFragmentHeight() {
Log.i(TAG, "updateFragmentHeight: " + mDataBinding.getRoot().getHeight());
mDataBinding.getRoot().requestLayout();
}
这两个页面的 updateFragmentHeight() 方法,实现原理:
- 获取当前页面的根布局 mDataBinding.getRoot()
- 根布局调用 requestLayout() ,作用是追溯到相关布局的最外层,重新计算高度;
- 就 IntroduceFragment 举例子,IntroduceFragment 根布局调用 requestLayout(),会追溯到 VideoDetailActivity 的最外层布局 ConstraintLayout,然后重新计算这个最外层相关布局的高度
这段代码的思路没有问题:根布局发起 requestLayout() 以后,会把重新计算尺寸和位置的请求,一路传递到外层相关布局,理论上 ViewPager2 也应该重新测量当前页面。

但一切并没有这么顺利。当前代码会出现空指针问题,原因是:
- 在 ViewPager 中,默认显示 position = 0 的页面,也就是当前简介页面 IntroduceFragment,但是 position = 1 的页面,也就是 VideoCommentFragment,因为没有切换 ViewPager ,所以是没有渲染出来的;
- 在 VideoCommentFragment 中,根据数据绑定,获取根布局,因为没有渲染,导致根布局为空,此时再调用 requestLayout(),就会出现空指针异常;

也就是说,首次进入详情页时,默认只渲染 position = 0 的简介页,评论页并没有真正完成视图创建。此时一旦切到评论页就去拿它的根布局,空指针问题会立刻出现。
4.2 预加载方案为什么没有真正解决问题
面对未渲染页面为空的问题,最直接的补救方式是让 ViewPager2 预先把两个页面都加载出来。这样一来,评论页在第一次切换之前就已经有了根布局,高度重算时就不会再因为 null 崩掉。
所以需要在 VideoDetailActivity ,对 ViewPager 进行设定预缓存,预缓存的页面数量,等于 ViewPager 的长度:
java
private void initViewPager() {
// ....
mDataBinding.viewPager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() {
@Override
public void onPageSelected(int position) {
super.onPageSelected(position);
//在切换fragment的时候动态计算对应fragment的高度
if (position == 0) {
mIntroduceFragment.updateFragmentHeight();
} else if (position == 1) {
mCommentFragment.updateFragmentHeight();
}
}
});
//指定viewpager预加载的页面数量
mDataBinding.viewPager.setOffscreenPageLimit(fragments.size());
}
项目路径:
LsxbugVideo/feature_mediaPlayer/src/main/java/com/ls/mediaplayer/ui/videodetail/VideoDetailActivity.java
重新运行以后,空指针确实消失了,但真正的问题并没有解决:评论页高度依然会被简介页撑起来。

控制台日志说明高度重算逻辑其实已经被触发了:

说明已经计算了高度,但是评论页高度,哪怕内容为空的情况下,高度也和 IntroduceFragment 的高度相同;
问题出在测量结果本身。预加载以后,两个页面的根布局虽然都存在了,但外层相关布局的高度,仍然会优先参考第一个页面。也就是说,这个方案只能解决"拿不到根布局"的问题,解决不了"拿到的高度仍然不对"的问题。
既然预加载反而把测量基准进一步固定到了第一个页面上,那就没有必要继续保留这段逻辑。
java
//指定viewpager预加载的页面数量
// mDataBinding.viewPager.setOffscreenPageLimit(fragments.size());
4.3 改为延迟重算,等待页面渲染完成
接下来真正要解决的是另一个时机问题:切页时目标页面还没渲染完,太早拿根布局会空,太早重算高度会沿用旧值。因此不能在切页回调里立刻 requestLayout(),而是要等目标页面完成一次实际渲染,再去触发重算。
简介页此时先保留直接刷新,评论页则改成延迟执行:
- 在 IntroduceFragment 中,实现 updateFragmentHeight():
java
public void updateFragmentHeight() {
mDataBinding.getRoot().requestLayout();
}
- 在 VideoCommentFragment 中,实现 updateFragmentHeight():
java
public void updateFragmentHeight() {
new Handler().postDelayed(() -> {
Log.i(TAG, "updateFragmentHeight: " + mDataBinding.getRoot().getHeight());
//重新计算当前根布局的所有父布局(所有相关布局)的大小和位置
mDataBinding.getRoot().requestLayout();
}, 500);
new Handler().postDelayed(() -> {
Log.i(TAG, "updateFragmentHeight: " + mDataBinding.getRoot().getHeight());
}, 1500);
}
项目路径:
LsxbugVideo/feature_mediaPlayer/src/main/java/com/ls/mediaplayer/ui/comment/VideoCommentFragment.java
这里延迟的意义很明确:
- 第一个
postDelayed(500)等待评论页根布局真正准备好,再发起高度重算。 - 第二个
postDelayed(1500)用来观察重算前后的高度变化,确认最终高度有没有落到评论页本身。
调整以后,从简介页切到评论页时就不会再因为根布局为空崩溃,同时评论页的高度也能在渲染完成后再被重新测量。

从日志可以看出,延迟 0.5 秒时拿到的还是初始高度,等重新请求布局并继续渲染以后,再往后观察就能拿到评论页实际内容对应的高度,这才是当前页面真正需要的测量结果。
5. 修复点击 Tab 切换时的内容缺失
5.1 问题根源:点击切换没有触发高度重绘
高度适配处理到这里,左右滑动 ViewPager2 已经基本正常了:

但只要把切换方式:换成 点击 Tab,问题又会重新出现。先从简介页点到评论页,再从评论页点回简介页,简介页顶部内容会直接丢失。

这类问题不是简介页布局本身突然坏掉,而是第三方 TabVpFlowLayout 在点击切换时,没有像手势滑动那样,把页面高度重绘链路完整触发出来。控件本身的配置如下:
xml
<com.zhengsr.tablib.view.flow.TabVpFlowLayout
android:id="@+id/tab_layout"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/play_view"
android:textSize="18sp"
app:tab_color="@color/white"
app:tab_type="rect"
app:tab_height="1dp"
app:tab_margin_l="40dp"
app:tab_orientation="horizontal"
app:tab_text_select_color="@color/white"
app:tab_text_unselect_color="@color/white"
android:layout_marginTop="5dp" />
项目路径:
LsxbugVideo/feature_mediaPlayer/src/main/res/layout/activity_video_detail.xml
这个问题可以直接在库的仓库里提 issue 反馈:

通过点击切换和滑动切换,我们大概能看到不同的页面切换现象:
- 滑动切换时,页面会出现一次明显的闪动,说明触发了重新测量和重绘。
- 点击切换时,页面几乎没有重绘迹象,切换后的高度直接沿用前一页的高度。
因为点击回到简介页时,外层高度仍然停留在评论页的高度,简介页可用空间瞬间缩小。
简介页底部挂着一个 match_parent 的视频列表页,它会优先把父容器占满,最终把顶部作者信息和按钮区域整个挤掉。

导致视频列表页马上占满简介页,视频列表页上面的内容就被挤压掉了:

5.2 调整简介页布局层级,避免列表挤压头部信息
既然根因是列表和头部信息,还处在同一个约束层级里,最直接的缓冲做法,就是先把视频列表从这一层约束关系里拆出来。这样即使点击切换没有及时纠正高度,列表也不会马上把顶部信息挤压掉。
这里的处理方式,是在外层增加一层 LinearLayout,让上半部分作者信息、标题和操作区继续待在 ConstraintLayout 中,视频列表页,则作为线性布局里的第二块内容单独摆放。

布局层级一改,新的兼容问题又出现了,出现布局转换错误,线性布局无法转为约束布局:

问题不在简介页自身,而在 BaseFragment。基类初始化加载样式时,默认把根布局当成 ConstraintLayout 处理,新的简介页根布局已经换成 LinearLayout,自然就没法再强转。

因此这里要先把基类的加载控件挂载逻辑,做成"按根布局类型判断"。
只有根布局确实是 ConstraintLayout 时,才去创建对应的约束参数并把 ProgressBar 加进来。
java
/**
* 初始化加载样式
*/
private void initProgressBar() {
mProgressBar = new ProgressBar(getContext());
if (mDataBinding.getRoot() instanceof ConstraintLayout) {
ConstraintLayout.LayoutParams layoutParams = new ConstraintLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
layoutParams.startToStart = ConstraintLayout.LayoutParams.PARENT_ID;
layoutParams.topToTop = ConstraintLayout.LayoutParams.PARENT_ID;
layoutParams.endToEnd = ConstraintLayout.LayoutParams.PARENT_ID;
layoutParams.bottomToBottom = ConstraintLayout.LayoutParams.PARENT_ID;
mProgressBar.setLayoutParams(layoutParams);
mProgressBar.setVisibility(View.GONE);//默认不可见
ConstraintLayout constraintLayout = (ConstraintLayout) mDataBinding.getRoot();
constraintLayout.addView(mProgressBar);
}
}
项目路径:
LsxbugVideo/library_base/src/main/java/com/ls/libbase/base/BaseFragment.java
如果根布局是 LinearLayout,后续完全可以按线性布局参数再补一套处理;第一步先去掉错误的强转,保证新布局层级能够正常运行。
重新运行程序,此时再次通过点击,切换 ViewPager 页面,哪怕因为 TabVpFlowLayout 点击,不会进行页面高度重定向,也不会出现作者信息和图标,被视频列表页面挤压掉的问题:

但这只是止损,不是最终修复。因为简介页高度仍然沿用了评论页的高度,列表区域虽然不再挤压头部信息,却也拿不到足够的滚动空间,整个屏幕无法继续随列表内容完整向下滑动。

5.3 兼容 BaseFragment 的加载样式并补齐简介页高度修正
要把这个问题彻底收住,简介页和评论页就必须使用同一套高度修正策略。
也就是说,不能只在评论页切换时延迟重算,简介页在从评论页切回来的场景里,也要等自身内容渲染完成后重新请求布局。
java
public void updateFragmentHeight() {
new Handler().postDelayed(() -> {
Log.i(TAG, "updateFragmentHeight: " + mDataBinding.getRoot().getHeight());
//重新计算当前根布局的所有父布局(所有相关布局)的大小和位置
mDataBinding.getRoot().requestLayout();
}, 500);
}
项目路径:
LsxbugVideo/feature_mediaPlayer/src/main/java/com/ls/mediaplayer/ui/introduce/IntroduceFragment.java
这样处理以后,点击切换回简介页时,页面不会再吃到评论页遗留下来的错误高度;简介页在 0.5 秒后会按自身内容重新测量,顶部信息、底部推荐列表和整页滚动能力也都会恢复正常。
6. 拉取视频详情数据并分发到简介页、评论页
6.1 明确接口入参、响应结构与 token 约束
页面结构稳定以后,接下来才轮到数据。视频详情页接口并不是只传一个视频 id 就够了,它还要求在请求头中携带用户 token,这样服务端才能返回当前用户针对该视频的点赞、收藏等状态字段。

视频 id 则来自列表页点击 item 时的页面跳转参数:

接口返回的数据不仅包含视频详情,还带上了评论列表、频道信息和当前用户状态字段,因此 Activity 后面可以一次请求,把简介页和评论页的基本数据都分发出去。
json
{
"code": 1,
"msg": "",
"time": "1741077035",
"data": {
"archivesInfo": {
"id": 129,
"user_id": 1,
"channel_id": 25,
"channel_ids": "",
"model_id": 1,
"special_ids": "",
"title": "三得利插画感广告,满屏都是治愈色",
"flag": "",
"style": "",
"image": "http://ali-img.kaiyanapp.com/cover/20230417/20cbaf4f63cdaf864e132771de812c24.jpg?imageMogr2/auto-orient/thumbnail/640x/interlace/1/quality/80/format/webp",
"images": "",
"video_file": "http://t-cdn.kaiyanapp.com/1681829377291_8fd4cbcd.mp4",
"seotitle": "",
"keywords": "",
"description": "日本三得利创意广告,插画感十足,满屏的治愈色彩都要溢出来了。这支短片将真人与动画相结合,清新治愈的日系色彩,温暖而又美好,简直太有创意了。From サントリー公式チャンネル「SUNTORY」",
"tags": "",
"price": "0.00",
"outlink": "",
"views": 44,
"comments": 1,
"likes": 6,
"dislikes": 0,
"collection": 2,
"diyname": "",
"isguest": 1,
"iscomment": 1,
"createtime": 1732527854,
"updatetime": 1732527854,
"publishtime": null,
"memo": "",
"duration": "01:50",
"content": "",
"islike": 0,
"iscollection": 1,
"user": {
"id": 1,
"nickname": "admin",
"avatar": "https://titok.fzqq.fun/uploads/20240826/50d42d478612bb3f289dd6258caa046b.jpeg",
"bio": "",
"url": "/u/1"
},
"channel": {
"id": 25,
"parent_id": 24,
"name": "广告",
"image": "https://ali-img.kaiyanapp.com/23d1a1dce9756535d314aed3cf9777a0.jpeg?image_process=image/auto-orient",
"diyname": "guanggao",
"items": 3,
"url": "/cms/guanggao.html",
"fullurl": "https://titok.fzqq.fun/cms/guanggao.html"
},
"url": "/cms/guanggao/129.html",
"fullurl": "https://titok.fzqq.fun/cms/guanggao/129.html",
"likeratio": "100",
"taglist": [],
"create_date": "3月前",
"ispaid": true
},
"commentList": [
{
"id": 19,
"user_id": 11,
"pid": 0,
"content": "fhbvf哈哈哈哈",
"comments": 0,
"createtime": 1739797279,
"user": {
"id": 11,
"nickname": "186****6510",
"avatar": "https://titok.fzqq.fun/uploads/20240826/50d42d478612bb3f289dd6258caa046b.jpeg",
"bio": "",
"email": "",
"url": "/u/11"
},
"create_date": "2周前"
}
],
"__token__": "2824059acfff258e30127a4b91b88f4e"
}
}
项目路径:
LsxbugVideo/feature_mediaPlayer/src/main/java/com/ls/mediaplayer/bean/ResVideoDetail.java
这里还有一个很容易忽略的细节:如果调试时没有在请求头里带上 token,服务端返回的数据虽然不至于完全失败,但点赞和收藏状态就无法按登录用户的真实状态返回。

此时返回结果里的点赞、收藏状态会退化成默认值 0:

所以这个接口在客户端侧一定不能只看 id 参数,token 也是视频详情状态正确与否的必要条件。
6.2 定义详情接口与实体类
确定好请求方式以后,先把接口签名补进 MediaApiService。
这里使用 @Header("token") 传登录态,使用 @Query("id") 传视频 id,这样调用方只要把两个关键参数准备好即可。
java
/**
* 获取视频详情
*
* @param id 视频id
* @param token 用户token
*/
@GET("addons/cms/api.archives/detail")
Call<ResBase<ResVideoDetail>> getVideoDetail(@Header("token") String token, @Query("id") int id);
项目路径:
LsxbugVideo/feature_mediaPlayer/src/main/java/com/ls/mediaplayer/api/MediaApiService.java
响应实体类里至少要把详情页当前会用到的字段全部接住,包括 archivesInfo、评论列表以及点赞、收藏、频道、用户等子结构。这样 ViewModel 在收到数据以后,就能直接把简介页和评论页需要的内容拆分出去。
java
/**
* 视频详情
*/
public class ResVideoDetail {
private ArchivesInfoBean archivesInfo;
private List<ResComment> commentList;
// get、set
public static class ArchivesInfoBean {
private int id;
private int user_id;
private int channel_id;
private String channel_ids;
private int model_id;
private String special_ids;
private String title;
private String flag;
private String style;
private String image;
private String images;
private String video_file;
private String seotitle;
private String keywords;
private String description;
private String tags;
private String price;
private String outlink;
private int views;
private int comments;
private int likes;
private int islike;
private int iscollection;
private int dislikes;
private int collection;
private String diyname;
private int isguest;
private int iscomment;
private int createtime;
private int updatetime;
private String publishtime;
private String memo;
private String duration;
private String content;
private UserInfo user;
private ResChannel channel;
private String url;
private String fullurl;
private String likeratio;
private String create_date;
private boolean ispaid;
private List<?> taglist;
}
}
项目路径:
LsxbugVideo/feature_mediaPlayer/src/main/java/com/ls/mediaplayer/bean/ResVideoDetail.java
6.3 在 Model 中封装请求
Model 层做的事情并不复杂,但职责必须清晰:
- 接收外部传入的视频 id。
- 从
UserManager取出当前用户 token。 - 调用
MediaApiService发起详情请求。 - 将 token、视频 id 作为 apiService 中,getVideoDetail() 接口的参数,得到
Call<ResBase<ResVideoDetail>>对象。 - 通过统一回调把结果回传给上层。
java
public class VideoDeatilModel {
/**
* 获取视频详情
* @param videoId
* @param callback
*/
public void requestDetail(int videoId, IRequestCallback<ResVideoDetail> callback){
MediaApiService apiService = MediaApiServiceProvider.getApiService();
String token = UserManager.getInstance().getToken();
Call<ResBase<ResVideoDetail>> call = apiService.getVideoDetail(token, videoId);
ApiCall.enqueue(call, new ApiCall.ApiCallback<ResBase<ResVideoDetail>>() {
@Override
public void onSuccess(ResBase<ResVideoDetail> result) {
callback.onLoadFinish(result.getData());
}
@Override
public void onError(int errorCode, String meesage) {
callback.onLoadFailure(errorCode,meesage);
}
});
}
}
项目路径:
LsxbugVideo/feature_mediaPlayer/src/main/java/com/ls/mediaplayer/ui/videodetail/VideoDeatilModel.java
这里把 token 的获取放在 Model 层,而不是让 Activity 手动拼请求参数,目的是把"发起视频详情请求"这件事尽量收束成一个稳定动作。上层只关心当前要请求哪个视频,不需要关心请求头如何拼装。
6.4 在 ViewModel 中整理详情与评论数据
到了 ViewModel 这一层,重点不再是发请求,而是把接口返回的混合数据拆成页面真正需要的两份状态:
- 一份是视频详情本身,供简介页和播放器使用。
- 一份是评论列表,供评论页使用。
- 设置 MutableLiveData 类型的参数:视频信息、评论列表;
- ViewModel 的 getVideoDetail() 需要外界传递视频 id,在调用 model 的 getVideoDetail() 接口时,视频 id 作为参数;同时需要传递接口回调对象;
- 重写接口回调对象的方法,在成功回调中,获取返回的视频数据,如视频详情信息 ArchivesInfo,视频评论列表 CommentList,设置到 MutableLiveData 成员变量中
java
public class VideoDeatilViewModel extends BaseViewModel {
private final VideoDeatilModel mModel;
//视频详情数据
private MutableLiveData<ResVideoDetail.ArchivesInfoBean> mVideoInfo = new MutableLiveData<>();
//评论数据
private MutableLiveData<List<ResComment>> mComments = new MutableLiveData<>();
// get
public VideoDeatilViewModel() {
mModel = new VideoDeatilModel();
}
public void reqeustDetail(int videoId){
showLoading(true);
mModel.requestDetail(videoId, new IRequestCallback<ResVideoDetail>() {
@Override
public void onLoadFinish(ResVideoDetail datas) {
showLoading(false);
ResVideoDetail.ArchivesInfoBean archivesInfo = datas.getArchivesInfo();
List<ResComment> commentList = datas.getCommentList();
//更新数据
mVideoInfo.setValue(archivesInfo);
mComments.setValue(commentList);
}
@Override
public void onLoadFailure(int errorCode, String message) {
showLoading(false);
showToast(message);
}
});
}
}
项目路径:
LsxbugVideo/feature_mediaPlayer/src/main/java/com/ls/mediaplayer/ui/videodetail/VideoDeatilViewModel.java
当前这条链路直接复用了视频详情接口返回的评论列表,因此可以一次请求同时填充两个分页区域。实际工程里,如果评论量较大,更合适的做法还是走独立的评论分页接口,这样既能控制数据量,也能更容易做上拉加载。

6.5 在 Activity 中触发请求并分发结果
VideoDetailActivity 是当前页面的数据汇聚点,因此请求入口也放在这里。它需要完成四个连续动作:
- 通过 ARouter 注入的方式,获取从主页到视频详情页,传递的视频 id;
- 调用
ViewModel,将 id 作为参数,发起详情请求。 - 通过观察者,观察视频详情详情信息,分发到视频简介页渲染;
- 通过观察者,观察视频评论列表信息,分发到视频评论页渲染;
- 在观察到视频详情信息时,还要在 Activity 播放该视频(服务端返回的视频 URL 不为空)
java
@Autowired(name = ARouterPath.Video.KEY_VIDEO_ID)
public int mVideoId;
@Override
protected void initData() {
Log.i(TAG, "initData: mVideoId = " + mVideoId);
mViewModel.reqeustDetail(mVideoId);
mViewModel.getVideoInfo().observe(this, archivesInfo -> {
//通知需要使用到这个数据的fragment
mIntroduceFragment.updateVideoInfo(archivesInfo);
//发起播放
});
mViewModel.getComments().observe(this, resComments -> {
mCommentFragment.updateComments(resComments);
});
}
项目路径:
LsxbugVideo/feature_mediaPlayer/src/main/java/com/ls/mediaplayer/ui/videodetail/VideoDetailActivity.java
这样 Activity 就把"请求一次 -> 分发两处"的链路打通了。
简介页后面只要消费 archivesInfo 渲染标题、频道、描述和点赞收藏状态,评论页只要消费评论列表更新 RecyclerView 即可,职责边界会比较清楚。
7. 在工程中接入 OkHttp 日志拦截器
详情接口接通以后,调试效率会直接影响排查速度。单纯依赖调试工具确认接口可用还不够,客户端也需要能看到自己真正发出了什么请求、拿回了什么响应,因此把 OkHttp 日志拦截器接进网络层是非常有必要的一步。
先把 logging-interceptor 的版本和坐标放进版本目录:
toml
[versions]
okhttp = "4.12.0"
[libraries]
okhttpInterceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
项目路径:
LsxbugVideo/gradle/libs.versions.toml
再在统一的 OkhttpClientProvider 中创建拦截器,并挂到 OkHttpClient.Builder 上。这样整个工程只要走这一个 Provider,日志能力就会自动生效。
java
/**
* 项目中的OkHttpClient统一在这里获取,以便统一管理
*/
public class OkhttpClientProvider {
public static OkHttpClient provide() {
//日志拦截器
HttpLoggingInterceptor interceptor = new HttpLoggingInterceptor();
// 设置拦截等级
interceptor.setLevel(HttpLoggingInterceptor.Level.BODY);
OkHttpClient build = new OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.addInterceptor(interceptor)
.build();
return build;
}
}
项目路径:
LsxbugVideo/network/src/main/java/com/ls/network/OkhttpClientProvider.java
拦截器等级可以按需要调整,开发阶段通常直接开到 BODY,这样请求头、请求体和响应体都能完整看到。

日志接通以后,接口是否带了 token、入参是否正确、服务端到底返回了什么内容,都可以直接在客户端日志里核对,排查效率会比盲猜快得多。

8. 小结
这套短视频详情页的实现并不是单点功能,而是一整条工程链路:先把视频能力从首页模块和公共设想中抽出来,归到独立的视频模块;再用 Media3 和 FlowHelper 搭起播放器区与分页容器;随后把简介页、评论页和底部推荐列表串成嵌套结构;最后补齐高度适配、详情请求和调试日志。这样处理以后,页面结构、数据分发和调试入口都落到了同一条清晰路径上,后续继续补播放器控制、评论发送和点赞收藏逻辑时,扩展成本会低很多。
9. 相关代码附录
9.1 VideoDetailActivity:串起 Tab、ViewPager 与详情分发
java
@Route(path = ARouterPath.Video.ACTIVITY_VIDEODETAIL)
public class VideoDetailActivity extends BaseActivity<ActivityVideoDetailBinding, VideoDeatilViewModel> {
@Autowired(name = ARouterPath.Video.KEY_VIDEO_ID)
public int mVideoId;
private IntroduceFragment mIntroduceFragment;
private VideoCommentFragment mCommentFragment;
@Override
protected VideoDeatilViewModel getViewModel() {
return new ViewModelProvider(this).get(VideoDeatilViewModel.class);
}
@Override
protected void initView() {
StatusBarUtils.addStatusBarHeight2RootView(mDataBinding.getRoot());
initViewPager();
initTab();
}
private void initTab() {
mDataBinding.tabLayout.setViewPager(mDataBinding.viewPager);
ArrayList titles = new ArrayList();
titles.add("简介");
titles.add("评论");
mDataBinding.tabLayout.setAdapter(new TabFlowAdapter(titles));
}
private void initViewPager() {
mIntroduceFragment = (IntroduceFragment) ARouter.getInstance()
.build(ARouterPath.Video.FRAGMENT_INTRODUCE)
.navigation();
mCommentFragment = (VideoCommentFragment) ARouter.getInstance()
.build(ARouterPath.Video.FRAGMENT_COMMENT)
.navigation();
ArrayList<Fragment> fragments = new ArrayList<>();
fragments.add(mIntroduceFragment);
fragments.add(mCommentFragment);
mDataBinding.viewPager.setAdapter(new FragmentStateAdapter(this) {
@NonNull
@Override
public Fragment createFragment(int position) {
return fragments.get(position);
}
@Override
public int getItemCount() {
return fragments == null ? 0 : fragments.size();
}
});
mDataBinding.viewPager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() {
@Override
public void onPageSelected(int position) {
super.onPageSelected(position);
if (position == 0) {
mIntroduceFragment.updateFragmentHeight();
} else if (position == 1) {
mCommentFragment.updateFragmentHeight();
}
}
});
}
@Override
protected void initData() {
mViewModel.reqeustDetail(mVideoId);
mViewModel.getVideoInfo().observe(this, archivesInfo -> {
mIntroduceFragment.updateVideoInfo(archivesInfo);
// 发起播放
});
mViewModel.getComments().observe(this, resComments -> {
mCommentFragment.updateComments(resComments);
});
}
}
项目路径:
LsxbugVideo/feature_mediaPlayer/src/main/java/com/ls/mediaplayer/ui/videodetail/VideoDetailActivity.java
9.2 IntroduceFragment:嵌入推荐列表并延迟修正高度
java
@Route(path = ARouterPath.Video.FRAGMENT_INTRODUCE)
public class IntroduceFragment extends BaseFragment<FragmentIntroduceBinding, IntroduceViewModel> {
private CommentReportPopupWindow mPopupWindow;
@Override
protected IntroduceViewModel getViewModel() {
return new ViewModelProvider(this).get(IntroduceViewModel.class);
}
@Override
protected void initView() {
VideoListFragment fragment = (VideoListFragment) ARouter.getInstance()
.build(ARouterPath.Video.FRAGMENT_VIDEO_LIST)
.withInt(ARouterPath.Video.KEY_VIDEO_LIST_TYPE, ARouterPath.Video.VIDEO_LIST_FRAGMENT_RECOMMEND)
.withBoolean(ARouterPath.Video.KEY_VIDEO_LIST_STYLE, true)
.navigation();
mDataBinding.ivComments.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
showBottomPopupwindow();
}
});
getChildFragmentManager().beginTransaction().add(mDataBinding.fcv.getId(), fragment).commit();
}
private void showBottomPopupwindow() {
if (mPopupWindow == null) {
AppCompatActivity activity = (AppCompatActivity) getActivity();
mPopupWindow = new CommentReportPopupWindow(activity);
mPopupWindow.setOnPopupInteractionListener(new CommentReportPopupWindow.OnPopupInteractionListener() {
@Override
public void onLikeClicked() {
mViewModel.onLikeClick();
}
@Override
public void onCollectionClicked() {
mViewModel.onCollectionClick();
}
@Override
public void onSendMessage(String message) {
mViewModel.sendComment(message);
}
});
}
mPopupWindow.showPopup(mDataBinding.getRoot());
}
public void updateFragmentHeight() {
new Handler().postDelayed(() -> {
mDataBinding.getRoot().requestLayout();
}, 500);
}
public void updateVideoInfo(ResVideoDetail.ArchivesInfoBean archivesInfo) {
mViewModel.updateArchivesInfo(archivesInfo);
}
}
项目路径:
LsxbugVideo/feature_mediaPlayer/src/main/java/com/ls/mediaplayer/ui/introduce/IntroduceFragment.java
9.3 VideoCommentFragment:评论页高度重算与输入框交互
java
@Route(path = ARouterPath.Video.FRAGMENT_COMMENT)
public class VideoCommentFragment extends BaseFragment<FragmentVideoCommentBinding, VideoDeatilViewModel> {
private ConmentAdapter mAdapter;
@Override
protected VideoDeatilViewModel getViewModel() {
return new ViewModelProvider(requireActivity()).get(VideoDeatilViewModel.class);
}
@Override
protected void initView() {
mDataBinding.recyclerView.setLayoutManager(new LinearLayoutManager(getContext()));
mAdapter = new ConmentAdapter();
mDataBinding.recyclerView.setAdapter(mAdapter);
mDataBinding.etChat.setOnEditorActionListener(new TextView.OnEditorActionListener() {
@Override
public boolean onEditorAction(TextView v, int actionId, KeyEvent event) {
if (actionId == EditorInfo.IME_ACTION_SEND) {
String text = mDataBinding.etChat.getText().toString().trim();
if (text != null && text.length() > 0) {
mViewModel.showToast("发送内容:" + text);
mDataBinding.etChat.getText().clear();
}
return true;
} else {
return false;
}
}
});
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
view.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
@Override
public void onGlobalLayout() {
Rect r = new Rect();
view.getWindowVisibleDisplayFrame(r);
int screenHeight = view.getRootView().getHeight();
int keypadHeight = screenHeight - r.bottom;
if (keypadHeight > screenHeight * 0.15) {
int navigationBarHeight = 0;
int resourceId = getResources().getIdentifier("navigation_bar_height", "dimen", "android");
if (resourceId > 0) {
navigationBarHeight = getResources().getDimensionPixelSize(resourceId);
}
mDataBinding.clComment.setTranslationY(-(keypadHeight - navigationBarHeight));
} else {
mDataBinding.clComment.setTranslationY(0);
}
}
});
}
public void updateFragmentHeight() {
new Handler().postDelayed(() -> {
mDataBinding.getRoot().requestLayout();
}, 500);
}
}
项目路径:
LsxbugVideo/feature_mediaPlayer/src/main/java/com/ls/mediaplayer/ui/comment/VideoCommentFragment.java
9.4 详情请求链路:MediaApiService、Model 与 ViewModel
java
public interface MediaApiService {
@GET("addons/cms/api.archives/detail")
Call<ResBase<ResVideoDetail>> getVideoDetail(@Header("token") String token, @Query("id") int id);
}
项目路径:
LsxbugVideo/feature_mediaPlayer/src/main/java/com/ls/mediaplayer/api/MediaApiService.java
接口签名确定以后,请求参数封装和回调桥接放在 Model 层完成:
java
public class VideoDeatilModel {
public void requestDetail(int videoId, IRequestCallback<ResVideoDetail> callback) {
MediaApiService apiService = MediaApiServiceProvider.getApiService();
String token = UserManager.getInstance().getToken();
Call<ResBase<ResVideoDetail>> call = apiService.getVideoDetail(token, videoId);
ApiCall.enqueue(call, new ApiCall.ApiCallback<ResBase<ResVideoDetail>>() {
@Override
public void onSuccess(ResBase<ResVideoDetail> result) {
callback.onLoadFinish(result.getData());
}
@Override
public void onError(int errorCode, String meesage) {
callback.onLoadFailure(errorCode, meesage);
}
});
}
}
项目路径:
LsxbugVideo/feature_mediaPlayer/src/main/java/com/ls/mediaplayer/ui/videodetail/VideoDeatilModel.java
最后由 ViewModel 把详情和评论列表拆成两份可观察状态:
java
public class VideoDeatilViewModel extends BaseViewModel {
private final VideoDeatilModel mModel;
private MutableLiveData<ResVideoDetail.ArchivesInfoBean> mVideoInfo = new MutableLiveData<>();
private MutableLiveData<List<ResComment>> mComments = new MutableLiveData<>();
public VideoDeatilViewModel() {
mModel = new VideoDeatilModel();
}
public void reqeustDetail(int videoId) {
showLoading(true);
mModel.requestDetail(videoId, new IRequestCallback<ResVideoDetail>() {
@Override
public void onLoadFinish(ResVideoDetail datas) {
showLoading(false);
mVideoInfo.setValue(datas.getArchivesInfo());
mComments.setValue(datas.getCommentList());
}
@Override
public void onLoadFailure(int errorCode, String message) {
showLoading(false);
showToast(message);
}
});
}
}
项目路径:
LsxbugVideo/feature_mediaPlayer/src/main/java/com/ls/mediaplayer/ui/videodetail/VideoDeatilViewModel.java
9.5 BaseFragment:根据根布局类型挂载加载控件
java
private void initProgressBar() {
mProgressBar = new ProgressBar(getContext());
if (mDataBinding.getRoot() instanceof ConstraintLayout) {
ConstraintLayout.LayoutParams layoutParams = new ConstraintLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT
);
layoutParams.startToStart = ConstraintLayout.LayoutParams.PARENT_ID;
layoutParams.topToTop = ConstraintLayout.LayoutParams.PARENT_ID;
layoutParams.endToEnd = ConstraintLayout.LayoutParams.PARENT_ID;
layoutParams.bottomToBottom = ConstraintLayout.LayoutParams.PARENT_ID;
mProgressBar.setLayoutParams(layoutParams);
mProgressBar.setVisibility(View.GONE);
ConstraintLayout constraintLayout = (ConstraintLayout) mDataBinding.getRoot();
constraintLayout.addView(mProgressBar);
}
}
项目路径:
LsxbugVideo/library_base/src/main/java/com/ls/libbase/base/BaseFragment.java