学习 Android(四)玩安卓项目实战

简介

在上一章节,我们对Android中常用的项目架构模式有了一定的了解,那么现在我们既有轮子(基础UI),又有框架了,是时候开始造车了,那么本章将开始进行Android项目实战练习,具体实战什么看作者想要实战什么(无规划,难易不定)…遇到啥就针对的去实战,本章节将针对MVP项目架构进行实战,篇幅会比较长,会针对某些插件,知识点进行单独的讲解,跟着一篇文章可以实现一个项目的完整运行!!!

这里项目中使用了 [玩Android 开放API-玩Android - wanandroid.com](https://www.wanandroid.com/index) 提供API,在这里进行声明并感谢大佬

1. 项目准备

  • 修改项目模块下build.gralde.kts配置文件:修改仓库地址便于下载依赖

    pluginManagement {
        repositories {
            maven {
                url = uri("https://www.jitpack.io")
            }
            maven {
                url = uri("https://maven.aliyun.com/repository/public/")
            }
            google()
            mavenCentral()
            gradlePluginPortal()
        }
    }
    
    dependencyResolutionManagement {
        repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
        repositories {
            maven {
                url = uri("https://www.jitpack.io")
            }
            maven {
                url = uri("https://maven.aliyun.com/repository/public/")
            }
            google()
            mavenCentral()
        }
    }
    
    rootProject.name = "MyKotlinMVPWanAndroidExample"
    include(":app")
    
  • 修改应用模块下build.gradle.kts 配置文件:开启 ViewBinding 和 添加依赖

    plugins {
        alias(libs.plugins.android.application)
        alias(libs.plugins.kotlin.android)
    }
    
    android {
        namespace = "com.example.studymvpexampleapplication"
        compileSdk = 35
    
        defaultConfig {
            applicationId = "com.example.studymvpexampleapplication"
            minSdk = 24
            targetSdk = 35
            versionCode = 1
            versionName = "1.0"
    
            testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
        }
    
        buildTypes {
            release {
                isMinifyEnabled = false
                proguardFiles(
                    getDefaultProguardFile("proguard-android-optimize.txt"),
                    "proguard-rules.pro"
                )
            }
        }
        compileOptions {
            sourceCompatibility = JavaVersion.VERSION_11
            targetCompatibility = JavaVersion.VERSION_11
        }
        kotlinOptions {
            jvmTarget = "11"
        }
    
        buildFeatures {
            /**
             * ViewBinding 是 Jetpack 提供的一项轻量级功能,
             * 它能够在编译时为每个布局文件生成对应的绑定类,
             * 使得我们可以通过类型安全和空安全的方式直接访问布局中的视图,从而几乎完全替代 findViewById(),并显著减少样板代码
             *
             * ViewBinding 会为每一个启用该特性的布局文件生成一个绑定类(<LayoutName>Binding),
             * 该类包含了所有带 android:id 的视图引用以及一个 getRoot() 方法,用于获取根视图
             * */
            viewBinding = true
        }
    }
    
    dependencies {
    
        implementation(libs.androidx.core.ktx)
        implementation(libs.androidx.appcompat)
        implementation(libs.material)
        implementation(libs.androidx.activity)
        implementation(libs.androidx.constraintlayout)
        implementation(libs.androidx.navigation.fragment.ktx)
        implementation(libs.androidx.navigation.ui.ktx)
        testImplementation(libs.junit)
        androidTestImplementation(libs.androidx.junit)
        androidTestImplementation(libs.androidx.espresso.core)
    
        /**
         * Gson(JSON 序列化/反序列化)
         * 实现 JSON 数据与 Java/Kotlin 对象的互相转换
         * */
        implementation ("com.google.code.gson:gson:2.7")
        /**
         * Glide(图片加载)
         * 高效加载网络/本地图片,支持缓存和图片处理
         * */
        implementation ("com.github.bumptech.glide:glide:3.7.0")
        /**
         * YUtils(工具集合)
         * 提供常用工具类(日志、屏幕适配、权限管理等)
         * */
        implementation ("com.github.yechaoa.YUtils:yutilskt:3.4.0")
        /**
         * Banner(轮播图)
         * 实现图片/视图轮播效果
         * */
        implementation ("com.youth.banner:banner:1.4.10")
        /**
         * Lifecycle 系列
         *
         * LiveData:实现数据观察
         * ViewModel:管理界面相关数据
         * lifecycle-extensions:旧版扩展库(已废弃,建议迁移到独立组件)
         * */
        implementation ("androidx.lifecycle:lifecycle-livedata-ktx:2.3.0-rc01")
        implementation ("androidx.lifecycle:lifecycle-extensions:2.2.0")
        // 极大简化 RecyclerView 适配器 的编写,并提供强大而灵活的分页、头尾布局、多类型、拖拽、滑动删除等功能
        implementation ("com.github.CymChad:BaseRecyclerViewAdapterHelper:3.0.6")
        implementation("androidx.lifecycle:lifecycle-viewmodel:2.5.1") {
            /**
             * 此依赖中排除了 lifecycle-viewmodel-ktx 模块 解决模块冲突
             * */
            exclude(group = "androidx.lifecycle", module = "lifecycle-viewmodel-ktx")
        }
        /**
         * Collection KTX
         * 为 Android 集合框架提供 Kotlin 扩展方法
         * */
        api("androidx.collection:collection-ktx:1.2.0")
        /**
         * Kotlin 协程
         * 提供协程支持,简化异步编程
         * */
        api("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.3")
        api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.3")
        /**
         * 网络请求
         * Retrofit + OkHttp
         * Retrofit:声明式 REST API 客户端
         * OkHttp:底层网络引擎
         * Logging Interceptor:网络请求日志拦截器
         * Gson Converter:响应数据转换
         * */
        api("com.squareup.retrofit2:retrofit:2.9.0")
        api("com.squareup.okhttp3:okhttp:4.9.1")
        api("com.squareup.okhttp3:logging-interceptor:4.9.1")
        api("com.squareup.retrofit2:converter-gson:2.9.0")
    
    
    }
    
  • 修改项目级 gradle.properties 文件

    # Project-wide Gradle settings.
    # IDE (e.g. Android Studio) users:
    # Gradle settings configured through the IDE *will override*
    # any settings specified in this file.
    # For more details on how to configure your build environment visit
    # http://www.gradle.org/docs/current/userguide/build_environment.html
    # Specifies the JVM arguments used for the daemon process.
    # The setting is particularly useful for tweaking memory settings.
    org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
    # When configured, Gradle will run in incubating parallel mode.
    # This option should only be used with decoupled projects. More details, visit
    # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
    # org.gradle.parallel=true
    # AndroidX package structure to make it clearer which packages are bundled with the
    # Android operating system, and which are packaged with your app's APK
    # https://developer.android.com/topic/libraries/support-library/androidx-rn
    android.useAndroidX=true
    android.enableJetifier=true
    # Kotlin code style for this project: "official" or "obsolete":
    kotlin.code.style=official
    # Enables namespacing of each library's R class so that its R class includes only the
    # resources declared in the library itself and none from the library's dependencies,
    # thereby reducing the size of the R class for that library
    android.nonTransitiveRClass=true
    

    由于项目中还用到了一些依赖旧版本的插件,所以添加一个 android.enableJetifier=true 以免报错无法运行

  • 资源文件:在 res 目录下

    • colors

      <?xml version="1.0" encoding="utf-8"?>
      <resources>
          <color name="colorPrimary">#673AB7</color>
          <color name="colorPrimaryDark">#512DA8</color>
          <color name="colorAccent">#7C4DFF</color>
      
          <color name="black">#212121</color>
          <color name="gray">#757575</color>
          <color name="line">#DCDCDC</color>
          <color name="white">#FFFFFF</color>
      
          <color name="white30">#B2FFFFFF</color>
      
          <color name="color_eaeaea">#eaeaea</color>
          <color name="color_757575">#757575</color>
      
      </resources>
      
    • strings

      <resources>
          <string name="app_name">玩安卓</string>
      
          <string name="title_home">首页</string>
          <string name="title_tree">体系</string>
          <string name="title_navi">导航</string>
          <string name="title_project">项目</string>
      
          <string name="navigation_drawer_open">Open navigation drawer</string>
          <string name="navigation_drawer_close">Close navigation drawer</string>
      
          <string name="action_search">搜索</string>
          <string name="action_settings">设置</string>
      
          <string name="title_activity_about">关于</string>
      
          <string name="large_text">
              "用到的库:\n\n"
              "YUtils\n\n"
              "retrofit\n\n"
              "rxjava2\n\n"
              "BRVAH\n\n"
              "banner\n\n"
              "glide\n\n"
              "agentweb\n\n"
              "VerticalTabLayout\n\n"
              "flowlayout\n\n"
          </string>
          <string name="hint_username">请输入账号</string>
          <string name="hint_password">请输入密码</string>
          <string name="hint_password_again">请再次输入密码</string>
          <string name="password">密码</string>
          <string name="username">账号</string>
          <string name="register">注册账号</string>
          <string name="login">登 录</string>
          <!-- TODO: Remove or change this placeholder text -->
          <string name="hello_blank_fragment">Hello blank fragment</string>
      
      </resources>
      
    • dimens

      <resources>
          <!-- Default screen margins, per the Android Design guidelines. -->
          <dimen name="activity_horizontal_margin">16dp</dimen>
          <dimen name="activity_vertical_margin">16dp</dimen>
      
          <dimen name="nav_header_vertical_spacing">8dp</dimen>
          <dimen name="nav_header_height">186dp</dimen>
          <dimen name="fab_margin">16dp</dimen>
      
          <dimen name="app_bar_height">200dp</dimen>
      
          <dimen name="sp_30">30sp</dimen>
          <dimen name="sp_24">24sp</dimen>
          <dimen name="sp_22">22sp</dimen>
          <dimen name="sp_20">20sp</dimen>
          <dimen name="sp_18">18sp</dimen>
          <dimen name="sp_16">16sp</dimen>
          <dimen name="sp_14">14sp</dimen>
          <dimen name="sp_12">12sp</dimen>
      
          <dimen name="dp_80">80dp</dimen>
          <dimen name="dp_70">70dp</dimen>
          <dimen name="dp_60">60dp</dimen>
          <dimen name="dp_40">40dp</dimen>
          <dimen name="dp_30">30dp</dimen>
          <dimen name="dp_28">28dp</dimen>
          <dimen name="dp_26">26dp</dimen>
          <dimen name="dp_24">24dp</dimen>
          <dimen name="dp_22">22dp</dimen>
          <dimen name="dp_20">20dp</dimen>
          <dimen name="dp_18">18dp</dimen>
          <dimen name="dp_16">16dp</dimen>
          <dimen name="dp_15">15dp</dimen>
          <dimen name="dp_12">12dp</dimen>
          <dimen name="dp_10">10dp</dimen>
          <dimen name="dp_8">8dp</dimen>
          <dimen name="dp_6">6dp</dimen>
          <dimen name="dp_5">5dp</dimen>
          <dimen name="dp_4">4dp</dimen>
          <dimen name="dp_2">2dp</dimen>
          <dimen name="dp_0">0dp</dimen>
          <dimen name="text_margin">16dp</dimen>
      </resources>
      
  • 项目结构

    在这里插入图片描述

2. base类

base包下创建各个功能模块中的组件(Activity、Fragment、Presenter、Adapter 等)基类,以便统一管理公共逻辑减少重复代码规范项目结构

在这里插入图片描述

具体封装内容根据项目而改变,没有统一的模版,这里的模版仅供参考 , 接下来我们将一一分析这些封装的基类在项目中的作用,其中一些工具类先不用在意,在之后会将工具类统一列出来

  • BaseApplication

    /**
     * BaseApplication
     * 通过在 AndroidManifest 声明
     * 在应用程序创建时第一个被实例化的
     * 其 onCreate() 在整个应用的生命周期仅执行一次
     * 一般在这个继承 Application 的类中, 我们会进行一些通用工具类、模块的初始化操作
     * */
    class BaseApplication: Application(){
        override fun onCreate() {
            super.onCreate()
            // 初始化YUtils工具类
            YUtils.init(this)
            // 注册 Activity 声明周期回调
            registerActivityLifecycleCallbacks(ActivityUtil.activityLifecycleCallbacks)
        }
    }
    

    记得在 AndroidManifest.xml 中进行声明绑定,否则无效

    <manifest xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:tools="http://schemas.android.com/tools" >
        <!-- 设置 name 去声明绑定自定义的 Application-->
        <application
            android:name=".base.BaseApplication"
            .....
        </application>
    
    </manifest>
    

    写完运行一下,看看有没有问题,这是一个习惯,不要一直埋头写代码,除非你很熟练了,如果刚开始,就一步一步来。因为还没写界面,默认的显示还是 Hello World!

  • BaseActivity

    /**
     * 抽象 BaseActivity,使用泛型 ViewBinding 简化布局绑定
     *
     * @param VB 具体的 ViewBinding 类型
     * @param block 一个用于创建 VB 的 lambda,通常传入 { inflater -> XxxActivityBinding.inflate(inflater) }
     */
    abstract class BaseActivity<VB : ViewBinding>(val block: (LayoutInflater) -> VB) :
        AppCompatActivity() {
        // 内部持有一个可空的绑定对象,防止在 onDestroy 后继续引用
        private var _binding: VB? = null
        /**
         * 对外公开的 binding 对象,只有在 onCreate 后到 onDestroy 之前可用
         * 访问时若 _binding 为 null,会抛出 IllegalStateException 提示已被销毁
         */
        val binding: VB
            get() = requireNotNull(_binding) { "biding 已被销毁" }
    
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            // 在 Activity 创建时,通过传入的 block 初始化 ViewBinding
            _binding = block(layoutInflater)
            // 将 binding.root 设置为当前 Activity 的内容视图
            setContentView(binding.root)
            // 调用各子类需实现的生命周期方法
            initView()
            initData()
            allClick()
        }
    
        /**
         * 初始化视图 例如 findView、RecyclerView 布局管理器等
         * */
        abstract fun initView()
    
        /**
         * 初始化或加载数据,例如网络请求、本地数据库查询等
         * */
        abstract fun initData()
    
        /**
         * 设置所有的点击事件回调监听
         * */
        abstract fun allClick()
    
        /**
         * 设置标题栏标题
         *
         * @param title 标题文本
         */
        protected fun setBarTitle(title: String) {
            supportActionBar?.title = title
        }
    
        /**
         * 启用默认返回按钮 (左上角箭头),并响应点击
         */
        protected fun setBackEnabled() {
            supportActionBar?.apply {
                setHomeButtonEnabled(true)
                setDisplayHomeAsUpEnabled(true)
            }
        }
    
        /**
         * 菜单项点击回调,处理左上角 Home/箭头返回
         */
        override fun onOptionsItemSelected(item: MenuItem): Boolean {
            val id = item.itemId
            if (id == android.R.id.home) {
                finish()
                return true
            }
            return super.onOptionsItemSelected(item)
        }
    
        override fun onDestroy() {
            super.onDestroy()
            // 在 Activity 销毁时,清空 binding 引用,防止内存泄漏
            _binding = null
        }
    }
    
  • BaseFragment

    /**
     * 抽象 BaseFragment,使用泛型 ViewBinding 简化布局绑定
     *
     * @param VB 具体的 ViewBinding 类型
     * @param block 一个用于创建 VB 的 lambda,通常传入 { inflater -> XxxFragmentBinding.inflate(inflater, container, false) }
     */
    abstract class BaseFragment<VB : ViewBinding>(val block: (LayoutInflater) -> VB) :
        Fragment() {
    
        // 内部持有一个可空的 binding 对象,防止在视图销毁后继续引用
        private var _binding: VB? = null
    
        /**
         * 对外公开的 binding 对象,只有在 onCreateView 到 onDestroyView 之间可用
         * 访问时若 _binding 为 null,会抛出 IllegalStateException 提示已被销毁
         */
        val binding: VB
            get() = requireNotNull(_binding) { "biding 已被销毁" }
    
        override fun onCreateView(
            inflater: LayoutInflater,
            container: ViewGroup?,
            savedInstanceState: Bundle?
        ): View? {
            // 在创建视图时,通过传入的 block 初始化 ViewBinding
            _binding = block(layoutInflater)
            // 返回根视图给 Fragment 宿主显示
            return binding.root
        }
    
        override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
            super.onViewCreated(view, savedInstanceState)
            // 视图创建后,调用子类需实现的初始化方法
            initView()
            initData()
            allClick()
        }
    
        override fun onDestroyView() {
            super.onDestroyView()
            // 在视图销毁时,清空 binding 引用,防止内存泄漏
            _binding = null
        }
    
        /**
         * 初始化视图组件,例如设置 RecyclerView 的 adapter 或初始化 UI 控件状态
         */
        protected abstract fun initView()
    
        /**
         * 初始化或加载数据,例如发起网络请求或读取本地数据库
         */
        protected abstract fun initData()
    
        /**
         * 设置所有的点击事件回调监听
         */
        protected abstract fun allClick()
    }
    

    至此,我们完成了对 ActivityFragment 简单通用抽离的封装,但是这只是针对普通的 ActivityFragment ,我们要将之用于 MVP 项目架构中,所以还要对使用到 MVP 模块的 ActivityFragment 进行二次封装,那么我们接着封装,别嫌麻烦,万事开头难,如果不想写屎山,那就从一开始就规划好,统一封装管理好…

  • BaseContract

    /**
     * MVP 架构的基础契约接口,定义了 View、Presenter、Model 三层的公共方法和规范。
     */
    interface BaseContract {
    
        /**
         * View 层接口,需要由具体的 Activity/Fragment 实现。
         */
        interface IBaseView {
            /**
             * 获取当前绑定的 Activity 实例,用于 UI 操作和上下文需求。
             * @return 当前 View 所在的 Activity,如果已经销毁则返回 null。
             */
            fun getActivity(): Activity?
        }
    
        /**
         * Presenter 层接口,负责业务逻辑处理并协调 View 与 Model。
         * 继承 LifecycleObserver 以便监听宿主生命周期。
         */
        interface IBasePresenter : LifecycleObserver {
            /**
             * 判断 Presenter 是否已与 View 建立关联。
             * 可在调用业务方法前进行检查,避免空指针或内存泄漏。
             *
             * @return true 表示已经 attach,false 表示尚未 attach 或已 detach
             */
            fun isViewAttach(): Boolean
    
            /**
             * 在 View 销毁时调用,断开 Presenter 与 View 的引用关联,
             * 以便垃圾回收器回收,防止内存泄漏。
             */
            fun detachView()
        }
    
        /**
         * Model 层接口,负责数据获取与处理,可由具体实现类定义网络请求、数据库操作等。
         */
        interface IBaseModel {
            // 可在此定义全局通用的数据处理方法,例如统一的错误封装或返回数据类型
        }
    }
    

    上述封装中出现了一个新的接口 LifecycleObserverLifecycleObserver 是一个标记接口(marker interface),本身不包含任何方法。其主要作用是标识实现了该接口的类为生命周期观察者。通过与 LifecycleOwner(如 ActivityFragment)配合使用,LifecycleObserver 可以监听宿主组件的生命周期事件。这里所说的将标识的成为生命周期观察者,涉及到了观察者模式*(定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都得到通知并自动更新)*,相信各位读者的面向对象的基础都很扎实吧A.A,这里就不对这个进行详细的描述了,只要知道了使用了这个标识,就可以将其标识为观察者。

  • BasePresenter

    /**
     * BasePresenter 抽象类,封装了 MVP 架构中 Presenter 层的通用逻辑:
     *  - 使用弱引用持有 View,防止内存泄漏
     *  - 在初始化时创建 Model 实例
     *  - 提供协程作用域用于异步操作,并在解绑时取消
     */
    abstract class BasePresenter<V : BaseContract.IBaseView, M>(view: V) :
        BaseContract.IBasePresenter {
    
        /** 对 View 的弱引用,避免强引用导致 Activity/Fragment 无法回收 */
        var mView: WeakReference<V?>? = null
    
        /** 对 Model 的引用,用于执行业务逻辑 */
        var mModel: M? = null
    
        /** 协程作用域,指定在主线程,适合更新 UI 操作 */
        val coroutineScope = CoroutineScope(Dispatchers.Main)
    
        init {
            // 在构造时绑定 View 并创建对应的 Model
            attachView(view)
            mModel = createModel()
        }
    
        /**
         * 创建并返回当前 Presenter 对应的 Model 实例
         * 子类必须实现此方法,提供具体的业务 Model
         */
        abstract fun createModel(): M
    
        /**
         * 将 View 与 Presenter 关联,使用 WeakReference 包装
         *
         * @param view 具体的 View 实现
         */
        open fun attachView(view: V) {
            mView = WeakReference(view)
        }
    
        /**
         * 获取当前关联的 View 实例
         *
         * @return 视图实例或 null(若已被回收)
         */
        open fun getView(): V? = mView?.get()
    
        /**
         * 检查 Presenter 是否仍与 View 建立了有效关联
         *
         * @return true:关联未断开且 View 未被回收;false:已断开或 View 为 null
         */
        override fun isViewAttach(): Boolean {
            return mView != null && mView?.get() != null
        }
    
        /**
         * 解除 Presenter 与 View 的关联,清理资源,并取消所有协程
         * 调用时机:通常在 Activity/Fragment 的 onDestroy 或 onDestroyView 中
         */
        override fun detachView() {
            // 清理对 View 的弱引用
            if (mView != null) {
                mView?.clear()
                mView = null
            }
            // 清理对 Model 的引用
            if (mModel != null) {
                mModel = null
            }
            // 取消所有在 coroutineScope 中启动的协程任务
            coroutineScope.cancel()
        }
    }
    
    
  • BaseMVPActivitiy

    /**
     * 基于 MVP 模式的 Activity 抽象基类,
     * 继承自 BaseActivity,用于统一管理 Presenter 的创建与生命周期。
     *
     * @param VB ViewBinding 类型,用于布局绑定
     * @param P Presenter 类型,实现 BaseContract.IBasePresenter
     * @param block 用于生成 ViewBinding 的 lambda 表达式,通常为 XxxActivityBinding.inflate
     */
    abstract class BaseMVPActivity<VB : ViewBinding, P : BaseContract.IBasePresenter>(
        block: (LayoutInflater) -> VB
    ) : BaseActivity<VB>(block) {
    
        /**
         * 延迟初始化 Presenter 实例,首次使用时通过 createPresenter() 方法创建。
         */
        protected val mPresenter: P by lazy {
            createPresenter()
        }
    
        /**
         * 子类必须实现该方法,用于提供具体的 Presenter 对象。
         *
         * @return 创建并返回一个新的 Presenter 实例
         */
        protected abstract fun createPresenter(): P
        
        override fun onDestroy() {
            super.onDestroy()
            // 在 Activity 销毁时,断开 Presenter 与 View 的关联,避免内存泄漏
            mPresenter.detachView()
        }
    }
    
  • BaseMVPFragment

    /**
     * 基于 MVP 模式的 Fragment 抽象基类,继承自 BaseFragment,
     * 用于统一管理 Presenter 的创建与生命周期。
     *
     * @param VB ViewBinding 类型,用于布局绑定
     * @param P Presenter 类型,实现 BaseContract.IBasePresenter
     * @param block 用于生成 ViewBinding 的 lambda 表达式,通常为 XxxFragmentBinding.inflate
     */
    abstract class BaseMVPFragment<VB : ViewBinding, P : BaseContract.IBasePresenter>(
        block: (LayoutInflater) -> VB
    ) : BaseFragment<VB>(block) {
    
        /**
         * 延迟初始化 Presenter 实例,首次使用时通过 createPresenter() 方法创建。
         */
        protected val mPresenter: P by lazy {
            createPresenter()
        }
        
        override fun onDestroyView() {
            super.onDestroyView()
            // 视图销毁时,断开 Presenter 与 View 的关联,避免内存泄漏
            mPresenter.detachView()
        }
    
        /**
         * 子类必须实现该方法,用于提供具体的 Presenter 对象。
         *
         * @return 创建并返回一个具体的 Presenter 实例
         */
        protected abstract fun createPresenter(): P
    
        /**
         * 获取当前 Presenter 实例,便于子类调用
         *
         * @return Presenter 实例
         */
        protected fun getPresenter(): P = mPresenter
    }
    

    至此我们对 ActivityFragment 的封装告一段落,接下来,我们将进行网络请求的封装,不过在封装之前,我们先看看这个项目中有那些自定义的工具类

