Android MVVM的实现

news2024/10/7 18:30:16

Android MVVM的实现

在这里插入图片描述

前言:

在我们写一些项目的时候,通常会对一些常用的一些常用功能进行抽象封装,简单例子:比如BaseActivity,BaseFragment等等…一般这些Base会去承载一些比如标题栏,主题之类的工作,方便Activity的一些风格的统一,也是预留一些函数方便进行HOOK进而实现一些功能。除此之外,一个网络请求也会根据项目采用的技术进行一些封装,比如OkHttp的全局的单例呀,网络请求的成功与失败的回调呀,把相应的状态进行上抛给View,这些都是我们在新建一个项目,采用不同技术方案时需要考虑的问题。

下面我就分享一下比较常用的一些技术方案去实现的一个MVVM的一个基础架构组件。

Find View

在Android项目中,因为传统的View布局的方式采用的是通过xml进行控件的布局,那么不可避免的我们需要在代码中进行view的操作,那么我们就需要findViewById,这个方法可能是每一个Android的开发人员都非常熟悉的一个方法,它的作用那我们就不必说,就是发现一个view并获取对应的实例,那么当我们布局文件越来越复杂的时候,我们需要每一个view都find一遍的话,那么明显重复代码冗长且易出错,但是不写又不行。当时你可以说用compose呀,抛掉XML,可是技术的普及总是需要一定的时间,在这之前传统的xml也是不能抛弃的。

在Android中用来代替的findViewById的方法有很多,但是大多已经过时了,或者说已经不推荐了,方案主要有以下几种。

方案状态优缺点
Butter Knife停止更新,库作者已不推荐-
kotlin-android-extensions谷歌已不推荐-
Data Binding可用优点:可以直接实现双向绑定,在XML中支持表达式
缺点:1.BUG比较难定位 2.根标签必须是layout 3.对构建速度和性能有部分影响
View Binding可用优点:避免findViewById大量重复代码同时精剪了部分功能,避免了Data Binding存在的问题
缺点:不支持双向绑定

一般情况下,推荐采用View Binding,View Binding一般使用方法如下:

val binding = ActivityLoginBinding.inflate(layoutInflater)
// val binding = ActivityLoginBinding.inflate(layoutInflater, parent, false)
// val binding = ActivityLoginBinding.bind(view)
binding.tv.text = "Hello Android!"

可能你会觉得如果每个使用这个布局的地方都要inflate一遍,那么我们可以借助kotlin的委托和反射,进一步简化代码。
最终呈现的效果,如下面代码所示:

class TestActivity : AppCompatActivity() {

    private val binding: ActivityLoginBinding by binding()
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding.edName.setText("hello")
    }

}

上述代码通过就通过委托和反射的方式,减少inflate和setContentView的代码。
具体实现可以参考:
https://github.com/DylanCaiCoding/ViewBindingKTX

Base类

在这部分,我觉得每个项目在Base中封装是不一样的,所以在本文章中,Base这一块反而是比较简单的一块,简单的有个继承关系即可

open class BaseActivity : AppCompatActivity()

open class BaseApplication : Application() 

open class BaseViewModel : ViewModel()

有一点需要说明的是,在Base Activity中我们需要尽量不去修改activity的生命周期,或者说添加而外的生命周期,如果是在多人合作的项目中,开发人员错误地理解部分代码,那么就有可能出现问题。所以我们在Base中尽量不要修改对应的生命周期,减少学习成本。

依赖注入

依赖注入或许在Java开发中很常见,其实也是可以应用在Android中用来减少一些重复代码,也可以方便开发人员对代码进行一些简单的测试,只需要替换对应的实现类即可。

那么Android常见的依赖注入方案有

方案优缺点
Dagger优点:功能强大
缺点:学习成本高,在Android上应用需要一定的熟练程度
Hilt谷歌根据Android平台的特点,在 Dagger 的基础上构建而成,减少了一定的学习成本
koin根据委托实现,学习成本低

