学习Android(四)

简介

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

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

1. 项目准备

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

    kotlin 复制代码
    pluginManagement {
        repositories {
            maven {
                url = uri("https://www.jitpack.io")
            }
            maven {
                url = uri("https://maven.aliyun.com/repository/public/")
            }
            ...
        }
    }
    
    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/")
            }
            ...
        }
    }
    ...
  • 修改应用模块下build.gradle.kts 配置文件:开启 ViewBinding 和 添加依赖

    kotlin 复制代码
    .
    .
    .
    
    android {
        .
        .
        .
    
        buildFeatures {
            viewBinding = true
        }
    }
    
    dependencies {
    
        .
        .
        .
    
        implementation ("com.google.code.gson:gson:2.7")
        implementation ("com.github.bumptech.glide:glide:3.7.0")
        implementation ("com.github.yechaoa.YUtils:yutilskt:3.4.0")
        implementation ("com.youth.banner:banner:1.4.10")
        implementation ("androidx.lifecycle:lifecycle-livedata-ktx:2.3.0-rc01")
        implementation ("androidx.lifecycle:lifecycle-extensions:2.2.0")
        implementation ("com.github.CymChad:BaseRecyclerViewAdapterHelper:3.0.6")
        implementation("androidx.lifecycle:lifecycle-viewmodel:2.5.1") {
            exclude(group = "androidx.lifecycle", module = "lifecycle-viewmodel-ktx")
        }
        api("androidx.collection:collection-ktx:1.2.0")
        api("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.3")
        api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.3")
        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 文件

    cpp 复制代码
    .
    .
    .
    android.useAndroidX=true
    android.enableJetifier=true
    .
    .
    .

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

  • 资源文件:在 res 目录下

    • colors

      xml 复制代码
      <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

      xml 复制代码
      <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

      xml 复制代码
      <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="dp_80">80dp</dimen>
          .
          .
          .
          
      </resources>
  • 项目结构

2. base类

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

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

  • BaseApplication

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

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

    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

    kotlin 复制代码
    /**
     * 抽象 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

    kotlin 复制代码
    /**
     * 抽象 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

    kotlin 复制代码
    /**
     * 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

    kotlin 复制代码
    /**
     * 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

    kotlin 复制代码
    /**
     * 基于 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

    kotlin 复制代码
    /**
     * 基于 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 异常工具类

    kotlin 复制代码
    /**
     * 异常处理工具类,用于统一捕获并处理各种运行时异常,
     * 并通过日志或吐司等方式向用户展示友好提示。
     */
    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 拓展函数

    kotlin 复制代码
    /**
     * 用于标识对象的类名
     * */
    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**

    kotlin 复制代码
    /**
     * GlideImageLoader 继承自 Banner 库(或其他框架)中定义的 ImageLoader 抽象类,
     * 用于统一管理图片加载逻辑。这里以 Glide 为示例实现,实际项目中可替换为 Picasso、Coil 等。
     */
    class GlideImageLoader : ImageLoader() {
       
        override fun displayImage(context: Context?, path: Any?, imageView: ImageView?) {
            Glide.with(context)
                .load(path)
                .into(imageView)
        }
    }
  • MLog

    kotlin 复制代码
    /**
     * 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

    kotlin 复制代码
    /**
     * 网络状态工具类
     * 提供检查设备当前网络(移动数据和 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 复制代码
    <?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

    kotlin 复制代码
    /**
     * 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 复制代码
    /**
     * 通用响应包装类,用于封装后端返回的标准字段与业务数据。
     * 如果后台返回数据是比较规范的,都会有这种标准字段的,具体啥因人或因项目而异
     * 将之抽离出来方便管理
     *
     * 该类为 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 接口封装类 **

    kotlin 复制代码
    /**
     * 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>>
    
            // -----------------------【收藏】----------------------
    
    
            /**
             * 收藏文章(站内)
             *
             * @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>
    
          
        }
    }

    项目中我们使用的网路请求第三方库是 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

      kotlin 复制代码
      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

      kotlin 复制代码
      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
      )
    • User

      kotlin 复制代码
      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 接收数据拦截器

    kotlin 复制代码
    /**
     *用于从服务器响应中接收并持久化 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 请求数据拦拦截器

    kotlin 复制代码
    /**
     * 用于在每次 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 封装后的网络请求服务

    kotlin 复制代码
    /**
     * 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 配置常量

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

    kotlin 复制代码
    /**
     * 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 复制代码
<?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 复制代码
    <?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

    kotlin 复制代码
    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 复制代码
    <?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 复制代码
      <?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

    kotlin 复制代码
    /**
     * 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

    kotlin 复制代码
    /**
     * 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

    kotlin 复制代码
    /**
     * 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

    kotlin 复制代码
    /**
     * 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 代码如下

    kotlin 复制代码
    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就行了。

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

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

8. RegisterActivity 注册界面

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

  • activity_register

    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: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

    kotlin 复制代码
    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

    kotlin 复制代码
    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

    kotlin 复制代码
    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

    kotlin 复制代码
    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 复制代码
<?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 复制代码
<?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

kotlin 复制代码
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

kotlin 复制代码
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 ,那是因为我们还没有对对应的界面进行设计

篇幅太长了,平台塞不下这么多文字。。。可以看看我在别的平台的文章...学习 Android(四)-CSDN博客

相关推荐
_一条咸鱼_2 小时前
深度揭秘!Android HorizontalScrollView 使用原理全解析
android·面试·android jetpack
_一条咸鱼_2 小时前
揭秘 Android RippleDrawable:深入解析使用原理
android·面试·android jetpack
_一条咸鱼_2 小时前
深入剖析:Android Snackbar 使用原理的源码级探秘
android·面试·android jetpack
_一条咸鱼_2 小时前
揭秘 Android FloatingActionButton:从入门到源码深度剖析
android·面试·android jetpack
_一条咸鱼_2 小时前
深度剖析 Android SmartRefreshLayout:原理、源码与实战
android·面试·android jetpack
_一条咸鱼_2 小时前
揭秘 Android GestureDetector:深入剖析使用原理
android·面试·android jetpack
_一条咸鱼_2 小时前
深入探秘 Android DrawerLayout:源码级使用原理剖析
android·面试·android jetpack
_一条咸鱼_2 小时前
深度揭秘:Android CardView 使用原理的源码级剖析
android·面试·android jetpack
_一条咸鱼_2 小时前
惊爆!Android RecyclerView 性能优化全解析
android·面试·android jetpack
_一条咸鱼_2 小时前
探秘 Android RecyclerView 惯性滑动:从源码剖析到实践原理
android·面试·android jetpack