(实战)基于MVVM+Kotlin+AAC架构之登录模块

一、背景

互联网技术飞速发展的时代,在Android领域从早期的MVC架构发展到现在的MVP、MVVM架构,开发语言也从Java语言,到新崛起的Kotlin语言,甚至是Flutter工具包所使用的跨平台Dart语言,一门新技术、新语言的出现总是需要不断的迭代,使之变得更为成熟才能更好的运用到实际项目中。

时至今日,MVVM+Kotlin+AAC组合架构已经变得越来越广泛使用,相较于MVP+Java组合架构有着很大的区别和优势:

  1. 使用Kotlin语言使得代码较Java语言更为简洁;
  2. 同时Kotlin特有的协程机制,使得异步并发到统一的过程更为高效;
  3. MVVM基于观察者模式采用数据驱动,去掉了mvp中的view层;
  4. AAC组件的引入方便生命周期的管理及数据到xml的交互和UI展示更为便捷。

所以我们应该掌握它,在合适的项目的中使用它。

本文是整理和学习了网上的各类资料后,最终自己实践而成的一个MVVM+Kotlin+AAC案例。

下面就随我一道来看看我是怎样搭建MvvmFrame这个项目的。

来一张图认识下MVVM+AAC结合的样子:
在这里插入图片描述

二、创建项目

本项目基于Android Studio 4.0.1编译器进行开发。

创建项目时,Language需要选择kotlin,然后Finish完成。

创建MvvmFrame项目

三、配置依赖

项目创建完成之后我们需要配置一些必要的依赖包,

1、首先,我们需要在Module:app 中的build.gradle文件中添加Kotlin的一些插件和协程相关的依赖包

Kotlin插件

apply plugin: 'kotlin-kapt'

协程相关的依赖包

implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.1.1'

2、其次,引入AAC组件

添加生命周期管理依赖包

implementation "androidx.lifecycle:lifecycle-extensions:2.0.0"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.1.0-alpha03"

在android节点下,打开dataBinding数据绑定开关

buildFeatures {
        dataBinding = true
    }

3、再次,引入网络请求框架retrofit2依赖

implementation 'com.squareup.retrofit2:retrofit:2.3.0'
implementation 'com.squareup.retrofit2:converter-gson:2.3.0'
implementation 'com.squareup.retrofit2:converter-scalars:2.5.0'

4、最后,我们得到了一份完整的build.gradle文件清单

apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-kapt'

android {
    compileSdkVersion 29

    defaultConfig {
        applicationId "com.hb.mvvmframe"
        minSdkVersion 16
        targetSdkVersion 29
        versionCode 1
        versionName "1.0"

        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
    }

    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }

    buildFeatures {
        dataBinding = true
    }
}

dependencies {
    implementation fileTree(dir: "libs", include: ["*.jar"])
    implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
    implementation 'androidx.core:core-ktx:1.3.1'

    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.1.1'
    implementation "androidx.lifecycle:lifecycle-extensions:2.0.0"
    implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.1.0-alpha03"

    implementation 'com.squareup.retrofit2:retrofit:2.3.0'
    implementation 'com.squareup.retrofit2:converter-gson:2.3.0'
    implementation 'com.squareup.retrofit2:converter-scalars:2.5.0'
 
    implementation 'androidx.appcompat:appcompat:1.2.0'
    implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
    testImplementation 'junit:junit:4.12'
    androidTestImplementation 'androidx.test.ext:junit:1.1.1'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'

}

四、创建包

配置依赖完成之后我们就可以开始正式的创建项目的包结构,此操作可以按照个人习惯进行创建。

创建包结构
data:负责提供数据,数据可包含如本地数据、网络请求返回的数据;
data.model:放置实体对象;
data.network:放置服务接口api及Request请求类;
data.repository:放置请求具体的服务接口类,返回数据;
ui:负责视图层的显示,包含Actvity,viewModel等类;
ui.base:放置一些视图层相关类的基类;
ui.login:放置具体的业务类;
util:工具包,放置各种工具类。

五、创建关键类或对象

创建包之后我们需要添加一些关键类或对象,这些类主要是项目中需要用到的类,在全局中使用或是基础封装。

目前项目结构演变为如下图所示的样子:

在这里插入图片描述
新增了三个文件,两个class和一个object。
ProjectApplication.kt:是项目的Application的子类,作用和我们之前的用法类同,声明了一个全局的上下文对象实例;
SPUtil.kt:是自定义的共享首选项(SharedPreferences )工具类,用来存储键值数据对到本地;
ServiceCreator.kt:是伴生对象,它的成员可以通过容器类的类名来访问,即相当与Java中的静态成员形式,是对网络请求方法的封装,方便其他类调用。

