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开发指南-应用架构指南

相关推荐
yzpyzp2 小时前
如果后台的Long类型的数据返回是null,那么Android客户端的数据bean的kotlin的Long类型的字段接受到数据后是null空指针吗?
android·kotlin
xvch6 小时前
Kotlin 2.1.0 入门教程(二十五)类型擦除
android·kotlin
l软件定制开发工作室15 小时前
Jetpack Architecture系列教程之(一)——Jetpack介绍
android jetpack
有点感觉2 天前
Android级联选择器,下拉菜单
kotlin
zhangphil2 天前
Android Coil3缩略图、默认占位图placeholder、error加载错误显示,Kotlin(1)
android·kotlin
xvch2 天前
Kotlin 2.1.0 入门教程(二十三)泛型、泛型约束、协变、逆变、不变
android·kotlin
xvch4 天前
Kotlin 2.1.0 入门教程(二十四)泛型、泛型约束、绝对非空类型、下划线运算符
android·kotlin
zhangphil4 天前
Android Coil ImageLoader MemoryCache设置Key与复用内存缓存,Kotlin
android·kotlin
mmsx4 天前
kotlin Java 使用ArrayList.add() ,set()前面所有值被 覆盖 的问题
android·开发语言·kotlin
lavins4 天前
android studio kotlin项目build时候提示错误 Unknown Kotlin JVM target: 21
jvm·kotlin·android studio