这里比较推荐Hilt,那么Hilt的用法比较简单,主要分为两部分,一部分为使用注解表明什么地方需要注入以及注入的对象的作用域。另外一部分就是被注入的对象如何产生。

Hilt接入:
首先,将 hilt-android-gradle-plugin 插件添加到项目的根级 build.gradle 文件中

plugins {
  ...
  id 'com.google.dagger.hilt.android' version '2.44' apply false
}

然后,应用 Gradle 插件并在 app/build.gradle 文件中添加以下依赖项:

...
plugins {
  id 'kotlin-kapt'
  id 'com.google.dagger.hilt.android'
}

android {
  ...
}

dependencies {
  implementation "com.google.dagger:hilt-android:2.44"
  kapt "com.google.dagger:hilt-compiler:2.44"
}

// Allow references to generated code
kapt {
  correctErrorTypes true
}

所有使用 Hilt 的应用都必须包含一个带有 @HiltAndroidApp 注解的 Application 类。

@HiltAndroidApp 会触发 Hilt 的代码生成操作,生成的代码包括应用的一个基类,该基类充当应用级依赖项容器。

@HiltAndroidApp
class ExampleApplication : Application() { ... }

Hilt使用如下:

//被注入的对象如何产生

//标记这是一个module.可以通过module模块向 Hilt 提供绑定信息。
@Module
//标记绑定作用域限定到ViewModel
@InstallIn(ViewModelComponent::class)
object RepositoryModule {

    //标记方法,提供依赖返回值,即产生对象
    @Provides
    fun provideTasksRepository(): UserRepository {
        return DefaultUserRepository()
    }
}

//表明什么地方需要注入

//表明这是一个被注入ViewModel
@HiltViewModel
//注入到构造函数的repository
class TestViewModel @Inject constructor(
    private val repository: UserRepository
) : BaseViewModel()

更多Hilt的使用方法参考链接:
https://developer.android.google.cn/training/dependency-injection/hilt-android?hl=zh-cn

网络实现

一般而言,如果没有特殊情况的话,Android网络请求一般采用的都是OkHttp来实现Http请求,数据格式一般采用Json。当然也有些项目为了性能采用RPC和Protobuf来进行数据通讯。

这里我们采用OkHttp ,Retrofit , Flow 来构建我们的数据传输,模拟的接口就采用玩Android的开放API来举例,接口文档地址:
https://www.wanandroid.com/blog/show/2

第一步导入依赖:

dependencies {
	...
    //OkHttp
    implementation(platform("com.squareup.okhttp3:okhttp-bom:4.10.0"))
    implementation("com.squareup.okhttp3:okhttp")
    implementation("com.squareup.okhttp3:logging-interceptor")

    //retrofit
    implementation "com.squareup.retrofit2:retrofit:2.9.0"
    implementation "com.squareup.retrofit2:converter-gson:2.9.0"
}

第二步构建OkHttp和Retrofit实例,结合Hilt实现


object NetUrlConst {

    const val BASE_URL = "https://www.wanandroid.com"

    /**
     * 登录
     */
    const val LOGIN = "/user/login"
}

@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {

    private const val TAG = "NetworkModule"

    @Provides
    @Singleton
    fun providesOKHttpClient():OkHttpClient{
        Log.i(TAG,"providesOKHttpClient")
        val okHttpClient = OkHttpClient()
            .newBuilder()
            .addInterceptor(HttpLoggingInterceptor { message ->
                Log.i(TAG, message)
            }.setLevel(HttpLoggingInterceptor.Level.BODY))
            .build()
        return okHttpClient
    }

    @Provides
    @Singleton
    fun providesRetrofit(client: OkHttpClient):Retrofit{
        Log.i(TAG,"providesRetrofit")
        return Retrofit.Builder()
            .baseUrl(NetUrlConst.BASE_URL)
            .client(client)
            .addConverterFactory(GsonConverterFactory.create())
            .build()
    }
}

