用kotlin+compose开发通知弹幕APP(一)

一、基础介绍

1、什么是Jetpack Compose

Jetpack Compose 是Google新发布的用于Android开发的UI工具包。用于替换我们目前常用的XML布局文件搭配findById的UI绘制方法。属于是一个很大的变动了。

compose属于声明式语法,附官方介绍:Jetpack Compose 用更少的代码、强大的工具和直观的 Kotlin API 简化并加速了 Android 上的 UI 开发。就是可以直接在activity里,去“绘制”当前页面布局,比XML布局来回findById确实强不少。能省不少模板代码。

值得一提的是,目前华为的arkui也是声明式语法,鸿蒙和安卓这两个操作系统,虽然采用不同的开发语言,不同的开发框架,但是在UI开发上,却提供了一致的方案,说明声明式UI语法,确实是大势所趋。

2、Kotlin

Kotlin就比较被人所知了,早在2017年的Google I/O大会上,就官宣了成为android开发的官方语言。所以现在还不用kotlin开发app,都会显得有点out。

kotlin作为一个基于JVM的语言,天生就对java兼容,就是说一个项目可以用java和kotlin混合编程,在kotlin的类中,可以直接调用java类文件里的方法。

3、项目简介

好嘞,接下来说说练手项目,我计划是获取系统的通知消息,比如微信消息,微博热搜什么的。把各种推送收集起来进行存储,并同时弹出实时弹幕进行展示!大概效果如截图所示:顶部横着漂浮的,就是一条测试弹幕。顶部是两个权限设置入口。下面是一个可滑动列表,用于展示已接收的历史通知消息。

界面和逻辑全部用compose和kotlin编写,看起来还行吧!

下面我逐步对项目进行说明,项目中对于kotlin和Compose的语法,我会挑一些比较陌生的进行讲解,代码边写边理解。

开干!
完成效果示意图

二、 大概思路

要完成这个APP,最核心的部分是一定要获取到系统通知。这块比较好说,继承NotificationListenerService类,就可以监听到系统的所有消息通知。接下来就是分几步了。
1、监听到消息后,解析其中我们需要的内容,进行保存。
2、把拿到的消息内容,渲染到APP界面上的列表中。
3、绘制一个展示弹幕的View,由于我们是需要在手机任意界面都可以看到实时消息弹幕,所以这个View就不能放在Activity里。这个View得做成悬浮窗的效果,得通过WindowManager来设置。

三、 项目基础配置

compose涉及的依赖比较多,如果人为配置还是很让人头疼。但是目前Android Studio支持一键创建compose+kotlin项目,所以如果是新建项目,到没有什么特别关注和难的地方。

补充几个项目里我额外用的依赖,部分依赖有版本号关联,不对的话会报错的,需要大家需要根据自己项目的配置,填写合适的版本号。

    //kotlin
    implementation 'androidx.core:core-ktx:1.9.22'
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.2"
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.2"
    implementation "org.jetbrains.kotlin:kotlin-reflect"
    implementation 'androidx.navigation:navigation-runtime-ktx:2.4.1'
    implementation("androidx.navigation:navigation-compose:2.5.3")
    //sqlite
    implementation 'org.litepal.guolindev:core:3.1.1'
    // compose Image Drawable
    implementation "com.google.accompanist:accompanist-drawablepainter:0.28.0"
}

四、开始第一步,监听获取系统消息

创建完项目后,可以先别急着写Activity,我们先去吧系统通知获取的部分做了。

系统消息得从NotificationListenerService继承类,然后去实现onNotificationPosted方法,就可以拿到消息了。所以我们得创建一个Service,继承NotificationListenerService类。

具体逻辑可以看注释

