业务代码参数透传满天飞?(二)

16 篇文章 0 订阅

引子

项目中参数多级透传满天飞的情况很常见,增加了开发的复杂度、出错的可能、及维护的的难度。

透传包括两种形式:

  1. 不同界面之间参数透传。
  2. 同一界面中不同层级控件间透传。

该系列的目标是消除这两种参数透传,使得不同界面以及同一界面内各层级间更加解耦,降低参数传递开发的复杂度,减少出错的可能,增加可维护性。

上一篇通过向前查询参数的方式解决了第一个 case,本篇先聚焦在第二个 case,即同一界面不同层级控件间的参数透传。

透传举例

比如下面这个场景:

特效卡片的点击事件需要传入私参“type”,以表示属于哪个 tab 页。

界面层级如下:素材集市用 EffectActivity 来承载,其中的标签栏下方是一个子 Fragment ,其中包含了 ViewPager 控件,该控件内部的每一个页又是一个 Fragment。

埋点私参和上报时机分处于两个不同的页面层级。上报时机在最内层 Fragment 触发,而私参在 Activity 层级生成,遂需通过两层 Fragment 的透传。

于是就会出现如下代码:

// EffcetActivity.kt
override fun showEffectListContent(index: Int, from: String?) {
    mVpEffectContent.adapter = SimpleFragmentStatePagerAdapter(supportFragmentManager).apply {
        mTitles = arrayOf("视频库", "音乐", "音效", "贴纸", "转场", "特效", "滤镜", "背景", "字幕", "字体")
        mCount = mTitles!!.size
        createFragment = { position ->
            when (position) {
                0 -> {
                    val fragment = RemoteCenterFragment.newInstance( -1, true, 0, MaterialProtocol.SOURCE.MATERIAL_MARKET, 1, from, 0, 0)
                    val paramsParser = DeepLinkParamsParser(compileDeepLinkParams())
                    (fragment as IDeepLinkPage).setDeepLinkParams(paramsParser.deeplinkParams)
                    fragment
                }
                1 -> {
                    MaterialMusicFragment.newInstance(from,mSelectedTabId,mSelectedModelId)
                }
                2 -> {
                    MaterialAudioFragment.newInstance(mSelectedTabId,mSelectedModelId)
                }
                // 索引值到类型值的映射
                3 -> {
                    EffectListFragment.newInstance(CommonConstant.SERVER.TYPE_VSTICKER)
                }
                4 -> {
                    EffectListFragment.newInstance(CommonConstant.SERVER.TYPE_TRANSITION)
                }
                5 -> {
                    EffectListFragment.newInstance(CommonConstant.SERVER.TYPE_EFFECT)
                }
                6 -> {
                    EffectListFragment.newInstance(CommonConstant.SERVER.TYPE_FILTER)
                }
                7 -> {
                    EffectListFragment.newInstance(CommonConstant.SERVER.TYPE_BACKGROUND)
                }
                8 -> {
                    EffectListFragment.newInstance(CommonConstant.SERVER.TYPE_SUBTITLE)
                }
                else -> {
                    EffectListFragment.newInstance(CommonConstant.SERVER.TYPE_FONT)
                }
            }
        }
    }
    mVpEffectContent.currentItem = index
    mVpEffectContent.offscreenPageLimit = 10
    mTlEffectTabs.setupWithViewPager(mVpEffectContent)
}

EffectListFragment即使承载 ViewPager 的 Fragment,上述代码在构建其实例时做了分类讨论,目的是为了根据不同类型的 tab 透传相应的 type 值。

EffectListFragment 不得不先接受透传参数并继续传递到下一个层级:

class EffectListFragment : BaseMvpFragment{
    // 保存透传参数的变量
    private var mCurrentType: Int = -1
    override fun initConfig(savedInstanceState: Bundle?) {
        // 获取透传参数
        mCurrentType = arguments?.getInt(CommonConstant.EFFECTCENTER.TYPE) ?: 0
        selectHotTab()
        showEffectDetails()
    }

