尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Winsock2.h 深度解析:C语言Socket编程从入门到避坑指南

Winsock2.h 深度解析:C语言Socket编程从入门到避坑指南 简介网络编程是软件开发者绕不开的必修课而C语言作为底层系统编程的经典语言在Windows平台上的Socket编程更是一项核心技能。网络通信基于TCP/IP协议栈在Windows系统中通过Winsock接口实现其中winsock2.h头文件是承载这些API的关键所在。理解头文件的组织逻辑和编译链接机制是搭建C语言网络通信程序的地基。在实际工程中开发者常会遇到windows.h与winsock2.h头文件包含顺序引发的重定义错误或是缺失ws2_32.lib导致的链接失败。本文将梳理网络编程的基本概念与技术原理从环境配置、编译选项到数据结构与核心API用法再到典型的TCP回显程序实现并结合实际操作演示如何利用netstat等工具排查错误码。无论是入门C语言网络开发还是解决线上编译链接难题本指南都能提供实用的技术参考与高效避坑路径。 写网络程序绕不开WINSOCK2.H这个头文件。我在 Windows 下用 C 语言写 Socket 通信第一次编译就遇到了一堆莫名其妙的“重定义”错误当时还以为是编译器坏了后来排查半天才发现是windows.h和winsock2.h的包含顺序在打架。今天就把这块彻底讲清楚从头文件本身的意义到完整跑通一个 TCP 回显程序再到那些你一定会遇到的坑一次说透。这篇文章适合刚接触 Windows 网络编程的 C 语言学习者也适合那些已经被各种链接错误折磨到怀疑人生的开发者看完照着做你至少能少走一个星期的弯路。1. 为什么网络编程绕不开 WINSOCK2.H1.1 Winsock 究竟是什么Winsock 的全称是 Windows Sockets API它规定了 Windows 系统下如何用 C 语言编写基于 TCP/IP 协议的网络通信程序。Winsock 1.1 时代对应的头文件是winsock.h但 1.1 版本功能太弱不支持多个协议栈同时并存、不支持重叠 I/O 等高级特性。到了 Winsock 2.0微软彻底重写了这套 API把对新协议的支持、异步通信机制、服务质量控制全部塞了进来头文件也从winsock.h变成了winsock2.h。有一个关键点很多人没意识到winsock2.h并不是winsock.h的简单升级而是“扩展重写”。如果你在代码里先包含了windows.h由于windows.h内部默认会去包含老的winsock.h紧接着你又包含winsock2.h两个头文件里的类型定义就会冲突编译器直接给你刷出几十条“重定义”错误。这种错误在初学阶段极容易遇到因为很多教材会告诉你“网络编程先包含 windows.h”这句话在 Winsock 2 时代已经成了技术债务。顺便提一嘴winsock2.h对应的导入库是ws2_32.lib不是老版本的wsock32.lib。你会在很多老旧的教程或者网上流传的代码里看到#pragma comment(lib, wsock32.lib)这种方式在新一代 Windows 系统上虽然也能链接通过但它只支持 Winsock 1.1 的 API。如果你调用了WSAPoll、WSASend这些 2.0 才有的函数链接就会报错。所以统一用ws2_32.lib才是最稳的。1.2 winsock2.h 和 windows.h 的冲突怎么解这个冲突是所有 Windows 网络编程新手的第一道坎。问题产生的根源在于windows.h这个“全家桶”头文件里面有一个条件编译分支在没有定义WIN32_LEAN_AND_MEAN宏的情况下它会自动包含winsock.h。于是当你写下#include windows.h #include winsock2.h预处理器先处理windows.h把老版本的 socket 相关类型、函数声明全部引入紧接着处理winsock2.h又引入了一遍新版本的。编译器跟着就晕了它看到SOCKET、sockaddr_in、timeval这些类型有两个不同的定义直接报重定义错误。解决办法有几种我按推荐程度排个序在包含windows.h之前定义WIN32_LEAN_AND_MEAN宏这样windows.h就不会去碰winsock.h#define WIN32_LEAN_AND_MEAN #include windows.h #include winsock2.h直接不包含windows.h纯写网络部分用到的 Windows 基础类型 Winsock 头文件本身会补齐。把winsock2.h放在windows.h前面。因为winsock2.h内部会定义一个叫_WINSOCKAPI_的宏windows.h在包含winsock.h之前会判断这个宏如果已经定义了就跳过老头文件。这个方案依赖宏名称的“约定”不推荐但你会在很多库里看到这种写法。第 1 种方案是我用得最多的干净利落能顺带提升编译速度。因为WIN32_LEAN_AND_MEAN还能让编译器少解析一堆用不到的头文件编译时间肉眼可见地缩短。1.3 链接阶段还要交代的事C 语言的头文件只负责放声明真正干活的代码在库文件里。winsock2.h里面全是 API 的声明而函数的实现位于系统动态库ws2_32.dll里对应的静态导入库是ws2_32.lib。你调用socket()、connect()、send()这些函数编译器生成目标文件时会留下一个“未解析符号”链接器需要在ws2_32.lib里找到这些符号地址把它填进去最终程序运行时再由 Windows 加载器去ws2_32.dll里加载真实代码。这就是为什么每次都要写#pragma comment(lib, ws2_32.lib)如果你用的是 Visual Studio也可以在项目属性 - 链接器 - 输入 - 附加依赖项里手动添加ws2_32.lib。如果你是 gcc 或 MinGW 用户则编译命令里要带上-lws2_32。比如gcc -o server.exe server.c -lws2_32注意是-lws2_32不是-lwsock32。前者对应 Winsock 2后者对应老版本我曾经在这个细节上栽过跟头。还有一个更隐蔽的坑#pragma comment(lib, ...)是微软的编译器指令gcc 和 clang 不认这个东西它们会把它当成一个未知的 pragma 直接忽略掉。所以写跨编译器代码时最好用#ifdef _MSC_VER把这条指令包起来然后用 Makefile 或者 CMake 去控制链接库这样代码在 Windows 的 MSVC、MinGW、甚至 Linux 上迁移都有路可退。2. 动手搭环境头文件路径、编译器和链接选项2.1 Visual Studio 环境配置要点在 Visual Studio 里新建一个空项目写网络程序之前先做两件小事第一把字符集设置为“未设置”。VS 默认是 Unicode 字符集会把main悄悄改写成wmain对初学者容易造成困惑。右键项目 - 属性 - 常规 - 字符集选择“未设置”。第二确认不启用预编译头。VS 默认新建项目会开“预编译头文件”机制生成一个pch.h要求每个源文件开头都包含它否则编译器报错。对于单独写一个网络小程序来说这个机制非常烦人直接在项目属性 - C/C - 预编译头里选“不使用预编译头”。配置完这两步加上一条#pragma comment(lib, ws2_32.lib)工程就能编译了。需要提醒的是VS 的解决方案管理器里如果找不到ws2_32.lib这个文件不用担心它是 Windows SDK 自带的你在代码里写链接指令就够了不需要手动拖入项目。2.2 VSCode MinGW 环境配置真相用 VSCode 写 C 语言的网络程序最大的问题不是配置 task而是编辑器怎么正确识别winsock2.h。你打开 VSCode 放入代码很快会发现编译器能编译但编辑器把WSAStartup划了一堆红色波浪线。原因在于 VSCode 的 C/C 插件默认没有把 MinGW 的头文件路径加到 includePath 里。解决方案是在项目根目录建立.vscode/c_cpp_properties.json文件把编译器路径和头文件路径手动指进去。以 MinGW-w64 为例假设你的编译器在C:/msys64/mingw64/bin/gcc.exe那么 includePath 应该写成这样{ configurations: [ { name: Win32, includePath: [ ${workspaceFolder}/**, C:/msys64/mingw64/include, C:/msys64/mingw64/x86_64-w64-mingw32/include ], compilerPath: C:/msys64/mingw64/bin/gcc.exe, cStandard: c11, intelliSenseMode: windows-gcc-x64 } ], version: 4 }配置完之后按CtrlShiftI重新加载 IntelliSense红色波浪线基本消失Ctrl点击头文件和函数也能跳转了。如果你发现跳转还是失效多半是因为你没有在文件里真正包含winsock2.h或者包含顺序不对导致宏冲突IntelliSense 放弃了对后续文件的分析。2.3 链接命令别写错VSCode 里用 MinGW 编译网络程序任务配置里最容易漏的就是-lws2_32。tasks.json里的 args 大致长这样args: [ -g, -o, ${fileDirname}/${fileBasenameNoExtension}.exe, ${fileDirname}/${fileBasenameNoExtension}.c, -lws2_32 ]把-lws2_32加在源文件后面位置其实没有严格要求但建议放在末尾因为链接器是按顺序扫描未解析符号的。虽然对于静态导入库来说顺序问题出现的概率很小但养成好习惯总没错。有个常见的“玄学”现象链接器报undefined reference toWSAStartup8这几乎可以肯定是没加-lws2_32。而报undefined reference to__imp_WSAStartup则说明你在代码里用了#include winsock.h链接器按 Winsock 1.1 的符号去匹配了。这两种报错长得很像但成因完全不同排查时先看头文件包含顺序再看链接选项。3. 核心 API 与数据结构照着抄之前先把原理吃透3.1 WSAStartup一切的开端Winsock 程序的第一步永远是WSAStartup它的作用是告诉操作系统“我要使用 Winsock 库这是我的版本需求请你初始化相应的资源。”函数签名int WSAStartup(WORD wVersionRequested, LPWSADATA lpWSAData);第一个参数用MAKEWORD(2, 2)表示请求 Winsock 2.2 版本。第二个参数是一个指向WSADATA结构体的指针系统初始化完成后会把实际的版本号、系统描述等信息填进去。为什么要手动指定版本号因为程序可能是用不同版本的 Winsock 编写的为了兼容性Windows 允许你声明自己所需的版本如果系统支持就返回 0。所以我经常说WSAStartup成功不意味着你的系统是 2.2 版本还要检查WSADATA.wVersion是不是真的等于 2.2。与它对应的清理函数是WSACleanup程序结束时调用释放资源。虽然 Windows 在进程退出时也会自动回收但在一个长期运行的服务程序里不调用它多次加载 DLL 后句柄泄漏是真实存在的。3.2 socket() 的底层逻辑socket()函数用来创建工作套接字签名如下SOCKET socket(int af, int type, int protocol);af是地址族写AF_INET表示 IPv4。有人写PF_INET也没错两者数值相等但语义上一个指“协议族”一个指“地址族”实际使用中混着写也编译得过去。type是套接字类型SOCK_STREAM表示流式套接字对应 TCPSOCK_DGRAM表示数据报套接字对应 UDP。protocol通常填 0让系统根据前两个参数自动推断。返回的SOCKET在 Winsock 2 中本质上是一个句柄它不一定是真正的文件描述符。所以你不能在 Windows 上直接把socket()的返回值塞给read()/write()必须调用recv()/send()或者WSARecv()/WSASend()。这一点和 Linux 差异很大我从 Linux 转过来写了半天才发现write()一直报错。3.3 网络字节序地址和端口为什么都要转网络通信中数据在网络上传输时采用的是大端字节序而 Intel CPU 的机器在内存中用的是小端字节序。你写了一个端口号 8080十进制是 0x1F90存到内存里可能是 90 1F 00 00直接发给对方的网卡对方读到 0x901F端口就变成了 36895。所以必须用htons()、htonl()把本机字节序转换为网络字节序。struct sockaddr_in server_addr; memset(server_addr, 0, sizeof(server_addr)); server_addr.sin_family AF_INET; server_addr.sin_port htons(8080); server_addr.sin_addr.s_addr inet_addr(127.0.0.1);inet_addr()的作用是把点分十进制字符串转成 32 位二进制网络字节序地址。它有个缺点出错时返回INADDR_NONE也就是0xFFFFFFFF这会导致合法地址255.255.255.255也变成“出错”。所以更严谨的写法是使用inet_pton()函数它是 IPv4/IPv6 通用的地址转换函数inet_pton(AF_INET, 127.0.0.1, server_addr.sin_addr);反方向的inet_ntoa()以及新的inet_ntop()用于把二进制地址转回字符串。inet_ntoa返回的缓冲区是静态的第二次调用会覆盖第一次的结果多线程环境下尤其危险能用inet_ntop就不要用inet_ntoa。3.4 connect、bind、listen、accept 各司其职服务端的流程是socket - bind - listen - accept。客户端的流程是socket - connect。bind()把套接字和本机的 IP、端口绑定在一起。为什么服务端一定要 bind因为客户端要主动连接必须知道服务端监听的地址和端口。客户端不 bind 也能工作因为系统会动态分配一个临时端口。但是我建议客户端也显式 bind方便在路由器和防火墙上放行规则也方便本机调试时通过netstat -ano定位进程。listen()把主动套接字变成被动监听套接字第二个参数是 listen backlog也就是内核中已完成三次握手但还没有被accept取走的连接队列长度。Windows 上这个值设置得很小比如 2也能用但在高并发场景下要调大。accept()从监听套接字的完成队列中取出一个连接返回一个新的 SOCKET 用于与该客户端通信。注意listen的那个套接字只剩一个任务接客。真正收发数据都是accept返回的新 socket。很多经典的阻塞模型就在accept之后陷入循环每个连接单独起线程处理。4. 实操从零写一个 TCP 回显服务器和客户端4.1 服务器端完整代码我写一个最简服务端功能是接收客户端数据原样发回也就是回显服务器。代码里注释写得比较全先把整体结构看完我再逐步拆解。#define WIN32_LEAN_AND_MEAN #include winsock2.h #include stdio.h #pragma comment(lib, ws2_32.lib) int main() { WSADATA wsaData; int ret WSAStartup(MAKEWORD(2, 2), wsaData); if (ret ! 0) { printf(WSAStartup failed: %d\n, ret); return 1; } SOCKET listen_sock socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (listen_sock INVALID_SOCKET) { printf(socket failed: %d\n, WSAGetLastError()); WSACleanup(); return 1; } struct sockaddr_in server_addr; memset(server_addr, 0, sizeof(server_addr)); server_addr.sin_family AF_INET; server_addr.sin_addr.s_addr htonl(INADDR_ANY); server_addr.sin_port htons(8080); ret bind(listen_sock, (struct sockaddr *)server_addr, sizeof(server_addr)); if (ret SOCKET_ERROR) { printf(bind failed: %d\n, WSAGetLastError()); closesocket(listen_sock); WSACleanup(); return 1; } ret listen(listen_sock, SOMAXCONN); if (ret SOCKET_ERROR) { printf(listen failed: %d\n, WSAGetLastError()); closesocket(listen_sock); WSACleanup(); return 1; } printf(Server listening on port 8080...\n); struct sockaddr_in client_addr; int client_len sizeof(client_addr); SOCKET client_sock accept(listen_sock, (struct sockaddr *)client_addr, client_len); if (client_sock INVALID_SOCKET) { printf(accept failed: %d\n, WSAGetLastError()); closesocket(listen_sock); WSACleanup(); return 1; } char client_ip[INET_ADDRSTRLEN] {0}; inet_ntop(AF_INET, client_addr.sin_addr, client_ip, sizeof(client_ip)); printf(Client connected: %s:%d\n, client_ip, ntohs(client_addr.sin_port)); char buffer[1024] {0}; while (1) { int recv_len recv(client_sock, buffer, sizeof(buffer) - 1, 0); if (recv_len 0) { buffer[recv_len] \0; printf(Recv: %s\n, buffer); send(client_sock, buffer, recv_len, 0); } else if (recv_len 0) { printf(Client closed connection.\n); break; } else { printf(recv failed: %d\n, WSAGetLastError()); break; } } closesocket(client_sock); closesocket(listen_sock); WSACleanup(); return 0; }这段代码的关键在于每一步的返回值检查。WSAStartup失败后不能继续执行socket()返回INVALID_SOCKET后要调用WSACleanup收尾避免资源泄漏。accept成功后原来的监听套接字依然处于监听状态所以你可以在循环里继续调用accept接受新的客户端连接这个版本为了演示简洁只处理了单个客户端。recv的返回值有三种情况大于 0 表示实际收到的字节数等于 0 表示对方关闭了连接或者调用shutdown(SD_RECEIVE)关闭了接收通道小于 0 表示出错。很多初学者在recv_len 0时继续 sleep 重试结果程序卡住这就是典型的分支检查遗漏。4.2 客户端完整代码客户端要简单一些socket 之后直接 connect 即可。#define WIN32_LEAN_AND_MEAN #include winsock2.h #include stdio.h #pragma comment(lib, ws2_32.lib) int main() { WSADATA wsaData; int ret WSAStartup(MAKEWORD(2, 2), wsaData); if (ret ! 0) { printf(WSAStartup failed: %d\n, ret); return 1; } SOCKET sock socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (sock INVALID_SOCKET) { printf(socket failed: %d\n, WSAGetLastError()); WSACleanup(); return 1; } struct sockaddr_in server_addr; memset(server_addr, 0, sizeof(server_addr)); server_addr.sin_family AF_INET; server_addr.sin_port htons(8080); inet_pton(AF_INET, 127.0.0.1, server_addr.sin_addr); ret connect(sock, (struct sockaddr *)server_addr, sizeof(server_addr)); if (ret SOCKET_ERROR) { printf(connect failed: %d\n, WSAGetLastError()); closesocket(sock); WSACleanup(); return 1; } printf(Connected to server.\n); const char *message Hello, Winsock2!; send(sock, message, (int)strlen(message), 0); char buffer[1024] {0}; int recv_len recv(sock, buffer, sizeof(buffer) - 1, 0); if (recv_len 0) { buffer[recv_len] \0; printf(Server echo: %s\n, buffer); } closesocket(sock); WSACleanup(); return 0; }客户端的connect在 TCP 协议中负责发起三次握手。连接成功不代表服务器立刻就能收到数据send只是把数据拷贝到系统协议栈的发送缓冲区真正发到网络由内核完成。所以如果send的返回值小于你准备要发送的长度不要简单当作错误要看是否返回了SOCKET_ERROR以及WSAGetLastError()的错误码。实际开发中要通过循环发送来保证完整发送或者调用WSASend的完成端口模式这已经属于进阶话题。4.3 运行测试和使用 netstat 排查先把服务器端编译运行起来再开另一个终端跑客户端你会看到客户端输出了Server echo: Hello, Winsock2!。如果你想验证端口监听状态在管理员 PowerShell 里执行netstat -ano | findstr :8080你会看到类似这样的输出TCP 0.0.0.0:8080 0.0.0.0:0 LISTENING 12345 TCP 127.0.0.1:51234 127.0.0.1:8080 ESTABLISHED 12345第一行是监听套接字第二行是已经建立的连接。最后一列是进程 PID。如果程序退出后端口没有释放你在任务管理器里找到该 PID 的进程杀掉或者直接用taskkill /F /PID 12345强杀。这里要特别提一下如果你在代码里绑定了127.0.0.1而不是INADDR_ANY那么只有回环地址能访问你的服务外网机器连不进来。反之INADDR_ANY表示监听所有网卡的 IP此时需要关掉防火墙或者放行相应端口否则外部客户端连不上。放行端口可以用netsh advfirewall firewall add rule nameMyTCP8080 dirin actionallow protocolTCP localport8080这个命令会往 Windows 防火墙添加一条入站规则记得调试完删掉不然留着是安全隐患。5. 头文件相关的常见错误与排查技巧5.1 重定义错误的终极解法如果你遇到了海量的“重定义”报错比如sockaddr : struct type redefinition几乎可以断定是windows.h包含了winsock.h后又去包含winsock2.h。这时候我一般按照下面的顺序排查检查代码开头是否定义了WIN32_LEAN_AND_MEAN。检查有没有间接包含windows.h。比如某些第三方库的头文件在你include之后内部又引入了windows.h这时候你需要在引入第三方库之前就定义WIN32_LEAN_AND_MEAN。使用#include winsock2.h时看看有没有别的头文件在winsock2.h之前显式包含了winsock.h。这种隐藏源头很难查最暴力但最有效的方法是全局搜索winsock.h和windows.h。我之前在一个项目里排查类似错误花了三个小时最后发现是mysql.h内部包含了windows.h而我的代码里把它放在了winsock2.h前面。一调整顺序问题迎刃而解。5.2 LNK2019无法解析的外部符号如果你编译通过但链接阶段报unresolved external symbol __imp__WSAStartup8 referenced in function _main这说明编译器已经拿到了函数声明但链接器没找到实现。优先检查代码里是否有#pragma comment(lib, ws2_32.lib)如果用命令行编译检查是否带-lws2_32。__imp_前缀说明WSAStartup是从 DLL 导入的函数在静态库中被dllimport属性修饰了。有时候你用gcc编译 MinGW 程序报错却是undefined reference toWSAStartup8这两种形式的区别在于目标文件的符号修饰方式不同。MSVC 生成 COFF 格式MinGW 生成 GNU 风格的导入符号都不会影响排查结论。5.3 运行时错误码速查表程序运行起来比编译错误更折磨人。我就吃过不少亏把高频错误码列成一张表遇到直接对照错误码值含义常见场景WSAEADDRINUSE10048端口已被占用上次程序未退出或端口被其他进程绑定WSAECONNRESET10054对方强制关闭连接客户端崩溃或 TCP RST 收到WSAETIMEDOUT10060连接超时远端 IP 不通或者防火墙拦截了连接WSAECONNREFUSED10061连接被拒绝服务端未启动或端口不匹配WSAENETUNREACH10051网络不可达网关配置错误或断网WSAEFAULT10014缓冲区地址非法指针指向无效内存或缓冲区长度填错遇到 10048 时最直接的办法是找到占用端口的进程并结束它netstat -ano | findstr :8080 taskkill /F /PID 12345其实setsockopt设置SO_REUSEADDR可以让服务器重启后立刻复用 TIME_WAIT 状态的端口但注意在 Windows 上这个选项的语义和 Linux 略有差异不建议无脑设置。实际调试的时候我经常直接换个端口测试省事。5.4 阻塞模式下 recv 卡住的问题默认创建的 socket 是阻塞模式。recv在没有数据到达时会一直挂起当前线程直到有数据或连接关闭。这在单线程程序里看起来就像“程序卡死了”。很多初学者把recv放在主循环里结果数据还没来界面早就无响应。解决方案有几种把 socket 设为非阻塞模式用ioctlsocket(sock, FIONBIO, mode)mode 1表示非阻塞。非阻塞模式下recv没有数据时立即返回SOCKET_ERROR错误码是WSAEWOULDBLOCK10035。使用select()或WSAPoll()监控 socket 的可读事件超时后才做后续处理这是跨平台最通用的方式。Windows 上更高级的可以用WSAEventSelect把 socket 事件绑定到事件对象配合WaitForMultipleObjects实现多连接并发。对于新手先用select准没错。它会阻塞等待一段时间如果超时返回 0有事件返回正数。把select放在recv前面可以优雅地控制阻塞时间不至于让程序毫无响应。6. 踩过这几道坑才算真正掌握了 WINSOCK2.H我最后再分享三个容易让人崩溃的场景都是我真实遇到过的。第一个是字节序。我一直用htons(8080)绑定端口但有一次在一个协议格式测试程序里直接读文件里的端口值忘了转换字节序结果服务端一直收不到数据抓包一看端口对不上。记住只要是地址和端口在网络传输之前必做字节序转换这不是推荐而是必须。第二个是recv收到的数据不保证是一个完整包。比如你发送 1000 字节接收方可能分两次收到第一次 600第二次 400。这就是 TCP 的流式特性。如果你的协议是基于“消息”的一定要自定义消息边界比如开头 4 字节存放消息长度接收方先读长度再读正文。我最早写聊天程序不加消息边界客户端和服务器互相发消息经常出现粘包排查起来非常痛苦。第三个是初始化失败后没有正确调用WSACleanup。别小看这个我见过一个长时间运行的服务程序每次网络故障重连时都要重新初始化 Winsock却忘了WSACleanup最后句柄耗尽系统级卡死。严谨的做法是WSAStartup成功之后保证代码无论走哪个分支退出前都会调用一次WSACleanup。可以借助 C 语言里有限的 RAII 思想用goto统一收尾或者定义宏清理这比在main里到处写closesocket要可靠得多。说实话winsock2.h这套接口设计并不算优雅它带着九十年代 Windows API 的老派风格函数命名长、参数多、错误处理繁琐。但它是在 Windows 下写 C 语言网络程序绕不开的唯一通道。弄懂头文件的包含策略、链接库的配置方式、核心 API 的细节以及那些看似莫名其妙实际有迹可循的错误码你就已经拿到了打开 Windows 网络编程大门的钥匙。把这个基础打牢后面再去学IOCP、WSAAsyncSelect这些进阶模型会顺畅很多。本文还有配套的精品资源点击获取
返回列表