Android Compose 新闻App(三)网络数据Compose UI显示加载、Room和DataStore使用(1)

二、Scaffold(脚手架)


你可能是第一次看到这个玩意。Compose 附带内置的 Material 组件可组合项,您可以用他们创建应用。最高级别的可组合项是 Scaffold。Scaffold 可让您实现具有基本 Material Design 布局结构的界面。Scaffold 可以为最常见的顶层 Material 组件(例如 TopAppBar、BottomAppBar、FloatingActionButton 和 Drawer)提供槽位。使用 Scaffold 时,您可以确保这些组件能够正确放置并协同工作。这是它里面提供的一些参数

在这里插入图片描述

你或许听说过Compose是声明式UI,但是更多的是插槽 API,插槽 API 是 Compose 引入的一种模式,它在可组合项的基础上提供了一层自定义设置。那么什么是插槽API呢?比如一个Button中有图标和文字,对应的就是Icon和Text,你可以认为这就是插槽。

理论的东西说了很多了,下面来实践一下。在MainActivity.kt中增加一个MainScreen函数

@Composable

private fun MainScreen() {

Scaffold {

}

}

然后在setContent和DefaultPreview中调用,下面我们预览一下:

在这里插入图片描述

一篇空白,我们可以把这个Scaffold当成是一个布局。下面我们新增一个TopAppBar

三、TopAppBar(顶部应用栏)


//顶部应用栏

TopAppBar(

title = {

Text(

text = stringResource(id = R.string.app_name),

modifier = Modifier.fillMaxWidth(),

textAlign = TextAlign.Center,

color = MaterialTheme.colors.onSecondary

)

}

)

在这里插入图片描述

这里的TopAppBar中设置title参数,然后写一个Text的插槽,设置文字、控件宽度、文字摆放位置、颜色。

下面预览一下:

在这里插入图片描述

预览的时候看不到状态栏,我们可以通过真机或者虚拟机来看一下效果。

在这里插入图片描述

① 属性值

在这里插入图片描述

这里的属性有几个是可以传入插槽的,就是有@Composable注解的,比如我们设置一下navigationIcon和action。

在这里插入图片描述

这里我们看到navigationIcon和actions的里面都有一个IconButton,这表示这个图标是可以点击的,然后我们设置点击事件,弹一个Toast,这里是一个扩展函数,我们在utils包下新建一个ToastUtils类,代码如下:

fun String.showToast() = Toast.makeText(App.context, this, Toast.LENGTH_SHORT).show()

fun String.showLongToast() = Toast.makeText(App.context, this, Toast.LENGTH_LONG).show()

fun Int.showToast() = Toast.makeText(App.context, this, Toast.LENGTH_SHORT).show()

fun Int.showLongToast() = Toast.makeText(App.context, this, Toast.LENGTH_LONG).show()

然后我们来解释一下找个Icon里面的内容,Icons.Filled.Person表示的是一个填充的Person图标,它里面是通过Path去绘制的,Icons是androidx.compose.material.icons依赖库里面的,因此不需要我们自己去写,都是material风格的图标。contentDescription就是一个描述,就是说明这个内容是什么意思,不是很重要。下面我们运行一下:

在这里插入图片描述

四、列表


我们现在有标题栏了,下面我们写页面主要内容,下面我们在MainActivity.kt中新增一个BodyContent()函数

@Composable