    override fun showEffectDetails() {
        mVpEffectDetails?.adapter = SimpleFragmentPagerAdapter(childFragmentManager).apply {
            mCount = 1
            createFragment = { position ->
                // 参数继续透传到下一个层级
                EffectDetailsFragment.newInstance(mCurrentType, CommonConstant.EFFECTCENTER.ORDER_HOT).also {
                    currentFragments[0] = it
                }
            }
        }
    }

    companion object {
        fun newInstance(type: Int): EffectListFragment {
            // 参数透传
            return EffectListFragment().apply {
                arguments = Bundle().apply { putInt(CommonConstant.EFFECTCENTER.TYPE, type) }
            }
        }
    }

最后接受并消费透传 type 的是 EffectDetailsFragment,即纵向滚动列表的承载页:

class EffectDetailsFragment : BaseMvpFragment<EffectDetailsContract.IView, EffectDetailsPresenter>() {
    // 保存透传参数的变量
    private var mCurrentType: Int = 0
    override fun initConfig(savedInstanceState: Bundle?) {
        // 接受透传参数
        mCurrentType = arguments?.getInt(CommonConstant.EFFECTCENTER.TYPE) ?: 0
        mOrder = arguments?.getInt(ORDER) ?: 0
        initDetailsContent()
    }

    override fun initEvent() {
        mTvNetworkRetry.setOnClickListener(this)
        mEffectDetailsAdapter.addOnItemClickListener(object : EffectDetailsAdapter.OnItemClickListener {
            override fun onItemClick(entity: EffectDataEntity?) {
                entity?.let {
                    // 消费透传参数,上报埋点
                    StudioReport.reportClickAlbumClick(it.type, it.id, mOrder, "all", mCurrentType)
                }
            }
        })
    }

    companion object {
        const val ORDER: String = "order"
        fun newInstance(type: Int, order: Int): EffectDetailsFragment {
            return EffectDetailsFragment().apply {
                // 接收透传
                arguments = Bundle().apply {
                    putInt(CommonConstant.EFFECTCENTER.TYPE, type)
                    putInt(ORDER, order)
                }
            }
        }
    }
}

整个界面层级以及参数传递路径如下:

Activity 中有一个 Fragment,而它内部又嵌套了一个 Fragment。

中间的 Fragment 很无辜,因为它并不需要消费 type 参数,而只是做一个快递员。

当前只有两层,如果层级再增多,因此而增加的复杂度和工作量让人难以接受。

向上查询

如果把上述传参的方式叫做 “自顶向下透传” 的话,下面要介绍的这个方案可以称为 “自底向上查询”

自顶向下透传是容易实现的,因为父亲总是持有孩子的引用,向孩子注入参数轻而易举。

有没有一种方案可以实现反向的参数查询,即当孩子触发埋点事件时,逐级向上查询父亲生成的参数。

Android 中的控件是持有父亲的:

// android.view.View.java
public final ViewParent getParent() {
    return mParent;
}

通过一个循环不停地获取当前控件的父控件,就能从 View 树的叶子结点遍历到树根:

var viewParent: View?
do {
    viewParent = viewParent?.parent as? View
} while(viewParent != null)

对于 Activity 来说,树根就是 DecorView。对于 Fragment 来说,树根就是 onCreateView() 中创建的视图。

Fragment 最终会以一个 View 的形式嵌入到 Activity 的 View 树中。所以对于当个 Activity 来说,不管嵌套几层 Fragment,其视图结构最终都可以归为一棵 View 树。

如何让 Activity View 树中的每一个控件都能携带业务参数?

需要定义一个接口:

// 可跟踪的结点
interface TrackNode {
    fun fillTrackParams(): HashMap<String, String>?
}

为 View 新增一个扩展属性,让每个控件都持有一个 TrackNode:

var View.trackNode: TrackNode?
    get() = this.getTag(R.id.spm_id_tag) as? TrackNode
    set(value) {
        this.setTag(R.id.spm_id_tag, value)
    }

将携带参数的能力存放在 View.tag 中,这样任何控件都可以携带参数了。

让 Activity 携带参数体现为让其根视图 DecorView 携带参数:

// 在所有 Activity 的基类中实现 TrackNode,则所有 Activity 都具备了携带参数的能力
open class BaseActivity : AppCompatActivity(), TrackNode{
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // Activity 携带参数表现为其根视图携带参数
        window.decorView.rootView.trackNode = this
    }