3. util

  • ExceptionUtil 异常工具类

    /**
     * 异常处理工具类,用于统一捕获并处理各种运行时异常,
     * 并通过日志或吐司等方式向用户展示友好提示。
     */
    object ExceptionUtil {
    
        /**
         * 捕获并处理通用异常,根据不同异常类型进行分类提示。
         *
         * @param e 捕获到的 Throwable 异常
         */
        fun catchException(e: Throwable) {
            // 打印异常堆栈,方便调试和定位问题
            e.printStackTrace()
    
            when (e) {
                is HttpException -> {
                    // HTTP 错误,使用状态码分类处理
                    catchHttpException(e.code())
                }
                is InterruptedIOException -> {
                    // I/O 操作被中断,可能是超时或主动取消
                    MLog.e("服务器连接失败,请稍后重试")
                }
                is UnknownHostException, is NetworkErrorException -> {
                    // 无法解析主机名或网络不可达
                    MLog.e("网络连接异常,请检查您的网络设置")
                }
                is MalformedJsonException, is JsonSyntaxException -> {
                    // JSON 格式不正确,服务器返回的数据有误
                    MLog.e("服务器返回数据格式错误,请稍后重试")
                }
                is ConnectException -> {
                    // 连接服务器失败
                    MLog.e("连接服务器失败")
                }
                else -> {
                    // 其他未知异常,给出通用提示
                    MLog.e("操作失败,请稍后重试")
                }
            }
        }
    
        /**
         * 处理 HTTP 异常,根据状态码决定提示信息。
         *
         * @param errorCode HTTP 响应状态码
         */
        private fun catchHttpException(errorCode: Int) {
            // 2xx 范围内视为正常,不做处理
            if (errorCode in 200 until 300) return
            // 非成功状态码,给出对应提示
            val msg = catchHttpExceptionCode(errorCode)
            MLog.e(msg)
        }
    
        /**
         * 根据 HTTP 状态码判断错误类型并返回对应提示文字。
         *
         * @param errorCode HTTP 响应状态码
         * @return 用户可见的错误提示
         */
        private fun catchHttpExceptionCode(errorCode: Int): String = when (errorCode) {
            in 500..599 -> {
                // 服务器内部错误
                "服务器异常,请稍后重试"
            }
            else -> {
                // 客户端请求错误或其他未知情况
                "请求错误,请稍后重试"
            }
        }
    }
    
    
  • Extends 拓展函数

    /**
     * 用于标识对象的类名
     * */
    val Any.TAG: String
        get() {
            return javaClass.simpleName
        }
    
    /**
     * toJsonString 函数则用于将对象转换为 JSON 格式的字符串
     * */
    fun Any.toJsonString(): String {
        return Gson().toJson(this)
    }
    
    /**
     * 这个函数的作用是简化启动协程的过程,并提供了统一的异常处理方式
     *
     * @param block: 这是一个挂起函数,它以 CoroutineScope 作为接收者并不返回任何结果。这个函数表示要在协程中执行的代码块。
     * @param onError: 这是一个接受 Throwable 类型参数的函数,用于处理在协程执行过程中发生的异常。
     * @param onComplete: 这是一个不带参数的函数,表示在协程执行完成后要执行的操作。
     * */
    fun launchCoroutine(
        block: suspend CoroutineScope.() -> Unit,
        onError: (e: Throwable) -> Unit = { _: Throwable -> },
        onComplete: () -> Unit = {}
    ) {
        MainScope().launch(
            CoroutineExceptionHandler { _, throwable ->
                run {
                    // 统一处理错误
                    ExceptionUtil.catchException(throwable)
                    onError(throwable)
                }
            }
        ) {
            try {
                block.invoke(this)
            } finally {
                onComplete()
            }
        }
    }
    
    /**
     * 随机颜色
     */
    fun randomColor(): Int {
        Random().run {
            //rgb取值0-255,但是值过大,就越接近白色,会看不清,所以限制在200
            val red = nextInt(200)
            val green = nextInt(200)
            val blue = nextInt(200)
            return Color.rgb(red, green, blue)
        }
    }
    

    之前的文章有针对 Kotlin 语法做了学习和讲解,这里对其语法的使用不在赘述,看不懂麻烦去翻翻前面TNT

  • **GlideImageLoader**

    
    /**
     * GlideImageLoader 继承自 Banner 库(或其他框架)中定义的 ImageLoader 抽象类,
     * 用于统一管理图片加载逻辑。这里以 Glide 为示例实现,实际项目中可替换为 Picasso、Coil 等。
     */
    class GlideImageLoader : ImageLoader() {
    
        /**
         * displayImage:图片加载回调方法
         *
         * @param context    上下文对象,用来启动 Glide 请求(通常传入 Activity 或 Fragment 的 context)
         * @param path       图片路径,这里类型定义为 Any 是为了兼容多种数据源:
         *                   - URL(String)
         *                   - 本地文件(File 或 Uri)
         *                   - 资源 ID(Int)
         *                   在使用时,需要根据自己传入的类型进行强转,切勿随意强转导致 ClassCastException。
         * @param imageView  目标 ImageView,用来显示加载后的图片
         *
         * 注意:
         * 1. 这里使用 Kotlin 的非空断言(!!),若 context 或 imageView 为 null,会抛出 NullPointerException,
         *    推荐在调用处确保不传入 null,或改为使用安全调用(?.)并做空值判断。
         * 2. Glide 会自动处理列表滚动时的取消与重用,无需手动清除旧请求。
         */
        override fun displayImage(context: Context?, path: Any?, imageView: ImageView?) {
            // 如果 context 或 imageView 为 null,直接返回,避免崩溃
            if (context == null || imageView == null) return
    
            // 使用 Glide 加载图片:
            // - with(context):指定生命周期,与 Activity/Fragment 同步,自动在 onDestroy 时取消请求
            // - load(path):加载不同类型的图片资源(String, Uri, File, Int 等)
            // - into(imageView):将图片展示到目标 ImageView
            Glide.with(context)
                .load(path)
                .into(imageView)
        }
    }
    
  • MLog

    /**
     * MLog 工具类,用于统一管理日志输出。
     * 通过 isDebug 常量控制是否打印日志,方便在发布版中关闭日志。
     */
    object MLog {
        /** 是否开启调试模式,若为 false 则不会输出日志 */
        private const val isDebug = true
    
        /**
         * 输出错误日志
         *
         * @param msg 要输出的错误信息
         */
        fun e(msg: String) {
            if (isDebug) {
                Log.e(TAG, msg)
            }
        }
    
        /**
         * 输出调试日志
         *
         * @param msg 要输出的调试信息
         */
        fun d(msg: String) {
            if (isDebug) {
                Log.d(TAG, msg)
            }
        }
    
        /**
         * 输出信息日志
         *
         * @param msg 要输出的信息
         */
        fun i(msg: String) {
            if (isDebug) {
                Log.i(TAG, msg)
            }
        }
    
        /**
         * 输出警告日志
         *
         * @param msg 要输出的警告信息
         */
        fun w(msg: String) {
            if (isDebug) {
                Log.w(TAG, msg)
            }
        }
    }
    
  • NetWorkUtils

    /**
     * 网络状态工具类
     * 提供检查设备当前网络(移动数据和 Wi-Fi)是否可用的方法
     */
    object NetWorkUtils {
    
        /**
         * 检查设备是否有可用网络(包含移动数据或 Wi-Fi)
         *
         * @return 若移动网络或 Wi-Fi 任一可用,则返回 true;否则返回 false
         */
        fun isConnected(): Boolean {
            // 获取全局 Application Context,判断任一网络类型是否可用
            return isNetworkConnected(YUtils.getAppContext())
                    || isWifiConnected(YUtils.getAppContext())
        }
    
        /**
         * 判断移动数据网络(2G/3G/4G/5G)是否可用
         *
         * @param context 用于获取系统网络服务的 Context,若为 null 则直接返回 false
         * @return 若当前存在且可用的移动数据网络,则返回 true;否则返回 false
         */
        fun isNetworkConnected(context: Context?): Boolean {
            return context?.let {
                // 获取系统的 ConnectivityManager
                val cm = it.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
                // activeNetworkInfo 可能为 null,使用安全调用
                cm.activeNetworkInfo?.isAvailable ?: false
            } ?: false
        }
    
        /**
         * 判断 Wi-Fi 网络是否可用
         *
         * @param context 用于获取系统网络服务的 Context,若为 null 则直接返回 false
         * @return 若 Wi-Fi 网络接口存在且可用,则返回 true;否则返回 false
         */
        fun isWifiConnected(context: Context?): Boolean {
            return context?.let {
                // 获取系统的 ConnectivityManager
                val cm = it.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
                // 通过 TYPE_WIFI 获取 Wi-Fi 网络状态,可能为 null
                cm.getNetworkInfo(ConnectivityManager.TYPE_WIFI)?.isAvailable ?: false
            } ?: false
        }
    }
    

    记得要要在 AndroidMainifest.xml 中添加权限

    <?xml version="1.0" encoding="utf-8"?>
    <manifest xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:tools="http://schemas.android.com/tools" >
    
        <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
    
        <application
           ...
        </application>
    
    </manifest>
    
  • ObjectUtil

    package com.example.studymvpexampleapplication.util
    
    import android.os.Build
    import android.util.LongSparseArray
    import android.util.SparseArray
    import android.util.SparseBooleanArray
    import android.util.SparseIntArray
    import android.util.SparseLongArray
    import androidx.collection.SimpleArrayMap
    
    /**
     * ObjectUtil 工具类,用于判断任意对象是否“为空”
     * - 支持常见集合、数组、CharSequence、Map 以及 Android 优化集合类型(SparseArray 系列、SimpleArrayMap)等。
     * - 若对象为 null,或内容/长度为 0,则视为空。
     */
    object ObjectUtil {
    
        /**
         * 判断对象是否为空
         *
         * @param obj 任意对象,可能为:
         *            - null
         *            - 原生数组(Array<*>)
         *            - CharSequence(String、Spannable 等)
         *            - Collection(List、Set 等)
         *            - Map(HashMap、LinkedHashMap 等)
         *            - SimpleArrayMap(androidx.collection.SimpleArrayMap)
         *            - SparseArray(android.util.SparseArray)
         *            - SparseBooleanArray、SparseIntArray、SparseLongArray(API ≥ 18)
         * @return true 代表“空”或不可用;false 代表至少有一个元素或非空字符串
         */
        fun isEmpty(obj: Any?): Boolean {
            // 1. 空对象
            if (obj == null) {
                return true
            }
            // 2. 原生数组,长度为 0
            if (obj.javaClass.isArray && java.lang.reflect.Array.getLength(obj) == 0) {
                return true
            }
            // 3. 字符序列(String、CharSequence 等),长度为 0
            if (obj is CharSequence && obj.isEmpty()) {
                return true
            }
            // 4. Java 通用集合 Collection(List、Set 等),isEmpty() 即可判断
            if (obj is Collection<*> && obj.isEmpty()) {
                return true
            }
            // 5. Java 通用 Map(HashMap、TreeMap 等),isEmpty() 即可判断
            if (obj is Map<*, *> && obj.isEmpty()) {
                return true
            }
            // 6. AndroidX 提供的优化版 Map:SimpleArrayMap,底层以数组实现,isEmpty() 可判断是否无元素&#8203;
            if (obj is androidx.collection.SimpleArrayMap<*, *> && obj.isEmpty()) {
                return true
            }
            // 7. Android 原生 SparseArray,key→value 映射,避免自动装箱;size() 为 0 时无元素&#8203;
            if (obj is SparseArray<*> && obj.size() == 0) {
                return true
            }
            // 8. Android 原生 SparseBooleanArray,存布尔值映射;size() 为 0 时无元素&#8203;
            if (obj is SparseBooleanArray && obj.size() == 0) {
                return true
            }
            // 9. Android 原生 SparseIntArray,存整型映射;size() 为 0 时无元素&#8203;
            if (obj is SparseIntArray && obj.size() == 0) {
                return true
            }
            // 10. Android Lollipop MR2 及以上新增的 SparseLongArray,存长整型映射;size() 为 0 时无元素&#8203;
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) {  // API 18+
                if (obj is SparseLongArray && obj.size() == 0) {
                    return true
                }
            }
            // 11. 以上情况都不满足,则视为非空
            return false
        }
    
        /**
         * 判断对象是否非空,等价于 !isEmpty(obj)
         *
         * @param obj 任意对象
         * @return true 代表至少有一个元素或非空字符串;false 代表“空”或 null
         */
        fun isNotEmpty(obj: Any?): Boolean {
            return !isEmpty(obj)
        }
    }
    
    

    至此,我们这个项目工具类就介绍完成了,接下来我们看看网络请求这块代码