三个文件的清单如下:

ServiceCreator.kt

package com.hb.mvvmframe.data.network

import com.hb.mvvmframe.ProjectApplication
import com.hb.mvvmframe.util.SPUtil
import okhttp3.CacheControl
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.converter.scalars.ScalarsConverterFactory
import java.net.HttpURLConnection
import java.util.concurrent.TimeUnit

object ServiceCreator {
    private const val BASE_URL = "http://127.0.0.1:8080/"
    private var REWRITE_CACHE_CONTROL_INTERCEPTOR =
        Interceptor { chain: Interceptor.Chain ->
            val cacheBuilder = CacheControl.Builder()
            cacheBuilder.maxAge(0, TimeUnit.SECONDS)
            cacheBuilder.maxStale(365, TimeUnit.DAYS)
            val cacheControl = cacheBuilder.build()

            var token by SPUtil(
                ProjectApplication.context,
                "token",
                ""
            )

            var request = chain.request()
            request = request.newBuilder()
                .cacheControl(cacheControl)
                .addHeader("token", token)
                .build()

            val originalResponse = chain.proceed(request)
            when (val responseCode = originalResponse.code()) {
                HttpURLConnection.HTTP_UNAUTHORIZED -> {
                    println("未登录")
                }
                else -> {
                    println(responseCode)
                }
            }
            val maxAge = 0
            originalResponse.newBuilder()
                .removeHeader("Pragma")
                .header("Cache-Control", "public ,max-age=$maxAge")
                .build()

        }

    private val httpClient =
        OkHttpClient.Builder().addInterceptor(REWRITE_CACHE_CONTROL_INTERCEPTOR)

    private val builder = Retrofit.Builder()
        .baseUrl(BASE_URL)
        .client(httpClient.build())
        .addConverterFactory(ScalarsConverterFactory.create())
        .addConverterFactory(GsonConverterFactory.create())

    private val retrofit = builder.build()

    fun <T> create(serviceClass: Class<T>): T = retrofit.create(serviceClass)
}

SPUtil.kt

package com.hb.mvvmframe.util

import android.annotation.SuppressLint
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import kotlin.reflect.KProperty

class SPUtil<T>(val context: Context, val name: String, private val default: T) {
    private val prefs: SharedPreferences by lazy {
        context.getSharedPreferences(
            context.packageName + "_preferences",
            Context.MODE_PRIVATE
        )
    }

    operator fun getValue(thisRef: Any?, property: KProperty<*>): T {
        Log.i("info", "调用$this 的getValue()")
        return getSharePreferences(name, default)
    }

    operator fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
        Log.i("info", "调用$this 的setValue() value参数值为:$value")
        putSharePreferences(name, value)
    }

    @SuppressLint("CommitPrefEdits")
    private fun putSharePreferences(name: String, value: T) = with(prefs.edit()) {
        when (value) {
            is Long -> putLong(name, value)
            is String -> putString(name, value)
            is Int -> putInt(name, value)
            is Boolean -> putBoolean(name, value)
            is Float -> putFloat(name, value)
            else -> throw IllegalArgumentException("This type of data cannot be saved!")
        }.apply()
    }

    @Suppress("UNCHECKED_CAST")
    private fun getSharePreferences(name: String, default: T): T = with(prefs) {
        val res: Any =
            when (default) {
            is Long -> getLong(name, default)
            is Int -> getInt(name, default)
            is Boolean -> getBoolean(name, default)
            is Float -> getFloat(name, default)
            is String ->  getString(name,default)
            else -> throw IllegalArgumentException("This type of data cannot be saved!")
        }!!
        return res as T
    }
}


ProjectApplication.kt

package com.hb.mvvmframe

import android.app.Application
import android.content.Context

class ProjectApplication: Application() {
    override fun onCreate() {
        super.onCreate()
        context=this
    }
    companion object{
        lateinit var context:Context
    }
}

六、配置AndroidManifest.xml清单文件

在编写业务代码之前,还得先在AndroidManifest.xml清单文件中添加如下配置:

1、添加网络权限。

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

2、在标签中添加ProjectApplication

android:name=".ProjectApplication"

当项目的 targetSdk>=28,即项目将运行在大于等于Android 9.0的系统时,如果后端网络接口(API)不支持https,也没有https证书的话,则还需添加屏蔽https的配置,否则http请求会失败。

3、在标签中添加屏蔽https的配置语句

android:networkSecurityConfig="@xml/network_security_config"

