MVVM+Kotlin+dataBinding

0 阅读9分钟

原创内容,请勿转发!!! 针对很多转行到Kotlin的软件工程师,网上或者有很多讲解Kotlin+MVVM的视频教程,但大多数都是很早以前的技术,部分方案甚至没有结合viewBinding来做,甚至连最基础的lifecycle等依赖库配置都没细说就开始讲MVVM+Kotlin了,在此特意编写该文章以确保有疑虑的新手可以加深对kotlin,MVVM软件模式的理解。

一、新建Kotlin工程

File-> New Project,在“Phone and Tablet”中选择 “Empty Views Activity”(不要选"Empty Activity")。如下图所示:

image.png

填充工程名,注意不要选择"Groovy DSL(build.gradle)"要选择"Kotlin DSL(build.gradle.kts)"

image.png

接着把工程关闭,否则可能要多等二十几分钟哦,接下来改gradle镜像地址

image.png

用VS Code(推荐)或者其他软件打开gradle-wrapper.properties文件,接着使用通义灵码(今天突然变成了QODER CN,是要跟TRAE CN靠齐了吗),然后打开"智能体``可以自动修复代码,选中第5行后,文本框中输入“改成腾讯的镜像地址”, 点击"接受"。如下图所示:

image.png

接着用 Android Studio重新打开项目

image.png 卡在这了,怎么办??

image.png 删除上图的 .gradle 和 .idea 两个目录,然后

image.png

image.png

接着神奇的事情发生了,最快一分钟不到就下载完了Gradle依赖,如果是Service可能要花十几分钟。

模拟器问题: image.png 出现安装问题,及时问 通义灵码的智能体(不是智能问答)

image.png 又开始卡了,先忍1分钟,如果超过1分钟还没出来,那就 adb devices后直接kill进程,换个模拟器。经过40秒漫长的等待,效果如下:

image.png

二、添加MVM必须的依赖库

首先在模块级别的build.gradle.kts(Module:app不是项目级别Project:MVVMDemo)中的 dependencies中添加下面的依赖库:

image.png

implementation(libs.androidx.lifecycle.viewmodel)
implementation(libs.androidx.lifecycle.livedata)
implementation(libs.androidx.recyclerview)
implementation(libs.androidx.cardview)
implementation(libs.retrofit)
implementation(libs.retrofit.gson)
implementation(libs.coil)

尝试点击 "Sync Now",一堆报错。

尝试点击第44行的代码“ implementation(libs.androidx.core.ktx)”按住Ctrl+左键单击(Mac是CMD+左键单击),点击进入后发现是 第13行: androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }

然后version.ref = "coreKtx"依赖

第3行的coreKtx = "1.10.1"

image.png

依葫芦画瓢: 在[versions]中添加:

lifecycle = "2.7.0"
recyclerview = "1.3.2"
cardview = "1.0.0"
retrofit = "2.9.0"
coil = "2.5.0"

在[libraries]中添加:

androidx-lifecycle-viewmodel = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycle" }
androidx-lifecycle-livedata = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx", version.ref = "lifecycle" }
androidx-recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "recyclerview" }
androidx-cardview = { group = "androidx.cardview", name = "cardview", version.ref = "cardview" }
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
coil = { group = "io.coil-kt", name = "coil", version.ref = "coil" }

完整代码:

build.gradle.kts文件:

dependencies {
    implementation(libs.androidx.core.ktx)
    implementation(libs.androidx.appcompat)
    implementation(libs.material)
    implementation(libs.androidx.activity)
    implementation(libs.androidx.constraintlayout)
    testImplementation(libs.junit)
    androidTestImplementation(libs.androidx.junit)
    androidTestImplementation(libs.androidx.espresso.core)

    implementation(libs.androidx.lifecycle.viewmodel)
    implementation(libs.androidx.lifecycle.livedata)
    implementation(libs.androidx.recyclerview)
    implementation(libs.androidx.cardview)
    implementation(libs.retrofit)
    implementation(libs.retrofit.gson)
    implementation(libs.coil)
}

build.gradle.kts文件:

[versions]
agp = "9.1.0"
coreKtx = "1.10.1"
junit = "4.13.2"
junitVersion = "1.1.5"
espressoCore = "3.5.1"
appcompat = "1.6.1"
material = "1.10.0"
activity = "1.8.0"
constraintlayout = "2.1.4"

lifecycle = "2.7.0"
recyclerview = "1.3.2"
cardview = "1.0.0"
retrofit = "2.9.0"
coil = "2.5.0"