4. http

  • BaseBean 通用网络相应基类

    /**
     * 通用响应包装类,用于封装后端返回的标准字段与业务数据。
     * 如果后台返回数据是比较规范的,都会有这种标准字段的,具体啥因人或因项目而异
     * 将之抽离出来方便管理
     *
     * 该类为 Kotlin 的数据类(data class),编译器会自动生成 equals()/hashCode()/toString()/copy() 等方法,
     * 主要用于持有数据而非行为逻辑。
     *
     * @param T 泛型参数,代表业务数据的类型;在使用时由调用方指定具体类型,
     *           Kotlin 默认允许任何类型(上界为 Any?)
     *
     * @property errorMsg 后端返回的错误描述字符串,通常为空或 "success" 表示无错误
     * @property errorCode 后端返回的状态码,一般 0 表示成功,非 0 表示不同类型的失败
     * @property data      业务数据主体,类型为 T;成功时携带实际数据,失败时可能为 null 或包含错误信息
     */
    data class BaseBean<T>(
        val errorMsg: String,
        val errorCode: Int,
        val data: T
    )
    

    这个类具体放到哪里呢?是放在 data.bean 下呢?还是 base 下呢?没啥好纠结的,这个放哪都行~

  • **API 接口封装类 **

    /**
     * API 接口封装类,用于定义网络请求的基础地址和各项具体接口。
     */
    class API {
    
        companion object {
            /**
             * 应用中所有 Retrofit 请求的基础 URL,
             * 所有子接口中的相对路径都会拼接到此地址后面
             */
            const val BASE_URL = "https://www.wanandroid.com/"
        }
    
        /**
         * WAZApi 接口定义了与 WanAndroid 后端交互的所有 HTTP 请求方法,
         * 使用 Retrofit 的注解来声明请求类型、路径和参数,
         * 并统一返回封装在 BaseBean<T> 中的响应体。
         */
        interface WAZApi {
    
            // -----------------------【登录注册】----------------------
    
            /**
             * 登录接口
             *
             * @param username 用户名(账号)
             * @param password 用户密码
             * @return 返回 BaseBean<User>,其中 data 部分包含登录成功后的用户信息
             */
            @FormUrlEncoded
            @POST("user/login")
            suspend fun login(
                @Field("username") username: String?,
                @Field("password") password: String?
            ): BaseBean<User>
    
            /**
             * 注册接口
             *
             * @param username 用户名(账号)
             * @param password 密码
             * @param repassword 重复输入的密码,用于校验
             * @return 返回 BaseBean<User>,其中 data 部分包含注册成功后的用户信息
             */
            @FormUrlEncoded
            @POST("user/register")
            suspend fun register(
                @Field("username") username: String?,
                @Field("password") password: String?,
                @Field("repassword") repassword: String?
            ): BaseBean<User>
    
            // -----------------------【首页相关】----------------------
    
            /**
             * 获取首页文章列表
             *
             * @param page 页码,从 0 开始
             * @return 返回 BaseBean<Article>,其中 data 部分包含文章列表及分页信息
             */
            @GET("article/list/{page}/json")
            suspend fun getArticleList(@Path("page") page: Int): BaseBean<Article>
    
            /**
             * 获取首页轮播图数据
             *
             * @return 返回 BaseBean<MutableList<Banner>>,其中 data 部分是 Banner 对象列表
             */
            @GET("banner/json")
            suspend fun getBanner(): BaseBean<MutableList<Banner>>
    
            // -----------------------【体系】----------------------
    
            /**
             * 获取体系数据列表
             *
             * @return 返回 BaseBean<MutableList<Tree>>,其中 data 部分是体系分类列表
             */
            @GET("tree/json")
            suspend fun getTree(): BaseBean<MutableList<Tree>>
    
            /**
             * 获取某个体系下的详情文章列表
             *
             * @param page 页码
             * @param cid 体系 ID
             * @return 返回 BaseBean<Article>,其中 data 部分包含该体系下的文章列表
             */
            @GET("article/list/{page}/json?")
            suspend fun getTreeChild(
                @Path("page") page: Int,
                @Query("cid") cid: Int
            ): BaseBean<Article>
    
            // -----------------------【导航】----------------------
    
            /**
             * 获取导航数据
             *
             * @return 返回 BaseBean<MutableList<Navi>>,其中 data 部分包含导航分类列表
             */
            @GET("navi/json")
            suspend fun getNavi(): BaseBean<MutableList<Navi>>
    
            // -----------------------【项目】----------------------
    
            /**
             * 获取项目分类数据
             *
             * @return 返回 BaseBean<MutableList<Project>>,其中 data 部分是项目分类列表
             */
            @GET("project/tree/json")
            suspend fun getProject(): BaseBean<MutableList<Project>>
    
            /**
             * 获取项目列表数据
             *
             * @param page 页码
             * @param cid 项目分类 ID
             * @return 返回 BaseBean<ProjectChild>,其中 data 部分包含该分类下项目列表
             */
            @GET("project/list/{page}/json?")
            suspend fun getProjectChild(
                @Path("page") page: Int,
                @Query("cid") cid: Int
            ): BaseBean<ProjectChild>
    
            // -----------------------【搜索】----------------------
    
            /**
             * 搜索文章
             *
             * @param page 页码
             * @param k 搜索关键词
             * @return 返回 BaseBean<Article>,其中 data 部分包含搜索到的文章列表
             */
            @FormUrlEncoded
            @POST("article/query/{page}/json?")
            suspend fun getSearchList(
                @Path("page") page: Int,
                @Field("k") k: String
            ): BaseBean<Article>
    
            /**
             * 获取搜索热词
             *
             * @return 返回 BaseBean<MutableList<Hotkey>>,其中 data 部分是热词列表
             */
            @GET("hotkey/json")
            suspend fun getHotkey(): BaseBean<MutableList<Hotkey>>
    
            // -----------------------【收藏】----------------------
    
            /**
             * 获取收藏的文章列表
             *
             * @param page 页码
             * @return 返回 BaseBean<Collect>,其中 data 部分包含收藏文章分页数据
             */
            @GET("lg/collect/list/{page}/json?")
            suspend fun getCollectList(@Path("page") page: Int): BaseBean<Collect>
    
            /**
             * 收藏文章(站内)
             *
             * @param id 文章 ID
             * @return 返回 BaseBean<String>,其中 data 部分一般为空或提示信息
             */
            @POST("lg/collect/{id}/json")
            suspend fun collect(@Path("id") id: Int): BaseBean<String>
    
            /**
             * 取消收藏文章(文章列表入口)
             *
             * @param id 文章 ID
             * @return 返回 BaseBean<String>,其中 data 部分一般为空
             */
            @POST("lg/uncollect_originId/{id}/json")
            suspend fun unCollect(@Path("id") id: Int): BaseBean<String>
    
            /**
             * 取消收藏文章(我的收藏页面入口)
             *
             * @param id 文章 ID
             * @param originId 原始文章 ID,用于服务端还原来源
             * @return 返回 BaseBean<String>,其中 data 部分一般为空
             */
            @FormUrlEncoded
            @POST("lg/uncollect/{id}/json")
            suspend fun unCollect1(
                @Path("id") id: Int,
                @Field("originId") originId: Int
            ): BaseBean<String>
        }
    }
    

    项目中我们使用的网路请求第三方库是 Retrofit 其是由 Square 开发的一个 类型安全(type-safe)的 HTTP 客户端,可用于 Android 和 Java 应用中,将 REST API 直接映射成 Java/Kotlin 接口,使网络请求代码更简洁、可维护square.github.io。它底层基于 OkHttp实现,默认支持同步及异步调用,并可通过 ConverterFactory(如 GsonMoshi)自动完成请求体和响应体的序列化与反序列化,比较简单,会用就行,这里不进行过多的研究。

  • Bean 网络请求相应类

    上述封装的 API 返回的对象,大部分的都是复杂对象,一般我们可以通过阅读后端提供的接口文档,知道对应接口的响应体内容,然后创建对应的响应类,我们的项目中使用了 GSON ,所以对于相应数据的映射处理还是比较方便的,大家在日常开发中,如果有对应的网络请求的处理,一定要和后端商量好,否则到头来难受的是自己…本项目使用的是玩Android 开放API-玩Android - wanandroid.com提供的接口,我们可以根据提供的示例去获取响应体内容,或者自己用 postMan 等网络请求工具去获取对应接口的相应数据,然后将对应相应数据类在项目中创建。

    • Article

      data class Article(
          val curPage: Int,
          val datas: MutableList<ArticleDetail>,
          val offset: Int,
          val over: Boolean,
          val pageCount: Int,
          val size: Int,
          val total: Int
      )
      
      data class ArticleDetail(
          val apkLink: String,
          val audit: Int,
          val author: String,
          val chapterId: Int,
          val chapterName: String,
          var collect: Boolean,
          val courseId: Int,
          val desc: String,
          val envelopePic: String,
          val fresh: Boolean,
          val id: Int,
          val link: String,
          val niceDate: String,
          val niceShareDate: String,
          val origin: String,
          val prefix: String,
          val projectLink: String,
          val publishTime: Long,
          val selfVisible: Int,
          val shareDate: Long,
          val shareUser: String,
          val superChapterId: Int,
          val superChapterName: String,
          val tags: List<Tag>,
          val title: String,
          val type: Int,
          val userId: Int,
          val visible: Int,
          val zan: Int
      )
      
      data class Tag(
          val name: String,
          val url: String
      )
      
    • Banner

      data class Banner(
          val desc: String,
          val id: Int,
          val imagePath: String,
          val isVisible: Int,
          val order: Int,
          val title: String,
          val type: Int,
          val url: String
      )
      
    • Collect

      data class Collect(
          val curPage: Int,
          val datas: MutableList<CollectDetail>,
          val offset: Int,
          val over: Boolean,
          val pageCount: Int,
          val size: Int,
          val total: Int
      )
      
      data class CollectDetail(
          val author: String,
          val chapterId: Int,
          val chapterName: String,
          val courseId: Int,
          val desc: String,
          val envelopePic: String,
          val id: Int,
          val link: String,
          val niceDate: String,
          val origin: String,
          val originId: Int,
          val publishTime: Long,
          val title: String,
          val userId: Int,
          val visible: Int,
          val zan: Int
      )
      
    • Hotkey

      data class Hotkey(
          val id: Int,
          val link: String,
          val name: String,
          val order: Int,
          val visible: Int
      )
      
    • Navi

      data class Navi(
          val articles: MutableList<ArticleX>,
          val cid: Int,
          val name: String
      )
      
      data class ArticleX(
          val apkLink: String,
          val audit: Int,
          val author: String,
          val chapterId: Int,
          val chapterName: String,
          val collect: Boolean,
          val courseId: Int,
          val desc: String,
          val envelopePic: String,
          val fresh: Boolean,
          val id: Int,
          val link: String,
          val niceDate: String,
          val niceShareDate: String,
          val origin: String,
          val prefix: String,
          val projectLink: String,
          val publishTime: Long,
          val selfVisible: Int,
          val shareDate: Long,
          val shareUser: String,
          val superChapterId: Int,
          val superChapterName: String,
          val tags: List<Any>,
          val title: String,
          val type: Int,
          val userId: Int,
          val visible: Int,
          val zan: Int
      )
      
    • Project

      data class Project(
          val children: List<Any>,
          val courseId: Int,
          val id: Int,
          val name: String,
          val order: Int,
          val parentChapterId: Int,
          val userControlSetTop: Boolean,
          val visible: Int
      )
      
    • ProjectChild

      data class ProjectChild(
          val curPage: Int,
          val datas: MutableList<DataX>,
          val offset: Int,
          val over: Boolean,
          val pageCount: Int,
          val size: Int,
          val total: Int
      )
      
      data class DataX(
          val apkLink: String,
          val audit: Int,
          val author: String,
          val chapterId: Int,
          val chapterName: String,
          val collect: Boolean,
          val courseId: Int,
          val desc: String,
          val envelopePic: String,
          val fresh: Boolean,
          val id: Int,
          val link: String,
          val niceDate: String,
          val niceShareDate: String,
          val origin: String,
          val prefix: String,
          val projectLink: String,
          val publishTime: Long,
          val selfVisible: Int,
          val shareDate: Long,
          val shareUser: String,
          val superChapterId: Int,
          val superChapterName: String,
          val tags: List<TagX>,
          val title: String,
          val type: Int,
          val userId: Int,
          val visible: Int,
          val zan: Int
      )
      
      data class TagX(
          val name: String,
          val url: String
      )
      
    • Tree

      data class Tree(
          val children: ArrayList<Children>,
          var isShow: Boolean,
          val courseId: Int,
          val id: Int,
          val name: String,
          val order: Int,
          val parentChapterId: Int,
          val userControlSetTop: Boolean,
          val visible: Int
      ) : Serializable
      
      
      data class Children(
          val children: ArrayList<Any>,
          val courseId: Int,
          val id: Int,
          val name: String,
          val order: Int,
          val parentChapterId: Int,
          val userControlSetTop: Boolean,
          val visible: Int
      ) : Serializable
      
    • User

      data class User(
          val admin: Boolean,
          val chapterTops: List<Any>,
          val collectIds: List<Int>,
          val email: String,
          val icon: String,
          val id: Int,
          val nickname: String,
          val password: String,
          val publicName: String,
          val token: String,
          val type: Int,
          val username: String
      )
      
  • ReceivedCookiesInterceptor 接收数据拦截器

    /**
     *用于从服务器响应中接收并持久化 Cookie 的拦截器
     * */
    class ReceivedCookiesInterceptor : Interceptor {
    
        @Throws(IOException::class)
        override fun intercept(chain: Interceptor.Chain): Response {
            // 先执行请求,获取原始响应
            val originalResponse: Response = chain.proceed(chain.request())
    
            // 判断响应头中是否包含 Set-Cookie
            if (originalResponse.headers("Set-Cookie").isNotEmpty()) {
                // 创建一个 HashSet 用于去重存储所有 Cookie 字符串
                val cookies: HashSet<String> = HashSet()
    
                // 遍历所有 Set-Cookie 头,将其添加到 HashSet 中
                for (header in originalResponse.headers("Set-Cookie")) {
                    cookies.add(header)
                }
    
                // 将去重后的 Cookie 集合保存到本地(如 SharedPreferences)
                SpUtil.setStringSet(MyConfig.COOKIE, cookies)
            }
    
            // 返回原始响应,保证拦截器链的后续执行和响应传递
            return originalResponse
        }
    }
    
  • AddCookiesInterceptor 请求数据拦拦截器

    /**
     * 用于在每次 HTTP 请求中添加存储在本地的 Cookie 的拦截器
     * */
    class AddCookiesInterceptor : Interceptor {
    
        @Throws(IOException::class)
        override fun intercept(chain: Interceptor.Chain): Response {
            // 从原始请求构造一个新的 Request.Builder,以便添加头部
            val builder: Request.Builder = chain.request().newBuilder()
    
            // 从 SharedPreferences 中读取所有已保存的 Cookie 字符串集合
            val stringSet = SpUtil.getStringSet(MyConfig.COOKIE)
    
            // 将每个 Cookie 都以 “Cookie” 头的形式添加到请求中
            for (cookie in stringSet) {
                builder.addHeader("Cookie", cookie)
            }
    
            // 构建新的请求并交给下一个拦截器或网络执行
            return chain.proceed(builder.build())
        }
    }
    
  • RetrofitService 封装后的网络请求服务

    /**
     * RetrofitService 单例对象,用于初始化并提供全局唯一的 Retrofit API 服务实例
     */
    object RetrofitService {
    
        // 持有 WAZApi 接口的实现,用于调用后端接口
        private var apiServer: API.WAZApi
    
        /**
         * 获取 API 服务实例
         *
         * @return WAZApi 的单例实现,可用于所有网络请求
         */
        fun getApiService(): API.WAZApi {
            return apiServer
        }
    
        // 在对象加载时(第一次访问时)执行一次,完成 Retrofit 和 OkHttpClient 的初始化
        init {
            // ==================== OkHttp 拦截器配置 ====================
    
            // 1. 日志拦截器:记录 HTTP 请求和响应的详细信息(包括请求头/响应体)
            val httpLoggingInterceptor = HttpLoggingInterceptor().apply {
                // 设置日志级别为 BODY,可打印请求和响应的全部内容
                level = HttpLoggingInterceptor.Level.BODY
            }
    
            // 2. 自定义拦截器:打印每次请求的 URL,便于调试和埋点
            val requestLoggingInterceptor = Interceptor { chain ->
                val request = chain.request()
                MLog.d("RequestUrl = ${request.url}")
                chain.proceed(request)
            }
    
            // ==================== OkHttpClient 构建 ====================
            val okHttpClient = OkHttpClient.Builder()
                // 添加日志拦截器
                .addInterceptor(httpLoggingInterceptor)
                /**
                 * 这两个拦截器结合起来,实现了在网络请求中处理 Cookie 的逻辑。
                 * AddCookiesInterceptor 用于在请求中添加 Cookie,
                 * 而 ReceivedCookiesInterceptor 用于从响应中提取新的 Cookie 并存储。
                 * 这样可以实现在应用中对 Cookie 的管理与传递。
                 * */
                .addInterceptor(AddCookiesInterceptor())
                .addInterceptor(ReceivedCookiesInterceptor())
                // 添加自定义请求日志拦截器
                .addInterceptor(requestLoggingInterceptor)
                // 设置连接超时时间:15 秒,防止网络请求长时间挂起
                .connectTimeout(15, TimeUnit.SECONDS)
                .build()
    
            // ==================== Retrofit 构建 ====================
            val retrofit = Retrofit.Builder()
                // 关联自定义的 OkHttpClient,实现拦截和超时配置
                .client(okHttpClient)
                // 添加 Gson 转换器工厂,将 JSON 数据自动序列化/反序列化为 Kotlin 对象
                .addConverterFactory(GsonConverterFactory.create())
                // 设置基础 URL,所有接口请求路径都会拼接到此地址后面
                .baseUrl(API.BASE_URL)
                .build()
    
            // 创建 WAZApi 接口的实现,并赋值给单例属性
            apiServer = retrofit.create(API.WAZApi::class.java)
        }
    }
    
    

