Jetpack 架构组件你了解多少?_jetpack组件

class MainViewModel(countReserved: Int) : ViewModel() {
    var counter = MutableLiveData<Int>()
    init {
        counter.value = countReserved
    }

    fun plusOne() {
        val count = counter.value ?: 0
        counter.value = count + 1
    }
    fun clear() {
        counter.value = 0
    }
}

这里将 counter 变量修改成了一个 MutableLiveData 对象,这是一种可变的 LiveData 。它主要有三种读写数据的方法,分别是:

  • getvalue() //用于获取 LiveData 中包含的数据
  • setValue() //用于给 LiveData 设置数据,但是只能在主线程中调用
  • postValue() //用于在非主线程中给 LiveData 设置数据

下面来修改 MainActivity 中的代码:

class MainActivity : AppCompatActivity() {
    ......
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        ......
        btn_plus.setOnClickListener{
            viewModel.plusOne()
        }
        btn_clear.setOnClickListener {
            viewModel.clear()
        }

        viewModel.counter.observe(this, Observer { count ->
            tv_info.text = count.toString()
        })
    }

    override fun onPause() {
        super.onPause()
        val edit = sp.edit()
        edit.putInt("count_reserved",viewModel.counter.value ?: 0)
        edit.apply()
    }
}

这里 counter 变量已经变成了一个 LiveData 对象,任何 LiveData 对象都可以调用它的 observe() 方法来观察数据的变化。observer() 方法接收两个参数:第一个参数是一个 LifecycleOwner 对象,这里也就是 Activity 自己。第二个参数是一个 Observer 接口,当 counter 中包含的数据发生变化时,就会回调到这里。

关于 observe() 方法,Google 官方在专门面向 Kotlin 语言的 API 中提供了很多好用的语法扩展,要使用它需添加依赖:

implementation 'androidx.lifecycle:lifecycle-livedata-ktv:2.2.0'

之后我们就可以使用如下结构的 observe() 方法了

viewModel.counter.observe(this) { count -> 
    tv_info.text = count.toString()
}

以上是 LiveData 基本用法,可以正常使用,但仍然不是最规范的用法, 主要问题是我们将 counter 这个可变的 LiveData 暴露给了外部,这样在 ViewModel 外面也是可以给 counter 设置数据,从而破坏了 LiveData 数据的封装性

比较推荐的做法是,永远只暴露不可变的 LiveData 给外部,下面来改造下 MainViewModel ,如下:

class MainViewModel(countReserved: Int) : ViewModel() {

    val counter : LiveData<Int>
        get() =_counter
    private val _counter = MutableLiveData<Int>()

    init {
        _counter.value = countReserved
    }
    fun plusOne() {
        val count = _counter.value ?: 0
        _counter.value = count + 1
    }
    fun clear() {
        _counter.value = 0
    }
}

这里 _counter 变量对于外部便是不可见的,而我们又定义了一个 counter 变量,类型声明为不可变的 LiveData ,并在它的 get() 属性方法中返回 _counter 变量。
这样当外界调用 counter 变量时,实际上获得的是 _counter 的实例,但是无法给 counter 设置数据,从而保证了 LiveData 数据的封装性

LiveData 中的 map 和 switchMap

LiveData 为了能够应对各种不同需求场景,提供了两种转换方法:map()switchMap() 方法

map()

这个方法的作用是将实际包含数据的 LiveData 和仅用于观察数据的 LiveData 进行转换,实例如下:
比如有一个 User 类,其中包含用户的姓名、年龄,如下:

data class User(var firstName: String,var lastName:String, var age: Int) {}

这里包含了三个数据,但如果我们的 Activity 明确了只需要用户的姓名,不关心年龄时 还将整个 User 暴露出去就不太合适,这时候就可以使用 map() 方法,将 User 类型的 LiveData 自由的转型成任意其他类型的 LiveData,如下:

class MainViewModel(countReserved: Int) : ViewModel() {
    private val userLiveData = MutableLiveData<User>()

    val userName : LiveData<String> = Transformations.map(userLiveData){ user ->
        "${user.firstName} ${user.lastName}"
    }
......
}

map() 方法接收两个参数:

  • 第一个:参数是原始的 LiveData 对象;
  • 第二个:参数是一个转换函数

我们就只需要在转换函数里写具体的逻辑即可
另外,我们将 userLiveData 声明成了 private ,以保证数据的封装性,外部只需要观察 userName 就可以了,当 userLiveData 数据发生变化时,map() 方法会监听到变化并执行转换函数中的逻辑,然后将转换之后的数据通知给 userName 的观察者

switchMap()

我们通过一个实例来学习。根据传入的 userId 参数去服务器请求或者到是数据库中查找相应的 User 对象,但是这里只是模拟示例,因此每次传入的 userId 当作用户姓名来创建一个新的 User 对象即可

代码如下,先创建一个 Repository 单例类,模拟获取用户数据的功能:

object Repository {
    fun getUser(userId: String) : LiveData<User>{
        val liveData = MutableLiveData<User>()
        liveData.value = User(userId,userId,0)
        return liveData
    }
}

下面获取 RepositoryLiveData 对象,

class MainViewModel(countReserved: Int) : Int{
    ...
    fun getUser(userId: String) : LiveData<User> {
        return Repository.getUser(userId)
    }
}

Activity 中观察 LiveData

viewModel.getUser(userId).observe(this) { -> 
}

以上的这种呢做法完全错误,因为每次调用 getUser() 方法返回的都是一个新的 LiveData 实例,而上述写法会一直观察老的 LiveData 实例,这种情况下,LiveData 是不可能观察的

以下是正确做法,
借助 switchMap ,它的使用场景比较固定:如果 VIewModel 中的某个 LiveData 对象是调用另外的方法获取的,那么就可以借助 switchMap() 方法,将这里 LiveData 对象转换成另外一个可观察的 LiveData 对象。修改 MainViewModel 中的代码,如下:

class MainViewModel(countReserved: Int) : ViewModel() {
    ....
    private val userIdLiveData = MutableLiveData<String>()

    val user: LiveData<User> = Transformations.switchMap(userIdLiveData) { userId ->
        Repository.getUser(userId)
    }
    fun getUser(userId: String) {
        userIdLiveData.value = userId
    }
}

switchMap() 方法接收两个参数:

  • 第一个:传入新增的 userIdLiveDataswitchMap() 方法会对它进行观察
  • 第二个:是一个转换函数,我们必须在这个转换函数中返回一个 LiveData 对象,因为 switchMap() 方法的工作原理就是要将转换函数中返回的 LiveData 对象转换成另一个可观察的 LiveData 对象。

现在 user 对象就是一个可观察的 LiveData 对象了
修改 Activity 中的代码,如下:

class MainActivity : AppCompatActivity() {
    ....
    override fun onCreate(savedInstanceState: Bundle?) {
        ....
        btn_getUser.setOnClickListener {
            val userId = (0..1000).random().toString()
            viewModel.getUser(userId)
        }
        viewModel.user.observe(this,{
        tv_info.text = it.firstName
        })
    }
}

现在已经实现了功能并且可以正常运行了。

当我们的 ViewModel 中某个获取数据的方法有可能是没有参数的,这个时候该怎么办呢?
这里我们先创建一个空的 LiveData 对象:

class MyViewModel : ViewModel(){

    private val refreshLiveData = MutableLiveData<Any?>()

    val refreshResult = Transformatinos.switchMap(refreshLiveData) {
        Repository.refresh()
    }

    fun refresh() {
        refreshLiveData.value = refreshLiveData.value
    }
}

