简介:工业控制界面范例是一份面向工业自动化开发者与HMI设计人员的完整示例工程,重点演示双缓冲绘图技术与实时数据采集模型,用于解决界面刷新闪烁、设备状态显示滞后等典型问题。资源包共72个文件,以C++源代码为主(21个cpp、22个h),并含位图素材(jpg/psd/ico)、工程配置文件(dsp/dsw/rc)与可执行程序,压缩后整体仅1.14MB,结构紧凑便于阅读。目前已有2718人学习,适合中高级开发者通过源码研究双缓冲绘制流程、数据采集与处理机制、以及HMI人机交互逻辑。范例内含窗口类、控件类、设备状态图及报警提示等模块,完整呈现从界面布局、按钮事件响应到后台数据采集的工程架构,并体现了直观操作与安全停机等设计考虑,可直接改造用于化工、电力、交通等工业监控场景,满足多种行业需求。
1. 工业控制界面范例:先从双缓冲说起,再谈控件怎么画
工业控制界面这个方向,表面看拼的是控件样式,实际拼的是绘制性能。某次在车间触摸屏上调上位机界面,数值刷新一快,圆盘仪表就开始闪,趋势曲线像断了电的示波器,操作人员还以为是设备出了问题,其实成因只是前端没做缓冲。这份工业控制界面范例,选的就是这个最常见的痛点:自绘控件加双缓冲。工程不大,但把仪表盘、实时趋势图、状态灯、控制按钮全部走了一遍双缓冲绘制流程,配合模拟数据源,开箱就能看到一条流畅的刷新链路。适合刚接手工业上位机、想搞清楚离屏渲染怎么落地的开发者,也适合想快速搭一套监控界面原型的人。
2. 技术选型与整体架构:一个双缓冲绘制框架该有的分层方式
2.1 为什么选 PySide6:事件循环、绘图体系与线程模型的取舍
拿到“工业控制界面”这类需求,技术选型通常绕不开 C++ Qt、C# WinForms、Web 组态三种路线。这份范例选了 PySide6,也就是 Qt for Python,理由很实际:第一,QPainter 的绘图模型和 C++ 版 Qt 完全一致,范例里学到的双缓冲写法迁到 C++ 工程没有认知成本;第二,Python 做数据采集、Modbus、OPC UA 接入时生态成熟,写胶水代码比 C++ 省事;第三,触摸屏工控机上跑 Python 已经有大量真实案例,性能瓶颈多数不在语言,而在渲染策略。
把界面控件做厚、把业务逻辑做薄,是这份工程的分层思路。控件只负责“拿数据、画自己”,数据采集放在独立的控制器模块里,两者之间用 Qt 信号槽解耦。这样替换数据源时,界面一行不用改。线程模型上也做了简化:界面始终在主线程跑事件循环,模拟数据源直接用 QTimer 驱动,不额外开线程。这个安排在演示阶段完全够用,等接入真实设备时再按需把采集丢到 QThread 里,信号槽天然跨线程安全,后面好扩展。
2.2 目录结构:每个文件在工程里的职责
industrial-ui-example/ ├── main.py # 程序入口,组装主窗口与布局 ├── requirements.txt # PySide6 依赖清单 ├── core/ │ ├── __init__.py │ └── buffered_widget.py # 双缓冲基类,所有自绘控件的父类 ├── widgets/ │ ├── __init__.py │ ├── gauge_widget.py # 圆盘仪表,温度、压力、转速通用 │ ├── trend_widget.py # 实时趋势曲线,环形缓冲存数据 │ ├── lamp_widget.py # 状态灯,布尔量可视化 │ └── button_panel.py # 控制按钮面板,走标准 QSS ├── controllers/ │ ├── __init__.py │ └── simulator.py # 模拟设备数据源,只发射信号 └── themes/ └── dark_theme.py # 颜色、字体统一配置core 和 widgets 的分离是这份工程最有价值的地方。core 里的 BufferedWidget 封装了“先画到离屏画布、再一次贴到窗口”的通用流程,widgets 里的每个控件只需要实现 draw_content 方法画自己的内容,不用关心缓冲细节。controllers 目录把数据源独立出来,意味着界面层不直接持有设备句柄,后续所有的数据接入都发生在这一层。themes 则把颜色、字体收敛到一个文件,换肤只改一处。
2.3 数据与界面分层的刷新链路
整个工程的刷新链路可以拆成三步。第一步,QTimer 每 100 毫秒触发一次模拟数据源的采集动作,产生温度、压力、转速三路数据;第二步,数据源通过 Signal 把数值发出去,仪表盘、趋势图各自接收;第三步,控件在 set_value 或 append 方法里只做两件事:更新自己的数据成员、调用 update() 请求重绘。QWidget 的 update() 不会立即触发重绘,而是把绘制请求合并到事件循环的下一次 paintEvent,Qt 会替你做掉一部分频率过高的刷新。
这套链路的关键在“合并”两个字。假如采集频率是 100Hz,而屏幕刷新率只有 60Hz,Qt 通过 update() 合并机制把多余的绘制请求吃掉,保证不会出现绘制堆积。如果换一批人来做,最容易翻车的写法是每来一帧数据就调用 repaint(),强制同步刷新,界面就会明显变卡。update() 是异步请求、repaint() 是同步强制,这两个 API 的差别在双缓冲体系里需要牢记。
3. 核心模块拆解:双缓冲渲染引擎与三个自绘控件的完整实现
3.1 双缓冲基类:QPixmap 离屏画布与 paintEvent 的配合
双缓冲的原理说起来一句话:先在一张内存画布上把所有图形画完,再一次把整张画布拷贝到窗口。这样做的意义在于,绘制过程中不会出现“画了一半就被人看到”的中间状态,画面自然不闪。这个范例把通用逻辑收敛在 BufferedWidget 基类里,代码如下。
# core/buffered_widget.py from PySide6.QtCore import Qt from PySide6.QtGui import QPainter, QPixmap, QColor from PySide6.QtWidgets import QWidget class BufferedWidget(QWidget): """双缓冲基类:先画到离屏 QPixmap,再一次性贴到窗口""" def __init__(self, parent=None): super().__init__(parent) self._canvas = None self._bg_color = QColor("#1e1e2e") self.setAttribute(Qt.WA_OpaquePaintEvent, True) def resizeEvent(self, event): super().resizeEvent(event) self._rebuild_canvas() self.update() def _rebuild_canvas(self): """画布与控件等大时贴图效率最高,尺寸变了必须重建""" size = self.size() if size.width() <= 0 or size.height() <= 0: return self._canvas = QPixmap(size) self._canvas.fill(self._bg_color) def paintEvent(self, event): if self._canvas is None: return painter = QPainter(self._canvas) try: self.draw_content(painter) finally: painter.end() target = QPainter(self) try: target.drawPixmap(0, 0, self._canvas) finally: target.end() def draw_content(self, painter: QPainter): """子类覆写此方法,画自己的图形,全程在离屏画布上进行""" pass这段代码里,resizeEvent 保证画布永远跟随控件尺寸变化,否则会出现画布拉伸导致的模糊。WA_OpaquePaintEvent 属性告诉 Qt 这个控件自己会画出不透明背景,不需要 Qt 先刷一层默认底色,省掉一次不必要的填充。paintEvent 里先构建 QPainter 针对离屏画布作画,结束后再构建第二个 QPainter 把画布贴到窗口,两个 QPainter 的创建和销毁都用 try/finally 包住,避免绘制中途抛异常时画笔资源泄漏。
参数方面,_bg_color 默认深色,适合工业组态常见的深色主题。子类可以在init里重新赋值,例如仪表盘想用浅色底,直接覆盖这个成员即可。这个基类把缓冲逻辑彻底封装后,控制类的 paintEvent 只做两件事:画内容、贴画布,没有任何业务判断混在里面,后续排查渲染问题时切入点非常清晰。
3.2 仪表盘控件:坐标系变换与指针绘制
仪表盘是最能体现自绘功底的一个控件。它需要画底盘、画刻度线、写刻度值、画指针、画中心轴,如果指针带颜色填充还要注意路径闭合。这份范例里的 GaugeWidget 继承自 BufferedWidget,内部用三角函数自己算刻度位置,不依赖 QPainter 的旋转坐标系,逻辑直白、容易二次调整。
# widgets/gauge_widget.py import math from PySide6.QtCore import QPointF, Qt from PySide6.QtGui import QPainter, QPen, QColor, QFont from core.buffered_widget import BufferedWidget class GaugeWidget(BufferedWidget): """圆盘仪表:适用于温度、压力、转速等模拟量显示""" def __init__(self, title="", min_value=0, max_value=100, unit="", parent=None): super().__init__(parent) self.title = title self.min_value = min_value self.max_value = max_value self.unit = unit self.value = min_value def set_value(self, value): """外部喂数据,只做限幅和更新,不立即刷新""" self.value = max(self.min_value, min(self.max_value, value)) self.update() def draw_content(self, painter: QPainter): w, h = self.width(), self.height() cx, cy = w / 2.0, h / 2.0 radius = min(w, h) / 2.0 - 24 painter.setRenderHint(QPainter.Antialiasing, True) # 底盘 painter.setBrush(QColor("#2a2a3c")) painter.setPen(Qt.NoPen) painter.drawEllipse(QPointF(cx, cy), radius, radius) # 刻度线,从 210 度开始画 240 度的范围 painter.setPen(QPen(QColor("#c8c8d0"), 1)) for i in range(11): angle = math.radians(210 - i * 24) cos_a, sin_a = math.cos(angle), math.sin(angle) outer = QPointF(cx + radius * cos_a, cy - radius * sin_a) inner = QPointF(cx + (radius - 12) * cos_a, cy - (radius - 12) * sin_a) painter.drawLine(inner, outer) # 指针:指向当前数值对应的角度 ratio = (self.value - self.min_value) / (self.max_value - self.min_value) angle = math.radians(210 - ratio * 240) painter.setPen(QPen(QColor("#ff6b6b"), 2)) painter.drawLine(QPointF(cx, cy), QPointF(cx + (radius - 28) * math.cos(angle), cy - (radius - 28) * math.sin(angle))) # 标题与数值 painter.setPen(QColor("#e0e0e0")) painter.setFont(QFont("Monospace", 9)) painter.drawText(int(cx - 40), int(cy + 24), 80, 20, Qt.AlignCenter, self.title) painter.setPen(QColor("#ffffff")) painter.setFont(QFont("Monospace", 11)) painter.drawText(int(cx - 50), int(cy + 44), 100, 20, Qt.AlignCenter, f"{self.value:.1f} {self.unit}")刻度循环里,i 从 0 到 10,对应 11 条刻度线。角度从 210 度递减到 -30 度,覆盖 240 度范围,经典仪表盘的布局。指针长度取 radius - 28,留出中心轴和刻度之间的空隙。PySide6 默认 y 轴向下,因此 cy - radius * sin_a 用减号让角度逆时针展开,这是绘制时最容易搞混的一点,方向反了指针会指向反方向。
set_value 做了限幅处理,外部传入超范围数值时自动截断,避免指针画到表盘外面。update() 只发请求不立即绘制,让 Qt 把多次连续赋值合并成一次 paintEvent。这个设计意图很明确:高频采集场景下,一百次 set_value 可能只触发几次真实的绘制调用。
3.3 实时趋势图:环形缓冲存数据、双缓冲出画面
趋势图的核心难点不在画折线,而在数据管理。工业现场动辄几小时的连续数据,如果全部存下来再绘制,内存和时间开销都不可控。范例用 deque 加 maxlen 构造了一个环形缓冲:数据超过窗口长度时,最旧的数据自动弹出。
# widgets/trend_widget.py from collections import deque from PySide6.QtCore import QPointF from PySide6.QtGui import QPainter, QPen, QColor, QPolygonF from core.buffered_widget import BufferedWidget class TrendWidget(BufferedWidget): """实时趋势图:只保留最近 N 个点,窗口溢出自动丢旧数据""" def __init__(self, max_points=300, parent=None): super().__init__(parent) self.max_points = max_points self.data = deque(maxlen=max_points) def append(self, value): """追加一个新采样点,并请求重绘""" self.data.append(value) self.update() def draw_content(self, painter: QPainter): n = len(self.data) if n < 2: return w, h = self.width(), self.height() pad = 8 min_v, max_v = min(self.data), max(self.data) span = max_v - min_v if span == 0: span = 1 points = [] for i, v in enumerate(self.data): x = pad + i * (w - 2 * pad) / (n - 1) y = h - pad - (v - min_v) / span * (h - 2 * pad) points.append(QPointF(x, y)) painter.setRenderHint(QPainter.Antialiasing, True) painter.setPen(QPen(QColor("#00d0a0"), 2)) painter.drawPolyline(QPolygonF(points))append 只是追加数据并调用 update。绘制时先求当前窗口的最小值和最大值,把数据归一化到控件的高度范围。x 坐标按当前数据量 n 等分,而不是按 max_points 等分,数据不满时曲线也能铺满整个控件宽度。这个细节值得注意:如果按 max_points 算 x,数据只有 50 个点时右侧会留出大片空白,看起来像界面卡了。
环形缓冲配合双缓冲的效果是:即使每秒追加 10 个数据点,绘制调用也被合并,趋势线依然平滑。deque 的 maxlen 参数是这个控件的核心配置,300 个点对应 30 秒的历史窗口,如果想要 5 分钟趋势,把 max_points 改成 3000 即可。内存占用只随 maxlen 线性增长,不会因为运行时间长而失控。
3.4 状态灯与控制面板:不是所有控件都需要自绘
状态灯也是自绘控件,但逻辑比仪表盘简单得多。它的核心只是根据布尔值决定圆的填充色,红色代表报警、绿色代表正常,再用 QTimer 做报警闪烁。控制按钮面板则刻意没有继承 BufferedWidget,而是用标准 QPushButton 加 QSS 实现。
# widgets/lamp_widget.py from PySide6.QtCore import Qt, QTimer from PySide6.QtGui import QPainter, QColor from core.buffered_widget import BufferedWidget class LampWidget(BufferedWidget): """状态灯:绿=正常,红=报警,报警时可配置闪烁""" def __init__(self, title="", parent=None): super().__init__(parent) self.title = title self.alarm = False self._visible_state = True self._flicker_timer = QTimer(self) self._flicker_timer.setInterval(400) self._flicker_timer.timeout.connect(self._toggle_visible) def set_alarm(self, active): self.alarm = active self._flicker_timer.start() if active else self._flicker_timer.stop() self._visible_state = True self.update() def _toggle_visible(self): self._visible_state = not self._visible_state self.update() def draw_content(self, painter: QPainter): if self.alarm and not self._visible_state: return color = QColor("#ff5252") if self.alarm else QColor("#69f0ae") painter.setRenderHint(QPainter.Antialiasing, True) painter.setBrush(color) painter.setPen(Qt.NoPen) painter.drawEllipse(4, 4, self.width() - 8, self.height() - 8)这一段演示了双缓冲基类如何处理“动态闪烁”场景。报警时启动 QTimer,每 400 毫秒切换一次可见状态,触发 update() 重绘。闪烁期间绘制器每次重画内容,但因为走的是离屏画布,切换过程干净利落,不会出现半透明拖影。非报警状态下定时器不运行,省去无意义的定时唤醒。
按钮面板为什么不用自绘?因为 QPushButton 自带完整的交互状态,包括悬停、按下、禁用、焦点,自绘实现这些状态需要大量代码,同时还要处理鼠标事件,性价比很低。工业界面里,凡是有标准控件可用的交互元素优先用标准控件加 QSS,自绘应该留给仪表、曲线、指示灯这类标准控件覆盖不到的元素。这是这份范例在架构上传递的一个重要信号:技术选型要匹配场景,不要为了炫技去重造轮子。
4. 从零运行到接入真实设备:把范例跑起来的三条路径
4.1 环境准备与依赖安装
运行这份工程只需要一个 Python 3.9 以上的环境和一个 PySide6 依赖。建议先用 venv 建独立环境,避免污染系统 Python。
python -m venv venv source venv/bin/activate # Windows 下执行 venv\Scripts\activate pip install -r requirements.txt python main.pyrequirements.txt 内容就是一行 PySide6,版本锁定到当前稳定版即可。装完直接跑 main.py,主窗口会弹出,左侧是仪表盘和状态灯,右侧是趋势图,底部是控制面板。数据源在窗口打开的同时开始模拟采集,整个界面应该在 1 秒内进入持续刷新状态。
如果跑起来后提示缺少 xxx 模块,绝大多数情况是 PySide6 版本和 Python 版本不匹配。检查方法是进入 python 交互模式执行 import PySide6,能导入说明安装正常,不能导入就看看 pip list 里 PySide6 是否真的装进了当前这个 venv。工控机上经常存在多个 Python 安装路径,activate 之后还要确认 which python 指向的是 venv 目录。
4.2 双缓冲效果验证:一份代码里的 A/B 对照
想亲眼看到双缓冲的价值,不需要改代码逻辑,只需在 BufferedWidget 里加一个类属性开关,把双缓冲路径和直接绘制路径做成可切换。
# core/buffered_widget.py 中新增 class BufferedWidget(QWidget): USE_BUFFER = True # 设为 False 可对比直接绘制的效果 def paintEvent(self, event): if not self.USE_BUFFER: painter = QPainter(self) try: self.draw_content(painter) finally: painter.end() return # 原有双缓冲逻辑不变把 USE_BUFFER 设成 False 再运行,画面立刻开始闪烁,尤其在窗口拖动、仪表数值快速变化时,残影和撕裂肉眼可见。这套 A/B 对照的写法很有价值:它证明闪烁不是控件的绘制逻辑问题,而是缓冲机制缺失。验证完毕后把开关改回 True,然后可以顺手把绘制帧率也打印出来,双缓冲开启时帧率通常比直接绘制有数倍提升,CPU 占用也会下降,因为合成次数的减少。
4.3 把模拟数据源换成真实设备采集:保持信号契约不变
模拟数据源之所以能和界面解耦,核心约定是“发射三个信号”。真实设备接入时,只要新数据源类名不同、信号接口一致,组装方式就完全一样。
# controllers/modbus_source.py 示意 from PySide6.QtCore import QObject, Signal, QTimer class ModbusSource(QObject): temperature_updated = Signal(float) pressure_updated = Signal(float) speed_updated = Signal(float) def __init__(self, host, port, unit_id, poll_ms=200): super().__init__() self._timer = QTimer(self) self._timer.setInterval(poll_ms) # 按实际 modbus 库建立连接,并绑定 self._timer.timeout 到读取函数 def start(self): self._timer.start()主窗口组装处把 DeviceSimulator() 替换成 ModbusSource(host="192.168.0.10", port=502, unit_id=1),余下代码不动。这个替换能成立,靠的是前端只依赖信号名,不依赖数据产生方式。真实项目中,Modbus 驱动的应答时间可能超过定时器间隔,必须把轮询放到独立 QThread 里跑,信号槽跨线程发射时 Qt 会把消息排队到主线程,界面不会卡。这三条路径合起来,既覆盖了演示用途,也覆盖了生产接入的改造方向。
5. 避坑记录:双缓冲界面常见的闪烁、撕裂与刷新异常
5.1 现象:画面依然闪烁,双缓冲像是没起作用
原因之一是没有正确开启 WA_OpaquePaintEvent。Qt 默认会先填充控件背景色,再执行 paintEvent 里的绘制,这个多余的背景填充动作会产生一次可见的闪烁,尤其是背景色和自绘主题色不一致时。原因之二是有人使用 repaint() 强制同步刷新,绕过了 update() 的合并机制,频繁的同步重绘直接导致闪烁和 CPU 飙升。
解决方法是双管齐下:BufferedWidget 初始化时明确 setAttribute(Qt.WA_OpaquePaintEvent, True);所有外部数据入口只调 update(),禁用 repaint()。代码审查时看到 repaint 就要警惕,这基本可以认定是某个性能问题的根源。
5.2 现象:数值刷新很慢,仪表和趋势图有明显的迟滞感
QTimer 的 interval 设到 500ms 时,数据变化要半秒才能反映到界面,操作人员会明显感到不跟手。这不是渲染慢,而是采样周期太长。常见做法是把 interval 调到 100ms,再配合 update() 合并机制保证总绘制次数不超过屏幕刷新率。100ms 的采样频率对绝大多数工业参数都足够,温度、压力、液位的物理变化远达不到这个速度。
如果调到 50ms 以下仍然卡顿,问题往往出在绘制本身,而不是定时器。图形过多、抗锯齿开销大、每帧全量重建画布都是嫌疑点。先关掉抗锯齿对比一下,再用 QElapsedTimer 测量 draw_content 的耗时,定位到具体绘制调用后再做优化。
5.3 现象:控件窗口放大后文字和线条变模糊
双缓冲的画布 QPixmap 尺寸是初始化时按 size() 创建的,窗口一旦 resize,如果画布没有重建,就会被拉伸显示,原本 1 像素的线条变成 2 像素宽的灰色糊边。这是双缓冲方案里最容易踩的坑。
解决的关键在 resizeEvent 里必须调用 _rebuild_canvas(),让画布尺寸跟随控件尺寸变化,然后触发 update() 让后续绘制基于新画布进行。范例里 BufferedWidget 已经内置了这个逻辑,二次开发时新增控件只要继承它就不用重复处理。要检查自己写的子类是否漏掉,把窗口从最小拖到最大,刻度线如果始终锐利,说明重建逻辑在工作。
5.4 现象:数据线程直接调 set_value 时程序随机崩溃
QWidget 不是线程安全的。某设备厂商 SDK 的回调线程里直接调用了 gauge.set_value(),表现是界面偶发崩溃、绘制异常。原因在于 update() 本身会触达窗口系统的事件机制,在非 GUI 线程调用属于未定义行为。
解决方法是让采集线程通过信号把数值发出来,在主线程槽函数里调 set_value。Qt 信号槽跨线程时自动使用队列连接,发射线程只负责投递消息,不直接操作 UI 对象。如果信号发射过于频繁,可以在采集线程里做一次轻量过滤,例如只在数值变化超过阈值时发信号,比在主线程重复接收再判断更省资源。
5.5 现象:高 DPI 屏幕上整体界面偏小,自绘文字发虚
工程机的显示缩放比例设置成 150% 后,布局偏小、文字发虚是常见反馈。Qt 6 默认开启高 DPI 缩放,但自绘控件的画布如果没有按 devicePixelRatio 处理,文字就会因为渲染在低分辨率位图上再被放大而模糊。
解决方法是入口处启用高 DPI 缩放属性,同时在 _rebuild_canvas 里按逻辑尺寸乘以设备像素比创建画布,绘制时再配合 painter.scale 恢复到逻辑坐标系。示例代码里预留了 _rebuild_canvas 的重建逻辑,扩展时不改基类、只在子类里调整画布比例系数即可。验证方法是把工程拷到一台 2K 分辨率的工控机上,看仪表文字和刻度是否和 1080P 屏一样锐利。
6. 二次开发实践:从复制控件到替换数据源的三步改法
6.1 新增一个自定义控件的标准模板
掌握了 BufferedWidget 的分层方式后,新增控件就变成一个固定套路。继承基类、写init定义自己的参数、覆写 draw_content,三步走完填入主窗口布局即可。
# 示例:新增一个水位条控件,模板可直接套用 from PySide6.QtGui import QPainter, QColor from core.buffered_widget import BufferedWidget class BarWidget(BufferedWidget): def __init__(self, min_value=0, max_value=100, parent=None): super().__init__(parent) self.min_value = min_value self.max_value = max_value self.value = min_value def set_value(self, value): self.value = max(self.min_value, min(self.max_value, value)) self.update() def draw_content(self, painter: QPainter): ratio = (self.value - self.min_value) / (self.max_value - self.min_value) w = int(self.width() * ratio) painter.setRenderHint(QPainter.Antialiasing, True) painter.setBrush(QColor("#00bcd4")) painter.setPen(QColor("#ffffff")) painter.drawRect(0, 0, w, self.height())做完这三步后,在 main.py 里追加一行布局,把 BarWidget 放入 QHBoxLayout 即可。整个过程中不需要碰缓冲逻辑,不需要关心 paintEvent 的内部机制,这正是基类封装的意义。
6.2 二次开发时的验证顺序与收尾习惯
改完控件后建议按三个层级验证:先跑静止画面看渲染质量,确认无闪烁、无模糊、无边缘撕裂;再做高频数据灌注测试,写一个 QTimer 以 10ms 间隔调用 set_value,观察 CPU 占用和界面流畅度;最后接入真实数据源做 24 小时稳定性验证,重点回看数据源断线重连时界面是否还能保持刷新。三次验证的核心关注点都是同一个指标:在数据刷新速率高于屏幕刷新率时,绘制调用是否仍然被合并机制稳定消化。
从那以后,我每次自绘控件新增功能,都强制走一遍“先对照基类模板、再跑高频灌注、最后测 resize 和高 DPI 表现”的流程。这套流程替我省掉过太多现场调试的心力,也希望帮你绕开那些我看过的坑。
本文还有配套的精品资源,点击获取