Android 组件化 之 如何优雅的实现同级组件的通信

Android 关于如何实现组件化的文章很多,涉及的内容也很广。
如何实现引入库的统一,如何单module编译,如何做好业务拆分和解耦。
本篇主要想在组件通讯方面探讨实现方案。


前言

google了一下,虽然限定时间2014年搜关键字「Android 组件化」也能搜出结果,但是「Android 组件化」文章的爆发期是从2015、2016年开始的。

组件化也有很多种划分办法,目前流行的分法是这么分的。
在这里插入图片描述

底层:一般是一个 lib_base 库。
中间业务层:根据功能划分,比如用户组件 lib_user,广告组件 lib_advertisement,阅读组件 lib_reader,等等诸如此类按功能划分的组件。
上层:app组件,在这里能真正的打出一个apk。

其中中间层这几个业务组件就算是同级组件了。它们没有依赖关系,但因为业务需要,它们又需要有相互调用的能力。

比如有一个业务场景是,点击一个广告的View,要判断用户有没有登录,拿到用户信息,没有登录的话跳转到登录页面。广告和用户是两个同级的、平行的组件,没有相互依赖,原本是不能相互调用的。

所以如何优雅的同级组件的通信,实现代码解耦是个值得思考的问题。





——————————————————————————————

方法1 纯原生

如果对 ARouter 熟的话,就会知道 ARouter 可以实现这个功能。利用它们的「通过依赖注入解耦:服务管理」。

我特意去找了一下ARouter的第一个Release Tag版本,是在2017年1月4日。
在这里插入图片描述
ARouter的服务管理暂且不谈,我想说的是,在我当年17年4月份入职的一家公司,里面的大佬已经很熟悉的运用组件化技术,并且能很好的处理平级组件之间的通信问题了。下面说说那些大佬是怎么写的。

1.胶水类 AdvertisementGlue 与 IAdvertisementGlue

package com.yao.lib_advertisement

import android.app.Activity
import java.util.*

object AdvertisementGlue {

    var advertisement: IAdvertisementGlue? = null

    fun openLoginActivity(activity: Activity) {
        advertisement?.openLoginActivity(activity)
    }

    fun loadUserConfig(): List<String> {
        return advertisement?.let {
            advertisement?.loadUserConfig()
        } ?: Collections.emptyList()
    }
}

interface IAdvertisementGlue {

    fun openLoginActivity(activity: Activity)

    fun loadUserConfig(): List<String>
}

首先这是在广告组件里的类文件。
文件里面有个内部接口 IAdvertisementGlue,定义了一些方法,这些方法都是需要依赖到同级组件的。
再有一个主类,在kotlin里就是单例 Object AdvertisementGlue 类。在Java里就是 class AdvertisementGlue 类,但里面都是静态方法。
类里面持有一个 IAdvertisementGlue 的实现类作为成员变量,两个方法的处理是对 advertisement 进行判空,然后调用它的实现。

2.胶水实现 AdvertisementGlueImpl

在 app 组件里,为接口 IAdvertisementGlue 写它的实现类 AdvertisementGlueImpl。

package com.yao.componentdemo

import android.app.Activity
import android.content.Intent
import com.yao.lib_advertisement.IAdvertisementGlue
import com.yao.lib_user.LoginActivity
import com.yao.lib_user.UserManager

object AdvertisementGlueImpl: IAdvertisementGlue {

    override fun openLoginActivity(activity: Activity) {
        activity.startActivity(Intent(activity, LoginActivity::class.java))
    }

    override fun loadUserConfig(): List<String> {
        return UserManager.currentUser?.channel ?: emptyList()
    }
}

由于 app 组件是引入了 lib_advertisement 和 lib_user,所以它可以访问到这两个组件。

3.初始化和调用

在使用到该广告模块之前,先给胶水类里面的实现类赋值,赋值可以在 application 或者 打开组件页面前。

AdvertisementGlue.advertisement = AdvertisementGlueImpl

真正调用的地方

package com.yao.lib_advertisement