refresh() 方法中,只是将 refreshLiveData 原有的数据取出来(默认是空),再重新设置到 refreshResult 当中,这样就能触发一次数据变化。
LiveData 内部不会判断即将设置的数据和原有数据是否相同,只要调用了 setValue()postValue() 方法,就一定会触发数据变化事件,然后我们只需要在 Activity 中观察 refreshResult 这个 LiveData 对象即可

Room

Room 是 Google 官方推出的一个 ORMObject Relational Mapping 对象关系映射)框架,并将它加入了 Jetpack 中。

Room 的整体结构主要由 EntityDaoDatabase 这三个部分组成,每个部分都有自己明确的职责:

  • Entity:用于定义封装实际数据的实体类,每个实体类都会在数据库中都有一张对应的表,并且表中的列是根据实体类中的字段自动生成的。
  • DaoDao 是数据访问的意思,同长会在这里对数据库的各项操作进行封装。在实际编程中,逻辑层就不用和底层数据打交道了,直接和 Dao 层进行交互就可。
  • Database:用于定义数据库中的关键信息,包括数据库的版本号、包含哪些实体类以及提供 Dao 层的访问实例。

Room 的具体用法

添加依赖:

....
apply plugin: 'kotlin-kapt'

dependencies{
    ....
    implementation 'androidx.room:room-runtime:2.2.5'
    kapt 'androidx.room:room-compiler:2.2.5'
}

kapt 只能在 Kotlin 项目中使用,如果是 Java 项目的话,使用 amotationProcessor 即可

接下来按照刚才介绍的 Room 的三个部分来一一进行实现

定义 Entity:

我们直接就使用 上文定义的 User 类来改造

@Entity
data class User(var firstName: String,var lastName:String, var age: Int) {

    @PrimaryKey(autoGenerate = true) 
    var id: Long = 0  //给每个实体类都添加一个字段,并设为主键
}

@Entity 注解:将 User 声明成了一个实体类
@PrimaryKey 注解:将 id 字段设为主键,将 autoGenerate 设置为 true ,使得主键的值自动生成

定义 Dao:

这一部分比较关键,因为所有访问数据库的操作都是在这里封装
新建一个 UserDao 接口,如下:

@Dao
interface UserDao {

    @Insert
    fun insetUser(user: User) : Long

    @Update
    fun updateUser(newUser: User)

    @Query("select * from User")
    fun loadAllUser() : List<User>

    @Query("select * from User where age > :age")
    fun loadUsersOlderThan(age: Int) : List<User>

    @Delete
    fun deleteUser(user: User)

    @Query("delete from User where lastName = :lastName")
    fun deleteUserByLastName(lastName: String) : Int
}

@Dao 注解:让 Room 能够识别到 UserDao 是一个 Dao
@Insert 注解:表示将参数传入 User 对象插入数据库中,插入完成后还会返回自动生成的主键 id
@Update 注解:表示会将参数中传入的 User 对象更新到数据库当中
@Delete 注解:表示会将参数传入的 User 对象从数据库中删除
以上几种数据库操作都直接使用注解表示即可,不用编写 SQL 语句。如果想要从数据库中查询数据,或者使用非实体类参数来增删改查数据,就必须编写 SQL 语句,比如刚刚定义的 loadAllUsers() 方法,用于从数据库中查询所有用户。

@Query 注解:该注解中必须编写 SQL 语句,可以将方法中传入的参数指定到 SQL 语句当中,比如 loadUserOlderThan() 方法就可以查询所有年龄大于指定参数的用户。

如果是使用非实体类参数来增删改查数据,也要编写 SQL 语句才行,而且只能使用 @Query 注解,比如 deleteUserByLastName() 方法

定义 Database

这部分一般只需要定义三个部分:数据库版本号、包含哪些实体类、提供 Dao 层的访问实例。
新建一个 AppDatabase.kt 文件,如下:

@Database(version = 1, entities = [User::class])
abstract class AppDatabase : RoomDatabase() {

