esp8266时钟+天气+提醒(二)代码篇一

esp8266时钟+天气+提醒(二)代码篇一

四、站在巨人的肩膀上

1. 导论

先来看看别人是怎么实现的:利用ESP8266+OLED(I2C)打造智能时钟(网络校时+实时天气+天气预报)icon-default.png?t=N7T8https://blog.csdn.net/weixin_44668788/article/details/120643078

我们要分析这篇文章中的代码并加以改造。可以点击上面的链接去原帖膜拜一下。

同时,本文也会探讨一些方法和原理(通式通法),包括:

  • ArduinoJson的使用
  • 字体的使用
  • U8g2的使用

等一些其他内容,希望能够有所帮助。

(没想到能写这么多)

2. 连接WiFi

// 连接WiFi
void connectWiFi(){
  WiFi.begin(ssid, password);                  // 启动网络连接
  Serial.print("Connecting to ");              // 串口监视器输出网络连接信息
  Serial.print(ssid); Serial.println(" ...");  // 告知用户NodeMCU正在尝试WiFi连接
  
  int i = 0;                                   // 这一段程序语句用于检查WiFi是否连接成功
  while (WiFi.status() != WL_CONNECTED) {      // WiFi.status()函数的返回值是由NodeMCU的WiFi连接状态所决定的。 
    delay(1000);                               // 如果WiFi连接成功则返回值为WL_CONNECTED                       
    Serial.print(i++); Serial.print(' ');      // 此处通过While循环让NodeMCU每隔一秒钟检查一次WiFi.status()函数返回值
  }                                            // 同时NodeMCU将通过串口监视器输出连接时长读秒。
                                               // 这个读秒是通过变量i每隔一秒自加1来实现的。                                              
  Serial.println("");                          // WiFi连接成功后
  Serial.println("Connection established!");   // NodeMCU将通过串口监视器输出"连接成功"信息。
  Serial.print("IP address:    ");             // 同时还将输出NodeMCU的IP地址。这一功能是通过调用
  Serial.println(WiFi.localIP());              // WiFi.localIP()函数来实现的。该函数的返回值即NodeMCU的IP地址。  
}

WiFi的连接与前面的示例是基本一致的,ssid和password分别是WiFi的名称和密码。

可以像示例中一样写,也可以像这篇文章的作者一样:

//----------WIFI连接配置----------
const char* ssid     = "";                                   
const char* password = "";        

这一块是定义常量,应该写在前面,注意还要导入相关库:

#include <ESP8266WiFi.h>

3. 获取天气

获取实时天气的函数为:

//----------用于获取实时天气的函数(0)----------
void TandW(){
  String reqRes = "/v3/weather/now.json?key=" + reqUserKey +
                  + "&location=" + reqLocation + 
                  "&language=en&unit=" +reqUnit;
  // 向心知天气服务器服务器请求信息并对信息进行解析
  httpRequest(reqRes,0);
  //延迟,需要低于20次/分钟
  delay(5000);
}

其中的reqRes是要请求的地址,作者使用了心知天气,这里先简单介绍一下心知天气。

3.1 心知天气

心知天气的官网地址为:心知天气

点击上面的链接跳转官网后,点击右上角注册/登录

根据说明注册并登录后,点击控制台,然后点击产品管理->添加产品,选择免费版。

免费版可以使用V3版3项数据,具体配置可以都可以在官网中找到。

V3版的使用手册为:心知天气使用手册(V3版)

3.2 实时天气

实时天气在手册的这一页:天气实况

接口地址为:

https://api.seniverse.com/v3/weather/now.json?key=your_api_key&location=beijing&language=zh-Hans&unit=c

请求参数

参数列表见上,其中API密钥在产品管理->免费版->API密钥->私钥处获取。

原作者设置语言为英语en,其他三个参数是预先定义好的:


// 心知天气HTTP请求所需信息
String reqUserKey = "";   // 私钥
String reqLocation = "beijing";            // 城市
String reqUnit = "c";                      // 摄氏/华氏

这里填写私钥和城市的拼音。城市拼音参见:接口中的通用参数-Location

拼接好了一个字符串参数reqRes,传入函数httpRequest,这个函数的作用是发请求,在下面定义。