[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" }
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }

androidx-lifecycle-viewmodel = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycle" }
androidx-lifecycle-livedata = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx", version.ref = "lifecycle" }
androidx-recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "recyclerview" }
androidx-cardview = { group = "androidx.cardview", name = "cardview", version.ref = "cardview" }
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
coil = { group = "io.coil-kt", name = "coil", version.ref = "coil" }

[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }

然后"Sync Now",就此,我们就完成了协程,列表,卡片,网络,网络解析,图片库这些依赖的安装,为接下来的MVVM提供最基础的支撑。

三、MVVM,View,以及两者的绑定

接着在模块级build.gradle.kts(Module:app)中配置viewBinding如下图所示:

image.png

新建包名viewModel如图:

image.png

3.1 新建一个ViewModel文件

命名为UserViewModel.kts:

以后所有的类,文件都是从androidX中引用:

image.png

接着先打开www.baidu.com. 然后打开手机模式,接着截张列表的图片

image.png

然后使用 通义灵码智能体,把图片复制到聊天框,输入提示语:“根据图片帮我写一个kotlin的model类,生成一个可以在协程中使用的方法,并返回二十条数据的数组,图片要用真实的网络图片,可以直接显示的

3.2 生成NewsRepository.kts的model文件:

package com.ht.mvvmdemo

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

/**
 * 新闻数据仓库
 */
class NewsRepository {

    /**
     * 获取新闻列表(协程中使用)
     * @return 新闻文章数组
     */
    suspend fun getNewsList(): Array<NewsArticle> = withContext(Dispatchers.IO) {
        // 模拟从网络或数据库获取数据
        arrayOf(
            NewsArticle(
                id = "1",
                title = "不靠剧场、不拼制作,这家北京企业把戏剧玩成消费新风口",
                coverImage = "https://picsum.photos/seed/news1/400/300.jpg",
                source = "京报网",
                publishTime = System.currentTimeMillis() - 3600000
            ),
            NewsArticle(
                id = "2",
                title = "抗衰老最佳睡眠时长出炉!50万人研究:你可能刚好睡在...",
                coverImage = "https://picsum.photos/seed/news2/400/300.jpg",
                source = "长城网",
                publishTime = System.currentTimeMillis() - 7200000
            ),
            NewsArticle(
                id = "3",
                title = "82岁大爷到银行存款,170万元竟全是练功券;警方:老人...",
                coverImage = "https://picsum.photos/seed/news3/400/300.jpg",
                source = "中原网",
                publishTime = System.currentTimeMillis() - 10800000
            ),
            NewsArticle(
                id = "4",
                title = "2024年高考报名人数再创新高,教育部发布最新政策解读",
                coverImage = "https://picsum.photos/seed/news4/400/300.jpg",
                source = "新华网",
                publishTime = System.currentTimeMillis() - 14400000
            ),
            NewsArticle(
                id = "5",
                title = "新能源汽车销量突破千万辆,行业迎来发展新机遇",
                coverImage = "https://picsum.photos/seed/news5/400/300.jpg",
                source = "经济日报",
                publishTime = System.currentTimeMillis() - 18000000
            ),
            NewsArticle(
                id = "6",
                title = "科学家发现新型量子材料,有望推动量子计算重大突破",
                coverImage = "https://picsum.photos/seed/news6/400/300.jpg",
                source = "科技日报",
                publishTime = System.currentTimeMillis() - 21600000
            ),
            NewsArticle(
                id = "7",
                title = "全国多地迎来强降雨,气象部门发布预警提醒",
                coverImage = "https://picsum.photos/seed/news7/400/300.jpg",
                source = "中国天气网",
                publishTime = System.currentTimeMillis() - 25200000
            ),
            NewsArticle(
                id = "8",
                title = "故宫博物院推出新展览,珍贵文物首次集中亮相",
                coverImage = "https://picsum.photos/seed/news8/400/300.jpg",
                source = "文旅中国",
                publishTime = System.currentTimeMillis() - 28800000
            ),
            NewsArticle(
                id = "9",
                title = "人工智能辅助医疗诊断准确率超95%,临床应用前景广阔",
                coverImage = "https://picsum.photos/seed/news9/400/300.jpg",
                source = "健康报",
                publishTime = System.currentTimeMillis() - 32400000
            ),
            NewsArticle(
                id = "10",
                title = "城市马拉松赛事火爆,全民健身热潮持续升温",
                coverImage = "https://picsum.photos/seed/news10/400/300.jpg",
                source = "体育报",
                publishTime = System.currentTimeMillis() - 36000000
            ),
            NewsArticle(
                id = "11",
                title = "农村电商蓬勃发展,助力乡村振兴新路径",
                coverImage = "https://picsum.photos/seed/news11/400/300.jpg",
                source = "农民日报",
                publishTime = System.currentTimeMillis() - 39600000
            ),
            NewsArticle(
                id = "12",
                title = "5G网络覆盖率达98%,数字经济迎来爆发式增长",
                coverImage = "https://picsum.photos/seed/news12/400/300.jpg",
                source = "通信世界",
                publishTime = System.currentTimeMillis() - 43200000
            ),
            NewsArticle(
                id = "13",
                title = "传统文化进校园,非遗传承从娃娃抓起",
                coverImage = "https://picsum.photos/seed/news13/400/300.jpg",
                source = "教育报",
                publishTime = System.currentTimeMillis() - 46800000
            ),
            NewsArticle(
                id = "14",
                title = "航天工程再获突破,空间站建设迈出关键一步",
                coverImage = "https://picsum.photos/seed/news14/400/300.jpg",
                source = "航天科技",
                publishTime = System.currentTimeMillis() - 50400000
            ),
            NewsArticle(
                id = "15",
                title = "绿色金融助力低碳转型,碳中和目标加速推进",
                coverImage = "https://picsum.photos/seed/news15/400/300.jpg",
                source = "金融时报",
                publishTime = System.currentTimeMillis() - 54000000
            ),
            NewsArticle(
                id = "16",
                title = "智慧城市建设成效显著,市民生活更加便捷",
                coverImage = "https://picsum.photos/seed/news16/400/300.jpg",
                source = "城市报",
                publishTime = System.currentTimeMillis() - 57600000
            ),
            NewsArticle(
                id = "17",
                title = "文化遗产保护新举措,千年古镇焕发新生机",
                coverImage = "https://picsum.photos/seed/news17/400/300.jpg",
                source = "文化遗产报",
                publishTime = System.currentTimeMillis() - 61200000
            ),
            NewsArticle(
                id = "18",
                title = "数字经济赋能传统产业,转型升级成果丰硕",
                coverImage = "https://picsum.photos/seed/news18/400/300.jpg",
                source = "产业经济报",
                publishTime = System.currentTimeMillis() - 64800000
            ),
            NewsArticle(
                id = "19",
                title = "国际文化交流活动丰富多彩,中华文化走向世界",
                coverImage = "https://picsum.photos/seed/news19/400/300.jpg",
                source = "国际在线",
                publishTime = System.currentTimeMillis() - 68400000
            ),
            NewsArticle(
                id = "20",
                title = "生态文明建设成效显著,绿水青山就是金山银山",
                coverImage = "https://picsum.photos/seed/news20/400/300.jpg",
                source = "环境报",
                publishTime = System.currentTimeMillis() - 72000000
            )
        )
    }
}

