ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C#上位机Basler相机采图实战:Pylon SDK从环境配置到Bitmap显示

C#上位机Basler相机采图实战:Pylon SDK从环境配置到Bitmap显示 简介面向C#开发者与机器视觉初学者压缩包演示了在Visual Studio环境中调用Basler Pylon SDK完成工业相机图像采集的完整流程。Pylon开发工具包支持千兆网与USB3.0视觉设备通过导入Pylon命名空间即可访问相机控制、图像抓取等接口同时自带了实时预览与调试工具。包内共76个文件包含8个C#源码文件、6个DLL运行库、9个exe可执行程序以及配置、资源、工程等辅助文件源码围绕相机枚举、创建实例、参数设置、抓图循环与资源释放等核心步骤展开每个模块均有对应接口说明配合软触发操作步骤讲解图片和ReadMe说明文档可帮助读者深入理解SDK调用逻辑及软触发模式下的采集流程。压缩包整体约2.16MB目录结构清晰便于直接打开工程对照研究。已有1411人学习/下载适合自动化产线、工业检测、视觉定位等场景中需要快速上手Basler相机二次开发的开发者也可作为C#结合机器视觉的入门参考。1. 这包要是能跑等于省掉了 C# 上位机采图最磨人的一层胶水很多第一次拿到 Basler 相机的 C# 上位机工程师第一步不是写代码而是卡在“相机灯亮了、但程序里什么都拿不到”。标题里这个基于 baslerSDK 的 zip说白了就是帮你把“相机枚举、打开、采图、转 Bitmap、显示”这一串动作封装成能跑的骨架。它要解决的是工业视觉里最基础也最容易被低估的问题怎么在 C# 里稳定地把相机的一帧图像变成内存里一块你能当普通数组处理的像素数据。这套东西适合谁适合正在做上位机、视觉检测或设备集成的开发不想从零啃 Pylon 原生文档也不想用 DirectShow 包一层不稳定方案的工程师。新手能照着把第一张图画出来熟手可以拿它当模板改触发、改多相机、接第三方算法库。后面讲的每个参数和坑都是实际搭产线采流才会碰到的血泪经验不是从手册里抄概念。2. 装对 SDK 和运行时x86/x64 混着来第一步就翻车2.1 先用 Pylon 而不是 DirectShowBasler 相机接口复杂SDK 必须对齐标题里写的是 baslerSDK实际指的就是 Basler 官方的 Pylon SDK。为什么不用 Windows 自带的 DirectShow/UVC 那套Basler 相机有 GigE、USB3.0、Camera Link 好几种接口GigE 相机走的是自己的传输层协议UVC 模式下很多相机虽然能枚举出来但采集帧率、像素格式、触发信号全都拿不全。尤其是多相机同时触发抓拍用 DirectShow 回调去分摄像头光区分设备就够你喝一壶。Pylon 的价值是给你一套统一 API不管相机是网口还是 USB 口代码层面的写法差别很小。安装时不要图省事只装 Runtime也不要只装 Visual Studio 插件。完整安装 Pylon Camera Suite因为里面有我们要的那几个原生 DLL、相机驱动和 IP 配置工具。装完以后C# 工程里实际需要引用的程序集主要是Basler.Pylon.dll而它底层依赖PylonC.Net.dll和一堆用 C 写的原生库。也就是说你在 C# 里看到的InstantCamera和IGrabResult只是托管封装真正干活的是原生代码。这个背景很重要后面你遇到的 Access violation 基本都从这里来。2.2 在 C# 工程里引用 Basler.Pylon平台目标必须锁死 x64创建项目的时候建议直接用 .NET Framework 4.7.2 或 .NET 6/8 的 WinForms 或 WPF 工程。不要选 AnyCPU也不要默认 32 位。Pylon 从 5.x 开始主推 64 位很多使用者的项目还在跑 x86结果一点运行就报BadImageFormatException。这不是 Pylon 的问题是托管 DLL 和原生 DLL 位数不一致。我一般会先在项目属性里把“平台目标”改成 x64再看一下 Pylon 安装目录里的 DLL 是否也是 64 位。引用方式有两种。第一种是直接添加引用在 Pylon 安装目录下的Development\DotNet文件夹里找Basler.Pylon.dll。第二种是如果你项目走 NuGet搜Basler.Pylon官方包也能引到但底层相机运行时仍然要装完整套件。换句话说NuGet 只是省了引用路径设备驱动和传输层不能省。补充一下输出目录里一定要有Basler.Pylon.dll和对应的原生依赖。VS 调试机子上有 Pylon 没问题部署到工控机时如果只拷单个 DLL 过去程序可能能启动但一枚举相机就报找不到动态库。最省事的方式是发布完以后把 Pylon 安装目录里几个关键文件一起拷走或者直接在工控机上装一遍 Runtime。2.3 验证环境写个最小代码把相机列出来环境配好没配好不要急着采图先写一个枚举相机的代码。这个步骤能帮你区分“SDK 坏了”和“相机没连上”。using Basler.Pylon; using System; using System.Collections.Generic; class Program { static void Main() { ListICameraInfo cameras CameraFinder.Enumerate(); if (cameras.Count 0) { Console.WriteLine(没有找到 Basler 相机先查网线和驱动。); return; } for (int i 0; i cameras.Count; i) { ICameraInfo info cameras[i]; Console.WriteLine($相机[{i}] 型号{info[PLCamera.DeviceModelName]} $序列号{info[PLCamera.DeviceSerialNumber]} 地址{info[PLCamera.DeviceIpAddress]}); } } }这段代码的逻辑很简单CameraFinder.Enumerate()返回的是当前机器能看到的全部 Basler 设备ICameraInfo是这个设备的“名片”。注意我用的是info[PLCamera.DeviceSerialNumber]这种方式取序列号而不是直接显示名字。原因是多相机项目里序列号才是唯一标识索引编号开机顺序一变就乱。PLCamera是 Pylon 提供的参数键常量类所有相机参数都能通过Parameters集合访问后面你设置像素格式和曝光时间时也是这一套。在这个阶段如果你发现枚举不到相机先别怀疑代码。检查相机电源灯、网线/线缆、IP 是否同网段。GigE 相机最典型的误配是相机 IP 是 192.168.1.x电脑网卡却在 192.168.0.x这种情况下 SDK 再正经也扫不到。用 Pylon 自带的 IP Configurator 把相机设成固定 IP比在代码里改可靠得多。3. 单帧采集从相机打开到像素字节流落到 byte[]核心就在这一步3.1 用 InstantCamera 打开相机先把触发和像素格式固定下来枚举设备之后真正干活的是InstantCamera这个类。它的名字已经告诉你它是“开箱即用”的相机对象内部把很多细节藏起来了。用InstantCamera(ICameraInfo)构造然后Open()。打开以后第一个要设置的参数是PixelFormat。Basler 相机默认可能是BayerRG8或Mono8还有的相机出厂是YCbCr。如果你不做任何设置直接GrabOne也能拿到数据但数据排列顺序你得自己猜后续转 Bitmap 特别容易花屏。我通常的做法是先关闭自动曝光自动增益把像素格式固定成自己需要的值。代码里不要不写 CameraOpened 事件直接在 Open 之后设参数也可以但更稳妥的是在CameraOpened事件里设置因为Open()成功后参数集合才完全可用。using Basler.Pylon; public class SingleFrameGrabber { private InstantCamera _camera; public void Open(ICameraInfo cameraInfo) { _camera new InstantCamera(cameraInfo); _camera.CameraOpened OnCameraOpened; _camera.Open(); } private void OnCameraOpened(object sender, EventArgs e) { // 固定像素格式为 8 位灰度Bayer 彩色输出可以后面转 _camera.Parameters[PLCamera.PixelFormat].SetValue(PLCamera.PixelFormat.Mono8); _camera.Parameters[PLCamera.AcquisitionMode].SetValue(PLCamera.AcquisitionMode.SingleFrame); } }这里有两个参数要解释清楚。AcquisitionMode.SingleFrame表示一次只采一帧适合做单次拍照初始化。PixelFormat.Mono8是每个像素 1 字节灰度处理起来最省心。如果你的项目要彩色相机建议先确认相机原生输出的PixelFormat是BayerRG8还是BayerGB8这个信息在ICameraInfo里也能看到。不要一上来就设BGR8然后抱怨颜色不对Bayer 格式必须经过转换才能得到 RGB不是简单交换通道。3.2 GrabOne 采一帧内存里的数据到底长什么样GrabOne是 InstantCamera 提供的同步方法参数是超时时间和超时处理方式。对上位机来说同步采图的好处是逻辑简单适合验证相机通路不适合连续长时间采集。public byte[] GrabSingleFrame(out uint width, out uint height) { using (IGrabResult grabResult _camera.GrabOne(3000, TimeoutHandling.ThrowException)) { if (!grabResult.GrabSucceeded) { throw new Exception(抓帧失败错误码 grabResult.ErrorCode); } width grabResult.Width; height grabResult.Height; byte[] raw grabResult.GetPixelDatabyte(); return raw; } }这里重点说两个细节。第一GrabOne的第一个参数 3000 是毫秒超时。工业相机在外部触发模式下可能等不到触发信号超时就抛出异常。你如果只做连续采集这个值设大一点没问题但设太大如果硬件掉了程序会卡死。第二GetPixelDatabyte()返回的是相机原始像素字节数组。如果是 Mono8这个数组长度就是 width * height如果是 BayerRG8也是 width * height因为 Bayer 也是 8 位。但注意它还没有被转换成 RGB直接拿给 Bitmap 会得到诡异的花纹。3.3 把像素字节流变成 BitmapStride 和 BGR 顺序是常见翻车点拿到原始 byte[] 以后转换工具是PixelDataConverter。这是 Pylon 提供的官方转换器比你自己写循环交换通道快得多。它会根据 grabResult 里的输入格式自动做 Bayer 去马赛克、YUV 到 RGB 的转换你要做的只是指定输出格式。using System.Drawing; using System.Drawing.Imaging; using System.Runtime.InteropServices; public Bitmap ConvertRawToBitmap(IGrabResult grabResult) { int width (int)grabResult.Width; int height (int)grabResult.Height; // Pylon 转换器输出按 BGR8 排列正好匹配 GDI 的 Format24bppRgb PixelDataConverter converter new PixelDataConverter(); converter.OutputPixelFormat PixelType.PixelType_BGR8packed; byte[] bgrBuffer new byte[width * height * 3]; converter.Convert(bgrBuffer, grabResult); Bitmap bitmap new Bitmap(width, height, PixelFormat.Format24bppRgb); BitmapData bmpData bitmap.LockBits(new Rectangle(0, 0, width, height), ImageLockMode.WriteOnly, PixelFormat.Format24bppRgb); // 必须按 Stride 逐行拷贝Windows 位图的行宽按 4 字节对齐 for (int row 0; row height; row) { IntPtr dstRow bmpData.Scan0 row * bmpData.Stride; Marshal.Copy(bgrBuffer, row * width * 3, dstRow, width * 3); } bitmap.UnlockBits(bmpData); return bitmap; }这段代码值得反复看的是for循环。很多新手拿到bgrBuffer之后直接Marshal.Copy(bgrBuffer, 0, bmpData.Scan0, bgrBuffer.Length)如果图片宽度是偶数且正好 4 字节对齐运气好没事一旦 width 乘以 3 不是 4 的倍数图像就会整体斜向偏移。BitmapData.Stride是 Bitmap 内部实际一行占的字节数它可能比width*3大所以必须逐行拷。这是 GDI 的老规矩不是 Pylon 的问题。另外注意PixelType.PixelType_BGR8packed。GDI 的Format24bppRgb在内存里其实是 BGR 顺序所以 Pylon 输出 BGR8packed 直接对应 Bitmap 的像素格式。你要是自己去把 B 和 R 互换反而会得到一张红蓝颠倒的图。这一点容易得很后面避坑章还得提。4. 连续采集和显示回调、队列和 UI 刷新别把相机线程卡死4.1 用 ImageGrabbed 事件还是 GrabLoop实时采图推荐事件回调单帧验证通过后你要做的是连续实时显示。最常见的错误思路是开一个线程循环GrabOne每循环一次就转 Bitmap 显示一次。这在低分辨率、低帧率下能跑但帧率上去后主线程和采集线程会互相拖累丢帧、内存增长、相机超时全都会来。Basler SDK 推荐的连续采图方式是在StreamGrabber.ImageGrabbed事件里取结果。这个事件本质是一个委托由 Pylon 内部取流线程触发和你的 UI 线程天然分离。你在回调里做的工作越少整体吞吐越好。回调里不应该做LockBits、不应该 new Bitmap、更不能直接操作 PictureBox。只做一件事拿到字节数组塞进一个线程安全的队列然后立刻释放IGrabResult。IGrabResult这个对象的生命周期很关键。在回调里如果不释放底层内存会被占住采集一段时间后你会发现内存涨得飞快。反过来如果你在回调里调用Dispose后续又想访问 grabResult 的尺寸信息就会报 Access violation。正确姿势是在回调里把所有需要的数据复制成托管数组然后 Dispose就像下面这样。using System.Collections.Concurrent; public class ContinuousGrabEngine : IDisposable { private readonly ConcurrentQueuebyte[] _queue new ConcurrentQueuebyte[](); private readonly object _sync new object(); private InstantCamera _camera; private int _width; private int _height; public event Actionbyte[], int, int FrameReady; public void Start(ICameraInfo info) { _camera new InstantCamera(info); _camera.CameraOpened OnCameraOpened; _camera.StreamGrabber.ImageGrabbed OnImageGrabbed; _camera.Open(); _camera.Parameters[PLCamera.AcquisitionMode].SetValue(PLCamera.AcquisitionMode.Continuous); _camera.StreamGrabber.StartGrabbing(); } private void OnCameraOpened(object sender, EventArgs e) { _width (int)_camera.Parameters[PLCamera.Width].GetValue(); _height (int)_camera.Parameters[PLCamera.Height].GetValue(); } private void OnImageGrabbed(object sender, ImageGrabbedEventArgs e) { using (IGrabResult result e.GrabResult) { if (!result.GrabSucceeded) return; byte[] raw result.GetPixelDatabyte(); // 只留最近 3 帧UI 处理不过来就丢帧 lock (_sync) { while (_queue.Count 3 _queue.TryDequeue(out _)) { } _queue.Enqueue(raw); } FrameReady?.Invoke(raw, (int)result.Width, (int)result.Height); } } public bool TryGetLatestFrame(out byte[] frame) { while (_queue.TryDequeue(out byte[] f)) { frame f; return true; } frame null; return false; } public void Dispose() { _camera?.StopGrabbing(); _camera?.Close(); _camera?.Dispose(); } }这段代码里有三个值得注意的设计。第一ImageGrabbed回调中用了using包裹IGrabResult确保释放。第二ConcurrentQueue是线程安全的队列但在判断Count和Enqueue之间仍然有竞争所以我加了lock。不要把ConcurrentQueue当成万能锁高并发下Count判断和Enqueue不是原子的。第三我给外部暴露了一个FrameReady事件同时又提供了TryGetLatestFrame这样 UI 既可以用事件被动刷新也可以自己定时轮询两种模式都能接。4.2 队列缓冲 定时器刷新 UI上位机界面不被采图卡住的写法拿到FrameReady事件后UI 侧最简单的做法是用 WinForms 的Timer每 33 毫秒取一次最新帧。这样不管相机跑到 60 帧还是 100 帧界面始终按 30 帧刷新CPU 占用低界面不撕裂。private Timer _uiTimer; private ContinuousGrabEngine _engine; private void StartUiRefresh() { _uiTimer new Timer(); _uiTimer.Interval 33; // 毫秒约 30 帧 _uiTimer.Tick OnUiTimerTick; _uiTimer.Start(); } private void OnUiTimerTick(object sender, EventArgs e) { if (_engine.TryGetLatestFrame(out byte[] raw)) { // raw 是 Mono8需要先转 BGRA 再显示这里省略转换直接建灰度图 Bitmap gray new Bitmap(width, height, PixelFormat.Format8bppIndexed); // 上一帧释放掉防止 GDI 句柄泄漏 Bitmap old pictureBox.Image as Bitmap; pictureBox.Image gray; old?.Dispose(); } }这里我不再写完整转换重点是old?.Dispose()。PictureBox赋值新图时旧图如果不释放窗口长时间运行会报“内存不足”或“GDI 无法创建图形对象”。但要小心如果旧图正在被OnPaint使用立刻 Dispose 偶尔会报参数异常所以我在定时器里赋值前才释放旧对象避免边画边删。另一个常见做法是把 PictureBox 的Parent设为空再释放但多数监控界面不需要这么极端。4.3 多相机采集用序列号区分设备而不是靠枚举顺序生产线的视觉工位经常一个工位挂两台相机一台拍全局一台拍对位。在这种场景下如果两个InstantCamera都靠CameraFinder.Enumerate()的索引去生成一旦相机开机顺序变化A 相机和 B 相机就换了身份。所以在多相机项目里我会先枚举出所有设备然后根据序列号找到ICameraInfo再创建相机。public InstantCamera CreateCameraBySerial(string serialNumber) { ListICameraInfo cameras CameraFinder.Enumerate(); foreach (ICameraInfo info in cameras) { string currentSerial info[PLCamera.DeviceSerialNumber]; if (currentSerial serialNumber) { InstantCamera camera new InstantCamera(info); camera.Open(); return camera; } } throw new Exception($没有找到序列号为 {serialNumber} 的相机); }这段代码讲的是一个硬道理工业现场不要信顺序要信唯一标识。PLCamera.DeviceSerialNumber是出厂固化编号同一型号也不会重复。你把序列号写在配置文件里上位机启动时让用户选择对应工位比硬编码索引可靠得多。接口上两个相机如果都是 GigE取流是两条独立链路互不影响但它们的回调都在各自的底层线程里回调里同样不能做重量级操作。5. 避坑与排查这五个坑每个都能耗掉你半天5.1 打开或是取流时蹦出 Access violation c0000005现象C# 程序在相机打开、相机回调释放结果、或者关闭相机时直接弹Access violation c0000005有时 VS 会停在Basler.Pylon.dll内部。这个异常是最多新手最先遇到的也是最难定位的。原因根本原因是托管代码和 Pylon 原生代码的生命周期不一致。常见的触发方式有三种一是平台目标 x86/x64 不一致原生 DLL 加载失败后已损坏二是把IGrabResult丢给另一个线程用结果在回调里 Dispose 了外部还在访问三是相机对象InstantCamera被 GC 回收了但底层原生句柄还没释放回调又触发。解决先把项目平台目标统一成 x64。然后在回调里不要跨线程持有IGrabResult必须把GetPixelDatabyte()拿到的托管数组复制出来立刻释放结果。最后检查工程里是否只引用了 Pylon 的 C# 程序集但没有把原生 DLL 拷贝到输出目录最常见就是PylonC.Net.dll缺失。5.2 GigE 相机在摄像机列表里看不到或者连上了但一直超时现象CameraFinder.Enumerate()返回空列表可相机灯亮着、网线也插着另一种是枚举到了相机但Open()超时或者GrabOne每帧都超时。原因GigE 相机走的是以太网传输网卡 IP 不在同一网段肯定枚举不到。能枚举到但超时通常是大图包传输被网卡拆包重传拖死比如网卡不支持 Jumbo Frame或者巨型帧没开启还有个隐蔽原因是 Windows 网卡节能策略电脑空闲一段时间后网卡自动降速相机流就断了。解决先用 Pylon IP Configurator 把相机 IP 改成静态地址例如相机192.168.100.2电脑网卡也设成192.168.100.1不要用 DHCP。然后在网卡属性里把“巨型帧”设为 9000 字节关闭以太网节能。最后检查防火墙Pylon 的传输走私有协议Windows 防火墙经常把它拦在局域网外临时放行或者直接关掉防火墙测试。命令检查可以用ping 相机IP但相机默认对 ICMP 可能不响应这个不能作为唯一判断。5.3 图像颜色发绿发紫怎么调都觉得不对劲现象彩色的 Basler 相机采回来的图偏绿、发紫或者有马赛克纹路尤其是拍白色物体时特别明显。原因相机原生输出是 Bayer 格式比如BayerRG8。如果你没有经过PixelDataConverter直接显示系统会把它当灰度或 RGB 解析结果就是伪彩色。还有一个问题是 Pylon 的PixelDataConverter输出PixelType_BGR8packed而 Bitmap 用的Format24bppRgb这两者排序正好一致不需要再交换通道。如果你自己写了 R/B 交换反而会多此一举。解决把PLCamera.PixelFormat明确设成BayerRG8或BayerGB8不要设成“自动”因为很多相机的默认值随分辨率切换会变。然后在转换时用PixelDataConverter.OutputPixelFormat PixelType.PixelType_BGR8packed。如果图还是偏色检查相机白平衡设置Basler 的BalanceWhiteAuto参数在首次打开时会自动调一次但你要在CameraOpened事件后延迟一两秒再采集否则白平衡还没收敛。5.4 程序跑十分钟内存涨了五百兆然后卡死现象连续采集显示一切正常但内存监控里占用一直上涨运行一段时间后界面假死。原因回调里 new 了 Bitmap 但没有释放或者IGrabResult没有释放再或者队列里的 byte[] 只进不出。最隐蔽的是回调里把byte[]转成MemoryStream时复制了一份又没有释放旧版本。对PictureBox多次赋新图不释放旧图也会造成 GDI 句柄暴涨最后整个窗口都画不出来。解决采集回调中不要 new Bitmap只入队原始数组。UI 定时器里才新建 Bitmap并且用前面说的old?.Dispose()。对IGrabResult一律using。对队列做长度限制超过 3 帧主动丢帧因为显示端帧率跟不上时队列越长延迟越大内存也越涨。还有一个排查技巧用任务管理器看“GDI 对象”计数如果每秒涨几个那就是 Bitmap 没释放看“工作集内存”涨得快那就是 byte[] 或 grabResult 没释放。5.5 相机灯亮但提示设备被占用或初始化失败现象相机独立供电、灯也亮但 C# 程序一打开就报“temp.initialize failed”或“device busy”。如果你同时开着 Pylon Viewer先把它关掉排除了再来查代码。原因Basler 相机同一时刻只允许一个应用打开设备Pylon Viewer 连接时会把设备占住。另一个原因是 USB3 相机的线缆过长或供电不足导致设备处于半连接状态枚举能看到但真正初始化传输通道就失败。解决关掉 Pylon Viewer重新拔插相机等待系统重新枚举。如果是 GigE 相机用 IP 工具调整网卡 IP 后再重置相机网络参数。在代码里打开相机之前先调用一次CameraFinder.Enumerate()刷新设备比直接new InstantCamera更可靠。6. 再进一步把 Basler 图像接到视觉处理流程别当纯显示器如果你的目标只是把画面显示在界面上上面几章已经够了。但大部分上位机项目拿了图是要送算法处理的要么自研要么接 VisionMaster 这类视觉软件。这时候你手里的byte[]就变成了最关键的交接物。一次典型的接力是从 Basler 拿到Mono8灰度数组转成 OpenCV 的Mat然后做二值化、找轮廓。using OpenCvSharp; public Mat ConvertGrayToMat(byte[] gray, int width, int height) { Mat mat new Mat(height, width, MatType.CV_8UC1, gray); return mat; } public void ProcessMat(Mat source) { using Mat binary new Mat(); Cv2.Threshold(source, binary, 128, 255, ThresholdTypes.Binary); Cv2.FindContours(binary, out Point[][] contours, out HierarchyIndex[] hierarchy, RetrievalModes.External, ContourApproximationModes.ApproxSimple); }用 Pylon 的PixelDataConverter输出Mono8时byte[] 天然就是CV_8UC1连格式转换都省了。这也是我推荐单目检测项目直接设Mono8的原因灰度图像素小、传输快、处理快彩色信息大多数视觉算法用不上。等你确认必须要彩色图再改成BGR8packed对应 OpenCV 的CV_8UC3。再往前一步就是触发模式。连续采集适合调试生产线上必须用硬件触发让视觉控制器在指定时刻抓拍。在 Basler Pylon 里就是三个参数TriggerSelectorFrameStart、TriggerModeOn、TriggerSourceLine1然后外部接线给Line1一个光耦信号。这个模式下GrabOne会一直等到触发信号到来所以你给它的超时时间要足够长不然相机还没触发程序就先超时报错了。我自己的习惯是每次接新项目后都会先跑一个 8 小时连续采集同时记录丢帧数量和内存走势。刚开始做上位机时我就是偷懒没在回调里释放IGrabResult结果产线跑到下午内存撑爆整个工控机死机最后返工排查了一整天。从那以后凡是涉及 Basler 采图我都会在交付前做一次长时间压力验证宁可多花几小时也不在客户现场翻车。这套基于 baslerSDK 的采集流程你把回调、队列、平台位数和像素格式这四件事处理干净剩下的其实都是业务流程的事。希望帮到你。本文还有配套的精品资源点击获取
返回列表