简介:面向移动边缘计算(MEC)场景的深度强化学习研究,这份Python源码实现了基于DQN的计算卸载与资源分配算法,并附带Q-learning对比基线,适合人工智能、通信工程等专业学生用于毕设或课程设计。资源压缩包共19个文件,大小113KB,涵盖mec_dqn.py、mec.py等核心算法代码,run_f1_dqn.sh、run_f2_q.sh等一键运行脚本,draw_f1.py等结果可视化工具,以及README说明、训练日志和结果图片,便于快速复现并分析收敛曲线。已有153人学习下载。代码结构清晰,注释完整,支持在本地环境直接运行调参;通过对比不同算法在任务卸载决策中的性能表现,可帮助读者理解MEC资源分配机制及深度强化学习建模方法,也可作为进一步扩展多用户、多基站场景的实验基础,适合需要完整项目参考的初学者与进阶者。
1. MEC 计算卸载不是新问题,但深度强化学习换了个解法
移动设备的电池和算力就那么点,任务全在本地跑,时延和功耗都压不住;全传到云中心,回程链路又扛不住。于是有了 MEC(移动边缘计算):路边一个边缘服务器,几毫秒就能响应,关键是把哪些任务放到边缘、分配多少通信和计算资源,这堆问题统称计算卸载与资源分配。
传统做法是用数学规划建模,比如把时延能耗做成目标函数,再用分支定界去解。可一碰上信道状态随机抖动、任务到达率忽高忽低的真实场景,模型参数一变就需要重新求解,online 根本来不及。深度强化学习在这里的价值不是“优化某个时隙”,而是通过与环境大量交互,学出一个从系统状态到卸载决策的映射函数,遇到没见过的负载模式也能直接给动作。这篇笔记就围绕 Python 实现的 MEC 计算卸载与资源分配源码展开,讲清楚 MDP 怎么建模、算法怎么选、训练有哪些坑,以及拿到一份源码后怎么验收。适合正在做 MEC 仿真、或者想用深度强化学习入门边缘计算调度的读者。
2. 把卸载和资源分配写成 MDP:动作空间怎么切,奖励怎么定才不打架
深度强化学习落地到 MEC 的第一步,不是写网络,而是把调度问题翻译成 MDP。这一步翻译错了,后面算法再先进也白搭。
2.1 为什么不用数学规划而是用 MDP:动态性决定了建模方式
先想清楚传统方法卡在哪。经典的计算卸载问题可以写成混合整数非线性规划(MINLP),优化的变量包括卸载决策(0/1 或比例)和资源分配(带宽、算力),约束包括任务时延上限、发射功率上限、边缘服务器算力上限。小规模场景下用 Gurobi 这类求解器能找到全局最优,但 MEC 的实际运行环境有三个致命特征:任务到达是随机过程、信道增益随时隙变化、边缘服务器的排队状态高度耦合。每个调度周期都要重解一次规划,求解时间可能比任务本身的时延预算还长。
深度强化学习走的是另一条路:不显式建模信道和任务分布的统计规律,而是让智能体在一个仿真环境里反复试错,用累积奖励来评估“好策略长什么样”。训练完成后,策略网络只是一个前向计算,输入当前观测,输出动作,推理耗时在毫秒级以下。所以“MDP + 深度强化学习”不是说它比数学规划更精确,而是它在高动态环境下能在线决策,这是工程上选它的核心理由。
2.2 状态、动作、奖励的工程取舍:任务队列、卸载比例、边缘频度怎么编码
状态空间的设计原则是:智能体必须能“看见”影响决策的所有因素,但又不能把与决策无关的信息塞进来。我一般会取四类信息:
- 每个用户设备的任务队列长度,反映当前积压压力;
- 新到达任务的大小和计算量,决定卸载的量级;
- 用户到边缘服务器的信道增益,算出传输速率才知道卸载快不快;
- 边缘服务器当前的排队长度和剩余算力,决定现在该不该给它加压。
动作空间是这里最容易出错的地方。卸载决策是离散的“卸载/不卸载”,但如果每个时隙都输出离散动作,动作空间会随用户数指数膨胀,而且没法表达“卸载一半”这种灰度决策。实际工程里多采用连续动作:卸载比例(0 到 1 之间)、发射功率(连续值)、边缘计算资源分配比例(一组和为 1 的权重)。这样就把三类决策统一成一个连续向量,直接对接 DDPG 这类连续控制算法。
奖励函数是另一个大坑。时延的单位是秒或毫秒,能耗单位是焦耳,任务丢弃是个 0/1 惩罚,三者的数值范围差好几个量级,直接相加会让量级大的那项主导梯度。常见做法是先把每项除以参考值做归一化,再乘权重相加。我在后面环境的代码里会给出具体实现。
2.3 一个最小可跑的 MEC 仿真环境:MECEnv 骨架与 step 逻辑
讨论完设计原则,直接给一个能跑起来的环境骨架。注意这不是完整生产级仿真,但它包含了 MEC 计算卸载最核心的动态计算卸载层逻辑:任务到达、本地计算、传输、边缘计算、排队更新。下面的类实现了 reset 和 step 两个强化学习环境必备方法。
import numpy as np class MECEnv: def __init__(self, n_user=8, n_edge=1, slot=0.01): self.n_user = n_user self.n_edge = n_edge self.slot = slot # 每个时隙的仿真时长,单位秒 # 环境参数,实际项目中这些通常从配置文件读取 self.bandwidth = 10e6 # 上行带宽 10MHz self.noise = 1e-9 # 噪声功率谱密度 self.local_cpu = np.random.uniform(0.8, 1.2, n_user) # 本地算力 GHz self.edge_cpu = 4.0 # 边缘服务器总算力 GHz def reset(self): self.task_queue = np.zeros(self.n_user) # 用户本地队列 self.arrival = np.random.poisson(2, self.n_user) # 新到达任务数 self.task_size = np.random.uniform(500, 1500, self.n_user) # KB self.channel_gain = np.random.exponential(1.0, (self.n_user, self.n_edge)) self.edge_queue = np.zeros(self.n_edge) return self._get_state() def step(self, action): # 动作拆包:连续动作,范围由外层归一化保证 unload_ratio = action[:self.n_user] # [0,1] 卸载比例 tx_power = action[self.n_user:2 * self.n_user] # 发射功率 edge_weight = action[-self.n_edge:] # 边缘资源分配权重 # 本地任务量:未卸载的部分留下 local_task = self.task_size * (1 - unload_ratio) offload_task = self.task_size * unload_ratio # 本地计算时延:任务量 / 本地算力 local_delay = local_task / (self.local_cpu * 1e9) # 香农公式算传输速率,再算传输时延 rate = self.bandwidth * np.log2(1 + tx_power * self.channel_gain[:, 0] / self.noise) tx_delay = offload_task / rate # 边缘计算时延:所有卸载任务总大小 / 分到的算力 edge_delay = offload_task.sum() / (self.edge_cpu * edge_weight[0] * 1e9) # 以系统内最大时延作为本时隙时延(保证所有任务都完成) delay = max(local_delay.max(), tx_delay.max(), edge_delay) + self.edge_queue[0] # 能耗:传输能耗 + 本地计算能耗 tx_energy = tx_power * self.slot local_energy = local_task * 1e-6 energy = tx_energy.mean() + local_energy.mean() # 奖励:负的时延和能耗,做归一化避免量纲失衡 reward = -(delay / 0.5 + energy / 1e3) # 更新队列状态,进入下一个时隙 self.edge_queue[0] = max(0, self.edge_queue[0] + offload_task.sum() / (self.edge_cpu * 1e9) - self.slot) self.arrival = np.random.poisson(2, self.n_user) self.task_size = np.random.uniform(500, 1500, self.n_user) self.channel_gain = np.random.exponential(1.0, (self.n_user, self.n_edge)) done = False info = {"delay": delay, "energy": energy} return self._get_state(), reward, done, info def _get_state(self): # 状态向量:队列长度 + 到达任务量 + 信道增益 + 边缘排队 return np.concatenate([ self.task_queue / 100.0, # 归一化,避免数量级差距 self.arrival / 10.0, self.channel_gain[:, 0], self.edge_queue / 100.0 ])这段代码有几个细节值得说明。第一,动作拆包时必须保证顺序和训练时一致,卸载比例、发射功率、边缘权重这三段不能错位,否则模型训练出来动作完全不可解释。第二,任务量的单位是 KB,算力单位是 GHz,计算时延公式里需要换算成统一的 bit 和 Hz,我直接在代码里用 1e9 做了换算,实际项目里这类单位换算最容易造成数量级错误。第三,队列更新用了简化的 M/D/1 排队模型,真实场景可以做更精细的排队仿真,但作为深度强化学习环境,这个粒度已经足够。
状态归一化是这里容易被新手忽略的一步。任务队列可能积压到几百,信道增益可能只有 0.1 到 1 之间,如果原样拼接进一个向量,神经网络输入维度之间的量纲差会让初始梯度方向被大数值特征主导。环境里我在_get_state中做了简单缩放,让所有特征大致落在 0 到 1 区间,深层网络的训练稳定性会明显提升。
3. 算法选型与源码实现:DQN 动不了,DDPG 刚刚好
同样的 MEC 问题,选错深度强化学习算法会浪费大量训练时间。这一步先做算法对比,再给出一套可以直接跑的 DDPG 实现。
3.1 各种深度强化学习算法列表对比:离散、连续与混合动作怎么选
现在主流的深度强化学习算法可以分为几类:基于值的、基于策略梯度的、以及两者结合的 Actor-Critic 框架。做 MEC 计算卸载的选型时,我的习惯是先看动作空间是离散还是连续。把常见算法列成一张对比表,看起来更直观:
| 算法 | 动作类型 | 适用场景 | MEC 适配度 |
|---|---|---|---|
| DQN | 离散 | 动作空间小、可枚举 | 低,卸载比例和功率分配本质连续 |
| DDPG | 连续 | 高维连续控制 | 高,适合卸载比例 + 功率 + 资源分配 |
| PPO | 离散/连续均可 | 样本效率要求高、稳定性优先 | 中高,收敛稳定但训练开销略大 |
| TD3 | 连续 | 对超参数敏感的场景 | 高,DDPG 的增强版,缓解 Q 值过估计 |
从这张深度强化学习算法列表对比可以看出,DQN 家族基本可以直接排除,因为 MEC 的卸载比例和发射功率天然是连续变量。你要硬把 0 到 1 的卸载比例离散成 10 档,用户数量一多,动作空间组合爆炸,训练根本无法收敛。DDPG 和 TD3 这类确定性策略梯度方法输出的是一个连续动作向量,天然适配。PPO 也能做连续动作,但训练时环境的交互轮次会更多,在仿真环境里倒无所谓,如果你想做到线上实时更新,DDPG 类方法更轻量。综合来看,入门 MEC 计算卸载源码,DDPG 是最常见的起点,也是后续扩展 TD3、SAC 的基础。
3.2 DDPG 网络结构:Actor 输出卸载比例,Critic 负责评价
DDPG 由四张网络组成:Actor、Critic 以及各自的目标网络。Actor 输入状态,输出动作;Critic 输入“状态 + 动作”,输出这个动作的 Q 值。目标网络的参数通过软更新缓慢逼近在线网络,用来稳定 TD 目标的计算。
import torch import torch.nn as nn class Actor(nn.Module): """策略网络:输入状态,输出归一化动作 [-1, 1]""" def __init__(self, obs_dim, act_dim, hidden=256): super().__init__() self.net = nn.Sequential( nn.Linear(obs_dim, hidden), nn.ReLU(), nn.Linear(hidden, hidden), nn.ReLU(), nn.Linear(hidden, act_dim), nn.Tanh() # 输出限制在[-1,1] ) def forward(self, obs): return self.net(obs) class Critic(nn.Module): """评价网络:输入状态+动作,输出Q值""" def __init__(self, obs_dim, act_dim, hidden=256): super().__init__() self.fc_s = nn.Linear(obs_dim, hidden) self.fc_a = nn.Linear(act_dim, hidden // 2) self.out = nn.Sequential( nn.ReLU(), nn.Linear(hidden + hidden // 2, hidden), nn.ReLU(), nn.Linear(hidden, 1) ) def forward(self, obs, act): s = self.fc_s(obs) a = self.fc_a(act) return self.out(torch.cat([s, a], dim=-1))Actor 网络最后一层用 Tanh,是为了把动作限制在 [-1, 1] 区间,方便统一采样。但环境真正需要的是物理动作:卸载比例必须在 [0,1],发射功率在 [p_min, p_max],边缘资源权重和为 1。所以训练循环里需要一个动作变换函数,把归一化动作映射到环境的实际物理量。这段转换逻辑必须放在环境外部、训练循环内部,不能写进网络,否则网络输出的分布会被归一化干扰。
Critic 网络的输入我先分别用两个全连接层提取状态和动作的特征,再拼接起来。比直接把状态和动作 concat 进一个全连接层效果更稳定,因为状态维度和动作维度的信息密度差异比较大,分开提取特征能让 Critic 更容易学习到“哪个动作更好”的映射。
3.3 经验回放与训练主循环:稳定训练的两块基石
DDPG 是 off-policy 算法,经验回放是它的命脉。MEC 环境产生的一条 transition 是(状态,动作,奖励,下一状态,是否结束),我们需要把最近的经验存起来,训练时随机采样打破时间相关性。标准的回放缓冲区实现和训练主循环如下:
import random from collections import deque class ReplayBuffer: def __init__(self, capacity=50000): self.buffer = deque(maxlen=capacity) def push(self, s, a, r, s_next, done): self.buffer.append((s, a, r, s_next, done)) def sample(self, batch_size): batch = random.sample(self.buffer, batch_size) s = torch.FloatTensor([x[0] for x in batch]) a = torch.FloatTensor([x[1] for x in batch]) r = torch.FloatTensor([x[2] for x in batch]).unsqueeze(1) s_next = torch.FloatTensor([x[3] for x in batch]) done = torch.FloatTensor([x[4] for x in batch]).unsqueeze(1) return s, a, r, s_next, done训练主循环里最关键的几个点:缓冲区里存的是归一化动作,不是映射后的物理动作;探索噪声随时间表衰减;目标网络用软更新。下面是一段典型的主循环骨架:
def train_ddpg(env, agent, buffer, cfg): for episode in range(cfg.max_episodes): state = env.reset() episode_reward = 0 noise_scale = max(cfg.noise_std * (1 - episode / cfg.max_episodes), 0.05) while True: # 1. 用当前策略输出动作,加探索噪声 state_tensor = torch.FloatTensor(state).unsqueeze(0) action_norm = agent.actor(state_tensor).detach().numpy()[0] action_norm += noise_scale * np.random.randn(env.n_user * 2 + env.n_edge) action_norm = np.clip(action_norm, -1.0, 1.0) # 2. 动作映射:归一化动作 -> 物理动作 n = env.n_user unload_ratio = (action_norm[:n] + 1) / 2 tx_power = (action_norm[n:2*n] + 1) / 2 * 0.5 # 功率范围 0~0.5W edge_weight = np.ones(1) # 单边缘服务器,权重固定为1 action_env = np.concatenate([unload_ratio, tx_power, edge_weight]) # 3. 交互并储存经验 next_state, reward, done, info = env.step(action_env) buffer.push(state, action_norm, reward, next_state, done) state = next_state episode_reward += reward # 4. 经验足够后开始更新 if len(buffer) > cfg.batch_size: batch = buffer.sample(cfg.batch_size) agent.update(batch) if done: break print(f"Episode {episode}, reward {episode_reward:.2f}")参数说明:cfg.noise_std初始探索噪声标准差,一般取 0.2 到 0.3,随着 episode 线性衰减到 0.05,这保证了训练初期充分探索、后期稳定利用。buffer.capacity至少 50000,MEC 环境的状态转移比较平缓,经验太少会导致多样性和灾难性遗忘。batch_size取 64 或 128,取决于你的显存和状态维度,状态维度过高时适当调大 batch 能稳定 Critic 的训练。
提示:经验回放里存的必须是归一化动作
action_norm,而不是环境实际执行的物理动作。因为 Critic 学习的是 Q(s, a_net),如果存物理动作,target 网络输出的动作和 Critic 的输入分布会对不上,训练必炸。
DDPG 的常见超参数我习惯这样设:
| 参数 | 经验值 | 说明 |
|---|---|---|
| actor_lr | 1e-4 | Actor 更新速度要比 Critic 慢 |
| critic_lr | 1e-3 | Critic 学习率稍高,快速逼近真实 Q 值 |
| gamma | 0.99 | 偏重长期累积奖励 |
| tau | 0.001 | 目标网络软更新系数,越小越稳 |
| max_episodes | 800 | 深度强化学习训练是百万步级的,别 50 个 episode 就下结论 |
tau这个参数很多人不调,默认 0.001 就放着。实际上目标网络更新太快,训练初期 Q 值震荡明显;更新太慢,算法对奖励变化不敏感。在 MEC 这种奖励信号变化缓慢的环境里,0.001 到 0.005 都能跑,但超过 0.01 很容易出现 loss 发散,这是血泪经验。
4. MEC-DRL 训练避坑:5 条让人想放弃的典型事故
这一章是我觉得最值得反复看的。跑深度强化学习做 MEC 调度的人,十有八九都在这几个坑里翻过车,每条按“现象 → 原因 → 解决”写清楚。
4.1 坑 1:动作维度错位,模型学会了“把任务的卸载比例设成发射功率”
现象:训练几十个 episode 后,从日志里看到每个用户的卸载比例几乎一致,但发射功率的波动带有明显的周期性,仔细一对比发现环境拿到的 action 顺序乱了。有时候任务队列已经排到几百,卸载比例却输出接近 0。
原因:动作拼接顺序不统一。训练主循环里按“卸载比例 + 发射功率 + 边缘权重”的顺序拼接,但环境step里拆包时写成了“发射功率 + 卸载比例 + 边缘权重”,维度和语义完全错位。网络以为自己在调卸载比例,实际环境却把它当成功率,属于典型的黑匣子错误。
解决:在MECEnv.step入口加一个断言,检查动作向量维度,并打印每一段的最小值、最大值。调试阶段花两分钟看一眼头几个 step 的实际动作输入,能省出半天调参时间。根本解法是把动作拆包的顺序常量定义在一个统一的地方,比如ACTION_ORDER = ["unload", "power", "edge_weight"],训练和环境的代码都从同一份配置读取。
4.2 坑 2:没有给 done 加掩码,训练到中后期 loss 死活不下
现象:回合奖励在某个水平震荡,150 个 episode 后完全没有上升趋势。打开 TensorBoard 看 Critic loss,发现 loss 在缓慢下降,但 Actor 的梯度却越来越小,最终策略几乎不变。
原因:计算 target Q 值时写成了target_q = reward + gamma * target_critic(next_state, next_action),忘了乘(1 - done)。MEC 仿真环境的 episode 长度如果是固定步数,done 在最后一步之前都是 False,影响不大;但如果不固定步数,某个回合提前结束时 Q 值会把一个不存在的未来状态计入回报,策略被错误信息污染,后期自然学不动。
解决:target Y 始终加上 done 掩码:
target_q = reward + gamma * (1 - done) * self.target_critic(next_state, next_action)这个(1 - done)是深度强化学习里最不起眼、但最致命的细节。哪怕你用过五次 DDPG,每次换新环境都可能在这个位置翻车。写了这行不代表就稳了,还要检查 done 的 dtype 是不是浮点数,整数 0/1 乘出来会有隐式类型问题。
4.3 坑 3:奖励量纲不一致,时延毫秒、能耗焦耳、一起上数就炸
现象:奖励曲线前期一直为负的几千,模型在训练初期完全不更新,或者更新后奖励剧烈震荡,整体呈现“躺平”状态。
原因:时延数值在毫秒到秒级,平均可能是 0.2;能耗的数值可能到几千焦耳。直接把reward = -(delay + energy)作为目标,能耗项完全压制时延项,Critic 学到的 Q 值主要反映能耗,卸载比例的作用被淹没。奖励尺度不稳定还会导致梯度幅度波动,Adam 的动量估计失效。
解决:把每个子项除以它的参考值再相加。比如固定一个最大可接受时延D_max = 0.5s,一个典型能耗E_ref = 1e3 J,然后 reward 写作-(delay / D_max + energy / E_ref)。这样两项的范围都在同一数量级,权重alpha和beta才有意义。如果嫌手动调权重麻烦,可以在预热阶段跑 100 个 episode 统计 delay 和 energy 的平均值,再用平均值做归一化,这相当于让算法自己找到量纲。
4.4 坑 4:状态特征差几个数量级,梯度一步就 NaN
现象:训练刚开始几十步,Actor loss 直接变成nan,或者在某个 episode 中途突然全部参数变成nan,重启后复现概率很高。
原因:状态向量里某一维的值特别大,比如任务队列积压到 10000,而信道增益是 0.01,两者相差六个数量级。网络初始化的梯度按标准正态分布走,遇到这么大的输入,隐藏层的激活值直接饱和或溢出,前向传播出inf,反向传播跟着全变nan。这不是深度强化学习的问题,是所有深度网络共用的基础问题,但 MEC 环境里状态特征天然混杂队列长度、功率、信道增益,特别容易踩中。
解决:环境里做 MinMax 归一化或标准差归一化。上一章MECEnv._get_state()里把队列长度除以 100,就是最朴素的方法。更稳妥的做法是在每次 reset 时记录当前 batch 的 min、max,用(x - min) / (max - min)变换,保证所有输入落在 [0,1] 区间。同时网络初始化用 Xavier 或 Kaiming,避免初期梯度消失。日志里出现nan,第一反应不是调学习率,而是检查输入数据和 reward 的范围。
4.5 坑 5:探索噪声与技术不会衰减,学到的策略反复“抽风”
现象:训练到后段,奖励曲线在一个较高位置横盘,偶尔掉下去又重新爬回来,策略输出的动作抖动明显,同一状态下解出来的卸载比例忽高忽低。
原因:训练全程使用固定的noise_std=0.2高斯噪声,即使训练到了 700 个 episode,动作仍然被 ±0.2 的噪声扰动。在 MEC 环境里,卸载比例 0.1 的波动可能就让边缘服务器过载,性能自然上下乱跳。此外,Actor 的学习率如果和 Critic 差距不够大,训练后期 Critic 已经稳定,Actor 还在大步长更新,动作输出就会出现振荡。
解决:用线性或指数衰减的探索噪声,我习惯把初始噪声 0.3 衰减到训练末期的 0.05,或者用 OU 噪声并逐步减小它的方差。同时把 Actor 学习率降到 1e-4,Critic 保持 1e-3,让策略更新更保守。这个技巧往往能让奖励曲线在横盘期再上一个台阶,属于“免费的午餐”。调深强化学习的人经常说训练很玄学,但噪声衰减和 Actor/Critic 学习率不对齐,这属于可解释的确定性错误,排查优先级应该高于乱调网络层数。
5. 源码拿到手后怎么验收:三条命令和一个判断拐点的土办法
网上能搜到的 MEC 深度强化学习源码不少,但拿到手直接能跑的不多。我的验收套路很固定:先跑通一个极短的训练,再看对比曲线,最后才信“效果不错”的结论。
一个规范的工程实现通常会有三个入口脚本:训练、评估、基线对比。以这份方案为例,命令大概是这样的:
python train.py --algo ddpg --episodes 50 --seed 42 python evaluate.py --checkpoint runs/ddpg_seed42/best.pt python compare.py --baseline random三条命令各有各的任务。第一条是快速验证环境、网络、回放是否串通,50 个 episode 不要求训练好,只要求不报错、不 NaN、日志正常;第二条是加载训练好的模型,关掉探索噪声跑固定随机种子,输出平均时延、能耗、任务丢弃率这三个核心指标;第三条拿随机卸载或者贪心卸载做对照,判断深度强化学习策略到底有没有学到东西——如果连随机基线都跑不过,说明环境或奖励八成有问题。
看曲线有个土办法但特别好用:训练初期深度强化学习的回报一定低于贪心基线,因为它在随机探索,这一步很多人会误删代码。如果训练到 200 个 episode 才开始反超基线,这很正常。真正需要警惕的是曲线没有任何变陡迹象,或者反超后又跌回去。把回报曲线做窗口平均,取窗口为 30 个 episode,曲线开始保持稳定不再下降的那个点,就是提前停止的最佳时机。超过这个点继续训练,模型大概率开始过拟合环境里的随机噪声。
我自己的验收习惯是,永远在环境里内置一个最简单的贪心卸载策略做地板:实时队列超过阈值就卸载 80%,否则卸载 20%。深度强化学习的策略至少要比这种几行代码的启发式策略好,否则所谓的“智能调度”就是在自欺欺人。装上这个地板策略之后,模型的每次改进都有参照系,也方便以后做对比实验。这套从环境到算法到验收的流程走通之后,再去碰 TD3、SAC 或者多智能体的场景,只是换算法壳子,底层的 MDP 设计和避坑逻辑是通用的。希望帮到你。
本文还有配套的精品资源,点击获取