import android.os.Bundle
import android.view.View
import android.widget.Button
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import com.alibaba.android.arouter.facade.annotation.Route
import com.yao.lib_base.RouterHub
import com.yao.lib_base.Util.print
import org.greenrobot.eventbus.EventBus

class Demo1NormalActivity : AppCompatActivity(), View.OnClickListener {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_demo_simple)

        title = "正常的(无添加的)调用"

        findViewById<Button>(R.id.btn_1).setOnClickListener(this)
        findViewById<Button>(R.id.btn_2).setOnClickListener(this)
    }

    override fun onClick(v: View?) {
        when (v?.id) {
            R.id.btn_1 -> {
                AdvertisementGlue.openLoginActivity(this)
            }
            R.id.btn_2 -> {
                val channelList: List<String> = AdvertisementGlue.loadUserConfig()
                Toast.makeText(this, channelList.print(), Toast.LENGTH_LONG).show()
            }
        }
    }

}

声明成kotlin的单例,或者是java全部使用静态方法的工具类,这样使用起来就很方便了。


小结

这套方案不需要使用任何库,纯原生无添加就实现了。结构上是依赖上层app组件,在app组件里去实现ad组件的接口,调用user组件的代码。
在这里插入图片描述





——————————————————————————————

方法2.1 ARouter的服务管理(我最开始的类放置方法)

刚开始看ARouter服务管理的文档,我还带着我以前用的胶水类的思考去看的。所以我是这么写的

1.接口类 AdvertisementServiceInAd

在广告组件里声明接口,继承 iProvider接口。

package com.yao.lib_advertisement

import android.app.Activity
import com.alibaba.android.arouter.facade.template.IProvider

interface AdvertisementServiceInAd: IProvider {

    fun openLoginActivity(activity: Activity)

    fun loadUserConfig(): List<String>
}

2.实现类 AdvertisementServiceInAdImpl

在app组件里写实现类,用上ARouter的注解。由于app引入了用户组件,所以可以使用用户组件的代码。

package com.yao.componentdemo

import android.app.Activity
import android.content.Context
import android.content.Intent
import android.util.Log
import com.alibaba.android.arouter.facade.annotation.Route
import com.yao.lib_advertisement.AdvertisementServiceInAd
import com.yao.lib_base.RouterHub
import com.yao.lib_user.LoginActivity
import com.yao.lib_user.UserManager

@Route(path = RouterHub.ADVERTISEMENT_SERVICE_IN_AD, name = "在Ad模块声明的广告服务")
class AdvertisementServiceInAdImpl: AdvertisementServiceInAd {

    override fun init(context: Context?) {
        Log.e("YAO", "AdvertisementServiceInBaseImpl init")
    }

    override fun openLoginActivity(activity: Activity) {
        activity.startActivity(Intent(activity, LoginActivity::class.java))
    }

    override fun loadUserConfig(): List<String> {
        return UserManager.currentUser?.channel ?: emptyList()
    }

}

3.使用

使用ARouter的注解,为成员变量 AdvertisementServiceInAd 赋值,就可以使用了。

package com.yao.lib_advertisement

import android.os.Bundle
import android.view.View
import android.widget.Button
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import com.alibaba.android.arouter.facade.annotation.Autowired
import com.alibaba.android.arouter.facade.annotation.Route
import com.alibaba.android.arouter.launcher.ARouter
import com.yao.lib_base.RouterHub
import com.yao.lib_base.Util.print

@Route(path = RouterHub.DEMO_2_1)
class Demo2ARouter1Activity : AppCompatActivity(), View.OnClickListener {