    abstract fun userDao() : UserDao
    companion object{
        private var instance: AppDatabase? = null

        @Synchronized
        fun getDatabase(context: Context): AppDatabase {
            instance?.let {
                return it
            }
            return Room.databaseBuilder(context.applicationContext,
                AppDatabase::class.java, "app_database")
                .build().apply {
                    instance = this
                }
        }
    }
}

@Database 注解:其中声明了数据库版本号以及包含的实体类,多个实体类之间用逗号隔开。
AppDatabase 类必须继承自 RoomDatabase 类,并且一定要使用 abstract 关键字声明成抽象类,然后提供相应的抽象方法,用于获取之前的 Dao 实例,比如这里提供的 userDao() 方法。之后在 companion object 结构体中编写了一个单例模式,原则上全局应该只存在一个 Appdatabase 实例。

databaseBuilder() 方法接收三个参数:

  • 第一个:参数一定要使用 applicationContext ,而不能使用普通 context() ,否则容易出现内存泄漏的情况。
  • 第二个:参数是 AppDatabaseClass 类型。
  • 第三个:参数是数据库名

修改 MainActivity 中的代码:

class MainActivity : AppCompatActivity() {
    ....
    override fun onCreate(savedInstanceState: Bundle?) {
        ....
        val userDao = AppDatabase.getDatabase(this).userDao()
        val user1 = User("Tony","ll",11)
        val user2 = User("Tom","SS",22)
        btn_addData.setOnClickListener {
            thread {
                user1.id = userDao.insetUser(user1)
                user2.id = userDao.insetUser(user2)
           }
        }
        btn_updateData.setOnClickListener {
            thread {
                user1.age = 33
                userDao.updateUser(user1)
            }
        }
        btn_deleteData.setOnClickListener {
            thread {
                userDao.deleteUserByLastName("SS")
            }
        }
        btn_queryData.setOnClickListener {
            thread {
                for (user in userDao.loadAllUser()) {
                    Log.d("MainActivity",user.toString())
                }
            }
        }   
    }
}

AddData 的点击事件中将 insertUser() 方法返回的主键 id 值赋值给了原来的 User 对象。之所以这样是因为使用 @Update@Delete 注解去更新和删除数据时都是基于这个 id 值来操作的。
由于数据库操作属于耗时操作,Room 默认不允许在主线程中进行数据库操作,因此上述的增删改查操作都放在了子线程中,不过为了方便调试,Room 还提供了一个更加简单的方法:

Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "app_database")
    .allowMainThreadQueries()
    .build()

这样 Room 就允许在主线程进行数据库操作了,不过建议只在测试环境使用

Room 的数据库升级

Room 在数据库升级方面设计得比价繁琐,并没有比原生的 SQLiteDatabase 简单到哪儿去。如果你目前还只是在开发测试阶段,不想编写那么繁琐的数据库升级逻辑,Room 提供了一个简单粗暴的方法:

Room.databaseBuilder(aontext.applicationContext, AppDatabase::class.java. "app_database")
    .fallbackToDestructiveMigration()
    .build()

构建 AppDatabase 实例时加入了一个 fallbackToDestructiveMigration() 方法,这样只要数据库进行了升级,Room 就会将当前的数据库销毁,然后再重新创建,但这样做的问题也就显而易见,之前数据库中的数据也会全部丢失。

下面是正规用法
我们先新建一个 Book 的实体类:

@Entity
data class Book(var nane:String, var price: Int) {

    @PrimaryKey(autoGenerate = true)
    var id : Long = 0
}

然后创建一个 BookDao 接口,并在其中随意定义一些 API

 @Dao
interface BookDao {

    @Insert
    fun insert(book: Book) : Long

    @Query("select * from Book")
    fun loadAllBooks() : List<Book>
}

修改 AppDatabase 中的代码:

@Database(version = 2, entities = [User::class, Book::class])
abstract class AppDatabase : RoomDatabase() {