上面代码就实现了OkHttp和Retrofit的单例,使用的话在需要的地方使用Hilt注入即可。
第三步定义错误码以及返回结果

/**错误码枚举*/
enum class NetCode(val value: Int){
    ERROR(-1),
    NORMAL(0)
}
/**返回实体基类*/
data class BaseEntity<T>(
    val data: T,
    val errorCode: Int,
    val errorMsg: String
) {
    val isSuccess
        get() = errorCode == NetCode.NORMAL.value
}
/**用户实体类*/
data class UserEntity(
    val id: Int,
    val nickname: String,
    val password: String,
    val publicName: String,
    val username: String
)

第四步生成Service

interface UserService {

    @POST(NetUrlConst.LOGIN)
    suspend fun login(
       @Query("username") username: String,
       @Query("password") password: String): BaseEntity<UserEntity>
}


@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
	...
    @Provides
    fun providesNetUserService(retrofit: Retrofit):UserService{
        Log.i(TAG,"providesNetUserService")
        return retrofit.create(UserService::class.java)
    }
}

Repository

接下来我们需要定义,提供数据的Model层的实现形式。
一般而言在Model层会提供一个抽象的Repository来进行数据的提供,来源包括本地以及网络数据。在Repository中分别有不同的DataSource来提供数据,而Repository则屏蔽这些具体的细节统一封装数据返回给ViewModel。
如下图所示
在这里插入图片描述
同时由于我们数据流采用的是Flow,那么我们需要定义一个协程的返回基类,用于包含我们成功的信息以及错误的时候的异常信息。
代码如下:

/**
 * Author: huangtao
 * Date: 2023/1/19
 * Desc: 用于协程内容的封装类
 * Error用来处理程序异常,不处理业务错误
 */
sealed class Result<out R> {

    data class Success<out T>(val data: T) : Result<T>()
    data class Error(val exception: Exception) : Result<Nothing>()

    override fun toString(): String {
        return when (this) {
            is Success<*> -> "Success[data=$data]"
            is Error -> "Error[exception=$exception]"
        }
    }
}

val Result<*>.succeeded
    get() = this is Result.Success && data != null

有了Result之后我们就可以方便地定义Repository的接口了
我们这边只接入用户登录的功能,那么方法也就一个。

/**
 * Desc: 登录的数据接口
 */
interface UserRepository {

    /**
     * 登录
     */
    suspend fun signIn(username: String, password: String): Result<BaseEntity<UserEntity>>
}

/**
 * Desc: 登录的数据源接口
 */
interface UserDataSource {

    /**
     * 登录
     */
    suspend fun signIn(username: String, password: String): Result<BaseEntity<UserEntity>>
}

//由于登录功能只有远程的实现,所以这边本地实现略
/**
 * Desc: 网络数据源
 */
class RemoteDataSource(
    private val service: UserService,
    private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) : UserDataSource {

    override suspend fun signIn(username: String, password: String) = withContext(ioDispatcher) {
        try {
            return@withContext Result.Success(service.login(username, password))
        } catch (e: Exception) {
            return@withContext Result.Error(e)
        }
    }
}

/**
 * Desc: 默认LoginRepository实现
 */
class DefaultUserRepository(
    private val remoteDataSource: UserDataSource,
    private val localDataSource: UserDataSource = null,
) : UserRepository {

    override suspend fun signIn(username: String, password: String): Result<BaseEntity<UserEntity>> {
        val result = remoteDataSource.signIn(username, password)
        if (result is Result.Success && result.data.isSuccess) {
        	//TODO 一般而言登录成功后,会进行一些数据缓存的逻辑等
        }
        return result
    }
}

相关实现写好后,我们通过Hilt的依赖注入暴露出去

/**
 * Desc: 用户模块的依赖注入
 */
@Qualifier
@Retention(AnnotationRetention.RUNTIME)
annotation class Remote

@Qualifier
@Retention(AnnotationRetention.RUNTIME)
annotation class Local

@Module
@InstallIn(ViewModelComponent::class)
object RepositoryModule {