我们通过自定义拦截器,保存了登录之后获取到的 Cookies,便于我们之后的网络请求,至于 Cookies 是什么,这里不进行说明,以后会单独写一篇关于网络的文章。 至此,我们网络请求的封装就完成了,接下来我们看看一下通用类的内容。

5. common

  • MyConfig 配置常量

    // 用于存放全局配置项的类
    class MyConfig {
    
        companion object {
            // 表示用户是否已登录的标志位,存储在 SharedPreferences 中的 key
            const val IS_LOGIN = "isLogin"
    
            // 存储用户登录后返回的 Cookie,用于后续接口请求时维持会话
            const val COOKIE = "cookie"
    
            // 存储用户名或用户标识,用于界面展示或请求参数
            const val USER_NAME = "username"
        }
    
    }
    
    
  • RetrofitResponseListener 自定义网络请求返回监听事件接口

    /**
     * RetrofitResponseListener 接口定义了一个通用的网络请求回调契约,
     * 使用泛型 T 来表示成功时返回的数据类型。实现该接口后,
     * 调用方可以在 onSuccess/onError 中编写具体的业务逻辑。
     *
     * 该接口属于典型的“监听者(Listener)”模式,用于异步事件通知,
     * 与 Android 中的 View 点击监听、Retrofit 1.x 中的 Callback<T> 类似。
     *
     * @param T : Any  表示回调成功时返回的数据类型,使用 Kotlin 的泛型来保证类型安全。
     */
    interface RetrofitResponseListener<T : Any> {
    
        /**
         * 当网络请求成功且响应体被正确解析为 T 类型时回调此方法。
         *
         * @param response 从服务器返回并解析后的数据对象,类型为 T
         */
        fun onSuccess(response: T)
    
        /**
         * 当网络请求失败、服务器返回错误码或解析异常时回调此方法。
         *
         * @param errorCode    HTTP 或应用级错误码(如非 2xx)
         * @param errorMessage 错误描述信息,便于提示或日志记录
         */
        fun onError(errorCode: Int, errorMessage: String)
    }
    

    不容易啊,总算完成了前期的所有准备工作,整体如下

在这里插入图片描述

还有一个关键的地方,如果需要进行网络请求的话,我们要在 AndroidManifest.xml 中设置网路请求的权限

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools" >

    <uses-permission android:name="android.permission.INTERNET"/>

    <application
       ...
    </application>

</manifest>

接下来,我们进行项目界面的开发。

6. SplashActivity 引导页面

这是我们项目的第一个页面,也是最简单的页面,首先读者还记得如何创建一个新的 Activity 吗? 我相信你一定手拿把掐的创建好了,值得注意的是,引导页面一般都是在程序启动的时候第一个出来的界面,那么我们需要在 AndroidManifest.xml 中将其 Activity 中设置为程序入口,我这里就只提醒一下,具体如何操作,读者肯定已经拿捏了。

  • activity_splash

    <?xml version="1.0" encoding="utf-8"?>
    <androidx.constraintlayout.widget.ConstraintLayout 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"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".presentation.splash.SplashActivity">
    
        <ImageView
            android:id="@+id/image_view"
            android:layout_width="100dp"
            android:layout_height="100dp"
            android:contentDescription="@null"
            android:src="@mipmap/ic_logo"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />
    
    </androidx.constraintlayout.widget.ConstraintLayout>
    

    界面内容非常简单,就是展示一个项目LOGO,图片这里就不提供,随便搞个图片就行了~咱们没有产品,没有设计,没有UI,凑合吧T<T。

  • SplashActivity

    还记得我们封装的 BaseActivityBaseMVPActivity 吗?我们使用那个呢?其实都行,如果项目中需要再引导页面中进行复杂数据处理,那么我就建议使用 BaseMVPActivity ,反之就用最简单的 BaseActivity

    class SplashActivity : BaseActivity<ActivitySplashBinding>({ inflater ->
        // 通过 lambda 传入 LayoutInflater,初始化 ViewBinding
        ActivitySplashBinding.inflate(inflater)
    }) {
    
        /**
         * 初始化视图组件
         * 在此方法中可完成状态栏、UI 控件的配置或动画启动
         */
        override fun initView() {
            // TODO: 根据需要在此处初始化视图,例如启动动画
        }
    
        /**
         * 初始化数据或后台逻辑
         * 可在此方法中加载配置、检查版本、从本地/网络获取数据等
         */
        override fun initData() {
            // TODO: 根据需要在此处初始化数据,例如读取缓存或接口预热
        }
    
        /**
         * 统一注册点击或交互事件
         * 用于延时跳转到主界面
         */
        override fun allClick() {
            // 使用匿名 Thread 对象启动一个新线程
            object : Thread() {
                override fun run() {
                    try {
                        // 暂停 1 秒(1000 毫秒),模拟停留时间
                        sleep(1000)
                        // 从 SplashActivity 跳转到 MainActivity
                        startActivity(Intent(this@SplashActivity, MainActivity::class.java))
                        // 结束当前 Activity,移除返回栈,避免用户返回到闪屏页
                        finish()
                    } catch (e: InterruptedIOException) {
                        // 捕获可能的中断异常并打印堆栈
                        e.printStackTrace()
                    }
                }
            }.start() // 启动线程
        }
    }
    

    接下来我们运行看一下是否有问题,现在实现的效果进入引导页面一秒后,跳转到还未进行处理的 MainActivity 界面,显示 Hello World!

    接下来我们将去实现一下登录界面,比 SplashActivity 复杂一丢丢,也就一丢丢,循序渐进的去进行项目的开发,也利于新手上手了解。

7. LoginActivity 登录界面