    // 使用依赖注入为服务赋值,通过注解标注字段,即可使用,无需主动获取。
    @JvmField
    @Autowired()
    var advertisementServiceInAd: AdvertisementServiceInAd? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_demo_simple)
        ARouter.getInstance().inject(this);

        title = "ARouter注入方式调用(使用上层模块写实现)"

        findViewById<Button>(R.id.btn_1).setOnClickListener(this)
        findViewById<Button>(R.id.btn_2).setOnClickListener(this)
    }

    override fun onClick(v: View?) {
        when (v?.id) {
            R.id.btn_1 -> {
                advertisementServiceInAd?.openLoginActivity(this)
            }
            R.id.btn_2 -> {
                val channelList: List<String> = advertisementServiceInAd?.loadUserConfig() ?: emptyList()
                Toast.makeText(this, channelList.print(), Toast.LENGTH_LONG).show()
            }
        }
    }
}

小结

相比起上面纯原生无添加的方案少了2个步骤,非常解耦了。使用父类引用+注入子类实现的方式,也是非常方便。
在这里插入图片描述





——————————————————————————————

方法2.2 ARouter的服务管理 (更合适的类放置方法)

上面说过 刚开始看ARouter我还带着我以前用的胶水类的思考去写的。实际上,ARouter服务管理的一种更好的写法应该是这样的。

1.接口类 AdvertisementServiceInBase (定义在 lib_base 组件里)

应该把接口定义下沉到base组件里

package com.yao.lib_base

import android.app.Activity
import com.alibaba.android.arouter.facade.template.IProvider

interface AdvertisementServiceInBase: IProvider {

    fun openLoginActivity(activity: Activity)

    fun loadUserConfig(): List<String>
}

2.实现类 AdvertisementServiceInAdImpl(定义在 lib_user 用户组件里)

在用户组件里写具体实现

package com.yao.lib_user

import android.app.Activity
import android.content.Context
import android.content.Intent
import android.util.Log
import com.alibaba.android.arouter.facade.annotation.Route
import com.yao.lib_base.AdvertisementServiceInBase
import com.yao.lib_base.RouterHub

@Route(path = RouterHub.ADVERTISEMENT_SERVICE_IN_BASE, name = "在Base模块声明的广告服务")
class AdvertisementServiceInBaseImpl : AdvertisementServiceInBase {

    override fun init(context: Context?) {
        Log.e("YAO", "AdvertisementServiceInBaseImpl init")
    }

    override fun openLoginActivity(activity: Activity) {
        activity.startActivity(Intent(activity, LoginActivity::class.java))
    }

    override fun loadUserConfig(): List<String> {
        return UserManager.currentUser?.channel ?: emptyList()
    }

}

3.使用

使用跟上面的一样

package com.yao.lib_advertisement

import android.os.Bundle
import android.view.View
import android.widget.Button
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import com.alibaba.android.arouter.facade.annotation.Autowired
import com.alibaba.android.arouter.facade.annotation.Route
import com.alibaba.android.arouter.launcher.ARouter
import com.yao.lib_base.*
import com.yao.lib_base.Util.print

@Route(path = RouterHub.DEMO_2_2)
class Demo2ARouter2Activity : AppCompatActivity(), View.OnClickListener {

    // 使用依赖注入为服务赋值,通过注解标注字段,即可使用,无需主动获取。
    @JvmField
    @Autowired()
    var advertisementServiceInBase: AdvertisementServiceInBase? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_demo_complex)
        ARouter.getInstance().inject(this);

        title = "ARouter注入方式调用(使用下层模块写接口)"

        findViewById<Button>(R.id.btn_1).setOnClickListener(this)
        findViewById<Button>(R.id.btn_2).setOnClickListener(this)
    }

    override fun onClick(v: View?) {
        when (v?.id) {
            R.id.btn_1 -> {
                advertisementServiceInBase?.openLoginActivity(this)
            }
            R.id.btn_2 -> {
                val channelList: List<String> = advertisementServiceInBase?.loadUserConfig() ?: emptyList()
                Toast.makeText(this, channelList.print(), Toast.LENGTH_LONG).show()
            }
        }
    }
}

小结

看了一下ARouter的官方的demo,没有讲类放置的方法。但是我自己比较起来,把接口定义放在base组件更合适,这样可以让实现类写在用户组件里。
在这里插入图片描述





——————————————————————————————

2.3 ARouter的服务管理 (更合适的接口定义)