3.3 发送请求
// 向心知天气服务器服务器请求信息并对信息进行解析
void httpRequest(String reqRes,int stat){
  WiFiClient client;

  // 建立http请求信息
  String httpRequest = String("GET ") + reqRes + " HTTP/1.1\r\n" + 
                              "Host: " + host + "\r\n" + 
                              "Connection: close\r\n\r\n";
  Serial.println(""); 
  Serial.print("Connecting to "); Serial.print(host);

  // 尝试连接服务器
  if (client.connect(host, 80)){
    Serial.println(" Success!");
 
    // 向服务器发送http请求信息
    client.print(httpRequest);
    Serial.println("Sending request: ");
    Serial.println(httpRequest);  
 
    // 获取并显示服务器响应状态行 
    String status_response = client.readStringUntil('\n');
    Serial.print("status_response: ");
    Serial.println(status_response);
 
    // 使用find跳过HTTP响应头
    if (client.find("\r\n\r\n")) {
      Serial.println("Found Header End. Start Parsing.");
    }
    if (stat == 0){
      
      // 利用ArduinoJson库解析心知天气响应信息(实时数据)
      parseInfo_now(client,1); 
    }else if(stat == 1){
      parseInfo_fut(client,1);
    }
  }
  else {
    Serial.println(" connection failed!");
    if (stat == 0){
     
      // 利用ArduinoJson库解析心知天气响应信息(实时数据)
      parseInfo_now(clientNULL,0); 
    }else if(stat == 1){
      parseInfo_fut(clientNULL,0);
    }
  }
  
  //断开客户端与服务器连接工作
  client.stop(); 
}

可以看到,在这个函数里又定义了一个字符串变量httpRequest,这是模拟HTTP请求报文。具体原理涉及《计算机网络》,不在这里赘述了。

拼接字符串中的host是目标主机(也即接口地址的主机域名部分),在程序开始已经定义了:

//----------天气API配置----------
const char* host = "api.seniverse.com";   // 将要连接的服务器地址  
const int httpPort = 80;              // 将要连接的服务器端口     

接下来发送请求(大括号未闭合):

if (client.connect(host, 80)){
    Serial.println(" Success!");
 
    // 向服务器发送http请求信息
    client.print(httpRequest);
    Serial.println("Sending request: ");
    Serial.println(httpRequest);  

先试图连接主机,如果连接成功,client.connect(host, 80)应当为true,这个函数或者说表达式能够检验连接是否建立。

接下来,通过client.print(httpRequest)发送请求。

发送请求成功后,服务器会给我们响应,具体以响应报文的形式实现(这也是《计算机网络》中的内容)。

// 获取并显示服务器响应状态行 
    String status_response = client.readStringUntil('\n');
    Serial.print("status_response: ");
    Serial.println(status_response);
 
    // 使用find跳过HTTP响应头
    if (client.find("\r\n\r\n")) {
      Serial.println("Found Header End. Start Parsing.");
    }
    if (stat == 0){
      
      // 利用ArduinoJson库解析心知天气响应信息(实时数据)
      parseInfo_now(client,1); 
    }else if(stat == 1){
      parseInfo_fut(client,1);
    }
  }

简单来说,响应报文由响应行、响应头和响应体组成。响应行是响应报文的第一行,而readStringUntil('\n')表示读取到第一个换行符(即'\n'),可见这个status_response就是响应行。响应行包括协议、状态码和描述,可以在串口监视器中看看它长什么样。

然后,使用find跳过HTTP响应头,这里不再细讲了。

之后判断stat是0还是1,这个参数为0时解析实时信息,为1时解析未来信息。

可以看到,在用于获取实时天气的函数 TandW中,为httpRequest传的stat参数就是0,作者还特意在这个函数那里标记了0。

3.4 信息解析

信息解析需要使用ArduinoJson这个库(返回的响应是json格式的),在库管理器中安装它,就像前一篇中一样。

原作者的代码如下:

// 利用ArduinoJson库解析心知天气响应信息(实时)
void parseInfo_now(WiFiClient client,int i){

  if(i==1){
  const size_t capacity = JSON_ARRAY_SIZE(1) + JSON_OBJECT_SIZE(1) + 2*JSON_OBJECT_SIZE(3) + JSON_OBJECT_SIZE(6) + 230;
  DynamicJsonDocument doc(capacity);
  
  deserializeJson(doc, client);
  
  JsonObject results_0 = doc["results"][0];
  
  JsonObject results_0_now = results_0["now"];
  const char* results_0_now_text = results_0_now["text"]; // "Sunny"
  const char* results_0_now_code = results_0_now["code"]; // "0"
  const char* results_0_now_temperature = results_0_now["temperature"]; // "32"
  
  const char* results_0_last_update = results_0["last_update"]; // "2020-06-02T14:40:00+08:00" 
 
  // 通过串口监视器显示以上信息
  String results_0_now_text_str = results_0_now["text"].as<String>(); 
  int results_0_now_code_int = results_0_now["code"].as<int>(); 
  int results_0_now_temperature_int = results_0_now["temperature"].as<int>(); 
  String results_0_last_update_str = results_0["last_update"].as<String>();   
 
  Serial.println(F("======Weahter Now======="));
  Serial.print(F("Weather Now: "));
  Serial.print(results_0_now_text_str);
  Serial.print(F(" "));
  Serial.println(results_0_now_code_int);
  Serial.print(F("Temperature: "));
  Serial.println(results_0_now_temperature_int);
  Serial.print(F("Last Update: "));
  Serial.println(results_0_last_update_str);
  Serial.println(F("========================"));
  display_0(results_0_now_temperature_int,results_0_now_text_str);
  results_0_now_text_str_old = results_0_now_text_str;
  results_0_now_temperature_int_old = results_0_now_temperature_int;
  }
  else{
    display_0(results_0_now_temperature_int_old,results_0_now_text_str_old);
  }
 
}

解析的主要原理在于

  • 定义了一个DynamicJsonDocument对象doc,其容量根据JSON结构的需求计算而来。这是为了确保有足够的空间存储解析后的JSON数据。
  • 使用deserializeJson(doc, client)函数从client读取JSON数据,并将其解析到doc中。
  • doc中提取天气信息,包括当前天气状况、天气代码、温度和最后更新时间。

特别需要指出的是,capacity就是创建doc时指定的容量,它是根据响应而定的。

接下来介绍一下设置方法:

借助ApiPost(或者其他的接口测试工具),对接口进行HTTP测试,得到响应为

实时天气接口响应

观察响应的结构,可以发现:

  1. 顶层对象:仅包含一个键results
  2. results数组:包含一个对象。
  3. results中的对象:包含三个键(location, now, last_update)。
  4. location对象:包含六个键(id, name, country, path, timezone, timezone_offset)。
  5. now对象:包含三个键(text, code, temperature)。

由此可知容量是上面五个的和:

const size_t capacity = JSON_ARRAY_SIZE(1) +  // results数组
                        JSON_OBJECT_SIZE(3) + // 对应results中的对象,有三个键
                        JSON_OBJECT_SIZE(6) + // 对应location对象
                        JSON_OBJECT_SIZE(3) + // 对应now对象
                        JSON_OBJECT_SIZE(1)   // 顶层对象

和原作者的比对一下,会发现还差了230,这是哪里来的呢?

其实,因为这个json里面有大量的字符串,所以需要额外的空间来保证字符串值都被存储。

字符串值的内存开销通常比简单的数字或布尔值要大,因为它们需要存储每个字符。此外,还需要为每个字符串分配结束字符'\0'(这是《C++程序设计》中的,可以去阅读相关书籍,这里不再赘述)。

但是这个值到底该怎么取呢?

实际上,这个值通常是一个估计值,因为除了上述开销,还应当留有一定的裕量。

但是,可以借助ArduinoJson库的Assistant来方便地确定这个值,链接:https://arduinojson.org/v6/assistant/

在步骤2中输入Json即可,这里就不放图了,接下来是步骤3:

可以看到,在Strings这一项,显示的数字是204,也就是说,对于我们这个capacity,需要加上的字符串存储空间为204,并且至少为204(建议发请求时设定language为en,就像原作者一样,因为中文占3字节,为了节省空间,还是英文更好一点)。

再考虑到裕量,所以设定为230,这是比较合理的选择。

以上就是通式通法。

特别说明:上述求和的方法目前已被弃用,推荐设为定值,即采用ArduinoJson库的Assistant的建议,也许还有其他的设置,但我还没有研究,仍采用的这种旧方法。如果有大佬有方案,可以在评论区指点一手或者贴个链接让我去膜一下。O(∩_∩)O

