Android 短视频播放详情页实战:从播放器模块拆分、Media3 与 FlowHelper 接入,到 ViewPager 高度适配和详情数据联动

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 承载视频播放区域。
  • 播放器下方放 TabVpFlowLayoutViewPager2,后续让简介页与评论页都挂在这里。
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

布局层要同时放入 TabVpFlowLayoutViewPager2,前者负责标题与指示样式,后者负责实际承载两个分页内容:

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 组装简介页与评论页。
  • TabVpFlowLayoutViewPager2 关联起来。
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中的文本改成白色

适配器创建时,如果 mStyletrue,就把白色样式状态传下去:

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

相关推荐
努力努力再努力wz1 小时前
【MySQL入门系列】:不只是建表:MySQL 表约束与 DDL 执行机制全解析
android·linux·服务器·数据结构·数据库·c++·mysql
陆业聪2 小时前
Prompt、Rule、Skill:被混用了一年的三个词,今天说清楚
android·人工智能·aigc
亚空间仓鼠2 小时前
关系型数据库MySQL(四):读写分离
android·数据库·mysql
互联网散修2 小时前
鸿蒙实战:用 want.param 实现视频播放器跨端迁移续播
华为·音视频·harmonyos·跨端迁移续播
JianZhen✓2 小时前
从零到一:基于声网Agora的医疗视频问诊前端实战指南
前端·音视频
恋猫de小郭2 小时前
JetBrains Amper 0.10 ,期待它未来替代 Gradle
android·前端·flutter
AI先驱体验官2 小时前
臻灵:边缘AI与数字人融合,企业级实时互动的技术拐点
android·大数据·人工智能·microsoft·实时互动
Kapaseker2 小时前
Kotlin 的 internal 修饰符到底咋回事儿?
android·kotlin
鹏程十八少3 小时前
1.2026金三银四 Android Glide 23连问终极拆解:生命周期、三级缓存、Bitmap复用,大厂面试官到底想听什么?
android·前端·面试