上面讲的是,把「广告组件里需要调用到同级组件(用户组件)的功能」封装成一个接口类,这个接口放在base组件里。
其实更合适的功能定义,应该是把「这个组件能提供的能力」封装成一个接口类放到base组件里。在这个例子里,就是用户组件能提供的能力。

1.接口类 UserService (定义用户组件对外提供的能力)

package com.yao.lib_base

import android.app.Activity
import com.alibaba.android.arouter.facade.template.IProvider
import io.reactivex.Observable
import io.reactivex.Single

interface UserService: IProvider {

    fun openLoginActivity(activity: Activity)

    fun loadUserConfig(): List<String>

    fun getVipLevel(): Int

    fun openVipActivity(activity: Activity)

    // 观察者模式回调
    fun registerUserInfoUpdate(callback: UserInfoUpdateCallback)

    // 网络请求式异步
    fun requestUserInfoUpdate(): Observable<String>
}


interface UserInfoUpdateCallback {
    fun onUserInfoUpdate(jsonString: String)
}

2.实现类 UserServiceImpl(在用户组件里,写能力的具体实现)

package com.yao.lib_user

import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.util.Log
import com.alibaba.android.arouter.facade.annotation.Route
import com.yao.lib_base.GsonUtil
import com.yao.lib_base.RouterHub
import com.yao.lib_base.UserInfoUpdateCallback
import com.yao.lib_base.UserService
import io.reactivex.Observable
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.functions.Consumer
import io.reactivex.internal.operators.flowable.FlowableReplay.observeOn
import java.util.concurrent.TimeUnit
import kotlin.random.Random

@Route(path = RouterHub.USER_SERVER, name = "lib_user模块提供出来的服务")
class UserServiceImpl : UserService {

    private val userInfoUpdateCallbackList = mutableListOf<UserInfoUpdateCallback>()

    override fun init(context: Context?) {
        Log.e("YAO", "UserServiceImpl init")
    }

    override fun openLoginActivity(activity: Activity) {
        activity.startActivity(Intent(activity, LoginActivity::class.java))
    }

    override fun loadUserConfig(): List<String> {
        return UserManager.currentUser?.channel ?: emptyList()
    }

    override fun getVipLevel(): Int {
        return UserManager.currentUser?.vipLevel ?: 0
    }

    override fun openVipActivity(activity: Activity) {
        activity.startActivity(Intent(activity, VipActivity::class.java))
    }

    @SuppressLint("CheckResult")
    override fun registerUserInfoUpdate(callback: UserInfoUpdateCallback) {
        if (!userInfoUpdateCallbackList.contains(callback)) {
            userInfoUpdateCallbackList.add(callback)
        }

        // 模拟事件触发
        Observable
            .interval(1, TimeUnit.SECONDS)
            .take(1)
            .observeOn(AndroidSchedulers.mainThread())
            .subscribe(Consumer {
                val userInfo = UserManager.currentUser?.let {
                    it.experience += 10
                    it
                }
                for (item in userInfoUpdateCallbackList) {
                    item.onUserInfoUpdate(GsonUtil.toJson(userInfo))
                }
            })
    }

    override fun requestUserInfoUpdate(): Observable<String> {
        val userInfo = UserManager.currentUser?.let {
            it.experience += 10
            it
        }

        // 模拟网络请求
        return Observable
            .timer(300L + Random.nextInt(700), TimeUnit.MILLISECONDS)
            .map {
                GsonUtil.toJson(userInfo)
            }
            .observeOn(AndroidSchedulers.mainThread())
    }
}

3.使用

package com.yao.lib_advertisement

import android.os.Bundle
import android.view.View
import android.widget.Button
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import com.alibaba.android.arouter.facade.annotation.Autowired
import com.alibaba.android.arouter.facade.annotation.Route
import com.alibaba.android.arouter.launcher.ARouter
import com.yao.lib_base.*
import com.yao.lib_base.Util.print
import io.reactivex.functions.Consumer

@Route(path = RouterHub.DEMO_2_2)
class Demo2ARouter2Activity : AppCompatActivity(), View.OnClickListener {