    override fun fillTrackParams(): HashMap<String, String>? {
        return null
    }
}

同样地,Fragment 也有类似的实现:

open class BaseFragment : Fragment(), TrackNode {

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        // Fragment 携带参数表现为其根视图携带参数
        getView()?.trackNode = this
    }

    override fun fillTrackParams(): HashMap<String, String>? {
        return null
    }
}

这样一来,一个窗口中整个 View 树的任何一个结点都具备了携带参数的能力,从最顶层的 Activity,到其内部的 Fragment,再到任何一个控件。参数就不再需要自顶向下透传,而是可以自底向上查询:

fun View.getTrackNode(): HashMap<String, String> {
    val map = hashMapOf<String, String>()
    // 获取当前结点的参数
    trackNode?.fillTrackParams()?.also { map.putAll(it) }
    // 不断获取父亲以向上查询
    var viewParent = parent as? View
    do {
        // 查询父控件是否携带参数
        val info = viewParent?.trackNode?.fillTrackParams() 
        // 若父控件携带参数则将其拼接
        info?.also { map.putAll(it) }
        // 继续获取父控件
        viewParent = viewParent?.parent as? View
    } while (viewParent != null) // 直到回溯到了整个界面的根视图
    return map
}

为 View 自定义了一个扩展方法,该方法返回一个 Map,该 Map 中包含了从当前界面向上到树根整个链路中所有携带的参数集合。

重构透传

先在 Activity 层级将标签页的 type 拼接到 TrackNode 中,而不是作为参数传递给 EffectListFragment:

// EffectActivity.kt
override fun showEffectListContent(index: Int, from: String?) {
    mVpEffectContent.adapter = SimpleFragmentStatePagerAdapter(supportFragmentManager,BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT).apply {
        mTitles = arrayOf("视频库", "音乐", "音效", "贴纸", "转场", "特效", "滤镜", "背景", "字幕", "字体")
        mCount = mTitles!!.size
        createFragment = { position ->
            when (position) {
                0 -> {
                    val fragment = RemoteCenterFragment.newInstance(-1,true,0,MaterialProtocol.SOURCE.MATERIAL_MARKET,1,from,0,0)
                    val paramsParser = DeepLinkParamsParser(compileDeepLinkParams())
                    (fragment as IDeepLinkPage).setDeepLinkParams(paramsParser.deeplinkParams)
                    fragment
                }
                1 -> MaterialMusicFragment.newInstance(from, mSelectedTabId, mSelectedModelId)
                2 -> MaterialAudioFragment.newInstance(mSelectedTabId, mSelectedModelId)
                // 可以无差别地构造 EffectListFragment 实例
                else -> EffectListFragment.newInstance()

            }
        }
    }
    mVpEffectContent.currentItem = index
    mVpEffectContent.offscreenPageLimit = 10
    mTlEffectTabs.setupWithViewPager(mVpEffectContent)
}
// 索引和常量的映射
private val tabMap = mapOf(
    3 to CommonConstant.SERVER.TYPE_VSTICKER,
    4 to CommonConstant.SERVER.TYPE_TRANSITION,
    5 to CommonConstant.SERVER.TYPE_EFFECT,
    6 to CommonConstant.SERVER.TYPE_FILTER,
    7 to CommonConstant.SERVER.TYPE_BACKGROUND,
    8 to CommonConstant.SERVER.TYPE_SUBTITLE,
    9 to CommonConstant.SERVER.TYPE_FONT,
)