登录界面,UI方面没啥好说的,就是普通的账号密码登录这样,主要是这个界面,将是我们第一个进行网路请求的界面,也将是我们开始使用 MVP 第一个实战模块。

  • activity_login

    <?xml version="1.0" encoding="utf-8"?>
    <androidx.constraintlayout.widget.ConstraintLayout 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"
        android:id="@+id/main"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".presentation.login.LoginActivity">
    
        <ImageView
            android:id="@+id/iv_login_logo"
            android:layout_width="@dimen/dp_70"
            android:layout_height="@dimen/dp_70"
            android:contentDescription="@null"
            android:src="@mipmap/ic_logo"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toTopOf="parent" />
    
        <LinearLayout
            android:id="@+id/ll_input_username"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginTop="@dimen/dp_20"
            android:background="@color/white"
            android:orientation="horizontal"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toBottomOf="@id/iv_login_logo">
    
            <TextView
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_weight="1"
                android:paddingLeft="@dimen/dp_20"
                android:paddingRight="@dimen/dp_20"
                android:text="@string/username"
                android:textSize="@dimen/sp_16" />
    
            <EditText
                android:id="@+id/et_username"
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_weight="3"
                android:background="@null"
                android:hint="@string/hint_username"
                android:maxLines="1"
                android:padding="@dimen/dp_8"/>
        </LinearLayout>
    
        <LinearLayout
            android:id="@+id/ll_input_password"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginTop="@dimen/dp_20"
            android:background="@color/white"
            android:orientation="horizontal"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toBottomOf="@id/ll_input_username">
    
            <TextView
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_weight="1"
                android:paddingLeft="@dimen/dp_20"
                android:paddingRight="@dimen/dp_20"
                android:text="@string/password"
                android:textSize="@dimen/sp_16" />
    
            <EditText
                android:id="@+id/et_password"
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_weight="3"
                android:background="@null"
                android:hint="@string/hint_password"
                android:inputType="textPassword"
                android:imeOptions="actionDone"
                android:maxLines="1"
                android:padding="@dimen/dp_8" />
    
        </LinearLayout>
    
        <android.widget.Button
            android:id="@+id/btn_login"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginTop="@dimen/dp_30"
            android:background="@drawable/selector_primary_oval"
            android:text="@string/login"
            android:textColor="@color/white"
            android:textSize="@dimen/sp_16"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toBottomOf="@id/ll_input_password" />
    
        <TextView
            android:id="@+id/tv_register"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="@dimen/dp_24"
            android:text="@string/register"
            android:textColor="@color/colorPrimary"
            android:textSize="@dimen/sp_14"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toBottomOf="@id/btn_login" />
    
    
    </androidx.constraintlayout.widget.ConstraintLayout>
    

    布局中为什么使用 android.widget.Button 而不是 Button 这里我就不再多说了,不知道翻之前基础UI学习关于 Button 的地方再看看

    • selector_primary_oval

      <?xml version="1.0" encoding="utf-8"?>
      <selector xmlns:android="http://schemas.android.com/apk/res/android">
          <item android:state_pressed="false">
              <shape>
                  <corners android:radius="999dp" />
                  <solid android:color="@color/colorPrimary" />
              </shape>
          </item>
          <item android:state_pressed="true">
              <shape>
                  <corners android:radius="999dp" />
                  <solid android:color="@color/colorPrimaryDark" />
              </shape>
          </item>
      </selector>
      
  • LoginContract

    /**
     * LoginContract 定义了登录模块的 MVP 三大角色接口:Model、View、Presenter,
     * 用于在登录功能中统一约定各层之间的交互方法,解耦并规范实现。
     */
    interface LoginContract : BaseContract {
    
        /**
         * Model 层接口,负责具体的数据获取和业务逻辑处理。
         * 继承自 IBaseModel,可根据需要扩展更多通用方法。
         */
        interface Model : BaseContract.IBaseModel {
            /**
             * 执行登录操作
             *
             * @param username   用户输入的用户名
             * @param password   用户输入的密码
             * @param listener   回调监听器,用于异步获取结果或错误信息
             */
            suspend fun login(
                username: String,
                password: String,
                listener: RetrofitResponseListener<User>
            )
        }
    
        /**
         * View 层接口,负责通知 Presenter 更新 UI,以及展示登录结果。
         * 继承自 IBaseView,可获取 Activity 上下文等通用方法。
         */
        interface View : BaseContract.IBaseView {
            /**
             * 登录成功时调用
             *
             * @param baseUserBean 服务器返回的用户信息实体
             */
            fun loginSuccess(baseUserBean: User)
    
            /**
             * 登录失败时调用
             *
             * @param errorMessage 错误提示信息,可用于 Toast 或错误页展示
             */
            fun loginError(errorMessage: String)
        }
    
        /**
         * Presenter 层接口,负责接收 View 的用户交互请求,
         * 调用 Model 完成业务逻辑后,将结果反馈给 View。
         * 继承自 IBasePresenter,提供生命周期管理方法。
         */
        interface Presenter : BaseContract.IBasePresenter {
            /**
             * 发起登录请求
             *
             * @param username 用户名
             * @param password 密码
             */
            fun login(username: String, password: String)
        }
    }
    
  • LoginModel

    /**
     * LoginModel 实现了 LoginContract.Model 接口,
     * 负责调用网络接口执行登录逻辑,并通过回调通知调用方结果。
     */
    class LoginModel : LoginContract.Model {
    
        /**
         * 发起登录请求
         *
         * @param username  用户名
         * @param password  密码
         * @param listener  登录结果回调,成功时返回 User 对象,失败时返回错误码和消息
         */
        override suspend fun login(
            username: String,
            password: String,
            listener: RetrofitResponseListener<User>
        ) = launchCoroutine(
            // 正常执行块:调用 RetrofitService.getApiService().login 发起网络请求
            {
                // 发起登录请求,获取封装在 BaseBean<User> 中的响应
                val userBaseBean = RetrofitService.getApiService().login(username, password)
                // 根据返回的 errorCode 判断请求是否成功
                if (userBaseBean.errorCode != 0) {
                    // 请求失败:通过 listener 回调 onError,传入错误码和错误消息
                    listener.onError(userBaseBean.errorCode, userBaseBean.errorMsg)
                } else {
                    // 请求成功:通过 listener 回调 onSuccess,传入解析出的 User 对象
                    listener.onSuccess(userBaseBean.data)
                }
            },
            // 异常处理块:捕获网络或解析过程中的任何异常并打印堆栈
            onError = { e: Throwable ->
                e.printStackTrace()
            }
        )
    }
    
  • LoginPresenter

    /**
     * LoginPresenter 实现了 LoginContract.Presenter,并继承自 BasePresenter,
     * 负责接收 View 层的登录请求,调用 Model 层执行业务,再将结果反馈给 View 层。
     *
     * @param view 传入的 LoginContract.View 实例,用于后续回调 UI 更新
     */
    class LoginPresenter(view: LoginContract.View) :
        BasePresenter<LoginContract.View, LoginContract.Model>(view),
        LoginContract.Presenter {
    
        /**
         * 创建并返回当前 Presenter 对应的 Model 实例
         *
         * @return LoginModel,用于执行实际的网络登录请求
         */
        override fun createModel(): LoginContract.Model = LoginModel()
    
        /**
         * 发起登录请求
         *
         * @param username 用户输入的用户名
         * @param password 用户输入的密码
         */
        override fun login(username: String, password: String) {
            // 使用 BasePresenter 中提供的 coroutineScope 在主线程启动协程
            coroutineScope.launch {
                // 调用 Model 层的 login 方法,传入用户名、密码和回调监听器
                mModel?.login(username, password, object : RetrofitResponseListener<User> {
                    /**
                     * 当登录成功时被回调
                     *
                     * @param response 成功返回的 User 对象
                     */
                    override fun onSuccess(response: User) {
                        // 从弱引用中获取 View 实例,并通知 View 层登录成功
                        mView?.get()?.loginSuccess(response)
                    }
    
                    /**
                     * 当登录失败时被回调
                     *
                     * @param errorCode    后端返回的错误码
                     * @param errorMessage 后端返回的错误描述
                     */
                    override fun onError(errorCode: Int, errorMessage: String) {
                        // 从弱引用中获取 View 实例,并通知 View 层登录失败
                        mView?.get()?.loginError(errorMessage)
                    }
                })
            }
        }
    }
    
  • LoginActivity

    /**
     * LoginActivity 基于 MVP 模式的 Activity,实现了 LoginContract.View,
     * 并通过 BaseMVPActivity 提供的 ViewBinding 与 Presenter 统一管理逻辑。
     */
    class LoginActivity :
        BaseMVPActivity<ActivityLoginBinding, LoginContract.Presenter>({ ActivityLoginBinding.inflate(it) }),
        LoginContract.View {
        /**
         * 创建并返回当前 Activity 对应的 Presenter 实例
         */
        override fun createPresenter(): LoginContract.Presenter = LoginPresenter(this)
        /**
         * 初始化视图组件,可在此处完成 Toolbar、状态栏等 UI 设置
         */
        override fun initView() {
        }
        /**
         * 初始化数据,如从本地或网络预加载必要内容
         */
        override fun initData() {
            setBarTitle("登录")
        }
        /**
         * 注册所有点击事件
         */
        override fun allClick() {
            // 登录按钮点击事件
            binding.btnLogin.setOnClickListener {
                // 获取用户输入
                val username = binding.etUsername.text.toString()
                val password = binding.etPassword.text.toString()
                // 非空校验
                if (ObjectUtil.isEmpty(username)) {
                    show("请输入用户名")
                } else if (ObjectUtil.isEmpty(password)) {
                    show("请输入密码")
                } else {
                    // 显示加载框
                    YUtils.showLoading(this@LoginActivity, "登录中")
                    // 调用 Presenter 发起登录
                    mPresenter.login(username, password)
                }
            }
            // 注册跳转到注册页面
            binding.tvRegister.setOnClickListener {
                show("我要注册!!!")
            }
        }
        /**
         * 登录成功回调
         *
         * @param baseUserBean 登录成功后返回的用户数据
         */
        override fun loginSuccess(baseUserBean: User) {
            // 隐藏加载框
            YUtils.hideLoading()
            // 提示登录成功
            show("登录成功 ${baseUserBean.username}")
            // 保存登录状态与用户名到 SharedPreferences
            SpUtil.setString(MyConfig.USER_NAME,baseUserBean.username)
            SpUtil.setBoolean(MyConfig.IS_LOGIN, true)
            // 跳转到主页面
            startActivity(Intent(this@LoginActivity, MainActivity::class.java))
            // 结束当前页,避免返回时停留此页
            finish()
        }
        /**
         * 登录失败回调
         *
         * @param errorMessage 登录失败的错误信息
         */
        override fun loginError(errorMessage: String) {
            // 隐藏加载框
            YUtils.hideLoading()
            // 提示登录失败
            show("登录失败 $errorMessage")
        }
        /**
         * IBaseView 要求实现的方法,返回当前 Activity 用于通用操作(如显示 Toast)
         */
        override fun getActivity(): Activity = this
    
    }
    

    至此我们完成了登陆界面的编写,运行一下,发现为什么还是直接进入到了 Hello Wrold! 界面呢?修改 SplashActivity 代码如下

    class SplashActivity : BaseActivity<ActivitySplashBinding>({ inflater ->
        // 通过 lambda 传入 LayoutInflater,初始化 ViewBinding
        ActivitySplashBinding.inflate(inflater)
    }) {
        .
        .
        .
        .
        /**
         * 统一注册点击或交互事件
         * 用于延时跳转到主界面
         */
        override fun allClick() {
            // 使用匿名 Thread 对象启动一个新线程
            object : Thread() {
                override fun run() {
                    try {
                        // 暂停 1 秒(1000 毫秒),模拟停留时间
                        sleep(1000)
                        // 根据登录状态进行页面跳转
                        if (SpUtil.getBoolean(MyConfig.IS_LOGIN)) {
                            startActivity(Intent(this@SplashActivity, MainActivity::class.java))
                        } else {
                            startActivity(Intent(this@SplashActivity, LoginActivity::class.java))
                        }
                        // 结束当前 Activity,移除返回栈,避免用户返回到闪屏页
                        finish()
                    } catch (e: InterruptedIOException) {
                        // 捕获可能的中断异常并打印堆栈
                        e.printStackTrace()
                    }
                }
            }.start() // 启动线程
        }
        .
        .
        .
    }
    

    然后我们在运行一下,发现终于成功的进入了 LoginActivity 界面,我们尝试一下登录吧,没有账号的可以去玩Android - wanandroid.com - 每日推荐优质文章下注册一个,或者先完成后面的注册界面在来登录。输入正确的账号密码后,登录成功!!!!说明目前来说,写的代码还没有啥bug,咱们真牛o( ̄▽ ̄)d。但是由于还没有做退出登录的操作,所以登录成功之后再进去,会一直跳转到 Hello World! , 后面会处理,现在我们将 APP 卸载掉,在run就行了。

    记得修改注册按钮点击事件

    // 注册跳转到注册页面
    binding.tvRegister.setOnClickListener {
        startActivity(Intent(this@LoginActivity, RegisterActivity::class.java))
    }
    

8. RegisterActivity 注册界面

登录注册不分家~所以注册界面也有必要,和登录界面的难度一样,没啥复杂的,咱们直接上手!后面例如网络请求这些处理的作者我就不写注释了,不是因为我懒,而是因为我相信各位读者能力,肯定看的懂 (~ ̄▽ ̄)~。

  • activity_register

    <?xml version="1.0" encoding="utf-8"?>
    <androidx.constraintlayout.widget.ConstraintLayout 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"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="#F3EFEF"
        android:padding="@dimen/dp_30"
        tools:context=".presentation.register.RegisterActivity">
    
        <ImageView
            android:id="@+id/iv_login_logo"
            android:layout_width="@dimen/dp_70"
            android:layout_height="@dimen/dp_70"
            android:contentDescription="@null"
            android:src="@mipmap/ic_logo"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toTopOf="parent" />
    
        <LinearLayout
            android:id="@+id/ll_input_username"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginTop="@dimen/dp_20"
            android:background="@color/white"
            android:orientation="horizontal"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toBottomOf="@id/iv_login_logo">
    
            <TextView
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_weight="1"
                android:paddingLeft="@dimen/dp_20"
                android:paddingRight="@dimen/dp_20"
                android:text="@string/username"
                android:textSize="@dimen/sp_16" />
    
            <EditText
                android:id="@+id/et_username"
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_weight="3"
                android:background="@null"
                android:hint="@string/hint_username"
                android:maxLines="1"
                android:padding="@dimen/dp_8" />
        </LinearLayout>
    
        <LinearLayout
            android:id="@+id/ll_input_password"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginTop="@dimen/dp_20"
            android:background="@color/white"
            android:orientation="horizontal"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toBottomOf="@id/ll_input_username">
    
            <TextView
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_weight="1"
                android:paddingLeft="@dimen/dp_20"
                android:paddingRight="@dimen/dp_20"
                android:text="@string/password"
                android:textSize="@dimen/sp_16" />
    
            <EditText
                android:id="@+id/et_password"
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_weight="3"
                android:background="@null"
                android:hint="@string/hint_password"
                android:imeOptions="actionDone"
                android:inputType="textPassword"
                android:maxLines="1"
                android:padding="@dimen/dp_8" />
    
        </LinearLayout>
    
        <LinearLayout
            android:id="@+id/ll_input_password_again"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginTop="@dimen/dp_20"
            android:background="@color/white"
            android:orientation="horizontal"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toBottomOf="@id/ll_input_password">
    
            <TextView
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_weight="1"
                android:paddingLeft="@dimen/dp_20"
                android:paddingRight="@dimen/dp_20"
                android:text="@string/password"
                android:textSize="@dimen/sp_16" />
    
            <EditText
                android:id="@+id/et_password_again"
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_weight="3"
                android:background="@null"
                android:hint="@string/hint_password_again"
                android:inputType="textPassword"
                android:imeOptions="actionDone"
                android:maxLines="1"
                android:padding="@dimen/dp_8" />
    
        </LinearLayout>
    
        <Button
            android:id="@+id/btn_register"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginTop="@dimen/dp_30"
            android:background="@drawable/selector_primary_oval"
            android:text="@string/register"
            android:textColor="@color/white"
            android:textSize="@dimen/sp_16"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/ll_input_password_again" />
    
    </androidx.constraintlayout.widget.ConstraintLayout>
    
  • RegisterContract

    interface RegisterContract : BaseContract {
    
        interface Model : BaseContract.IBaseModel {
            suspend fun register(
                username: String,
                password: String,
                passwordAgain: String,
                listener: RetrofitResponseListener<User>
            )
        }
    
        interface View : BaseContract.IBaseView {
            fun registerSuccess(baseUserBean: User)
            fun registerError(errorMessage: String)
        }
    
        interface Presenter : BaseContract.IBasePresenter {
            fun register(username: String, password: String, passwordAgain: String)
        }
    
    }
    
  • RegisterModel

    class RegisterModel : RegisterContract.Model {
        override suspend fun register(
            username: String,
            password: String,
            passwordAgain: String,
            listener: RetrofitResponseListener<User>
        ) = launchCoroutine({
            val userBaseBean = RetrofitService.getApiService()
                .register(username, password, passwordAgain)
            if (userBaseBean.errorCode != 0) {
                listener.onError(userBaseBean.errorCode, userBaseBean.errorMsg)
            } else {
                listener.onSuccess(userBaseBean.data)
            }
        }, onError = { e: Throwable ->
            e.printStackTrace()
        })
    
    }
    
  • RegisterPresenter

    class RegisterPresenter(view: RegisterContract.View) :
        BasePresenter<RegisterContract.View, RegisterContract.Model>(view), RegisterContract.Presenter {
    
        override fun createModel(): RegisterContract.Model = RegisterModel()
    
        override fun register(username: String, password: String, passwordAgain: String) {
            coroutineScope.launch {
                mModel?.register(
                    username,
                    password,
                    passwordAgain,
                    object : RetrofitResponseListener<User> {
    
                        override fun onSuccess(response: User) {
                            mView?.get()?.registerSuccess(response)
                        }
    
                        override fun onError(errorCode: Int, errorMessage: String) {
                            mView?.get()?.registerError(errorMessage)
                        }
    
                    })
            }
        }
    
    }
    
  • RegisterActivity

    class RegisterActivity : BaseMVPActivity<ActivityRegisterBinding, RegisterContract.Presenter>({
        ActivityRegisterBinding.inflate(it)
    }), RegisterContract.View {
    
        override fun createPresenter(): RegisterContract.Presenter {
            return RegisterPresenter(this)
        }
    
        override fun getActivity(): Activity {
            return this
        }
    
        override fun initView() {
            setBackEnabled()
        }
    
        override fun initData() {
            setBarTitle("注册")
        }
    
        override fun allClick() {
            binding.btnRegister.setOnClickListener {
                val username = binding.etUsername.text.toString()
                val password = binding.etPassword.text.toString()
                val passwordAgain = binding.etPasswordAgain.text.toString()
                // 注册信息筛选
                if (ObjectUtil.isEmpty(username)) {
                    show("请输入注册账号")
                } else if (ObjectUtil.isEmpty(password)) {
                    show("请输入密码")
                } else if (ObjectUtil.isEmpty(passwordAgain)) {
                    show("请再次输入密码")
                } else if (password != passwordAgain) {
                    show("请确保两次密码输入一致")
                } else {
                    YUtils.showLoading(this, "注册中")
                    mPresenter.register(username, password, passwordAgain)
                }
            }
        }
    
        override fun registerSuccess(baseUserBean: User) {
            YUtils.hideLoading()
            show("注册成功,请登录")
            finish() // 结束当前注册界面, 退出到登录界面
        }
    
        override fun registerError(errorMessage: String) {
            YUtils.hideLoading()
            show("$errorMessage") // 提示注册失败信息
        }
    
    }
    

    至此我们完成注册界面的实现,没有注册过的读者赶紧尝试注册一下吧,注册完再去验证一下登录是否成功吧>O<。