    // 使用依赖注入为服务赋值,通过注解标注字段,即可使用,无需主动获取。
    @JvmField
    @Autowired()
    var advertisementServiceInBase: AdvertisementServiceInBase? = null

    @JvmField
    @Autowired()
    var userService: UserService? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_demo_complex)
        ARouter.getInstance().inject(this);

        title = "ARouter注入方式调用(使用下层模块写接口)"

        findViewById<Button>(R.id.btn_1).setOnClickListener(this)
        findViewById<Button>(R.id.btn_2).setOnClickListener(this)
        findViewById<Button>(R.id.btn_3).setOnClickListener(this)
        findViewById<Button>(R.id.btn_4).setOnClickListener(this)
        findViewById<Button>(R.id.btn_5).setOnClickListener(this)
        findViewById<Button>(R.id.btn_6).setOnClickListener(this)
    }

    override fun onClick(v: View?) {
        when (v?.id) {
            R.id.btn_1 -> {
                advertisementServiceInBase?.openLoginActivity(this)
            }
            R.id.btn_2 -> {
                val channelList: List<String> = advertisementServiceInBase?.loadUserConfig() ?: emptyList()
                Toast.makeText(this, channelList.print(), Toast.LENGTH_LONG).show()
            }
            R.id.btn_3 -> {
                val level = userService?.getVipLevel() ?: 0
                Toast.makeText(this, "会员等级:$level", Toast.LENGTH_LONG).show()
            }
            R.id.btn_4 -> {
                userService?.openVipActivity(this)
            }
            R.id.btn_5 -> {
                userService?.registerUserInfoUpdate(userInfoUpdateCallback)
            }
            R.id.btn_6 -> {
                userService
                    ?.requestUserInfoUpdate()
                    ?.subscribe {
                        Toast.makeText(
                            this@Demo2ARouter2Activity,
                            "请求用户信息更新:$it",
                            Toast.LENGTH_SHORT
                        ).show()
                    }
            }
        }
    }

    private val userInfoUpdateCallback = object : UserInfoUpdateCallback {
        override fun onUserInfoUpdate(jsonString: String) {
            Toast.makeText(this@Demo2ARouter2Activity, "用户信息更新回调:$jsonString", Toast.LENGTH_SHORT).show()
        }
    }
}

小结

这是最终版本的同级组件通讯设计了。
用户组件不仅仅为广告组件提供打开登录页面,获取用户信息功能。
还可能有为其他组件提供获取会员等级,打开会员页面。比如阅读组件,只有VIP会员才能阅读最新章节。

为了测试这种方案,还写了一个用户信息更新的监听回调,传一个Callback过去,用户信息有更新后可以收到回调。
还写了一个使用rxjava的请求用户信息的异步网络方法。都没什么问题。
在这里插入图片描述





——————————————————————————————

2.4 ARouter的服务管理 (还能优化的空间)

总的来说,同级组件通信,不是依赖一个上层的app组件,就是依赖一个下层的base组件。想上下都不依赖那是不行的。
但是这种做法,会搞的base组件有各种业务的接口类。任何业务模块增、删对外提供的功能,或者改下参数、实体类,都需要改到base组件的代码。

所以我搜索资料看能不能优化这个问题。看到了一个这个讨论
多模块下,可以把每个模块的内容放到模块内而不下沉到base module吗
在这里插入图片描述
这个回答的末尾,zgq105兄弟讲了一种设计方案,下面zhi1ong兄弟,是ARouter第一作者,对这种方案表示了认同。




所以最终的设计应该是这样的。
从用户组件里抽取出一些对外提供能力的接口放在 lib_user_export 组件里。
还有一些实体类,比如User类,里面的字段可能就有几十个了。一些业务组件强相关的工具类,比如筛选计算出用户的阅读了多少个VIP作品。也可以放到 lib_user_export 里,供同级模块使用。
这样同等级的其他业务组件就不需要依赖具体的实现类,也完全做到与base组件解耦了。
在这里插入图片描述