// Activity 层级的参数拼接
override fun fillTrackParams(): HashMap<String, String>? {
    // 只拼接当前显示页的常量
    return hashMapOf("type" to tabMap[mVpEffectContent.currentItem].toString()
}

第二个层级的 Fragment 在构建实例时不再接受传参(更加单纯):

class EffectListFragment : BaseFragment() {
    companion object {
        // 没有参数传入的构造方法
        fun newInstance(): EffectListFragment = EffectListFragment()
    }
}

在最内层的 Fragment 消费参数:

class EffectDetailsFragment : BaseFragment(){
    // 向上查参         
    private val type: Int
        get() = view?.getTrackNode()?.getOrElse("type") { "" }?.safeToInt() ?: 0          
    override fun initEvent() {
        mEffectDetailsAdapter.addOnItemClickListener(object : EffectDetailsAdapter.OnItemClickListener {
            override fun onItemClick(entity: EffectDataEntity?) {
                // 消费参数进行埋点
                entity?.let {
                     ReportUtil.reportClick(it.id, type)
                }
            }
        })
    }

    companion object {
        // 没有type 传入的构造方法
        fun newInstance(): EffectDetailsFragment = EffectDetailsFragment()
    }
}

消费参数时不再通过上一个界面透传,而是通过自底向上的查询。

因为约定的参数是 HashMap<String, String> 类型的,而消费的参数是 Int 类型的所以得进行类型转换

如果强制的使用如下方式进行转换,则可能发生运行时崩溃,比如下面这个场景:

" " as Int

为了避免这类崩溃,有必要做一个统一处理:

fun String?.safeToInt(): Int = this?.let {
    try {
        Integer.parseInt(this)
    } catch (e: NumberFormatException) {
        e.printStackTrace()
        0
    }
} ?: 0

为 String 定义一个扩展方法,该方法返回 Int 值,在内部调用Integer.parseInt(this)将当前的 String 转换为 Int,并在其外层包裹了 try-catch 以捕获非数字字串转换异常的情况。

使用 Kotlin 中的预定义let()方法配合try-catch表达式以及 Evis 运算符,让这个方法的表达异常简洁。

其中let()的定义如下:

public inline fun <T, R> T.let(block: (T) -> R): R {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    return block(this)
}

let 也是一个扩展方法,被扩展对象是泛型,表示它可以被任何对象调用。let 接收一个 lambda,该 lambda 会将类型 T 变换为 类型 R,let 方法内部只是通过block(this)执行了该 lambda 并返回,遂 let 的返回值即是 lambda 的值(lambda 最后一条语句的值)。

从 let 的定义可以看出,它通常用于将一个对象转换为另一个对象。当前场景中它被用于将 String 转换为 Int。

String 转换为 Int 是可能抛异常的,遂用 try-catch 包裹之。Kotlin 中try-catch是一个表达式,它是有值的,等于每个分支最后一条语句的值。这个特性使得不必多声明一个局部变量:

int result = 0;
try {
    result = Integer.parseInt(str);
} catch (Exception e) {
    result = -1
}
return result;

所以整个 safeToInt() 的返回值是 let 的返回,而 let 的返回值是 try-catch 的返回值。

最后因为被扩展的对象是 String?,所以返回值是可空的,方法内部通过?:处理了这种情况。表达式1 ?: 表达式2意思是当表达式1为空时,执行表达式2。

适用场景

自底向上查询参数方案适用于同一窗口的任何层级之间的参数传递

当在 Fragment 中向上查询时,要在onCreateView()之后,因为在此之前,Fragment 的视图层级还未生成,getView()会返回 null。

RecyclerView 中 ItemView 无法使用自底向上查询,因为ItemView.parent为空。

可以在 inflate ItemView 布局时将 attachToRoot 设置为 true:

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
    val itemView = LayoutInflater.from(parent.context).inflate(
        R.layout.material_item_effect_details, 
        parent, 
        true)// 将 attachToRoot 设置为 true
    return ViewHolder(itemView)
}

这样 ItemView 的 parent 就不为空了,但是 ItemView 的 LayoutParam 就会被其父控件的 LayoutParam 覆盖,使得 ItemView 的布局样式不符合预期。

列表项参数透传解决方案

那列表相关的参数透传路径就一定得是 Activity -> Adapter -> ViewHolder ?

Adapter 的语义是完成数据到视图的转换。ViewHolder 的语义是描述如何构建表项视图及其交互。

如果将透传逻辑和表项的构建及交互逻辑耦合在一起,除了增加了透传参数的复杂度,还使得后者无法被独立复用。

更好的做法是将表项的曝光和点击事件上移到 Activity/Fragment 处理,为此新增了两个扩展法方法:

fun RecyclerView.setOnItemClickListener(listener: (View, Int, Float, Float) -> Boolean) {
    addOnItemTouchListener(object : RecyclerView.OnItemTouchListener {
        val gestureDetector = GestureDetector(context, object : GestureDetector.OnGestureListener {
            override fun onShowPress(e: MotionEvent?) {
            }

            override fun onSingleTapUp(e: MotionEvent?): Boolean {
                e?.let {
                    findChildViewUnder(it.x, it.y)?.let { child ->
                        val realX = if (child.left >= 0) it.x - child.left else it.x
                        val realY = if (child.top >= 0) it.y - child.top else it.y
                        return listener( child, getChildAdapterPosition(child), realX, realY )
                    }
                }
                return false
            }

            override fun onDown(e: MotionEvent?): Boolean {
                return false
            }

            override fun onFling( e1: MotionEvent?, e2: MotionEvent?, velocityX: Float, velocityY: Float ): Boolean {
                return false
            }

            override fun onScroll( e1: MotionEvent?, e2: MotionEvent?, distanceX: Float, distanceY: Float ): Boolean {
                return false
            }

            override fun onLongPress(e: MotionEvent?) {
            }
        })

        override fun onTouchEvent(rv: RecyclerView, e: MotionEvent) {

        }

        override fun onInterceptTouchEvent(rv: RecyclerView, e: MotionEvent): Boolean {
            gestureDetector.onTouchEvent(e)
            return false
        }

        override fun onRequestDisallowInterceptTouchEvent(disallowIntercept: Boolean) {
        }
    })
}

通过判断触点坐标落在 RecyclerView 的哪个孩子上进行点击事件的回调。详细分析可以点击读源码长知识 | 更好的 RecyclerView 表项点击监听器

以及 RecyclerView 表项百分比曝光扩展方法:

fun RecyclerView.onItemVisibilityChange(percent: Float = 0.5f, block: (itemView: View, adapterIndex: Int, isVisible: Boolean) -> Unit) {
    val rect = Rect() // reuse rect object rather than recreate it everytime for a better performance
    val visibleAdapterIndexs = mutableSetOf<Int>()
    val scrollListener = object : OnScrollListener() {
        override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
            super.onScrolled(recyclerView, dx, dy)
            // iterate all children of RecyclerView to check whether it is visible
            for (i in 0 until childCount) {
                val child = getChildAt(i)
                val adapterIndex = getChildAdapterPosition(child)
                val childVisibleRect = rect.also { child.getLocalVisibleRect(it) }
                val visibleArea = childVisibleRect.let { it.height() * it.width() }
                val realArea = child.width * child.height
                if (visibleArea >= realArea * percent) {
                    if (visibleAdapterIndexs.add(adapterIndex)) {
                        block(child, adapterIndex, true)
                    }
                } else {
                    if (adapterIndex in visibleAdapterIndexs) {
                        block(child, adapterIndex, false)
                        visibleAdapterIndexs.remove(adapterIndex)
                    }
                }
            }
        }
    }
    addOnScrollListener(scrollListener)
    addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {
        override fun onViewAttachedToWindow(v: View?) {
        }

        override fun onViewDetachedFromWindow(v: View?) {
            if (v == null || v !is RecyclerView) return
            if (ViewCompat.isAttachedToWindow(v)) {
                v.removeOnScrollListener(scrollListener)
            }
            removeOnAttachStateChangeListener(this)
        }
    })
}

通过监听列表滚动事件,并在其中遍历列表所有的孩子,同时计算每个孩子矩形区域在列表中展示的百分比判断其可见性,详细分析可以点击

总结

通过思路的转变,将“自顶向下透传参数”转变为“自顶向上查询参数”,降低了同一界面层级中各控件之间的耦合,使得每个控件都更加单纯。

推荐阅读

业务代码参数透传满天飞?(一)

业务代码参数透传满天飞?(二)

全网最优雅安卓控件可见性检测

全网最优雅安卓列表项可见性检测

页面曝光难点分析及应对方案

你的代码太啰嗦了 | 这么多对象名?

你的代码太啰嗦了 | 这么多方法调用?

作者:唐子玄
链接:https://juejin.cn/post/7165427216589783076

最后

