Android - 不完全测试驱动开发实践 - 初级篇

前言

测试驱动开发(TDD)是我一直想要尝试和使用开发方法,但是直至今天才有机会第一次将其应用到正式开发阶段。

从开始的模糊,到慢慢了解如何使用,再到借助它将逻辑捋的越来越清楚,再到之后每次跑完所有测试带给我的信心,我知道这就是我想要的,开发过程再也不是碰运气,我拥有了使用代码测试代码的能力。

因为是不完全从测试驱动开发,本片文章有所不准确的地方也请大家指正。

感谢我的团队~

导读

在所有开始之前,需要给大家介绍一些简要的关于TDD的知识,大家可以从如下地址了解到什么是TDD以及为什么需要TDD:

  1. 维基百科 - 测试驱动开发
  2. 维基百科 - Test-driven development
  3. 读《推行TDD的思考》有感
  4. 你今天写了自动化测试吗

本片文章以一个假设的需求为切入点 - 数据仓库设计(DataRepository),从如下角度来践行TDD:

  1. 介绍JUnit、Mock与PowerMock
  2. 配置环境
  3. 数据仓库设计思路
  4. 数据仓库测试开发思路
  5. 带来的好处与缺陷

该篇文章仅设计逻辑测试部分,并不涉及UI测试,请提前知晓。

介绍JUnit、Mock与PowerMock

在Java的世界中,TDD的基础是单元测试,而Junit就是一个非常强大的单元测试库。

当我们初建一个Android项目时,Android Studio就已经帮我们准备了一些专门用于单元测试的目录,一个空项目如下所示:


UnitTestDemo
    - app
        - src
            - androidTest
            - main
            - test

其中,testmain两个目录是我们这次主要关心的:

  • androidTest:目录专门用于测试UI逻辑
  • main目录专门用于编写项目源码
  • test目录用门用于测试业务逻辑代码

在此处Junit就不详细介绍了,更具体的可以参看这里:

https://zh.wikipedia.org/wiki/JUnit
https://junit.org/junit5/

在这个部分,把关注点放在MockPowerMock上,之所以这么说是因为Junit为我们提供了测试代码的可能性,但是当项目依赖于其他模块时,我们可以借助Mock来模拟依赖的类,来控制我们的测试流程。

当我们处于Android环境时更是如此,当我们需要依赖HandlerBroadcast或者其他与环境相关的代码时,如果不去Mock它,别说测试了,连运行恐怕都运行不起来。

如果说Junit给了我们汽车,那么Mock与PowerMock就是给了我们飞机。

上文中经常提到的Mock实际上指的是Mockito框架,在首页中他是这么介绍自己的:

Tasty mocking framework for unit tests in Java (美味可口的模拟测试框架 - Java)

Mocktio为我们提供了这样一种能力:模拟一个类,不真实的执行它,而是模拟执行并返回我们想要的数据,且可以去验证类的行为。

下面是它官网的一段实例,展示了上面我们说到的能力:模拟、执行、返回、验证。

import static org.mockito.Mockito.*;

// mock creation
// 模拟一个列表
List mockedList = mock(List.class);

// using mock object - it does not throw any "unexpected interaction" exception
// 执行某些行为
mockedList.add("one");
mockedList.clear();

// stubbing appears before the actual execution
// 模拟返回值
when(mockedList.get(0)).thenReturn("first");

// selective, explicit, highly readable verification
// 验证行为是否被执行到
verify(mockedList).add("one");
verify(mockedList).clear();

// the following prints "first"
// 下面会打印出first,与上面模拟返回的一致
System.out.println(mockedList.get(0));

切换到Android场景中,我们可以做到下面做种样子,验证Handlerpost是否被执行;模拟Handlerpost方法执行,验证Runnablerun方法是否被调用等。

@Test
public void testHandler() {

    Handler handler = mock(Handler.class);

    // 验证Handler的post方法是否被执行了
    handler.post(new Runnable() {
        @Override
        public void run() {

        }
    });
    verify(handler).post(any(Runnable.class));

    // 模拟post方法执行,并验证run方法有没有被执行
    when(handler.post(any(Runnable.class))).thenAnswer(new Answer() {
        @Override
        public Object answer(InvocationOnMock invocation) throws Throwable {
            Runnable runnable = invocation.getArgument(0);
            runnable.run();
            return null;
        }
    });
    Runnable spy = spy(new Runnable() {
        @Override
        public void run() {

        }
    });
    handler.post(spy);
    // 验证run方法是否被执行
    verify(spy).run();
}

