Windows BLE C/C++:WinRT 扫描连接与 GATT 读写通知

发布时间:2026/10/24 4:19:04
Windows BLE C/C++:WinRT 扫描连接与 GATT 读写通知 简介面向需要在 Windows 上开发 BLE 蓝牙通信的 C/C 开发者尤其适合已掌握基础语法、希望用 WinRT 接口完成设备发现、连接与 GATT 交互的中级读者。压缩包共 11 个文件约 10KB包含 4 个 h 头文件、4 个 cpp 源文件以及 vcxproj 工程文件、filters 筛选配置和 user 用户配置工程结构完整可直接作为 Visual Studio 项目参考。代码围绕 BluetoothAdvertisement、BluetoothLEDevice、GattCharacteristic 等 WinRT 类型展开展示蓝牙广播扫描、设备枚举、服务与特征访问等关键环节。已有 5966 人学习适合用来对照理解 Windows BLE 编程流程、梳理工程组织方式并在此基础上扩展自己的通信模块。1. 用 C/C 在 Windows 上驱动 BLE 设备先看清这套代码的边界工位上摆着几颗 BLE 温湿度贴片、一台扫码枪、一个自研的低功耗主控要求写个 C 程序开机自动扫到指定设备连上去读 GATT 特征值还得接住设备主动推过来的通知。这类需求在 Windows 上不算难难的是第一次踩进去的时候不知道从哪根线头开始拉。这份资源是一组围绕 Windows BLE 操作的 C/C 源码与工程配置覆盖广播扫描、设备连接、GATT 服务与特征发现、特征读写、CCCD 通知订阅、配对与掉线重连主线走 C/WinRT 这套 Windows 原生的蓝牙 LE 接口另外保留了 Win32 蓝牙 API 的对照实现。它适合已经能写 C、但没在 Windows 上碰过 BLE 的开发者也适合手里有一堆老 Win32 代码、想搞清楚哪些能留哪些得换的人。2. API 选型C/WinRT 与 Win32 蓝牙接口的能力分界在 Windows 上做 BLE第一道坎不是写代码是选接口。选错了后面全是返工用 Win32 那套经典蓝牙函数去连一个只广播的 BLE 传感器你会发现设备列表里根本没有它。所以先把两条路线摊开看清楚。2.1 两条路线到底差在哪Windows 上跟蓝牙相关的公开接口大体可以分成三代最早是bluetoothapis.h那一套经典蓝牙BR/EDRAPIBluetoothFindFirstRadio、BluetoothFindFirstDevice、BluetoothEnumerateInstalledServices都在这条线上后来 Windows 8 补了一组BluetoothGATT*函数头文件在bthledef.h/bluetoothleapis.h能做 GATT 读写但缓冲区要手工两段式分配、句柄要自己管生命周期再往后就是 WinRT 的Windows.Devices.Bluetooth命名空间C 侧通过 C/WinRT 调用异步模型、服务发现、通知订阅都是现成的。维度C/WinRT 蓝牙 APIWin32 蓝牙 API头文件winrt/Windows.Devices.Bluetooth*.hbluetoothapis.h/bluetoothleapis.h链接库WindowsApp.libBthprops.lib调用模型IAsyncOperationco_await同步阻塞BLE GATT 读写完整支持BluetoothGATT*一族缓冲区和句柄手工管理通知订阅CCCD 一行写完需要BluetoothGATTRegisterEvent回调经典蓝牙 SPP不支持支持打包应用要求部分接口需要包标识无选型结论很直接只要目标是 BLE 的 GATT 交互走 C/WinRT。只有两种情况需要回到 Win32一是要跟经典蓝牙的 SPP 串口设备打交道二是老项目跑在 Windows 7 上没法升级。这份资源里两套都留了但主线是前者。2.2 工程搭建从零建一个能编过的最小工程C/WinRT 的头文件随 Windows SDK 一起装不需要额外拉包CMake 里配好编译选项就能用。cmake_minimum_required(VERSION 3.20) project(ble_probe LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) add_executable(ble_probe src/main.cpp src/ble_scanner.cpp src/gatt_client.cpp ) # /await 让 C17 模式下能写 co_await/utf-8 避免中文日志乱码 target_compile_options(ble_probe PRIVATE /EHsc /await /utf-8) # WindowsApp.lib 提供 WinRT 激活工厂的导入符号 target_link_libraries(ble_probe PRIVATE WindowsApp)编译选项里三个都不能省。/await是 C17 下用协程的前提换成/std:c20之后编译器自带协程支持这个开关就可以去掉/EHsc保证 WinRT 抛出的hresult_error能被正常捕获/utf-8是给日志用的BLE 调试大量依赖打印源码里带中文又不加这个开关控制台会输出一堆问号排查时反而更慢。如果是打成 MSIX 包分发清单里必须声明蓝牙能力否则运行时扫描器会直接以DisabledByPolicy停下Capabilities DeviceCapability Namebluetooth / /Capabilities提示非打包的普通桌面程序调用 WinRT 蓝牙接口一般没问题但涉及DeviceInformation枚举的少数路径会因为没有包标识而受限。常见做法是绕开设备枚举直接用广播里拿到的地址调FromBluetoothAddressAsync。2.3 Win32 老接口还能干什么老接口不是完全没用它擅长的是看一眼环境里有什么。下面这段是资源里win32_enum那个示例的骨架用来确认适配器状态、已经配对过哪些设备。#include windows.h #include bluetoothapis.h #include cstdio #pragma comment(lib, Bthprops.lib) int wmain() { BLUETOOTH_DEVICE_SEARCH_PARAMS params{}; params.dwSize sizeof(params); params.fReturnAuthenticated TRUE; // 已配对且在用的 params.fReturnRemembered TRUE; // 记忆过的 params.fReturnConnected TRUE; // 当前已连接 params.fReturnUnknown TRUE; // 没配过对的 params.fIssueInquiry TRUE; // 主动查询会明显变慢 params.cTimeoutMultiplier 2; // 超时倍数1 约 1.28 秒 BLUETOOTH_DEVICE_INFO info{}; info.dwSize sizeof(info); HBLUETOOTH_DEVICE_FIND find BluetoothFindFirstDevice(params, info); if (!find) { printf(enum failed, err%lu\n, GetLastError()); return 1; } do { printf(%-20S %02X:%02X:%02X:%02X:%02X:%02X\n, info.szName, info.Address.rgBytes[5], info.Address.rgBytes[4], info.Address.rgBytes[3], info.Address.rgBytes[2], info.Address.rgBytes[1], info.Address.rgBytes[0]); } while (BluetoothFindNextDevice(find, info)); BluetoothFindDeviceClose(find); return 0; }这段逻辑说白了就是填一个搜索参数结构体然后循环取。参数里fIssueInquiry是分水岭置 FALSE 只从系统缓存里读毫秒级返回但只能看到配对过的设备置 TRUE 会真的发起一次查询能发现新设备代价是耗时按cTimeoutMultiplier × 1.28 秒走。BLUETOOTH_ADDRESS的字节序是反的打印时从rgBytes[5]倒着来才跟设备上贴的 MAC 对得上。它的局限也得说清楚这条路走的是经典蓝牙查询纯 BLE 设备大概率不会出现在结果里就算出现了也只能拿到名字和地址做不了服务发现和特征读写。所以它的定位是开机自检和故障定位不是业务通道。3. 扫描与连接广播过滤、地址映射和服务表发现接口定下来之后第一件要跑通的事是看得见设备。BLE 跟经典蓝牙最大的区别在于设备是靠广播包暴露自己的你不主动扫描它永远不会出现在任何列表里。3.1 广播扫描器过滤条件和去重表扫描器本身很简单难的是别让它把内存吃光。车间里几十上百个 BLE 设备同时广播一秒几十条回调直接往std::vector里塞几分钟就涨到几十万条。#include winrt/Windows.Foundation.h #include winrt/Windows.Devices.Bluetooth.Advertisement.h #include map #include mutex using namespace winrt; using namespace Windows::Devices::Bluetooth::Advertisement; struct AdvRecord { uint64_t address 0; int16_t rssi 0; std::wstring name; int64_t lastSeen 0; }; class BleScanner { public: void Start() { // Active 会额外发 SCAN_REQ能拿到 scan response 里的完整名字 watcher_.ScanningMode(BluetoothLEScanningMode::Active); watcher_.Received([this](BluetoothLEAdvertisementWatcher const, BluetoothLEAdvertisementReceivedEventArgs const args) { std::lock_guardstd::mutex lock(mtx_); auto rec table_[args.BluetoothAddress()]; rec.address args.BluetoothAddress(); rec.rssi args.RawSignalStrengthInDBm(); rec.lastSeen args.Timestamp().time_since_epoch().count(); auto name args.Advertisement().LocalName(); if (!name.empty()) rec.name name; }); watcher_.Stopped([](BluetoothLEAdvertisementWatcher const, BluetoothLEAdvertisementWatcherStoppedEventArgs const args) { printf(watcher stopped, reason%d\n, (int)args.Error()); }); watcher_.Start(); } std::mapuint64_t, AdvRecord Snapshot() { std::lock_guardstd::mutex lock(mtx_); return table_; } private: BluetoothLEAdvertisementWatcher watcher_; std::mapuint64_t, AdvRecord table_; std::mutex mtx_; };几个参数值得单独拎出来说。ScanningMode选Active是因为纯被动扫描只能收到广播包而很多设备把完整名字和设备类型塞在 scan response 里被动模式下拿不到。代价是扫描器要额外发请求功耗和空中占用都会涨如果只关心厂商自定义数据切回Passive更省事。用std::mapuint64_t, AdvRecord而不是vector是因为 BLE 设备地址天然唯一插入时自动覆盖同一条记录等于免费做了去重和最后活跃时间维护。BluetoothAddress是 48 位塞进uint64_t没问题。如果只关心特定厂商的设备可以在启动前挂一个过滤器把无关广播挡在回调之外省掉大量锁竞争BluetoothLEAdvertisementFilter filter; BluetoothLEAdvertisementBytePattern pattern; pattern.DataType(BluetoothLEAdvertisementDataTypes::CompleteServiceUuids16()); pattern.Offset(0); filter.BytePatterns().Append(pattern); watcher_.AdvertisementFilter(filter);Offset指的是从广播数据段起始位置算的偏移DataType决定了要匹配的是哪一段。这两个值填错过滤器会安静地匹配不上任何东西——不会有异常也不会有日志表现就是什么都没收到跟没开扫描一模一样这是排查时最容易走偏的一处。3.2 从地址到服务表拿到地址之后连接和发现可以一气呵成。#include winrt/Windows.Devices.Bluetooth.h #include winrt/Windows.Devices.Bluetooth.GenericAttributeProfile.h using namespace Windows::Devices::Bluetooth; using namespace Windows::Devices::Bluetooth::GenericAttributeProfile; fire_and_forget ConnectAndDiscover(uint64_t address) { auto device co_await BluetoothLEDevice::FromBluetoothAddressAsync(address); if (!device) { printf(device not reachable: %016llX\n, address); co_return; } device.ConnectionStatusChanged([](BluetoothLEDevice const d, IInspectable const) { printf(connection - %d\n, (int)d.ConnectionStatus()); }); // Uncached 才去问设备要最新的服务表Cached 会返回系统缓存 auto svcResult co_await device.GetGattServicesAsync(BluetoothCacheMode::Uncached); if (svcResult.Status() ! GattCommunicationStatus::Success) { printf(discover services failed: %d\n, (int)svcResult.Status()); co_return; } for (auto const svc : svcResult.Services()) { auto chrResult co_await svc.GetCharacteristicsAsync(BluetoothCacheMode::Uncached); if (chrResult.Status() ! GattCommunicationStatus::Success) continue; for (auto const chr : chrResult.Characteristics()) { printf(svc%04X chr%04X props0x%08X\n, (unsigned)svc.Uuid().Data1, (unsigned)chr.Uuid().Data1, (unsigned)chr.CharacteristicProperties()); } } }FromBluetoothAddressAsync返回的BluetoothLEDevice可能为空这代表系统压根没找到这个地址对应的设备——可能超出了范围也可能设备已经关机。它不会抛异常只返回空所以要显式判断。BluetoothCacheMode这个枚举是整段代码里最关键的一个参数。传Cached时系统会拿自己缓存的服务表返回给你速度飞快,但设备固件升级、服务表变了之后你拿到的还是几天前那份。第一次调试一律用Uncached宁可慢两秒,也别对着旧 UUID 找半天问题。CharacteristicProperties()是个位标志后面订阅通知、写特征之前都要靠它判断能力常见的位含义如下标志位含义用在哪Read可读调ReadValueAsync前必查Write可写带响应WriteWithResponseWriteWithoutResponse可写无响应WriteWithoutResponse吞吐高Notify支持通知CCCD 写NotifyIndicate支持指示CCCD 写Indicate有确认包不查属性直接写WinRT 不会在客户端拦你请求会发到设备然后被拒最后拿回来一个ProtocolError问题定位就绕远了。3.3 对象生命周期连接为什么会莫名其妙断掉这是新手最容易翻车的地方而且现象极其迷惑日志里明明打印了Connected两秒后设备就掉线重连又连上再掉。根本原因是BluetoothLEDevice是引用计数对象FromBluetoothAddressAsync返回的实例一旦离开作用域被析构底层连接就跟着释放。上面那段ConnectAndDiscover如果直接把device当局部变量用完就扔连接寿命就等于函数寿命。对象作用释放后的后果BluetoothLEDevice设备连接主体连接断开GattDeviceService服务句柄该服务下的特征全部失效GattCharacteristic特征句柄订阅的回调不再触发GattSession连接保活与 MTU 信息保活失效MTU 变化收不到常规做法是把BluetoothLEDevice、用到的GattDeviceService和GattCharacteristic全都提升为长生命周期对象的成员别在函数栈上留。另外还有一种保活手段是拿GattSessionauto session GattSession::FromDeviceId(device.BluetoothDeviceId()); session.MaintainConnection(true); // 告诉系统别因为空闲就把连接回收了 printf(max pdu size %u\n, session.MaxPduSize());MaintainConnection(true)相当于给系统一个明确信号即使用户切走窗口、程序进入后台也别主动断。这行代码在长时间采集场景里几乎是必加的省掉大量跑一晚上第二天发现数据缺了一半的事故。4. GATT 读写与通知特征值操作的完整链路服务表拉出来之后真正的业务就三件事读、写、收通知。这三件事单独看都不复杂凑在一起时参数和时序的坑才开始冒头。4.1 读特征缓冲区和字节序#include winrt/Windows.Storage.Streams.h std::vectoruint8_t ReadCharacteristic(GattCharacteristic const chr) { auto result chr.ReadValueAsync(BluetoothCacheMode::Uncached).get(); if (result.Status() ! GattCommunicationStatus::Success) { printf(read failed: %d\n, (int)result.Status()); return {}; } auto reader DataReader::FromBuffer(result.Value()); std::vectoruint8_t out(reader.UnconsumedBufferLength()); reader.ReadBytes(out); // 一次读完避免多次小拷贝 return out; }.get()是在同步上下文里等异步结果适合后台线程或控制台程序如果这段代码跑在 UI 线程上会直接卡住界面那种场景要换成co_await。DataReader::FromBuffer不改动原缓冲区UnconsumedBufferLength()给出剩余可读字节数按这个长度一次性ReadBytes是最省事的读法。拿到的字节流怎么解释取决于设备厂商的协议。BLE 传感器常见的格式是小端序的定点数比如两字节温度、单位 0.01 摄氏度int16_t raw static_castint16_t(out[0] | (out[1] 8)); // 小端 double celsius raw * 0.01;这里翻车的典型方式是直接用memcpy到一个结构体上遇上没做字节对齐的协议就整个错位读出来的数值看着像真的实际差了几十倍。4.2 写特征带响应和不带响应的取舍#include winrt/Windows.Storage.Streams.h GattCommunicationStatus WriteCharacteristic(GattCharacteristic const chr, std::vectoruint8_t const payload, bool withResponse) { DataWriter writer; writer.WriteBytes(payload); auto buffer writer.DetachBuffer(); auto option withResponse ? GattWriteOption::WriteWithResponse : GattWriteOption::WriteWithoutResponse; auto result chr.WriteValueWithResultAsync(buffer, option).get(); return result.Status(); }两种写法的差别不只是速度选项协议行为单包开销适用场景WriteWithResponse设备回 Write Response一个往返配置寄存器、关键指令WriteWithoutResponse不等确认直接发无往返固件升级、连续数据流带响应写一次要等一个完整往返按连接间隔 30ms 算一秒最多三十来次不带响应可以连着灌吞吐能翻好几倍。代价是没有确认丢包你自己不知道。我的习惯是控制指令一律带响应数据通道才用不带响应而且不带响应时要自己在应用层加序号和校验。写之前还要确认属性位特征只声明了WriteWithoutResponse的时候你用WriteWithResponse发设备会直接回一个错误码看起来像是写失败实际是能力不匹配。4.3 订阅通知CCCD 和回调线程通知是 BLE 传感器最常用的模式——设备有数据就推不用主机轮询。void SubscribeCharacteristic(GattCharacteristic const chr) { // 回调里拿到的是原始字节处理逻辑扔给自己的工作线程 chr.ValueChanged([](GattCharacteristic const c, GattValueChangedEventArgs const args) { auto reader DataReader::FromBuffer(args.CharacteristicValue()); std::vectoruint8_t data(reader.UnconsumedBufferLength()); reader.ReadBytes(data); printf(notify %04X len%zu\n, (unsigned)c.Uuid().Data1, data.size()); }); auto status chr.WriteClientCharacteristicConfigurationDescriptorAsync( GattClientCharacteristicConfigurationDescriptorValue::Notify).get(); if (status ! GattCommunicationStatus::Success) { printf(cccd write failed: %d\n, (int)status); } }WriteClientCharacteristicConfigurationDescriptorAsync这一步是往特征的 CCCD 描述符里写0x0001Notify或0x0002Indicate设备收到之后才开始推数据。写成功不代表一定能收到还得特征本身带Notify属性。取消订阅不是把回调置空就行必须往 CCCD 里写None否则设备还在推只是你这边没人接chr.WriteClientCharacteristicConfigurationDescriptorAsync( GattClientCharacteristicConfigurationDescriptorValue::None).get();注意ValueChanged的回调运行在 WinRT 线程池的线程上不是调用Subscribe的那个线程。回调里做重活会拖慢整个通知队列处理逻辑要么快进快出要么丢进自己的队列。4.4 MTU 协商与分包默认 ATT MTU 是 23 字节扣掉 3 字节头部一个包实际能带 20 字节有效载荷。要传 200 字节的数据就得自己切成十包。std::vectorstd::vectoruint8_t SplitByMtu(std::vectoruint8_t const data, size_t mtu) { // ATT 头占 3 字节实际可用负载 MTU - 3 size_t chunk (mtu 3) ? (mtu - 3) : 20; std::vectorstd::vectoruint8_t out; for (size_t i 0; i data.size(); i chunk) { auto end std::min(i chunk, data.size()); out.emplace_back(data.begin() i, data.begin() end); } return out; }mtu从GattSession::MaxPduSize()拿Windows 10 之后这个值会随链路协商变化可以订阅MaxPduSizeChanged事件跟着调整分包大小。写死 20 字节虽然不会错但吞吐只有实际能力的三分之一到五分之一长包传输时差别非常明显。分包之后还要在协议层给设备一个边界信号要么每包带序号和总长度要么用固定的首尾标志。BLE 的 GATT 写本身不保证这一包是消息结尾设备侧只能靠应用层协议判断。5. 避坑与排查BLE 在 Windows 上最常翻车的五件事5.1 扫描器一条回调都不触发现象watcher_.Start()返回正常Received回调一次都没进日志一片空白。原因通常有三层系统蓝牙开关被关了或适配器被禁用当前适配器不支持低功耗应用是打包应用但清单里漏了bluetooth能力。前两种都不会抛异常扫描器只会安静地停掉。解决在启动扫描前先做一次适配器体检把状态打出来。auto adapter BluetoothAdapter::GetDefaultAsync().get(); if (!adapter) { printf(no bluetooth adapter\n); return; } printf(LE supported%d radio state%d\n, adapter.IsLowEnergySupported(), (int)adapter.Radio().State());Radio().State()返回On/Off/DisabledDisabled一般是飞行模式或组策略禁用。这一步花十行代码能省掉半小时的代码是不是写错了自我怀疑。5.2 连接建立两秒后自动断开现象ConnectionStatusChanged先报Connected紧接着报Disconnected重连重复同样过程。原因BluetoothLEDevice实例被析构。FromBluetoothAddressAsync返回的是引用计数对象赋给局部变量、函数一返回就释放底层连接跟着回收。解决把设备对象提升为成员变量同时加GattSession::FromDeviceId(...).MaintainConnection(true)双保险。另外要注意GattDeviceService和GattCharacteristic也是同理订阅用的特征实例必须是长期持有的那一个重新GetCharacteristicsAsync拿到的是新实例挂在上面的回调也跟着换了。5.3 CCCD 写成功但通知收不到现象WriteClientCharacteristicConfigurationDescriptorAsync返回Success设备侧灯也在闪PC 端ValueChanged就是不进。原因写的是Indicate而设备只支持Notify或者反过来某些设备对不匹配的类型不报错直接忽略另一种情况是回调挂在了另一个GattCharacteristic实例上两个实例指向同一个 UUID 但不是同一个对象。解决先查CharacteristicProperties()里到底有没有Notify位再决定写Notify还是Indicate。回调一定挂在订阅时用的那个实例上别重新发现一遍服务再挂。5.4 写入返回 ProtocolError现象WriteValueWithResultAsync返回GattCommunicationStatus::ProtocolError。原因最常见的是单包超过 MTU 上限ATT 层直接拒其次是特征只支持WriteWithoutResponse却用了WriteWithResponse还有一种隐蔽情况是设备要求先写某个使能寄存器顺序错了后面全错。解决先用SplitByMtu切包再核对属性位最后对照设备厂商的协议文档确认写入时序。排查时可以临时把 payload 缩到 1 字节试一次能过就说明是长度问题不能过就是权限或时序问题两分钟就能分流。5.5 服务表和固件对不上现象设备固件升级过新增了一个特征程序里死活发现不了。原因BluetoothCacheMode::Cached返回的是系统缓存的服务表Windows 会把发现过的 GATT 结构缓存很久。解决调试期一律Uncached确实是缓存脏了去系统蓝牙设置里把设备删除重配一次缓存跟着清掉。生产环境可以在固件版本变化时主动触发一次Uncached全量发现之后再用Cached提速。6. 进阶把 BLE 访问封装成可复用的一层零散调用能跑通之后下一步是把它收成一个稳定的连接层否则每加一个设备型号就要复制一遍代码。我在资源里把这一层拆成三块扫描器只管发现连接器只管链路状态协议层只管字节流解释。连接器的核心是一个显式状态机别用一堆布尔量凑enum class LinkState { Idle, Scanning, Connecting, Discovering, Ready, Backoff, Failed }; // 退避重连100ms 起步指数增长封顶 5 秒 std::chrono::milliseconds BackoffDelay(int attempt) { auto ms 100LL std::min(attempt, 6); return std::chrono::milliseconds(std::minlong long(ms, 5000)); }状态机的价值在于每个状态只有一个入口和一个出口。Connecting超时进BackoffBackoff计时到点回ConnectingDiscovering失败直接进Failed不再重试——因为服务发现失败通常是协议不匹配重连一百次也没用。这套规则写死之后程序跑着跑着就不动了这类问题会少一大半。排查手段上两个习惯值得养成。一是每个 GATT 操作都打印status加耗时Success也打因为连接劣化时最先变化的是耗时从 20ms 涨到 300ms 就是链路要出问题的前兆。二是拿手机上的通用 BLE 调试 App 做交叉验证同一台设备用手机能读到、PC 读不到问题就在 PC 侧手机也读不到问题在设备侧。这个二分法能省掉大量猜谜。auto t0 std::chrono::steady_clock::now(); auto st WriteCharacteristic(chr, payload, true); auto ms std::chrono::duration_caststd::chrono::milliseconds( std::chrono::steady_clock::now() - t0).count(); printf([gatt] write uuid%04X len%zu status%d cost%lldms\n, (unsigned)chr.Uuid().Data1, payload.size(), (int)st, (long long)ms);有几条血泪经验是从实际事故里换来的永远不要在 UI 线程上调.get()卡住的不只是界面还有整个通知回调链永远在写特征前查一次属性位省下的两行代码会用两小时排查来还掉线重连之后必须重新走一遍服务发现和 CCCD 订阅别指望系统帮你恢复。从那以后我每次接新设备都强制先把扫描器单独跑满十分钟看设备名和 RSSI 是否稳定再动连接代码——这一步跳过去后面所有问题都会长得像连接问题。资源里ble_scanner和gatt_client两个 target 是独立的可以先单独编出来跑通一个再往下改比从空文件开始搭要快得多。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询