如果想要成为架构师或想突破20~30K薪资范畴,那就不要局限在编码,业务,要会选型、扩展,提升编程思维。此外,良好的职业规划也很重要,学习的习惯很重要,但是最重要的还是要能持之以恒,任何不能坚持落实的计划都是空谈。

如果你没有方向,这里给大家分享一套由阿里高级架构师编写的《Android八大模块进阶笔记》,帮大家将杂乱、零散、碎片化的知识进行体系化的整理,让大家系统而高效地掌握Android开发的各个知识点。
在这里插入图片描述
相对于我们平时看的碎片化内容,这份笔记的知识点更系统化,更容易理解和记忆,是严格按照知识体系编排的。

一、架构师筑基必备技能

1、深入理解Java泛型
2、注解深入浅出
3、并发编程
4、数据传输与序列化
5、Java虚拟机原理
6、高效IO
……

在这里插入图片描述

二、Android百大框架源码解析

1.Retrofit 2.0源码解析
2.Okhttp3源码解析
3.ButterKnife源码解析
4.MPAndroidChart 源码解析
5.Glide源码解析
6.Leakcanary 源码解析
7.Universal-lmage-Loader源码解析
8.EventBus 3.0源码解析
9.zxing源码分析
10.Picasso源码解析
11.LottieAndroid使用详解及源码解析
12.Fresco 源码分析——图片加载流程

在这里插入图片描述

三、Android性能优化实战解析

  • 腾讯Bugly:对字符串匹配算法的一点理解
  • 爱奇艺:安卓APP崩溃捕获方案——xCrash
  • 字节跳动:深入理解Gradle框架之一:Plugin, Extension, buildSrc
  • 百度APP技术:Android H5首屏优化实践
  • 支付宝客户端架构解析:Android 客户端启动速度优化之「垃圾回收」
  • 携程:从智行 Android 项目看组件化架构实践
  • 网易新闻构建优化:如何让你的构建速度“势如闪电”?

在这里插入图片描述

四、高级kotlin强化实战

1、Kotlin入门教程
2、Kotlin 实战避坑指南
3、项目实战《Kotlin Jetpack 实战》

  • 从一个膜拜大神的 Demo 开始

  • Kotlin 写 Gradle 脚本是一种什么体验?

  • Kotlin 编程的三重境界

  • Kotlin 高阶函数

  • Kotlin 泛型

  • Kotlin 扩展

  • Kotlin 委托

  • 协程“不为人知”的调试技巧

  • 图解协程:suspend

在这里插入图片描述

五、Android高级UI开源框架进阶解密

1.SmartRefreshLayout的使用
2.Android之PullToRefresh控件源码解析
3.Android-PullToRefresh下拉刷新库基本用法
4.LoadSir-高效易用的加载反馈页管理框架
5.Android通用LoadingView加载框架详解
6.MPAndroidChart实现LineChart(折线图)
7.hellocharts-android使用指南
8.SmartTable使用指南
9.开源项目android-uitableview介绍
10.ExcelPanel 使用指南
11.Android开源项目SlidingMenu深切解析
12.MaterialDrawer使用指南
在这里插入图片描述

六、NDK模块开发

1、NDK 模块开发
2、JNI 模块
3、Native 开发工具
4、Linux 编程
5、底层图片处理
6、音视频开发
7、机器学习

在这里插入图片描述

七、Flutter技术进阶

1、Flutter跨平台开发概述
2、Windows中Flutter开发环境搭建
3、编写你的第一个Flutter APP
4、Flutter开发环境搭建和调试
5、Dart语法篇之基础语法(一)
6、Dart语法篇之集合的使用与源码解析(二)
7、Dart语法篇之集合操作符函数与源码分析(三)

在这里插入图片描述

八、微信小程序开发

1、小程序概述及入门
2、小程序UI开发
3、API操作
4、购物商场项目实战……

在这里插入图片描述

全套视频资料:

一、面试合集
在这里插入图片描述
二、源码解析合集

在这里插入图片描述
三、开源框架合集

在这里插入图片描述
欢迎大家一键三连支持,若需要文中资料,直接点击文末CSDN官方认证微信卡片免费领取↓↓↓

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值