虽然Mockito已经很强大了,但是它还是有不能做到的事情,它不能模拟类中的静态方法、私有方法、final方法,于是就有了衍生框架PowerMockito

虽然PowerMockito仅有2000不到的Star,但是它确实还挺好用。

// 被测试的类与静态方法
public class Static {

    public static boolean isPass() {
        return false;
    }
}

// 使用PowerMockito测试静态方法
@Test
public void testStaticMethod() {
    PowerMockito.mockStatic(Static.class);

    when(Static.isPass()).thenReturn(true);

    assertThat(true, is(Static.isPass()));
}

虽然PowerMockito很强大,但是还是不要过多使用,尤其是针对私有方法与final方法。

PowerMockito更多使用方法请查看这里这里

配置环境

MockitoPowerMockito的环境配置也很简单,在Android Studio工程中加入如下依赖:

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])

    testImplementation 'junit:junit:4.12'
    testImplementation 'org.mockito:mockito-core:2.8.9'
    testImplementation 'org.hamcrest:hamcrest-all:1.3'
    testImplementation 'org.powermock:powermock-module-junit4:1.7.4'
    testImplementation 'org.powermock:powermock-api-mockito2:1.7.4'
}

注意:mockito-core的最近版本是2.18.3,但是请不要随意升级,因为目前powermock还未兼容最新版本。

在编写测试类时,还需要在类头部加上@Runwith(PowerMockRunner.class)@PrepareForTest({xxx.class})等注解,告诉Junit与PowerMock当前的运行环境与想要模拟的含有静态方法的类。

更详细的关于配置PowerMockito的文档请查看这里

开始之前