class MyNotificationService : NotificationListenerService() {
    private val TAG = "MyNotificationService"
    override fun onCreate() {
        super.onCreate()
    }

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        Log.i(TAG, "Service is started" + "-----")
        return super.onStartCommand(intent, flags, startId)
    }

   //重写消息监听的方法
    override fun onNotificationPosted(sbn: StatusBarNotification?) {
        //super.onNotificationPosted(sbn)
        try {
            //有些通知不能解析出TEXT内容,这里做个信息能判断
            if (sbn!!.notification.tickerText != null) {
                //转成自定义的Notifications对象
                val notice = toNotice(sbn)
                //协程
                GlobalScope.launch(Dispatchers.IO) {
                    //数据库存储
                    saveNotice(notice)
                }
            }
        } catch (e: Exception) {
            Log.e(TAG, "保存失败:$e")
        }
    }

   
    private fun toNotice(sbn: StatusBarNotification): Notifications {
        val extras = sbn.notification.extras
        // 获取接收消息的抬头
        val notificationTitle = extras.getString(Notification.EXTRA_TITLE)
        // 获取接收消息的内容
        val notificationText = extras.getString(Notification.EXTRA_TEXT)
        Log.e(TAG, "ticker" + sbn.notification.tickerText)
        Log.e(TAG, "$notificationTitle     $notificationText")
        val simpleDateFormat = SimpleDateFormat("yyyyMMdd-HH:mm:ss")
        val date = Date(sbn.notification.`when`)
        val n = Notifications()  \\Notifications类为自定义的消息Bean类
        val time = simpleDateFormat.format(date)
        n.time = time
        n.visibility = sbn.notification.visibility.toString()
        n.title = notificationTitle
        n.ticker = notificationText
        n.iconLevel = sbn.notification.iconLevel.toString()
        n.creatorPackage = sbn.packageName
        n.channelId = sbn.notification.channelId
        n.category = sbn.notification?.category.toString()
        n.isUpload = "0"
        n.timeStamp = System.currentTimeMillis().toString()
        return n
    }

    private suspend fun saveNotice(n: Notifications) {
        n.save() \\数据保存,此为引入的郭神的litepal带来的方法
        KLog.d(TAG, "消息保存成功")
    }

    override fun onDestroy() {
        super.onDestroy()
    }

MyNotificationService类全是由kotlin写的,以防有的小伙伴看不懂有些代码在干嘛,部分语法我拿出来在这里单独说明一下。

            if (sbn!!.notification.tickerText != null) {
                val notice = toNotice(sbn)
                GlobalScope.launch(Dispatchers.IO) {
                    saveNotice(notice)
                }
            }

          private suspend fun saveNotice

sbn!!.notification

            if (sbn!!.notification.tickerText != null) {
               ...
            }
   

“!!”表示,当变量如果为null时,会抛出空指针异常,不会执行后面的属性调用。

这个感觉不就是java的默认规则吗,java在变量为空的时候,调用属性那就是会报错空指针异常。kotlin为啥还要加“!!”,什么都不加,不也一样嘛?

其实这里这样处理,确实它的原因。主要还是因为kotlin是一个对空对象管理很严格的语言,所有的对象在声明的时候,你就要说明该对象是否可为空,是否可重新赋值。

而在这里,sbn这个对象并不是我们声明的,而是从onNotificationPosted方法里面入参带进来的。也就是说,kotlin认为,sbn此时是不可信的。

所以kotlin的语法,要求开发者一定要对这个对象进行null值校验处理。

而在这里,我是先假设,我无条件相信系统传来的sbn肯定是一个有值的对象,不可能为null。所以,为了避免为一个有值的对象,频繁做null判空,我就给加上了"!!"。

意思就是:“空指针就空指针把,反正我相信它不会为空”(因为我是相信NotificationListenerService不会给我null),大家在正常开发其他程序时不要模仿,对于传参处理该判空还是得判)

GlobalScope.launch()

                GlobalScope.launch(Dispatchers.IO) {
                    ...
                }

这一行其实很简单,它是kotlin协程的一个启动语法,通过launch可以启动一个协程。

启动协程还有其他的语句,在这里就不做赘述了,大家可以百度都有。

协程可以普遍理解为是轻量化的线程(当然实际它不是线程)。

在对协程使用不深,比如只是调用一个异步任务的情况,那大家可以试着把协程就当做线程去使用。

但是协程总归不是线程,我说说我个人理解:协程是线程上的片段,一个线程可以创建运行多个协程。而一个协程也不只归属于某一个线程,它可以先后切换运行在多个线程之间。