9. MainActiviy 主界面

我们通过阅读 玩Android 开放API-玩Android - wanandroid.com API文档,知道我们项目可以实现的页面有很多很多,那么这么多页面我们该如何实现呢?在这里项目中将使用 Viewpager + fragment 实现 可滑动分页 的UI模式,在 MainActivity 中切换多个 Fragment ,接下来让我们看看怎么实现吧,项目中主要实现首页、体系、导航、项目的内容,其他需要读者自行进行拓展开发了。现在我们一步一步来

activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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"
    android:id="@+id/main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".presentation.main.MainActivity">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical">

        <androidx.viewpager.widget.ViewPager
            android:id="@+id/view_pager"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            android:layout_weight="1" />

        <com.google.android.material.bottomnavigation.BottomNavigationView
            android:id="@+id/bottom_navigation"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:background="?android:attr/windowBackground"
            app:menu="@menu/bottom_navigation" />

    </LinearLayout>

</androidx.constraintlayout.widget.ConstraintLayout>
  • ViewPager

    使用户能够左右滑动在不同页面之间切换,是基于经典滑动分页模式的组件,在本项目,我们将 HomeFragmentProjectFragmentTreeFragmentNavFragment 通过适配器加载到 ViewPager 中,通过文字描述,是不是感觉有点像 RecyclerView 一样?ViewPager(尤其是 ViewPager2)和 RecyclerView 在底层设计上都采用了Adapter+LayoutManager(或类似机制)的模式,因此二者在使用感、代码结构和性能优化思路上有诸多相似之处,所以很容易上手的,这里不再进行详细描述哈~

  • BottomNavigationView

    是 Material Design 指南中底部导航栏的标准实现,适合在应用中承载 3–5 个“顶级”导航目的地,让用户可一键切换主界面

res 资源文件夹下,创建一个 menu 文件夹,在该文件夹中,我们创建 bottom_navigation 文件

<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">

    <item
        android:id="@+id/navigation_home"
        android:icon="@drawable/ic_home"
        android:title="@string/title_home" />

    <item
        android:id="@+id/navigation_tree"
        android:icon="@drawable/ic_tree"
        android:title="@string/title_tree" />

    <item
        android:id="@+id/navigation_navi"
        android:icon="@drawable/ic_navi"
        android:title="@string/title_navi" />

    <item
        android:id="@+id/navigation_project"
        android:icon="@drawable/ic_project"
        android:title="@string/title_project" />

</menu>

这是底部导航栏的资源文件,icon 资源网上一搜一大把 iconfont-阿里巴巴矢量图标库 这里有很多免费的,读者没有这些小图标就去搜吧

MainActivity

class MainActivity : BaseActivity<ActivityMainBinding>({ ActivityMainBinding.inflate(it) }) {

    /**
     * 初始化视图,在此方法中调用 initFragments(),
     * 用于设置 ViewPager 和 BottomNavigationView 之间的联动
     */
    override fun initView() {
        initFragments()
    }

    /**
     * 初始化数据,这里暂无数据加载逻辑,可根据需求补充
     */
    override fun initData() {

    }

    /**
     * 注册所有点击和滑动事件监听
     */
    override fun allClick() {
        /**
         * ViewPager 滑动监听:
         * 当页面滑动时,根据当前位置更新 BottomNavigationView 的选中状态
         */
        binding.viewPager.addOnPageChangeListener(object : OnPageChangeListener {
            override fun onPageScrolled(
                position: Int,
                positionOffset: Float,
                positionOffsetPixels: Int
            ) {
                // 将对应 menu 项设为选中,以同步底部导航栏状态
                binding.bottomNavigation.menu[position].isChecked = true
            }

            override fun onPageSelected(position: Int) {
                // 页面选中时的回调(可选,用于额外逻辑)
            }

            override fun onPageScrollStateChanged(state: Int) {
                // 滚动状态变化时的回调(可选,用于额外逻辑)
            }
        })

        /**
         * BottomNavigationView 点击事件:
         * 根据用户点击切换 ViewPager 的当前页面
         */
        binding.bottomNavigation.setOnNavigationItemSelectedListener {
            when (it.itemId) {
                R.id.navigation_home -> {
                    // 切换到首页 Fragment
                    binding.viewPager.currentItem = 0
                    return@setOnNavigationItemSelectedListener true
                }
                R.id.navigation_tree -> {
                    // 切换到体系 Fragment
                    binding.viewPager.currentItem = 1
                    return@setOnNavigationItemSelectedListener true
                }
                R.id.navigation_navi -> {
                    // 切换到导航 Fragment
                    binding.viewPager.currentItem = 2
                    return@setOnNavigationItemSelectedListener true
                }
                R.id.navigation_project -> {
                    // 切换到项目 Fragment
                    binding.viewPager.currentItem = 3
                    return@setOnNavigationItemSelectedListener true
                }
            }
            false
        }
    }

    /**
     * 初始化 ViewPager 的 Fragment 列表并绑定适配器
     */
    private fun initFragments() {
        // 创建适配器并添加各个页面的 Fragment
        val viewPagerAdapter = CommonViewPageAdapter(supportFragmentManager).apply {
            addFragment(HomeFragment())    // 首页
            addFragment(TreeFragment())    // 体系
            addFragment(NaviFragment())    // 导航
            addFragment(ProjectFragment()) // 项目
        }
        // 设置 ViewPager 的预加载页面数,保证左右各保留最多 3 个 Fragment
        binding.viewPager.offscreenPageLimit = 3
        // 绑定适配器
        binding.viewPager.adapter = viewPagerAdapter
        // 保留 BottomNavigationView 引用,实际操作在 allClick() 中完成
        binding.bottomNavigation
    }
}

代码中的首页、体系、导航、项目这几个模块,我们抽离出来成单独的模块,在对应模块中进行对应界面数据的获取,项目架构如下所示:

在这里插入图片描述

VIewPagerCommonViewPageAdapter

class CommonViewPageAdapter : FragmentPagerAdapter {

    // 可选的页面标题列表,用于 getPageTitle() 返回
    private var mTitles: List<String>? = null

    // 存放实际要展示的 Fragment 列表
    private var mFragments: MutableList<Fragment> = ArrayList()

    /**
     * 构造函数:仅传入 FragmentManager
     * @param fm 管理 Fragment 的 FragmentManager,必须非空
     */
    constructor(fm: FragmentManager) : super(fm)

    /**
     * 构造函数:传入 FragmentManager 和页面标题列表
     * @param fm     管理 Fragment 的 FragmentManager
     * @param titles 每个页面对应的标题列表
     */
    constructor(fm: FragmentManager?, titles: List<String>?) : super(fm!!) {
        mTitles = titles
    }

    /**
     * 向适配器中动态添加一个 Fragment 页面
     * @param fragment 要添加的 Fragment 实例
     */
    fun addFragment(fragment: Fragment) {
        mFragments.add(fragment)
    }

    /**
     * 返回总页数,即 Fragment 列表的大小
     */
    override fun getCount(): Int {
        return mFragments.size
    }

    /**
     * 根据位置返回对应的 Fragment
     * @param position 页面索引,从 0 开始
     */
    override fun getItem(position: Int): Fragment {
        return mFragments[position]
    }

    /**
     * 返回每个页面的标题,用于 TabLayout 等组件显示
     * @param position 页面索引
     * @return 对应位置的标题字符串
     */
    override fun getPageTitle(position: Int): CharSequence? {
        // mTitles 非空并包含足够元素时返回对应标题
        return mTitles!![position]
    }
}

至此,我们的主界面总算完成了整个项目容器的搭建,运行一下,登陆成功之后,成功进入主界面,底部四个导航栏,页面左右可以滑动,点击底部导航也可以进行页面的滑动,但是内容都是 Hello blank fragment ,那是因为我们还没有对对应的界面进行设计,那么接下来们开始我们首页的 Fragment 的页面设计开发吧,这里要清楚的知道 FragmentActivity 还是有写区别的,貌似之前的文章没有详细讲解过有啥区别,那么这里就当做文章拓展内容,我们先讲一下 ActivityFragment 之间的区别,以及什么时候用,怎么用 Fragment ,并且简单的介绍一下这两者的生命周期。

10. ActivityFragment

在 Android 中,Activity 是应用的基本界面组件,代表一个可与用户交互的屏幕,而 Fragment 则是可复用的 UI 片段,需要依附在 Activity 或其他 Fragment 中才能存在。Activity 拥有独立的任务栈和系统级生命周期回调,而 Fragment 的生命周期则在宿主 Activity 的管理之下,更轻量且适合在同一屏幕内动态展示、添加或替换不同模块。通常,当需要全屏、独立的功能时使用 Activity;当需要多 Pane 布局、Tab 切换或可复用的 UI 片段时使用 Fragment。下面详细展开

Activity

  • Activity 是一个应用组件,它为用户提供一个交互界面,用于完成具体操作(如查看邮件列表、编写信息等)。
  • 每个应用至少包含一个被声明为主界面的 Activity,通过 <activity android:name="…">AndroidManifest.xml 中注册。
  • 应用可包含多个 Activity,它们在“任务”(Task)中按打开顺序组成后退栈(Back Stack),用户可通过系统 Back 按钮在它们之间导航。
  • Activity 的生命周期由系统以六个核心回调管理:onCreate()onStart()onResume()onPause()onStop()onDestroy(),并可通过 onRestart() 在停止后重新启动。
    • onCreate():初始化 UI 与数据;
    • onStart():Activity 即将对用户可见;
    • onResume():Activity 已在前台并可交互;
    • onPause():另一 Activity 获得焦点,进行短暂挂起;
    • onStop():不再可见;
    • onDestroy():即将被销毁,释放所有资源。
  • 使用 Activity 的场景
    • 全屏独立页面:如登录、设置、引导页等,需独立任务上下文。
    • 系统入口或 Deep Link:对于外部启动点(广播、通知、URL 解析),通常映射到特定 Activity。
    • 跨 Task 弹出窗口:如 AlertDialog 风格的透明 Activity,需脱离宿主视图。

Fragment

  • Fragment 代表可重用的 UI 片段,自带布局和生命周期,但不能独立存在,必须被添加到一个宿主 Activity(或父级 Fragment)中。
  • Fragment 拥有自己的生命周期回调(如 onAttach()onCreateView() 等),并通过 FragmentManager 进行增删、替换和回退管理。
  • 它们设计用于在平板(多 Pane)与手机(单 Pane)等不同屏幕尺寸上复用界面逻辑,并支持运行时动态添加或移除。
  • Fragment 生命周期更细,包含:onAttach()onCreate()onCreateView()onActivityCreated()onStart()onResume()onPause()onStop()onDestroyView()onDestroy()onDetach()
    • onAttach():与宿主 Activity 建立关联;
    • onCreateView():创建并返回 UI 视图;
    • onActivityCreated():宿主 Activity 的 onCreate() 完成后回调;
    • 随后的 onStart()onResume() 等与 Activity 类似;
    • onDestroyView():销毁 UI,但 Fragment 对象仍在;
    • onDestroy()onDetach():释放资源并与宿主断开。
  • 使用 Fragment 的场景
    • 多 Pane 布局:在平板或大屏上同时展示列表与详情。
    • Tab / ViewPager:通过 ViewPager2 + FragmentStateAdapter 实现滑屏分页布局。
    • 动态 UI 组合:运行时根据业务需求添加或替换不同的 UI 模块。
    • 可重用组件:同一逻辑在多个 Activity 或上下文中被复用。

11. HomeFragment 首页

讲到这里,所以首页到底要怎么设计呢?问得好,我也不知道…又没有产品、UI设计,我还真不知道,哈哈哈,在网上搜了一大堆做这个玩安卓项目的APK,就拿那些来做示例吧。

HomeContract

/**
 * HomeContract 定义了首页模块在 MVP 架构中的三大角色接口:
 * - Model:负责数据获取与业务处理
 * - View:负责 UI 更新与用户交互回调
 * - Presenter:负责协调 View 与 Model、执行业务逻辑
 */
interface HomeContract : BaseContract {

    /**
     * Model 层接口,继承自 IBaseModel,可挂载公共生命周期管理方法
     */
    interface Model : BaseContract.IBaseModel {
        /**
         * 获取轮播图数据
         *
         * @param listener 网络请求回调,返回 Banner 列表或错误信息
         */
        suspend fun getBanner(listener: RetrofitResponseListener<MutableList<Banner>>)

        /**
         * 获取首页文章列表(第一页或指定页)
         *
         * @param page     页码,从 0 或 1 开始
         * @param listener 网络请求回调,返回 Article 对象或错误信息
         */
        suspend fun getArticleList(page: Int, listener: RetrofitResponseListener<Article>)

        /**
         * 拉取更多文章(分页加载下一页)
         *
         * @param page     下一页页码
         * @param listener 网络请求回调,返回 Article 对象或错误信息
         */
        suspend fun getMoreArticleList(page: Int, listener: RetrofitResponseListener<Article>)

        /**
         * 收藏文章
         *
         * @param id       待收藏的文章 ID
         * @param listener 网络请求回调,返回成功提示或错误信息
         */
        suspend fun collect(id: Int, listener: RetrofitResponseListener<String>)

        /**
         * 取消收藏文章
         *
         * @param id       待取消收藏的文章 ID
         * @param listener 网络请求回调,返回成功提示或错误信息
         */
        suspend fun unCollect(id: Int, listener: RetrofitResponseListener<String>)
    }