在开始TDD之前,让我们再看一下要良好的践行TDD都需要注意哪些? (可参阅这里

这里写图片描述


首要需求分析,将需求分解为任务列表,再从列表中挑选一个任务,转换成一组测试用例,然后不断循环去实现。

绿
快速的让测试用例变绿。

重构
识别坏味道,进行重构。

接下来着手学习TDD吧。

数据仓库(DataRepository)设计

核心的需求点如下:

  1. 能够多级缓存复用数据(内存、文件、网络)
  2. 能够同步、异步获取数据

这里写图片描述

对外API设计

  1. 同步全量获取视频信息 List<VideoInfo> getAllVideoSync();
  2. 异步全量获取视频信息 List<VideoInfo> getAllVideoAsync();

逻辑层中 :

  1. 获取数据时,需要按照先内存、再文件缓存、最后网络数据的顺序来获取数据。

在本篇文章中,我们从任务列表中选取同步全量获取视频信息这个任务,并转化成为一组测试用例:

  1. 获取SDK实例
  2. 调用getAllVideoSync()方法
  3. 从内存中获取数据
  4. 先从内存拿数据,为空再从文件缓存中获取数据
  5. 先从内存拿数据,为空再从文件缓存中拿数据,为空再从网络中获取数据
  6. 校验网络数据是否被存储到文件缓存中
  7. 校验数据是否正确

做完用例分解后,就让我们一步一步按照“红 - 绿 - 蓝”的节奏来编写用例与逻辑吧。

数据仓库(DataRepository) - 用例编写与实现

Demo中,每完成一个Case的编写与实现都会Commit一次,我会尽量做到完善。

让我们踏上征途吧。

CI - 获取SDK实例

先给我们的SDK起个好听的名字DataRepository,再把它做成一个单例。

再考虑下如何验证有效性!只需要断言判定getInstance()获取的数据不为空就好了。

Commit记录

CI - 调用getAllVideoSync()方法

DataRepository中创建了getAllVideoSync()方法并创建了VideoInfo类。

由于本次的目的只是验证getAllVideoSync()方法是否能够正确调用,所以我们不关心返回结果。

Commit记录

CI - 从内存中获取数据

本次想要从内存中获取缓存数据,所以关心返回结果。

可以直接在TestCase中直接给DataRepositorymAllVideoInfo赋值,来达到模拟内存缓存值存在的情况。

Commit记录

CI - 先从内存拿数据,为空再从文件缓存中获取数据

从这一个Case开始就变得复杂一些了。虽然Commit记录中详细的写明了我都做了什么,但是还是简要介绍一下做这些操作的思路。

步骤一:重构了获取DataRepository的方法,原因是每次都写一下获取逻辑很麻烦,还不如封装一下。

步骤二: 由于验证的行为是内存数据为空,但是缓存值存在,那么就需要修改验证结果。

// 验证文件缓存值存在,获取结果不为null
List<VideoInfo> userInfoList = instance.getAllVideoSync();
assertThat(userInfoList, is(nullValue()));

步骤三:运行单测之后自然是红色错误,我们转而进入DataRepositorygetAllVideoSync内部去实现获取缓存的逻辑。 自然而然,我们期望有一个帮助类能够直接拿到缓存的结果,从那个文件拿我们并不关心,于是我们有了FileCacheHelper.getAllVideoCache(),在编写逻辑之后,代码如下:

public List<VideoInfo> getAllVideoSync() {

    // 内存缓存存在时直接返回
    if (mAllVideoInfo != null) {
        return mAllVideoInfo;
    }

    // 获取文件缓存,文件缓存存在时赋值给内存缓存并返回数据
    mAllVideoInfo = mFileCacheHelper.getAllVideoCache();
    if (mAllVideoInfo != null) {
        return mAllVideoInfo;
    }
    return mAllVideoInfo;
}

此时是连编译都无法通过的,原因是还没有mFileCacheHelper这个变量,而且连FileCacheHelper类也没有创建,此外,FileCacheHelper也不能直接在UserInfoManager中构建出来,如果要构建,那么注意力就转移到FileCacheHelper的实现上了。

当创建完FileCacheHelper以及getAllVideoCache方法,再回到测试用例中。 由于本阶段并不关心FileCacheHelper类的真是逻辑如何,所以mock来模拟一个FileCacheHelper的实例对象,并且希望它的getAllVideoCache()方法默认返回null

// 模拟一个FileCacheHelper实例
@Mock
FileCacheHelper mFileCacheHelper;


// Refactor Get Instance Method
private DataRepository getNewInstance() {
    ...
    // 让FileCacheHelper.getAllUserInfoCache默认返回null
    when(mFileCacheHelper.getAllVideoCache()).thenReturn(null);

    return instance;
}

再回到getAllVideoSync_memoryCacheNull_diskCacheExist单元测试中,由于是验证文件缓存存在的情况,所以期望mFileCacheHelper.getAllVideoCache()返回一个有效值:

// 假设逻辑调用mFileCacheHelper.getAllVideoCache()时,返回一个空列表
when(mFileCacheHelper.getAllVideoCache()).thenReturn(new ArrayList<UserInfo>());

完成这一步,单元测试就能跑通了,而getAllVideoSync()中关于文件缓存获取的逻辑也完成了。

Commit记录 - 重构获取DataRepository的方法

Commit记录 - 实现文件缓存逻辑

CI - 先从内存拿数据,为空再从文件缓存中拿数据,为空再从网络中获取数据

这里的步骤和上面一个非常类似,所以就不重复表述了。

从这两个Case中可以知道,完全可以在仅关心DataRepository的情况下,把逻辑补充完整,暂时不关心的FileCacheHelperNetHelper可以暂放一边,通过模拟它们来跑通逻辑。

相信不用我说,你也一定知道这多么有用处。

Commit记录

CI - 校验网络数据是否被存储到文件缓存中

为了确保模拟的FileCacheHelper类中的方法被调用,可以使用如下的方法:

// 验证行为,保存到缓存中是否被执行了
 verify(mFileCacheHelper).saveDataToCache(list);

Commit记录

好处与缺陷

如果仔细看完上面数据仓库的例子,相信你对测试驱动开发一定有一些认识了,下面就谈谈它的好处与难点。

好处:

  1. 给予开发者信心,让你知道自己写的代码是可靠的
  2. 提升对项目需求分析、分解任务、安排优先级的能力
  3. 提高重构的能力
  4. 将焦点聚集在当前关注的地方

难点:
1. 思维方式的转变 - “很多人不懂“意图式编程”,总是习惯先实现一个东西,再去调用它。而测试先行就要求先使用,再实现。这样能少走很多弯路,减少返工。”
2. 测试框架的选型与使用,虽然MockitoPowerMockito已经很简单了,但是还是有一些学习成本的。

最后引用一段话,也是我想说的:

最后我想说:
TDD不是银弹,不可能适合所有的场景,但这不应该成为我们拒绝它的理由。
也不要轻易否定TDD,如果要否定,起码要在认真实践过之后。

最后,祝好~

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Android开发案例驱动教程》 配套代码。 注: 由于第12,13,14章代码太大,无法上传到一个包中。 这三节代码会放到其他压缩包中。 作者:关东升,赵志荣 Java或C++程序员转变成为Android程序员 采用案例驱动模式展开讲解知识点,即介绍案例->案例涉及技术->展开知识点->总结的方式 本书作者从事多年一线开发和培训,讲解知识点力求细致,深入浅出 目 录 前言 第1章 Android操作系统概述 1 1.1 Android历史介绍 1 1.2 Android架构 1 1.3 Android平台介绍 2 1.4 现有智能手机操作系统比较 4 第2章 Android开发环境搭建 5 2.1 Eclipse和ADT插件 5 2.1.1 Eclipse安装 5 2.1.2 ADT插件 6 2.2 Android SDK 8 2.2.1 Android SDK的获得 8 2.2.2 Android SDK版本说明 10 2.2.3 ADT配置 10 2.3 Android开发模拟器 11 2.3.1 创建模拟器 11 2.3.2 启动模拟器 13 2.3.3 键盘映射与模拟器控制 13 2.3.4 横屏与竖屏切换 14 第3章 第一个Android程序 15 3.1 HelloAndroid 15 3.1.1 在Eclipse中创建项目 15 3.1.2 编写程序项目代码 17 3.1.3 运行HelloAndroid 18 3.1.4 Android工程目录 19 3.1.5 AndroidManifest.xml文件 21 3.2 Android中的组件介绍 22 3.3 使用Android SDK帮助 23 3.3.1 Android SDK API文档 23 3.3.2 Android SDK开发指南 24 3.3.3 Android SDK samples 24 3.4 使用DDMS帮助调试程序 26 3.4.1 启动DDMS 26 3.4.2 Device 28 3.4.3 Emulator Control 29 3.4.4 File Explorer 30 3.4.5 LogCat 31 3.5 使用ADB帮助调试程序 33 3.5.1 查询模拟器实例和设备 34 3.5.2 进入shell 34 3.5.3 导入导出文件 35 3.6 应用程序的打包、安装和卸载 37 3.6.1 应用程序打包 37 3.6.2 应用程序安装 40 3.6.3 应用程序卸载 40 本章小结 42 第4章 UI基础知识 43 4.1 Android UI组件概述 43 4.1.1 View 43 4.1.2 ViewGroup 44 4.1.3 布局管理器 44 4.2 UI设计工具 44 4.2.1 DroidDraw工具 44 4.2.2 ADT插件UI设计工具 46 4.3 事件处理模型 47 4.3.1 接口实现事件处理模型 47 4.3.2 内部类事件处理模型 49 4.3.3 匿名内部类事件处理模型 51 4.4 Activity中的常用事件 53 4.4.1 触摸事件 53 4.4.2 键盘事件 55 4.5 菜单 57 4.5.1 文本菜单 57 4.5.2 图片文本菜单 59 本章小结 60 第5章 UI基础控件 61 5.1 按钮 61 5.1.1 Button 62 5.1.2 ImageButton 63 5.1.3 ToggleButton 64 5.2 TextView 64 5.3 EditText 65 5.4 RadioButton和RadioGroup 66 5.4.1 RadioButton 66 5.4.2 RadioGroup 67 5.5 CheckBox 68 5.6 ImageView 70 5.7 Progress Bar 70 5.7.1 条状进度条 71 5.7.2 圆形进度条 73 5.7.3 对话框进度条 74 5.7.4 标题栏中进度条 75 5.8 SeekBar 76 5.9 RatingBar 78 本章小结 82 第6章 UI高级控件 83 6.1 列表类控件 83 6.1.1 Adapter概念 83 6.1.2 AutoComplete 84 6.1.3 Spinner 87 6.1.4 ListView 90 6.1.5 GridView 96 6.1.6 Gallery 99 6.2 Toast 103 6.2.1 文本类型 103 6.2.2 图片类型 104 6.2.3 复合类型 105 6.2.4 自定义显示位置Toast 106 6.3 对话框 107 6.3.1 文本信息对话框 107 6.3.2 简单列表项对话框 109 6.3.3 单选项列表项对话框 111 6.3.4 复选框列表项对话框 113 6.3.5 复杂布局列表项对话框 115 6.4 Android国际化和本地化 118 本章小结 121 第7章 UI布局 122 7.1 FrameLayout 122 7.1.1 TextSwitcher 124 7.1.2 ImageSwitcher 126 7.1.3 DatePicker 129 7.1.4 TimePicker 131 7.1.5 ScrollView 133 7.1.6 选项卡 134 7.2 LinearLayout 138 7.3 RelativeLayout 139 7.4 AbsoluteLayout 141 7.5 TableLayout 143 7.6 布局嵌套 146 7.7 屏幕旋转 152 本章小结 154 第8章 多线程 155 8.1 多线程案例--计时器 155 8.2 线程概念 156 8.2.1 进程概念 156 8.2.2 线程概念 156 8.3 Java中的线程 157 8.3.1 Java中的实现线程体方式1 157 8.3.2 Java中的实现线程体方式2 160 8.3.3 Java中的实现线程体方式3 162 8.4 Android中的线程 163 8.4.1 Android线程应用中的问题与分析 164 8.4.2 Message和MessageQueue 169 8.4.3 Handler 169 8.4.4 Looper和HandlerThread 172 本章小结 178 第9章 Activity和Intent 179 9.1 Activity 179 9.1.1 创建Activity 179 9.1.2 Activity生命周期 180 9.2 Intent 183 9.2.1 显式Intent 184 9.2.2 隐式Intent 186 9.2.3 匹配组件 186 9.3 多Activity之间跳转 188 9.3.1 多个Activity之间数据传递 189 9.3.2 跳转与返回 192 9.3.3 任务与标志 196 9.4 Android系统内置Intent 199 本章小结 201 第10章 数据存储 203 10.1 健康助手案例 203 10.2 Android数据存储概述 205 10.3 本地文件 205 10.3.1 访问SD卡 207 10.3.2 访问应用文件目录 212 10.4 SQLite数据库 216 10.4.1 SQLite数据类型 216 10.4.2 Android平台下管理SQLite数据库 216 10.5 编写访问SQLite数据库组件 220 10.5.1 DBHelper类 220 10.5.2 数据插入 222 10.5.3 数据删除 224 10.5.4 数据修改 224 10.5.5 数据查询 227 10.6 案例重构 229 10.6.1 系统架构设计 229 10.6.2 重构数据访问层 230 10.7 为案例增加参数设置功能 238 10.7.1 Shared Preferences 240 10.7.2 Preferences控件介绍 243 10.7.3 使用Preferences控件的案例 248 本章小结 250 第11章 Content Provider 251 11.1 Content Provider概述 251 11.2 Content URI 252 11.2.1 Content URI含义 252 11.2.2 内置的Content URI 253 11.3 通过Content Provider访问联系人 253 11.3.1 查询联系人 255 11.3.2 通过联系人ID查询联系人的Email 258 11.3.3 按照过滤条件查询Email 259 11.3.4 查询联系人的电话 261 11.4 通过Content Provider访问通话记录 262 11.4.1 查询通话记录 262 11.4.2 按照过滤条件查询通话记录 264 11.5 通过Content Provider访问短信 266 11.6 自定义Content Provider实现数据访问 269 11.6.1 编写Content Provider 269 11.6.2 在不同的应用中调用Content Provider 277 11.6.3 重构Content Provider调用 278 本章小结 281 第12章 多媒体 282 12.1 多媒体文件介绍 282 12.1.1 音频多媒体文件介绍 282 12.1.2 视频多媒体文件介绍 283 12.2 Android音频播放 284 12.2.1 Android音频/视频播放状态 284 12.2.2 音频播放案例介绍 286 12.2.3 资源音频文件播放 287 12.2.4 本地音频文件播放 291 12.2.5 网络音频文件播放 292 12.2.6 完善案例其他功能 293 12.3 Android音频录制 303 12.3.1 Android音频/视频录制状态 303 12.3.2 音频录制案例介绍 303 12.3.3 音频录制案例实现 305 12.4 Android视频播放 309 12.4.1 视频播放案例 309 12.4.2 采用MediaPlayer类播放视频 310 12.4.3 使用VideoView控件重构案例 315 本章小结 316 第13章 Service 317 13.1 Service概述 317 13.1.1 本地Service生命周期 317 13.1.2 远程Service生命周期 318 13.2 本地Service 319 13.2.1 本地Service案例 319 13.2.2 编写AudioService 320 13.2.3 调用Service 322 13.2.4 重构案例 323 13.3 远程Service 325 13.3.1 远程Service调用原理 325 13.3.2 远程Service案例 326 13.3.3 设计AIDL文件 327 13.3.4 编写AudioService 331 13.3.5 调用远程Service 336 13.3.6 组件间参数传递 343 本章小结 347 第14章 Broadcast Receiver和Notification 348 14.1 Broadcast Receiver 348 14.1.1 音频播放案例 349 14.1.2 编写音频播放Broadcast Receiver 350 14.1.3 注册音频播放Broadcast Receiver 351 14.1.4 接收系统的广播 353 14.1.5 MP3下载服务案例 353 14.2 Notification 358 14.2.1 完善MP3下载服务案例 358 14.2.2 完善音频播放案例 363 14.2.3 其他形式的Notification 369 本章小结 371 第15章 云端应用 372 15.1 典型云端应用--城市天气信息服务 372 15.2 网络通信技术与实现 374 15.2.1 网络通信技术介绍 376 15.2.2 Java URL类实现方式 377 15.2.3 Apache HttpClient实现方式 378 15.3 数据交换格式 380 15.3.1 纯文本格式 381 15.3.2 XML格式 381 15.3.3 JSON格式 385 15.4 自定义服务器端程序实例 387 15.4.1 Java Servlet概述 387 15.4.2 编写城市信息服务的Servlet 388 15.4.3 编写城市天气服务的Servlet 393 15.4.4 再次探讨HttpClient的POST请求 395 15.5 云端应用案例优化 400 本章小结 404 第16章 Google Map和定位服务 405 16.1 MyMap服务系统案例 405 16.2 Android Google Map 406 16.2.1 申请Google Map Android API Key 407 16.2.2 编写Android Google Map骨架程序 409 16.2.3 控制地图 412 16.2.4 地图的显示模式 416 16.2.5 地图的图层 419 16.2.6 查询与定位 422 16.3 Android定位服务 430 16.3.1 开启定位服务 431 16.3.2 模拟测试 433 16.3.3 GPS与Google Map结合 435 16.4 案例重构 437 16.4.1 重构"定位查询"方法 438 16.4.2 重构"查询周围"方法 440 本章小结 443 第17章 Android通信应用 444 17.1 电话应用开发 444 17.1.1 拨打电话功能 444 17.1.2 呼入电话状态 446 17.2 短信和彩信应用开发 450 17.2.1 Android内置的发送短信/彩信功能 450 17.2.2 自己编写发送文本内容的短信 452 17.2.3 自己编写接收文本内容的短信 458 17.2.4 自己编写发送二进制内容的短信 459 17.2.5 自己编写接收二进制内容的短信 461 17.3 蓝牙通信 463 17.3.1 Android 2 BluetoothChat案例 464 17.3.2 Android 2 蓝牙API介绍 464 17.3.3 TCP Socket与蓝牙Socket的区别 465 17.3.4 BluetoothChat中的类 466 17.3.5 初始化本地蓝牙设备 467 17.3.6 查找蓝牙设备 471 17.3.7 管理连接 476 17.3.8 互相之间的通信 480 17.4 WiFi通信 484 17.4.1 管理WiFi 484 17.4.2 扫描热点 487 17.4.3 Socket通信 489

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值