Jetpack系列(九) -- MVVM 框架demo实现(1)

MVVM

前言

时间: 23/10/12

AndroidStudio版本: Giraffe 2022.3.1 JDK:17 开发语言: Kotlin

Gradle版本: 8.0 Gradle plugin Version: 8.1.1

概述

前面已经将大部分 jetpack 组件介绍完成,虽然还有一些组件缺少了部分细节,但是已经不影响我们日常的开发了。这一节我们就讲一下由 Google 推荐的 Android 应用开发框架 ------ MVVM 。

虽然目前 Google 又推出了一个 MVI 开发框架,但是这并不影响 MVVM 的地位,目前使用最多的仍然是 MVVM 。当然了,没有最好的框架,只有最适合的框架。

什么是 MVVM

MVVM 是 Model - View - ViewModel 的简称。

MVVM架构的本质是数据驱动,它的最大的特点是单向依赖。MVVM架构通过观察者模式让ViewModel与View解耦,实现了View依赖ViewModel,ViewModel依赖Model的单向依赖。

  • 模型层(Model) ,负责与数据库和网络层通信,获取并存储数据。与MVP的区别在于Model层不再通过回调通知业务逻辑层数据改变,而是通过观察者模式实现。

  • 视图(View) 负责将Model层的数据做可视化的处理,同时与ViewModel层交互。

  • 视图模型(ViewModel) 主要负责业务逻辑的处理,同时与 Model 层 和 View层交互。与MVP的Presenter相比,ViewModel不再依赖View,使得解耦更加彻底。

MVVM 相对于 MVC、MVP 框架有很多有点,其解耦更彻底,数据单向流动。用户操作时是 View -> ViewModel -> Model,数据显示是则相反。

MVVM 是 Google 推荐使用的框架,所以 Google 也提供了相应的一系列组件来实现这个框架。上面的图片是 Google 给出的通过 jetpack 组件来实现 MVVM 框架的结构图。

  • Activity/Fragment 即 "View" 层,完成视图 UI 的操作,它拥有 ViewModel,用以实现数据交流。

  • ViewModel 中存在 LiveData 组件,组成 MVVM 中的 "ViewModel" 层,它主要负责各种数据交互,它拥有 Repository。

  • Repository 是 Google 定义的与数据交互的一个概念,它分有两种方式去获取数据,即为本地数据,网络数据。也就是说 Repository 以下的部分可以统称为 "Model" 层。

它们的拥有形式都是单向的,例如 Activity/Fragment 拥有 ViewModel,而 ViewModel 不能操作 UI 甚至不能存在对 UI 操作的可能,每个模块负责相关的业务,这样就能实现最大化的解耦。

原则上来讲,框架是一种理念,它并存在硬性要求。换句话说,只要你写的代码它符合 MVVM 框架的理念,它就可以归于 MVVM 框架。只不过用 jetpack 来实现 MVVM 相对会更加便利。

实现 MVVM 框架

在这节,我会先简单使用 jetpack 组件来构建一个 mvvm 框架的简易 demo。当然我使用的数据来源是网络,即我自己搭建了一个服务器,所以关于数据获取的方式请根据条件自行决定。从本地数据库即 SQLite 获取是一样的。

涉及到的 jetpack 组件有 LiveData、DataBinding、ViewModel 以及 Kotlin 协程。网络请求框架使用的是 Retrofit

实现 User Login 功能

