简介围绕BACnet楼宇自动控制技术展开的一套C#实践工程面向准备入门或正在学习BACnet协议、需要动手验证设备读写的开发者帮助解决协议理解停留在文档层面、缺少可运行示例的问题。压缩包共131个文件整体约2.12MB以32个xml配置文件、19个dll程序集、10个pdb调试符号、8个cs源码以及nupkg依赖包、sln与csproj工程文件为主另含exe可执行程序、png界面截图、txt与config说明构成可直接编译运行的完整解决方案结构。已有311人学习说明其在同类学习资料中具备一定参考价值。读者可从中获得设备对象属性读写与属性值变化订阅通知的调用范例借助源码理清BACnet客户端与设备之间的交互流程并结合调试符号与依赖包排查运行环境问题适合作为协议学习阶段的对照代码与二次开发起点。1. 从一台新风机说起BACnet 到底在楼里管什么楼里那台新风机夏天开多大风、冬天几点关背后是楼宇自控系统在说话。麻烦在于一栋楼里的设备来自七八个厂家控制器、温控器、水表、电表各有各的脾气谁也不认谁的接口。BACnet 就是让它们能互相听懂的一套通用语言它规定了设备怎么自我介绍、怎么被读数、怎么被改参数、值变了又怎么主动通知别人。这份资源把 BACnet 楼宇自动控制里最常用的三件事拆成了能跑的 C# 例子——读设备属性、写设备属性、订阅属性值变化。它适合刚接触楼宇自控、会一点点 C# 但没碰过现场设备的人不适合想研究报文二进制格式的老手。往下读你会先弄懂几个词再跑通第一个能读到真实数值的程序。2. 三个词先记住对象、属性、实例号刚打开一份 BACnet 设备手册的人最常见的反应是「这写的是啥」。满页的 Analog Input、Present_Value、Instance 1001看着像天书。其实这套模型非常朴素用一张 Excel 表就能类比完设备是一张工作簿对象是里面的工作表属性是每张表的列实例号是每张表的编号。把这一层想通后面所有的读写和订阅都只是「按编号找到表、按列名取值」的动作。2.1 一台设备不是「一台机器」是一堆对象的集合在 BACnet 的世界里你连上的那台控制器并不是一个整体它内部被切成了几十上百个「对象」。一个温度采集通道是一个模拟量输入对象一个风机启停是一个二进制输出对象一个季节模式是一个多态输入对象。每种对象类型有自己的名字比如模拟量输入Analog Input、模拟量输出Analog Output、二进制输入Binary Input、二进制输出Binary Output、多态输入Multi-state Input以及最特殊的设备对象Device。为什么非要拆成对象因为拆开之后所有厂家都遵循同一套描述方式。你不需要知道某台控制器是哪个厂的第几代产品只要知道它内部有一个「模拟量输入、实例号 3」的对象就能读到它的当前值。这就是互操作性的来源也是楼宇自控里跨品牌集成能成立的前提。这里有个新手最容易忽略的点对象类型和对象是两回事。Analog Input 是类型Analog Input 3 才是一个具体的对象。你以后写代码时几乎每个接口都要同时传「类型」和「实例号」两个参数少一个都定位不到东西。2.2 属性是对象的字段读写读写读的就是它每个对象身上挂着一组属性像一张表里的列。以模拟量输入为例最常用的几个属性是当前值Present_Value、对象名称Object_Name、单位Units、状态标志Status_Flags、报警上下限High_Limit / Low_Limit。你打开监控画面看到的那个「23.5 ℃」本质就是读了一次 Present_Value你在后台改的那个设定值本质就是写了一次 Present_Value 或者 Setpoint_Reference。这里要建立一个很重要的直觉BACnet 里没有「读一个点」这种说法只有「读某个对象的某个属性」。这句听起来像抠字眼但它决定了你写代码时的参数结构。很多新手拿着点名去问设备要值结果发现接口签名要的是三个参数卡在那儿半天就是因为脑子里还停留在「点」的概念上。还有一类属性叫属性数组比如 Priority_Array优先级数组它本身是一个长度为 16 的数组。读这种属性时要额外带一个数组下标读到的是某一级优先级上当前挂着什么值。这个后面讲写值的时候会专门说先记住「有的属性是单个值有的属性是一串值」。2.3 实例号是门牌号别和对象类型搞混实例号是一个 0 到 4194302 之间的整数由工程调试人员在设备里配好作用等同于门牌号。同一台设备内部同一个类型下的实例号不能重复不同设备之间设备对象自己的实例号也就是设备实例号在整个网络里必须唯一这是设备之间互相识别身份的凭据。我在带新人时见过一种典型翻车他想读 3 号楼 2 层的温度从图纸上抄了一个「3」然后写代码时把对象类型也填成了 3。程序不报错就是读不到东西因为类型 3 恰好是模拟量输入但实例号 3 上挂着的是别的东西。所以每次定位一个对象脑子里要默念一遍类型是什么、实例号是多少两个都要对。那怎么知道设备里有哪些对象、实例号分别是多少靠猜是不行的。设备对象上有一个属性叫 Object_List它列出了这台设备里所有对象的编号。先读它再挨个读这是摸清一台陌生设备的标准流程比翻手册快得多。2.4 不写代码也能先摸一遍读和写背后的两个服务BACnet 把「能做的事」定义成一个个服务Service。入门阶段你只需要认识四个读单个属性ReadProperty、一次读多个属性ReadPropertyMultiple、写属性WriteProperty、订阅变化SubscribeCOV。前三个是问答式的你问一次它答一次第四个是登记式的你告诉设备「这个点变了记得喊我」然后等它主动上报。在你还没装好开发环境之前其实可以先用手头工具摸一遍设备这一步对建立感觉特别有用找一台能连上的控制器确认你的电脑和它在同一个网段用厂家自带的调试软件或者通用的 BACnet 设备浏览器做一次设备发现Who-Is 广播设备回 I-Am在列表里选中那台设备展开它的对象列表找到设备对象读它的 Object_Name 和 Object_List从 Object_List 里挑一个模拟量输入读它的 Present_Value看看这个数字是不是和现场温度对得上找一个允许写的点比如某个设定值读一下它的 Priority_Array看看 16 个格子里哪个有值。这五步做完你对「对象 属性 实例号」的理解会远超看十页文档。而且这一步不涉及任何代码不会因为环境问题卡住纯粹是在建立对设备的直觉。等你能熟练地用手点出这些值再用 C# 复现一遍速度会快很多。3. 第一个例子用 C# 连上设备读一个值准备工作其实不多但有几样东西必须先确认不然代码写得再对也读不到值。这一章的重点不是把 C# 语法讲透而是让你在最短时间内看到屏幕上打出一个真实的、和现场对得上的数字。那个瞬间对新手很重要它能把「玄学」变成「工程」。3.1 动手前的准备清单先列清楚你需要什么。软件侧一个能编译 C# 的编辑器.NET 6 或以上运行时还有一款 C# 的 BACnet 协议栈库。协议栈库的作用是把底层的报文封装成方法调用让你不用自己拼二进制。装的时候用包管理器搜一下关键字选一个还在维护、文档里有示例的就行具体用哪款不影响你理解后面的内容因为接口思路都差不多只是类名不同。硬件侧一台能 ping 通的 BACnet 设备或者厂家提供的模拟器。这点很关键很多人卡在「没有设备」其实不少控制器厂商会提供 PC 端的仿真工具能起一个虚拟设备让你练读写。如果没有找一个有设备的实验室或同事帮个忙也比纯看文档强。信息侧你需要四样东西——设备的 IP 地址、BACnet 端口默认 47808也就是十六进制的 0xBAC0、目标对象的类型、目标对象的实例号。前两个问网络管理员后两个从图纸或者设备浏览器里抄。提示如果你的电脑和设备不在同一个网段先解决网络问题再写代码。BACnet 走的是 UDP很多现场设备不响应跨网段广播设备发现会失败但单播读写可能还是通的这两种情况要分开判断。3.2 第一个例子的完整代码先看代码再逐行解释。下面这个例子的目标是连上一台设备读它设备对象的名称和某个模拟量输入的当前值然后打印出来。using System; using System.Net; using Bacnet.Client; // 协议栈客户端的命名空间以你实际引入的库为准 using Bacnet.Core; // 对象类型、属性枚举等基础类型通常在这个命名空间 class Program { static void Main() { // 1. 目标设备地址现场控制器的 IP BACnet 标准端口 var deviceIp IPAddress.Parse(192.168.1.50); var endpoint new IPEndPoint(deviceIp, 47808); // 2. 创建客户端。有些库还要在这里传本机端口、超时时间、重试次数 using var client new BacnetClient(endpoint); // 3. 读设备对象的名字。设备对象的实例号 设备实例号 var deviceName client.ReadProperty( ObjectType.Device, // 对象类型设备 1001, // 实例号这台设备的设备实例号 PropertyIdentifier.ObjectName); Console.WriteLine($设备名称{deviceName}); // 4. 读一个模拟量输入的当前值 var presentValue client.ReadProperty( ObjectType.AnalogInput, // 对象类型模拟量输入 3, // 实例号图纸上抄来的 3 PropertyIdentifier.PresentValue); Console.WriteLine($AI-3 当前值{presentValue}); } }这段代码里真正干活的只有两个 ReadProperty 调用其余都是准备工作。第一个调用读的是设备名称它的作用是验证「连接是通的、设备实例号是对的」因为设备对象一定存在读它最能排除地址错误。第二个调用才是你真正关心的业务数据读的是某个模拟量输入的当前值。分两步走是有意为之一旦第二步失败你可以立刻判断是对象定位错了而不是网络问题。参数逐个说一下。ObjectType 是枚举常见值有 Device、AnalogInput、AnalogOutput、BinaryInput、BinaryOutput、MultiStateInput写错类型会读不到东西但通常不报错。实例号是整数不要写成字符串。PropertyIdentifier 同样是枚举PresentValue 是最常用的一个ObjectName、Units、StatusFlags 也经常用。返回值的类型取决于属性模拟量一般能转成浮点数对象名称是字符串读回来之后要按需转换别一股脑当字符串拼。3.3 跑不通时先看这三处新手第一次跑十有八九不会一次成功。按下面顺序排查能省很多时间。第一处看网络能不能 ping 通设备防火墙有没有拦 UDP 出站本机是不是有多张网卡导致走错了出口。第二处看身份设备实例号填的是不是设备的真实实例号很多控制器面板上写着 Device ID和对象实例号长得像但含义不同。第三处看超时默认超时可能只有一两秒现场设备响应慢的时候会直接抛异常把超时调到 3 到 5 秒往往就好了。如果程序没抛异常但打印出来是空值多半是属性的数据类型处理有问题比如读回来的值是封装对象直接打印就成了类型名。这时候把它拆开看看里面到底是数字还是字符串。这种问题不难但第一次遇到容易怀疑人生。3.4 跑通之后马上做的一件事读 Object_List第一个值读出来之后别急着往下写功能。花十分钟把设备的 Object_List 读出来这一步的价值在于让你知道这台设备里到底有什么。Object_List 是一个数组属性读法有两种一次性读全部或者按下标一个一个读。新手建议先读全部看看返回了多少个对象再抽几个挨个读 Present_Value和现场实际对一对。这个过程有点像拿到一台新手机先翻一遍设置菜单。翻完之后你对设备的能力边界就有了数它有几个模拟量输入、几个输出、有没有报警点。后面写监控界面时你才知道该画多少个格子。顺便说一句Object_List 里的每个元素是「对象类型 实例号」的组合读的时候要按这个结构解析不能当成单纯的整数列表。4. 读写进阶改一个设定值再让设备自己上报能读之后下一步就是写和订阅。这两个功能是楼宇自控上位机最核心的两块写用来下发控制指令订阅用来做实时刷新。这里有一个坎很多人读写都会了却卡在「为什么我写了值但设备没反应」原因就在优先级数组上。4.1 一次读多个点批量读省时间先补一个实用技巧。监控画面上有几十个点如果一个一个读一轮下来可能要好几秒画面刷新会明显卡顿。协议里提供了批量读的服务一次请求可以带回多个对象的多个属性。C# 里的写法通常是传一个「读请求列表」返回一个结果集合。// 构造批量读请求三个点每个点读当前值 var requests new[] { new ReadRequest(ObjectType.AnalogInput, 1, PropertyIdentifier.PresentValue), new ReadRequest(ObjectType.AnalogInput, 2, PropertyIdentifier.PresentValue), new ReadRequest(ObjectType.AnalogOutput, 5, PropertyIdentifier.PresentValue), }; // 一次网络往返拿回全部结果 var results client.ReadPropertyMultiple(requests); foreach (var r in results) { Console.WriteLine(${r.ObjectType}-{r.Instance} {r.Value}); }批量读的参数结构和单点读一样只是外面套了一层数组。要注意的是批量读并不是越大越好。有些现场控制器处理能力有限一次塞几十个请求进去它会直接不响应或者超时。我的经验是每批控制在 10 到 20 个点按楼层或者按设备分组出错时也好定位是哪一组的问题。这个数字不是标准是现场试出来的不同厂家差别很大。4.2 写值为什么有个优先级数组现在说写。很多人第一次写值的方式是「直接把当前值改成我想要的数」然后在界面上看到数字变了就以为成功了。结果过一会儿再读值又回到原来的样子或者根本没变。这不是玄学是优先级在起作用。BACnet 的输出对象和部分输入对象都有一个 16 格的优先级数组每一格代表一个优先级1 最高16 最低中间有一个格子是「 relinquish释放」。当多个来源同时对同一个点下指令时谁的数字有效取决于谁占的优先级更高。比如消防联动写优先级 1楼控程序写优先级 8手动操作面板写优先级 16那么只要消防那一格有值其他格子的写入都不会体现在当前值上。所以正确的写法是「占一个格子」而不是「改一个数」。你写完值之后读回来的当前值会反映最高的那个非空格子。想撤销自己的指令就把自己那一格写成释放null当前值会自动回落到下一个有值的格子。这个机制在工程上非常有价值它保证了紧急指令永远压得住日常调节但对新手来说就是个隐形陷阱你写的值没生效不一定是代码错了很可能是别人占了更高的格子。优先级常见用途说明1消防联动、紧急停机最高一般不轻易占用8楼控程序、日常调节上位机下发设定值常用这一级16手动操作、现场面板最低用于临时调试null释放把自己那一格清空让值回落上面这张表是行业里的常见习惯不是协议强制规定具体项目怎么分配要问清楚。我建议新手统一用 8 或者 16 起步别去碰 1 到 5那几个格子通常在调试阶段就被占死了你抢不到也不该抢。4.3 第二个例子写一个设定值再读回来验证看代码。这个例子的目标是给一个模拟量输出写 21.5占优先级 8然后立刻读一次当前值确认生效。// 1. 写入占住优先级 8写入 21.5 client.WriteProperty( ObjectType.AnalogOutput, // 对象类型 5, // 实例号 PropertyIdentifier.PresentValue, // 要写的属性 21.5f, // 写入的值注意类型要和属性匹配 priority: 8); // 优先级不传则用默认 // 2. 立刻读回当前值验证 var current client.ReadProperty( ObjectType.AnalogOutput, 5, PropertyIdentifier.PresentValue); Console.WriteLine($写后当前值{current}); // 3. 用完释放自己那一格让值回落到其他优先级 client.WriteProperty( ObjectType.AnalogOutput, 5, PropertyIdentifier.PresentValue, null, // null 表示释放这一级 priority: 8);三步各有各的意义。第一步是真正的写priority 参数决定了你占哪一格。第二步是验证注意读回来的可能不是你写的数如果别人占了更高的优先级这里就会显示别人的值这恰恰是排查问题的线索。第三步是释放很多新手写完就不管了结果自己的优先级一直占着别人再怎么调都没用现场调试的人会被你气死。写值还有几个要注意的地方。值的数据类型要和对象匹配模拟量写浮点数二进制写枚举比如 0 表示停止、1 表示运行多态量写整数。写超出量程的值有些设备会拒绝并返回错误码有些会直接截断行为不统一所以边界检查要自己在代码里做。另外写操作涉及权限有些点在生产环境是只读的写会返回错误这个错误要捕获并提示不能让它把程序搞崩。4.4 订阅属性变化不用一直问的 COV前面所有的读本质都是「你问一次设备答一次」。监控画面要实时刷新就得不停地问这叫轮询。轮询的问题很明显几十个点每秒问一遍网络上是持续的请求设备 CPU 也被你占着而且大部分时候值根本没变问了个寂寞。订阅变化Change of Value简称 COV换了个思路你告诉设备「这个点我关心变了记得喊我」然后就不管了。设备内部自己监测值变化超过设定的阈值就主动发一条通知过来。你的程序平时什么都不用做收到通知再更新界面。网络负载能降一个数量级实时性反而更好。这个机制特别适合什么样的点温度、湿度、压力这种缓慢变化的模拟量用订阅非常划算。开关状态、报警状态这种偶尔跳变的点也很合适。反过来如果你要记录历史曲线、每秒一个采样点那还是老老实实轮询或者用设备的趋势记录功能订阅不适合做高频采样。4.5 第三个例子订阅一个点并处理回调代码长这样。目标是订阅一个模拟量输入值变化时打印出来订阅有效期设为 300 秒。// 订阅告诉设备这个点变了要通知我 client.SubscribeCov( ObjectType.AnalogInput, // 对象类型 3, // 实例号 callback: (objectId, values) { // 回调里拿到的是变化后的值不同库的参数结构不同 Console.WriteLine($对象 {objectId} 变化 - {values[0]}); }, subscribeTime: 300, // 订阅有效期单位秒 issueConfirmedNotifications: true); // 用确认通知防止丢包回调函数是整个订阅机制的核心。它不是你去调用的是设备发通知过来时协议栈帮你触发的所以回调里面不要做耗时操作更不要在里面直接操作界面控件否则很容易卡住或者抛跨线程异常。常见的做法是回调里只把值塞进一个队列或变量由主线程定时取出来刷新界面。subscribeTime 这个参数要重点说。它表示这次订阅的有效期单位是秒到期后设备就不再通知你了。为什么要有有效期因为设备不知道你的程序什么时候会崩如果订阅永久有效每个连过的客户端都会在设备里留下一条记录时间长了设备的内存就被占满了。有效期是一种自我保护机制。所以你的程序必须负责续订在到期之前重新发一次订阅请求。issueConfirmedNotifications 决定通知要不要对方确认。开了确认设备发通知后会等你回一个应答没收到就重发可靠性高但开销大不开确认发一次就完事丢了就丢了。监控类应用建议开确认尤其是报警点。4.6 订阅不是一劳永逸lifetime 和续订接着上一段说续订。一个稳定的实现通常是这样的程序启动时把所有关心的点批量订阅一遍记下每个订阅的到期时间起一个定时器在到期前三分之一的时间点集中续订一次。续订的方式就是再调一次 SubscribeCov设备会刷新这条记录的有效期。还有个细节容易被忽略程序退出时要主动取消订阅。不取消也能活反正会过期但在调试阶段反复启动停止设备里会堆一堆僵尸订阅占着资源。取消订阅的接口一般是 UnsubscribeCov参数和订阅时一致。养成「订阅和取消成对出现」的习惯现场会少很多莫名其妙的故障。最后提一句阈值。有些设备的订阅支持设一个变化阈值COV Increment只有变化幅度超过这个值才通知。比如温度设 0.5那么从 23.1 变到 23.3 不会通知变到 23.7 才通知。这个参数设得太小通知会非常频繁跟轮询差不多了设得太大界面上数字会跳变。一般按工艺要求来温度 0.2 到 0.5压力按量程的百分之一左右具体现场调。5. 新手避坑五个最容易翻车的地方这一章是我带新人时反复听到的抱怨按出现频率排序。每条都按「现象、原因、解决」写你可以当成一份急救手册出问题的时候直接对照。5.1 能 ping 通却读不到任何值现象程序不报错或者报超时但电脑 ping 设备是通的。原因BACnet 走的是 UDP 47808ping 走的是 ICMP两者通路不一样。常见情况是防火墙只放行了 ICMP没放行 UDP或者设备只监听了特定网卡你的包从另一张网卡出去了还有一种情况是设备配置了 BBMD广播管理设备跨网段时需要专门配置才能通信。解决先确认防火墙对 UDP 47808 的出入站都放行再确认本机路由走的是哪张网卡多网卡机器尤其容易出错最后确认是否跨网段以及有没有 BBMD。这三步按顺序查不要跳。5.2 实例号抄错或者类型和实例号搞反现象读某个点一直读不到换成别的点又能读到。原因把对象类型当成了实例号或者从图纸上抄错了数字。还有一种情况是不同楼层的图纸用的是同一套编号实际设备里加了偏移比如 2 层的 3 号点实际是 103。解决先读设备的 Object_List把真实存在的对象列出来和图纸对一遍。这一步能解决九成的定位错误。对不上就以设备里的为准图纸只是参考。5.3 写了值但读回来还是原来的数现象WriteProperty 没报错但紧接着读当前值数字没变。原因优先级被人占了。你写的是 16 级而楼控程序在 8 级上一直有输出当前值就永远显示 8 级的数。还有一种可能是你写的属性和设备实际使用的属性不是同一个比如你写 Present_Value但设备逻辑读的是 Setpoint_Reference。解决先读 Priority_Array看看 16 个格子里哪些有值、值是多少一眼就能看出谁在压着你。再确认这个点到底该写哪个属性有些点的设定值和当前值是分开的两个属性写错了当然没反应。5.4 订阅了几分钟就收不到通知了现象刚订阅上的时候能收到变化过一段时间彻底没动静重新订阅又好了。原因订阅有有效期到期自动失效。你的程序没有续订逻辑或者续订定时器被异常打断了。解决给每个订阅记录到期时间起一个定时器在到期前续订。同时加上日志把订阅成功、续订、失效都打出来出问题时一眼能看出是没续上还是设备那边掉了。5.5 点一多就大面积超时现象单点测试都正常接上几十个点之后程序开始随机超时设备响应也变慢。原因请求发得太密或者批量读一次塞了太多对象超出了设备的处理能力。有些低端控制器每秒只能处理个位数的请求你一下子发几十个它只能丢包。解决控制并发串行发送或者限制同时未完成的请求数批量读按 10 到 20 个一组分批给每个请求设合理的超时和重试次数重试不要无限循环。还有一个容易忽略的点轮询间隔不要太短能订阅的点尽量用订阅别用轮询硬刷。6. 下一步学什么从能读能写到能做界面走到这里你已经能连设备、读属性、写值、订阅变化了。这四件事组合起来其实已经够做一个最小可用的监控小工具。接下来往哪走取决于你想做哪一层的工作。下面这条路线是我带新人时常用的顺序按它走不会绕远。阶段学什么练什么大概花多久第一步对象模型熟练化用 Object_List 摸清一台设备的所有点能不看文档说出常见对象类型一到两周第二步批量读与异常处理把单点读改成批量读加上超时、重试、错误码分类处理一周第三步写值与优先级实现设定值下发和释放能读懂 Priority_Array一到两周第四步订阅与连接管理实现订阅续订、断线重连、订阅恢复两到三周第五步数据结构与界面解耦把设备数据缓存成对象模型界面只读缓存两到三周第一步的重点不是写代码是把对象模型刻进脑子里。拿到任何一台设备先读 Object_List再按类型分组看看它有哪些输入输出。这个动作做熟了后面看任何厂家的设备都不会发怵。第二步是分水岭。很多人写的代码只能在实验室跑一到现场就崩问题几乎都出在异常处理上。现场设备会掉线、会重启、会返回各种错误码你的代码必须扛得住。建议的做法是给每个请求包一层统一的方法把超时、重试、日志都收在里面业务代码只关心结果。第三步和第四步是工程能力的体现。优先级管理做不好现场调试的人会天天找你订阅续订做不好界面上的数会莫名其妙停住不动。这两块没有捷径多写几遍就有感觉了。第五步往上就是架构层面的事了。别把设备读数直接绑到界面控件上中间一定要有一层缓存模型界面定时从缓存取数。这样做的好处是设备掉线时界面显示的是最后一次有效值加一个离线标记而不是一片空白或者直接崩掉。这个习惯越早养成越好。如果你还想再深入可以去看协议里的服务端部分也就是自己做一个设备或者网关让别的系统来读你。这一步会逼着你把所有细节都想清楚是检验理解程度的好办法。另外对象类型里的日程Schedule、趋势记录Trend Log、通知类Notification Class这几个高级对象实际项目里用得不少可以挑一个感兴趣的单独研究。我第一次做这类项目的时候图省事把所有读取都写成了单点轮询界面上一百多个点每秒刷一遍。实验室里跑得好好的到了现场第二天就开始大面积超时设备直接被拖慢现场调试的人打电话过来问是不是我的程序在捣乱。从那以后我每次接手新设备都会强制走一遍「先读 Object_List 摸清家底、再按分组批量读、能订阅的绝不轮询」这个流程再也没翻过同样的车。你刚开始学的时候可能会觉得这些规矩麻烦等真的在现场跑起来就知道省下的是自己的时间。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?