注意:网络请求为异步,如果需要在协程中使用函数,函数必须声明为suspend(这是规范)。 类似于 async await必须修饰Promise函数,return new Promise((resolve,reject)=> return resolve("abc") 一样的意思

3.3 生成 Model文件:

NewsArticle:

package com.ht.mvvmdemo

/**
 * 新闻文章数据模型
 */
data class NewsArticle(
    val id: String,              // 文章ID
    val title: String,           // 文章标题
    val coverImage: String,      // 封面图片URL
    val source: String,          // 来源(如:京报网、长城网、中原网)
    val publishTime: Long = 0,   // 发布时间(时间戳)
    val content: String = "",    // 文章内容
    val author: String = ""      // 作者
)

3.4 ViewModel如何使用Model中的数据

package com.ht.mvvmdemo.viewModel

import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.ht.mvvmdemo.NewsArticle
import com.ht.mvvmdemo.NewsRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext

class UserViewModel: ViewModel() {

    private val model = NewsRepository()
    private val _news  = MutableLiveData<Array<NewsArticle>>()
    val news = _news

    fun getNews(){
        viewModelScope.launch {
            // 异步网络请求 data为  Array<NewsArticle>
        val data =   withContext(Dispatchers.IO){
               model.getNewsList()
            }

//            回到主线程,当时最好是使用
//            withContext(Dispatchers.Main.immediate){
//                news.value = data
//            }
            news.value = data
        }
    }
}

3.5 设置view和viewmodel的绑定

private val userModel = UserViewModel()

初始化binding

package com.ht.mvvmdemo

import android.os.Bundle
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import com.ht.mvvmdemo.databinding.ActivityMainBinding
import com.ht.mvvmdemo.viewModel.UserViewModel

class MainActivity : AppCompatActivity() {
    
    
    // 这是延迟初始化,延迟初始化是在类被创建时才初始化变量,而不是在变量被使用的时候才初始化变量
    // ActivityMainBinding viewbing内部自动将layout文件绑定了一个驼峰命名的Binding类,比如该项目中的activity_main.xml自动绑定ActivityMainBinding类
    private lateinit var binding: ActivityMainBinding

    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        
        initView()
//        setContentView(R.layout.activity_main)
//        ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
//            val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
//            v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
//            insets
//        }
    }
    
    fun initView() {
    // 没有初始化,后续用binding会直接报错
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)
    // viewmodel livedata的setvalue ,postvalue更新后,监测到数据更新,再通过adapter更新数据源的流程,实现了viewmodel向view的通信
    viewModel.news.observe(this) { newsList -> adapter.updateData(newsList.toList()) }
    // view向viewmodel通信
    viewModel.getNews()

    }
}

