学习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博客

相关推荐
BD_Marathon12 小时前
【MySQL】函数
android·数据库·mysql
西西学代码12 小时前
安卓开发---耳机的按键设置的UI实例
android·ui
maki07716 小时前
虚幻版Pico大空间VR入门教程 05 —— 原点坐标和项目优化技巧整理
android·游戏引擎·vr·虚幻·pico·htc vive·大空间
千里马学框架17 小时前
音频焦点学习之AudioFocusRequest.Builder类剖析
android·面试·智能手机·车载系统·音视频·安卓framework开发·audio
fundroid20 小时前
掌握 Compose 性能优化三步法
android·android jetpack
TeleostNaCl21 小时前
如何在 IDEA 中使用 Proguard 自动混淆 Gradle 编译的Java 项目
android·java·经验分享·kotlin·gradle·intellij-idea
旷野说1 天前
Android Studio Narwhal 3 特性
android·ide·android studio
maki0771 天前
VR大空间资料 01 —— 常用VR框架对比
android·ue5·游戏引擎·vr·虚幻·pico
xhBruce1 天前
InputReader与InputDispatcher关系 - android-15.0.0_r23
android·ims
领创工作室1 天前
安卓设备分区作用详解-测试机红米K40
android·java·linux