同时在项目的/res目录下添加xml文件夹,并在/res/xml/文件夹下创建network_security_config.xml文件。

network_security_config.xml文件内容如下:

<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
    <base-config cleartextTrafficPermitted="true">
        <trust-anchors>
            <certificates src="system" />
        </trust-anchors>
    </base-config>
</network-security-config>

七、编写业务代码

目前基础工作准备完毕,我们可以开始编写业务代码了。

如以实现登录模块为例,最终业务代码编写完成后的项目结构图如下:

在这里插入图片描述

我们先来看看这三个文件:
在这里插入图片描述
LoginActivity.kt

package com.hb.mvvmframe.ui.login

import android.os.Bundle
import android.widget.Toast
import androidx.appcompat.app.ActionBar
import androidx.databinding.DataBindingUtil
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders
import com.hb.mvvmframe.ProjectApplication
import com.hb.mvvmframe.R
import com.hb.mvvmframe.databinding.ActivityLoginBinding
import com.hb.mvvmframe.ui.base.BaseActivity
import com.hb.mvvmframe.util.InjectorUtil
import com.hb.mvvmframe.util.SPUtil
import kotlinx.android.synthetic.main.activity_login.*
import kotlinx.android.synthetic.main.include_toolbar.*

class LoginActivity : BaseActivity() {
    private val viewModel by lazy {
        ViewModelProviders.of(this, InjectorUtil.getLoginModelFactory())
            .get(LoginViewModel::class.java)
    }
    private val binding by lazy {
        DataBindingUtil.setContentView<ActivityLoginBinding>(
            this, R.layout.activity_login
        )
    }
    private var spToken by SPUtil(
        ProjectApplication.context,
        "token",
        ""
    )
    private var spUserName by SPUtil(
        ProjectApplication.context,
        "username",
        ""
    )
    private var spPassword by SPUtil(
        ProjectApplication.context,
        "password",
        ""
    )

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

    override fun setViewModel() {
        binding.viewModel = viewModel
        binding.lifecycleOwner = this
    }

    override fun setAppTitle() {
        setSupportActionBar(toolbar)
        val actionBar: ActionBar? = supportActionBar
        if (actionBar != null) {
            actionBar.setDisplayHomeAsUpEnabled(true)
            actionBar.title = null
            toolTitle.text = "登录"
        }
    }

    override fun initView() {
        viewModel.username = spUserName
        viewModel.password = spPassword

        la_btn_login.setOnClickListener {
            viewModel.login()
        }

        viewModel.resLogin.observe(this, Observer { res ->
            if (res.code == 0) {
                //首选项操作
                spToken = res.token
                spUserName = la_et_name.text.toString().trim()
                spPassword = la_et_psd.text.toString().trim()

                Toast.makeText(ProjectApplication.context, res.msg, Toast.LENGTH_SHORT).show()

                //跳转到其他界面
                //var intent = Intent(this, MainActivity::class.java)
                //startActivity(intent)
                //finish()
            } else {
                Toast.makeText(ProjectApplication.context, res.msg, Toast.LENGTH_SHORT).show()
            }
        })
    }
}

在上述代码文件中,我们看到这几个override方法setViewModel(),setAppTitle(), initView(),它们都是来自父类BaseActivity,我们进行了分封装,添加了对应的抽象函数。

BaseActivity.kt 文件代码会在后面段落中给出。

读上面这段代码,我们需要关注的几个重点:

  • private val viewModel和private val binding是声明的两个懒加载对象;
  • viewModel
    变量是为了引用LoginViewModel实例而存在,有它我们可以获取或更新网络API返回的数据,并通过binding.viewModel=viewModel
    语句将实例传递到xml布局;
  • binding
    变量是为了与R.layout.activity_login布局文件建立数据绑定的链接即LiveData而存在,有它我们可以根据id载入布局中的控件,并直接根据数据的变化绑定和刷新UI界面的数据。

LoginModelFactory.kt

package com.hb.mvvmframe.ui.login

import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.hb.mvvmframe.data.repository.LoginRepository

class LoginModelFactory (private val repository: LoginRepository) : ViewModelProvider.NewInstanceFactory(){
    @Override
    override fun <T : ViewModel?> create(modelClass: Class<T>): T {
        return LoginViewModel(repository) as T
    }
}

通过工厂的方式创建LoginViewModel对象,方便传入不同的构造函数。
在LoginActivity中我们通过InjectorUtil.getLoginModelFactory()这一句来载入实例对象。

LoginViewModel.kt

package com.hb.mvvmframe.ui.login