    @Provides
    fun provideTasksRepository(
        @Remote remoteDataSource: UserDataSource
    ): UserRepository {
        return DefaultUserRepository(remoteDataSource)
    }
}

@Module
@InstallIn(ViewModelComponent::class)
object DataSourceModule {

    @Remote
    @Provides
    fun provideUserRemoteDataSource(
        userService: UserService
    ): UserDataSource {
        return RemoteDataSource(userService)
    }

//	  本地数据源,如果有的话
//    @Local
//    @Provides
//    fun provideTasksLocalDataSource(): UserDataSource {
//        return LocalDataSource()
//    }
}

那么至此,Model层就算实现完成了。

View and ViewModel

通过上面的一系列工作,那么我们现在就可以愉快地写界面以及业务逻辑。
我们还是通过简单的用户登录这个界面来举例。
首先是我们的布局界面,简简单单一个按钮,两个输入框。
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:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".LoginActivity">

    <androidx.constraintlayout.widget.Guideline
        android:id="@+id/gl"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:orientation="horizontal"
        app:layout_constraintGuide_percent="0.33" />

    <EditText
        android:id="@+id/ed_name"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginStart="68dp"
        android:layout_marginEnd="68dp"
        android:background="@null"
        android:hint="输入用户名"
        android:padding="6dp"
        android:textSize="18sp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/gl" />

    <EditText
        android:id="@+id/ed_password"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginStart="68dp"
        android:layout_marginTop="18dp"
        android:layout_marginEnd="68dp"
        android:background="@null"
        android:hint="输入密码"
        android:inputType="textPassword"
        android:padding="6dp"
        android:textSize="18sp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/ed_name" />

    <Button
        android:id="@+id/bt_login"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginStart="68dp"
        android:layout_marginTop="18dp"
        android:layout_marginEnd="68dp"
        android:padding="6dp"
        android:text="登录"
        android:textSize="18sp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/ed_password" />

</androidx.constraintlayout.widget.ConstraintLayout>

View层activity

@AndroidEntryPoint
class LoginActivity : BaseActivity() {

    private val binding: ActivityLoginBinding by binding()

    private val mViewModel: LoginViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        lifecycleScope.launchWhenResumed {
            mViewModel.loginFlow.collect {
                if (it == null) return@collect
                Toast.makeText(this@LoginActivity, "返回的数据=$it", Toast.LENGTH_LONG).show()
            }
        }

        binding.btLogin.click {
            loginLogic()
        }

    }

    private fun loginLogic() {
        val name = binding.edName.text.toString().trim()
        val password = binding.edPassword.text.toString().trim()
        if (!CheckUtils.checkName(name)) {
            Toast.makeText(this, "用户名${Constant.NAME_LENGTH}位", Toast.LENGTH_LONG).show()
            return
        }
        if (!CheckUtils.checkPassWord(password)) {
            Toast.makeText(this, "密码${Constant.PASSWORD_LENGTH}位", Toast.LENGTH_LONG).show()
            return
        }
        mViewModel.login(name, password)
    }

}

ViewModel

@HiltViewModel
class LoginViewModel @Inject constructor(
    private val repository: UserRepository
) : BaseViewModel() {

    private val mLoginFlow = MutableStateFlow<Result<BaseEntity<UserEntity>>?>(null)
    val loginFlow: Flow<Result<BaseEntity<UserEntity>>?> get() = mLoginFlow

    fun login(name: String, password: String) {
        viewModelScope.launch {
            mLoginFlow.value = repository.signIn(name, password)
        }
    }
}

到此一个简单的MVVM架构就实现了

  • 如果你有疑问或者更好的想法,欢迎进群讨论Android 学习交流群

源码传送门:
https://github.com/huangtaoOO/TaoComponent

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/179267.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

提权漏洞和域渗透历史漏洞整理

Windows提权在线辅助工具 https://i.hacking8.com/tiquan/&#x1f334;Kernel privilege escalation vulnerability collection, with compilation environment, demo GIF map, vulnerability details, executable file (提权漏洞合集) https://github.com/Ascotbe/Kernelhu…

