ARTICLE DETAIL

资讯详情

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

BLE开发必懂:GAP与GATT从广播到数据通信的完整指南

BLE开发必懂:GAP与GATT从广播到数据通信的完整指南 提到低功耗蓝牙技术BLE很多刚入门的开发者、做物联网硬件的朋友或者想自己写个App连智能设备的人第一个绕不开的坎就是GAP和GATT。这两个缩写几乎出现在所有蓝牙协议文档、SDK示例和面试题里但网上资料要么太浅只告诉你“GAP管广播、GATT管数据”看完似懂非懂要么一上来就甩规范原文密密麻麻的表格直接劝退。我做嵌入式蓝牙开发这些年带过不少新人也踩过不少协议层面的坑。发现只要把GAP和GATT这两个家伙的关系理清楚后面看协议栈、调SDK、排查兼容性问题都会顺很多。这篇文章不打算逐条翻译蓝牙5.4规范我想用做产品、写固件的视角把GAP和GATT到底是什么、各自管哪一段、从广播到连接再到收发数据的完整流程是什么样配合我实际踩过的坑一次讲明白。适合刚接触BLE的开发者也适合那些已经在调SDK但心里没底的朋友。1. 先搞懂BLE协议栈GAP和GATT到底站在哪一层1.1 从物理层到应用层BLE其实是一套分层体系很多朋友一上来就研究GAP和GATT但对它们在整个协议栈里的位置没有概念。低功耗蓝牙技术不是“一个协议”而是一整套分层协议栈。只有先搞清楚层次关系你才能理解为什么GAP管的东西和GATT完全不一样。物理层PHY负责在2.4GHz频段收发射频信号BLE把这段频谱分成40个信道每个信道间隔2MHz其中37、38、39三个信道专门用来广播其余37个信道用来传数据。链路层Link Layer负责设备发现、广播、连接状态机以及跳频、重传这些底层机制。再往上HCIHost Controller Interface是控制器和主机之间的分界线比如你把一个BLE模块接到单片机上单片机通过串口给模块发HCI命令这个串口就是HCI层。真正让开发者每天打交道的是主机层Host。主机层包含L2CAP逻辑链路控制和适配协议负责把上层数据分包、复用SMP安全管理协议负责配对和密钥分发再往上就是ATT、GATT和GAP。没接触过协议栈的朋友可以把这层结构想象成寄快递物理层是运输货车链路层是快递分拣中心L2CAP是包装盒ATT/GATT是包裹里的文件而GAP是快递员第一次联系你时的“身份确认”和“签收流程”。重点是GAP和GATT都在Host层而且是并列关系。它们一个管“设备之间怎么认识、怎么建立连接”一个管“认识之后怎么交换业务数据”。很多人把GAP和GATT混在一起甚至以为它们和链路层一样是上下级关系这是入门阶段最大的误解。1.2 GAP和GATT的分工一个是“名片”规则一个是“病历本”规则我常用一个生活化的比喻来解释GAP和GATT的关系。GAPGeneric Access Profile通用访问规范解决的是“两个设备怎么在公共场合发现彼此、确认身份、然后再建立连接”。它更像一套交际礼仪你到会场要先出示名片别人看到你的名片知道你是谁、是干什么的然后你们约个时间找个会议室坐下来谈。这个过程中要不要交换名片、名片上写什么字段、用什么方式打招呼比如是主动握手上来说一句还是只远远站着让别人看见你都是GAP管的事。GATTGeneric Attribute Profile通用属性规范解决的是“建立连接之后业务数据该按照什么格式组织、怎么读写”。它更像医院的病历本病人到了诊室医生翻开病历本看到“体温”这一栏填的是36.5“血压”这一栏填的是120/80。每一项都有固定位置、固定格式医生知道在病历本对应位置读取就知道该填什么、该看什么。GATT定义的就是这本“病历本”的排版规范和读写规则。简单说GAP负责“认识你”GATT负责“看你的数据”。两者阶段不同、职责不同但又必须配合。一个蓝牙设备没有GAP别人根本发现不了它没有GATT就算连接上了双方也不知道数据怎么组织通信依然是鸡同鸭讲。1.3 为什么建议先学GAP和GATT再钻其他细节L2CAP、SMP这些协议同样重要但我在带新人时的经验是优先掌握GAP和GATT对项目开发和产品设计的收益最大。原因很简单GAP定义了设备形态广播者、观察者、外设、中心设备GATT决定了数据模型服务和特征怎么设计这两点直接对应你写代码时的回调函数和UI界面。而L2CAP的流控细节、SMP密钥协商过程在绝大多数应用中你不需要手动干预协议栈和SDK已经帮你处理了。还有一个现实原因BLE面试和联调中问得最多、最容易出问题的就是GAP和GATT。比如“外设广播后中心设备扫描时到底能不能拿到完整数据”“连接成功后为什么找不到服务”“为什么收不到通知”——这些问题的答案都藏在GAP和GATT的细节里。先把这两个家伙吃透就等于拿到了BLE开发的地图。2. GAP详解连接建立前的一切规矩2.1 四种设备角色先搞清楚自己在哪一边GAP定义了四种设备角色所有BLE设备都能归到其中一类Broadcaster广播者只能发广播不建立连接。典型场景是iBeacon、Eddystone这类信标设备它们不停广播一个ID手机扫到就知道“你走到某个位置了”。Observer观察者只能扫描广播不建立连接。典型场景是手机上的扫描工具、室内定位接收端。Peripheral外设可以广播也可以被连接。典型场景是手环、温度计、体脂秤这类设备它们广播自己等手机来连接。Central中心设备可以扫描、发起连接、和多个外设保持连接。典型场景是手机、平板、车载主机。这里有一个容易混淆的点Peripheral和Central是GAP层的角色但它和设备本身的“能力”没有绝对关系。比如一个耳机既是Peripheral被手机连接也可能在某些场景下作为Central去连接另一个耳机做音频分享。所以做产品设计时不要一上来指着设备说“你是外设所以只能广播”要看它在具体连接场景中扮演什么角色。很多人还会把“广播”和“可连接”等同起来。实际上GAP里有一个设备状态机包含待机Standby、广播Advertising、扫描Scanning、发起连接Initiating、连接Connection这么几个状态。一个设备可以在广播状态下也可以只广播不响应连接就是Broadcaster还可以在广播的同时允许别人连进来就是Peripheral。广播期间是否接受连接请求取决于广播包里携带的广播类型这一点很多新手会看漏。2.2 广播数据里藏着什么AD Structure解析GAP管广播但广播数据具体长什么样很多开发者一直是一知半解。这里我直接拆一个实际的广播示例。BLE广播数据的最小单位叫AD StructureAdvertising Data Structure每段由三部分组成长度1字节、AD Type1字节、数据若干字节。一段广播就是由多个AD Structure拼接而成的。举个例子假设一个设备的广播数据是0x02 0x01 0x06长度2字节类型0x01Flags数据0x06。0x06二进制是00000110表示“支持LE通用发现模式不支持BR/EDR”翻译成人话就是“我是纯BLE设备不是双模蓝牙”。0x03 0x03 0x0F 0x18类型0x03是“16位服务UUID列表部分”数据0x180F是电池服务的UUID小端序意思是“我有电池服务”。0x09 0x09 0x6D 0x79 0x5F 0x64 0x65 0x76 0x69 0x63 0x65类型0x09是“完整本地名称”数据“my_device”。看到这里你就明白了广播并不是杂乱无章地喊话而是按照GAP定义的字段格式把设备能力、设备名、服务UUID有组织地广播出去。实际开发中用nRF Connect或LightBlue扫描设备时看到的“Flags”“Complete Local Name”“Service UUIDs”这些字段就是从这一段段AD Structure解析出来的。广播包的大小有严格限制传统广播模式下有效数据最多31字节。所以设计广播内容时要精打细算名字太长了放不下怎么办UUID太多了怎么办这些都是在GAP层就要考虑的问题。我见过不少产品广播里既想放设备名又想放服务UUID还要带厂商私有数据结果31字节根本塞不下。这种时候就要想清楚优先级或者考虑使用扩展广播LE Extended Advertising但扩展广播会带来功耗和兼容性的新问题不是所有手机都支持得很好。2.3 连接参数不是随便选的连接间隔、从机延迟、监督超时GAP不只是管“发现”和“连接建立”还定义了连接成功之后连接参数的协商机制。很多开发者在调功耗、调稳定性时绕不开这三个参数连接间隔、从机延迟、监督超时。连接间隔Connection Interval指两个连接事件之间的时间间隔单位是1.25ms取值范围是7.5ms到4s。连接间隔越短数据吞吐越高但功耗也越高。从机延迟Slave Latency指从设备可以跳过多少个连接事件取值范围0到499。从机延迟越大从设备可以睡越久功耗越低但中心设备发数据后从设备的响应会变慢。监督超时Supervision Timeout指连接双方多久没有成功通信就判定连接断开单位是10ms范围100ms到32s通常设为几秒到十几秒。这三个参数的取舍要结合业务场景。比如一个心率胸带需要实时不断上报心率数据连接间隔可能设到15ms到30ms从机延迟设0或1但如果是一个智能门锁大部分时间不需要收发数据连接间隔可以设到200ms以上从机延迟拉高让设备大部分时间可以休眠。这里要提醒一下连接参数不是外设单方面说了算而是外设通过GAP的连接参数更新请求Connection Parameter Update Request发起中心设备有权接受或拒绝。如果你调了一个连接的参数对方没有响应可能不是你的参数不对而是中心设备不认可。当年做一款运动手环时我们把连接间隔从30ms改到50ms想省电结果很多Android手机在连接一两分钟后主动断链最后查下来就是Android对连接间隔和从机延迟的时间窗口有硬性限制修改后的参数没有落在系统允许的区间内。2.4 配对与安全GAP层的保护机制GAP还管着配对Pairing和绑定Bonding。简单说配对是指两个设备通过交换临时密钥建立加密连接的过程绑定则是在配对之后双方把长期密钥保存下来以后再连时不用重新配对。这里需要区分两代配对算法。蓝牙4.2及之前广泛使用的是LE Legacy Pairing它使用AES-128加密但不同配对方法安全性差异很大。Just Works方式没有任何用户交互直接生成临时密钥安全性最弱Passkey Entry要求一个设备显示6位数字、另一个设备输入安全性明显提升Out of BandOOB则通过其他物理通道交换密钥安全性最高。蓝牙5.0时代主推LE Secure Connections它使用ECDH椭圆曲线迪菲-赫尔曼密钥交换进一步解决了中间人攻击问题并新增了Numeric Comparison方式——两个设备都显示一组数字用户比较一致后按确认兼顾安全性和易用性。注意Legacy Pairing和Secure Connections的兼容性存在一些历史坑如果需要兼容老设备得在固件里明确配置配对算法和加密密钥大小。配对加密对于很多IoT产品是必须的。尤其是那些涉及用户隐私或设备控制的服务比如智能锁、门禁、健康设备如果服务特征属性没有设置加密权限任何连接上的设备都能直接读写数据这会造成很大的安全隐患。在GATT层设计服务时每个特征值都应该明确它的安全要求这一点我会在下一章细讲。3. GATT详解连接之后的“数据字典”3.1 ATT是传输机制GATT才是数据结构很多人搞不清GATT和ATT的关系。ATTAttribute Protocol是一种底层的属性读写协议它的核心是“属性Attribute”。一个属性包含句柄Handle、类型UUID、权限Permissions和值Value。客户端通过读取、写入这些属性完成数据交换。GATT则是在ATT之上定义的一套“组织规范”它规定了属性该如何按服务、特征、描述符排布。可以这样理解ATT是纸、笔和写字的动作GATT则是“写病历”的格式要求。没有ATT数据没法在BLE链路上传输没有GATT数据虽然能传但双方不知道这些字节代表什么含义、该放在什么位置。一个典型的问题“我用BLE给设备发一串字节设备怎么知道我发的什么意思”答案就在这里你的数据最终会写到某个特征值Characteristic Value里设备端代码会根据这个特征值的UUID和业务逻辑去解析收到的字节。GATT就是那个约定让“发数据的设备”和“收数据的设备”对数据的语义达成一致。3.2 Service、Characteristic、DescriptorGATT的树形结构GATT把数据组织成一个层级结构从上到下依次是Service服务、Characteristic特征、Descriptor描述符。Service是一个功能模块相当于一个分组。一个设备通常有多个服务比如电池服务、设备信息服务、心率服务。Service本身没有数据它的价值是归拢特征。Characteristic是真正用来传输数据的地方它包含一个值和若干个属性。每个特征都有自己的UUID支持的操作读、写、通知、指示由特征属性Characteristic Properties决定。比如温度计的“温度测量”特征属性通常是“通知”设备连接后主动把温度推给手机而不需要手机反复拉取。Descriptor是特征的附加说明或配置项。最常见的是CCCDClient Characteristic Configuration Descriptor客户端特征配置描述符UUID是0x2902。它的作用是让客户端比如手机去开启或关闭某个特征的通知功能。实际开发中绝大多数“收不到数据”问题的根源都是没有正确写入CCCD来使能通知。我拿一个心率服务举例。心率计的心率服务UUID是0x180D服务下有一个“心率测量”特征UUID是0x2A37属性包含“通知”同时包含一个CCCD描述符0x2902。连接建立后手机第一步在服务下找到0x2A37特征第二步往它的CCCD里写入0x0001使能通知第三步设备才开始主动往手机上推心率数据。如果不写CCCD很多设备就一直不推数据这不是设备的bug而是协议规定的“通知开关”没打开。3.3 UUID16位、32位、128位到底怎么用UUID是GATT中识别服务和特征的唯一标识。蓝牙SIG协会为常见服务定义了一组16位UUID比如电池服务0x180F、设备信息服务0x180A、心率服务0x180D、电量特征0x2A19等。16位UUID的好处是占用空间小、兼容性高而且能被各平台自动识别和展示。如果你要定义一个自定义服务SIG规定应该使用128位UUID格式基于Base UUID0000xxxx-0000-1000-8000-00805F9B34FB。理论上你可以把Base UUID里的xxxx换成任意值但要注意这只是一个底座不是让你把所有产品都用同一个“xxxx”值。正规做法是用UUID生成工具生成一个随机128位UUID比如a7631234-...并且把这个UUID固定到你的固件和App协议文档中不要随便改。还有不要使用已经是SIG保留的16位UUID来定义你自己的服务比如你把自定义服务做成0x180D那和标准心率服务冲突手机可能识别成心率计调试时会非常混乱。16位和32位UUID在设计上只是为了节省广播包和ATT报文的长度本质上是128位UUID的一种压缩表示。比如SIG的心率测量特征完整UUID其实是00002A37-0000-1000-8000-00805F9B34FB但在BLE协议里可以简写为0x2A37。日常开发时你看到的“0x2A37”形式只是一种方便阅读的写法而已。3.4 四种操作读、写、通知、指示GATT里最常见的四种数据操作方式是读Read、写Write、通知Notify、指示Indicate。它们的区别很多人在开发中掌握不到位。Read是最简单的“拉取”模式。客户端主动发起读请求设备把特征值返回给客户端。适合低频、实时性要求不高的数据比如设备型号、固件版本。Write有两种有响应的写Write Request和无响应的写Write Command。有响应的写会携带一个确认信号确保对端收到了但速度慢无响应的写不要求对端确认速度快适合大数据量传输但可靠性低发送方并不知道有没有真正写进去。选择的时候要看业务控制指令比如开锁必须用有响应的写如果大批量升级固件可以用无响应的写加速但要做好失败重传机制。Notify和Indicate都是设备主动把数据推给客户端的方式。Notify不需要客户端确认速度快但客户端没有“收到确认机制”会有丢数据的可能Indicate需要客户端确认可靠但吞吐量低。心率、温度这些周期性、可以容忍偶尔丢包的数据用Notify足够电量低这种重要事件建议用Indicate确保到达。这里要特别说一下MTU。BLE默认的ATT_MTU是23个字节其中有效载荷只有20个字节。如果数据动不动超过20字节要么在上层做分片重组要么在连接后协商更大的MTU。Android和iOS都支持MTU协商但协商需要一定时间而且不同手机的最大MTU支持不一样iOS一般可以支持185字节Android很多设备能到247甚至更高。我做OTA升级时最关心这个单包能传的数据量直接决定升级耗时。4. 从GAP建立连接到GATT通信一条完整的BLE链路4.1 连接生命周期广播、扫描、建链、发现服务、收发数据把GAP和GATT串起来一个标准BLE连接的完整生命周期大致是这样的。设备A外设上电后开始广播广播包里带上设备名、服务UUID、厂商私有数据等信息。设备B手机不断扫描收到广播后在扫描界面上展示出来。这个阶段完全是GAP的舞台。手机点击设备发起连接请求外设接受连接两个设备进入连接状态。此时GAP的连接参数协商完成双方确定了连接间隔、从机延迟、监督超时。这一阶段仍然以GAP为主。连接建立之后手机立刻会做一件事服务发现Service Discovery。它会把外设上所有服务都拉下来会看到一个个Service、Characteristic、Descriptor列表。这就是GATT在起作用了。很多人连接成功后打开服务列表是一片空白原因可能在于服务发现失败、设备不支持GATT、或者缓存了旧的服务数据。服务发现完成后手机找到目标特征根据业务需求做读、写、使能通知等操作。比如手机往某个特征里写入一条控制指令设备收到后执行动作再通过另一个特征Notify状态回来。到这里GATT的工作就全面展开了。最后一方断开连接或超时连接结束。如果设备在业务上还要继续广播比如断开后重新进入可发现模式GAP又会介入让设备回到广播状态。4.2 用手机调试工具验证你理解的GAP和GATT理论讲再多不如打开手机实际看一遍。我用得最多的是nRF Connect其次是LightBlue它们能同时展示GAP和GATT这两个维度的信息非常适合验证协议理解。打开nRF Connect后扫描页面会列出周围所有正在广播的设备。每一条记录里你能看到设备名、MAC地址、RSSI信号强度以及广播数据中解析出的AD Structure。这就是GAP层在做的事情。点进某一台设备把广播数据展开你会看到Flags、Complete Local Name、Service UUIDs这些字段对应我们在2.2节拆解的结构。点击“Connect”设备连接成功后下方会列出该设备的GATT服务列表。展开某一个服务可以看到下面的特征、描述符每个特征旁边都有读写权限标识。在这个界面上你可以直接尝试读取特征值、写入数据、开启通知。如果在连接后立刻点开服务列表你还能看到“正在发现服务”的过程这对应我们刚才说的Service Discovery阶段。把调试工具当“放大镜”来观察协议行为再看GAP和GATT的细节会直观很多。4.3 如果你要自己写固件先规划好GATT服务结构我见过很多项目代码写到一半才发现GATT服务设计不合理导致App、固件、测试来回改。我强烈建议在写任何蓝牙代码之前先把GATT服务设计成一张结构表。假设你要做一个蓝牙温湿度计服务和特征的规划大致是服务特征UUID读写属性通知/指示说明温度服务温度值自定义128位UUID1只读加密Notify温度更新时推送湿度服务湿度值自定义128位UUID2只读加密Notify湿度更新时推送设备信息服务固件版本0x2A26只读无供App读取版本号电池服务电量0x2A19只读Notify电量变化时提醒做这个表的时候要想清楚几件事哪些数据需要加密比如温度属于健康数据最好加密哪些数据用Notify推送哪些数据靠手机Read拉取广播包里需不需要带完整服务UUID。把这些定下来再去看SDK的示例代码你就会发现自己是在“按图施工”而不是被示例代码牵着走。这也是从“会调例程”到“会做产品”的关键一步。5. 避坑指南GAP和GATT开发中的常见问题与排查技巧5.1 常见问题速查表我在实际开发中整理过一份排查清单遇到问题先对表大部分情况能快速定位。现象可能原因排查方向手机扫描不到设备广播间隔太长、广播信道被干扰、设备处于私有模式或定向广播检查广播参数确认广播事件是否有发出尝试降低广播间隔检查天线路由和信道能发现设备但连不上设备在同一时间只允许单连接广播类型不是可连接广播连接参数被中心设备拒绝确认Peripheral连接状态检查广播PDU类型是否支持连接查看连接参数是否合法连接成功但服务列表为空服务发现失败设备未正确配置GATT数据库中心设备缓存旧GATT数据清空蓝牙缓存重试确认设备端GATT server已启动检查ATT MTU协商是否异常收不到通知没有写入CCCD使能通知连接间隔过长导致推送延迟特征属性没有配置通知在特征对应的0x2902描述符写入0x0001确认特征属性包含Notify检查连接参数设备自动断连监督超时设置不合理连接间隔超出中心设备限制射频链路质量差导致丢包检查监督超时是否小于连接参数的合理倍数查看连接事件是否频繁丢失调整连接参数功耗异常高广播间隔过短连接间隔过短从机延迟未配置加长广播间隔放宽连接间隔配置从机延迟并用电流仪器实测对比Android/iOS表现不一致各系统对GATT缓存、广播解析、MTU协商策略不同用nRF Connect在两个平台对比抓包按照系统规范微调参数另外这里插一句题外话。你在搜索引擎里搜“GAP”偶尔会看到一排结果和蓝牙完全无关说的是数据库在不同机房之间的日志间隔有时候也叫GAP比如oracle主备切换里常见的“resolvable gap”。跟蓝牙完全两码事别被带偏。搜技术资料时记得加上“BLE”“蓝牙”这些限定词否则很容易翻到另一个领域的文章白白浪费时间。5.2 三条独家经验谈第一条经验在广播数据里精简设备名。31字节的广播空间非常有限设备名一旦超过十几字节很多广播数据就得塞到扫描响应里而扫描响应需要主动扫描才能拿全会增加扫描时长和功耗。我做产品时设备名通常控制在12字节以内能放下“完整本地名称”字段就尽量不放“简称”加扫描响应。第二条经验服务发现之后先把CCCD写一遍。无论你是做外设固件还是做手机App连接建立后第一时间检查是否需要使能通知。很多项目里“连上了但收不到数据”的根源都是没有写CCCD。尤其是iOS上的CoreBluetooth如果App没有在didDiscoverCharacteristics回调里写入CCCD后面基本就等不到数据推送。听起来很基础但它就是实操中最频繁的坑。第三条经验不要迷信“连接间隔越小越好”。连接间隔确实影响实时性但也会让射频更忙、功耗更高还会让路由器、其他BLE外设的干扰更明显。有些产品把连接间隔设到了7.5ms最低值实际测量只能跑到15ms甚至更低而且经常出现连接不稳定。做功耗优化时把连接间隔稍微放宽比如从30ms改到50ms数据量不大的场景下体验几乎没差别但功耗可能降低三分之一。5.3 一些实操细节能省你半天调试时间调试时可用的一个实用技巧是在手机端切换系统的“蓝牙日志”开关抓取HCI日志。Android开发者选项里有“蓝牙HCI信息收集”选项打开后重启蓝牙系统会把所有GAP、GATT、ATT层的通信过程记录下来。分析这些日志能清楚看到Service Discovery的具体报文、每次读写的响应状态、连接参数的实际协商结果。iOS上用Xcode也能拿到类似的PacketLogger日志。遇到协议层面的疑难杂症这是最有效的定位手段。在实际的项目里还有一个容易被忽略的点就是UART串口和BLE模块的流控。如果你用的是BLE透传模块比如常见的CC2541、nRF52832方案主机通过UART给模块发数据再由模块把数据封装成GATT特征值发出去。这个过程中UART的波特率、流控设置、和ATT_MTU的大小会直接影响单包数据长度。我们之前做一个数据采集设备手机App总是收到截断的数据查到最后才发现是UART缓冲区大小和ATT_MTU不匹配导致单包数据超过MTU只能在上层做分片。这种问题不抓包根本发现不了。最后再分享一个小技巧。GATT广播包里的Service UUID尽量和实际固件里定义的Service UUID保持完全一致。很多JDY、HC系列透传模块会把“自定义服务UUID”和“默认透传服务UUID”同时放在广播里结果手机扫到时看到一堆UUId尝试验配却只能匹配其中一个App端如果固定了广播服务UUID去过滤设备就会遇到“扫描不到目标设备”的情况。建议在产品定义阶段把广播服务UUID、App过滤逻辑、GATT服务结构三位一体地确定下来再进开发阶段能省下一大批兼容性问题。我个人在实际操作中的体会是GAP和GATT不是背完术语就能搞定的东西一定要在真实设备上跑一遍从广播到读写通知的完整流程边看协议日志边理解。我前两年做一款体温贴和App联调时反复出现“连接成功但App看不到数据”后来发现是Android手机缓存了旧的GATT服务换了设备、清了蓝牙缓存才好。当时我就想如果一开始团队把GAP和GATT的分工讲清楚大家排查时根本不会绕这么多弯路。GAP是“认识人的那套流程”GATT是“认识以后怎么说话的那本字典”这两层搞定了BLE开发的一大半问题都有了思路。
返回列表