    /**
     * View 层接口,继承自 IBaseView,可获取 Activity 对象等通用方法
     */
    interface View : BaseContract.IBaseView {

        // 轮播图回调
        fun getBannerSuccess(bannerList: MutableList<Banner>)  // 成功
        fun getBannerError(errorMessage: String)               // 失败

        // 文章列表回调
        fun getArticleListSuccess(article: Article)            // 成功
        fun getArticleListError(errorMessage: String)          // 失败

        // 加载更多文章回调
        fun getMoreArticleListSuccess(article: Article)        // 成功
        fun getMoreArticleListError(errorMessage: String)      // 失败

        // 收藏回调
        fun collectSuccess(successMessage: String)             // 收藏成功
        fun collectError(errorMessage: String)                 // 收藏失败

        // 取消收藏回调
        fun unCollectSuccess(successMessage: String)           // 取消收藏成功
        fun unCollectError(errorMessage: String)               // 取消收藏失败

        // 用户未登录时的提示(可跳转到登录页)
        fun login(msg: String)
    }

    /**
     * Presenter 层接口,继承自 IBasePresenter,负责接收 View 的调用并调度 Model
     */
    interface Presenter : BaseContract.IBasePresenter {
        /**
         * 请求获取轮播图
         */
        fun getBanner()

        /**
         * 请求获取文章列表
         *
         * @param page 页码
         */
        fun getArticleList(page: Int)

        /**
         * 请求加载更多文章
         *
         * @param page 页码
         */
        fun getMoreArticleList(page: Int)

        /**
         * 请求收藏文章
         *
         * @param id 文章 ID
         */
        fun collect(id: Int)

        /**
         * 请求取消收藏
         *
         * @param id 文章 ID
         */
        fun unCollect(id: Int)
    }
}

HomeModel

/**
 * HomeModel:MVP 模式中 Home 模块的 Model 实现
 * 接收 Presenter 请求获取数据,并返回给 Presenter
 */
class HomeModel : HomeContract.Model {

    /**
     * 获取轮播图数据
     * 通过 launchCoroutine 在协程中执行网络请求,简化异步处理
     */
    override suspend fun getBanner(listener: RetrofitResponseListener<MutableList<Banner>>) {
        return launchCoroutine({
            // 调用 RetrofitService 获取 Banner 列表
            val baseBannerBean = RetrofitService.getApiService().getBanner()
            MLog.d("baseBannerBean = ${baseBannerBean.data.toJsonString()}")

            // 根据返回的 errorCode 决定回调 success 还是 error
            if (baseBannerBean.errorCode != 0) {
                listener.onError(baseBannerBean.errorCode, baseBannerBean.errorMsg)
            } else {
                listener.onSuccess(baseBannerBean.data)
            }
        }, onError = { e: Throwable ->
            // 网络或解析异常时打印异常信息
            e.printStackTrace()
        })
    }

    /**
     * 获取首页文章列表(分页第一页)
     * @param page  页码,从 0 或 1 开始
     */
    override suspend fun getArticleList(page: Int, listener: RetrofitResponseListener<Article>) {
        return launchCoroutine({
            // 请求第 page 页文章数据
            val baseArticleBean = RetrofitService.getApiService().getArticleList(page)

            if (baseArticleBean.errorCode != 0) {
                listener.onError(baseArticleBean.errorCode, baseArticleBean.errorMsg)
            } else {
                listener.onSuccess(baseArticleBean.data)
            }
        }, onError = { e: Throwable ->
            e.printStackTrace()
        })
    }

    /**
     * 加载更多文章(分页加载后续页)
     * @param page 下一页页码
     */
    override suspend fun getMoreArticleList(
        page: Int,
        listener: RetrofitResponseListener<Article>
    ) {
        return launchCoroutine({
            // 与 getArticleList 相同接口,利用不同 page 参数实现“加载更多”
            val baseArticleBean = RetrofitService.getApiService().getArticleList(page)

            if (baseArticleBean.errorCode != 0) {
                listener.onError(baseArticleBean.errorCode, baseArticleBean.errorMsg)
            } else {
                listener.onSuccess(baseArticleBean.data)
            }
        }, onError = { e: Throwable ->
            e.printStackTrace()
        })
    }

    /**
     * 收藏文章
     * @param id 文章 ID
     */
    override suspend fun collect(id: Int, listener: RetrofitResponseListener<String>) {
        return launchCoroutine({
            // 调用收藏接口
            val baseCollectBean = RetrofitService.getApiService().collect(id)
            if (baseCollectBean.errorCode != 0) {
                listener.onError(baseCollectBean.errorCode, baseCollectBean.errorMsg)
            } else {
                // 收藏成功,返回固定提示
                listener.onSuccess("收藏成功")
            }
        }, onError = { e: Throwable ->
            e.printStackTrace()
        })
    }

    /**
     * 取消收藏文章
     * @param id 文章 ID
     */
    override suspend fun unCollect(id: Int, listener: RetrofitResponseListener<String>) {
        return launchCoroutine({
            // 调用取消收藏接口
            val baseCollectBean = RetrofitService.getApiService().unCollect(id)
            if (baseCollectBean.errorCode != 0) {
                listener.onError(baseCollectBean.errorCode, baseCollectBean.errorMsg)
            } else {
                // 取消收藏成功,返回固定提示
                listener.onSuccess("取消收藏成功")
            }
        }, onError = { e: Throwable ->
            e.printStackTrace()
        })
    }

}

HomePrensenter

/**
 * HomePresenter:MVP 模式中 Home 模块的 Presenter 实现
 * 负责从 Model 获取数据,并将结果回调给 View
 *
 * @param view 关联的 View 层接口,用于回调 UI 更新
 */
class HomePresenter(view: HomeContract.View) :
    BasePresenter<HomeContract.View, HomeContract.Model>(view), HomeContract.Presenter {

    /**
     * 创建并返回对应的 Model 实例
     */
    override fun createModel(): HomeContract.Model = HomeModel()

    /**
     * 获取轮播图数据
     * - 在协程作用域中启动异步请求
     * - 请求成功时调用 View.getBannerSuccess()
     * - 请求失败时调用 View.getBannerError()
     */
    override fun getBanner() {
        coroutineScope.launch {
            mModel?.getBanner(object : RetrofitResponseListener<MutableList<Banner>> {
                override fun onSuccess(response: MutableList<Banner>) {
                    mView?.get()?.getBannerSuccess(response)
                }
                override fun onError(errorCode: Int, errorMessage: String) {
                    mView?.get()?.getBannerError(errorMessage)
                }
            })
        }
    }

    /**
     * 获取首页文章列表
     * @param page 页码,从 0 或 1 开始
     */
    override fun getArticleList(page: Int) {
        coroutineScope.launch {
            mModel?.getArticleList(page, object : RetrofitResponseListener<Article> {
                override fun onSuccess(response: Article) {
                    mView?.get()?.getArticleListSuccess(response)
                }
                override fun onError(errorCode: Int, errorMessage: String) {
                    mView?.get()?.getArticleListError(errorMessage)
                }
            })
        }
    }

    /**
     * 加载更多文章(分页加载下一页)
     * @param page 下一页页码
     */
    override fun getMoreArticleList(page: Int) {
        coroutineScope.launch {
            mModel?.getArticleList(page, object : RetrofitResponseListener<Article> {
                override fun onSuccess(response: Article) {
                    mView?.get()?.getMoreArticleListSuccess(response)
                }
                override fun onError(errorCode: Int, errorMessage: String) {
                    mView?.get()?.getMoreArticleListError(errorMessage)
                }
            })
        }
    }

    /**
     * 收藏文章
     * @param id 文章 ID
     * - 如果返回错误码 -1001(未登录),调用 View.login() 提示登录
     * - 其他错误调用 View.collectError()
     */
    override fun collect(id: Int) {
        coroutineScope.launch {
            mModel?.collect(id, object : RetrofitResponseListener<String> {
                override fun onSuccess(response: String) {
                    mView?.get()?.collectSuccess(response)
                }
                override fun onError(errorCode: Int, errorMessage: String) {
                    if (errorCode == -1001) {
                        // 用户未登录,触发登录流程
                        mView?.get()?.login(errorMessage)
                    } else {
                        mView?.get()?.collectError(errorMessage)
                    }
                }
            })
        }
    }

    /**
     * 取消收藏文章
     * @param id 文章 ID
     */
    override fun unCollect(id: Int) {
        coroutineScope.launch {
            mModel?.unCollect(id, object : RetrofitResponseListener<String> {
                override fun onSuccess(response: String) {
                    mView?.get()?.unCollectSuccess(response)
                }
                override fun onError(errorCode: Int, errorMessage: String) {
                    mView?.get()?.unCollectError(errorMessage)
                }
            })
        }
    }
}

这里建议读者能手敲一下代码,去体验一下封装之后的 MVP 模块之间的关联性,我们通过定义的 HomeContract 首页界面的契约类 ,这是一个接口,我们对应的 ModelPresenter 都会去实现在契约类声明的方法,假如有新的请求,我们只用在 HomeContract 中添加接口,在对应实现类中,编译器就会让我们强制去实现,这样便于统一管理,我们接着在 View 层, 也就是我们的 HomeFragment 中实现 HomeContract 定义的 View 层接口,就会发现这个模块的每层都各司其职,阅读性和管理性都很好,那么我们接下来看看 HomeFragment 的具体实现吧。

fragment_home

<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout 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"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:fitsSystemWindows="true"
    tools:context=".presentation.home.HomeFragment">

    <com.google.android.material.appbar.AppBarLayout
        android:id="@+id/app_bar"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:fitsSystemWindows="true"
        android:theme="@style/AppTheme.AppBarOverlay">

        <com.google.android.material.appbar.CollapsingToolbarLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:fitsSystemWindows="true"
            app:contentScrim="?attr/colorPrimary"
            app:layout_scrollFlags="scroll|exitUntilCollapsed"
            app:toolbarId="@+id/toolbar">

            <com.youth.banner.Banner
                android:id="@+id/banner"
                android:layout_width="match_parent"
                android:layout_height="200dp"
                app:layout_collapseMode="pin" />

        </com.google.android.material.appbar.CollapsingToolbarLayout>

    </com.google.android.material.appbar.AppBarLayout>

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recycler_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
        app:layout_behavior="@string/appbar_scrolling_view_behavior"
        tools:listitem="@layout/item_article" />

</androidx.coordinatorlayout.widget.CoordinatorLayout>

这个布局看着简单,但是有少些组件我之前没有讲过,这里稍微讲讲哈,别急

在这个布局中,CoordinatorLayoutAppBarLayout/CollapsingToolbarLayout 搭配 RecyclerView、并结合 com.youth.banner.Banner,共同实现了“可折叠头部+列表联动滚动”的典型首页场景。总体思路是:将轮播图放在可折叠的工具栏内,当列表向上滚动时折叠隐藏轮播图;反向滚动时展开轮播图,并且利用 fitsSystemWindows 处理状态栏插图,从而获得浸入式、流畅的 UI 效果。

  • CoordinatorLayout:容器与嵌套滚动枢纽

    CoordinatorLayout 是一个支持复杂、重叠和嵌套布局的 ViewGroup,它通过 Behavior 机制,将子视图之间的滑动、折叠、拖拽等交互关联起来。
    在此布局中,它是根容器,包裹 AppBarLayoutRecyclerView,并通过 app:layout_behavior="@string/appbar_scrolling_view_behavior" 将列表的滚动偏移分发给 AppBarLayout,实现头部折叠与内容滚动的联动​。

  • AppBarLayoutCollapsingToolbarLayout:可折叠头部

    AppBarLayout 是一个垂直线性布局,专门用于承载可滚动的工具栏或头部,并与 CoordinatorLayout 配合,响应嵌套滚动事件。
    其子视图 CollapsingToolbarLayout 则提供“折叠展开”功能,通过 app:layout_scrollFlags="scroll|exitUntilCollapsed" 指定——当内容向上滑动时,先滚动折叠,直至折叠至最小高度;反向滑动则展开至完整高度​。
    CollapsingToolbarLayout 内部,我们放置了 Banner 组件,并给它设置 app:layout_collapseMode="pin",表示该视图在折叠过程中保持固定,不参与平移,而是“钉”在布局顶部直至消失。

  • Banner:头部轮播图

    这里使用的 com.youth.banner.Banner 是一个基于 ViewPager 的第三方轮播控件,支持自动轮播、指示器与自定义动画等功能。
    将其置于 CollapsingToolbarLayout 内,并借助 layout_collapseMode="pin",可在列表滑动时流畅折叠隐藏,同时保持固定位置直到完全收起,提升视觉体验。

  • RecyclerView 与滚动行为联动

    列表部分使用 RecyclerView,并在 XML 中指定 app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" 实现垂直线性布局。
    更关键的是,通过 app:layout_behavior="@string/appbar_scrolling_view_behavior",将 RecyclerView 注册为 AppBarLayout.ScrollingViewBehavior 的滚动兄弟,使其滚动时自动驱动 AppBarLayout 的折叠与展开。

  • fitsSystemWindows :状态栏插图处理

    在根布局、AppBarLayoutCollapsingToolbarLayout 上设置 android:fitsSystemWindows="true",可让它们在沉浸式状态栏模式下正确调整内边距,将内容延伸至状态栏之下,并保证布局不被遮挡。需要注意的是,早期版本中 CollapsingToolbarLayout + fitsSystemWindows 可能存在图片缩放问题,但在 AndroidX 最新版本中已修复相关 BUG。

通过以上架构与配置,就可以快速构建一个带有折叠头部、轮播图和滚动列表联动的高端首页界面。

@style/AppTheme.AppBarOverlay

在资源文件 vlues 目录中的 themes.xml 添加这个主题

<style name="AppTheme.AppBarOverlay" parent="ThemeOverlay.AppCompat.Dark.ActionBar" />

item_article.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    app:cardBackgroundColor="@color/white"
    app:cardCornerRadius="@dimen/dp_5"
    app:cardElevation="@dimen/dp_2"
    app:contentPadding="@dimen/dp_15"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:foreground="?android:attr/selectableItemBackground">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:gravity="center_vertical"
        android:orientation="horizontal">

        <ImageView
            android:id="@+id/article_image"
            android:layout_width="@dimen/dp_60"
            android:layout_height="@dimen/dp_60"
            android:contentDescription="@null"
            android:src="@mipmap/ic_launcher" />

        <LinearLayout
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginStart="@dimen/dp_15"
            android:layout_marginLeft="@dimen/dp_15"
            android:layout_marginEnd="@dimen/dp_10"
            android:layout_marginRight="@dimen/dp_10"
            android:layout_weight="1"
            android:orientation="vertical">

            <TextView
                android:id="@+id/article_title"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:ellipsize="end"
                android:maxLines="2"
                android:text="标题"
                android:textColor="@color/black"
                android:textSize="@dimen/sp_16" />

            <LinearLayout
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:layout_marginTop="@dimen/dp_10"
                android:gravity="center_vertical"
                android:orientation="horizontal">

                <TextView
                    android:id="@+id/article_chapter"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_marginRight="@dimen/dp_5"
                    android:background="@drawable/shape_border_primary_oval"
                    android:includeFontPadding="false"
                    android:paddingLeft="@dimen/dp_6"
                    android:paddingTop="@dimen/dp_2"
                    android:paddingRight="@dimen/dp_6"
                    android:paddingBottom="@dimen/dp_2"
                    android:text="标题"
                    android:textColor="@color/colorPrimary"
                    android:textSize="@dimen/sp_12" />

                <TextView
                    android:id="@+id/article_date"
                    android:layout_width="0dp"
                    android:layout_height="wrap_content"
                    android:layout_weight="1"
                    android:ellipsize="end"
                    android:gravity="end"
                    android:maxLines="1"
                    android:text="标题"
                    android:textColor="@color/gray" />

            </LinearLayout>

        </LinearLayout>

        <ImageView
            android:id="@+id/article_favorite"
            android:layout_width="@dimen/dp_30"
            android:layout_height="@dimen/dp_30"
            android:contentDescription="@null"
            android:src="@drawable/ic_like_normal" />

    </LinearLayout>