所以如果大家对异步任务有着复杂的使用,比如有多个异步任务,任务之间还有先后顺序要求。那我建议,在彻底熟悉协程的任务机制前,此类场景不要轻易使用协程。

Dispatchers.IO

                //指定调度器
                GlobalScope.launch(Dispatchers.IO) {
                    ...
                }
                //传参为空,则使用默认调度器Dispatchers.Default
                GlobalScope.launch() {
                    ...
                }

我们说过一个协程不只归属于某一个线程里,它可以切换运行在多个线程之间。

这是协程调度器,来指定当前协程运行在哪个线程上。

在这里,通过使用GlobalScope.launch(),创建一个协程时,可选传的一个入参,叫做协程调度器。它的作用是确定让协程运行在哪个线程上。

需要注意的是,调度器不仅可以在创建协程的时候指定,也可以在协程运行中,进行指定切换。比如下面这个例子:

GlobalScope.launch(Dispatchers.MAIN) {
     //使用MAIN调度器,在UI主线程中运行
     Log.i(TAG, "此为UI线程")
     
    withContext(Dispatchers.IO) {
        //使用IO调度器,切换协程所在的线程,此刻代码在非UI线程运行
        Log.i(TAG, "此为IO子线程")
        }
        
    //回到了主线程
    Log.i(TAG, "此为UI线程")
}

GlobalScope.launch(),如果不传参数,launch()默认使用的调度器为Dispatchers.Default。这个调度器被设计用于运行 CPU 密集型操作,运算类任务可以使用它。

Dispatchers.IO调度器被设计为,适合读写I/O任务。因为我这个时候任务是将通知内容写入数据库,所以我选择了这个调度器。

还有Dispatchers.Main调度器,Main这个一看就是主线程相关,这确实就是主调度器。因为主线程是UI线程,如果使用不当,导致主线程被堵塞,那应用就卡顿,十分影响用户体验,所以想使用主调度器,一定要谨慎。

suspend

          private suspend fun saveNotice(){
             ...
          }

suspend是挂起函数的声明关键字。当一个函数需要在协程中调用的时候,那这个函数则必须被声明为挂起函数。

最后补充一下Notifications

class Notifications : LitePalSupport() { //继承LitePalSupport,继承litepal的sql方法
        var id: Int? = null
        var title: String? = null
        var ticker: String? = null
        var category: String? = null
        var iconLevel: String? = null
        var visibility: String? = null
        var creatorPackage: String? = null
        var time: String? = null
        var channelId: String? = null
        var isUpload: String? = null
        var timeStamp: String? = null
        var appName: String? = null
    }

五、开始编写主页面

页面十分简单,能看到两个顶部菜单,一个占据大篇幅的列表,以及一个右下角悬浮的按钮。

两个顶部菜单是用于跳转设置页,让用户授权的。右下角的按钮,是用户控制弹幕开启或关闭的。

NoticeActivity (启动页)

class NoticeActivity : ComponentActivity() {

      private lateinit var viewModel: MainViewModel
      
      override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        //启动服务
        val intent = Intent(this, MyNotificationService::class.java) 
        startService(intent) 