import android.widget.Toast
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.hb.mvvmframe.ProjectApplication
import com.hb.mvvmframe.data.model.ResLogin
import com.hb.mvvmframe.data.repository.LoginRepository
import kotlinx.coroutines.launch

class LoginViewModel(private val repository: LoginRepository) : ViewModel() {
    var resLogin = MutableLiveData<ResLogin>()
    var username = ""
    var password = ""


    fun login() {
        launch({
            resLogin.value = repository.login(username, password)
        }, {
            Toast.makeText(ProjectApplication.context, it.message, Toast.LENGTH_SHORT).show()
        })
    }


    private fun launch(block: suspend () -> Unit, error: suspend (Throwable) -> Unit) =
        viewModelScope.launch {
            try {
                block()
            } catch (e: Throwable) {
                error(e)
            }
        }
}

阅读本类主要有几个注意点:

  • LoginRepository是用于为ViewModel层提供数据的,后面会单独分析它,而我们现在用的是网络请求,所以这个地方我们用到了suspend关键字,也就是挂起的意思,挂起操作将会关联到后续的协程处理。

  • 上面LoginActivity.kt、LoginModelFactory.kt、 LoginViewModel.kt三个类涉及到的辅助类InjectorUtil、BaseActivity及布局文件activity_login.xml,这个地方一并给出。

InjectorUtil.kt

package com.hb.mvvmframe.util

import com.hb.mvvmframe.data.network.CommonNetwork
import com.hb.mvvmframe.data.repository.LoginRepository
import com.hb.mvvmframe.ui.login.LoginModelFactory


object InjectorUtil {
    fun getLoginModelFactory() = LoginModelFactory(getLoginRepository())

    private fun getLoginRepository() = LoginRepository.getInstance(
        CommonNetwork.getInstance()
    )

}

BaseActivity.kt

package com.hb.mvvmframe.ui.base

import android.R
import android.os.Bundle
import android.view.MenuItem
import androidx.appcompat.app.AppCompatActivity

abstract class BaseActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setViewModel()
        initIntent()
        setAppTitle()
        initView()
    }

    private fun initIntent() {}
    abstract fun setViewModel()
    abstract fun setAppTitle()
    abstract fun initView()

    override fun onOptionsItemSelected(item: MenuItem): Boolean {
        when (item.itemId) {
            R.id.home -> finish()
            else -> {
            }
        }
        return true
    }
}

activity_login.xml