恶意代码分析实战 13 反调试技术

13.1 Lab16-01 首先&#xff0c;将可执行文件拖入IDA中。 我们可以看到有三处都调用了sub_401000函数&#xff0c;并且代码都在哪里停止执行。由于没有一条线从这些方框中引出&#xff0c;这就意味着函数可能终止了程序。 右侧每一个大框中都包含一个检查&#xff0c;这个检查…

Makefile学习②:Makefile基本语法

Makefile学习②&#xff1a;Makefile基本语法 Makefile基本语法 目标&#xff1a; 依赖 &#xff08;Tab&#xff09;命令 目标&#xff1a;一般是指要编译的目标&#xff0c;也可以是一个动作 依赖&#xff1a;指执行当前目标所要依赖的先项&#xff0c;包括其他目标&#xf…

neural collaborative filtering 阅读笔记

本文主要介绍了一种一种基于神经网络的技术&#xff0c;来解决在含有隐形反馈的基础上进行推荐的关键问题————协同过滤。 2.1 Learning from Implicit Data yui1,(ifinteraction(useru,itemi)isobserved)y_{ui} 1,(if interaction (user u, item i) is observed)yui​1,(…

还在为ElementUI的原生校验方式苦恼吗,快用享受element-ui-verify插件的快乐吧(待续)

element-ui-verify 本文章意在介绍element-ui-verify插件使用&#xff0c;以及对比elementUI原生校验方式&#xff0c;突显该插件用少量代码也能实现原生的校验效果甚至更好。 1.先观察一个示例 <template><d2-container><el-form :model"ruleForm&qu…

二叉树超级经典OJ题

目录1.根据二叉树创建字符串2.二叉树的层序遍历3.二叉树的层序遍历II4.二叉树的最近公共祖先5.二叉搜索树与双向链表6.从前序与中序遍历序列构造二叉树1.根据二叉树创建字符串 根据二叉树创建字符串 给你二叉树的根节点root&#xff0c;请你采用前序遍历的方式&#xff0c;将二…

编码器M法测速仿真(Simulink)

编码器M法和T法测速的详细讲解可以参看下面的文章链接,这里不再赘述,这里主要介绍Simulink里建模仿真,带大家从另一个角度理解编码器测速原理。 PLC通过编码器反馈值计算速度的推荐做法(算法解析+ST代码)_RXXW_Dor的博客-CSDN博客_编码器计算速度程序实例PLC如何测量采集编…

Power BI中类似Vlookup的查询筛选功能如何实现

一、问题描述 在Excel中有一个非常经典的函数Vlookup&#xff0c;可以通过首列查找&#xff0c;返回相对应的其他列的值。这种功能&#xff0c;在Power BI中没有Vlookup函数&#xff0c;那么该如何实现这一功能呢&#xff1f;下面通过一个实例做分析演示。 二、数据源 已知某…

厚积薄发打卡Day114:Debug设计模式:设计原则(二)<接口隔离原则、迪米特法则>

厚积薄发打卡Day114&#xff1a;Debug设计模式&#xff1a;设计原则&#xff08;二&#xff09;<接口隔离原则、迪米特法则> 接口隔离原则 定义 用多个专门的接口&#xff0c;而不使用单一的总接口&#xff0c;客户端不应该依赖它不需要的接口 一个类对一个类的依赖应…

如何将 Spring Boot 项目提交到 Gitee 进行远程管理?

如何将初始化完成的 Spring Boot 项目&#xff0c;提交的 Gitee 仓库&#xff0c;进行远程管理&#xff1f; 目录 如何将初始化完成的Spring Boot项目&#xff0c;提交的Gitee仓库&#xff0c;进行远程管理&#xff1f; 0. 环境说明 1.创建并初始化Spring Boot项目 2.创建…

如何设计一个 70w 在线人数的弹幕系统 ?

