代码要写成别人看不懂的样子(二十七)

本文介绍了MVP模式,它通过将视图层与数据层分离,降低修改成本。通过实例展示了如何在MVP架构下创建视图模板、解析和管理数据,以提高开发效率和模块复用性。

本篇文章参考书籍《JavaScript设计模式》–张容铭

前言

  同学们, MVC 模式有没有搞搞清楚,可能有点感觉了,但还是不太熟悉对吧,没关系, MVC 之所以被大家传的神乎其神,其实是开启了一种新模式,这种模式,简化了我们开发过程中的一些繁琐操作,让我们可以把精力更多的放在业务上,而 MVC 本身其实还是可以优化的。

  从上一节的代码来看, MVC 模式在添加新功能的时候,获取的数据改变了,我们不仅要修改控制器层,还要修改视图层,这对于复杂需求修改成本是很高的。

  这对于这一问题,衍生出了今天的主题, MVP 模式

MVP 模式

  MVP 即模型(Model)—视图(View)—管理器(Presenter)

   View 层不直接引用 Model 层内的数据,而是通过 Presenter 层实现对 Model 层内的数据访问。即所有层次的交互都发生在 Presenter 层中。

  我们知道,在 MVC 模式中,视图层常常因为要渲染页面而直接引用数据层内的数据,对于这一切,控制器是不知情的,那就会导致,在新增需求的时候,不仅要修改视图层,还要修改控制器。

  在 MVP 模式中,我们将视图层和数据层解耦,统一交给控制器层管理,这样视图层只负责创建视图模板,数据层只负责管理数据,功能就独立了出来,而剩下的管理数据, UI 视图创建,交互逻辑,动画特效等都交给控制层,这样控制层的功能多了,晋升成为了管理层, C 就变成了 P

  基本原理我们清楚了,接下来就是如何实现,通过上面描述,我们知道数据层的变化不需要太多,主要是视图层不一样了,我们首先先创建一个单体对象。

// MVP 单体对象
~(function(window) {
    //MVP 构造函数
    var MVP = function() {};
    //数据层
    MVP.model = function() {};
    //视图层
    MVP.view = function() {};
    //管理层
    MVP.presenter = function() {};
    //MVP入口
    MVP.init= function() {};
    //暴露 MVP 对象,这样可在外部访问 MVP
    window.MVP = MVP;
}) (window)

  数据层变化不大,代码如下:

//数据层与 MVC 相似
MVP.model = function() {
    var M = {};
    M.data = {}
    M.conf = {}
    return {
        getData: function(m) {
            return M.data[m];
        },
        /**
         * 设置数据
         * @param   m   模块名称
         * @param   v   模块数据
         */
        setData: function(m, v) {
            M.data[m] = v;
            return v;
        },
        getConf: function(c) {
            return M.conf[c];
        },
        /**
         * 设置配置
         * @param   c   配置项名称
         * @param   v   配置项值
         */
        setConf: function(c, v) {
            M.conf[c] = v;
            return v;
        }
    }
} ()

  接下来是改革重点,视图层修改,比如说我们做一个如下的导航条。
在这里插入图片描述
  为了在管理层中渲染并创建视图,每次渲染都需要视图层提供一个导航视图模板。

var tpl = [
    '<li class="{#mnode#} {#choose#} {#last#}" data-mode="{#mode#}">',
        '<a id="nav_{#mode#}" class="nav_{#mode#}" href="{#url#}" title="{#text#}">',
            '<i class="nav-icon-{#mnode#}"></i>',
            '<span>{#text#}</span>',
        '</a>',
    '</li>',
].join('');

  又是这种头疼的字符串模板,写起来超级费劲,各位估计也看烦了。我们可以借鉴一下 Zen Coding 那样,快速创建模板,比如上面的模板转化成 Zen Coding 模式如下:

'li.@mode @choose @last[data-mode=@mode]>a#nav_@mode.nav-@mode[href=@url title=@text]>i.nav-icon-@mode+span{@text}'

  上面这种写法,稍加观察就能理解,对于这种写法,我们需要对视图层做如下处理:

//视图层
MVP.view = MVP.view = function() {
    return function(str) {
        //将参数字符串转化成期望模板
        return html2canvas;
    }
} ();

  接下来我们要解析字符串并创建视图,对于参数字符串 str ,我们做的第一步是分层,也就是说要确认每一个元素之间的层级关系,我们发现 ‘>’ 表示后面的元素是前面的元素的子元素,而 ‘+’ 表示前面元素与后面元素是兄弟元素。由于兄弟元素处在元素树中的同一层级上,因此我们要先做 ‘>’ 不同层级处理后作 ‘+’ 同一层级处理,最后针对每一个元素做处理并按层级顺序拼接成期望模板字符串。

MVP.view = function() {
    //子元素或者兄弟元素替换模板
    var REPLACEKEY = '__REPLACEKEY__';
    //获取完整元素模板
    function getHTML(str, replacePos) {}
    /**
     * 数组迭代器
     * @param arr  数组
     * @param fn   回调函数
     */
    function eachArray(arr, fn) {
        //遍历数组
        for (let i = 0, len = arr.length; i < len; i++) {
            //将索引值,索引对应值,数组长度传入回调函数中并执行
            fn(i, arr[i], len);
        }
    }
    /**
     * 替换兄弟元素模板或者子元素模板
     * @param str   原始字符串
     * @param rep   兄弟元素模板或者子模版
     */
    function formateItem(str, rep) {
        //用对应元素字符串替换兄弟元素模板或者子元素模板
        return str.replace(new RegExp(REPLACEKEY, 'g'), rep);
    }
    //模板解析器
    return function(str) {
            //模板层级数组
        var part = str
        //去除首位空白符
        .replace(/^\s+|\s+$/g, '')
        //去除 > 两端空白符
        .replace(/^\s+(>)\s+/g, '$1')
        //以 > 分组
        .split('>'),
            //模块视图根模板
            html = REPLACEKEY,
            //同层元素
            item,
            //同级元素模板
            nodeTpl;
        //遍历每组元素
        eachArray(part, function(partIndex, partValue, partLen) {
            //为同级元素分组
            item = partValue.split('+');
            //设为同级元素初始模板
            nodeTpl = REPLACEKEY;
            //遍历同级每一个元素
            eachArray(item, function(itemIndex, itemValue, itemLen) {
                /**
                 * 用渲染元素得到的模板去渲染同级元素模板,此处简化逻辑操作
                 * 如果 itemIndex (同级元素索引) 对应元素不是最后一个 则做兄弟元素处理
                 * 否则,如果 partIndex (同级索引) 对应的层级不是最后一层 则作为父层级处理
                 * (该层级有子层级,即该元素是父元素)
                 * 否则,该元素无兄弟元素无子元素
                 */
                nodeTpl = formateItem(nodeTpl, getHTML(itemValue, itemIndex === itemLen - 1 ? (partIndex === partLen - 1 ? '' : 'in') : 'add'));
            });
            //用渲染子层级得到的模板去渲染父层级模板
            html = formateItem(html, nodeTpl);
        })
        //返回期望视图模板
        return html;
    }
} ();

  最后我们要做的就是对一个元素模板的渲染,即 getHTML 方法。 getHTML 方法比较复杂,首先要分清该元素是否拥有子元素,或者拥有兄弟元素,或是最后一个叶子元素 3 中情况。并针对三种情况分别处理。

  然后要将元素补成完整元素, 如 div 要转化成 <div></div>

  接下来要对元素的特殊属性 id # 标识)或 class . 标识)做处理。

  然后在处理元素的其他属性( [] 内的用空格分割的属性组)。

  最后要将可替换内容标识( @ 标识)替换成代码库中模板渲染方法中可嗅探内容标识形式(比如我们引用 A 框架中 formateString 方法的可嗅探内容标识为 {##} )。

/**
 * 获取完整元素模板
 * @param  str    元素字符串
 * @param  type   元素类型
 */
function getHTML(str, type) {
    //简化实现,只处理字符串中第一个{}里面的内容
    return str
        .replace(/^(\w+)([^\{\}]*)?(\{([@\w]+)\})?(.*?)$/, function(match, $1, $2, $3, $4, $5) {
            $2 = $2 || '';    //元素属性参数容错处理
            $3 = $3 || '';    //(元素内容)参数容错处理
            $4 = $4 || '';    //元素内容参数容错处理
            //去除元素内容后面添加的元素属性中的{}内容
            //以 str=div 为例,如果div元素有子元素则表示成<div>__REPLACEKEY__</div>
            //如果div有兄弟元素则表示成<div></div>__REPLACEKEY__,否则表示成<div></div>
            $5 = $5.replace(/\{([@\w]+)\}/g, '');
            return type === 'in' ?
                '<' + $1 + $2 + $5 + '>' + $4 + REPLACEKEY + '</' + $1 + '>' : 
            type === 'add' ?
                '<' + $1 + $2 + $5 + '>' + $4 + '</' + $1 + '>' + REPLACEKEY :
                '<' + $1 + $2 + $5 + '>' + $4 + '</' + $1 + '>' 
        })
        //处理特殊标识#--id属性
        .replace(/#([@\-\w]+)/g, ' id="$1"')
        //处理特殊标识.--class属性
        .replace(/\.([@\-\s\w]+)/g, ' class="$1"')
        //处理其他属性组
        .replace(/\[(.+)\]/g, function(match, key) {
                //元素属性组
            var a = key
                    //过滤其中引号
                    .replace(/'|"/g, '')
                    //以空格分组
                    .split(' '),
                //属性模板字符串
                h = '';
            //遍历属性组
            for(var j = 0, len = a.length; j < len; j++) {
                //处理并拼接每一个属性
                h += ' ' + a[j].replace(/=(.*)/g, '="$1"');
            }
            //返回属性组模板字符串
            return h;
        })
        //处理可替换内容,可根据不同模板渲染引擎自由处理
        .replace(/@(\W+)/g, '(#$1#)');
}

  有了模板引擎,我们在管理器中实现就容易多了,为了使管理器更适合我们的 MVP 模式,只要对管理器稍加改动,添加管理器执行方法 init ,这样方便在任何时候执行我们的管理器,不过总体来说还是和 MVC 中的控制器很类似。

//管理器层
MVP.presenter = function() {
    var V = MVP.view;
    var M = MVP.model;
    var C = {};
    return {
        //执行方法
        init: function() {
            //遍历内部管理器
            for(var i in C) {
                //执行所有管理器内部逻辑
                C[i] && C[i](M, V, i);
            }
        }
    };
} ();

  完整的 MVP 对象创建出来了,接下来我们创建一个导航,首先为管理器添加导航管理器逻辑。

var C = {
    /**
     * 导航管理器
     * @param   M   数据层对象
     * @param   V   视图层对象
     */
    nav: function(M, V) {
        //处理导航渲染数据
        data[0].choose = 'choose';
        data[data.length - 1].last = 'last';
        //获取导航渲染模板
        var tpl = V('li.@mode @choose @last[data-mode=@mode]>a#nav_@mode.nav-@mode[href=@url title=@text]>i.nav-icon-@mode+span{@text}');
        $
        //创建导航容器
        .create('ul', {
            'class': 'navigation',
            'id': 'nav'
        })
        //插入导航视图
        .html(
            //渲染导航视图
            A.formateString(tpl, data)
        )
        //导航模块添加到页面中
        .appendTo('#container');
        //其他交互逻辑与动画逻辑
        //...
    }
};

  假设我们现在可以从后端获取导航模块数据并已经通过 setData 方法设置在数据层中。

M.data = {
    //导航模块渲染数据
    nav: [
        {
            text: '新闻头条',
            mode: 'news',
            url: 'http://www.example.com/01'
        },
        {
            text: '最新电影',
            mode: 'movie',
            url: 'http://www.example.com/02'
        },
        {
            text: '热门游戏',
            mode: 'game',
            url: 'http://www.example.com/03'
        },
        {
            text: '进入特价',
            mode: 'price',
            url: 'http://www.example.com/04'
        },
    ]
}

  万事俱备,只欠执行了,接下来为 MVP 对象创建一个快捷执行方法 init

//MVP 入口
MVP.init = function() {
    this.presenter.init();
}

  等到页面加载完毕后我们就可以渲染并创建导航模块了。

window.onload = function() {
    //执行管理器
    MVP.init();
}

  是不是比 MVC 方便多了,有什么需求增加只要修改响应的管理器就可以了。不过现在模块化开发是主流,一个模块的开发是依赖 MVP 对象实现的,因此我们要将 MVP 封装在模块内。

F.module('lib/MVP', function() {
    //MVP构造函数
    var MVP = function() {};
    //MVP实现
    //...
    return MVP;
});

  有了 MVP 对象模块,我们就可以在其他模块中引用 MVP 模块了,不过目前为止我们还不能使用 MVP 为管理器添加其他控制器模块。所以我们完成 MVP 构造函数,实现通过 MVP (模块名称,模块管理器,服务器端获取的数据) 的方式添加模块。

//MVP构造函数
var MVP = function(modName, pst, data) {
    //在数据层中添加 modName 渲染数据模块
    MVP.model.setData(modName, data);
    //在管理器层中添加 modName 管理器模块
    MVP.presenter.add(modName, pst);
};

  从上面代码可以看出 MVP 构造函数做了两件事,首先为数据层添加模块数据,然后为管理器曾添加管理器模块,我们已经在数据层 model 中实现了 setData 方法,所以现在只剩下管理器层中的 add 方法有待实现。

//管理器层
MVP.presenter = function() {
    //...
    return {
        /**
         * 为管理器添加模块
         * @param  modName   模块名称
         * @param  pst       模块管理器
         */
        add: function(modName, pst) {
            C[modName] = pst;
            return this;
        }
    };
} ();

  管理器的 add 方法允许我们以管理器名称 + 模块管理器的形式在管理器对象层中添加模块管理器,这样我们在外部就可以自由的添加模块了。比如创建一个简单的网址模块。
在这里插入图片描述
  我们可以在外部模块中创建网址模块。

//网址模块
F.module(['lib/MVP', 'lib/A'], function(MVP, $) {
    //页面加载完成执行 参考 A 框架(可以在上一节的代码中查看,或者从我上传的资源中查看)
    $(function() {
        //为 MVP 对象添加一个网址模块
        MVP(
            //模块名称
            'sites',
            /**
             * 模块控制器
             * @param   M        数据对象层引用
             * @param   V        视图对象层引用
             * @param   modName  模块名称
             */
            function(M, V, modName) {
                //渲染模板<li><a href="#">{#text#}</a></li>
                var tpl = V('li>a[href="#"]{@text}');
                $
                //创建网址模块容器
                .create('ul', {
                    'class': 'store-nav',
                    'id': modName
                })
                //向网址模块容器中插入网址模块视图
                .html(
                    //创建网址模块视图
                    $.formateString(tpl, M.getData(modName))
                )
                //插入页面中
                .appendTo('#container');
                //其他交互与特效...
            },
            //模块数据
            [
                '聚划算',
                '1号店',
                '九块邮',
                '优购网',
                '爱淘宝',
                '1折网',
            ]
        );
    });
});

  模块创建完毕我们就可以执行所有模块控制器了。

$(function() {
	MVP.init();
})

  当然对于模块间的通信我们还可以通过观察者模式来实现。

总个小结

   MVP MVC 的最大区别就是将视图层与数据层完全解耦,使得对视图层的修改不会影响到数据层,数据层内的数据改动又不会影响到视图层。在管理器中对数据或者视图灵活的调用就可使数据层内的数据与视图层内的视图得到高效复用。

  因此, MVP 模式可以实现一个管理器,可以调用多个数据,或者创建多种视图,而且不受限制。



完全没关系!你说“**我全都看不懂**”,那我们就从最最基础的开始,像教一个刚学编程的人一样,**一句一句、一步一步地解释这段代码到底在做什么**。 我们不跳步、不假设你知道任何图形学或数学知识。准备好,我们现在开始: --- ## 🌟 目标:画一个“圆柱体”(比如像一根水管、一个罐头) 我们要用 OpenGL(一种图形库)在屏幕上画出这个圆柱体。 但计算机不能直接理解“圆柱”这种东西,它只能画: - 点 - 线 - 三角形 所以我们必须把“圆柱”拆成很多小三角形拼起来 —— 就像乐高积木搭模型。 --- ## 🔧 第一步:准备材料(定义参数) ```cpp void Cylinder::initData(DataParam *param) { dataParam = *param; ``` ### ✅ 这句什么意思? - `DataParam *param` 是一个“装数据的盒子”的指针。 - 它里面可能有这些信息: ```cpp struct DataParam { float radius; // 圆柱半径(粗细) GLuint longSegments; // 把圆周切成几段(越多样子越圆) float height; // 高度(多长) }; ``` - `dataParam = *param;` 表示:“把这个盒子的内容复制到我自己的成员变量里保存下来。” 👉 所以现在我们知道要画一个多粗、多高、多精细的圆柱了。 --- ### 继续往下: ```cpp GLfloat radius = dataParam.radius; GLuint longSegments = dataParam.longSegments; GLfloat height = dataParam.height; ``` ✅ 把刚才复制过来的数据取出来,起个短名字方便后面用。 比如: - `radius` 就是半径(比如 1.0) - `height` 是高度(比如 2.0) - `longSegments` 是“把圆切几块”(比如 8 块 → 八边形) --- ## 📐 第二步:计算角度(怎么画一个圆?) ```cpp float fTheta = (glm::pi<float>() * 2.0f) / longSegments; ``` ### ❓这是什么? 我们来慢慢讲。 ### 💡 想象你在一个操场上,想绕着中心走一圈,走出一个“圆形”。 你可以: 1. 每次走一小步 2. 走完后转一个小角度 3. 再走下一步…… 这样走很多次,就画出了一个近似的圆。 而每次转的角度是多少呢? > 一圈总共是 **360 度**,也就是 **2π 弧度**(程序员喜欢用弧度) 如果你把圆分成 `longSegments = 8` 段,那每段就是: $$ \frac{2\pi}{8} = \frac{\pi}{4} $$ 👉 所以这行代码的意思是: > “我要把一个完整的圆($2\pi$)平均分成 `longSegments` 份,每一份的角度差是 `fTheta`” 🎯 `fTheta` 就是你每画一个点时增加的角度。 --- ## 🧮 第三步:需要多少个顶点?(提前申请内存) ```cpp int numVertices = 2 * (longSegments + 1) + (longSegments + 2) + (longSegments + 2); ``` 这句话看起来复杂,其实就是在数:**一共要用多少个“点”来组成这个圆柱?** 我们把它拆开来看! ### 🔹 第一部分:侧面(筒身)→ `2 * (longSegments + 1)` - 我们要在上下两个圈上各放一堆点: - 下面一圈:`longSegments + 1` 个点(因为首尾重合,所以比段数多1) - 上面一圈:同样 `longSegments + 1` 个点 - 所以侧面一共需要:`2 * (L+1)` 个点 📌 举个例子:如果 `longSegments = 8`,那么每圈9个点,共 `2×9=18` 个点 --- ### 🔹 第二部分:上底盖(顶上的圆)→ `longSegments + 2` - 画一个圆盖,要用 `GL_TRIANGLE_FAN`(扇形模式),需要: - 1 个中心点 - `longSegments + 1` 个边缘点(闭合) - 所以总共:`1 + (L+1) = L+2` 个点 --- ### 🔹 第三部分:下底盖(底下的圆)→ 又一个 `longSegments + 2` - 同理,下面也要画一个圆盖,也需要 `L+2` 个点 --- ### ✅ 加起来总数: ``` 侧面: 2*(L+1) 上盖: L+2 下盖: L+2 总点数 = 2L+2 + L+2 + L+2 = 4L + 6 ``` 代入 `L=8` → `4×8 + 6 = 38` 个点 所以我们要准备一个能装 **38 个点** 的数组。 --- ## 🗃️ 第四步:创建一个“点”的数组 ```cpp if (vertices) { delete[] vertices; } vertices = new TextureColorVertex[numVertices]; ``` ### ✅ 解释: - `vertices` 是一个数组,用来存所有的“顶点” - 每个顶点包含: - 坐标(x, y, z) - 颜色(r, g, b) - 纹理坐标(s, t) 👉 就像 Excel 表格的一行,记录一个点的所有信息。 #### 为什么先 `delete[]`? - 如果之前已经画过一次圆柱,`vertices` 已经分配过内存了 - 再次初始化前必须先释放旧内存,否则会**内存泄漏** #### `new TextureColorVertex[38]` 是什么? - 在电脑内存中开辟一块空间,可以放 38 个这样的“点” - 就像租了一个有 38 个格子的柜子,每个格子放一个点的信息 --- ## 🌀 第五步:生成侧面的点(最关键的一步) ```cpp for (int i = 0; i < (longSegments + 1); i++) { ``` 👉 循环 `L+1` 次(比如 9 次),每次生成一对点:一个在底下,一个在顶上。 --- ### 🟢 第一个点:底部的点 ```cpp vertices[2 * i].coordinate.x = radius * cosf(i * fTheta); vertices[2 * i].coordinate.y = -(height / 2.0f); vertices[2 * i].coordinate.z = radius * sinf(i * fTheta); ``` 我们来理解这三行。 #### 数学知识:如何用角度算出 x 和 z? 想象你在画一个圆: - 角度为 θ - 半径为 r - 那么: - $ x = r \cdot \cos(\theta) $ - $ z = r \cdot \sin(\theta) $ 这里的 `i * fTheta` 就是当前的角度。 例如: - i=0 → 角度=0 → x=r, z=0 → 正右方 - i=1 → 角度=π/4 → 斜上方 - ... - i=8 → 角度=2π → 回到起点 🎯 所以这一系列点就在 XY 平面上绕了一圈(其实是 XZ 平面,Y 是上下) Y 固定为 `-height / 2.0f` → 所有底边点都在“最下面” --- ### 🔵 第二个点:顶部的点 ```cpp vertices[2 * i + 1].coordinate.x = radius * cosf(i * fTheta); vertices[2 * i + 1].coordinate.y = (height / 2.0f); vertices[2 * i + 1].coordinate.z = radius * sinf(i * fTheta); ``` 和上面几乎一样,只是 Y 是 `+height/2` → 所有点都在“最上面” --- ### 📦 存储位置是怎么安排的? | i | 底部点索引 | 顶部点索引 | |---|------------|------------| | 0 | 0 | 1 | | 1 | 2 | 3 | | 2 | 4 | 5 | | ... | ... | ... | 所以整个侧面变成了这样一排点: ``` [底0][顶0][底1][顶1][底2][顶2]... ``` OpenGL 用 `GL_TRIANGLE_STRIP` 把它们连成带状三角形,形成筒壁。 --- ### 🎨 设置纹理坐标(简单理解:贴图位置) ```cpp vertices[2 * i].texture.s = (longSegments - i) * (1.0f / longSegments); vertices[2 * i].texture.t = 0; ``` - `s` 和 `t` 类似于图片上的横纵坐标(0~1之间) - 这里 `s` 是 `(L - i)/L` → 随着 i 增大而减小 → **从右往左贴图** - `t=0` → 贴图最下面 - 顶点设 `t=1` → 贴图最上面 ⚠️ 缺点:纹理是倒着贴的!正常应该用 `i * (1.0/L)` --- ### 🖍️ 设置颜色 ```cpp vertices[2 * i].color.r = ... = 1.0f; ``` - RGB 都设为 1 → 白色 --- ## 🛑 到这里为止:完成了侧面 18 个点 接下来要写“上底盖”和“下底盖” 所以我们需要知道:**下一个该写哪个位置?** --- ## 📍 关键行来了! ```cpp int start = 2 * (longSegments + 1); ``` 👉 这句话的意思是: > “我已经写了 `2*(L+1)` 个点(侧面),所以下一个可用的位置是第 `2*(L+1)` 号。” 比如 `L=8` → `2*9 = 18` → 所以下一个点从 `vertices[18]` 开始写 🎯 完全就是一个“记账”操作:前面用了多少格子?现在该从哪开始? --- ## ✅ 总结:你现在明白了吗? | 代码 | 实际含义 | |------|----------| | `fTheta = 2π / L` | 每一步转多少角度 | | `numVertices = ...` | 总共要多少个点 | | `new TextureColorVertex[N]` | 开辟内存存点 | | `for(...)` | 一个个生成点 | | `x = r*cosθ`, `z = r*sinθ` | 用三角函数画圆 | | `start = 2*(L+1)` | 记录“接下来从哪个位置开始写” | --- ## 🧩 最后一句话类比: 想象你要做一盘饺子: 1. 先看要做几个(`longSegments`) 2. 准备面粉和馅(`new` 内存) 3. 擀皮:一个个切好(生成侧面点) 4. 包完一圈后,开始做盖子(上底、下底) 5. `start` 就像是你对老婆说:“前面18个饺子包完了,现在该包盖子了,从第19个位置摆起。” ---
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值