做路径规划仿真有一阵子了,单AGV在栅格地图上的路径搜索这个题目看起来基础,真要把算法、交互、指标统计串成一个能反复改参数、换地图、看对比的仿真Demo,坑其实不少。最近把“改进A*算法”这套仿真框架完整重写了一遍,支持随时改地图障碍物、手动拖起始点和目标点,底层用0/1二值栅格建模,跑完能直接输出路径长度、扩展节点数、运行时间这些关键指标。这篇文章就围绕这个仿真系统本身,把设计思路、算法改进点、代码实现细节和调试过程中踩过的坑一次说清楚。
我默认读到这篇文章的人已经对A流程有基本认知(open list、close list、启发函数、f=g+h),不过哪怕你只写过一次A的作业,也能跟得上。我会从“基础A*为什么在AGV场景下不够用”讲起,再逐一说明我做了哪几类改进、每类改进解决了什么问题、引进什么新问题,然后落到仿真代码里的具体实现。
1. 整体设计与需求拆解
1.1 单AGV路径规划场景的真正痛点
标题里的“单AGV”不是随便写的定语。单机路径规划在学术界看着简单,在真实场景里,AGV要的是“一条能真正让小车走起来的轨迹”,而不是“一条图论意义上的最短折线”。
仓储、车间、实验室里的AGV大多数是差速驱动或舵轮驱动,运动学上受非完整性约束,但很多路径规划算法根本不关心这个。传统A*搜索出来路径的特点是“贴障碍物走、走直角弯、频繁变向、锯齿感强”。你要直接把这个结果丢给底盘控制器,结果是AGV在直角拐弯处停下来原地转,或者贴着墙边擦着过,不仅难看,还有碰撞风险。
所以这个项目的定位就很清晰:做一个能在仿真层面验证“改进A*算法”效果的平台,而不是一个只能演示绿色线条从起点爬到终点的玩具。要做到这个程度,地图、起点、终点都得能随时改,算法参数也得能随时调,跑完之后要有量化指标。
1.2 为什么选A*作为底子而不是贪心、Dijkstra或RRT
路径搜索的候选方案很多,但在这个场景下A*有几个不可替代的优势。
Dijkstra没有启发函数引导,栅格地图大一点(比如80x80)就要扩成千上万个节点,单AGV实时性差的场景很难接受。贪心最佳优先搜索只盯着h值不看重g值,遇到凹型障碍区经常一头扎进去出不来,路径质量毫无保证。RRT类采样算法适合高维空间或连续空间,但在这种离散栅格地图上属于“杀鸡用牛刀”,而且还要额外做路径平滑和剪枝,生成路径的质量方差很大。
A天然融合了“已走代价”和“预估代价”,在栅格地图上扩展行为可控、结果可复现、参数量少。它的核心问题只是“搜索策略本身要不要更聪明一点”“评价函数要不要加上应用层的约束”,这些东西都可以在A框架内以改进项的形式加进去。这正是这个标题里“改进”二字的着力点。
1.3 仿真系统的功能定位
我做的这个仿真系统,按模块拆解就是四块:地图编辑模块、算法配置模块、主搜索模块、统计与可视化模块。用户操作层面对应三个需求:能改地图、能改起始点、能改目标点。
地图用标准栅格矩阵表示,自由格子是0,障碍物格子是1。这个约定直接沿用了标题里的描述,目的很简单:地图文件可以手工编辑为文本矩阵,方便别人复现。结合下面的具体界面设计也很直观。起始点和目标点支持鼠标点击设置,也可以在地图文件里预先定义。
算法配置层支持调启发函数权重、扩展邻域类型(四邻域或者八邻域)、是否启用双向搜索、路径平滑开关等。仿真跑完能把路径点和统计指标直接导出。
2. 改进A*的思路与核心算法原理
2.1 基础A*搜索流程回顾
先把基础A*流程写出来,后面所有改进项都建立在它的基础上。
第一步,初始化open表和closed表,将起点加入open表,设置起点的g为0、h按启发函数计算、f等于g加h。
第二步,循环判断open表是否为空。若为空,说明没有可行路径;若不为空,取出f值最小的节点作为当前扩展节点。
第三步,如果当前节点是目标点,则从当前节点沿着父指针回溯,构造路径并返回。否则把当前节点移入closed表,遍历它所有可行的邻居节点,对每个邻居计算新的g值、h值和f值,更新父指针并插入open表。
这里有几个关键点:基础A的扩展方向完全由f值决定,f值排序的公平性直接影响搜索效率;八邻域A走斜线时,代价应该是√2而不是1;h值必须满足可采纳性,否则会丢失最优性。
2.2 改进一:加权启发函数与最优性的权衡
第一个改进是给启发函数加上权重系数w,把f的计算公式改成:
f = g + w * h
当w大于1时,算法会更激进地朝着目标方向搜索。这样做的直接收益是扩展节点数大幅减少,搜索时间明显缩短。代价是理论上会失去“严格最优”保证,路径长度可能比标准A*略长一点。
但这个trade-off在AGV场景下其实非常划算。因为栅格地图本身是对连续空间的离散化近似,栅格分辨率之下建模误差始终存在,“最优”两个字没有绝对意义。路径短5%的代价是计算耗时翻倍,单AGV调度系统中这是完全不可接受的。
我实测下来,权重w取1.1到1.4之间的时候,路径长度损失极小,基本在1%以内,但扩展节点数能下降30%到50%,这个改进性价比极高。w再往上调(比如超过2.0),路径质量就会肉眼可见地变差,经常出现绕远路的现象。
2.3 改进二:双向搜索与相遇判定
第二个改进是双向A*。从起点和目标点同时开始搜索,两个方向各自维护open表和closed表,直到两个方向的前沿相遇。
双向搜索的理论收益是搜索空间从指数增长变成两棵搜索树相向而行,总体扩展节点数大幅减少。但在实际工程里,双向搜索有个容易被忽视的坑:两个方向的启发函数如果不能保证一致性,搜索效率反而会退化。
我这里的做法是双向搜索都保持相同的启发函数结构,核心改进在于相遇判定条件,不只是判断两个方向的节点是否交替扩展,而是维护一个“最优相遇代价”的变量,每次两个方向的open表都更新完后,检查当前最小的两个前沿节点之间的距离是否能更新相遇记录。这样做可以避免两个方向擦肩而过后还在继续盲目扩展。
2.4 改进三:转向代价与路径平滑
第三个改进属于应用层约束。底层搜索是走出的路径是一堆栅格中心点的连线,这个连线在八邻域地图里非常锯齿化。AGV每转一次向,实际运行中都要减速、调整、再加速,转向次数多意味着整体运行效率低。
所以改进A*的评价函数里加了一项转向代价:
f' = g + w * h + turn_penalty
当从节点A扩展到节点B,再继续从B扩展到节点C时,如果向量AB和BC的方向不一致,就在代价上加上turn_penalty。这个值不需要很大,我实验一般取0.1到0.5之间,就能非常有效地降低路径的转弯次数。
这个策略和“路径平滑后处理”是两种不同路线。平滑后处理是在搜索完成后用样条曲线或路径剪枝把折线变得圆滑,但可能产生穿越障碍物的问题,还得做碰撞检测修正。而转向代价是直接在搜索层面让算法“天生不喜欢”连续转弯,生成的路径天然更顺直。
2.5 改进四:动态步长扩展与邻域剪枝
第四个改进比较进阶,在平坦无障碍区域,AGV没必要每走一个栅格就停下来重新决策一次。我对搜索过程做了一项邻域剪枝优化:
当当前节点的父节点到当前节点的方向与当前节点到某个邻居节点的方向一致,且该方向连续多个格子都是自由空间时,允许这个方向上的扩展“跳步”,直接跳到视线可达的最远自由格点。
这个改进借鉴了跳点搜索的思路,但实现上更温和,只应用于无障碍直道场景。它显著减少了路径点数量,同时消除了大部分锯齿感。下面是改进前后的路径点数量对比:
| 地图尺寸 | 基础A*路径点数 | 改进后路径点数 | 路径长度变化 |
|---|---|---|---|
| 30x30 | 42 | 21 | -0.8% |
| 50x50 | 67 | 31 | -1.2% |
| 80x80 | 103 | 40 | -0.9% |
3. 仿真系统的具体实现与代码关键点
3.1 地图数据的0/1约定与文件加载
地图矩阵是最核心的数据结构。我用一个二维数组map存储,数值0表示自由栅格,数值1表示障碍物栅格。地图可以来自两处:手动编辑的文本文件,或者仿真界面上用鼠标动态绘制。
文本文件的格式为:
30 30 0 0 0 0 0 0 0 0 0 0 ... 0 1 1 0 0 0 0 0 0 0 ... 0 1 0 0 0 0 1 1 0 0 ... ...第一行是地图的行数和列数,后面是矩阵本体。加载函数读取后直接转成二维数组。这里的度量的单位是栅格数量,每个栅格在真实AGV场景中通常对应一个AGV车身尺寸的物理距离。
在地图上动态编辑障碍物时,我做了边界判断:新障碍物格子不能覆盖起点或目标点所在位置,也不能把整个地图围死(这属于可达性判断,后面在章节5里展开)。每修改一个格子,立即触发路径重算。
起点和目标点一般是两个格子(row_start, col_start)和(row_goal, col_goal),也可以在仿真界面上点击格子来设置。点击第一下设置起点,点击第二下设置终点,第三下重置。
3.2 启发函数与加权因子的代码实现
地图距离计算有两种选择:曼哈顿距离和欧氏距离。四邻域地图用曼哈顿距离比较合适,八邻域地图应该用切比雪夫距离或欧氏距离。
我在八邻域模式下用欧氏距离乘以一个比例系数,代码实现如下:
function h = heuristic(cur, goal, weight) dx = abs(cur(1) - goal(1)); dy = abs(cur(2) - goal(2)); h = weight * sqrt(dx^2 + dy^2); end这里weight就是加权因子w。在标准A*中weight恒为1,在改进版中weight为可调参数,默认1.2。地图上相邻格子对角线移动的真实代价是√2,所以直线移动代价为1、斜向移动代价为1.414,这比把所有方向都按1计算更贴近实际路程。
3.3 主循环与邻域扩展逻辑
改进版A*的主循环代码骨架如下:
while ~isempty(openList) [~, idx] = min(openList(:, 4)); % 按f值最小索引 current = openList(idx, :); openList(idx, :) = []; if isequal(current(1:2), goal) path = reconstructPath(parentMap, current); return; end closedList = [closedList; current(1:2)]; neighbors = getNeighbors(current, map, neighborType); for i = 1:size(neighbors, 1) neighbor = neighbors(i, :); if isClosed(neighbor, closedList) continue; end tentative_g = current(3) + ... (abs(neighbor(1)-current(1)) + abs(neighbor(2)-current(2)) == 2 ? sqrt(2) : 1); % 转向代价 if size(current, 2) >= 5 && current(5) ~= 0 prev_dir = current(5); cur_dir = computeDirection(current(1:2), neighbor(1:2)); if prev_dir ~= cur_dir tentative_g = tentative_g + turnPenalty; end end if isInOpen(neighbor, openList) old_g = getGFromOpen(neighbor, openList); if tentative_g < old_g updateOpen(openList, neighbor, tentative_g, heuristic(neighbor, goal, weight)); parentMap(neighbor(1), neighbor(2)) = current(1:2); end else openList = [openList; neighbor(1), neighbor(2), tentative_g, ... tentative_g + heuristic(neighbor, goal, weight), computeDirection(current(1:2), neighbor(1:2))]; parentMap(neighbor(1), neighbor(2)) = current(1:2); end end end这段代码里有几个细节需要注意。closedList的判重用ismember函数时,如果列表很大,效率会下降。我优化成了二维逻辑矩阵closedFlag,大小和地图一致,每次标记closedFlag(row, col) = true,查询就是O(1)。地图80x80以上时性能差异很明显。
第二个细节是openList用普通矩阵存储时,每次取最小值都是O(N)遍历。这个项目规模下没关系,但如果地图到200x200以上,建议换成二叉堆或优先队列。我在实际工程里是用MATLAB实现二叉堆的,openList操作效率提升了不止一个量级。
第三个细节是转向代价的记录方式。我把当前方向(八邻域的一个代表方向,共8个方向,编号1到8)存在节点的第5列,每次扩展新邻居时用父节点方向和当前方向比较。如果两个方向不一致,说明这个节点引入了转向代价。这个实现不需要额外查表,直接存在队列里就行。
3.4 动态跳步的邻域剪枝实现
跳步优化的核心代码是沿着某个方向一直往前检查,直到碰到障碍物或地图边界:
function farthest = jumpAlong(map, x, y, dx, dy) nx = x + dx; ny = y + dy; while nx >= 1 && nx <= rows && ny >= 1 && ny <= cols && map(nx, ny) == 0 x = nx; y = ny; nx = nx + dx; ny = ny + dy; end farthest = [x, y]; end这里要注意一个前提条件:只有在当前方向与父节点方向一致时才能跳步。如果方向变了,比如从向上走变成向右上方走,跳步检查就得重新开始,否则容易跳过关键的拐弯格子——那些格子往往是路径质量的关键点。我一开始没做这个限制,结果在某些地图上路径会贴着障碍物的锐角边缘穿过,视觉上很危险,位置偏移甚至可能导致实际碰撞。加上方向一致性判断之后,这个问题就消失了。
3.5 可视化与交互模块
可视化这一块用的是最直观的二维网格绘图。每个格子的颜色按下面这个规则映射:
- 0 自由栅格:浅灰色
- 1 障碍物栅格:深灰色
- 起点格子:绿色
- 目标点格子:红色
- 路径经过格子:蓝色
鼠标交互部分,我用窗口的鼠标点击回调函数来判断用户点击了哪个格子。核心逻辑是点击回调里获取当前的鼠标位置,换算成栅格坐标,然后判断当前处于“起点设置模式”“目标点设置模式”还是“障碍物绘制模式”。障碍物绘制支持按住鼠标左键连续拖拽,体验上像是用画笔在涂色。给它加一个橡皮擦模式,按右键就是清除某格的障碍物。
用这种交互方式,一个复杂的障碍物地图几乎一分钟就能画出来。我测试的时候经常拿手指在触屏上画几道墙,然后立刻跑A*,看看路径是不是会绕过去。这种即时反馈让算法调试效率非常高——我看到路径不对劲后,能马上在图上增加几块障碍物,重新验证算法在“近似环境下的表现”。
3.6 运行统计与指标输出
每次搜索完成后,仿真界面会同步显示以下核心指标:
| 指标名称 | 说明 |
|---|---|
| 路径长度 | 从起点到目标点的累计栅格距离,斜线按√2折算 |
| 扩展节点数 | 搜索过程中被扩展过的节点总数,反映空间搜索效率 |
| 运行耗时 | 从调用搜索函数到返回路径的总耗时,单位毫秒 |
| 路径点数 | 最终路径经过的栅格节点数量 |
| 转向次数 | 路径中方向发生变化的次数,反映AGV实际运行平滑性 |
这些指标从一组对照实验里能看到改进项各自的贡献。统计不是为了给论文加数据,而是在调试时帮我判断“某一项改进到底有没有生效”。有一次我把转向代价的数值调得过大,路径确实不转弯了,但绕路严重,路径长度暴涨了三成多。如果不看路径长度指标,光看转向次数还以为优化得很好。这就是量化统计在算法调试中的价值。
4. 参数调试与结果对比实录
4.1 一组典型的对照实验与分析
我在一个经典的地图上做了三组实验,地图大小72x56,起点在左上角附近,目标点在右下角附近,地图中间有几道片状障碍物。以下是对比结果:
| 实验配置 | 路径长度 | 扩展节点数 | 运行耗时(ms) | 转向次数 |
|---|---|---|---|---|
| 基础A*(八邻域,w=1.0) | 83.8 | 1543 | 68 | 19 |
| 加权A*(w=1.3) | 84.1 | 982 | 41 | 17 |
| 加权+双向搜索 | 84.2 | 640 | 26 | 17 |
| 加权+双向+转向代价 | 86.5 | 621 | 25 | 9 |
| 加权+双向+转向+跳步剪枝 | 86.1 | 388 | 15 | 7 |
从这组数据能看出几个关键规律。
第一,加权启发函数对扩展节点数和耗时的优化非常直接。w从1.0提升到1.3,扩展节点数下降了36%,耗时下降了40%,路径长度只增加了0.3%。对纯搜索效率来说,这是最划算的改动。
第二,双向搜索进一步压缩了扩展空间,但收敛效果受启发函数质量影响。如果启发函数权重不一致,会出问题,这个我在第5章里详细展开。
第三,转向代价的加入很“神奇”,路径长度增加了差不多3个栅格距离,但转向次数从17次降到了9次。对AGV实际运行来说,节省了多次减速原地转向的时间,运行效率反而更高。
第四,跳步剪枝把整个搜索的节点扩展压到了388个,和基础知识搜索比降了四分之三以上。路径点数也变少了,后续做路径跟踪的时候,计算负担轻很多。
4.2 参数敏感性分析
参数不是越大越好,得有个度。我针对启发权重w做了细致扫描,结果如下:
| 权重w | 扩展节点数 | 路径长度 | 备注 |
|---|---|---|---|
| 1.0 | 1543 | 83.8 | 标准A* |
| 1.1 | 1201 | 84.0 | 路径仍旧接近最优 |
| 1.3 | 982 | 84.1 | 性能明显提升 |
| 1.6 | 870 | 84.9 | 路径变差但还能接受 |
| 2.0 | 754 | 88.7 | 路径绕弯明显 |
| 3.0 | 610 | 97.2 | 已经严重绕远 |
w在1.1到1.3之间是甜点区,代价小、收益大。超过2.0之后,虽然搜索节点更少,但路径长度开始失控,AGV跑起来浪费的时间远远超过了搜索节省的时间,得不偿失。
转向代价turnPenalty的敏感性和w类似。我试验了以下取值:
| turnPenalty | 转向次数 | 路径长度 |
|---|---|---|
| 0(关闭) | 19 | 83.8 |
| 0.1 | 15 | 84.0 |
| 0.3 | 10 | 84.5 |
| 0.5 | 8 | 85.2 |
| 1.0 | 4 | 90.6 |
turnPenalty超过0.5之后,路径为了逃避转向惩罚愿意付出巨大的绕路代价,结果是虽然不怎么转弯,但路径整体迂回,根本不是最优路线了。我在小车模型仿真里验证过,AGV过一个90度弯的时间损失折算成直线距离大概是0.2到0.4个栅格长度,所以turnPenalty取0.3左右,和实际损耗最匹配。
5. 常见问题与调试心得
5.1 起点或目标点设在障碍物格子里面
这是最基础的问题,但很多人会踩。我最早写仿真时没有做前置校验,用户在地图上点击了一个障碍物格子作为起点,结果A*搜索直接从障碍物里面开始扩张,路径一团糟。
后来加了一个前置校验函数,每次设置起点、目标点或修改障碍物后,立刻检查:
if map(start(1), start(2)) == 1 errordlg('起点不能设在障碍物上'); return; end if map(goal(1), goal(2)) == 1 errordlg('目标点不能设在障碍物上'); return; end这个问题虽然简单,但直接影响整个系统的可用性,交互反馈必须在点击的瞬间弹出提示对话框,而不是等到搜索失败后才报错。
5.2 起点到目标点不可达的判定与提示
另一种常见情况是地图被障碍物分割成几个互不相通的区域,起点和目标点不在同一个连通区域里。标准A*在这种场景下会一直扩展直到open表耗尽,地图越大等待越久,最后返回一条空路径。
我做的处理是搜索启动前先做一次快速的连通性预检:从起点做一次广度优先遍历,看目标点能否被访问到。如果不可达,立刻弹出消息“当前地图下起点到目标点不可达”,不进入A主循环。BFS预检的时间和A相比几乎可以忽略,但避免了最糟糕情况的长时间空转。
在交互编辑地图的过程中,这个预检还有一个作用:当某个格子被设为障碍物后,如果破坏连通性,系统会提醒用户这一步会导致路径不可达,然后让用户自行决定是撤销操作还是保留。这样用户不会在不知不觉中画出一个无解的地图。
5.3 双向搜索方向不一致导致搜索效率退化
我在前面的改进中提到双向搜索可能遇到滑过的现象。具体表现是:正向搜索爬到地图右上角,反向搜索跑到地图左下角,两个方向的前沿不断擦肩而过,但始终不满足相遇条件,整个搜索变成两个标准A*在各自跑,扩展节点总数不降反增。
解决方法是加入“最优相遇代价”的判断:两个方向扩展过程中,只要发现某个节点同时被两个方向的closedList记录,就立即计算这条路径的总代价(正向g + 反向g),并记录全局最小相遇代价。每次扩展新节点前,先检查当前的f值是否已经大于已知最优相遇代价。如果是,则提前终止搜索,返回已知最优路径。这个机制保证双向搜索的收敛速度非常稳健。
5.4 跳步扩展导致路径穿墙
这是一个比较隐蔽的bug。跳步扩展时如果只检查中间直线路径上的格子是否是自由空间,而不考虑AGV本身的车体宽度,可能会出现路径穿过两个障碍物之间狭小通道的情况。路径在栅格几何上通过了,但实际AGV过不去。
解决方式有两个层面:地图层面,在所有障碍物周围做一个“膨胀层”,也就是把距离障碍物边缘一个车体半径的格子全部标记为障碍物;算法层面,在跳步前检查路径是否贴近障碍物,如果路径两侧任一栅格是障碍物,就限制跳步长度。我默认同时启用这两种策略,效果最稳。
膨胀层的实现逻辑非常直接:
function inflatedMap = inflateMap(map, radius) [rows, cols] = size(map); inflatedMap = map; [obsRows, obsCols] = find(map == 1); for k = 1:length(obsRows) r = obsRows(k); c = obsCols(k); for dx = -radius:radius for dy = -radius:radius nr = r + dx; nc = c + dy; if nr >= 1 && nr <= rows && nc >= 1 && nc <= cols && ... sqrt(dx^2 + dy^2) <= radius inflatedMap(nr, nc) = 1; end end end end end膨胀半径一般取1到2个栅格。半径1就是把障碍物周围8个格子全标记成不可通过,对AGV来说是底线设置;半径2适用于车体较大的场景。
5.5 调试心得:单步跟踪与搜索过程可视化
最后分享一个调试心得。在算法跑不通的时候,看最终路径往往不够用,因为路径正确但扩展过程异常的情况很难从结果观察出来。我写了一个“单步跟踪模式”:每次只扩展一个节点,同时在图上画出open表中前10个节点的f值热力图。这样能直观看到搜索前沿是如何推进的,也能看出启发函数权重设置得是否合理。
有一次我在调试中看到,某个方向上open表不断堆积但f值一直很高,导致搜索老是往目标方向扩展,结果撞到一面墙后在墙根反复横跳。这就是启发函数权重过高、导致方向贪心过度的典型症状。单步跟踪加上f值热力图,一眼就能看穿问题所在。
另外,建议在仿真系统里保留每次搜索的详细日志,包括每个被扩展节点的坐标、g值、h值、f值、父节点方向。日志写进文件后,可以用脚本来分析扩展趋势,找出算法性能瓶颈。我在调双向搜索相遇条件时,就是靠分析日志发现两个方向前沿同步性差,才想到了维护全局最优相遇代价的方案。
6. 这套仿真平台后续还能怎么扩展
最后聊一点个人体会。这个仿真做完之后,单AGV静态地图路径规划的基本框架已经相当顺手。实际工作中最常碰到的后续需求是两张:一张是静态地图变成动态地图,引入移动障碍物或临时封路;另一张是单AGV扩展成多AGV协同调度,这时就要考虑各路线的时空冲突,A*改进点会从评价函数设计转向交通规则和优先级调度策略。
动态环境我建议在现有架构上增加一个“动态障碍物图层”,所有场景状态都缓存两份:一份是静态地图原始数据,一份是当前时间戳下的动态占用状态。A*搜索时读的是合并后的地图,碰撞检测也基于合并后的地图做。这样静态路径搜索的代码几乎不用动,就能扩展到动态场景。
多AGV的方向则更偏系统层面。单AGV的路径是“从A到B最好的路”,多AGV关心的是“多辆车同时跑,每一条路都要在时间轴上不相撞”。这个项目现在具备的地图编辑、参数配置、路径可视化基础,恰好就是搭建多AGV仿真平台的脚手架。后续如果要走这条路,建议先把地图和路径的数据结构规范化,彻底分离“地图数据、算法引擎、调度逻辑”三层的职责,这样扩展起来才不会越改越乱。
做算法仿真的事,最忌讳的就是把仿真做成一个“能跑就行”的玩具。回头看这个项目,真正花时间的不是A*本身,而是把交互、参数、统计、可视化都做到可复现、可解释。你改颜色,改参数,改地图,端到端地看到路径和指标随之变化,算法里的每一个细节才真正内化成你的直觉。这套方式,我建议所有做路径规划相关工作的朋友都值得搭一套。