3.6 设置Adapter,ViewHolder对应的卡片xml

item_news.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"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_margin="8dp"
    app:cardCornerRadius="8dp"
    app:cardElevation="4dp">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="horizontal"
        android:padding="12dp">

        <ImageView
            android:id="@+id/iv_cover"
            android:layout_width="120dp"
            android:layout_height="90dp"
            android:scaleType="centerCrop"
            android:contentDescription="@string/app_name" />

        <LinearLayout
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginStart="12dp"
            android:layout_weight="1"
            android:orientation="vertical">

            <TextView
                android:id="@+id/tv_title"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:layout_marginBottom="8dp"
                android:maxLines="2"
                android:textColor="#333333"
                android:textSize="16sp"
                android:textStyle="bold" />

            <TextView
                android:id="@+id/tv_source"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:textColor="#999999"
                android:textSize="12sp" />
        </LinearLayout>
    </LinearLayout>
</androidx.cardview.widget.CardView>

设置Adapter以及更新 暂未使用binding

package com.ht.mvvmdemo

import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import coil.load

class NewsAdapter(
    private var newsList: List<NewsArticle> = emptyList(),
    private val onItemClickListener: ((NewsArticle) -> Unit)? = null
) : RecyclerView.Adapter<NewsAdapter.NewsViewHolder>() {

    class NewsViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        val ivCover: ImageView = itemView.findViewById(R.id.iv_cover)
        val tvTitle: TextView = itemView.findViewById(R.id.tv_title)
        val tvSource: TextView = itemView.findViewById(R.id.tv_source)
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NewsViewHolder {
        val view = LayoutInflater.from(parent.context)
            .inflate(R.layout.item_news, parent, false)
        return NewsViewHolder(view)
    }

    override fun onBindViewHolder(holder: NewsViewHolder, position: Int) {
        val news = newsList[position]
        
        // 加载封面图片
        holder.ivCover.load(news.coverImage) {
            placeholder(R.drawable.ic_launcher_background)
            error(R.drawable.ic_launcher_background)
            crossfade(true)
        }
        
        // 设置标题和来源
        holder.tvTitle.text = news.title
        holder.tvSource.text = news.source
        
        // 设置点击事件
        holder.itemView.setOnClickListener {
            onItemClickListener?.invoke(news)
        }
    }

    override fun getItemCount(): Int {
        Log.d("NewsAdapter", "ItemCount: ${newsList.size}")
        return newsList.size
    }

    // 更新数据
    fun updateData(newList: List<NewsArticle>) {
        newsList = newList
        notifyDataSetChanged()
    }
}

3.7 设置recycleview及Adapter

adapter = NewsAdapter() // Initialize adapter with newsList value


binding.recyclerView.adapter = adapter
binding.recyclerView.layoutManager = LinearLayoutManager(this)
// viewmodel livedata的setvalue ,postvalue更新后,监测到数据更新,再通过adapter更新数据源的流程,实现了viewmodel向view的通信
    viewModel.news.observe(this) { newsList -> adapter.updateData(newsList.toList()) }
    // view向viewmodel通信
    viewModel.getNews()

4. 权限配置:

android:usesCleartextTraffic="true">

以及

<uses-permission android:name="android.permission.INTERNET" />

最终效果:
image.png

如果用MutableStateFlow则需要协程collect收集改变