fun BodyContent(modifier: Modifier = Modifier) {

Column(modifier = modifier.padding(8.dp)) {

repeat(100) {

Text(“Item #$it”)

}

}

}

① 显示列表

这个函数需要在MainScreen()函数中调用。

在这里插入图片描述

下面运行一下:

在这里插入图片描述

② 滑动列表

你会发现你滑动不了,我们只需要加一行代码就可以滑动了,如下图所示:

在这里插入图片描述

通过modifier的链式调用verticalScroll()函数,再传进去rememberScrollState()。你可能又会问了,那横向滚动呢?为了区分一下,我再改了这个BodyContent函数。

在这里插入图片描述

下面我们运行一下:

在这里插入图片描述

好了,现在我们已经掌握了列表的基本使用了,下面我们加上网络请求返回的数据来看。

在这里插入图片描述

这里我们就显示这个news的数组数据。

③ 加载网络数据

之前在initData中进行数据请求的返回处理,拿到了返回值,如下图所示:

在这里插入图片描述

这里层层传值到BodyContent函数中,在这个函数中我们就来显示数据,函数的代码如下:

@Composable

fun BodyContent(news: List, modifier: Modifier = Modifier) {

LazyColumn(

state = rememberLazyListState(),

modifier = modifier.padding(8.dp)

) {

items(news) { new ->

Column(modifier = Modifier.padding(8.dp)) {

Text(

text = new.title,

fontWeight = FontWeight.ExtraBold,

fontSize = 16.sp,

modifier = Modifier.padding(0.dp, 10.dp)

)

Text(text = new.summary, fontSize = 12.sp)

Row(modifier = Modifier.padding(0.dp, 10.dp)) {

Text(text = new.infoSource, fontSize = 12.sp)

Text(

text = new.pubDateStr,

fontSize = 12.sp,

modifier = Modifier.padding(8.dp, 0.dp)

)

}

}

Divider(

modifier = Modifier.padding(horizontal = 8.dp),

color = colorResource(id = R.color.black).copy(alpha = 0.08f)

)

}

}

}

看起来内容比较多啊,说明一下:

在这里插入图片描述

首先是这个LazyColumn,LazyColumn,它只会渲染界面上的可见项,因而有助于提升性能,而且无需使用 scroll 修饰符。Jetpack Compose 中的 LazyColumn 等同于 Android 视图中的 RecyclerView。这里的state就使用rememberLazyListState()。

那么这里就说完了。

在这里插入图片描述

这个items里面就是显示数据,然后我们构建item的布局,常规的属性值就没啥好说的,这里就说一下这个Divider,这就是一个分隔线。我们增加一个左右填充,然后设置分隔线的颜色,这里用了一个black色值,就是#000000,在colors.xml中添加即可,然后设置这个颜色值的透明度,太亮了不好看。

然后你需要在setContent中添加initData()的调用

在这里插入图片描述

下面我们运行一下:

在这里插入图片描述

这样写代码是不是很简单呢?

五、Room使用


现在数据有了,那么为了减少接口API的访问次数,我们需要将数据存储到本地数据库中,我们可以在每天访问两次或一次接口,然后其余的访问都从数据库中去获取数据。这样是不是很好呢?这里我们使用的是Room数据库,它在Java和Kotlin中使用的方式有点点变化,总体区别不大。

① 添加依赖

要使用Room,首先是添加依赖,现在项目的build.gradle中定义好Room数据库的依赖版本:

room_version = ‘2.3.0’

在这里插入图片描述

然后到app模块下的build.gradle中的dependencies{}闭包中去添加依赖:

//Room数据库

implementation “androidx.room:room-runtime:$room_version”

implementation “androidx.room:room-ktx:$room_version”

kapt “androidx.room:room-compiler:$room_version”

如下图所示:

在这里插入图片描述

然后Sync Now即可,相比于Hilt来说,你会觉得Room的引入更简单了,这里的room-ktx库是是对Kotlin协程的支持。Java使用时没有这个库。

② 基础配置

下面我们来使用它,首先是实体Bean,在com.llw.goodnews包下新建db包,然后将bean包移动到db包下,打开EpidemicNews类,

在这里插入图片描述

添加两个注解,然后我们添加接口,在db包下新建一个dao包,dao包下新建一个NewsItemDao接口,里面的代码如下:

@Dao

interface NewsItemDao {

@Query(“SELECT * FROM newsitem”)

fun getAll(): List

@Insert

fun insertAll(newsItem: List?)

@Query(“DELETE FROM newsitem”)

fun delete()

}

最后在db包下创建一个AppDatabase用于处理数据库,代码如下:

@Database(entities = [NewsItem::class], version = 1, exportSchema = false)

abstract class AppDatabase : RoomDatabase() {

abstract fun newsItemDao(): NewsItemDao

companion object {

@Volatile

private var instance: AppDatabase? = null

private const val DATABASE_NAME = “good_news.db”

fun getInstance(context: Context): AppDatabase {

return instance ?: synchronized(this) {

instance ?: Room.databaseBuilder(context, AppDatabase::class.java, DATABASE_NAME).build().also { instance = it }

}

}

}

}

这里很简单的代码,也没啥好说的,就是初始化,然后单例。下面进入到App中,如下所示配置

在这里插入图片描述

③ 使用

这里我们存储的数据表是NewsItem,但是网络请求返回的是EpidemicNews,因此我们要改一下返回的数据,改的话就在EpidemicNewsRepository中,这里我们请求成功之后返回的是epidemicNews,如下图所示:

在这里插入图片描述

然后我们增加两行代码:

在这里插入图片描述

这里就是拿到数据之后保存到本地数据库中,为什么要先删除呢?因为我要保证每次拿到的数据都是当前最新的并且和网络返回的数据一样。然后我们回到MainActivity.kt中,先运行一次,保证我们的数据库中有数据保存之后,再按照如下图所示的代码去改动。

在这里插入图片描述

这就是说当我的数据库中有数据了,那么就从本地数据库中去获取数据显示在UI上,运行一下:

在这里插入图片描述

你会发现报错了,报错的原因就是我标注的这里,大意就是无法在主线程中访问数据库,那么也好解决,在Room上加一个配置就可以了。打开AppDatabase,如下图所示修改一下即可。

在这里插入图片描述

下面再运行一下就可以了。不过我们依然要去解决在主线程中访问数据库的问题,这个后面再说,现在你会觉得这样切换太麻烦了,先请求一次网络,然后改一下代码再去请求数据库,这也太low了,不行,绝对不行。下面我们改一下,通过代码来解决这个问题。

六、DataStore使用


刚才的问题可以通过什么方式去解决呢?本地缓存,在Android中提到缓存,你最开始想到的就是SP(SharedPreferences),然后是腾讯的MMKV,再是DataStore,这三者是先后顺序出现的,也许你还不知道DataStore是什么,没关系,我这里也不会讲的,哈哈哈。是不是很意外。当然了你不了解可以去看看Android Jetpack组件 DataStore的使用和简单封装,看完了你就知道怎么用了,当然你也可以不用看,因为实际上我们的用法和SP差不多,都是封装成工具类来使用,在那篇文章中就是这样封装,在这里就直接拿来用。

① 添加依赖

DataStore也是Jetpack的组件,因此我们使用的话也是需要添加依赖的。首先依然是在项目的build.gradle中添加依赖版本

datastore_version = ‘1.0.0’

在这里插入图片描述

然后是在app的build.gradle中的dependencies{}闭包中添加如下依赖:

//DataStore

implementation “androidx.datastore:datastore-preferences:$datastore_version”

implementation “androidx.datastore:datastore-preferences-core:$datastore_version”

位置如下图所示:

在这里插入图片描述

然后Sync Now。

② 封装

首先在App中增加如下代码

在这里插入图片描述

我们在utils包下新建一个EasyDataStore.kt,里面的代码如下:

object EasyDataStore {

// 创建DataStore

private val App.dataStore: DataStore by preferencesDataStore(name = “GoodNews”)

// DataStore变量

private val dataStore = App.instance.dataStore

/**

  • 存数据

*/

fun putData(key: String, value: T) {

runBlocking {

when (value) {

is Int -> putIntData(key, value)

is Long -> putLongData(key, value)

is String -> putStringData(key, value)

is Boolean -> putBooleanData(key, value)

is Float -> putFloatData(key, value)

is Double -> putDoubleData(key, value)

else -> throw IllegalArgumentException(“This type cannot be saved to the Data Store”)

}

}

}

/**

  • 取数据

*/

fun getData(key: String, defaultValue: T): T {

val data = when (defaultValue) {

is Int -> getIntData(key, defaultValue)

is Long -> getLongData(key, defaultValue)

is String -> getStringData(key, defaultValue)

is Boolean -> getBooleanData(key, defaultValue)

is Float -> getFloatData(key, defaultValue)

is Double -> getDoubleData(key, defaultValue)

else -> throw IllegalArgumentException(“This type cannot be saved to the Data Store”)

}

return data as T

}

/**

  • 存放Int数据

*/

private suspend fun putIntData(key: String, value: Int) = dataStore.edit {

it[intPreferencesKey(key)] = value

}

/**

  • 存放Long数据

*/

private suspend fun putLongData(key: String, value: Long) = dataStore.edit {

it[longPreferencesKey(key)] = value

}

/**

  • 存放String数据

*/

private suspend fun putStringData(key: String, value: String) = dataStore.edit {

it[stringPreferencesKey(key)] = value

}

/**

  • 存放Boolean数据

*/

private suspend fun putBooleanData(key: String, value: Boolean) = dataStore.edit {

it[booleanPreferencesKey(key)] = value

}

/**

  • 存放Float数据

*/

private suspend fun putFloatData(key: String, value: Float) = dataStore.edit {

it[floatPreferencesKey(key)] = value

}

/**

  • 存放Double数据

*/

private suspend fun putDoubleData(key: String, value: Double) = dataStore.edit {

it[doublePreferencesKey(key)] = value

}

/**

  • 取出Int数据

*/

private fun getIntData(key: String, default: Int = 0): Int = runBlocking {

return@runBlocking dataStore.data.map {

it[intPreferencesKey(key)] ?: default

}.first()

}

/**

  • 取出Long数据

*/

private fun getLongData(key: String, default: Long = 0): Long = runBlocking {

return@runBlocking dataStore.data.map {

it[longPreferencesKey(key)] ?: default

}.first()

}

/**

  • 取出String数据

*/

private fun getStringData(key: String, default: String? = null): String = runBlocking {

return@runBlocking dataStore.data.map {

it[stringPreferencesKey(key)] ?: default

}.first()!!

}

/**

  • 取出Boolean数据

*/

private fun getBooleanData(key: String, default: Boolean = false): Boolean = runBlocking {

return@runBlocking dataStore.data.map {

it[booleanPreferencesKey(key)] ?: default

}.first()

}

/**

  • 取出Float数据

*/

private fun getFloatData(key: String, default: Float = 0.0f): Float = runBlocking {

return@runBlocking dataStore.data.map {

it[floatPreferencesKey(key)] ?: default

}.first()

}

/**

  • 取出Double数据

*/

private fun getDoubleData(key: String, default: Double = 0.00): Double = runBlocking {

return@runBlocking dataStore.data.map {

it[doublePreferencesKey(key)] ?: default

}.first()

}

}

这个工具类我就不多解释了,代码也不难,你可能只是不了解而已,也就是协程和DataStore的配合使用。下面我们怎么把这个用到刚才所说的问题中呢?

③ 使用

首先先说一下业务逻辑,通过一个缓存值记录当天是否有请求网络API接口,没有请求就从网络中返回数据,然后保存到数据库中,第二次请求这个缓存值就有效果了,那么就从本地数据库中返回数据。这样就可以了,好了下面我们来使用吧。

这里我们需要在定义常量,在Constant中增加如下代码:

/**

  • 今日请求接口返回数据的时间戳

*/

const val REQUEST_TIMESTAMP = “requestTimestamp_news”

然后我们回到EpidemicNewsRepository中

在这里插入图片描述

因为我们要在这里判断数据是从本地来还是网络来,这里我们通过时间戳来处理。如果当前时间小于缓存中的时间,则从本地数据库获取,反之从网络中获取,这里我们创建一个工具类,在utils包下,新建一个EasyDate.kt,代码如下:

object EasyDate {

private const val STANDARD_TIME = “yyyy-MM-dd HH:mm:ss”

private const val FULL_TIME = “yyyy-MM-dd HH:mm:ss.SSS”

private const val YEAR_MONTH_DAY = “yyyy-MM-dd”

private const val YEAR_MONTH_DAY_CN = “yyyy年MM月dd号”

private const val HOUR_MINUTE_SECOND = “HH:mm:ss”

private const val HOUR_MINUTE_SECOND_CN = “HH时mm分ss秒”

private const val YEAR = “yyyy”

private const val MONTH = “MM”

private const val DAY = “dd”

private const val HOUR = “HH”

private const val MINUTE = “mm”

private const val SECOND = “ss”

private const val MILLISECOND = “SSS”

private const val YESTERDAY = “昨天”

private const val TODAY = “今天”

private const val TOMORROW = “明天”

private const val SUNDAY = “星期日”

private const val MONDAY = “星期一”

private const val TUESDAY = “星期二”

private const val WEDNESDAY = “星期三”

private const val THURSDAY = “星期四”

private const val FRIDAY = “星期五”

private const val SATURDAY = “星期六”

private val weekDays = arrayOf(SUNDAY, MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY)

/**

  • 获取标准时间

  • @return 例如 2021-07-01 10:35:53

*/

val dateTime: String get() = SimpleDateFormat(STANDARD_TIME, Locale.CHINESE).format(Date())

/**

  • 获取完整时间

  • @return 例如 2021-07-01 10:37:00.748

*/

val fullDateTime: String get() = SimpleDateFormat(FULL_TIME, Locale.CHINESE).format(Date())

/**

  • 获取年月日(今天)

  • @return 例如 2021-07-01

*/

val theYearMonthAndDay: String

get() = SimpleDateFormat(YEAR_MONTH_DAY, Locale.CHINESE).format(Date())

/**

  • 获取年月日

  • @return 例如 2021年07月01号

*/

val theYearMonthAndDayCn: String

get() = SimpleDateFormat(YEAR_MONTH_DAY_CN, Locale.CHINESE).format(Date())

/**

  • 获取年月日

  • @param delimiter 分隔符

  • @return 例如 2021年07月01号

*/

fun getTheYearMonthAndDayDelimiter(delimiter: CharSequence): String =

SimpleDateFormat(YEAR + delimiter + MONTH + delimiter + DAY, Locale.CHINESE).format(Date())

/**

  • 获取时分秒

  • @return 例如 10:38:25

*/

val hoursMinutesAndSeconds: String get() = SimpleDateFormat(HOUR_MINUTE_SECOND, Locale.CHINESE).format(Date())

/**

  • 获取时分秒

  • @return 例如 10时38分50秒

*/

val hoursMinutesAndSecondsCn: String get() = SimpleDateFormat(HOUR_MINUTE_SECOND_CN, Locale.CHINESE).format(Date())

/**

  • 获取时分秒

  • @param delimiter 分隔符

  • @return 例如 2021/07/01

*/

fun getHoursMinutesAndSecondsDelimiter(delimiter: CharSequence): String =

SimpleDateFormat(HOUR + delimiter + MINUTE + delimiter + SECOND, Locale.CHINESE).format(Date())

/**

  • 获取年

  • @return 例如 2021

*/

val year: String get() = SimpleDateFormat(YEAR, Locale.CHINESE).format(Date())

/**

  • 获取月

  • @return 例如 07

*/

val month: String get() = SimpleDateFormat(MONTH, Locale.CHINESE).format(Date())

/**

  • 获取天

  • @return 例如 01

*/

val day: String get() = SimpleDateFormat(DAY, Locale.CHINESE).format(Date())

/**

  • 获取小时

  • @return 例如 10

*/

val hour: String get() = SimpleDateFormat(HOUR, Locale.CHINESE).format(Date())

/**

  • 获取分钟

  • @return 例如 40

*/

val minute: String get() = SimpleDateFormat(MINUTE, Locale.CHINESE).format(Date())

/**

  • 获取秒

  • @return 例如 58

*/

val second: String get() = SimpleDateFormat(SECOND, Locale.CHINESE).format(Date())

/**

  • 获取毫秒

  • @return 例如 666

*/

val milliSecond: String get() = SimpleDateFormat(MILLISECOND, Locale.CHINESE).format(Date())

/**

  • 获取时间戳

  • @return 例如 1625107306051

*/

val timestamp: Long get() = System.currentTimeMillis()

/**

  • 将时间转换为时间戳

  • @param time 例如 2021-07-01 10:44:11

  • @return 1625107451000

*/

fun dateToStamp(time: String?): Long {

val simpleDateFormat = SimpleDateFormat(STANDARD_TIME, Locale.CHINESE)

var date: Date? = null

try {

date = simpleDateFormat.parse(time)

} catch (e: ParseException) {

e.printStackTrace()

}

return Objects.requireNonNull(date)!!.time

}

/**

  • 将时间戳转换为时间

  • @param timeMillis 例如 1625107637084

  • @return 例如 2021-07-01 10:47:17

*/

fun stampToDate(timeMillis: Long): String = SimpleDateFormat(STANDARD_TIME, Locale.CHINESE).format(Date(timeMillis))

/**

  • 获取第二天凌晨0点时间戳

  • @return

*/

fun getMillisNextEarlyMorning(): Long {

val cal = Calendar.getInstance()

//日期加1

cal.add(Calendar.DAY_OF_YEAR, 1)

//时间设定到0点整

cal[Calendar.HOUR_OF_DAY] = 0

cal[Calendar.SECOND] = 0

cal[Calendar.MINUTE] = 0

cal[Calendar.MILLISECOND] = 0

return cal.timeInMillis

}

/**

  • 获取今天是星期几

  • @return 例如 星期四

*/

val todayOfWeek: String

get() {

val cal = Calendar.getInstance()

cal.time = Date()

var index = cal[Calendar.DAY_OF_WEEK] - 1

if (index < 0) {

index = 0

}

return weekDays[index]

}

/**

  • 根据输入的日期时间计算是星期几

  • @param dateTime 例如 2021-06-20

  • @return 例如 星期日

*/

fun getWeek(dateTime: String): String {

val cal = Calendar.getInstance()

if (“” == dateTime) {

cal.time = Date(System.currentTimeMillis())

} else {

val sdf = SimpleDateFormat(YEAR_MONTH_DAY, Locale.getDefault())

var date: Date?

try {

date = sdf.parse(dateTime)

} catch (e: ParseException) {

date = null

e.printStackTrace()

}

if (date != null) {

cal.time = Date(date.time)

}

}

return weekDays[cal[Calendar.DAY_OF_WEEK] - 1]

}

/**

  • 获取输入日期的昨天

  • @param date 例如 2021-07-01

  • @return 例如 2021-06-30

*/

最后

最后这里放上我这段时间复习的资料,这个资料也是偶然一位朋友分享给我的,里面包含了腾讯、字节跳动、阿里、百度2019-2021面试真题解析,并且把每个技术点整理成了视频和PDF(知识脉络 + 诸多细节)。

还有 高级架构技术进阶脑图、高级进阶架构资料 帮助大家学习提升进阶,也可以分享给身边好友一起学习。

一起互勉~
《Android学习笔记总结+移动架构视频+大厂面试真题+项目实战源码》点击传送门,即可获取!
0

cal[Calendar.MINUTE] = 0

cal[Calendar.MILLISECOND] = 0

return cal.timeInMillis

}

/**

  • 获取今天是星期几

  • @return 例如 星期四

*/

val todayOfWeek: String

get() {

val cal = Calendar.getInstance()

cal.time = Date()

var index = cal[Calendar.DAY_OF_WEEK] - 1

if (index < 0) {

index = 0

}

return weekDays[index]

}

/**

  • 根据输入的日期时间计算是星期几

  • @param dateTime 例如 2021-06-20

  • @return 例如 星期日

*/

fun getWeek(dateTime: String): String {

val cal = Calendar.getInstance()

if (“” == dateTime) {

cal.time = Date(System.currentTimeMillis())

} else {

val sdf = SimpleDateFormat(YEAR_MONTH_DAY, Locale.getDefault())

var date: Date?

try {

date = sdf.parse(dateTime)

} catch (e: ParseException) {

date = null

e.printStackTrace()

}

if (date != null) {

cal.time = Date(date.time)

}

}

return weekDays[cal[Calendar.DAY_OF_WEEK] - 1]

}

/**

  • 获取输入日期的昨天

  • @param date 例如 2021-07-01

  • @return 例如 2021-06-30

*/

最后

最后这里放上我这段时间复习的资料,这个资料也是偶然一位朋友分享给我的,里面包含了腾讯、字节跳动、阿里、百度2019-2021面试真题解析,并且把每个技术点整理成了视频和PDF(知识脉络 + 诸多细节)。

还有 高级架构技术进阶脑图、高级进阶架构资料 帮助大家学习提升进阶,也可以分享给身边好友一起学习。

[外链图片转存中…(img-VBapWwP8-1714385353719)]

[外链图片转存中…(img-uGwY3IEr-1714385353722)]

一起互勉~
《Android学习笔记总结+移动架构视频+大厂面试真题+项目实战源码》点击传送门,即可获取!

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值