        setContent {
            MyNoticeTheme {
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background,
                    tonalElevation = 5.dp
                ) {
                    Page()
                }
            }
        }
        //初始化viewModle,MainViewModel为自定义类
        viewModel = ViewModelProvider(this)[MainViewModel::class.java]
        //获取消息列表
        viewModel.initNotis() 
        //liveData观察者注册,监听list变化,触发界面刷新
        viewModel.nList.observe(this) {  //观察监听list变化,触发界面刷新
            if (it.size != 0) {
                viewModel.notiList.clear()
                viewModel.notiList = it as MutableList<Notifications>
            }
        }
    }

     //声明这个函数是个界面绘制方法
    @Composable 
    fun Page(modifier: Modifier = Modifier) {
        //Box,这是一个简单的布局组件,它可以将它内部的子组件堆叠在一起,可以覆盖或者并列显示
        Box(modifier = modifier.fillMaxSize()) {
             //Column 它能够将里面的子项按照从上到下的顺序垂直排列,类似于XML布局里面的竖向线性布局
             Column {    
                 //自定义界面方法
                 Menu()  
                 //mutableStateListOf函数,用于创建一个可观察的状态对象,此时用于创建可观测的Notifications对象
                 //remember函数用于在Composable重触发绘制的时候,保持住当前值。
                 //组合remember和mutableStateListOf使用,被修饰的对象可以实现状态响应。能实现类似于Vue的数据绑定,达到响应式编程的效果。
                 viewModel.notiList = remember { mutableStateListOf<Notifications>() } 
                 //自定义界面方法 
                 NotiList()
             }
             //compose里面的FloatingActionButton,作用效果与xml里面的FloatingActionButton一致
             LargeFloatingActionButton(
                //点击事件
                onClick = { },
                modifier = Modifier
                    .align(Alignment.BottomEnd)
                    .padding(32.dp),
                containerColor = MaterialTheme.colorScheme.tertiaryContainer,
                contentColor = MaterialTheme.colorScheme.onTertiaryContainer
            ) {
             //Button内部局部  
            }
        }
    }

    @Composable
    fun Menu(modifier: Modifier = Modifier) {
        Surface(
            shape = MaterialTheme.shapes.medium, shadowElevation = 5.dp,
            modifier = Modifier.padding(all = 18.dp)
        ) {
            Column(
                modifier = Modifier.background(MaterialTheme.colorScheme.background)
            ) {

                Row(
                    modifier = Modifier
                        .clickable {
                            //打开监听引用消息Notification access
                            val intent_s = Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)
                            startActivity(intent_s)
                        }
                        .padding(all = 15.dp),
                    verticalAlignment = Alignment.CenterVertically
                ) {
                    Text(
                        text = "通知设置",
                        modifier = Modifier
                            .padding(all = 1.dp)
                            .weight(1f),
                        style = MaterialTheme.typography.titleMedium,
                        maxLines = 1
                    )
                    Icon(
                        Icons.Default.ArrowForward,
                        contentDescription = "跳转进入",
                        modifier = Modifier
                            .padding(end = 4.dp)
                    )
                }

                Row(
                    modifier = Modifier
                        .clickable {
                            val intent_p =
                                Intent(
                                    Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
                                    Uri.parse("package:com.liuzi.mynotice")
                               )
                            startActivity(intent_p)
                            }
                        }
                        .padding(all = 15.dp),
                    verticalAlignment = Alignment.CenterVertically


                ) {

                    Text(
                        text = "应用设置",
                        modifier = Modifier
                            .padding(all = 1.dp)
                            .weight(1f),
                        style = MaterialTheme.typography.titleMedium,
                        maxLines = 1
                    )
                    Icon(
                        Icons.Default.ArrowForward,
                        contentDescription = "跳转进入",
                        modifier = Modifier
                            .padding(end = 4.dp)
                    )
                }
           }
        }
    }
    
    @Composable
    fun NotiList() {
        //竖向列表控件
        LazyColumn( 
            verticalArrangement = Arrangement.spacedBy(4.dp),
            contentPadding = PaddingValues(18.dp)
        ) {
            //列表子项遍历notilist,循环渲染自定义界面NotiCard
            items(viewModel.notiList) { no ->  
                NotiCard(no)   //
            }
        }
    }

     //循环列表的子项,具体每个通知的卡片
    @Composable
    fun NotiCard(no: Notifications) {
        Spacer(modifier = Modifier.height(8.dp))
        Surface(shape = MaterialTheme.shapes.medium, shadowElevation = 5.dp) {
            //Row,横向线性布局
            Row(
                modifier = Modifier
                     //点击时间,点击打开对应APP
                    .clickable {
                        val intent =
                            packageManager.getLaunchIntentForPackage(no.creatorPackage)
                        intent?.let { startActivity(it) }
                    }
                    .background(MaterialTheme.colorScheme.surfaceVariant)
                    .padding(start = 10.dp, end = 8.dp)
                    .fillMaxWidth(),
                verticalAlignment = Alignment.CenterVertically
            ) {
                //图片组件,用于显示对应通知消息的app图标
                Image(
                    painter =
                    if (no.creatorPackage != null)
                        rememberDrawablePainter(
                             //获取app图标
                            drawable = applicationContext.getPackageManager()
                                .getApplicationIcon(no.creatorPackage)
                        )
                    else
                        painterResource(
                            id = R.drawable.notifi
                        ),
                    contentDescription = null,
                    modifier = Modifier
                        .size(40.dp)
                        .clip(CircleShape)
                        .border(1.5.dp, MaterialTheme.colorScheme.secondary, CircleShape),
                    contentScale = ContentScale.Crop
                )
                Spacer(modifier = Modifier.width(8.dp))

                Column {
                    Text(
                        text = no.title ?: "未知",
                        modifier = Modifier.padding(all = 4.dp),
                        style = MaterialTheme.typography.bodyLarge,
                        maxLines = 1
                    )


                    Text(
                        text = no.ticker ?: "未知",
                        modifier = Modifier.padding(all = 4.dp),
                        color = MaterialTheme.colorScheme.secondary,
                        style = MaterialTheme.typography.bodyLarge,
                        maxLines = 1
                    )

                }
            }
        }
        Spacer(modifier = Modifier.height(8.dp))
    }
}