(这就是 data binding layout布局的写法,不清楚的童鞋可以参考本系列历史文章(二)Android Jetpack 组件之数据绑定库 (DataBinding)

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <data>

        <variable
            name="viewModel"
            type="com.hb.mvvmframe.ui.login.LoginViewModel" />
    </data>

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical">

        <include layout="@layout/include_toolbar" />

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="left"
            android:layout_marginLeft="16dp"
            android:layout_marginTop="84dp"
            android:text="@string/str_welcome"
            android:textColor="@color/color_title"
            android:textSize="22sp"
            android:textStyle="bold" />

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="0dp"
            android:layout_marginTop="84dp"
            android:layout_weight="1"
            android:gravity="center_horizontal"
            android:orientation="vertical"
            android:paddingLeft="16dp"
            android:paddingRight="16dp"
            tools:context=".ui.login.LoginActivity">

            <EditText
                android:id="@+id/la_et_name"
                android:layout_width="match_parent"
                android:layout_height="45dp"
                android:imeOptions="actionNext"
                android:hint="@string/str_login_name_hint"
                android:text="@={viewModel.username}"
                android:textColor="@color/color_title"
                android:textSize="16sp" />

            <EditText
                android:id="@+id/la_et_psd"
                android:layout_width="match_parent"
                android:layout_height="45dp"
                android:layout_marginTop="10dp"
                android:hint="@string/str_login_psd_hint"
                android:imeOptions="actionNext"
                android:inputType="textPassword"
                android:text="@={viewModel.password}"
                android:textColor="@color/color_title"
                android:textSize="16sp" />

            <Button
                android:id="@+id/la_btn_login"
                android:layout_width="match_parent"
                android:layout_height="45dp"
                android:layout_marginTop="20dp"
                android:background="@color/colorPrimary"
                android:text="@string/str_login"
                android:textColor="@color/white"
                android:textSize="16sp" />

        </LinearLayout>
    </LinearLayout>
</layout>

接下来我们关注数据提供层(repository)模块,涉及到的几个类如下图所示:
在这里插入图片描述
ResLogin.kt 是实体类;
CommonService.kt 这是一个接口类,用于存放我们的API访问接口;
CommonNetwork.kt 负责将ServiceCreator和CommonService组织到一起,进行请求访问,并返回数据,在这个类中我们用到了协程;
LoginRepository.kt 作为我们唯一的数据来源接口,负责将CommonNetwork返回的数据或其他本地查询的数据进行封装,提供统一的API,供上层透明化的调用。

接下来我们看看这些类的代码清单。

ResLogin.kt

package com.hb.mvvmframe.data.model

import com.google.gson.annotations.SerializedName

class ResLogin {
    @SerializedName("retCode")
    var code = -1

    @SerializedName("retMsg")
    var msg = ""

    var token = ""

    lateinit var retObj: RetObjBean

    inner class RetObjBean {
        private val id = 0
        private val name: String? = null
        private val phone: Any? = null
        private val token: String? = null
        private val menuList: List<MenuListBean>? = null

        inner class MenuListBean{
            private val id = 0
            private val name: String? = null
            private val updateDate: Long = 0
        }
    }
}

CommonService.kt

package com.hb.mvvmframe.data.network.api

import com.hb.mvvmframe.data.model.ResLogin
import retrofit2.Call
import retrofit2.http.Field
import retrofit2.http.FormUrlEncoded
import retrofit2.http.Headers
import retrofit2.http.POST

interface CommonService {

    @FormUrlEncoded
    @Headers("Content-Type:application/x-www-form-urlencoded; charset=utf-8") //传递汉字到后台不乱码
    @POST("app_user/login")
    fun login(
        @Field("loginName") name: String,
        @Field("password") psd: String
    ): Call<ResLogin>

}

CommonNetwork.kt

package com.hb.mvvmframe.data.network

import com.hb.mvvmframe.data.network.api.CommonService
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException

class CommonNetwork {
    private val commonService = ServiceCreator.create(CommonService::class.java)


    suspend fun fetchLogin(name: String, password: String) =
        commonService.login(name, password).await()

    private suspend fun <T> Call<T>.await(): T {
        return kotlin.coroutines.suspendCoroutine { continuation ->
            enqueue(object : Callback<T> {
                override fun onFailure(call: Call<T>, t: Throwable) {
                    continuation.resumeWithException(t)
                }

                override fun onResponse(call: Call<T>, response: Response<T>) {
                    val body = response.body()
                    if (body != null) continuation.resume(body)
                    else continuation.resumeWithException(RuntimeException("response body is null"))
                }

            })
        }
    }

    companion object {
        private var network: CommonNetwork? = null
        fun getInstance(): CommonNetwork {
            if (network == null) {
                synchronized(CommonNetwork::class.java) {
                    if (network == null) {
                        network = CommonNetwork()
                    }
                }
            }
            return network!!
        }
    }

}

CommonNetwork中我们使用了kotlin.coroutines.suspendCoroutine 协程实例,通过.wait就可以方法进行调用,调用的方法我们需要加上suspend 挂起关键字。

LoginRepository.kt

package com.hb.mvvmframe.data.repository

import com.hb.mvvmframe.data.network.CommonNetwork
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

class LoginRepository private constructor(
    private val network: CommonNetwork
) {

    suspend fun login(name: String, password: String) = withContext(Dispatchers.IO) {
        val result = network.fetchLogin(name, password)
        result
    }

    companion object {
        private var instance: LoginRepository? = null
        fun getInstance(network: CommonNetwork): LoginRepository {
            if (instance == null) {
                synchronized(LoginRepository::class.java) {
                    if (instance == null) {
                        instance = LoginRepository(network)
                    }
                }

            }
            return instance!!
        }
    }
}

LoginRepository类里面我们可以获取多种数据源,如本地数据或网络请求返回的数据,但对其他层只需要提供统一的API即可。

八、总结

最后来个静态的效果图😀:

在这里插入图片描述

整个MVVM架构的案例已经完结 ,本例程需要熟悉kotlin语言对于java的同学可能看起来点陌生,不过大家可以看看本博的其他文章或许会有更多的了解。

(一)Android Jetpack 组件介绍
(二)Android Jetpack 组件之数据绑定库 (DataBinding)
(三)Android Jetpack 组件之ViewModel
(四)Android Jetpack 组件之LiveData
(五)Kotlin的基础用法

原创不易,求个关注。

在这里插入图片描述

微信公众号:一粒尘埃的漫旅
里面有很多想对大家说的话,就像和朋友聊聊天。
写代码,做设计,聊生活,聊工作,聊职场。
我见到的世界是什么样子的?
搜索关注我吧。

公众号与博客的内容不同。

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值