    abstract fun userDao() : UserDao
    abstract fun bookDao() : Book

    companion object{

        val MIGRATION_1_2 = object : Migration(1,2) {
            override fun migrate(database: SupportSQLiteDatabase) {
                database.execSQL("create table Book (id integer primary key autoincrement not null, name text not null, price integer not null")
            }
        }

        private var instance: AppDatabase? = null

        @Synchronized
        fun getDatabase(context: Context): AppDatabase {
            instance?.let {
                return it
            }
            return Room.databaseBuilder(context.applicationContext,
                AppDatabase::class.java, "app_database")
                .addMigrations(MIGRATION_1_2)
                .build().apply {
                    instance = this
                }
        }
    }
}

我们在 @Database 注释中将版本号升级到了 2 ,并将 Book 嘞添加到了实体类声明中。
companion object 结构体中,实现了一个 Migration 的匿名类,并传入了 12 这两个参数,表示当前数据库版本从 1 升级到 2 的时候就执行这个匿名类中的升级逻辑。这里是要新增一张 Book 表,所以需要在 migrate() 方法中编写相应的建表语句。Book 表的建表语句必须和 Book 实体类中声明的结构完全一致。最后在构建 AppDatabase 实例时,加入一个 addMigrations() 方法,并将参数传入即可。

当我们的数据库可能不需要新建表,而是添加一个列时,可以使用 alter 语句修改表结构即可,如下:

@Entity
data class Book(var nane:String, var price: Int, var pages: Int) {
    @PrimaryKey(autoGenerate = true)
    var id : Long = 0
}

这里添加了一个页数 pages 的字段,之后修改 AppDatabase 的代码:

@Database(version = 2, entities = [User::class, Book::class])
abstract class AppDatabase : RoomDatabase() {
    ....
    companion object{
        ....
        var MIGRATION_2_3 = object : Migration(2, 3) {
            override fun migrate(database: SupportSQLiteDatabase) {
                database.execSQL("alter table Book add column pages integer not null default 'unknown'")
            }
        }
                private var instance: AppDatabase? = null

        @Synchronized
        fun getDatabase(context: Context): AppDatabase {
            ....
            return Room.databaseBuilder(context.applicationContext,
                AppDatabase::class.java, "app_database")
                .addMigrations(MIGRATION_1_2,MIGRATION_2_3)
                .build().apply {
                    instance = this
                }
        }
    }
}

升级步骤和之前差不多,就不多说了

WorkManager

WorkManager 适合用于处理一些要求定时执行的任务,它可以根据操作系统的版本自动选择底层是使用 AlarmManager 实现还是 JobScheduler 实现,而且它还支持周期性任务、链式任务处理等功能。

WorkManager 的基本用法主要分以下三步:

  1. 定义一个后台任务,并实现具体的任务逻辑;
  2. 配置该后台任务的运行条件和约束信息,并构建后台任务请求;
  3. 将改后台任务请求传入 WorkManagerenqueue() 方法中,系统会在合适的时间运行。

下面来按照以上步骤来实现。第一步,定义一个后台任务,新建一个 SimpleWorker 类,如下:

class SimpleWorker(context: Context, params: WorkerParameters) : Worker(context,params) {
    override fun doWork(): Result {
        Log.d("SimpleWorker", "do work in SimpleWorker")
        return Result.success()
    }
}

每一个后台任务都必须继承 Work 类。doWork() 方法不会运行在主线程中,因此可以在这里执行耗时操作,另外该方法要求返回一个 Result 对象,用于表示任务的运行结果。成功就是 Result.success() ,失败是 Result.failure() 。除了这两种还有一个 Result.retry() 方法,它其实也代表着失败,只是可以结合 WorkRequest.BuildersetBackoffCriteria() 方法来重新执行任务。

第二步,配置该后台任务的运行条件和约束信息。
这里进行最基本的配置,代码如下:

var request= OneTimeWorkRequest.Builder(SimpleWorker::class.java).build()

OneTimeWorkRequest.BuilderWorkRequest.Builder 的子类,用于构建单词运行的后台任务请求,还有另一个 PeriodicWorkRequest.Builder 也是其子类,用于构建周期性运行的任务请求,但为了降低设备性能消耗,它的构造函数中传入的运行周期间隔不能短于 15 分钟,实例如下:

var request = PeriodicWorkRequest.Builder(SimpleWorker::class.java, 15,
    TimeUnit.MINUTES).build()

第三步,最后只需要将构建好的后台任务请求传入 WorkManagerenqueue() 方法中,系统就会在合适的时间去运行了:

WorkManager.getInstance(context).enqueue(request)

下面来测试下,修改 MainActivity 中的代码:

class MainActivity : AppCompatActivity() {
    ....
    override fun onCreate(savedInstanceState: Bundle?) {
        ....
        btn_doWork.setOnClickListener {
            val request= OneTimeWorkRequest.Builder(SimpleWorker::class.java).build()
            WorkManager.getInstance(this).enqueue(request)
        }
    }
}

这里后台任务的具体时间由我们所指定的约束以及系统的一些优化所决定的,由于这里没有指定任何约束,因此后台任务基本上会在点击按钮之后立刻运行。

使用 WorkManager 处理复杂任务

让后台任务在指定的延迟时间后运行,可以借助 setInitialDelay() 方法,如下:

var request = PeriodicWorkRequest.Builder(SimpleWorker::class.java)
    .setInitialDelay(5, TimeUnit.MINUTES)
    .build()

setInitalDelay() 接收两个参数,第一个是要延迟的时间,第二个是该时间的单位:可以选择毫秒、秒、分钟、小时、天都可以。

可以控制运行时间之后,再来添加点别的功能,比如给后台任务请求添加标签,如下所示:

var request = PeriodicWorkRequest.Builder(SimpleWorker::class.java)
    ....
    .addTag("simple")
    .build()

该功能最主要的一个功能就是通过标签来取消后台任务请求:

WorkManager.getInstance(this).cancelAllWorkByTag("simple")

使用标签可以将可以将同一标签名的所有后台请求全部取消

如果没有标签,也可以通过 id 来来取消后台任务请求:

WorkManager.getInstance(this).cancelAllWorkById(request.id)

如果想要一次性取消所有后台任务:

WorkManager.getInstance(this).cancelAllWork()

在上文中提到,如果 doWork() 方法中返回了 Result.retry(),那么可以结合 setBackoffCriteria() 方法来重新执行任务,如下:

自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数HarmonyOS鸿蒙开发工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则几千的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!

因此收集整理了一份《2024年HarmonyOS鸿蒙开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。
img
img
img

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上HarmonyOS鸿蒙开发知识点,真正体系化!

由于文件比较大,这里只是将部分目录大纲截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新

如果你觉得这些内容对你有帮助,可以添加VX:vip204888 (备注鸿蒙获取)
img

一个人可以走的很快,但一群人才能走的更远。不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎扫码加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!

try(),那么可以结合 setBackoffCriteria()` 方法来重新执行任务,如下:

自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数HarmonyOS鸿蒙开发工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则几千的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!

因此收集整理了一份《2024年HarmonyOS鸿蒙开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。
[外链图片转存中…(img-N499cP4N-1712848687832)]
[外链图片转存中…(img-Z8iKUM8o-1712848687832)]
[外链图片转存中…(img-il2ZqAvp-1712848687833)]

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上HarmonyOS鸿蒙开发知识点,真正体系化!

由于文件比较大,这里只是将部分目录大纲截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新

如果你觉得这些内容对你有帮助,可以添加VX:vip204888 (备注鸿蒙获取)
[外链图片转存中…(img-baFowdUd-1712848687833)]

一个人可以走的很快,但一群人才能走的更远。不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎扫码加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值