MainViewModel

什么是ViewModel呢?这个其实并不是Compose引入的新机制,这是Android已经提供的一种开发机制。这与UI工具使用Compose还是XML无关,可能很多大佬在之前已经用过。

在初学Android开发的时候,我们会将一个活动的UI交互,UI渲染刷新,业务逻辑处理,数据请求处理等一系列逻辑,全部写在当前的Activity类中。

但理论上,这样做不符合“单一责任”原则。Activity类只应该负责UI交互,UI渲染刷新。其他的逻辑处理,数据请求应该单独存放和处理。

ViewModel类就是为此出现的,专门用于处理、和存放应用程序页面所需的数据,将Activity释放出来,只需要处理UI交互,UI渲染的工作。

将Activity拆分出ViewModel还有一个好处,那就是当应用遇到横竖屏切换,或者夜间模式切换的时候,Activity是会触发生命周期重启的,除非把数据使用savedInstanceState保存,否则生命周期重启的时候,用户未被保存的数据会被清空。

而在此时,ViewModel的生命周期,是不受Activity的生命周期影响。也就是说,当手机横竖屏切换时,虽然Activity重启了,但是由于数据所在的对象在ViewModel中,所以数据并不会丢失。

class MainViewModel : ViewModel() {
    //创建LiveData对象
    private var _notiList = MutableLiveData<List<Notifications>>()

    private val TAG = "MainViewModel"

    var notiList = mutableListOf<Notifications>()

    val nList: LiveData<List<Notifications>>
        get() = _notiList

    init {
        //viewmodel对象创建初始化时,执行init
    }

       //提供从数据库获取通知数据的方法
    fun initNotis() {
        //调用litepal方法,获取最新的1000条通知消息
        val notisList = LitePal.order("time desc").limit(1000).find<Notifications>()
        //postValue用于更新数据内容
        _notiList.postValue(notisList)
    }

}

这里大概内容大家应能理解,比较陌生的,应该只有是修饰List的LiveData。

LiveData 是Jetpack的一个组件,是一个可被观察的数据存储器类, 具有感知组件生命周期的能力。当 LiveData 保存的数据发生变化时就会通知观察者,观察者接收到通知后可以进行 UI 数据刷新或者其他操作。
我们在Activity中,就注册了对_notiList对象的观察。所以ViewModel这里的数据更新,会被Acitivty获取到,开展UI刷新的操作。

六、总结

本次我们完成了两个部分的内容:
1、创建了一个service,继承NotificationListenerService,获得到了系统通知的数据。
2、使用Compose绘制了主页,能展示通知记录。其中使用了ViewModel和LiveData,将数据保存和业务逻辑与UI交互进行了拆分。

项目使用NotificationListenerService,还需要做一些配置,我在这里说明一下。

在AndroidMenifest中,对service的注册内容,补充声明permission和filter。

        <service
            android:name=".service.MyNotificationService"
            android:exported="true"
            android:label="通知监控"
            android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"
            <intent-filter>
                <action android:name="android.service.notification.NotificationListenerService" />
            </intent-filter>
        </service>