背景 现在的抖音、快手等一系列能支持直播的软件如何做到的同一时间万人发弹幕的功能的&#xff1f; 问题分析 带宽压力&#xff1b; 弱网导致的弹幕卡顿、丢失&#xff1b; 性能与可靠性。 带宽优化 启用Http压缩 HTTP压缩是指在Web服务器和浏览器间传输压缩文本内容的方…

网络— — 二层交换机

网络— — 二层交换机 1 交换机端口 g0/3 g&#xff1a;1000M/s0&#xff1a;模块号3&#xff1a;接口号 每个端口网速&#xff1a; Ethernet 10M/s FastEthernet 100M/s GigabitEthernet 1000M/s TenGigabitEthernet 10000Mb/s 2 数据链路层&#xff08;2层&#xff09; Data…

python图像处理(opencv入门)

【 声明:版权所有,欢迎转载,请勿用于商业用途。 联系信箱:feixiaoxing @163.com】 前面我们讨论了fpga的基本操作,这些都是作为整个图像处理的基础部分进行学习的,本质上还是希望用fpga来对部分算法进行加速处理。当然,fpga实现的只是一部分算法,并不是所有的算…

【手写 Promise 源码】第二篇 - Promise 功能介绍与特性分析

一&#xff0c;前言 上一篇&#xff0c;对 Promise 进行了简单介绍&#xff0c;主要涉及以下几个点&#xff1a; Promise 简介和基本使用&#xff1b;Promise 和 callback 对比&#xff1b;Promise 的重要性和作用&#xff1b;Promise 使用场景&#xff1a;Promisify 封装&am…

Python Flask 框架设置响应头,一篇文章学明白

flask 响应头设置&#x1f4d6; 导读&#x1f4d6; Python Flask 设置响应头&#x1f389; response.headers 属性&#x1f389; flask.abort() 函数返回特定状态码&#x1f4d6; Flask 中可设置的响应头参数&#x1f4d6; 其他关于响应头的配置&#x1f381; 使用中间件&#…

[Python]调用pytdx的代码示例

安装pytdxpip install pytdx简单示范from pytdx.hq import TdxHq_API api TdxHq_API() # 数据获取接口一般返回list结构&#xff0c; with api.connect(119.147.212.81, 7709): # 返回普通listdata api.get_security_bars(9, 0, 000001, 0, 10) print(data)输出结果[Ordered…

康巴丝(compas)某Wi-Fi万年历无法自动校时的问题

笔者前两年购买过一块康巴丝牌的电子万年历&#xff0c;该万年历的最大特点可以通过Wi-Fi联网自动校时。 后来的某一天&#xff0c;笔者将原来的H3C AP&#xff08;11AC&#xff09;升级为锐捷AP&#xff08;11AX&#xff09;。 在之后的几个月中&#xff0c;发现这块电子万年…

Linux常用命令——setsebool命令

在线Linux命令查询工具(http://www.lzltool.com/LinuxCommand) setsebool 修改SElinux策略内各项规则的布尔值 补充说明 setsebool命令是用来修改SElinux策略内各项规则的布尔值。setsebool命令和getsebool命令是SELinux修改和查询布尔值的一套工具组。SELinux的策略与规则…

【云原生】Prometheus 监控系统的初步了解与系统搭建

内容预知 前言 1. Prometheus的相关知识 1.1 Prometheus的认识与了解 1.2 Prometheus的特点 1.3 Prometheus存储引擎TSDB 的特点优势 1.4 Prometheus 的生态组件 &#xff08;1&#xff09;Prometheus server &#xff08;2&#xff09;Client Library &#xff08;3&a…

主流的操作系统(带你快速了解)

&#x1f4dc; “作者 久绊A” 专注记录自己所整理的Java、web、sql等&#xff0c;IT技术干货、学习经验、面试资料、刷题记录&#xff0c;以及遇到的问题和解决方案&#xff0c;记录自己成长的点滴。 &#x1f341; 操作系统【带你快速了解】对于电脑来说&#xff0c;如果说…