ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Windows平台C++/WinRT BLE开发实战:从扫描到GATT与配对

2026/9/7 8:07:29 拓冰建站 浏览量
Windows平台C++/WinRT BLE开发实战:从扫描到GATT与配对 简介面向Windows平台C/C开发者的低功耗蓝牙BLE操作示例工程基于WinRT API开发解决原生C代码中调用蓝牙协议栈进行设备通信的问题。示例代码覆盖设备发现、广播监听、GATT服务连接、特征值读取与写入等常用环节适合正在学习Windows蓝牙编程或希望快速实现BLE功能的工程师。压缩包体积约10KB共11个文件。文件组成以4个头文件和4个C源文件为主头文件负责声明接口与数据结构源文件负责具体业务逻辑另附Visual Studio工程配置与筛选文件方便直接打开编译或迁移复用。工程将蓝牙操作集中封装为独立模块并包含DLL入口和预编译头整体结构清晰能够直观展示WinRT组件在传统C工程中如何集成与调用。已有5951人学习该资源体量小巧、目标明确既可当作入门材料也能在项目开发中直接参考其中的连接管理与数据处理流程。 做 Windows 平台上的 BLE 开发尤其还想用 C/C 而不是 C#第一反应通常是去翻微软官方的 Bluetooth 文档。翻完基本就懵了官方示例几乎全是 C#/JavaScript/WinRTC 的例子少得可怜Win32 传统蓝牙 API 又主要覆盖 BR/EDR对 BLE 的 GATT 这套支持非常有限。网上能搜到的 C 源码要么是远古时代的要么干脆编译不过。我最早也在这个坑里卡了很久后来把 Windows.Devices.Bluetooth 这套 WinRT API 用 C/WinRT 封装跑通之后整个开发链路才算打开。这篇文章会把从扫描、连接、GATT 服务发现、特征值读写、通知订阅到配对邦定和 MTU 协商的完整实操经验梳理一遍适合想在 Windows 上用 C/C 跟 BLE 设备通信的开发者参考也适合刚入手 BLE 开发、想弄明白整套流程该怎么走的新手。如果你手里有一块 ESP32、Nordic 开发板或者蓝牙串口模块跟着做一遍就能快速验证。1. 技术路线之争为什么绕不开UWP这套API1.1 传统Win32蓝牙API为什么不行Windows 上传统的蓝牙 API 有两个主要入口一个是 Winsock 风格的 Bluetooth socket API一个是 SetupAPI/DeviceIoControl 那套底层接口。这两个东西设计时主要面向蓝牙 2.0/3.0 时代的 BR/EDR 经典蓝牙对应的是 RFCOMM、SDP 服务发现这一套模型。BLE 和 BR/EDR 在底层接入层LL以上走的协议栈几乎完全不同。BLE 的核心是 GATT 这个属性协议而 GATT 数据是打包在 ATTAttribute ProtocolPDU 里的传统 Winsock 蓝牙接口根本没有暴露 ATT/GATT 层的能力你拿它做 BLE 连接、发现服务、收发 characteristic基本等于用电话线上网跑 4K 视频——不是不行是压根没这个通道。如果你非要用 Win32 API 做 BLE能查到的资料大多是指向“微软在 Windows 8 之后把 BLE 的支持放在 WinRT 里”然后就没有然后了。这是一条死胡同没必要硬闯。1.2 C/WinRT 是当前最舒服的姿势WinRTWindows Runtime是微软在 Win8 之后引入的组件化 API 体系Windows.Devices.Bluetooth 全家桶都建在它上面。早期 C 开发者通过 C/CX微软对 C 做的扩展语法带 ^ 和 ref class 那种来调用但这个语法比较别扭而且和标准 C 的互操作体验一般。C/WinRT 是微软后来力推的标准 C 封装头文件库形式纯 C17 就能用不需要 C/CX 那套非标准语法。关键点在于C/WinRT 的 API 可以在普通 Win32 桌面程序里直接用不一定非要写成 UWP 应用。这一点很多人有误解以为做 WinRT 开发就必须上 UWP 模板、必须签 MSIX 包。实际上你创建一个普通的控制台程序或 Win32 窗口程序初始化 WinRT 运行时之后就可以像调用普通 C 库一样调用蓝牙 API。1.3 开发环境准备我目前的开发环境是 Visual Studio 2022 Windows 11 SDK用 CMake 构建。C/WinRT 的库不是自己单独下载的Windows SDK 装好之后头文件和实现都打包在里面了。配置步骤大概是Visual Studio 安装时勾选“使用 C 的桌面开发”工作负载MSVC 工具链和 Windows SDK 一起装。项目属性里确认 C 语言标准是 C17 或更高。include 目录里确认有winrt/Windows.Devices.Bluetooth.h这个头文件可访问就算 OK。代码开头调用winrt::init_apartment();初始化 WinRT 运行时。如果你用 VSCode 写 C/C也可以但注意 VSCode 本身不带 MSVC 编译器需要配合 Visual Studio 的 Developer Command Prompt 环境来跑 CMake 和 cl.exe或者安装 Ninja clang 组合。建议刚开始直接上 Visual Studio省去环境变量配置的心智负担。1.4 权限与打包的坑还有一个常见困惑使用了蓝牙 API 是否需要像 UWP 那样在 Package.appxmanifest 里声明bluetoothcapability如果是普通桌面程序非 MSIX 打包调用这些 API 时系统不会严格强制 manifest 权限声明。但如果你发布的是 MSIX 打包应用就一定要在 manifest 里加DeviceCapability Namebluetooth/否则运行时访问会被拒。Windows 10/11 系统层面的蓝牙开关也要确认是打开的否则设备枚举会返回空列表。这个听起来很基础但我遇到过好几次明明代码写对了却扫不到设备最后发现是系统飞行模式开着把蓝牙关了。2. 扫描与过滤找到你要的那台BLE设备2.1 BluetoothLEAdvertisementWatcher 的基本用法BLE 设备广播的核心 API 是BluetoothLEAdvertisementWatcher。它会侦听空中的广播报文收到后会触发Received回调回调里携带BluetoothLEAdvertisementReceivedEventArgs里面包含设备地址、RSSI、广播数据和可选的扫描响应数据。一个最基础的控制台扫描程序大概是这个样子#include winrt/Windows.Devices.Bluetooth.Advertisement.h #include winrt/Windows.Foundation.Collections.h #include iostream using namespace winrt; using namespace Windows::Devices::Bluetooth::Advertisement; void StartScan() { BluetoothLEAdvertisementWatcher watcher; watcher.Received([](BluetoothLEAdvertisementWatcher const, BluetoothLEAdvertisementReceivedEventArgs const args) { auto address args.BluetoothAddress(); auto rssi args.RawSignalStrengthInDBm(); auto localName args.Advertisement().LocalName(); std::string name winrt::to_string(localName); std::cout addr std::hex address rssi std::dec rssi name name std::endl; }); watcher.Start(); }注意BluetoothAddress()返回的是 64 位整数通常用%012llX这种格式打印成 6 字节 MAC 地址的样子。广播里没带设备名称时LocalName()是空字符串这非常常见别因为没名字就以为设备没广播。2.2 广播类型与 Active/Passive 扫描的区别热度词里有“ble广播类型”这块值得展开。BLE 广播 PDU 主要分几类ADV_IND可连接的广播、ADV_SCAN_IND可扫描但不可连接、ADV_NONCONN_IND不可连接也不可扫描、ADV_DIRECT_IND定向连接广播通常用于快速重连。BluetoothLEAdvertisementWatcher的ScanningMode可以选择 Active 或 Passive。Active 模式会主动向设备发送 SCAN_REQ设备收到后如果支持会回复 SCAN_RSP里面通常包含完整的设备名和附加服务数据Passive 模式只被动接收广播不发请求更省电但拿到的信息少。实际操作中如果你在找某个外设强烈建议用 Active 模式因为很多设备的设备名只在广播包里放简写完整名称要等扫描响应才发出来。开启方式就是给ScanningMode赋值watcher.ScanningMode(BluetoothLEScanningMode::Active);2.3 按服务UUID或厂商数据过滤生产环境里扫描设备往往不是扫到啥都收而是要过滤出目标设备。常见手段有两种一种是按服务 UUID 过滤。BLE 广播数据里允许塞 Service UUID 列表Advertisement().ServiceUuids()可以拿到。你可以遍历这个集合判断是否包含你要连接的那个服务的 UUID比如自定义服务是 128 位 UUID或 SIG 定义的 16 位 UUID 如 0xFFE0。另一种是按厂商自定义数据过滤。Nordic、TI、Dialog 这类厂商芯片常见的做法是把设备 MAC 或名字塞在 Manufacturer Specific Data 里。C/WinRT 里拿到的ManufacturerData()是一个IVectorBluetoothLEManufacturerData每项有两个关键字段CompanyId厂商 ID比如 0x0059 是 Nordic和DataIBuffer需要转成字节数组做解析。我自己的习惯是优先用服务 UUID 过滤因为比解析厂商数据简单而且不容易被不同厂商的广播格式差异坑到。2.4 扫描时容易踩的坑第一个坑Watcher 重复 Start 会抛异常或触发Stopped事件。同一时刻一个 watcher 实例只能处于 Running 状态如果想重新扫要先Stop()等待Stopped事件触发后再Start()。不要连续调用两次 Start。第二个坑Received 回调跑在非 UI 线程。回调线程是线程池不是主线程。控制台程序无所谓但如果你做的是窗口程序回调里更新 UI 控件必须要DispatcherQueue切回 UI 线程。第三个坑扫描到设备的BluetoothAddress()是公共地址还是随机地址这个信息很重要。很多 BLE 设备为了防止被追踪默认使用随机地址每次广播地址可能不同。如果你用地址做设备记忆会发现设备重连时地址对不上这是因为随机地址的重解析需要配合 IRK身份解析密钥Windows 系统层面邦定后会自动处理地址解析但你应用层如果在扫描阶段就存了原始地址就可能和对不上。3. 连接与GATT把设备的“功能菜单”翻出来3.1 GATT 结构先聊清楚GATTGeneric Attribute Profile是一个层级结构设备Device→ 服务Service→ 特征Characteristic→ 描述符Descriptor。用一句话概括服务是功能分类特征是这个分类里具体的可读可写属性描述符是特征的辅助配置。生活化类比把一个 BLE 设备看成一家餐厅。设备是餐厅本身服务是“饮料区”“主食区”“甜品区”特征是“可乐”“汉堡”“蛋糕”描述符则是“这个蛋糕是否允许被改成现做”的配置项。你要喝可乐得先找到饮料区再找到可乐这个条目然后才能下单。GATT 里所有服务、特征都有 UUID 标识。16 位 UUID 是蓝牙 SIG 定义的标准属性比如 Battery Service 是 0x180FDevice Information 是 0x180A自定义的 128 位 UUID 通常由厂商自己定义格式类似{xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx}。Windows 的 API 里GattDeviceService::Uuid()和GattCharacteristic::Uuid()返回的是一个winrt::guid打印格式需要用winrt::to_hstring转一下。16 位 UUID 在 API 里会变成 0000xxxx-0000-1000-8000-00805f9b34fb 这种完整 GUID 形式这是蓝牙 SIG 的基 UUID别觉得是自己代码错了。3.2 从地址到设备对象扫描之后拿到设备的BluetoothAddress以及地址类型接下来要获得BluetoothLEDevice对象#include winrt/Windows.Devices.Bluetooth.h using namespace Windows::Devices::Bluetooth; uint64_t targetAddress 0xXXXXXXXXXXXX; BluetoothLEDevice device co_await BluetoothLEDevice::FromBluetoothAddressAsync(targetAddress);如果你不熟悉co_await需要开启 C20 的协程支持。C/WinRT 的异步操作默认要以协程方式等待这是初学者最容易卡住的地方。C17 模式下可以用get()同步等待auto deviceOp BluetoothLEDevice::FromBluetoothAddressAsync(targetAddress); auto device deviceOp.get();FromBluetoothAddressAsync本质上是一个连接操作。这个 API 返回后不保证物理链路已经建立但设备对象已经可用后续的 GATT 操作会触发实际的连接流程。3.3 枚举服务和特征的代码骨架连接后最重要的事是枚举 GATT 服务找出你要操作的特征。下面这段代码把服务的 UUID 和它下面的所有特征 UUID 都打出来#include winrt/Windows.Devices.Bluetooth.GenericAttributeProfile.h using namespace Windows::Devices::Bluetooth::GenericAttributeProfile; void DumpServices(BluetoothLEDevice const device) { auto servicesResult device.GetGattServicesAsync().get(); for (auto const service : servicesResult.Services()) { std::wcout LService: std::wstring(service.Uuid()) std::endl; auto charsResult service.GetCharacteristicsAsync().get(); for (auto const characteristic : charsResult.Characteristics()) { std::wcout L Char: std::wstring(characteristic.Uuid()) std::endl; } } }这里有个隐藏的属性访问器问题device.GetGattServicesAsync()返回的GattDeviceServicesResult里Services()是一个向量但当设备尚未完全准备好时可能会是空的。实际使用中可以先判断servicesResult.Status()是否为GattCommunicationStatus::Success再处理服务列表。3.4 访问权限和被缓存的坑调用GetGattServicesAsync()之前在 UWP 应用中有一个常见的RequestAccessAsync()步骤用于请求访问蓝牙设备的权限。桌面程序里这个调用不是强制必须的但如果你发现设备对象拿不到任何服务建议还是在文档里确认当前 Windows 版本的权限策略。另外一个很毒的坑Windows 会缓存 GATT 服务发现结果。设备端改了服务结构或特征 UUID应用重新连接后拿到的还是旧缓存。解决办法是让用户去系统设置里删除该设备的配对记录再重新配对或者设备端通过改变广播名、匹配码等方式强制系统重新发现。3 开发阶段我经常改服务端代码这个缓存问题浪费了我不少时间。4. 特征值读写与通知订阅真正收发数据的地方4.1 读特征值ReadValueAsync拿到GattCharacteristic后读操作非常简单auto readResult characteristic.ReadValueAsync().get(); if (readResult.Status() GattCommunicationStatus::Success) { auto buffer readResult.Value(); // 把 IBuffer 转成字节数组 auto reader Windows::Storage::Streams::DataReader::FromBuffer(buffer); uint8_t byte reader.ReadByte(); }ReadValueAsync适用于读那些静态或低频变化的数据比如电池电量、设备序列号。如果你的设备需要频繁推送数据别用轮询读走通知订阅。4.2 写特征值WriteWithResponse 与 WriteWithoutResponse写特征是 BLE 控制指令的下发通道。注意区分两种写类型WriteWithResponse设备收到数据后会回复 ACK应用可以确认这次写入是否成功适合关键控制指令。WriteWithoutResponse只发数据不等待确认吞吐率更高适合大数据流。C/WinRT 的写操作Windows::Storage::Streams::DataWriter writer; writer.WriteBytes(std::vectoruint8_t{0x01, 0x02, 0x03}); auto status characteristic.WriteValueAsync(writer.DetachBuffer(), GattWriteOption::WriteWithResponse).get(); if (status GattCommunicationStatus::Success) { // 写入成功 }需要说清楚的是写类型并不完全由你决定每个特征在服务端定义时就指定了属性读、写、写无应答等。你可以看characteristic.CharacteristicProperties()里是否包含GattCharacteristicProperties::WriteWithoutResponse再决定用哪种方式写。如果特征本身只支持 WriteWithResponse你强行用 WriteWithoutResponse 会失败。4.3 通知订阅从“轮询”到“推送”BLE 最有价值的地方就是 Notify/Indicate 机制设备端数据变化时主动上报应用不用反复读。订阅步骤如下第一步给特征的ValueChanged事件挂接回调characteristic.ValueChanged([](GattCharacteristic const c, GattValueChangedEventArgs const args) { auto reader Windows::Storage::Streams::DataReader::FromBuffer(args.CharacteristicValue()); // 读数据注意这里也是线程池回调 });第二步开启 CCCDClient Characteristic Configuration Descriptor。这一步经常被忽略因为很多新手以为挂上ValueChanged事件就会自动收到数据。实际上服务端是否上报通知取决于客户端有没有写 CCCD 这个描述符auto cccdStatus characteristic.WriteClientCharacteristicConfigurationDescriptorAsync( GattClientCharacteristicConfigurationDescriptorValue::Notify ).get();只有这个调用返回 Success 后设备端才会开始往你这推数据。关闭通知时写GattClientCharacteristicConfigurationDescriptorValue::None。4.4 分包和 MTU 的问题默认情况下BLE 一个 ATT 数据包能承载的用户数据只有 20 字节因为 MTU 默认 23 字节减去 ATT 头等开销。很多新手写完一个WriteValueAsync传了 100 字节数据发现只收到 20 字节就开始怀疑代码有问题。这就是 MTU 没协商导致的。Windows 上如果你没有做任何 MTU 协商动作实际写入大量数据时系统可能直接报错也可能只发送了部分。建议开发阶段先查一下特征支持的写长度GattCharacteristic没有直接的 “max write length” 属性但你可以写一个小的探测包来确认。真正的 MTU 协商放到后面专门讲。5. 配对邦定与MTU协商最容易翻车的两座山5.1 Pairing 和 Bonding 到底在什么场景需要先澄清两个概念Pairing配对是临时建立安全连接的过程Bonding邦定是在配对基础上交换并保存长期密钥之后重连不需要重新配对。热度词里“ble调试助手绑定(bond)”应该就是这个场景。很多 BLE 外设不需要配对比如普通的温度传感器、ibeacon广播直接读就行。但有些设备设计时要求加密连接比如智能锁、数字钥匙、心率带不配对就不让你访问 GATT 服务。Windows 上的表现是你在GetGattServicesAsync或ReadValueAsync时返回GattCommunicationStatus::AccessDenied。5.2 Windows 上怎么用 C 触发配对UWP/WinRT 里配对相关的 API 是DeviceInformation.Pairing.CustomPairing。你需要先从设备对象的DeviceInformation拿到配对信息然后调用PairAsync#include winrt/Windows.Devices.Enumeration.h using namespace Windows::Devices::Enumeration; auto deviceInfo device.DeviceInformation(); auto pairingKinds DevicePairingKinds::ConfirmOnly; auto result deviceInfo.Pairing().Custom().PairAsync(pairingKinds).get(); if (result.Status() DevicePairingResultStatus::Paired) { // 配对成功 }DevicePairingKinds有几种常用值ConfirmOnly用户点确认即可、ProvidePin需要输入 PIN 码显示在设备端、ConfirmPinMatch两端显示同一个 PIN用户确认匹配。实际开发中如果你的设备是那种没有屏幕的传感器通常是 Just Works 方式配对用ConfirmOnly就行如果设备有小屏幕显示 6 位数字那就用ProvidePin在事件回调里把 PIN 传进去。ProvidePin藏在事件里示例代码略复杂核心是挂接PairingRequested事件在里面根据DevicePairingRequestedEventArgs类型返回 PIN。这部分如果你第一次写很容易找不到ProvidePin的赋值位置——它不在PairAsync参数里而在事件回调里调用args.Accept(pin)。5.3 配对影响全局这个坑请务必重视Windows 的配对操作是系统级的状态不是应用级。你用自己程序配对的设备会被记录在 Windows 的蓝牙设备列表里之后你系统里的其他程序也能看到它。反过来如果你的程序在BluetoothLEDevice::FromBluetoothAddressAsync时系统已经存了这个设备的配对信息它会自动尝试重新邦定。有几次我在调试过程中改了设备端的安全参数导致 Windows 这边旧密钥失效应用层报错。这时候最有效的办法不是改代码而是去系统的蓝牙设置里删掉这个设备重新配对。所以我调试反复修改安全性相关的代码时会先手动清理配对记录。5.4 MTU 协商的正确理解MTUMaximum Transmission Unit指的是 ATT 层最大传输单元。BLE 4.0 默认 MTU 是 23 字节其中 3 字节是 ATT 头所以单个数据包最多带 20 字节用户数据。要提高吞吐需要客户端和服务端协商一个更大的 MTU。Windows 上系统在连接建立后会自动和远端协商 MTU你可以在GattSession对象里读取协商结果#include winrt/Windows.Devices.Bluetooth.GenericAttributeProfile.h auto session GattSession::FromDeviceIdAsync(device.BluetoothDeviceId()).get(); uint16_t maxPduSize session.MaxPduSize();这个MaxPduSize()就是实际能承载的 ATT PDU 大小减去 3 个字节 ATT 头才是你真正能写的数据长度。比如MaxPduSize() 247那单包用户数据最多 244 字节。重点来了UWP/WinRT 这套公开 API 没有提供主动请求特定 MTU 值的接口。MTU 协商由系统自动处理你只能读结果。如果设备端和系统协商后 MTU 没达到你预期能做的就是改服务端配置比如 ESP32 里把ATT_MTU默认值调大或者走到 HCI 层自己发LE Configure MTU命令——那是另一个深水区一般应用不值得做。测试时用 ESP32 这类开发板很直观ESP-IDF 里默认 MTU 是 517 或 247你可以用esp_ble_gatt_set_mtu设置。Windows 连接后GattSession::MaxPduSize()读到的值应该和 ESP32 协商值一致。不一致的话检查一下设备端是否真的在广播阶段就允许更高 MTU。6. 抓包调试与跨平台移植的实用建议6.1 没有逻辑分析仪用这套组合也能调做 BLE 开发光看自己代码日志不够最好能看到空中报文。最便宜的办法是装一个 BLE 抓包工具配合 Windows 的 HCI 日志Wireshark 安装时勾选 “Microsoft Bluetooth HCI Extensible Sniffer”然后用管理员权限运行msbtsniffer就能抓到经过 Windows 协议栈的 BLE HCI 包。如果 Wireshark 抓不到常见原因是微软的抓包驱动和你的蓝牙适配器不兼容。Intel 的无线网卡普遍支持Realtek 的有些勉强。手机端用 Nordic 的 nRF Connect 或者厂商的“BLE 调试助手”是验证设备行为最快的途径。开发时我常用思路是先用手机 App 确认设备端广播、服务和特征读写一切正常再用 Windows 端的程序去连这样把问题边界划清楚——是设备端问题还是 Windows API 调用问题一测便知。6.2 用 ESP32 做冒烟测试设备如果你手头没有现成的 BLE 外设建议直接拿一块 ESP32 开发板用 ESP-IDF 或 Arduino 写一个简单的 GATT Server广播一个自定义服务和特征。原因是 ESP32 资料多、成本低、改起来方便可以随时调整广播名、服务 UUID、MTU 大小来模拟各种异常情况。我做过的冒烟测试设备长这样广播名TEST_BLE一个自定义服务{0000ffe0-0000-1000-8000-00805f9b34fb}里面一个可读写特征{0000ffe1-0000-1000-8000-00805f9b34fb}每 1 秒通过 Notify 推一个递增计数。Windows 端程序连接后能扫描到、能读写、能收通知整套流程就算验证通过。6.3 跨平台移植的提前思考如果你的产品后面可能跑 Linux 或移动端建议别把 C/WinRT 的 API 调用散落在业务代码里而是封装一个薄薄的一层抽象接口比如IBleScanner、IBleGattClient。这样 Windows 上实现用 WinRTLinux 上实现用 BlueZ 的 D-Bus API或者直接上 SimpleBLE 这类跨平台库业务层不动。SimpleBLE 这个库我个人比较推荐看源码学思路它内部对 Windows 底层就是封装 WinRT对 Linux 封装 BlueZ对 macOS 封装 CoreBluetooth。它的设计简洁但功能覆盖有限复杂应用还是得自己封装。如果项目一开始就确定要跨 Windows/Linux直接用 SimpleBLE 起步能省不少时间。6.4 调试阶段值得投入的一次性准备工作再分享一个经验先把日志系统做好再做协议联调。BLE 开发的特点是异步事件多——扫描回调、连接状态变化、通知回调全在不同的线程上跑日志如果没带时间戳和线程 ID出问题根本没法定位。我自己的日志格式至少包含三要素时间戳毫秒级、线程 ID、日志级别。连接状态变化时打一条“connect start / connected / disconnected / access denied”扫描到设备时打地址和 RSSI收到通知时打长度和前几个字节。这样排查问题基本靠日志就能还原现场而不是靠猜。写完这篇回头再看C/C 做 Windows BLE 开发这条路没有想象中那么冷门但确实没有一条官方给出的“傻瓜式”路径。只要把技术栈选对坚持用 Windows.Devices.Bluetooth C/WinRT剩下的就是从扫描、连接、GATT 到配对一步步打通。我最初被卡得最惨的地方是不知道 Win32 程序也能直接调 WinRT API以及 MTU 协商不受应用层控制这两个认知盲区希望这篇文章能把这两座山替你提前铲平。本文还有配套的精品资源点击获取