还有就是,记得我们Activity的UI里面,放了两个菜单还记得吗。程序运行后,记得分别点一下,进入系统设置里,授权一下程序的通知权限。

OK,程序应该可以运行了,数据都可以拿到了并保存在本地sqlite了,那下一篇我们开始把通知做成实时弹幕!

  • 18
    点赞
  • 26
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
Kotlin是一种静态类型的编程语言,具有JVM的可移植性和Java的互操作性。Spring Boot是一个用于创建独立的、基于Spring框架的Java应用程序的框架,它提供了快速开发应用程序所需的所有功能。JavaFX是一个用于创建丰富客户端应用程序的框架,它提供了丰富的UI组件和布局管理器。 要使用Kotlin Spring Boot和JavaFX开发桌面应用程序,需要完成以下步骤: 1. 创建一个Kotlin Spring Boot项目。可以使用Spring Initializr创建项目,选择Kotlin和Spring Web依赖项。 2. 添加JavaFX依赖项。可以在pom.xml文件中添加以下依赖项: ``` <dependency> <groupId>org.openjfx</groupId> <artifactId>javafx-controls</artifactId> <version>16</version> </dependency> <dependency> <groupId>org.openjfx</groupId> <artifactId>javafx-fxml</artifactId> <version>16</version> </dependency> ``` 3. 创建一个JavaFX应用程序类。可以使用JavaFX的Application类作为应用程序的入口点。在这个类中,可以创建UI组件,处理事件和管理应用程序的状态。以下是一个简单的JavaFX应用程序类的示例: ```kotlin import javafx.application.Application import javafx.fxml.FXMLLoader import javafx.scene.Parent import javafx.scene.Scene import javafx.stage.Stage class MyApplication : Application() { override fun start(primaryStage: Stage?) { val root: Parent = FXMLLoader.load(javaClass.getResource("/fxml/main.fxml")) primaryStage?.title = "My Application" primaryStage?.scene = Scene(root) primaryStage?.show() } companion object { @JvmStatic fun main(args: Array<String>) { launch(MyApplication::class.java, *args) } } } ``` 4. 创建FXML布局文件。FXML是一种XML格式的文件,用于定义UI组件和布局。可以使用Scene Builder或手动创建FXML文件。以下是一个简单的FXML布局文件的示例: ```xml <?xml version="1.0" encoding="UTF-8"?> <?import javafx.scene.control.Button?> <?import javafx.scene.layout.AnchorPane?> <AnchorPane xmlns:fx="http://javafx.com/fxml/1" fx:id="root" prefHeight="400" prefWidth="600"> <Button fx:id="button" text="Click me" layoutX="250" layoutY="180" /> </AnchorPane> ``` 5. 在JavaFX应用程序类中加载FXML布局文件。可以使用FXMLLoader类加载FXML布局文件,并将其添加到应用程序的场景图中。以下是一个示例: ```kotlin val root: Parent = FXMLLoader.load(javaClass.getResource("/fxml/main.fxml")) primaryStage?.title = "My Application" primaryStage?.scene = Scene(root) primaryStage?.show() ``` 6. 处理UI事件。可以在JavaFX应用程序类中添加事件处理程序,以响应UI组件的事件。以下是一个处理按钮单击事件的示例: ```kotlin button.setOnAction { event -> println("Button clicked!") } ``` 7. 使用Spring Boot管理应用程序的状态。可以使用Spring Boot的依赖注入和管理功能来管理应用程序的状态和依赖关系。可以在Spring Boot的配置类中定义bean,然后在JavaFX应用程序类中使用它们。以下是一个简单的Spring Boot配置类的示例: ```kotlin @Configuration class AppConfig { @Bean fun myService(): MyService { return MyService() } } ``` 8. 在JavaFX应用程序类中使用Spring Boot的依赖注入功能。可以在JavaFX应用程序类的构造函数中注入Spring Boot管理的bean。以下是一个示例: ```kotlin class MyApplication : Application() { @Autowired lateinit var myService: MyService // ... } ``` 这就是使用Kotlin Spring Boot和JavaFX开发桌面应用程序的基本步骤。当然,还有很多其他的细节和技术,可以根据需要进行学习和应用。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值