此外再补充两点:

  1. 如果出现错误DeserializationError::NoMemory,这意味着容量选小了,应当扩大容量。
  2. DynamicJsonDocument,内存分配在heap区,无固定大小,可以自动增长所需空间,方法调用完自动回收,建议内存大小大于1KB使用。

上面的第2条我是在简书上看到的,原文链接为玩转 ESP32 + Arduino (十五) ArduinoJSON库(V6版本),欢迎前去膜拜。

此外,这个assistant还有一个非常强大的功能,它可以根据json生成解析代码:

这大大减小了撰写代码的困难程度。

原作者在这里进行了类型转换:

  String results_0_now_text_str = results_0_now["text"].as<String>(); 
  int results_0_now_code_int = results_0_now["code"].as<int>(); 
  int results_0_now_temperature_int = results_0_now["temperature"].as<int>(); 
  String results_0_last_update_str = results_0["last_update"].as<String>(); 

本来提取到的数据都是C风格的字符串,在这里将其转换成了String和int类型。

在最开始,定义了两个全局变量,用于保存断网前的最新数据:

//保存断网前的最新数据
int results_0_now_temperature_int_old;
String results_0_now_text_str_old;
int results_0_daily_1_high_int_old;
int results_0_daily_1_low_int_old;
String results_0_daily_1_text_day_str_old;

可以看到,在类型转换后,就将获得的数据展示并储存了下来:

  display_0(results_0_now_temperature_int,results_0_now_text_str);
  results_0_now_text_str_old = results_0_now_text_str;
  results_0_now_temperature_int_old = results_0_now_temperature_int;

其中display_0函数就是用于将数据显示到屏幕上,而下面两行的作用就是将数据储存在全局变量里,这样就可以在其他函数中使用了。

值得注意的是,在这个解析函数parseInfo_now中,除了client这个需要解析的对象外,还存在一个参数i,不难看出,如果i==1,就解析数据、更新数据、显示数据,而其他情况下就直接显示原有数据,即results_0_now_temperature_int_old和results_0_now_text_str_old。

3.5 未来天气

与实时天气大同小异,这里不再赘述了。

4. 数据展示

上面用到的display_0函数如下:

//----------输出实时天气----------
void display_0(int results_0_now_temperature_int,String results_0_now_text_str){
  //显示输出
  u8g2.clearBuffer();
  u8g2.setFont(u8g2_font_wqy16_t_gb2312);
  u8g2.setCursor(15, 14);
  u8g2.print("杭州实时天气");
  u8g2.setFont(u8g2_font_logisoso24_tr);
  u8g2.setCursor(45, 44);
  u8g2.print(results_0_now_temperature_int);
  u8g2.setCursor(35, 61);
  u8g2.setFont(u8g2_font_unifont_t_chinese2);
  u8g2.print(results_0_now_text_str);
  u8g2.sendBuffer();
}

这一部分使用了u8g2这个库,它是一个非常强大的LCD、OLED、elink的扩展库,需要在库管理器中安装。如果使用的显示屏属于上面三种,就可以使用这个库。

注意导入方式为:

#include <U8g2lib.h>
4.1 U8g2

接下来先简单介绍一下这个库的常用函数:

u8g2.clearBuffer();

这个函数的作用是清除图形缓冲区。这是准备绘制新图形之前的常见步骤,以确保从干净的状态开始,简言之——清屏。

u8g2.setFont(u8g2_font_wqy16_t_gb2312);

这个函数的作用是设置字体,可以看到我们已经传入了一个参数u8g2_font_wqy16_t_gb2312,它代表着龙泉驿点阵宋体16x16 点阵字库。

GitHub上有个中文字库仓库,得到了较为广泛地使用:https://github.com/larryli/u8g2_wqy

同样,也有英文字库。具体可以搜索相关资料,这里不再赘述。

  u8g2.setCursor(15, 14);
  u8g2.print("杭州实时天气");

这两行的作用是设置光标输出。需要注意的是,它的意思是将画图位置移动到x=15,y=14处,然后以这个点的右上区域进行字符串的显示,纵坐标必须留有裕量,否则输出会到屏幕外面,这样就看不到了。