一般来说,我们写功能是从数据开始往视图推进,即先完成数据的获取,再完成视图的构建和相关操作。

  1. 添加 Retrofit 相关依赖

    groovy 复制代码
        implementation("com.squareup.retrofit2:retrofit:2.9.0")
        implementation("com.squareup.retrofit2:converter-gson:2.9.0")
  2. 构建 User 实体类

    kotlin 复制代码
    class User : BaseObservable() {
        var id = 0
        @get: Bindable
        var username: String? = null
            set(value) {
                field = value
                notifyPropertyChanged(BR.username)
            }
        @get: Bindable
        var password: String? = null
            set(value) {
                field = value
                notifyPropertyChanged(BR.password)
            }
        var userStatus = 0
    
    
    
        override fun toString(): String {
            return "User(id=$id, username=$username, password=$password, userStatus=$userStatus)"
        }
    }

    其中 username 和 password 实现了数据变化监听,这样能简化登录时按钮监听中的获取数据的步骤。

  3. 新建两个类,一个是用于接收 json 数据的 class ApiResult,一个是网络请求接口类 interface NetWorkApi。

    kotlin 复制代码
    //ApiResult<T>
    class ApiResult<T>(
        var status: Int = -1,//网络请求状态,一般成功是 200,还有大家熟悉的404等
        var msg: String? = null,//网络请求状态说明
        var data: T? = null//请求返回的数据
    )
    
    //NetWorkApi
    interface NetWorkApi {
    
        @POST("user/login")
        suspend fun login(@Body body: RequestBody): ApiResult<User?>
    }

    其中 ApiResult 需要根据实际返回的 json 文件来构建,一般我们写后端构建服务器时,都是返回这样一个模板,传回的数据自定义泛型就行。

  4. 新建一个 Retrofit 单例工具类 RetrofitService,用以获取接口类的 Retrofit 实现。

    kotlin 复制代码
    object RetrofitService {
        private val retrofit = Retrofit.Builder()
            .baseUrl(NetWorkConst.BASE_URL)
            .addConverterFactory(GsonConverterFactory.create())
            .build()
    
        fun getApi(): NetWorkApi {
            return retrofit.create()
        }
    }

    NetWorkConst.BASE_URL 是全局静态常量,这里就不具体展示了哈。实在需要的话就私信我吧。

    通常情况下,我们在编辑代码时,是不能出现 硬编码( hard code ) 的,因为 hardcode 不方便管理与维护。

    hardcode指的是代码中直接使用 字符串,数值等。那么我们就可以新建一个类,来存放这些数值。当然还有一种情况,就是调用 string.xml 中的字符,这个情况我后面也有。

    一般来说,我们在编码时需要使用的,包括参数,标识等都存放在 Constants 类中,而如果我们需要显示,例如弹出的 Toast,Dialog 等有字符串,这些字符串是需要保存在 string.xml 中的。简单来说就是,用户看得见的在 string.xml 中,用户看不见的在 Constants 类中。

  5. 根据 Google 组件框架图,新建一个 UserRepository 类,里头存放的是网络请求的具体实现。

    kotlin 复制代码
    class UserRepository {
    
        private val api = RetrofitService.getApi()
    
        suspend fun login(user: MutableLiveData<User?>, requestBody: RequestBody) {
            execute({ api.login(requestBody) }, user)
        }
        
        suspend fun <T> execute(
            block: suspend () -> ApiResult<T>,//挂起函数作为参数
            response: MutableLiveData<T>,
        ) {
            try {
                val result = block.invoke()
                if (result.status == SUCCESS_STATUS) {
                    response.postValue(result.data)
                } else {
                    result.msg?.let {
                        Toast.error(appContext, it)
                    }
                }
            } catch (e: IOException) {
                e.printStackTrace()
                Toast.error(appContext, appContext.getString(R.string.connect_server_failed))
            }
        }
    }

    注意到 login 方法中传入了两个参数,第一个是用来接收数据的,第二个是封装好的RequestBody用以网络请求的。

    使用 MutableLiveData 来接收的好处是,我们可以直接在 View 层,即Activity/Fragment 中通过 LiveData的observer 来获取网络请求的结果。具体看下面的 ViewModel。

    Toast 是我自己根据开源项目 Toasty 重构的一个库,appContext 是applicationContext,我在 Application 类中获取的全局可用的 Context,这样可以防止内存泄漏。

    Toast 依赖

    groovy 复制代码
    implementation("com.github.Beacon0423.myLib:toast:v1.1.2")
    //需要在 settings.gradle.kts 中添加
    dependencyResolutionManagement {
        repositories {
            google()
            mavenCentral()
            maven(url = "https://jitpack.io")//添加这行就行
        }
    }

    appContext实现

    kotlin 复制代码
    class App : Application() {
    
        companion object {
            lateinit var appContext: Context
        }
    
        override fun onCreate() {
            super.onCreate()
            appContext = applicationContext
        }
    }

    这里编写的 execute 方法是一个通用的网络请求函数,关于 User 的网络请求都应该可以使用这个方法,理论是我们可以编写一个 BaseRepository 类来实现这个方法,我们可以继承这个类直接调用这个方法,会更加简洁。

  6. 新建一个 UserModelView 类,在 ViewModel 中使用协程来调用 suspend fun。

    kotlin 复制代码
    class UserViewModel : ViewModel() {
        private var _user = MutableLiveData<User?>()
        val userLiveData: LiveData<User?> = _user
    
        private val repository = UserRepository()
    
        fun login(user: User?) {
            if (user == null || user.username.isNullOrEmpty() 
                || user.password.isNullOrEmpty()) {
                Toast.warning(appContext, appContext.getString(R.string.username_or_password_empty))
                return
            }
    
            val requestBody =
                RequestBody.create(MediaType.parse("application/json;"), Gson().toJson(user))
            viewModelScope.launch {
                repository.login(_user, requestBody)
            }
        }
    }

    login 方法只需要传入用户输入的 user,而其中只有 username 和 password 是必要的,其余是否为空都无关。

  7. 新建 BaseActivity,LoginActivity

    kotlin 复制代码
    //BaseActivity
    abstract class BaseActivity<VDB : ViewDataBinding>: AppCompatActivity() {
        protected lateinit var binding: VDB
    
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            binding = setDataBinding()
            setContentView(binding.root)
        }
    
        protected abstract fun setDataBinding() : VDB
    }
    
    //LoginActivity
    class LoginActivity : BaseActivity<ActivityLoginBinding>() {
        private val TAG = "LoginActivity"
    
        private lateinit var userViewModel: UserViewModel
    
        private lateinit var mUser: User
    
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
    
            userViewModel = ViewModelProvider(this)[UserViewModel::class.java]
            mUser = User()
            binding.user = mUser
    
            userViewModel.userLiveData.observe(this) {
                if (it == null) return@observe
                if (mUser.username == it.username) {
                    startActivity(Intent(this@LoginActivity, MainActivity::class.java))
                    finish()
                }
            }
    
            binding.btnLogin.setOnClickListener {
                Log.d(TAG, mUser.toString())
                userViewModel.login(mUser)
            }
        }
    
        override fun setDataBinding(): ActivityLoginBinding {
            return ActivityLoginBinding.inflate(layoutInflater)
        }
    }

    BaseActivity 我就不作说明了,具体可以看之前的同系列 ViewBinding 组件文章。

    在 LoginAcvtivity 中,我们使用了 Databinding 的数据双向绑定功能,如此就不需要在 btnLogin 的点击监听获取用户输入的 username 和 password 了,直接调用 viewmodel 的 login 方法即可。而对于获取结果后的操作,则放在了 observer 中,因为在 Repository 中post了 新的值,这里检测到后就能执行相应逻辑( 这里就实现了界面跳转 )

  8. 新建 activity_login.xml

    xml 复制代码
    <?xml version="1.0" encoding="utf-8"?>
    <layout xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto">
    
        <data>
    
            <variable
                name="user"
                type="com.may.part_10.entity.User" />
        </data>
    
        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:orientation="vertical">
    
            <com.google.android.material.textfield.TextInputLayout
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:layout_marginStart="40dp"
                android:layout_marginTop="160dp"
                android:layout_marginEnd="40dp">
    
                <com.google.android.material.textfield.TextInputEditText
                    android:layout_width="match_parent"
                    android:layout_height="wrap_content"
                    android:hint="@string/text_your_username"
                    android:text="@={user.username}" />
            </com.google.android.material.textfield.TextInputLayout>
    
    
            <com.google.android.material.textfield.TextInputLayout
                android:id="@+id/textInputLayout"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:layout_marginStart="40dp"
                android:layout_marginTop="20dp"
                android:layout_marginEnd="40dp">
    
                <com.google.android.material.textfield.TextInputEditText
                    android:id="@+id/password"
                    android:layout_width="match_parent"
                    android:layout_height="wrap_content"
                    android:digits="@string/password_digits"
                    android:hint="@string/text_your_password"
                    android:inputType="textPassword"
                    android:maxLength="16"
                    android:text="@={user.password}" />
            </com.google.android.material.textfield.TextInputLayout>
    
    
            <Button
                android:id="@+id/btn_login"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:layout_marginStart="40dp"
                android:layout_marginTop="80dp"
                android:layout_marginEnd="40dp"
                android:text="@string/login" />
    
            <TextView
                android:id="@+id/tv_register"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_gravity="end"
                android:layout_marginTop="6dp"
                android:layout_marginEnd="40dp"
                android:text="@string/noaccount_register"
                android:textColor="#294FC5" />
    
        </LinearLayout>
    </layout>

    最后别忘了在 AndroidManifest.xml 中注册 LoginActivity 为首启动activity,申明Application的name为App。以及添加网络请求权限。

    xml 复制代码
        <uses-permission android:name="android.permission.INTERNET" />
        <application
            android:name=".App"
            ....>
            <activity
                android:name=".activities.LoginActivity"
                android:launchMode="singleInstancePerTask"
                android:exported="true">
                <intent-filter>
                    <action android:name="android.intent.action.MAIN" />
    
                    <category android:name="android.intent.category.LAUNCHER" />
                </intent-filter>
            </activity>
            <activity android:name=".activities.MainActivity" />

    到这里,登录功能就基本实现了,看看效果

