简介
在上一章节,我们对Android中常用的项目架构模式有了一定的了解,那么现在我们既有轮子(基础UI),又有框架了,是时候开始造车了,那么本章将开始进行Android项目实战练习,具体实战什么看作者想要实战什么(无规划,难易不定)...遇到啥就针对的去实战,本章节将针对MVP项目架构进行实战,篇幅会比较长,会针对某些插件,知识点进行单独的讲解,跟着一篇文章可以实现一个项目的完整运行!!!
这里项目中使用了 [玩Android 开放API-玩Android - wanandroid.com](www.wanandroid.com/index) 提供API,在这里进行声明并感谢大佬
1. 项目准备
-
修改项目模块下
build.gralde.kts
配置文件:修改仓库地址便于下载依赖kotlinpluginManagement { 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() }
至此,我们完成了对
Activity
、Fragment
简单通用抽离的封装,但是这只是针对普通的Activity
和Fragment
,我们要将之用于MVP
项目架构中,所以还要对使用到MVP
模块的Activity
、Fragment
进行二次封装,那么我们接着封装,别嫌麻烦,万事开头难,如果不想写屎山,那就从一开始就规划好,统一封装管理好... -
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 { // 可在此定义全局通用的数据处理方法,例如统一的错误封装或返回数据类型 } }
上述封装中出现了一个新的接口
LifecycleObserver
,LifecycleObserver
是一个标记接口(marker interface),本身不包含任何方法。其主要作用是标识实现了该接口的类为生命周期观察者。通过与LifecycleOwner
(如Activity
或Fragment
)配合使用,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 }
至此我们对
Activity
、Fragment
的封装告一段落,接下来,我们将进行网络请求的封装,不过在封装之前,我们先看看这个项目中有那些自定义的工具类
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() 可判断是否无元素​ 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/** * 通用响应包装类,用于封装后端返回的标准字段与业务数据。 * 如果后台返回数据是比较规范的,都会有这种标准字段的,具体啥因人或因项目而异 * 将之抽离出来方便管理 * * 该类为 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
(如Gson
、Moshi
)自动完成请求体和响应体的序列化与反序列化,比较简单,会用就行,这里不进行过多的研究。 -
Bean
网络请求相应类上述封装的
API
返回的对象,大部分的都是复杂对象,一般我们可以通过阅读后端提供的接口文档,知道对应接口的响应体内容,然后创建对应的响应类,我们的项目中使用了GSON
,所以对于相应数据的映射处理还是比较方便的,大家在日常开发中,如果有对应的网络请求的处理,一定要和后端商量好,否则到头来难受的是自己...本项目使用的是玩Android 开放API-玩Android - wanandroid.com提供的接口,我们可以根据提供的示例去获取响应体内容,或者自己用postMan
等网络请求工具去获取对应接口的相应数据,然后将对应相应数据类在项目中创建。-
Article
kotlindata 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
kotlindata 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
kotlindata 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
还记得我们封装的
BaseActivity
和BaseMVPActivity
吗?我们使用那个呢?其实都行,如果项目中需要再引导页面中进行复杂数据处理,那么我就建议使用BaseMVPActivity
,反之就用最简单的BaseActivity
kotlinclass 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
代码如下kotlinclass 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
kotlininterface 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
kotlinclass 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
kotlinclass 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
kotlinclass 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
使用户能够左右滑动在不同页面之间切换,是基于经典滑动分页 模式的组件,在本项目,我们将
HomeFragment
、ProjectFragment
、TreeFragment
、NavFragment
通过适配器加载到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
}
}
代码中的首页、体系、导航、项目这几个模块,我们抽离出来成单独的模块,在对应模块中进行对应界面数据的获取,项目架构如下所示:

VIewPager
的 CommonViewPageAdapter
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博客