——————————————————————————————

附加话题1

说到依赖注入,我想到了dagger2这个库。想尝试用这个实现为父类接口引用+注入子类实现类的方式。发现行不通。
因为要在广告模块写好@Inject,还要使用到 @Module 和 @Provider 指定接口应该提供哪个实现类,这就要求在广告模块访问到子类实现类。属于调用到同级业务模块代码了。

@Module
class UserModule {

    @Provides
    fun providerUserDaggerService(): UserDaggerService {
        return UserDaggerServiceImpl()
    }
}

附加话题2

之前有群友调侃到「如果你不知道怎么解耦,就使用EventBus」「万物皆可EventBus」,好奇的态度写了下代码感受一下。

1.Event类

因为发消息和收需要用到,又是跨组件,所以要写在base组件了。

package com.yao.lib_base

class OpenLoginActivityEvent() {

}

class LoadUserConfigEvent(var callback: CallbackStringList) {

}


// 各种回调 -------------------------
interface CallbackInt {
    fun onCallback(int :Int)
}

interface CallbackString {
    fun onCallback(string :String)
}

interface CallbackStringList {
    fun onCallback(list :List<String>)
}
2.调用方

打开一个登录页面,就是发一个Event。加载用户消息,发一个Event并且还有传一个Callback过去接受数据回调。

package com.yao.lib_advertisement

import android.os.Bundle
import android.view.View
import android.widget.Button
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import com.alibaba.android.arouter.facade.annotation.Route
import com.yao.lib_base.CallbackStringList
import com.yao.lib_base.LoadUserConfigEvent
import com.yao.lib_base.OpenLoginActivityEvent
import com.yao.lib_base.RouterHub
import com.yao.lib_base.Util.print
import org.greenrobot.eventbus.EventBus

@Route(path = RouterHub.DEMO_4)
class Demo4EventBusActivity : AppCompatActivity(), View.OnClickListener {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_demo_simple)

        title = "EventBus 方式调用"

        findViewById<Button>(R.id.btn_1).setOnClickListener(this)
        findViewById<Button>(R.id.btn_2).setOnClickListener(this)
    }

    override fun onClick(v: View?) {
        when (v?.id) {
            R.id.btn_1 -> {
                EventBus.getDefault().post(OpenLoginActivityEvent())
            }
            R.id.btn_2 -> {
                EventBus.getDefault().post(LoadUserConfigEvent(object : CallbackStringList {
                    override fun onCallback(list: List<String>) {
                        Toast.makeText(this@Demo4EventBusActivity, list.print(), Toast.LENGTH_SHORT).show()
                    }
                }))
            }
        }
    }
}
3.提供方

想着在用户组件了写业务提供方,发现 EventBus 要注册啊,会常驻在内存啊。用户页面如果没打开,没有类可以给我注册啊。写个Service?那也太重了。
妥协写在了永远不会关闭的MainActivity里,发现又要用上app组件了。
另外一个缺点是,如果回调数据是一个实体UserInfo,还是摆脱不了广告组件要依赖用户组件的关系。

package com.yao.componentdemo

class MainActivity : AppCompatActivity(), View.OnClickListener {

    ...

    @Subscribe(threadMode = ThreadMode.MAIN)
    fun onMessageEvent(event: OpenLoginActivityEvent) {
        startActivity(Intent(this, LoginActivity::class.java))
    }

    @Subscribe(threadMode = ThreadMode.MAIN)
    fun onMessageEvent(event: LoadUserConfigEvent) {
        event.callback.onCallback(UserManager.currentUser?.channel ?: emptyList())
    }
}





——————————————————————————————

最终总结

可以看到 「方法2.3」是已经是适合大部分场景的同级组件通信方案设计了。
如果公司的业务组件真的很多,或者需要打出aar包给别的部门使用,可以使用更完善的方案「方法2.4」。


但写代码这么多年,一个重要感受是「不要过度封装!」选择适合自己的就好。





——————————————————————————————

最后附上Component demo链接
  • 2
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 4
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值