总结

本节通过结合前面 jetpack 所学到的一些知识,编写了一个使用 jetpack 组件构建的 MVVM 框架的 demo。

后续我还会根据这个 demo 进行深入,包括 用户注册、用户密码加密、其它 Model( Book ) 的一些功能等。总的来说,关于 MVVM 框架 demo 的编写不止一篇文章,后续还会出一篇文章关于我的服务器的实现,后端代码使用的是 springboot 框架。以及如何零基础,低费用甚至零费用就能实现的简易后端。

至于网络框架 Retrofit 的使用,这边没有过多描述。可以自行查阅相关文章,例如我之前写的( 没错,我又来了 )

有道智云翻译API + retrofit实现在线翻译Android app

看到这里了,不妨点个赞,加个收藏吧。也欢迎评论指出不足

Demo地址(GitHub)

参考文章:

Android 架构思想与 MVVM 框架封装

Android开发指南-应用架构指南

相关推荐
闲暇部落17 小时前
‌Kotlin中的?.和!!主要区别
android·开发语言·kotlin
长亭外的少年1 天前
Kotlin 编译失败问题及解决方案:从守护进程到 Gradle 配置
android·开发语言·kotlin
JIAY_WX1 天前
kotlin
开发语言·kotlin
麦田里的守望者江2 天前
KMP 中的 expect 和 actual 声明
android·ios·kotlin
菠菠萝宝2 天前
【YOLOv8】安卓端部署-1-项目介绍
android·java·c++·yolo·目标检测·目标跟踪·kotlin
恋猫de小郭2 天前
Kotlin Multiplatform 未来将采用基于 JetBrains Fleet 定制的独立 IDE
开发语言·ide·kotlin
枫__________2 天前
kotlin 协程 job的cancel与cancelAndJoin区别
android·开发语言·kotlin
鸠摩智首席音效师3 天前
如何在 Ubuntu 上配置 Kotlin 应用环境 ?
linux·ubuntu·kotlin
jikuaidi6yuan4 天前
Java与Kotlin在鸿蒙中的地位
java·kotlin·harmonyos
liulanba4 天前
Kotlin的data class
前端·微信·kotlin