简介
在上一章节,我们对Android中常用的项目架构模式有了一定的了解,那么现在我们既有轮子(基础UI),又有框架了,是时候开始造车了,那么本章将开始进行Android项目实战练习,具体实战什么看作者想要实战什么(无规划,难易不定)…遇到啥就针对的去实战,本章节将针对MVP项目架构进行实战,篇幅会比较长,会针对某些插件,知识点进行单独的讲解,跟着一篇文章可以实现一个项目的完整运行!!!
这里项目中使用了 [玩Android 开放API-玩Android - wanandroid.com](https://www.wanandroid.com/index) 提供API,在这里进行声明并感谢大佬
1. 项目准备
-
修改项目模块下
build.gralde.kts
配置文件:修改仓库地址便于下载依赖pluginManagement { repositories { maven { url = uri("https://www.jitpack.io") } maven { url = uri("https://maven.aliyun.com/repository/public/") } google() mavenCentral() gradlePluginPortal() } } dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { maven { url = uri("https://www.jitpack.io") } maven { url = uri("https://maven.aliyun.com/repository/public/") } google() mavenCentral() } } rootProject.name = "MyKotlinMVPWanAndroidExample" include(":app")
-
修改应用模块下
build.gradle.kts
配置文件:开启ViewBinding
和 添加依赖plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) } android { namespace = "com.example.studymvpexampleapplication" compileSdk = 35 defaultConfig { applicationId = "com.example.studymvpexampleapplication" minSdk = 24 targetSdk = 35 versionCode = 1 versionName = "1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } buildTypes { release { isMinifyEnabled = false proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) } } compileOptions { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 } kotlinOptions { jvmTarget = "11" } buildFeatures { /** * ViewBinding 是 Jetpack 提供的一项轻量级功能, * 它能够在编译时为每个布局文件生成对应的绑定类, * 使得我们可以通过类型安全和空安全的方式直接访问布局中的视图,从而几乎完全替代 findViewById(),并显著减少样板代码 * * ViewBinding 会为每一个启用该特性的布局文件生成一个绑定类(<LayoutName>Binding), * 该类包含了所有带 android:id 的视图引用以及一个 getRoot() 方法,用于获取根视图 * */ viewBinding = true } } dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.appcompat) implementation(libs.material) implementation(libs.androidx.activity) implementation(libs.androidx.constraintlayout) implementation(libs.androidx.navigation.fragment.ktx) implementation(libs.androidx.navigation.ui.ktx) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) /** * Gson(JSON 序列化/反序列化) * 实现 JSON 数据与 Java/Kotlin 对象的互相转换 * */ implementation ("com.google.code.gson:gson:2.7") /** * Glide(图片加载) * 高效加载网络/本地图片,支持缓存和图片处理 * */ implementation ("com.github.bumptech.glide:glide:3.7.0") /** * YUtils(工具集合) * 提供常用工具类(日志、屏幕适配、权限管理等) * */ implementation ("com.github.yechaoa.YUtils:yutilskt:3.4.0") /** * Banner(轮播图) * 实现图片/视图轮播效果 * */ implementation ("com.youth.banner:banner:1.4.10") /** * Lifecycle 系列 * * LiveData:实现数据观察 * ViewModel:管理界面相关数据 * lifecycle-extensions:旧版扩展库(已废弃,建议迁移到独立组件) * */ implementation ("androidx.lifecycle:lifecycle-livedata-ktx:2.3.0-rc01") implementation ("androidx.lifecycle:lifecycle-extensions:2.2.0") // 极大简化 RecyclerView 适配器 的编写,并提供强大而灵活的分页、头尾布局、多类型、拖拽、滑动删除等功能 implementation ("com.github.CymChad:BaseRecyclerViewAdapterHelper:3.0.6") implementation("androidx.lifecycle:lifecycle-viewmodel:2.5.1") { /** * 此依赖中排除了 lifecycle-viewmodel-ktx 模块 解决模块冲突 * */ exclude(group = "androidx.lifecycle", module = "lifecycle-viewmodel-ktx") } /** * Collection KTX * 为 Android 集合框架提供 Kotlin 扩展方法 * */ api("androidx.collection:collection-ktx:1.2.0") /** * Kotlin 协程 * 提供协程支持,简化异步编程 * */ api("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.3") api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.3") /** * 网络请求 * Retrofit + OkHttp * Retrofit:声明式 REST API 客户端 * OkHttp:底层网络引擎 * Logging Interceptor:网络请求日志拦截器 * Gson Converter:响应数据转换 * */ api("com.squareup.retrofit2:retrofit:2.9.0") api("com.squareup.okhttp3:okhttp:4.9.1") api("com.squareup.okhttp3:logging-interceptor:4.9.1") api("com.squareup.retrofit2:converter-gson:2.9.0") }
-
修改项目级
gradle.properties
文件# Project-wide Gradle settings. # IDE (e.g. Android Studio) users: # Gradle settings configured through the IDE *will override* # any settings specified in this file. # For more details on how to configure your build environment visit # http://www.gradle.org/docs/current/userguide/build_environment.html # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # org.gradle.parallel=true # AndroidX package structure to make it clearer which packages are bundled with the # Android operating system, and which are packaged with your app's APK # https://developer.android.com/topic/libraries/support-library/androidx-rn android.useAndroidX=true android.enableJetifier=true # Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official # Enables namespacing of each library's R class so that its R class includes only the # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library android.nonTransitiveRClass=true
由于项目中还用到了一些依赖旧版本的插件,所以添加一个
android.enableJetifier=true
以免报错无法运行 -
资源文件:在
res
目录下-
colors
<?xml version="1.0" encoding="utf-8"?> <resources> <color name="colorPrimary">#673AB7</color> <color name="colorPrimaryDark">#512DA8</color> <color name="colorAccent">#7C4DFF</color> <color name="black">#212121</color> <color name="gray">#757575</color> <color name="line">#DCDCDC</color> <color name="white">#FFFFFF</color> <color name="white30">#B2FFFFFF</color> <color name="color_eaeaea">#eaeaea</color> <color name="color_757575">#757575</color> </resources>
-
strings
<resources> <string name="app_name">玩安卓</string> <string name="title_home">首页</string> <string name="title_tree">体系</string> <string name="title_navi">导航</string> <string name="title_project">项目</string> <string name="navigation_drawer_open">Open navigation drawer</string> <string name="navigation_drawer_close">Close navigation drawer</string> <string name="action_search">搜索</string> <string name="action_settings">设置</string> <string name="title_activity_about">关于</string> <string name="large_text"> "用到的库:\n\n" "YUtils\n\n" "retrofit\n\n" "rxjava2\n\n" "BRVAH\n\n" "banner\n\n" "glide\n\n" "agentweb\n\n" "VerticalTabLayout\n\n" "flowlayout\n\n" </string> <string name="hint_username">请输入账号</string> <string name="hint_password">请输入密码</string> <string name="hint_password_again">请再次输入密码</string> <string name="password">密码</string> <string name="username">账号</string> <string name="register">注册账号</string> <string name="login">登 录</string> <!-- TODO: Remove or change this placeholder text --> <string name="hello_blank_fragment">Hello blank fragment</string> </resources>
-
dimens
<resources> <!-- Default screen margins, per the Android Design guidelines. --> <dimen name="activity_horizontal_margin">16dp</dimen> <dimen name="activity_vertical_margin">16dp</dimen> <dimen name="nav_header_vertical_spacing">8dp</dimen> <dimen name="nav_header_height">186dp</dimen> <dimen name="fab_margin">16dp</dimen> <dimen name="app_bar_height">200dp</dimen> <dimen name="sp_30">30sp</dimen> <dimen name="sp_24">24sp</dimen> <dimen name="sp_22">22sp</dimen> <dimen name="sp_20">20sp</dimen> <dimen name="sp_18">18sp</dimen> <dimen name="sp_16">16sp</dimen> <dimen name="sp_14">14sp</dimen> <dimen name="sp_12">12sp</dimen> <dimen name="dp_80">80dp</dimen> <dimen name="dp_70">70dp</dimen> <dimen name="dp_60">60dp</dimen> <dimen name="dp_40">40dp</dimen> <dimen name="dp_30">30dp</dimen> <dimen name="dp_28">28dp</dimen> <dimen name="dp_26">26dp</dimen> <dimen name="dp_24">24dp</dimen> <dimen name="dp_22">22dp</dimen> <dimen name="dp_20">20dp</dimen> <dimen name="dp_18">18dp</dimen> <dimen name="dp_16">16dp</dimen> <dimen name="dp_15">15dp</dimen> <dimen name="dp_12">12dp</dimen> <dimen name="dp_10">10dp</dimen> <dimen name="dp_8">8dp</dimen> <dimen name="dp_6">6dp</dimen> <dimen name="dp_5">5dp</dimen> <dimen name="dp_4">4dp</dimen> <dimen name="dp_2">2dp</dimen> <dimen name="dp_0">0dp</dimen> <dimen name="text_margin">16dp</dimen> </resources>
-
-
项目结构
2. base类
base包下创建各个功能模块中的组件(Activity、Fragment、Presenter、Adapter 等)基类,以便统一管理公共逻辑、减少重复代码、规范项目结构
具体封装内容根据项目而改变,没有统一的模版,这里的模版仅供参考 , 接下来我们将一一分析这些封装的基类在项目中的作用,其中一些工具类先不用在意,在之后会将工具类统一列出来
-
BaseApplication
/** * BaseApplication * 通过在 AndroidManifest 声明 * 在应用程序创建时第一个被实例化的 * 其 onCreate() 在整个应用的生命周期仅执行一次 * 一般在这个继承 Application 的类中, 我们会进行一些通用工具类、模块的初始化操作 * */ class BaseApplication: Application(){ override fun onCreate() { super.onCreate() // 初始化YUtils工具类 YUtils.init(this) // 注册 Activity 声明周期回调 registerActivityLifecycleCallbacks(ActivityUtil.activityLifecycleCallbacks) } }
记得在
AndroidManifest.xml
中进行声明绑定,否则无效<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" > <!-- 设置 name 去声明绑定自定义的 Application--> <application android:name=".base.BaseApplication" ..... </application> </manifest>
写完运行一下,看看有没有问题,这是一个习惯,不要一直埋头写代码,除非你很熟练了,如果刚开始,就一步一步来。因为还没写界面,默认的显示还是
Hello World!
-
BaseActivity
/** * 抽象 BaseActivity,使用泛型 ViewBinding 简化布局绑定 * * @param VB 具体的 ViewBinding 类型 * @param block 一个用于创建 VB 的 lambda,通常传入 { inflater -> XxxActivityBinding.inflate(inflater) } */ abstract class BaseActivity<VB : ViewBinding>(val block: (LayoutInflater) -> VB) : AppCompatActivity() { // 内部持有一个可空的绑定对象,防止在 onDestroy 后继续引用 private var _binding: VB? = null /** * 对外公开的 binding 对象,只有在 onCreate 后到 onDestroy 之前可用 * 访问时若 _binding 为 null,会抛出 IllegalStateException 提示已被销毁 */ val binding: VB get() = requireNotNull(_binding) { "biding 已被销毁" } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // 在 Activity 创建时,通过传入的 block 初始化 ViewBinding _binding = block(layoutInflater) // 将 binding.root 设置为当前 Activity 的内容视图 setContentView(binding.root) // 调用各子类需实现的生命周期方法 initView() initData() allClick() } /** * 初始化视图 例如 findView、RecyclerView 布局管理器等 * */ abstract fun initView() /** * 初始化或加载数据,例如网络请求、本地数据库查询等 * */ abstract fun initData() /** * 设置所有的点击事件回调监听 * */ abstract fun allClick() /** * 设置标题栏标题 * * @param title 标题文本 */ protected fun setBarTitle(title: String) { supportActionBar?.title = title } /** * 启用默认返回按钮 (左上角箭头),并响应点击 */ protected fun setBackEnabled() { supportActionBar?.apply { setHomeButtonEnabled(true) setDisplayHomeAsUpEnabled(true) } } /** * 菜单项点击回调,处理左上角 Home/箭头返回 */ override fun onOptionsItemSelected(item: MenuItem): Boolean { val id = item.itemId if (id == android.R.id.home) { finish() return true } return super.onOptionsItemSelected(item) } override fun onDestroy() { super.onDestroy() // 在 Activity 销毁时,清空 binding 引用,防止内存泄漏 _binding = null } }
-
BaseFragment
/** * 抽象 BaseFragment,使用泛型 ViewBinding 简化布局绑定 * * @param VB 具体的 ViewBinding 类型 * @param block 一个用于创建 VB 的 lambda,通常传入 { inflater -> XxxFragmentBinding.inflate(inflater, container, false) } */ abstract class BaseFragment<VB : ViewBinding>(val block: (LayoutInflater) -> VB) : Fragment() { // 内部持有一个可空的 binding 对象,防止在视图销毁后继续引用 private var _binding: VB? = null /** * 对外公开的 binding 对象,只有在 onCreateView 到 onDestroyView 之间可用 * 访问时若 _binding 为 null,会抛出 IllegalStateException 提示已被销毁 */ val binding: VB get() = requireNotNull(_binding) { "biding 已被销毁" } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { // 在创建视图时,通过传入的 block 初始化 ViewBinding _binding = block(layoutInflater) // 返回根视图给 Fragment 宿主显示 return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) // 视图创建后,调用子类需实现的初始化方法 initView() initData() allClick() } override fun onDestroyView() { super.onDestroyView() // 在视图销毁时,清空 binding 引用,防止内存泄漏 _binding = null } /** * 初始化视图组件,例如设置 RecyclerView 的 adapter 或初始化 UI 控件状态 */ protected abstract fun initView() /** * 初始化或加载数据,例如发起网络请求或读取本地数据库 */ protected abstract fun initData() /** * 设置所有的点击事件回调监听 */ protected abstract fun allClick() }
至此,我们完成了对
Activity
、Fragment
简单通用抽离的封装,但是这只是针对普通的Activity
和Fragment
,我们要将之用于MVP
项目架构中,所以还要对使用到MVP
模块的Activity
、Fragment
进行二次封装,那么我们接着封装,别嫌麻烦,万事开头难,如果不想写屎山,那就从一开始就规划好,统一封装管理好… -
BaseContract
/** * MVP 架构的基础契约接口,定义了 View、Presenter、Model 三层的公共方法和规范。 */ interface BaseContract { /** * View 层接口,需要由具体的 Activity/Fragment 实现。 */ interface IBaseView { /** * 获取当前绑定的 Activity 实例,用于 UI 操作和上下文需求。 * @return 当前 View 所在的 Activity,如果已经销毁则返回 null。 */ fun getActivity(): Activity? } /** * Presenter 层接口,负责业务逻辑处理并协调 View 与 Model。 * 继承 LifecycleObserver 以便监听宿主生命周期。 */ interface IBasePresenter : LifecycleObserver { /** * 判断 Presenter 是否已与 View 建立关联。 * 可在调用业务方法前进行检查,避免空指针或内存泄漏。 * * @return true 表示已经 attach,false 表示尚未 attach 或已 detach */ fun isViewAttach(): Boolean /** * 在 View 销毁时调用,断开 Presenter 与 View 的引用关联, * 以便垃圾回收器回收,防止内存泄漏。 */ fun detachView() } /** * Model 层接口,负责数据获取与处理,可由具体实现类定义网络请求、数据库操作等。 */ interface IBaseModel { // 可在此定义全局通用的数据处理方法,例如统一的错误封装或返回数据类型 } }
上述封装中出现了一个新的接口
LifecycleObserver
,LifecycleObserver
是一个标记接口(marker interface),本身不包含任何方法。其主要作用是标识实现了该接口的类为生命周期观察者。通过与LifecycleOwner
(如Activity
或Fragment
)配合使用,LifecycleObserver
可以监听宿主组件的生命周期事件。这里所说的将标识的成为生命周期观察者,涉及到了观察者模式*(定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都得到通知并自动更新)*,相信各位读者的面向对象的基础都很扎实吧A.A,这里就不对这个进行详细的描述了,只要知道了使用了这个标识,就可以将其标识为观察者。 -
BasePresenter
/** * BasePresenter 抽象类,封装了 MVP 架构中 Presenter 层的通用逻辑: * - 使用弱引用持有 View,防止内存泄漏 * - 在初始化时创建 Model 实例 * - 提供协程作用域用于异步操作,并在解绑时取消 */ abstract class BasePresenter<V : BaseContract.IBaseView, M>(view: V) : BaseContract.IBasePresenter { /** 对 View 的弱引用,避免强引用导致 Activity/Fragment 无法回收 */ var mView: WeakReference<V?>? = null /** 对 Model 的引用,用于执行业务逻辑 */ var mModel: M? = null /** 协程作用域,指定在主线程,适合更新 UI 操作 */ val coroutineScope = CoroutineScope(Dispatchers.Main) init { // 在构造时绑定 View 并创建对应的 Model attachView(view) mModel = createModel() } /** * 创建并返回当前 Presenter 对应的 Model 实例 * 子类必须实现此方法,提供具体的业务 Model */ abstract fun createModel(): M /** * 将 View 与 Presenter 关联,使用 WeakReference 包装 * * @param view 具体的 View 实现 */ open fun attachView(view: V) { mView = WeakReference(view) } /** * 获取当前关联的 View 实例 * * @return 视图实例或 null(若已被回收) */ open fun getView(): V? = mView?.get() /** * 检查 Presenter 是否仍与 View 建立了有效关联 * * @return true:关联未断开且 View 未被回收;false:已断开或 View 为 null */ override fun isViewAttach(): Boolean { return mView != null && mView?.get() != null } /** * 解除 Presenter 与 View 的关联,清理资源,并取消所有协程 * 调用时机:通常在 Activity/Fragment 的 onDestroy 或 onDestroyView 中 */ override fun detachView() { // 清理对 View 的弱引用 if (mView != null) { mView?.clear() mView = null } // 清理对 Model 的引用 if (mModel != null) { mModel = null } // 取消所有在 coroutineScope 中启动的协程任务 coroutineScope.cancel() } }
-
BaseMVPActivitiy
/** * 基于 MVP 模式的 Activity 抽象基类, * 继承自 BaseActivity,用于统一管理 Presenter 的创建与生命周期。 * * @param VB ViewBinding 类型,用于布局绑定 * @param P Presenter 类型,实现 BaseContract.IBasePresenter * @param block 用于生成 ViewBinding 的 lambda 表达式,通常为 XxxActivityBinding.inflate */ abstract class BaseMVPActivity<VB : ViewBinding, P : BaseContract.IBasePresenter>( block: (LayoutInflater) -> VB ) : BaseActivity<VB>(block) { /** * 延迟初始化 Presenter 实例,首次使用时通过 createPresenter() 方法创建。 */ protected val mPresenter: P by lazy { createPresenter() } /** * 子类必须实现该方法,用于提供具体的 Presenter 对象。 * * @return 创建并返回一个新的 Presenter 实例 */ protected abstract fun createPresenter(): P override fun onDestroy() { super.onDestroy() // 在 Activity 销毁时,断开 Presenter 与 View 的关联,避免内存泄漏 mPresenter.detachView() } }
-
BaseMVPFragment
/** * 基于 MVP 模式的 Fragment 抽象基类,继承自 BaseFragment, * 用于统一管理 Presenter 的创建与生命周期。 * * @param VB ViewBinding 类型,用于布局绑定 * @param P Presenter 类型,实现 BaseContract.IBasePresenter * @param block 用于生成 ViewBinding 的 lambda 表达式,通常为 XxxFragmentBinding.inflate */ abstract class BaseMVPFragment<VB : ViewBinding, P : BaseContract.IBasePresenter>( block: (LayoutInflater) -> VB ) : BaseFragment<VB>(block) { /** * 延迟初始化 Presenter 实例,首次使用时通过 createPresenter() 方法创建。 */ protected val mPresenter: P by lazy { createPresenter() } override fun onDestroyView() { super.onDestroyView() // 视图销毁时,断开 Presenter 与 View 的关联,避免内存泄漏 mPresenter.detachView() } /** * 子类必须实现该方法,用于提供具体的 Presenter 对象。 * * @return 创建并返回一个具体的 Presenter 实例 */ protected abstract fun createPresenter(): P /** * 获取当前 Presenter 实例,便于子类调用 * * @return Presenter 实例 */ protected fun getPresenter(): P = mPresenter }
至此我们对
Activity
、Fragment
的封装告一段落,接下来,我们将进行网络请求的封装,不过在封装之前,我们先看看这个项目中有那些自定义的工具类
3. util
-
ExceptionUtil
异常工具类/** * 异常处理工具类,用于统一捕获并处理各种运行时异常, * 并通过日志或吐司等方式向用户展示友好提示。 */ object ExceptionUtil { /** * 捕获并处理通用异常,根据不同异常类型进行分类提示。 * * @param e 捕获到的 Throwable 异常 */ fun catchException(e: Throwable) { // 打印异常堆栈,方便调试和定位问题 e.printStackTrace() when (e) { is HttpException -> { // HTTP 错误,使用状态码分类处理 catchHttpException(e.code()) } is InterruptedIOException -> { // I/O 操作被中断,可能是超时或主动取消 MLog.e("服务器连接失败,请稍后重试") } is UnknownHostException, is NetworkErrorException -> { // 无法解析主机名或网络不可达 MLog.e("网络连接异常,请检查您的网络设置") } is MalformedJsonException, is JsonSyntaxException -> { // JSON 格式不正确,服务器返回的数据有误 MLog.e("服务器返回数据格式错误,请稍后重试") } is ConnectException -> { // 连接服务器失败 MLog.e("连接服务器失败") } else -> { // 其他未知异常,给出通用提示 MLog.e("操作失败,请稍后重试") } } } /** * 处理 HTTP 异常,根据状态码决定提示信息。 * * @param errorCode HTTP 响应状态码 */ private fun catchHttpException(errorCode: Int) { // 2xx 范围内视为正常,不做处理 if (errorCode in 200 until 300) return // 非成功状态码,给出对应提示 val msg = catchHttpExceptionCode(errorCode) MLog.e(msg) } /** * 根据 HTTP 状态码判断错误类型并返回对应提示文字。 * * @param errorCode HTTP 响应状态码 * @return 用户可见的错误提示 */ private fun catchHttpExceptionCode(errorCode: Int): String = when (errorCode) { in 500..599 -> { // 服务器内部错误 "服务器异常,请稍后重试" } else -> { // 客户端请求错误或其他未知情况 "请求错误,请稍后重试" } } }
-
Extends
拓展函数/** * 用于标识对象的类名 * */ val Any.TAG: String get() { return javaClass.simpleName } /** * toJsonString 函数则用于将对象转换为 JSON 格式的字符串 * */ fun Any.toJsonString(): String { return Gson().toJson(this) } /** * 这个函数的作用是简化启动协程的过程,并提供了统一的异常处理方式 * * @param block: 这是一个挂起函数,它以 CoroutineScope 作为接收者并不返回任何结果。这个函数表示要在协程中执行的代码块。 * @param onError: 这是一个接受 Throwable 类型参数的函数,用于处理在协程执行过程中发生的异常。 * @param onComplete: 这是一个不带参数的函数,表示在协程执行完成后要执行的操作。 * */ fun launchCoroutine( block: suspend CoroutineScope.() -> Unit, onError: (e: Throwable) -> Unit = { _: Throwable -> }, onComplete: () -> Unit = {} ) { MainScope().launch( CoroutineExceptionHandler { _, throwable -> run { // 统一处理错误 ExceptionUtil.catchException(throwable) onError(throwable) } } ) { try { block.invoke(this) } finally { onComplete() } } } /** * 随机颜色 */ fun randomColor(): Int { Random().run { //rgb取值0-255,但是值过大,就越接近白色,会看不清,所以限制在200 val red = nextInt(200) val green = nextInt(200) val blue = nextInt(200) return Color.rgb(red, green, blue) } }
之前的文章有针对
Kotlin
语法做了学习和讲解,这里对其语法的使用不在赘述,看不懂麻烦去翻翻前面TNT -
**
GlideImageLoader**
/** * GlideImageLoader 继承自 Banner 库(或其他框架)中定义的 ImageLoader 抽象类, * 用于统一管理图片加载逻辑。这里以 Glide 为示例实现,实际项目中可替换为 Picasso、Coil 等。 */ class GlideImageLoader : ImageLoader() { /** * displayImage:图片加载回调方法 * * @param context 上下文对象,用来启动 Glide 请求(通常传入 Activity 或 Fragment 的 context) * @param path 图片路径,这里类型定义为 Any 是为了兼容多种数据源: * - URL(String) * - 本地文件(File 或 Uri) * - 资源 ID(Int) * 在使用时,需要根据自己传入的类型进行强转,切勿随意强转导致 ClassCastException。 * @param imageView 目标 ImageView,用来显示加载后的图片 * * 注意: * 1. 这里使用 Kotlin 的非空断言(!!),若 context 或 imageView 为 null,会抛出 NullPointerException, * 推荐在调用处确保不传入 null,或改为使用安全调用(?.)并做空值判断。 * 2. Glide 会自动处理列表滚动时的取消与重用,无需手动清除旧请求。 */ override fun displayImage(context: Context?, path: Any?, imageView: ImageView?) { // 如果 context 或 imageView 为 null,直接返回,避免崩溃 if (context == null || imageView == null) return // 使用 Glide 加载图片: // - with(context):指定生命周期,与 Activity/Fragment 同步,自动在 onDestroy 时取消请求 // - load(path):加载不同类型的图片资源(String, Uri, File, Int 等) // - into(imageView):将图片展示到目标 ImageView Glide.with(context) .load(path) .into(imageView) } }
-
MLog
/** * MLog 工具类,用于统一管理日志输出。 * 通过 isDebug 常量控制是否打印日志,方便在发布版中关闭日志。 */ object MLog { /** 是否开启调试模式,若为 false 则不会输出日志 */ private const val isDebug = true /** * 输出错误日志 * * @param msg 要输出的错误信息 */ fun e(msg: String) { if (isDebug) { Log.e(TAG, msg) } } /** * 输出调试日志 * * @param msg 要输出的调试信息 */ fun d(msg: String) { if (isDebug) { Log.d(TAG, msg) } } /** * 输出信息日志 * * @param msg 要输出的信息 */ fun i(msg: String) { if (isDebug) { Log.i(TAG, msg) } } /** * 输出警告日志 * * @param msg 要输出的警告信息 */ fun w(msg: String) { if (isDebug) { Log.w(TAG, msg) } } }
-
NetWorkUtils
/** * 网络状态工具类 * 提供检查设备当前网络(移动数据和 Wi-Fi)是否可用的方法 */ object NetWorkUtils { /** * 检查设备是否有可用网络(包含移动数据或 Wi-Fi) * * @return 若移动网络或 Wi-Fi 任一可用,则返回 true;否则返回 false */ fun isConnected(): Boolean { // 获取全局 Application Context,判断任一网络类型是否可用 return isNetworkConnected(YUtils.getAppContext()) || isWifiConnected(YUtils.getAppContext()) } /** * 判断移动数据网络(2G/3G/4G/5G)是否可用 * * @param context 用于获取系统网络服务的 Context,若为 null 则直接返回 false * @return 若当前存在且可用的移动数据网络,则返回 true;否则返回 false */ fun isNetworkConnected(context: Context?): Boolean { return context?.let { // 获取系统的 ConnectivityManager val cm = it.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager // activeNetworkInfo 可能为 null,使用安全调用 cm.activeNetworkInfo?.isAvailable ?: false } ?: false } /** * 判断 Wi-Fi 网络是否可用 * * @param context 用于获取系统网络服务的 Context,若为 null 则直接返回 false * @return 若 Wi-Fi 网络接口存在且可用,则返回 true;否则返回 false */ fun isWifiConnected(context: Context?): Boolean { return context?.let { // 获取系统的 ConnectivityManager val cm = it.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager // 通过 TYPE_WIFI 获取 Wi-Fi 网络状态,可能为 null cm.getNetworkInfo(ConnectivityManager.TYPE_WIFI)?.isAvailable ?: false } ?: false } }
记得要要在
AndroidMainifest.xml
中添加权限<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" > <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <application ... </application> </manifest>
-
ObjectUtil
package com.example.studymvpexampleapplication.util import android.os.Build import android.util.LongSparseArray import android.util.SparseArray import android.util.SparseBooleanArray import android.util.SparseIntArray import android.util.SparseLongArray import androidx.collection.SimpleArrayMap /** * ObjectUtil 工具类,用于判断任意对象是否“为空” * - 支持常见集合、数组、CharSequence、Map 以及 Android 优化集合类型(SparseArray 系列、SimpleArrayMap)等。 * - 若对象为 null,或内容/长度为 0,则视为空。 */ object ObjectUtil { /** * 判断对象是否为空 * * @param obj 任意对象,可能为: * - null * - 原生数组(Array<*>) * - CharSequence(String、Spannable 等) * - Collection(List、Set 等) * - Map(HashMap、LinkedHashMap 等) * - SimpleArrayMap(androidx.collection.SimpleArrayMap) * - SparseArray(android.util.SparseArray) * - SparseBooleanArray、SparseIntArray、SparseLongArray(API ≥ 18) * @return true 代表“空”或不可用;false 代表至少有一个元素或非空字符串 */ fun isEmpty(obj: Any?): Boolean { // 1. 空对象 if (obj == null) { return true } // 2. 原生数组,长度为 0 if (obj.javaClass.isArray && java.lang.reflect.Array.getLength(obj) == 0) { return true } // 3. 字符序列(String、CharSequence 等),长度为 0 if (obj is CharSequence && obj.isEmpty()) { return true } // 4. Java 通用集合 Collection(List、Set 等),isEmpty() 即可判断 if (obj is Collection<*> && obj.isEmpty()) { return true } // 5. Java 通用 Map(HashMap、TreeMap 等),isEmpty() 即可判断 if (obj is Map<*, *> && obj.isEmpty()) { return true } // 6. AndroidX 提供的优化版 Map:SimpleArrayMap,底层以数组实现,isEmpty() 可判断是否无元素​ if (obj is androidx.collection.SimpleArrayMap<*, *> && obj.isEmpty()) { return true } // 7. Android 原生 SparseArray,key→value 映射,避免自动装箱;size() 为 0 时无元素​ if (obj is SparseArray<*> && obj.size() == 0) { return true } // 8. Android 原生 SparseBooleanArray,存布尔值映射;size() 为 0 时无元素​ if (obj is SparseBooleanArray && obj.size() == 0) { return true } // 9. Android 原生 SparseIntArray,存整型映射;size() 为 0 时无元素​ if (obj is SparseIntArray && obj.size() == 0) { return true } // 10. Android Lollipop MR2 及以上新增的 SparseLongArray,存长整型映射;size() 为 0 时无元素​ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) { // API 18+ if (obj is SparseLongArray && obj.size() == 0) { return true } } // 11. 以上情况都不满足,则视为非空 return false } /** * 判断对象是否非空,等价于 !isEmpty(obj) * * @param obj 任意对象 * @return true 代表至少有一个元素或非空字符串;false 代表“空”或 null */ fun isNotEmpty(obj: Any?): Boolean { return !isEmpty(obj) } }
至此,我们这个项目工具类就介绍完成了,接下来我们看看网络请求这块代码
4. http
-
BaseBean
通用网络相应基类/** * 通用响应包装类,用于封装后端返回的标准字段与业务数据。 * 如果后台返回数据是比较规范的,都会有这种标准字段的,具体啥因人或因项目而异 * 将之抽离出来方便管理 * * 该类为 Kotlin 的数据类(data class),编译器会自动生成 equals()/hashCode()/toString()/copy() 等方法, * 主要用于持有数据而非行为逻辑。 * * @param T 泛型参数,代表业务数据的类型;在使用时由调用方指定具体类型, * Kotlin 默认允许任何类型(上界为 Any?) * * @property errorMsg 后端返回的错误描述字符串,通常为空或 "success" 表示无错误 * @property errorCode 后端返回的状态码,一般 0 表示成功,非 0 表示不同类型的失败 * @property data 业务数据主体,类型为 T;成功时携带实际数据,失败时可能为 null 或包含错误信息 */ data class BaseBean<T>( val errorMsg: String, val errorCode: Int, val data: T )
这个类具体放到哪里呢?是放在
data.bean
下呢?还是base
下呢?没啥好纠结的,这个放哪都行~ -
**
API
接口封装类 **/** * API 接口封装类,用于定义网络请求的基础地址和各项具体接口。 */ class API { companion object { /** * 应用中所有 Retrofit 请求的基础 URL, * 所有子接口中的相对路径都会拼接到此地址后面 */ const val BASE_URL = "https://www.wanandroid.com/" } /** * WAZApi 接口定义了与 WanAndroid 后端交互的所有 HTTP 请求方法, * 使用 Retrofit 的注解来声明请求类型、路径和参数, * 并统一返回封装在 BaseBean<T> 中的响应体。 */ interface WAZApi { // -----------------------【登录注册】---------------------- /** * 登录接口 * * @param username 用户名(账号) * @param password 用户密码 * @return 返回 BaseBean<User>,其中 data 部分包含登录成功后的用户信息 */ @FormUrlEncoded @POST("user/login") suspend fun login( @Field("username") username: String?, @Field("password") password: String? ): BaseBean<User> /** * 注册接口 * * @param username 用户名(账号) * @param password 密码 * @param repassword 重复输入的密码,用于校验 * @return 返回 BaseBean<User>,其中 data 部分包含注册成功后的用户信息 */ @FormUrlEncoded @POST("user/register") suspend fun register( @Field("username") username: String?, @Field("password") password: String?, @Field("repassword") repassword: String? ): BaseBean<User> // -----------------------【首页相关】---------------------- /** * 获取首页文章列表 * * @param page 页码,从 0 开始 * @return 返回 BaseBean<Article>,其中 data 部分包含文章列表及分页信息 */ @GET("article/list/{page}/json") suspend fun getArticleList(@Path("page") page: Int): BaseBean<Article> /** * 获取首页轮播图数据 * * @return 返回 BaseBean<MutableList<Banner>>,其中 data 部分是 Banner 对象列表 */ @GET("banner/json") suspend fun getBanner(): BaseBean<MutableList<Banner>> // -----------------------【体系】---------------------- /** * 获取体系数据列表 * * @return 返回 BaseBean<MutableList<Tree>>,其中 data 部分是体系分类列表 */ @GET("tree/json") suspend fun getTree(): BaseBean<MutableList<Tree>> /** * 获取某个体系下的详情文章列表 * * @param page 页码 * @param cid 体系 ID * @return 返回 BaseBean<Article>,其中 data 部分包含该体系下的文章列表 */ @GET("article/list/{page}/json?") suspend fun getTreeChild( @Path("page") page: Int, @Query("cid") cid: Int ): BaseBean<Article> // -----------------------【导航】---------------------- /** * 获取导航数据 * * @return 返回 BaseBean<MutableList<Navi>>,其中 data 部分包含导航分类列表 */ @GET("navi/json") suspend fun getNavi(): BaseBean<MutableList<Navi>> // -----------------------【项目】---------------------- /** * 获取项目分类数据 * * @return 返回 BaseBean<MutableList<Project>>,其中 data 部分是项目分类列表 */ @GET("project/tree/json") suspend fun getProject(): BaseBean<MutableList<Project>> /** * 获取项目列表数据 * * @param page 页码 * @param cid 项目分类 ID * @return 返回 BaseBean<ProjectChild>,其中 data 部分包含该分类下项目列表 */ @GET("project/list/{page}/json?") suspend fun getProjectChild( @Path("page") page: Int, @Query("cid") cid: Int ): BaseBean<ProjectChild> // -----------------------【搜索】---------------------- /** * 搜索文章 * * @param page 页码 * @param k 搜索关键词 * @return 返回 BaseBean<Article>,其中 data 部分包含搜索到的文章列表 */ @FormUrlEncoded @POST("article/query/{page}/json?") suspend fun getSearchList( @Path("page") page: Int, @Field("k") k: String ): BaseBean<Article> /** * 获取搜索热词 * * @return 返回 BaseBean<MutableList<Hotkey>>,其中 data 部分是热词列表 */ @GET("hotkey/json") suspend fun getHotkey(): BaseBean<MutableList<Hotkey>> // -----------------------【收藏】---------------------- /** * 获取收藏的文章列表 * * @param page 页码 * @return 返回 BaseBean<Collect>,其中 data 部分包含收藏文章分页数据 */ @GET("lg/collect/list/{page}/json?") suspend fun getCollectList(@Path("page") page: Int): BaseBean<Collect> /** * 收藏文章(站内) * * @param id 文章 ID * @return 返回 BaseBean<String>,其中 data 部分一般为空或提示信息 */ @POST("lg/collect/{id}/json") suspend fun collect(@Path("id") id: Int): BaseBean<String> /** * 取消收藏文章(文章列表入口) * * @param id 文章 ID * @return 返回 BaseBean<String>,其中 data 部分一般为空 */ @POST("lg/uncollect_originId/{id}/json") suspend fun unCollect(@Path("id") id: Int): BaseBean<String> /** * 取消收藏文章(我的收藏页面入口) * * @param id 文章 ID * @param originId 原始文章 ID,用于服务端还原来源 * @return 返回 BaseBean<String>,其中 data 部分一般为空 */ @FormUrlEncoded @POST("lg/uncollect/{id}/json") suspend fun unCollect1( @Path("id") id: Int, @Field("originId") originId: Int ): BaseBean<String> } }
项目中我们使用的网路请求第三方库是
Retrofit
其是由 Square 开发的一个 类型安全(type-safe)的 HTTP 客户端,可用于 Android 和 Java 应用中,将 REST API 直接映射成 Java/Kotlin 接口,使网络请求代码更简洁、可维护square.github.io。它底层基于OkHttp
实现,默认支持同步及异步调用,并可通过ConverterFactory
(如Gson
、Moshi
)自动完成请求体和响应体的序列化与反序列化,比较简单,会用就行,这里不进行过多的研究。 -
Bean
网络请求相应类上述封装的
API
返回的对象,大部分的都是复杂对象,一般我们可以通过阅读后端提供的接口文档,知道对应接口的响应体内容,然后创建对应的响应类,我们的项目中使用了GSON
,所以对于相应数据的映射处理还是比较方便的,大家在日常开发中,如果有对应的网络请求的处理,一定要和后端商量好,否则到头来难受的是自己…本项目使用的是玩Android 开放API-玩Android - wanandroid.com提供的接口,我们可以根据提供的示例去获取响应体内容,或者自己用postMan
等网络请求工具去获取对应接口的相应数据,然后将对应相应数据类在项目中创建。-
Article
data class Article( val curPage: Int, val datas: MutableList<ArticleDetail>, val offset: Int, val over: Boolean, val pageCount: Int, val size: Int, val total: Int ) data class ArticleDetail( val apkLink: String, val audit: Int, val author: String, val chapterId: Int, val chapterName: String, var collect: Boolean, val courseId: Int, val desc: String, val envelopePic: String, val fresh: Boolean, val id: Int, val link: String, val niceDate: String, val niceShareDate: String, val origin: String, val prefix: String, val projectLink: String, val publishTime: Long, val selfVisible: Int, val shareDate: Long, val shareUser: String, val superChapterId: Int, val superChapterName: String, val tags: List<Tag>, val title: String, val type: Int, val userId: Int, val visible: Int, val zan: Int ) data class Tag( val name: String, val url: String )
-
Banner
data class Banner( val desc: String, val id: Int, val imagePath: String, val isVisible: Int, val order: Int, val title: String, val type: Int, val url: String )
-
Collect
data class Collect( val curPage: Int, val datas: MutableList<CollectDetail>, val offset: Int, val over: Boolean, val pageCount: Int, val size: Int, val total: Int ) data class CollectDetail( val author: String, val chapterId: Int, val chapterName: String, val courseId: Int, val desc: String, val envelopePic: String, val id: Int, val link: String, val niceDate: String, val origin: String, val originId: Int, val publishTime: Long, val title: String, val userId: Int, val visible: Int, val zan: Int )
-
Hotkey
data class Hotkey( val id: Int, val link: String, val name: String, val order: Int, val visible: Int )
-
Navi
data class Navi( val articles: MutableList<ArticleX>, val cid: Int, val name: String ) data class ArticleX( val apkLink: String, val audit: Int, val author: String, val chapterId: Int, val chapterName: String, val collect: Boolean, val courseId: Int, val desc: String, val envelopePic: String, val fresh: Boolean, val id: Int, val link: String, val niceDate: String, val niceShareDate: String, val origin: String, val prefix: String, val projectLink: String, val publishTime: Long, val selfVisible: Int, val shareDate: Long, val shareUser: String, val superChapterId: Int, val superChapterName: String, val tags: List<Any>, val title: String, val type: Int, val userId: Int, val visible: Int, val zan: Int )
-
Project
data class Project( val children: List<Any>, val courseId: Int, val id: Int, val name: String, val order: Int, val parentChapterId: Int, val userControlSetTop: Boolean, val visible: Int )
-
ProjectChild
data class ProjectChild( val curPage: Int, val datas: MutableList<DataX>, val offset: Int, val over: Boolean, val pageCount: Int, val size: Int, val total: Int ) data class DataX( val apkLink: String, val audit: Int, val author: String, val chapterId: Int, val chapterName: String, val collect: Boolean, val courseId: Int, val desc: String, val envelopePic: String, val fresh: Boolean, val id: Int, val link: String, val niceDate: String, val niceShareDate: String, val origin: String, val prefix: String, val projectLink: String, val publishTime: Long, val selfVisible: Int, val shareDate: Long, val shareUser: String, val superChapterId: Int, val superChapterName: String, val tags: List<TagX>, val title: String, val type: Int, val userId: Int, val visible: Int, val zan: Int ) data class TagX( val name: String, val url: String )
-
Tree
data class Tree( val children: ArrayList<Children>, var isShow: Boolean, val courseId: Int, val id: Int, val name: String, val order: Int, val parentChapterId: Int, val userControlSetTop: Boolean, val visible: Int ) : Serializable data class Children( val children: ArrayList<Any>, val courseId: Int, val id: Int, val name: String, val order: Int, val parentChapterId: Int, val userControlSetTop: Boolean, val visible: Int ) : Serializable
-
User
data class User( val admin: Boolean, val chapterTops: List<Any>, val collectIds: List<Int>, val email: String, val icon: String, val id: Int, val nickname: String, val password: String, val publicName: String, val token: String, val type: Int, val username: String )
-
-
ReceivedCookiesInterceptor
接收数据拦截器/** *用于从服务器响应中接收并持久化 Cookie 的拦截器 * */ class ReceivedCookiesInterceptor : Interceptor { @Throws(IOException::class) override fun intercept(chain: Interceptor.Chain): Response { // 先执行请求,获取原始响应 val originalResponse: Response = chain.proceed(chain.request()) // 判断响应头中是否包含 Set-Cookie if (originalResponse.headers("Set-Cookie").isNotEmpty()) { // 创建一个 HashSet 用于去重存储所有 Cookie 字符串 val cookies: HashSet<String> = HashSet() // 遍历所有 Set-Cookie 头,将其添加到 HashSet 中 for (header in originalResponse.headers("Set-Cookie")) { cookies.add(header) } // 将去重后的 Cookie 集合保存到本地(如 SharedPreferences) SpUtil.setStringSet(MyConfig.COOKIE, cookies) } // 返回原始响应,保证拦截器链的后续执行和响应传递 return originalResponse } }
-
AddCookiesInterceptor
请求数据拦拦截器/** * 用于在每次 HTTP 请求中添加存储在本地的 Cookie 的拦截器 * */ class AddCookiesInterceptor : Interceptor { @Throws(IOException::class) override fun intercept(chain: Interceptor.Chain): Response { // 从原始请求构造一个新的 Request.Builder,以便添加头部 val builder: Request.Builder = chain.request().newBuilder() // 从 SharedPreferences 中读取所有已保存的 Cookie 字符串集合 val stringSet = SpUtil.getStringSet(MyConfig.COOKIE) // 将每个 Cookie 都以 “Cookie” 头的形式添加到请求中 for (cookie in stringSet) { builder.addHeader("Cookie", cookie) } // 构建新的请求并交给下一个拦截器或网络执行 return chain.proceed(builder.build()) } }
-
RetrofitService
封装后的网络请求服务/** * RetrofitService 单例对象,用于初始化并提供全局唯一的 Retrofit API 服务实例 */ object RetrofitService { // 持有 WAZApi 接口的实现,用于调用后端接口 private var apiServer: API.WAZApi /** * 获取 API 服务实例 * * @return WAZApi 的单例实现,可用于所有网络请求 */ fun getApiService(): API.WAZApi { return apiServer } // 在对象加载时(第一次访问时)执行一次,完成 Retrofit 和 OkHttpClient 的初始化 init { // ==================== OkHttp 拦截器配置 ==================== // 1. 日志拦截器:记录 HTTP 请求和响应的详细信息(包括请求头/响应体) val httpLoggingInterceptor = HttpLoggingInterceptor().apply { // 设置日志级别为 BODY,可打印请求和响应的全部内容 level = HttpLoggingInterceptor.Level.BODY } // 2. 自定义拦截器:打印每次请求的 URL,便于调试和埋点 val requestLoggingInterceptor = Interceptor { chain -> val request = chain.request() MLog.d("RequestUrl = ${request.url}") chain.proceed(request) } // ==================== OkHttpClient 构建 ==================== val okHttpClient = OkHttpClient.Builder() // 添加日志拦截器 .addInterceptor(httpLoggingInterceptor) /** * 这两个拦截器结合起来,实现了在网络请求中处理 Cookie 的逻辑。 * AddCookiesInterceptor 用于在请求中添加 Cookie, * 而 ReceivedCookiesInterceptor 用于从响应中提取新的 Cookie 并存储。 * 这样可以实现在应用中对 Cookie 的管理与传递。 * */ .addInterceptor(AddCookiesInterceptor()) .addInterceptor(ReceivedCookiesInterceptor()) // 添加自定义请求日志拦截器 .addInterceptor(requestLoggingInterceptor) // 设置连接超时时间:15 秒,防止网络请求长时间挂起 .connectTimeout(15, TimeUnit.SECONDS) .build() // ==================== Retrofit 构建 ==================== val retrofit = Retrofit.Builder() // 关联自定义的 OkHttpClient,实现拦截和超时配置 .client(okHttpClient) // 添加 Gson 转换器工厂,将 JSON 数据自动序列化/反序列化为 Kotlin 对象 .addConverterFactory(GsonConverterFactory.create()) // 设置基础 URL,所有接口请求路径都会拼接到此地址后面 .baseUrl(API.BASE_URL) .build() // 创建 WAZApi 接口的实现,并赋值给单例属性 apiServer = retrofit.create(API.WAZApi::class.java) } }
我们通过自定义拦截器,保存了登录之后获取到的 Cookies
,便于我们之后的网络请求,至于 Cookies
是什么,这里不进行说明,以后会单独写一篇关于网络的文章。 至此,我们网络请求的封装就完成了,接下来我们看看一下通用类的内容。
5. common
-
MyConfig
配置常量// 用于存放全局配置项的类 class MyConfig { companion object { // 表示用户是否已登录的标志位,存储在 SharedPreferences 中的 key const val IS_LOGIN = "isLogin" // 存储用户登录后返回的 Cookie,用于后续接口请求时维持会话 const val COOKIE = "cookie" // 存储用户名或用户标识,用于界面展示或请求参数 const val USER_NAME = "username" } }
-
RetrofitResponseListener
自定义网络请求返回监听事件接口/** * RetrofitResponseListener 接口定义了一个通用的网络请求回调契约, * 使用泛型 T 来表示成功时返回的数据类型。实现该接口后, * 调用方可以在 onSuccess/onError 中编写具体的业务逻辑。 * * 该接口属于典型的“监听者(Listener)”模式,用于异步事件通知, * 与 Android 中的 View 点击监听、Retrofit 1.x 中的 Callback<T> 类似。 * * @param T : Any 表示回调成功时返回的数据类型,使用 Kotlin 的泛型来保证类型安全。 */ interface RetrofitResponseListener<T : Any> { /** * 当网络请求成功且响应体被正确解析为 T 类型时回调此方法。 * * @param response 从服务器返回并解析后的数据对象,类型为 T */ fun onSuccess(response: T) /** * 当网络请求失败、服务器返回错误码或解析异常时回调此方法。 * * @param errorCode HTTP 或应用级错误码(如非 2xx) * @param errorMessage 错误描述信息,便于提示或日志记录 */ fun onError(errorCode: Int, errorMessage: String) }
不容易啊,总算完成了前期的所有准备工作,整体如下
还有一个关键的地方,如果需要进行网络请求的话,我们要在 AndroidManifest.xml
中设置网路请求的权限
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" >
<uses-permission android:name="android.permission.INTERNET"/>
<application
...
</application>
</manifest>
接下来,我们进行项目界面的开发。
6. SplashActivity
引导页面
这是我们项目的第一个页面,也是最简单的页面,首先读者还记得如何创建一个新的 Activity
吗? 我相信你一定手拿把掐的创建好了,值得注意的是,引导页面一般都是在程序启动的时候第一个出来的界面,那么我们需要在 AndroidManifest.xml
中将其 Activity
中设置为程序入口,我这里就只提醒一下,具体如何操作,读者肯定已经拿捏了。
-
activity_splash
<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".presentation.splash.SplashActivity"> <ImageView android:id="@+id/image_view" android:layout_width="100dp" android:layout_height="100dp" android:contentDescription="@null" android:src="@mipmap/ic_logo" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> </androidx.constraintlayout.widget.ConstraintLayout>
界面内容非常简单,就是展示一个项目LOGO,图片这里就不提供,随便搞个图片就行了~咱们没有产品,没有设计,没有UI,凑合吧T<T。
-
SplashActivity
还记得我们封装的
BaseActivity
和BaseMVPActivity
吗?我们使用那个呢?其实都行,如果项目中需要再引导页面中进行复杂数据处理,那么我就建议使用BaseMVPActivity
,反之就用最简单的BaseActivity
class SplashActivity : BaseActivity<ActivitySplashBinding>({ inflater -> // 通过 lambda 传入 LayoutInflater,初始化 ViewBinding ActivitySplashBinding.inflate(inflater) }) { /** * 初始化视图组件 * 在此方法中可完成状态栏、UI 控件的配置或动画启动 */ override fun initView() { // TODO: 根据需要在此处初始化视图,例如启动动画 } /** * 初始化数据或后台逻辑 * 可在此方法中加载配置、检查版本、从本地/网络获取数据等 */ override fun initData() { // TODO: 根据需要在此处初始化数据,例如读取缓存或接口预热 } /** * 统一注册点击或交互事件 * 用于延时跳转到主界面 */ override fun allClick() { // 使用匿名 Thread 对象启动一个新线程 object : Thread() { override fun run() { try { // 暂停 1 秒(1000 毫秒),模拟停留时间 sleep(1000) // 从 SplashActivity 跳转到 MainActivity startActivity(Intent(this@SplashActivity, MainActivity::class.java)) // 结束当前 Activity,移除返回栈,避免用户返回到闪屏页 finish() } catch (e: InterruptedIOException) { // 捕获可能的中断异常并打印堆栈 e.printStackTrace() } } }.start() // 启动线程 } }
接下来我们运行看一下是否有问题,现在实现的效果进入引导页面一秒后,跳转到还未进行处理的
MainActivity
界面,显示Hello World!
接下来我们将去实现一下登录界面,比
SplashActivity
复杂一丢丢,也就一丢丢,循序渐进的去进行项目的开发,也利于新手上手了解。
7. LoginActivity
登录界面
登录界面,UI方面没啥好说的,就是普通的账号密码登录这样,主要是这个界面,将是我们第一个进行网路请求的界面,也将是我们开始使用 MVP
第一个实战模块。
-
activity_login
<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:id="@+id/main" android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".presentation.login.LoginActivity"> <ImageView android:id="@+id/iv_login_logo" android:layout_width="@dimen/dp_70" android:layout_height="@dimen/dp_70" android:contentDescription="@null" android:src="@mipmap/ic_logo" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" app:layout_constraintTop_toTopOf="parent" /> <LinearLayout android:id="@+id/ll_input_username" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="@dimen/dp_20" android:background="@color/white" android:orientation="horizontal" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" app:layout_constraintTop_toBottomOf="@id/iv_login_logo"> <TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:paddingLeft="@dimen/dp_20" android:paddingRight="@dimen/dp_20" android:text="@string/username" android:textSize="@dimen/sp_16" /> <EditText android:id="@+id/et_username" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="3" android:background="@null" android:hint="@string/hint_username" android:maxLines="1" android:padding="@dimen/dp_8"/> </LinearLayout> <LinearLayout android:id="@+id/ll_input_password" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="@dimen/dp_20" android:background="@color/white" android:orientation="horizontal" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" app:layout_constraintTop_toBottomOf="@id/ll_input_username"> <TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:paddingLeft="@dimen/dp_20" android:paddingRight="@dimen/dp_20" android:text="@string/password" android:textSize="@dimen/sp_16" /> <EditText android:id="@+id/et_password" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="3" android:background="@null" android:hint="@string/hint_password" android:inputType="textPassword" android:imeOptions="actionDone" android:maxLines="1" android:padding="@dimen/dp_8" /> </LinearLayout> <android.widget.Button android:id="@+id/btn_login" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="@dimen/dp_30" android:background="@drawable/selector_primary_oval" android:text="@string/login" android:textColor="@color/white" android:textSize="@dimen/sp_16" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" app:layout_constraintTop_toBottomOf="@id/ll_input_password" /> <TextView android:id="@+id/tv_register" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="@dimen/dp_24" android:text="@string/register" android:textColor="@color/colorPrimary" android:textSize="@dimen/sp_14" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" app:layout_constraintTop_toBottomOf="@id/btn_login" /> </androidx.constraintlayout.widget.ConstraintLayout>
布局中为什么使用
android.widget.Button
而不是Button
这里我就不再多说了,不知道翻之前基础UI学习关于Button
的地方再看看-
selector_primary_oval
<?xml version="1.0" encoding="utf-8"?> <selector xmlns:android="http://schemas.android.com/apk/res/android"> <item android:state_pressed="false"> <shape> <corners android:radius="999dp" /> <solid android:color="@color/colorPrimary" /> </shape> </item> <item android:state_pressed="true"> <shape> <corners android:radius="999dp" /> <solid android:color="@color/colorPrimaryDark" /> </shape> </item> </selector>
-
-
LoginContract
/** * LoginContract 定义了登录模块的 MVP 三大角色接口:Model、View、Presenter, * 用于在登录功能中统一约定各层之间的交互方法,解耦并规范实现。 */ interface LoginContract : BaseContract { /** * Model 层接口,负责具体的数据获取和业务逻辑处理。 * 继承自 IBaseModel,可根据需要扩展更多通用方法。 */ interface Model : BaseContract.IBaseModel { /** * 执行登录操作 * * @param username 用户输入的用户名 * @param password 用户输入的密码 * @param listener 回调监听器,用于异步获取结果或错误信息 */ suspend fun login( username: String, password: String, listener: RetrofitResponseListener<User> ) } /** * View 层接口,负责通知 Presenter 更新 UI,以及展示登录结果。 * 继承自 IBaseView,可获取 Activity 上下文等通用方法。 */ interface View : BaseContract.IBaseView { /** * 登录成功时调用 * * @param baseUserBean 服务器返回的用户信息实体 */ fun loginSuccess(baseUserBean: User) /** * 登录失败时调用 * * @param errorMessage 错误提示信息,可用于 Toast 或错误页展示 */ fun loginError(errorMessage: String) } /** * Presenter 层接口,负责接收 View 的用户交互请求, * 调用 Model 完成业务逻辑后,将结果反馈给 View。 * 继承自 IBasePresenter,提供生命周期管理方法。 */ interface Presenter : BaseContract.IBasePresenter { /** * 发起登录请求 * * @param username 用户名 * @param password 密码 */ fun login(username: String, password: String) } }
-
LoginModel
/** * LoginModel 实现了 LoginContract.Model 接口, * 负责调用网络接口执行登录逻辑,并通过回调通知调用方结果。 */ class LoginModel : LoginContract.Model { /** * 发起登录请求 * * @param username 用户名 * @param password 密码 * @param listener 登录结果回调,成功时返回 User 对象,失败时返回错误码和消息 */ override suspend fun login( username: String, password: String, listener: RetrofitResponseListener<User> ) = launchCoroutine( // 正常执行块:调用 RetrofitService.getApiService().login 发起网络请求 { // 发起登录请求,获取封装在 BaseBean<User> 中的响应 val userBaseBean = RetrofitService.getApiService().login(username, password) // 根据返回的 errorCode 判断请求是否成功 if (userBaseBean.errorCode != 0) { // 请求失败:通过 listener 回调 onError,传入错误码和错误消息 listener.onError(userBaseBean.errorCode, userBaseBean.errorMsg) } else { // 请求成功:通过 listener 回调 onSuccess,传入解析出的 User 对象 listener.onSuccess(userBaseBean.data) } }, // 异常处理块:捕获网络或解析过程中的任何异常并打印堆栈 onError = { e: Throwable -> e.printStackTrace() } ) }
-
LoginPresenter
/** * LoginPresenter 实现了 LoginContract.Presenter,并继承自 BasePresenter, * 负责接收 View 层的登录请求,调用 Model 层执行业务,再将结果反馈给 View 层。 * * @param view 传入的 LoginContract.View 实例,用于后续回调 UI 更新 */ class LoginPresenter(view: LoginContract.View) : BasePresenter<LoginContract.View, LoginContract.Model>(view), LoginContract.Presenter { /** * 创建并返回当前 Presenter 对应的 Model 实例 * * @return LoginModel,用于执行实际的网络登录请求 */ override fun createModel(): LoginContract.Model = LoginModel() /** * 发起登录请求 * * @param username 用户输入的用户名 * @param password 用户输入的密码 */ override fun login(username: String, password: String) { // 使用 BasePresenter 中提供的 coroutineScope 在主线程启动协程 coroutineScope.launch { // 调用 Model 层的 login 方法,传入用户名、密码和回调监听器 mModel?.login(username, password, object : RetrofitResponseListener<User> { /** * 当登录成功时被回调 * * @param response 成功返回的 User 对象 */ override fun onSuccess(response: User) { // 从弱引用中获取 View 实例,并通知 View 层登录成功 mView?.get()?.loginSuccess(response) } /** * 当登录失败时被回调 * * @param errorCode 后端返回的错误码 * @param errorMessage 后端返回的错误描述 */ override fun onError(errorCode: Int, errorMessage: String) { // 从弱引用中获取 View 实例,并通知 View 层登录失败 mView?.get()?.loginError(errorMessage) } }) } } }
-
LoginActivity
/** * LoginActivity 基于 MVP 模式的 Activity,实现了 LoginContract.View, * 并通过 BaseMVPActivity 提供的 ViewBinding 与 Presenter 统一管理逻辑。 */ class LoginActivity : BaseMVPActivity<ActivityLoginBinding, LoginContract.Presenter>({ ActivityLoginBinding.inflate(it) }), LoginContract.View { /** * 创建并返回当前 Activity 对应的 Presenter 实例 */ override fun createPresenter(): LoginContract.Presenter = LoginPresenter(this) /** * 初始化视图组件,可在此处完成 Toolbar、状态栏等 UI 设置 */ override fun initView() { } /** * 初始化数据,如从本地或网络预加载必要内容 */ override fun initData() { setBarTitle("登录") } /** * 注册所有点击事件 */ override fun allClick() { // 登录按钮点击事件 binding.btnLogin.setOnClickListener { // 获取用户输入 val username = binding.etUsername.text.toString() val password = binding.etPassword.text.toString() // 非空校验 if (ObjectUtil.isEmpty(username)) { show("请输入用户名") } else if (ObjectUtil.isEmpty(password)) { show("请输入密码") } else { // 显示加载框 YUtils.showLoading(this@LoginActivity, "登录中") // 调用 Presenter 发起登录 mPresenter.login(username, password) } } // 注册跳转到注册页面 binding.tvRegister.setOnClickListener { show("我要注册!!!") } } /** * 登录成功回调 * * @param baseUserBean 登录成功后返回的用户数据 */ override fun loginSuccess(baseUserBean: User) { // 隐藏加载框 YUtils.hideLoading() // 提示登录成功 show("登录成功 ${baseUserBean.username}") // 保存登录状态与用户名到 SharedPreferences SpUtil.setString(MyConfig.USER_NAME,baseUserBean.username) SpUtil.setBoolean(MyConfig.IS_LOGIN, true) // 跳转到主页面 startActivity(Intent(this@LoginActivity, MainActivity::class.java)) // 结束当前页,避免返回时停留此页 finish() } /** * 登录失败回调 * * @param errorMessage 登录失败的错误信息 */ override fun loginError(errorMessage: String) { // 隐藏加载框 YUtils.hideLoading() // 提示登录失败 show("登录失败 $errorMessage") } /** * IBaseView 要求实现的方法,返回当前 Activity 用于通用操作(如显示 Toast) */ override fun getActivity(): Activity = this }
至此我们完成了登陆界面的编写,运行一下,发现为什么还是直接进入到了
Hello Wrold!
界面呢?修改SplashActivity
代码如下class SplashActivity : BaseActivity<ActivitySplashBinding>({ inflater -> // 通过 lambda 传入 LayoutInflater,初始化 ViewBinding ActivitySplashBinding.inflate(inflater) }) { . . . . /** * 统一注册点击或交互事件 * 用于延时跳转到主界面 */ override fun allClick() { // 使用匿名 Thread 对象启动一个新线程 object : Thread() { override fun run() { try { // 暂停 1 秒(1000 毫秒),模拟停留时间 sleep(1000) // 根据登录状态进行页面跳转 if (SpUtil.getBoolean(MyConfig.IS_LOGIN)) { startActivity(Intent(this@SplashActivity, MainActivity::class.java)) } else { startActivity(Intent(this@SplashActivity, LoginActivity::class.java)) } // 结束当前 Activity,移除返回栈,避免用户返回到闪屏页 finish() } catch (e: InterruptedIOException) { // 捕获可能的中断异常并打印堆栈 e.printStackTrace() } } }.start() // 启动线程 } . . . }
然后我们在运行一下,发现终于成功的进入了
LoginActivity
界面,我们尝试一下登录吧,没有账号的可以去玩Android - wanandroid.com - 每日推荐优质文章下注册一个,或者先完成后面的注册界面在来登录。输入正确的账号密码后,登录成功!!!!说明目前来说,写的代码还没有啥bug,咱们真牛o( ̄▽ ̄)d。但是由于还没有做退出登录的操作,所以登录成功之后再进去,会一直跳转到Hello World!
, 后面会处理,现在我们将 APP 卸载掉,在run就行了。记得修改注册按钮点击事件
// 注册跳转到注册页面 binding.tvRegister.setOnClickListener { startActivity(Intent(this@LoginActivity, RegisterActivity::class.java)) }
8. RegisterActivity
注册界面
登录注册不分家~所以注册界面也有必要,和登录界面的难度一样,没啥复杂的,咱们直接上手!后面例如网络请求这些处理的作者我就不写注释了,不是因为我懒,而是因为我相信各位读者能力,肯定看的懂 (~ ̄▽ ̄)~。
-
activity_register
<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" android:background="#F3EFEF" android:padding="@dimen/dp_30" tools:context=".presentation.register.RegisterActivity"> <ImageView android:id="@+id/iv_login_logo" android:layout_width="@dimen/dp_70" android:layout_height="@dimen/dp_70" android:contentDescription="@null" android:src="@mipmap/ic_logo" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" app:layout_constraintTop_toTopOf="parent" /> <LinearLayout android:id="@+id/ll_input_username" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="@dimen/dp_20" android:background="@color/white" android:orientation="horizontal" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" app:layout_constraintTop_toBottomOf="@id/iv_login_logo"> <TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:paddingLeft="@dimen/dp_20" android:paddingRight="@dimen/dp_20" android:text="@string/username" android:textSize="@dimen/sp_16" /> <EditText android:id="@+id/et_username" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="3" android:background="@null" android:hint="@string/hint_username" android:maxLines="1" android:padding="@dimen/dp_8" /> </LinearLayout> <LinearLayout android:id="@+id/ll_input_password" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="@dimen/dp_20" android:background="@color/white" android:orientation="horizontal" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" app:layout_constraintTop_toBottomOf="@id/ll_input_username"> <TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:paddingLeft="@dimen/dp_20" android:paddingRight="@dimen/dp_20" android:text="@string/password" android:textSize="@dimen/sp_16" /> <EditText android:id="@+id/et_password" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="3" android:background="@null" android:hint="@string/hint_password" android:imeOptions="actionDone" android:inputType="textPassword" android:maxLines="1" android:padding="@dimen/dp_8" /> </LinearLayout> <LinearLayout android:id="@+id/ll_input_password_again" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="@dimen/dp_20" android:background="@color/white" android:orientation="horizontal" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" app:layout_constraintTop_toBottomOf="@id/ll_input_password"> <TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:paddingLeft="@dimen/dp_20" android:paddingRight="@dimen/dp_20" android:text="@string/password" android:textSize="@dimen/sp_16" /> <EditText android:id="@+id/et_password_again" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="3" android:background="@null" android:hint="@string/hint_password_again" android:inputType="textPassword" android:imeOptions="actionDone" android:maxLines="1" android:padding="@dimen/dp_8" /> </LinearLayout> <Button android:id="@+id/btn_register" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="@dimen/dp_30" android:background="@drawable/selector_primary_oval" android:text="@string/register" android:textColor="@color/white" android:textSize="@dimen/sp_16" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" app:layout_constraintTop_toBottomOf="@+id/ll_input_password_again" /> </androidx.constraintlayout.widget.ConstraintLayout>
-
RegisterContract
interface RegisterContract : BaseContract { interface Model : BaseContract.IBaseModel { suspend fun register( username: String, password: String, passwordAgain: String, listener: RetrofitResponseListener<User> ) } interface View : BaseContract.IBaseView { fun registerSuccess(baseUserBean: User) fun registerError(errorMessage: String) } interface Presenter : BaseContract.IBasePresenter { fun register(username: String, password: String, passwordAgain: String) } }
-
RegisterModel
class RegisterModel : RegisterContract.Model { override suspend fun register( username: String, password: String, passwordAgain: String, listener: RetrofitResponseListener<User> ) = launchCoroutine({ val userBaseBean = RetrofitService.getApiService() .register(username, password, passwordAgain) if (userBaseBean.errorCode != 0) { listener.onError(userBaseBean.errorCode, userBaseBean.errorMsg) } else { listener.onSuccess(userBaseBean.data) } }, onError = { e: Throwable -> e.printStackTrace() }) }
-
RegisterPresenter
class RegisterPresenter(view: RegisterContract.View) : BasePresenter<RegisterContract.View, RegisterContract.Model>(view), RegisterContract.Presenter { override fun createModel(): RegisterContract.Model = RegisterModel() override fun register(username: String, password: String, passwordAgain: String) { coroutineScope.launch { mModel?.register( username, password, passwordAgain, object : RetrofitResponseListener<User> { override fun onSuccess(response: User) { mView?.get()?.registerSuccess(response) } override fun onError(errorCode: Int, errorMessage: String) { mView?.get()?.registerError(errorMessage) } }) } } }
-
RegisterActivity
class RegisterActivity : BaseMVPActivity<ActivityRegisterBinding, RegisterContract.Presenter>({ ActivityRegisterBinding.inflate(it) }), RegisterContract.View { override fun createPresenter(): RegisterContract.Presenter { return RegisterPresenter(this) } override fun getActivity(): Activity { return this } override fun initView() { setBackEnabled() } override fun initData() { setBarTitle("注册") } override fun allClick() { binding.btnRegister.setOnClickListener { val username = binding.etUsername.text.toString() val password = binding.etPassword.text.toString() val passwordAgain = binding.etPasswordAgain.text.toString() // 注册信息筛选 if (ObjectUtil.isEmpty(username)) { show("请输入注册账号") } else if (ObjectUtil.isEmpty(password)) { show("请输入密码") } else if (ObjectUtil.isEmpty(passwordAgain)) { show("请再次输入密码") } else if (password != passwordAgain) { show("请确保两次密码输入一致") } else { YUtils.showLoading(this, "注册中") mPresenter.register(username, password, passwordAgain) } } } override fun registerSuccess(baseUserBean: User) { YUtils.hideLoading() show("注册成功,请登录") finish() // 结束当前注册界面, 退出到登录界面 } override fun registerError(errorMessage: String) { YUtils.hideLoading() show("$errorMessage") // 提示注册失败信息 } }
至此我们完成注册界面的实现,没有注册过的读者赶紧尝试注册一下吧,注册完再去验证一下登录是否成功吧>O<。
9. MainActiviy
主界面
我们通过阅读 玩Android 开放API-玩Android - wanandroid.com API文档,知道我们项目可以实现的页面有很多很多,那么这么多页面我们该如何实现呢?在这里项目中将使用 Viewpager + fragment
实现 可滑动分页 的UI模式,在 MainActivity
中切换多个 Fragment
,接下来让我们看看怎么实现吧,项目中主要实现首页、体系、导航、项目的内容,其他需要读者自行进行拓展开发了。现在我们一步一步来
activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".presentation.main.MainActivity">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<androidx.viewpager.widget.ViewPager
android:id="@+id/view_pager"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/bottom_navigation"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?android:attr/windowBackground"
app:menu="@menu/bottom_navigation" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
-
ViewPager
使用户能够左右滑动在不同页面之间切换,是基于经典滑动分页模式的组件,在本项目,我们将
HomeFragment
、ProjectFragment
、TreeFragment
、NavFragment
通过适配器加载到ViewPager
中,通过文字描述,是不是感觉有点像RecyclerView
一样?ViewPager
(尤其是ViewPager2
)和RecyclerView
在底层设计上都采用了Adapter+LayoutManager(或类似机制)的模式,因此二者在使用感、代码结构和性能优化思路上有诸多相似之处,所以很容易上手的,这里不再进行详细描述哈~ -
BottomNavigationView
是 Material Design 指南中底部导航栏的标准实现,适合在应用中承载 3–5 个“顶级”导航目的地,让用户可一键切换主界面
在 res
资源文件夹下,创建一个 menu
文件夹,在该文件夹中,我们创建 bottom_navigation
文件
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/navigation_home"
android:icon="@drawable/ic_home"
android:title="@string/title_home" />
<item
android:id="@+id/navigation_tree"
android:icon="@drawable/ic_tree"
android:title="@string/title_tree" />
<item
android:id="@+id/navigation_navi"
android:icon="@drawable/ic_navi"
android:title="@string/title_navi" />
<item
android:id="@+id/navigation_project"
android:icon="@drawable/ic_project"
android:title="@string/title_project" />
</menu>
这是底部导航栏的资源文件,icon
资源网上一搜一大把 iconfont-阿里巴巴矢量图标库 这里有很多免费的,读者没有这些小图标就去搜吧
MainActivity
class MainActivity : BaseActivity<ActivityMainBinding>({ ActivityMainBinding.inflate(it) }) {
/**
* 初始化视图,在此方法中调用 initFragments(),
* 用于设置 ViewPager 和 BottomNavigationView 之间的联动
*/
override fun initView() {
initFragments()
}
/**
* 初始化数据,这里暂无数据加载逻辑,可根据需求补充
*/
override fun initData() {
}
/**
* 注册所有点击和滑动事件监听
*/
override fun allClick() {
/**
* ViewPager 滑动监听:
* 当页面滑动时,根据当前位置更新 BottomNavigationView 的选中状态
*/
binding.viewPager.addOnPageChangeListener(object : OnPageChangeListener {
override fun onPageScrolled(
position: Int,
positionOffset: Float,
positionOffsetPixels: Int
) {
// 将对应 menu 项设为选中,以同步底部导航栏状态
binding.bottomNavigation.menu[position].isChecked = true
}
override fun onPageSelected(position: Int) {
// 页面选中时的回调(可选,用于额外逻辑)
}
override fun onPageScrollStateChanged(state: Int) {
// 滚动状态变化时的回调(可选,用于额外逻辑)
}
})
/**
* BottomNavigationView 点击事件:
* 根据用户点击切换 ViewPager 的当前页面
*/
binding.bottomNavigation.setOnNavigationItemSelectedListener {
when (it.itemId) {
R.id.navigation_home -> {
// 切换到首页 Fragment
binding.viewPager.currentItem = 0
return@setOnNavigationItemSelectedListener true
}
R.id.navigation_tree -> {
// 切换到体系 Fragment
binding.viewPager.currentItem = 1
return@setOnNavigationItemSelectedListener true
}
R.id.navigation_navi -> {
// 切换到导航 Fragment
binding.viewPager.currentItem = 2
return@setOnNavigationItemSelectedListener true
}
R.id.navigation_project -> {
// 切换到项目 Fragment
binding.viewPager.currentItem = 3
return@setOnNavigationItemSelectedListener true
}
}
false
}
}
/**
* 初始化 ViewPager 的 Fragment 列表并绑定适配器
*/
private fun initFragments() {
// 创建适配器并添加各个页面的 Fragment
val viewPagerAdapter = CommonViewPageAdapter(supportFragmentManager).apply {
addFragment(HomeFragment()) // 首页
addFragment(TreeFragment()) // 体系
addFragment(NaviFragment()) // 导航
addFragment(ProjectFragment()) // 项目
}
// 设置 ViewPager 的预加载页面数,保证左右各保留最多 3 个 Fragment
binding.viewPager.offscreenPageLimit = 3
// 绑定适配器
binding.viewPager.adapter = viewPagerAdapter
// 保留 BottomNavigationView 引用,实际操作在 allClick() 中完成
binding.bottomNavigation
}
}
代码中的首页、体系、导航、项目这几个模块,我们抽离出来成单独的模块,在对应模块中进行对应界面数据的获取,项目架构如下所示:
VIewPager
的 CommonViewPageAdapter
class CommonViewPageAdapter : FragmentPagerAdapter {
// 可选的页面标题列表,用于 getPageTitle() 返回
private var mTitles: List<String>? = null
// 存放实际要展示的 Fragment 列表
private var mFragments: MutableList<Fragment> = ArrayList()
/**
* 构造函数:仅传入 FragmentManager
* @param fm 管理 Fragment 的 FragmentManager,必须非空
*/
constructor(fm: FragmentManager) : super(fm)
/**
* 构造函数:传入 FragmentManager 和页面标题列表
* @param fm 管理 Fragment 的 FragmentManager
* @param titles 每个页面对应的标题列表
*/
constructor(fm: FragmentManager?, titles: List<String>?) : super(fm!!) {
mTitles = titles
}
/**
* 向适配器中动态添加一个 Fragment 页面
* @param fragment 要添加的 Fragment 实例
*/
fun addFragment(fragment: Fragment) {
mFragments.add(fragment)
}
/**
* 返回总页数,即 Fragment 列表的大小
*/
override fun getCount(): Int {
return mFragments.size
}
/**
* 根据位置返回对应的 Fragment
* @param position 页面索引,从 0 开始
*/
override fun getItem(position: Int): Fragment {
return mFragments[position]
}
/**
* 返回每个页面的标题,用于 TabLayout 等组件显示
* @param position 页面索引
* @return 对应位置的标题字符串
*/
override fun getPageTitle(position: Int): CharSequence? {
// mTitles 非空并包含足够元素时返回对应标题
return mTitles!![position]
}
}
至此,我们的主界面总算完成了整个项目容器的搭建,运行一下,登陆成功之后,成功进入主界面,底部四个导航栏,页面左右可以滑动,点击底部导航也可以进行页面的滑动,但是内容都是 Hello blank fragment
,那是因为我们还没有对对应的界面进行设计,那么接下来们开始我们首页的 Fragment
的页面设计开发吧,这里要清楚的知道 Fragment
和 Activity
还是有写区别的,貌似之前的文章没有详细讲解过有啥区别,那么这里就当做文章拓展内容,我们先讲一下 Activity
和 Fragment
之间的区别,以及什么时候用,怎么用 Fragment
,并且简单的介绍一下这两者的生命周期。
10. Activity
和 Fragment
在 Android 中,Activity 是应用的基本界面组件,代表一个可与用户交互的屏幕,而 Fragment 则是可复用的 UI 片段,需要依附在 Activity 或其他 Fragment 中才能存在。Activity 拥有独立的任务栈和系统级生命周期回调,而 Fragment 的生命周期则在宿主 Activity 的管理之下,更轻量且适合在同一屏幕内动态展示、添加或替换不同模块。通常,当需要全屏、独立的功能时使用 Activity;当需要多 Pane 布局、Tab 切换或可复用的 UI 片段时使用 Fragment。下面详细展开
Activity
Activity
是一个应用组件,它为用户提供一个交互界面,用于完成具体操作(如查看邮件列表、编写信息等)。- 每个应用至少包含一个被声明为主界面的 Activity,通过
<activity android:name="…">
在AndroidManifest.xml
中注册。 - 应用可包含多个 Activity,它们在“任务”(Task)中按打开顺序组成后退栈(Back Stack),用户可通过系统 Back 按钮在它们之间导航。
- Activity 的生命周期由系统以六个核心回调管理:
onCreate()
→onStart()
→onResume()
→onPause()
→onStop()
→onDestroy()
,并可通过onRestart()
在停止后重新启动。- onCreate():初始化 UI 与数据;
- onStart():Activity 即将对用户可见;
- onResume():Activity 已在前台并可交互;
- onPause():另一 Activity 获得焦点,进行短暂挂起;
- onStop():不再可见;
- onDestroy():即将被销毁,释放所有资源。
- 使用 Activity 的场景
- 全屏独立页面:如登录、设置、引导页等,需独立任务上下文。
- 系统入口或 Deep Link:对于外部启动点(广播、通知、URL 解析),通常映射到特定 Activity。
- 跨 Task 弹出窗口:如
AlertDialog
风格的透明 Activity,需脱离宿主视图。
Fragment
Fragment
代表可重用的 UI 片段,自带布局和生命周期,但不能独立存在,必须被添加到一个宿主 Activity(或父级 Fragment)中。- Fragment 拥有自己的生命周期回调(如
onAttach()
、onCreateView()
等),并通过FragmentManager
进行增删、替换和回退管理。 - 它们设计用于在平板(多 Pane)与手机(单 Pane)等不同屏幕尺寸上复用界面逻辑,并支持运行时动态添加或移除。
- Fragment 生命周期更细,包含:
onAttach()
→onCreate()
→onCreateView()
→onActivityCreated()
→onStart()
→onResume()
→onPause()
→onStop()
→onDestroyView()
→onDestroy()
→onDetach()
。- onAttach():与宿主 Activity 建立关联;
- onCreateView():创建并返回 UI 视图;
- onActivityCreated():宿主 Activity 的
onCreate()
完成后回调; - 随后的
onStart()
、onResume()
等与 Activity 类似; - onDestroyView():销毁 UI,但 Fragment 对象仍在;
- onDestroy() 与 onDetach():释放资源并与宿主断开。
- 使用 Fragment 的场景
- 多 Pane 布局:在平板或大屏上同时展示列表与详情。
- Tab / ViewPager:通过
ViewPager2 + FragmentStateAdapter
实现滑屏分页布局。 - 动态 UI 组合:运行时根据业务需求添加或替换不同的 UI 模块。
- 可重用组件:同一逻辑在多个 Activity 或上下文中被复用。
11. HomeFragment
首页
讲到这里,所以首页到底要怎么设计呢?问得好,我也不知道…又没有产品、UI设计,我还真不知道,哈哈哈,在网上搜了一大堆做这个玩安卓项目的APK,就拿那些来做示例吧。
HomeContract
/**
* HomeContract 定义了首页模块在 MVP 架构中的三大角色接口:
* - Model:负责数据获取与业务处理
* - View:负责 UI 更新与用户交互回调
* - Presenter:负责协调 View 与 Model、执行业务逻辑
*/
interface HomeContract : BaseContract {
/**
* Model 层接口,继承自 IBaseModel,可挂载公共生命周期管理方法
*/
interface Model : BaseContract.IBaseModel {
/**
* 获取轮播图数据
*
* @param listener 网络请求回调,返回 Banner 列表或错误信息
*/
suspend fun getBanner(listener: RetrofitResponseListener<MutableList<Banner>>)
/**
* 获取首页文章列表(第一页或指定页)
*
* @param page 页码,从 0 或 1 开始
* @param listener 网络请求回调,返回 Article 对象或错误信息
*/
suspend fun getArticleList(page: Int, listener: RetrofitResponseListener<Article>)
/**
* 拉取更多文章(分页加载下一页)
*
* @param page 下一页页码
* @param listener 网络请求回调,返回 Article 对象或错误信息
*/
suspend fun getMoreArticleList(page: Int, listener: RetrofitResponseListener<Article>)
/**
* 收藏文章
*
* @param id 待收藏的文章 ID
* @param listener 网络请求回调,返回成功提示或错误信息
*/
suspend fun collect(id: Int, listener: RetrofitResponseListener<String>)
/**
* 取消收藏文章
*
* @param id 待取消收藏的文章 ID
* @param listener 网络请求回调,返回成功提示或错误信息
*/
suspend fun unCollect(id: Int, listener: RetrofitResponseListener<String>)
}
/**
* View 层接口,继承自 IBaseView,可获取 Activity 对象等通用方法
*/
interface View : BaseContract.IBaseView {
// 轮播图回调
fun getBannerSuccess(bannerList: MutableList<Banner>) // 成功
fun getBannerError(errorMessage: String) // 失败
// 文章列表回调
fun getArticleListSuccess(article: Article) // 成功
fun getArticleListError(errorMessage: String) // 失败
// 加载更多文章回调
fun getMoreArticleListSuccess(article: Article) // 成功
fun getMoreArticleListError(errorMessage: String) // 失败
// 收藏回调
fun collectSuccess(successMessage: String) // 收藏成功
fun collectError(errorMessage: String) // 收藏失败
// 取消收藏回调
fun unCollectSuccess(successMessage: String) // 取消收藏成功
fun unCollectError(errorMessage: String) // 取消收藏失败
// 用户未登录时的提示(可跳转到登录页)
fun login(msg: String)
}
/**
* Presenter 层接口,继承自 IBasePresenter,负责接收 View 的调用并调度 Model
*/
interface Presenter : BaseContract.IBasePresenter {
/**
* 请求获取轮播图
*/
fun getBanner()
/**
* 请求获取文章列表
*
* @param page 页码
*/
fun getArticleList(page: Int)
/**
* 请求加载更多文章
*
* @param page 页码
*/
fun getMoreArticleList(page: Int)
/**
* 请求收藏文章
*
* @param id 文章 ID
*/
fun collect(id: Int)
/**
* 请求取消收藏
*
* @param id 文章 ID
*/
fun unCollect(id: Int)
}
}
HomeModel
/**
* HomeModel:MVP 模式中 Home 模块的 Model 实现
* 接收 Presenter 请求获取数据,并返回给 Presenter
*/
class HomeModel : HomeContract.Model {
/**
* 获取轮播图数据
* 通过 launchCoroutine 在协程中执行网络请求,简化异步处理
*/
override suspend fun getBanner(listener: RetrofitResponseListener<MutableList<Banner>>) {
return launchCoroutine({
// 调用 RetrofitService 获取 Banner 列表
val baseBannerBean = RetrofitService.getApiService().getBanner()
MLog.d("baseBannerBean = ${baseBannerBean.data.toJsonString()}")
// 根据返回的 errorCode 决定回调 success 还是 error
if (baseBannerBean.errorCode != 0) {
listener.onError(baseBannerBean.errorCode, baseBannerBean.errorMsg)
} else {
listener.onSuccess(baseBannerBean.data)
}
}, onError = { e: Throwable ->
// 网络或解析异常时打印异常信息
e.printStackTrace()
})
}
/**
* 获取首页文章列表(分页第一页)
* @param page 页码,从 0 或 1 开始
*/
override suspend fun getArticleList(page: Int, listener: RetrofitResponseListener<Article>) {
return launchCoroutine({
// 请求第 page 页文章数据
val baseArticleBean = RetrofitService.getApiService().getArticleList(page)
if (baseArticleBean.errorCode != 0) {
listener.onError(baseArticleBean.errorCode, baseArticleBean.errorMsg)
} else {
listener.onSuccess(baseArticleBean.data)
}
}, onError = { e: Throwable ->
e.printStackTrace()
})
}
/**
* 加载更多文章(分页加载后续页)
* @param page 下一页页码
*/
override suspend fun getMoreArticleList(
page: Int,
listener: RetrofitResponseListener<Article>
) {
return launchCoroutine({
// 与 getArticleList 相同接口,利用不同 page 参数实现“加载更多”
val baseArticleBean = RetrofitService.getApiService().getArticleList(page)
if (baseArticleBean.errorCode != 0) {
listener.onError(baseArticleBean.errorCode, baseArticleBean.errorMsg)
} else {
listener.onSuccess(baseArticleBean.data)
}
}, onError = { e: Throwable ->
e.printStackTrace()
})
}
/**
* 收藏文章
* @param id 文章 ID
*/
override suspend fun collect(id: Int, listener: RetrofitResponseListener<String>) {
return launchCoroutine({
// 调用收藏接口
val baseCollectBean = RetrofitService.getApiService().collect(id)
if (baseCollectBean.errorCode != 0) {
listener.onError(baseCollectBean.errorCode, baseCollectBean.errorMsg)
} else {
// 收藏成功,返回固定提示
listener.onSuccess("收藏成功")
}
}, onError = { e: Throwable ->
e.printStackTrace()
})
}
/**
* 取消收藏文章
* @param id 文章 ID
*/
override suspend fun unCollect(id: Int, listener: RetrofitResponseListener<String>) {
return launchCoroutine({
// 调用取消收藏接口
val baseCollectBean = RetrofitService.getApiService().unCollect(id)
if (baseCollectBean.errorCode != 0) {
listener.onError(baseCollectBean.errorCode, baseCollectBean.errorMsg)
} else {
// 取消收藏成功,返回固定提示
listener.onSuccess("取消收藏成功")
}
}, onError = { e: Throwable ->
e.printStackTrace()
})
}
}
HomePrensenter
/**
* HomePresenter:MVP 模式中 Home 模块的 Presenter 实现
* 负责从 Model 获取数据,并将结果回调给 View
*
* @param view 关联的 View 层接口,用于回调 UI 更新
*/
class HomePresenter(view: HomeContract.View) :
BasePresenter<HomeContract.View, HomeContract.Model>(view), HomeContract.Presenter {
/**
* 创建并返回对应的 Model 实例
*/
override fun createModel(): HomeContract.Model = HomeModel()
/**
* 获取轮播图数据
* - 在协程作用域中启动异步请求
* - 请求成功时调用 View.getBannerSuccess()
* - 请求失败时调用 View.getBannerError()
*/
override fun getBanner() {
coroutineScope.launch {
mModel?.getBanner(object : RetrofitResponseListener<MutableList<Banner>> {
override fun onSuccess(response: MutableList<Banner>) {
mView?.get()?.getBannerSuccess(response)
}
override fun onError(errorCode: Int, errorMessage: String) {
mView?.get()?.getBannerError(errorMessage)
}
})
}
}
/**
* 获取首页文章列表
* @param page 页码,从 0 或 1 开始
*/
override fun getArticleList(page: Int) {
coroutineScope.launch {
mModel?.getArticleList(page, object : RetrofitResponseListener<Article> {
override fun onSuccess(response: Article) {
mView?.get()?.getArticleListSuccess(response)
}
override fun onError(errorCode: Int, errorMessage: String) {
mView?.get()?.getArticleListError(errorMessage)
}
})
}
}
/**
* 加载更多文章(分页加载下一页)
* @param page 下一页页码
*/
override fun getMoreArticleList(page: Int) {
coroutineScope.launch {
mModel?.getArticleList(page, object : RetrofitResponseListener<Article> {
override fun onSuccess(response: Article) {
mView?.get()?.getMoreArticleListSuccess(response)
}
override fun onError(errorCode: Int, errorMessage: String) {
mView?.get()?.getMoreArticleListError(errorMessage)
}
})
}
}
/**
* 收藏文章
* @param id 文章 ID
* - 如果返回错误码 -1001(未登录),调用 View.login() 提示登录
* - 其他错误调用 View.collectError()
*/
override fun collect(id: Int) {
coroutineScope.launch {
mModel?.collect(id, object : RetrofitResponseListener<String> {
override fun onSuccess(response: String) {
mView?.get()?.collectSuccess(response)
}
override fun onError(errorCode: Int, errorMessage: String) {
if (errorCode == -1001) {
// 用户未登录,触发登录流程
mView?.get()?.login(errorMessage)
} else {
mView?.get()?.collectError(errorMessage)
}
}
})
}
}
/**
* 取消收藏文章
* @param id 文章 ID
*/
override fun unCollect(id: Int) {
coroutineScope.launch {
mModel?.unCollect(id, object : RetrofitResponseListener<String> {
override fun onSuccess(response: String) {
mView?.get()?.unCollectSuccess(response)
}
override fun onError(errorCode: Int, errorMessage: String) {
mView?.get()?.unCollectError(errorMessage)
}
})
}
}
}
这里建议读者能手敲一下代码,去体验一下封装之后的 MVP 模块之间的关联性,我们通过定义的 HomeContract
首页界面的契约类 ,这是一个接口,我们对应的 Model
、Presenter
都会去实现在契约类声明的方法,假如有新的请求,我们只用在 HomeContract
中添加接口,在对应实现类中,编译器就会让我们强制去实现,这样便于统一管理,我们接着在 View 层, 也就是我们的 HomeFragment
中实现 HomeContract
定义的 View 层接口,就会发现这个模块的每层都各司其职,阅读性和管理性都很好,那么我们接下来看看 HomeFragment
的具体实现吧。
fragment_home
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
tools:context=".presentation.home.HomeFragment">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/app_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fitsSystemWindows="true"
android:theme="@style/AppTheme.AppBarOverlay">
<com.google.android.material.appbar.CollapsingToolbarLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
app:contentScrim="?attr/colorPrimary"
app:layout_scrollFlags="scroll|exitUntilCollapsed"
app:toolbarId="@+id/toolbar">
<com.youth.banner.Banner
android:id="@+id/banner"
android:layout_width="match_parent"
android:layout_height="200dp"
app:layout_collapseMode="pin" />
</com.google.android.material.appbar.CollapsingToolbarLayout>
</com.google.android.material.appbar.AppBarLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_behavior="@string/appbar_scrolling_view_behavior"
tools:listitem="@layout/item_article" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
这个布局看着简单,但是有少些组件我之前没有讲过,这里稍微讲讲哈,别急
在这个布局中,CoordinatorLayout
与 AppBarLayout
/CollapsingToolbarLayout
搭配 RecyclerView
、并结合 com.youth.banner.Banner
,共同实现了“可折叠头部+列表联动滚动”的典型首页场景。总体思路是:将轮播图放在可折叠的工具栏内,当列表向上滚动时折叠隐藏轮播图;反向滚动时展开轮播图,并且利用 fitsSystemWindows
处理状态栏插图,从而获得浸入式、流畅的 UI 效果。
-
CoordinatorLayout
:容器与嵌套滚动枢纽CoordinatorLayout
是一个支持复杂、重叠和嵌套布局的ViewGroup
,它通过 Behavior 机制,将子视图之间的滑动、折叠、拖拽等交互关联起来。
在此布局中,它是根容器,包裹AppBarLayout
与RecyclerView
,并通过app:layout_behavior="@string/appbar_scrolling_view_behavior"
将列表的滚动偏移分发给AppBarLayout
,实现头部折叠与内容滚动的联动。 -
AppBarLayout
与CollapsingToolbarLayout
:可折叠头部AppBarLayout
是一个垂直线性布局,专门用于承载可滚动的工具栏或头部,并与CoordinatorLayout
配合,响应嵌套滚动事件。
其子视图CollapsingToolbarLayout
则提供“折叠展开”功能,通过app:layout_scrollFlags="scroll|exitUntilCollapsed"
指定——当内容向上滑动时,先滚动折叠,直至折叠至最小高度;反向滑动则展开至完整高度。
在CollapsingToolbarLayout
内部,我们放置了Banner
组件,并给它设置app:layout_collapseMode="pin"
,表示该视图在折叠过程中保持固定,不参与平移,而是“钉”在布局顶部直至消失。 -
Banner
:头部轮播图这里使用的
com.youth.banner.Banner
是一个基于ViewPager
的第三方轮播控件,支持自动轮播、指示器与自定义动画等功能。
将其置于CollapsingToolbarLayout
内,并借助layout_collapseMode="pin"
,可在列表滑动时流畅折叠隐藏,同时保持固定位置直到完全收起,提升视觉体验。 -
RecyclerView
与滚动行为联动列表部分使用
RecyclerView
,并在 XML 中指定app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
实现垂直线性布局。
更关键的是,通过app:layout_behavior="@string/appbar_scrolling_view_behavior"
,将RecyclerView
注册为AppBarLayout.ScrollingViewBehavior
的滚动兄弟,使其滚动时自动驱动AppBarLayout
的折叠与展开。 -
fitsSystemWindows
:状态栏插图处理在根布局、
AppBarLayout
及CollapsingToolbarLayout
上设置android:fitsSystemWindows="true"
,可让它们在沉浸式状态栏模式下正确调整内边距,将内容延伸至状态栏之下,并保证布局不被遮挡。需要注意的是,早期版本中CollapsingToolbarLayout
+fitsSystemWindows
可能存在图片缩放问题,但在 AndroidX 最新版本中已修复相关 BUG。
通过以上架构与配置,就可以快速构建一个带有折叠头部、轮播图和滚动列表联动的高端首页界面。
@style/AppTheme.AppBarOverlay
在资源文件 vlues
目录中的 themes.xml
添加这个主题
<style name="AppTheme.AppBarOverlay" parent="ThemeOverlay.AppCompat.Dark.ActionBar" />
item_article.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
app:cardBackgroundColor="@color/white"
app:cardCornerRadius="@dimen/dp_5"
app:cardElevation="@dimen/dp_2"
app:contentPadding="@dimen/dp_15"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:foreground="?android:attr/selectableItemBackground">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<ImageView
android:id="@+id/article_image"
android:layout_width="@dimen/dp_60"
android:layout_height="@dimen/dp_60"
android:contentDescription="@null"
android:src="@mipmap/ic_launcher" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/dp_15"
android:layout_marginLeft="@dimen/dp_15"
android:layout_marginEnd="@dimen/dp_10"
android:layout_marginRight="@dimen/dp_10"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/article_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="2"
android:text="标题"
android:textColor="@color/black"
android:textSize="@dimen/sp_16" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/dp_10"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/article_chapter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginRight="@dimen/dp_5"
android:background="@drawable/shape_border_primary_oval"
android:includeFontPadding="false"
android:paddingLeft="@dimen/dp_6"
android:paddingTop="@dimen/dp_2"
android:paddingRight="@dimen/dp_6"
android:paddingBottom="@dimen/dp_2"
android:text="标题"
android:textColor="@color/colorPrimary"
android:textSize="@dimen/sp_12" />
<TextView
android:id="@+id/article_date"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:ellipsize="end"
android:gravity="end"
android:maxLines="1"
android:text="标题"
android:textColor="@color/gray" />
</LinearLayout>
</LinearLayout>
<ImageView
android:id="@+id/article_favorite"
android:layout_width="@dimen/dp_30"
android:layout_height="@dimen/dp_30"
android:contentDescription="@null"
android:src="@drawable/ic_like_normal" />
</LinearLayout>
</androidx.cardview.widget.CardView>
- 容器:
CardView
- 使用场景:
CardView
提供了符合 Material Design 的卡片式样,包括圆角、阴影和背景色,常用于列表或网格中的每一项承载独立内容。 - 属性说明:
app:cardCornerRadius="@dimen/dp_5"
设置圆角半径,使卡片四角平滑。app:cardElevation="@dimen/dp_2"
增加阴影高度,突显卡片层次。app:contentPadding="@dimen/dp_15"
在卡片内容区域内统一留白,增强视觉舒适度。android:foreground="?android:attr/selectableItemBackground"
继承自FrameLayout
的前景属性,提供触摸时的水波/选中效果,提升交互反馈
- 使用场景:
其他资源文件读者自己处理一下哈~至此我们首页的UI设计就完成了,那么接下来,我们看看实际如何处理数据,并且将数据添加到布局中
HomeArticleAdapter
/**
* ArticleAdapter 继承自 BRVAH 提供的 BaseQuickAdapter,
* 用于为 RecyclerView 提供文章列表的 ViewHolder 绑定与加载更多功能。
*
* @param ArticleDetail 数据类型,对应单条文章详情
* @param BaseViewHolder BRVAH 自带的简单 ViewHolder
* @param R.layout.item_article 列表项布局资源 ID
*/
class HomeArticleAdapter : BaseQuickAdapter<ArticleDetail, BaseViewHolder>(R.layout.item_article),
LoadMoreModule { // 启用“加载更多”模块
init {
// 注册 child view 点击事件,这里指定点赞图标可响应点击
addChildClickViewIds(R.id.article_favorite)
}
/**
* 将数据绑定到 ViewHolder
*
* @param holder BRVAH 提供的 BaseViewHolder,封装了 itemView
* @param item 当前绑定的数据对象 ArticleDetail
*/
override fun convert(holder: BaseViewHolder, item: ArticleDetail) {
// 将 HTML 格式的标题字符串转换为富文本并设置到标题 TextView
holder.setText(R.id.article_title, Html.fromHtml(item.title))
// 设置章节名称
holder.setText(R.id.article_chapter, item.chapterName)
// 设置友好日期字符串
holder.setText(R.id.article_date, item.niceDate)
// 根据是否已收藏,加载不同的图标到点赞 ImageView
val favView = holder.getView<ImageView>(R.id.article_favorite)
if (item.collect) {
// 已收藏时,显示高亮的心形图标
Glide.with(context)
.load(R.drawable.ic_like_checked)
.into(favView)
} else {
// 未收藏时,显示灰色的心形图标
Glide.with(context)
.load(R.drawable.ic_like_normal)
.into(favView)
}
}
}
HomeFragment
/**
* HomeFragment:基于 MVP 模式的首页 Fragment
* - 继承自 BaseMVPFragment,实现 HomeContract.View 及各类回调接口
* - 负责展示轮播图、文章列表、加载更多、收藏逻辑等
*/
class HomeFragment :
BaseMVPFragment<FragmentHomeBinding, HomeContract.Presenter>({ FragmentHomeBinding.inflate(it) }),
HomeContract.View, // 与 Presenter 交互的 View 接口
OnBannerListener, // Banner 点击回调
OnLoadMoreListener, // 上拉加载更多回调
OnItemClickListener, // 列表项点击回调
OnItemChildClickListener { // 列表子项点击回调
companion object {
private const val TOTAL_COUNTER = 20 // 每次请求的最大条目数
private var CURRENT_SIZE = 0 // 当前已加载条目数
private var CURRENT_PAGE = 0 // 当前加载的页码
}
// 轮播图数据列表
private lateinit var bannerList: List<Banner>
// 文章数据列表
private lateinit var mDataList: MutableList<ArticleDetail>
// 列表适配器
private lateinit var mHomeArticleAdapter: HomeArticleAdapter
// 当前操作的文章位置(用于收藏/取消收藏回调)
private var mPosition: Int = 0
override fun initView() {
// 此处可初始化视图组件,已在布局中通过 ViewBinding 关联
}
override fun initData() {
// 请求轮播图和第一页文章列表
mPresenter.getBanner()
mPresenter.getArticleList(CURRENT_PAGE)
}
override fun allClick() {
// 此处可统一注册额外点击事件
}
override fun createPresenter(): HomeContract.Presenter {
// 创建并返回 Presenter 实例
return HomePresenter(this)
}
// ==================== Banner 回调 ====================
override fun getBannerSuccess(bannerList: MutableList<Banner>) {
this.bannerList = bannerList
// 提取图片地址和标题
val images: MutableList<String> = ArrayList()
val titles: MutableList<String> = ArrayList()
for (index in bannerList.indices) {
images.add(bannerList[index].imagePath)
titles.add(bannerList[index].title)
}
// 根据屏幕高度动态设置 Banner 高度
val layoutParams = binding.banner.layoutParams
layoutParams.height = (DisplayUtil.getScreenHeight() / 1.8).roundToInt()
// 配置并启动 Banner
binding.banner.setImages(images)
.setBannerTitles(titles)
.setBannerStyle(BannerConfig.CIRCLE_INDICATOR_TITLE_INSIDE)
.setImageLoader(GlideImageLoader())
.start()
// 监听 Banner 点击事件
binding.banner.setOnBannerListener(this)
}
override fun getBannerError(errorMessage: String) {
// 轮播图加载失败,弹出提示
show(errorMessage)
}
// ==================== 文章列表回调 ====================
override fun getArticleListSuccess(article: Article) {
// 保存当前加载条数和数据
CURRENT_SIZE = article.size
mDataList = article.datas
// 初始化并配置适配器
mHomeArticleAdapter = HomeArticleAdapter().apply {
animationEnable = true // 启用加载动画
setOnItemClickListener(this@HomeFragment) // 列表项点击
setOnItemChildClickListener(this@HomeFragment) // 子项点击(收藏图标)
loadMoreModule.setOnLoadMoreListener(this@HomeFragment) // 加载更多
}
// 绑定适配器并填充数据
binding.recyclerView.adapter = mHomeArticleAdapter
mHomeArticleAdapter.setList(mDataList)
}
override fun getArticleListError(errorMessage: String) {
// 文章列表加载失败,弹出提示
show(errorMessage)
}
override fun getMoreArticleListSuccess(article: Article) {
// 分页加载成功,追加数据并通知适配器
mDataList.addAll(article.datas)
CURRENT_SIZE = article.datas.size
mHomeArticleAdapter.addData(article.datas)
mHomeArticleAdapter.loadMoreModule.loadMoreComplete()
}
override fun getMoreArticleListError(errorMessage: String) {
// 分页加载失败,弹出提示
show(errorMessage)
}
// ==================== 收藏/取消收藏回调 ====================
override fun collectSuccess(successMessage: String) {
// 收藏成功,更新数据并刷新列表
show(successMessage)
mDataList[mPosition].collect = true
mHomeArticleAdapter.notifyDataSetChanged()
}
override fun collectError(errorMessage: String) {
// 收藏失败,弹出提示
show(errorMessage)
}
override fun unCollectSuccess(successMessage: String) {
// 取消收藏成功,更新数据并刷新列表
show(successMessage)
mDataList[mPosition].collect = false
mHomeArticleAdapter.notifyDataSetChanged()
}
override fun unCollectError(errorMessage: String) {
// 取消收藏失败,弹出提示
show(errorMessage)
}
// ==================== 登录提示回调 ====================
override fun login(msg: String) {
// 弹出对话框提示需登录,并跳转到 LoginActivity
val builder = AlertDialog.Builder(binding.root.context).apply {
setTitle("提示")
setMessage(msg)
setPositiveButton("确定") { _, _ ->
startActivity(Intent(binding.root.context, LoginActivity::class.java))
}
setNegativeButton("取消", null)
}
builder.create().show()
}
// ==================== 交互事件 ====================
override fun onItemChildClick(
adapter: BaseQuickAdapter<*, *>,
view: View,
position: Int
) {
// 子项点击(收藏图标)
mPosition = position
if (mDataList[position].collect) {
mPresenter.unCollect(mDataList[position].id)
} else {
mPresenter.collect(mDataList[position].id)
}
}
override fun onLoadMore() {
// 加载更多时延迟 1s 再执行,模拟加载
binding.recyclerView.postDelayed({
if (CURRENT_SIZE < TOTAL_COUNTER) {
// 数据已加载完毕
mHomeArticleAdapter.loadMoreModule.loadMoreEnd(true)
} else {
// 加载下一页
CURRENT_PAGE++
mPresenter.getMoreArticleList(CURRENT_PAGE)
}
}, 1000)
}
override fun onItemClick(
adapter: BaseQuickAdapter<*, *>,
view: View,
position: Int
) {
// 列表项点击,跳转到详情页
}
override fun OnBannerClick(position: Int) {
// Banner 点击,跳转到对应链接详情页
}
override fun onDestroyView() {
super.onDestroyView()
// Fragment 销毁视图时,停止 Banner 自动轮播,避免资源泄漏
binding.banner.stopAutoPlay()
}
}
至此,我们运行一下,因为还没有退出登录功能,所以,建议卸载后再 run ,将流程从头走一次,确保没有bug。登录成功之后,界面如下
并且我们可以点击进行收藏和取消收藏,要看看是否收藏成功,我们可以登录账号去官方查看
可以发现没有问题,美滋滋~那么我们接着实现点击跳转至详情界面吧
activity_detail.xml
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/web_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".presentation.detail.DetailActivity">
<!-- 原生 WebView,用于加载网页 -->
<WebView
android:id="@+id/detail_webview"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>
DetailActivity
// DetailActivity:展示网页详情,使用原生 WebView
class DetailActivity :
BaseActivity<ActivityDetailBinding>({ ActivityDetailBinding.inflate(it) }) {
companion object {
// Intent 传参的 URL Key
const val WEB_URL: String = "web_url"
// Intent 传参的 Title Key
const val WEB_TITLE: String = "web_title"
}
// 原生 WebView 实例
private lateinit var webView: WebView
override fun initView() {
setBackEnabled() // 启用左上角返回
// 在布局中找到 WebView
webView = binding.root.findViewById(R.id.detail_webview)
initWebView() // 初始化 WebView 设置
}
override fun initData() {
// 设置标题栏内容
setBarTitle(intent.getStringExtra(WEB_TITLE) ?: "")
// 加载页面
webView.loadUrl(intent.getStringExtra(WEB_URL) ?: "")
}
override fun allClick() {
}
/**
* 初始化 WebView 各项配置
*/
@SuppressLint("SetJavaScriptEnabled")
private fun initWebView() {
// 让 WebView 获得焦点以响应触摸
webView.requestFocusFromTouch()
// 访问 WebSettings 来配置浏览器行为
val settings: WebSettings = webView.settings
settings.apply {
javaScriptEnabled = true // 支持 JavaScript
setSupportZoom(true) // 支持缩放
builtInZoomControls = true // 启用缩放控件
displayZoomControls = false // 隐藏原生缩放按钮
useWideViewPort = true // 支持双层比例,用于适配屏幕
loadWithOverviewMode = true // 以概览模式加载
layoutAlgorithm = WebSettings.LayoutAlgorithm.SINGLE_COLUMN // 单列布局
}
// 可选:在 WebView 下添加底部提示文字
addBGChild(binding.root as FrameLayout)
// 设置客户端拦截导航,保持在当前 WebView 中打开
webView.webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(
view: WebView?,
request: WebResourceRequest?
): Boolean {
// 在当前 WebView 加载新 URL
view?.loadUrl(request?.url.toString())
return true
}
}
}
/**
* 在 WebView 所在的 FrameLayout 顶部添加提示文字
*/
private fun addBGChild(frameLayout: FrameLayout) {
val title = "技术由 WebView 提供"
val tv = TextView(frameLayout.context).apply {
text = title
textSize = 16f
setTextColor(Color.parseColor("#727779"))
}
frameLayout.setBackgroundColor(Color.parseColor("#272b2d"))
val lp = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.WRAP_CONTENT,
FrameLayout.LayoutParams.WRAP_CONTENT
).apply {
gravity = Gravity.CENTER_HORIZONTAL
// 15dp 转 px
val scale = resources.displayMetrics.density
topMargin = (15 * scale + 0.5f).toInt()
}
// 将提示文字插入到最底层
frameLayout.addView(tv, 0, lp)
}
/**
* 按键处理:优先由 WebView 处理返回事件(页面回退)
*/
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
return if (keyCode == KeyEvent.KEYCODE_BACK && webView.canGoBack()) {
webView.goBack() // 网页回退
true
} else {
super.onKeyDown(keyCode, event)
}
}
/**
* Activity 暂停时,暂停 WebView
*/
override fun onPause() {
webView.onPause()
super.onPause()
}
/**
* Activity 恢复时,恢复 WebView
*/
override fun onResume() {
webView.onResume()
super.onResume()
}
/**
* Activity 销毁时,销毁 WebView 以释放资源
*/
override fun onDestroy() {
// 先从父容器移除,然后销毁
(webView.parent as? ViewGroup)?.removeView(webView)
webView.removeAllViews()
webView.destroy()
super.onDestroy()
}
}
详情界面已经有了,记得在点击事件中进行设置
HomeFragment
override fun onItemClick(
adapter: BaseQuickAdapter<*, *>,
view: View,
position: Int
) {
// 列表项点击,跳转到详情页
val intent = Intent(binding.root.context, DetailActivity::class.java).apply {
putExtra(DetailActivity.WEB_URL, mDataList[position].link)
putExtra(DetailActivity.WEB_TITLE, mDataList[position].title)
}
startActivity(intent)
}
override fun OnBannerClick(position: Int) {
// Banner 点击,跳转到对应链接详情页
val intent = Intent(binding.root.context, DetailActivity::class.java).apply {
putExtra(DetailActivity.WEB_URL, bannerList[position].url)
putExtra(DetailActivity.WEB_TITLE, bannerList[position].title)
}
startActivity(intent)
}
至此我们首页界面的功能就完成了!!!篇幅太长了,其他界面在下一章节再讲吧,读者好好消化一下吧,下一章节咱们在继续