</androidx.cardview.widget.CardView>

在这里插入图片描述

  • 容器:CardView
    • 使用场景CardView 提供了符合 Material Design 的卡片式样,包括圆角、阴影和背景色,常用于列表或网格中的每一项承载独立内容。
    • 属性说明
      • app:cardCornerRadius="@dimen/dp_5" 设置圆角半径,使卡片四角平滑。
      • app:cardElevation="@dimen/dp_2" 增加阴影高度,突显卡片层次。
      • app:contentPadding="@dimen/dp_15" 在卡片内容区域内统一留白,增强视觉舒适度。
      • android:foreground="?android:attr/selectableItemBackground" 继承自 FrameLayout 的前景属性,提供触摸时的水波/选中效果,提升交互反馈

其他资源文件读者自己处理一下哈~至此我们首页的UI设计就完成了,那么接下来,我们看看实际如何处理数据,并且将数据添加到布局中

HomeArticleAdapter


/**
 * ArticleAdapter 继承自 BRVAH 提供的 BaseQuickAdapter,
 * 用于为 RecyclerView 提供文章列表的 ViewHolder 绑定与加载更多功能。
 *
 * @param ArticleDetail 数据类型,对应单条文章详情
 * @param BaseViewHolder BRVAH 自带的简单 ViewHolder
 * @param R.layout.item_article 列表项布局资源 ID
 */
class HomeArticleAdapter : BaseQuickAdapter<ArticleDetail, BaseViewHolder>(R.layout.item_article),
    LoadMoreModule {  // 启用“加载更多”模块

    init {
        // 注册 child view 点击事件,这里指定点赞图标可响应点击
        addChildClickViewIds(R.id.article_favorite)
    }

    /**
     * 将数据绑定到 ViewHolder
     *
     * @param holder BRVAH 提供的 BaseViewHolder,封装了 itemView
     * @param item   当前绑定的数据对象 ArticleDetail
     */
    override fun convert(holder: BaseViewHolder, item: ArticleDetail) {
        // 将 HTML 格式的标题字符串转换为富文本并设置到标题 TextView
        holder.setText(R.id.article_title, Html.fromHtml(item.title))

        // 设置章节名称
        holder.setText(R.id.article_chapter, item.chapterName)

        // 设置友好日期字符串
        holder.setText(R.id.article_date, item.niceDate)

        // 根据是否已收藏,加载不同的图标到点赞 ImageView
        val favView = holder.getView<ImageView>(R.id.article_favorite)
        if (item.collect) {
            // 已收藏时,显示高亮的心形图标
            Glide.with(context)
                .load(R.drawable.ic_like_checked)
                .into(favView)
        } else {
            // 未收藏时,显示灰色的心形图标
            Glide.with(context)
                .load(R.drawable.ic_like_normal)
                .into(favView)
        }
    }

}

HomeFragment

/**
 * HomeFragment:基于 MVP 模式的首页 Fragment
 * - 继承自 BaseMVPFragment,实现 HomeContract.View 及各类回调接口
 * - 负责展示轮播图、文章列表、加载更多、收藏逻辑等
 */
class HomeFragment :
    BaseMVPFragment<FragmentHomeBinding, HomeContract.Presenter>({ FragmentHomeBinding.inflate(it) }),
    HomeContract.View,                        // 与 Presenter 交互的 View 接口
    OnBannerListener,                         // Banner 点击回调
    OnLoadMoreListener,                       // 上拉加载更多回调
    OnItemClickListener,                      // 列表项点击回调
    OnItemChildClickListener {                // 列表子项点击回调

    companion object {
        private const val TOTAL_COUNTER = 20 // 每次请求的最大条目数
        private var CURRENT_SIZE = 0         // 当前已加载条目数
        private var CURRENT_PAGE = 0         // 当前加载的页码
    }

    // 轮播图数据列表
    private lateinit var bannerList: List<Banner>
    // 文章数据列表
    private lateinit var mDataList: MutableList<ArticleDetail>
    // 列表适配器
    private lateinit var mHomeArticleAdapter: HomeArticleAdapter
    // 当前操作的文章位置(用于收藏/取消收藏回调)
    private var mPosition: Int = 0

    override fun initView() {
        // 此处可初始化视图组件,已在布局中通过 ViewBinding 关联
    }

    override fun initData() {
        // 请求轮播图和第一页文章列表
        mPresenter.getBanner()
        mPresenter.getArticleList(CURRENT_PAGE)
    }

    override fun allClick() {
        // 此处可统一注册额外点击事件
    }

    override fun createPresenter(): HomeContract.Presenter {
        // 创建并返回 Presenter 实例
        return HomePresenter(this)
    }

    // ==================== Banner 回调 ====================

    override fun getBannerSuccess(bannerList: MutableList<Banner>) {
        this.bannerList = bannerList

        // 提取图片地址和标题
        val images: MutableList<String> = ArrayList()
        val titles: MutableList<String> = ArrayList()
        for (index in bannerList.indices) {
            images.add(bannerList[index].imagePath)
            titles.add(bannerList[index].title)
        }

        // 根据屏幕高度动态设置 Banner 高度
        val layoutParams = binding.banner.layoutParams
        layoutParams.height = (DisplayUtil.getScreenHeight() / 1.8).roundToInt()

        // 配置并启动 Banner
        binding.banner.setImages(images)
            .setBannerTitles(titles)
            .setBannerStyle(BannerConfig.CIRCLE_INDICATOR_TITLE_INSIDE)
            .setImageLoader(GlideImageLoader())
            .start()

        // 监听 Banner 点击事件
        binding.banner.setOnBannerListener(this)
    }

    override fun getBannerError(errorMessage: String) {
        // 轮播图加载失败,弹出提示
        show(errorMessage)
    }

    // ==================== 文章列表回调 ====================

    override fun getArticleListSuccess(article: Article) {
        // 保存当前加载条数和数据
        CURRENT_SIZE = article.size
        mDataList = article.datas

        // 初始化并配置适配器
        mHomeArticleAdapter = HomeArticleAdapter().apply {
            animationEnable = true                     // 启用加载动画
            setOnItemClickListener(this@HomeFragment)  // 列表项点击
            setOnItemChildClickListener(this@HomeFragment) // 子项点击(收藏图标)
            loadMoreModule.setOnLoadMoreListener(this@HomeFragment) // 加载更多
        }

        // 绑定适配器并填充数据
        binding.recyclerView.adapter = mHomeArticleAdapter
        mHomeArticleAdapter.setList(mDataList)
    }

    override fun getArticleListError(errorMessage: String) {
        // 文章列表加载失败,弹出提示
        show(errorMessage)
    }

    override fun getMoreArticleListSuccess(article: Article) {
        // 分页加载成功,追加数据并通知适配器
        mDataList.addAll(article.datas)
        CURRENT_SIZE = article.datas.size
        mHomeArticleAdapter.addData(article.datas)
        mHomeArticleAdapter.loadMoreModule.loadMoreComplete()
    }

    override fun getMoreArticleListError(errorMessage: String) {
        // 分页加载失败,弹出提示
        show(errorMessage)
    }

    // ==================== 收藏/取消收藏回调 ====================

    override fun collectSuccess(successMessage: String) {
        // 收藏成功,更新数据并刷新列表
        show(successMessage)
        mDataList[mPosition].collect = true
        mHomeArticleAdapter.notifyDataSetChanged()
    }

    override fun collectError(errorMessage: String) {
        // 收藏失败,弹出提示
        show(errorMessage)
    }

    override fun unCollectSuccess(successMessage: String) {
        // 取消收藏成功,更新数据并刷新列表
        show(successMessage)
        mDataList[mPosition].collect = false
        mHomeArticleAdapter.notifyDataSetChanged()
    }

    override fun unCollectError(errorMessage: String) {
        // 取消收藏失败,弹出提示
        show(errorMessage)
    }

    // ==================== 登录提示回调 ====================

    override fun login(msg: String) {
        // 弹出对话框提示需登录,并跳转到 LoginActivity
        val builder = AlertDialog.Builder(binding.root.context).apply {
            setTitle("提示")
            setMessage(msg)
            setPositiveButton("确定") { _, _ ->
                startActivity(Intent(binding.root.context, LoginActivity::class.java))
            }
            setNegativeButton("取消", null)
        }
        builder.create().show()
    }

    // ==================== 交互事件 ====================

    override fun onItemChildClick(
        adapter: BaseQuickAdapter<*, *>,
        view: View,
        position: Int
    ) {
        // 子项点击(收藏图标)
        mPosition = position
        if (mDataList[position].collect) {
            mPresenter.unCollect(mDataList[position].id)
        } else {
            mPresenter.collect(mDataList[position].id)
        }
    }

    override fun onLoadMore() {
        // 加载更多时延迟 1s 再执行,模拟加载
        binding.recyclerView.postDelayed({
            if (CURRENT_SIZE < TOTAL_COUNTER) {
                // 数据已加载完毕
                mHomeArticleAdapter.loadMoreModule.loadMoreEnd(true)
            } else {
                // 加载下一页
                CURRENT_PAGE++
                mPresenter.getMoreArticleList(CURRENT_PAGE)
            }
        }, 1000)
    }

    override fun onItemClick(
        adapter: BaseQuickAdapter<*, *>,
        view: View,
        position: Int
    ) {
        // 列表项点击,跳转到详情页
    }

    override fun OnBannerClick(position: Int) {
        // Banner 点击,跳转到对应链接详情页
        
    }

    override fun onDestroyView() {
        super.onDestroyView()
        // Fragment 销毁视图时,停止 Banner 自动轮播,避免资源泄漏
        binding.banner.stopAutoPlay()
    }

}

至此,我们运行一下,因为还没有退出登录功能,所以,建议卸载后再 run ,将流程从头走一次,确保没有bug。登录成功之后,界面如下

在这里插入图片描述

并且我们可以点击进行收藏和取消收藏,要看看是否收藏成功,我们可以登录账号去官方查看

在这里插入图片描述

可以发现没有问题,美滋滋~那么我们接着实现点击跳转至详情界面吧

activity_detail.xml

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout 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"
    android:id="@+id/web_container"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".presentation.detail.DetailActivity">

    <!-- 原生 WebView,用于加载网页 -->
    <WebView
        android:id="@+id/detail_webview"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

</FrameLayout>

DetailActivity

// DetailActivity:展示网页详情,使用原生 WebView
class DetailActivity :
    BaseActivity<ActivityDetailBinding>({ ActivityDetailBinding.inflate(it) }) {

    companion object {
        // Intent 传参的 URL Key
        const val WEB_URL: String = "web_url"
        // Intent 传参的 Title Key
        const val WEB_TITLE: String = "web_title"
    }

    // 原生 WebView 实例
    private lateinit var webView: WebView

    override fun initView() {
        setBackEnabled()  // 启用左上角返回
        // 在布局中找到 WebView
        webView = binding.root.findViewById(R.id.detail_webview)
        initWebView()     // 初始化 WebView 设置
    }

    override fun initData() {
        // 设置标题栏内容
        setBarTitle(intent.getStringExtra(WEB_TITLE) ?: "")
        // 加载页面
        webView.loadUrl(intent.getStringExtra(WEB_URL) ?: "")
    }

    override fun allClick() {
        
    }

    /**
     * 初始化 WebView 各项配置
     */
    @SuppressLint("SetJavaScriptEnabled")
    private fun initWebView() {
        // 让 WebView 获得焦点以响应触摸
        webView.requestFocusFromTouch()

        // 访问 WebSettings 来配置浏览器行为
        val settings: WebSettings = webView.settings
        settings.apply {
            javaScriptEnabled = true            // 支持 JavaScript
            setSupportZoom(true)                // 支持缩放
            builtInZoomControls = true          // 启用缩放控件
            displayZoomControls = false         // 隐藏原生缩放按钮
            useWideViewPort = true              // 支持双层比例,用于适配屏幕
            loadWithOverviewMode = true         // 以概览模式加载
            layoutAlgorithm = WebSettings.LayoutAlgorithm.SINGLE_COLUMN // 单列布局
        }

        // 可选:在 WebView 下添加底部提示文字
        addBGChild(binding.root as FrameLayout)

        // 设置客户端拦截导航,保持在当前 WebView 中打开
        webView.webViewClient = object : WebViewClient() {
            override fun shouldOverrideUrlLoading(
                view: WebView?,
                request: WebResourceRequest?
            ): Boolean {
                // 在当前 WebView 加载新 URL
                view?.loadUrl(request?.url.toString())
                return true
            }
        }
    }

    /**
     * 在 WebView 所在的 FrameLayout 顶部添加提示文字
     */
    private fun addBGChild(frameLayout: FrameLayout) {
        val title = "技术由 WebView 提供"
        val tv = TextView(frameLayout.context).apply {
            text = title
            textSize = 16f
            setTextColor(Color.parseColor("#727779"))
        }
        frameLayout.setBackgroundColor(Color.parseColor("#272b2d"))
        val lp = FrameLayout.LayoutParams(
            FrameLayout.LayoutParams.WRAP_CONTENT,
            FrameLayout.LayoutParams.WRAP_CONTENT
        ).apply {
            gravity = Gravity.CENTER_HORIZONTAL
            // 15dp 转 px
            val scale = resources.displayMetrics.density
            topMargin = (15 * scale + 0.5f).toInt()
        }
        // 将提示文字插入到最底层
        frameLayout.addView(tv, 0, lp)
    }

    /**
     * 按键处理:优先由 WebView 处理返回事件(页面回退)
     */
    override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
        return if (keyCode == KeyEvent.KEYCODE_BACK && webView.canGoBack()) {
            webView.goBack()  // 网页回退
            true
        } else {
            super.onKeyDown(keyCode, event)
        }
    }

    /**
     * Activity 暂停时,暂停 WebView
     */
    override fun onPause() {
        webView.onPause()
        super.onPause()
    }

    /**
     * Activity 恢复时,恢复 WebView
     */
    override fun onResume() {
        webView.onResume()
        super.onResume()
    }

    /**
     * Activity 销毁时,销毁 WebView 以释放资源
     */
    override fun onDestroy() {
        // 先从父容器移除,然后销毁
        (webView.parent as? ViewGroup)?.removeView(webView)
        webView.removeAllViews()
        webView.destroy()
        super.onDestroy()
    }
}

详情界面已经有了,记得在点击事件中进行设置

HomeFragment

override fun onItemClick(
    adapter: BaseQuickAdapter<*, *>,
    view: View,
    position: Int
) {
    // 列表项点击,跳转到详情页
    val intent = Intent(binding.root.context, DetailActivity::class.java).apply {
        putExtra(DetailActivity.WEB_URL, mDataList[position].link)
        putExtra(DetailActivity.WEB_TITLE, mDataList[position].title)
    }
    startActivity(intent)
}

override fun OnBannerClick(position: Int) {
    // Banner 点击,跳转到对应链接详情页
    val intent = Intent(binding.root.context, DetailActivity::class.java).apply {
        putExtra(DetailActivity.WEB_URL, bannerList[position].url)
        putExtra(DetailActivity.WEB_TITLE, bannerList[position].title)
    }
    startActivity(intent)
}

至此我们首页界面的功能就完成了!!!篇幅太长了,其他界面在下一章节再讲吧,读者好好消化一下吧,下一章节咱们在继续

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值