因为刚才设置了中文字体,所以能够输出中文(想修改输出的内容就在此修改)。

此外,显示屏的分辨率值得注意,更高分辨率的显示屏可以显示更多的字符。例如,128x64像素的OLED屏幕在使用16x16字体时,理论上每行可以显示8个字符。所以这个情况是合适的(我的显示屏就是128x64的),而且这六个字得以基本居中,但如果长度大于8,建议滚动显示以保证效果。

u8g2.sendBuffer();

这个函数用于将在内存中准备好的图形缓冲区内容发送到显示屏上。在U8g2库中,绘制操作(如设置像素、绘制线条、显示文本等)首先在内存中的一个图形缓冲区(frame buffer)进行,而不是直接在显示屏上。这种方法允许复杂的图形处理和更新,而不会直接影响到屏幕显示,从而避免了绘图过程中可能出现的闪烁现象。

此外,还有draw系列函数没有用到,具体包括画线、画框等,使用方法比较简单,大家可以自行了解。

特别注意,使用以上函数前,应当先初始化显示屏,这意味着将显示屏所需的硬件接口设置为正确的工作状态,这里就是指配置I2C(这是一种基础通信协议,具体可参看《通信原理》)。

初始化方法如下:

u8g2.begin();

特别地,对于我们的项目,还需要调用下面这个方法来显示各种语言的文本(主要是中文):

u8g2.enableUTF8Print();

它的作用是允许UTF8形式输出。

4.2 温度显示

第一行的文字部分已经在4.1中简要介绍过了,接下来讨论温度显示的实现方法。

  u8g2.setFont(u8g2_font_logisoso24_tr);
  u8g2.setCursor(45, 44);
  u8g2.print(results_0_now_temperature_int);

可以观察到,上面三行代码实现了将温度显示到屏幕上。

在输出前先设置了字体为Logisoso24,它的特点是具有较大的尺寸(大约24个像素高),适合显示重要的或需要突出的信息。字体名称中的_tr后缀表示该字体支持透明背景,即在文本周围不会有一个不透明的矩形框。

但是有一个问题,原作者只设计了数字的显示,没有单位(即摄氏度°C),这样比较影响观感,如何优化呢?

很简单,在温度后面加上一行代码即可:

u8g2.print("°C");

但是这产生了又一个奇怪的问题,我们可以看到显示屏只显示了C,而没有°。

这是因为:在U8g2库中,不是所有的字体都包含全范围的Unicode字符,特别是对于一些专用或较大的字体来说,它们可能仅支持有限的字符集。

而我们所使用的Logisoso24恰好属于这种情况,解决方案有两种(对于其他的特殊字符也使用):

  1. 采用Unicode字符集,例如u8g2_font_unifont_t_symbols,它支持的字符更多。
  2. 手动绘制符号,对于这种情况就是说在合适的位置画一个圆。

优化后的代码如下(摄氏度的符号会居于数字的右下侧):

  u8g2.setFont(u8g2_font_logisoso24_tr);
  u8g2.setCursor(45, 44);
  u8g2.print(results_0_now_temperature_int);
  u8g2.setFont(u8g2_font_unifont_t_symbols);
  u8g2.print("°C");

最后是显示气象名称:

  u8g2.setCursor(35, 61);
  u8g2.setFont(u8g2_font_unifont_t_chinese2);
  u8g2.print(results_0_now_text_str);

包括Sunny、Cloudy、Overcast等等,具体可以参看心知天气的手册,不再赘述了。

不过谨记,必须发送缓冲区才能使上述生效:

u8g2.sendBuffer();

5. 时间获取

5.1 NTP

时间的获取是根据NTP实现的,先简要介绍一下NTP。

NTP(Network Time Protocol,网络时间协议),

是用于同步网络中计算机的时间的协议。它可以使计算机时钟与世界标准时间(如UTC)对齐。NTP是一种分层的、自适应的同步系统,包括一组分布式的服务器和客户端。这些服务器和客户端通过网络交换时间信息,以此来调整客户端的本地时钟。

正因为分布式,所以我们使用阿里云的:

static const char ntpServerName[] = "ntp1.aliyun.com";

它主要为国内网络设备提供服务。

NTP基于UDP报文进行传输,使用的UDP端口号为123,如下面这个函数所示:

// 向给定地址的时间服务器发送NTP请求
void sendNTPpacket(IPAddress &address)
{
    memset(packetBuffer, 0, NTP_PACKET_SIZE);
    packetBuffer[0] = 0b11100011; // LI, Version, Mode
    packetBuffer[1] = 0;          // Stratum, or type of clock
    packetBuffer[2] = 6;          // Polling Interval
    packetBuffer[3] = 0xEC;       // Peer Clock Precision
    // 8 bytes of zero for Root Delay & Root Dispersion
    packetBuffer[12] = 49;
    packetBuffer[13] = 0x4E;
    packetBuffer[14] = 49;
    packetBuffer[15] = 52;
    Udp.beginPacket(address, 123); //NTP需要使用的UDP端口号为123
    Udp.write(packetBuffer, NTP_PACKET_SIZE);
    Udp.endPacket();
}

UDP是一种运输层协议,具体原理、应用等可参看《计算机网络》。

NTP的具体实现原理可以参看《通信原理》,这里不再细讲原理。

特别需要指出的是,尽管NTP的精度很高,但误差仍然存在,因此如果发现存在几百毫秒的误差,也是正常的。当然,我们的项目也并不要求那么精确。

5.2 代码实现

获取数据的部分已经在5.1中展示了,数据显示的部分如下:

void oledClockDisplay()
{
    int years, months, days, hours, minutes, seconds, weekdays;
    years = year();
    months = month();
    days = day();
    hours = hour();
    minutes = minute();
    seconds = second();
    weekdays = weekday();
    Serial.printf("%d/%d/%d %d:%d:%d Weekday:%d\n", years, months, days, hours, minutes, seconds, weekdays);
    u8g2.clearBuffer();
    u8g2.setFont(u8g2_font_unifont_t_chinese2);
    u8g2.setCursor(0, 14);
    if (isNTPConnected)
    {
        if(timeZone>=0)
        {
            u8g2.print("当前时间(UTC+");
            u8g2.print(timeZone);
            u8g2.print(")");
        }
        else
        {
            u8g2.print("当前时间(UTC");
            u8g2.print(timeZone);
            u8g2.print(")");
        }
    }
    else
        u8g2.print("无网络!"); //如果上次对时失败,则会显示无网络
    String currentTime = "";
    if (hours < 10)
        currentTime += 0;
    currentTime += hours;
    currentTime += ":";
    if (minutes < 10)
        currentTime += 0;
    currentTime += minutes;
    currentTime += ":";
    if (seconds < 10)
        currentTime += 0;
    currentTime += seconds;
    String currentDay = "";
    currentDay += years;
    currentDay += "/";
    if (months < 10)
        currentDay += 0;
    currentDay += months;
    currentDay += "/";
    if (days < 10)
        currentDay += 0;
    currentDay += days;

    u8g2.setFont(u8g2_font_logisoso24_tr);
    u8g2.setCursor(0, 44);
    u8g2.print(currentTime);
    u8g2.setCursor(0, 61);
    u8g2.setFont(u8g2_font_unifont_t_chinese2);
    u8g2.print(currentDay);
    u8g2.drawXBM(80, 48, 16, 16, xing);
    u8g2.setCursor(95, 62);
    u8g2.print("期");
    if (weekdays == 1)
        u8g2.print("日");
    else if (weekdays == 2)
        u8g2.print("一");
    else if (weekdays == 3)
        u8g2.print("二");
    else if (weekdays == 4)
        u8g2.print("三");
    else if (weekdays == 5)
        u8g2.print("四");
    else if (weekdays == 6)
        u8g2.print("五");
    else if (weekdays == 7)
        u8g2.drawXBM(111, 49, 16, 16, liu);
    u8g2.sendBuffer();
}

大家可以按需修改,如果本篇文章有疏漏,参考原作者的文章即可:利用ESP8266+OLED(I2C)打造智能时钟(网络校时+实时天气+天气预报)

此外还有断网处理,与3.4中相近,这里不再赘述了。

未完待续

没想到写了这么多,,下一篇放我自己的代码(也许说是补丁或者其他的更加合适...)。

至少下一篇会介绍ESP8266的中断机制,其他的emm看情况看看能写多少吧。

  • 18
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值