这几年在给园区、机房、生产线做可视化大屏的时候,我发现一个特别普遍的诉求:大屏上除了要有跳动的业务数据,往往还得有现场实时画面。比如监控机房设备运行状态、看车间流水线、看无人配送车的位置。业务数据用 WebSocket 推,画面想做到实时,不少人第一反应是拉一路 RTSP 流过来,或者用 HLS 推流,结果延迟感人——画面经常比现实晚了三五秒,甚至十几秒。调度员盯着屏幕喊“停”,现场设备其实早过去了,根本没法精准联动。
后来我把整个方案拆开重新做了一遍,用 WebRTC 接视频流、WebSocket 驱动状态数据,两块各自封装好了再接到大屏可视化框架里。做完之后延迟基本压在 300ms 到 800ms 这个范围,现场画面和业务数据能做到同屏联动,体验和之前完全是两回事。这篇就从头到脚讲讲我这套方案的设计思路、核心封装细节、以及一堆实战中踩出来的坑,给正准备搞类似系统的朋友做个参考。
1. 内容整体设计与思路拆解
1.1 为什么大屏场景必须用 WebRTC
先说视频这块。传统的大屏视频方案大概有这么几条路:RTSP 直接解码播放、HLS 切片直播、HTTP-FLV、以及基于 UDP 的私有协议。
RTSP 想要低延迟,需要在网页端用 ActiveX 插件或者做协议转换,ActiveX 已经基本退出历史舞台,协议转换通常又得引入一层媒体服务器转发,架构变复杂。HLS 的延迟瓶颈在于切片机制,就算走 LL-HLS,切片间隔和播放器缓冲叠下来,做到三秒以内已经很吃力。HTTP-FLV 在 PC 浏览器上有比较成熟的方案,延迟能做到一两秒,但它走 TCP,一旦网络抖动,队头阻塞导致画面卡顿是常事,而且移动端支持不太乐观。
WebRTC 不一样。它天生就是为实时通信设计的,走的是 UDP 或基于 UDP 的 SRTP,传输层天然绕开了 TCP 对实时音视频的延迟惩罚。更重要的是,WebRTC 自带一套完整的自适应码率、丢包重传、抖动缓冲、音视频同步机制,这些能力如果全部自己用 Socket 从零写,工作量相当可观。
大屏场景对视频有两个硬指标:一是延迟要低,二是画面要稳定。低延迟靠 WebRTC 的传输机制保证,稳定性靠它的拥塞控制和自适应码率策略。实话说,当初我把几路 RTSP 接到大屏上做实验,画面一卡一卡的,后来全部换成 WebRTC 接入,问题一次解决。
1.2 状态驱动:数据不只是展示,还要反哺交互
再说数据这块。传统大屏的数据交互模式大多是“请求-响应”或者“定时轮询”,大屏打开一个定时器,每几秒去后端拉一次数据,然后塞到图表里。当业务状态复杂起来——比如一组设备有好几个状态维度:在线离线、运行模式、告警等级、实时功率——这种模式很容易出现数据断层。图表上一个状态已经把页面画成红色了,但设备对象在下一次轮询到来之前,内部结构还是上一刻的绿色状态,画面和真实世界始终差着半步。
所以我在这套方案里引入了“状态驱动”的思路,状态驱动不是简单地把 WebSocket 收来的数据丢给前端组件去渲染,而是从顶层设计上维护一份统一的实时状态仓库,把收到的数据先写到状态仓库,再由状态仓库去通知所有依赖它的可视化组件更新。
换个角度理解:视频流是“同步真实世界画面”,数据流是“同步业务状态”。二者在大屏上叠加,才能做到看到画面同时看到状态,而且状态和画面之间的时间误差控制在可接受范围内。这套模型的出发点,本质上是在解决“真实世界 —— 数据采集 —— 网络传输 —— 前端渲染”整条链路的信息一致性问题。
用状态驱动还有一个好处:组件解耦。业务组件不需要关心数据从哪来、多久来一次,它只需要订阅自己关心的状态切片,状态变了,组件自己更新。数据接入、数据清洗、业务展示三层互不干扰,后续要加一个新的数据源,或者更换后端的推送协议,前端主体结构完全不用动。
2. 核心细节解析与实操要点
2.1 WebRTC 播放链路的完整拆解
WebRTC 在浏览器里的角色主要是接收端和渲染端,真正负责推流、转流、信令交换的是服务端。大屏项目里最常见的架构是:摄像头通过 RTSP 接入流媒体服务,由服务端把 RTSP 流转成 WebRTC 的 SDP 会话,前端页面拿到 SDP 再建立 PeerConnection。
完整的播放链路长这样:
- 摄像头产生原始视频流,通过 RTSP 协议送到媒体服务。
- 媒体服务解析 RTSP 流,通过 WebRTC 网关对外发布。
- 前端通过信令通道拿到网关返回的 SDP Offer/Answer。
- 前端创建
RTCPeerConnection,设置远端描述,添加Transceiver。 - 媒体流通过 ICE 协商完成后开始传输。
- 前端拿到
MediaStream,绑定到<video>标签的srcObject上。
这里有一个容易被忽视的点:RTCPeerConnection的创建时机。如果在前端页面初始化时就一并创建,但此刻用户还没有点击某个摄像头,连接就会空悬着,白白占用资源。更合理的做法是按需创建——用户切换查看某一路画面时,再动态建立连接,关闭时回收。
另外一个实用技巧:Transceiver的方向要设成recvonly。很多新手在接入播放流时会忽略这个参数,导致协商时以为自己要发流,音频轨视频轨的 SDP 信息对不上,播放黑屏或者只有声音没有画面。recvonly明确告诉对端“我只收不发”,协商过程更干净。
const pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:your-stun-server' }] }); pc.addTransceiver('video', { direction: 'recvonly' }); pc.addTransceiver('audio', { direction: 'recvonly' });2.2 WebSocket 状态通道的设计要点
WebSocket 通道的封装实际上面临几个典型的工程问题:连接生命周期管理、断线重连、消息协议设计、消息总线的分发效率。
连接生命周期方面,最简单也最实用的方案是做一个单例的 WebSocket 管理器。整个应用只建立一条通道,所有需要实时数据的模块共享这一条连接,而不是每个组件单独去开一条。这样可以避免多个连接造成服务端资源浪费,也能从根本上避免多个 WebSocket 同时断线重连时产生“重连风暴”。
协议设计方面,我建议至少在消息体里包含这几个字段:type表示消息类型、channel表示订阅的数据通道、data是真正的业务负载。这样一个连接可以承载多种类型的数据推送,前端可以通过channel做精确分发。
{ "type": "state", "channel": "device-pump-001", "data": { "status": "running", "temperature": 52.6, "power": 1180 } }断线重连的逻辑里,最核心的是“指数退避 + 最大重试次数”。直接写一个setInterval每隔几秒 reconnect 一次,一旦服务端重启,客户端会在同一时间发起大量连接请求,服务端可能直接被冲垮。正确做法是第一次重连等 1 秒,第二次 2 秒,第三次 4 秒,到达上限(比如 30 秒)后维持这个频率。
2.3 状态仓库与可视化组件的联动机制
状态仓库的实现不一定要依赖重量级框架,一个轻量级的事件发布订阅模型就够了。核心思路是:状态数据统一收口到一个 Store 对象里,组件通过subscribe(key, callback)注册自己关心的状态片段,Store 内部在数据变更时精确派发对应的事件。
class StateStore { private states: Map<string, any> = new Map(); private watchers: Map<string, Set<Function>> = new Map(); setState(key: string, value: any) { const changedKey = this.compareAndUpdate(key, value); if (changedKey) { this.emit(changedKey, value); } } compareAndUpdate(key: string, value: any): string | null { const oldValue = this.states.get(key); // 浅比较,实际场景可以按需做深比较 if (JSON.stringify(oldValue) !== JSON.stringify(value)) { this.states.set(key, value); return key; } return null; } emit(key: string, value: any) { const watchers = this.watchers.get(key); if (watchers) { watchers.forEach((callback) => callback(value)); } } subscribe(key: string, callback: Function) { if (!this.watchers.has(key)) { this.watchers.set(key, new Set()); } this.watchers.get(key).add(callback); } }为什么做compareAndUpdate这一步很关键。WebSocket 推送的原始数据经过序列化和网络传输后,前端接收到的可能和上一次完全相同(比如设备状态没变化,但服务端为了保活周期性推送了同一份快照)。如果不去重就直接触发视图更新,一个高频周期推送就能让大屏上的所有组件频繁重渲染,性能开销呈指数增长。这一步相当于给状态流加了一道闸门,状态没变,视图不动。
大屏可视化组件那边,只需要在初始化时订阅自己关心的状态 key,收到变更后直接更新对应图表的setOption或者 DOM 节点样式即可。
3. 实操过程与核心环节实现
3.1 WebRTC 视频流组件的高度封装
实战里我不能每次切换画面都把 PeerConnection 的完整逻辑写一遍,所以我把 WebRTC 播放能力封装成一个可复用的组件,对外只暴露src、autoplay、muted这几个业务属性,底层细节全部隐藏。
封装时有几个关键细节值得展开:
第一个是 SDP 协商完成之后,要把ontrack事件里拿到的流绑定到远离真实 DOM 的MediaStream对象上。如果直接绑定到一个临时变量,React 或 Vue 在重渲染时可能因为流对象的引用变化导致 video 元素黑屏。稳妥的做法是维护一个字符串 ID 到MediaStream的映射表。
第二个是connectionState和iceConnectionState的监听。连接被服务端断开、网络切换、摄像头离线都会触发状态变化,组件上要暴露onStateChange回调,让外层大屏可以通过 UI 变化提示用户“视频通道已断开”。
第三个是自动播放策略。浏览器的自动播放限制是绕不开的:带声音的视频不能自动播放,只能用户主动点击后才能发声。大屏场景通常不需要声音,所以组件默认muted必须设置为true,这样才能在页面刷新后立即自动拉流播放。
class RTCVideoPlayer { private pc: RTCPeerConnection | null = null; private streamMap: Map<string, MediaStream> = new Map(); async play(videoElement: HTMLVideoElement, streamUrl: string) { this.dispose(); this.pc = new RTCPeerConnection({ iceServers: this.config.iceServers }); this.pc.addTransceiver('video', { direction: 'recvonly' }); if (this.config.enableAudio) { this.pc.addTransceiver('audio', { direction: 'recvonly' }); } else { // 禁用音频轨,避免获得权限弹窗 } this.pc.ontrack = (event) => { const stream = event.streams[0]; if (stream) { videoElement.srcObject = stream; videoElement.muted = true; videoElement.autoplay = true; videoElement.playsInline = true; } }; // 信令交互:把本地 offer 发送给服务端,拿到 answer const offer = await this.pc.createOffer(); await this.pc.setLocalDescription(offer); const answer = await this.signalChannel.request({ type: 'webrtc-sdp', sdp: this.pc.localDescription, streamUrl }); await this.pc.setRemoteDescription(answer); } dispose() { if (this.pc) { this.pc.getTransceivers().forEach((t) => { t.stop(); }); this.pc.close(); this.pc = null; } } }3.2 WebSocket 状态驱动的完整接入流程
状态驱动的接入流程主要分三步:建立连接、接收数据、写入仓库。
建立连接这一步,我把它放在一个共享的StatusClient单例里。单例的好处很明显:页面有多个图表组件、多个告警弹窗、多个设备列表,它们不需要各自维护连接。唯一要注意的是,单例的connect方法必须是幂等的。
class StatusClient { private static instance: StatusClient | null = null; private ws: WebSocket | null = null; private store: StateStore = new StateStore(); private reconnectAttempts = 0; private heartbeatTimer: number | null = null; static getInstance() { if (!StatusClient.instance) { StatusClient.instance = new StatusClient(); } return StatusClient.instance; } connect(url: string) { if (this.ws && this.ws.readyState === WebSocket.OPEN) { return; } this.ws = new WebSocket(url); this.ws.onmessage = (event) => { const message = JSON.parse(event.data); // 根据消息类型做分发 this.handleMessage(message); }; this.ws.onclose = () => { this.handleReconnect(); }; this.startHeartbeat(); } handleMessage(message: any) { if (message.type === 'state') { this.store.setState(message.channel, message.data); } // 其他类型消息统一走事件总线 } handleReconnect() { const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000); this.reconnectAttempts++; setTimeout(() => { this.connect(this.url); }, delay); } startHeartbeat() { this.heartbeatTimer = window.setInterval(() => { if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify({ type: 'ping' })); } }, 15000); } }这里有个很实际的坑:WebSocket 断线不一定会触发onclose,比如网络突然断掉,客户端可能还认为连接是健康的。光靠onclose做重连是不够的。我加了一个心跳机制,每 15 秒发送一次 ping,如果超过 45 秒没有收到 pong,就主动关闭当前 WebSocket 并触发重连逻辑。
接收数据到写入仓库之间,还要做一层数据格式的规整。服务端推送的数据往往带着各种冗余字段,比如原始字符串、时间戳精度不一致、单位不统一。这些如果在各个组件内部各自处理,很容易出现一份数据两个组件解析结果不一致的情况。我在handleMessage里统一调用了normalizeData方法,把关键字段的类型、单位、格式全部清洗一遍,之后才写入 Store。这样下游组件拿到的永远是一致的标准结构。
3.3 大屏渲染层的性能和联动策略
大屏可视化通常包含地图、3D 场景、折线图、柱状图、仪表盘、告警列表这些组件,种类多、数量大。如果每来一条 WebSocket 消息就同步触发渲染,页面会被高频重绘拖垮。我实践下来的策略是:状态仓库更新是同步的,视图渲染是异步合并的。
具体做法是:Store 的数据变更先进入一个变更队列,然后在requestAnimationFrame回调里统一处理这一帧内累积的所有变更。举个例子:一秒钟内服务端推送了 30 条设备温度数据,旧方案可能导致 30 次图表更新;合并策略下,这 30 条数据在同一帧内统一写入图表配置,最终只触发一次重绘。视觉上完全感觉不到区别——帧率有上限,但数据完整性不受影响。
const pendingChanges: Array<{ key: string; value: any }> = []; function scheduleRender() { if (pendingChanges.length === 0) return; const frameChanges = pendingChanges.splice(0, pendingChanges.length); for (const change of frameChanges) { // 根据 key 分发到具体图表组件的更新方法 dispatchToComponent(change.key, change.value); } requestAnimationFrame(scheduleRender); }渲染联动层面还有一个细节:图表组件在收到数据后,是否能正确判断自己是否需要重绘。举个例子,一个只展示“高/中/低”安全等级的状态灯条,如果推送来的数据经过规整后仍然是“中”,它就不需要重新绘制。这个判断逻辑放在组件内部的shouldUpdate方法里,由组件自己决策。整个系统的更新粒度越小,渲染开销越低,这是大屏项目性能优化里性价比最高的一招。
3.4 参数选择与配置参考
这里给出一份我实际项目中用过的关键参数参考,可以直接抄作业:
| 参数项 | 推荐值 | 说明 |
|---|---|---|
| WebRTC ICE 服务 | 自建 STUN + TURN | 生产环境强烈建议自建,公共 STUN 在高峰期不稳定 |
| 视频编码 | H.264(硬编) | 兼容性最好,软编在高分辨率场景容易吃满 CPU |
| 码率上限 | 2~4 Mbps(1080p) | 大屏一般投到 1080p,再高意义不大,带宽和 CPU 成本却翻倍 |
| WebSocket 心跳间隔 | 15 秒 | 太勤浪费流量,太疏无法及时发现死链 |
| 断线重连退避 | 1s / 2s / 4s / 8s / 16s / 30s | 指数退避封顶 30 秒 |
| 渲染合并帧 | requestAnimationFrame | 60fps 上限,自然合并一帧内所有变更 |
| 状态去重策略 | JSON 序列化浅比较 | 数据量不大时性价比极高 |
4. 常见问题与排查技巧实录
4.1 WebRTC 播放黑屏与卡顿排查
黑屏是大屏接入 WebRTC 最头大的问题,通常不是某个单一原因,而是一连串环节中的某个点断了。我把这些年的排查经验整理成了一张速查表:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 黑屏且 console 无报错 | 自动播放策略限制 | 确认 video 标签 muted 是否为 true |
| 黑屏但有声音 | 视频轨协商失败 | 检查 SDP 里是否包含 video m-line |
| 黑屏且 ICE 状态卡在 checking | TURN 配置缺失 | 检查 ICE 候选是否成功配对 |
| 画面卡顿、花屏 | 网络带宽不足 | 启动码率自适应,检查客户端下行带宽 |
| 首帧延迟过高 | 服务端 GOP 设置过大 | 把关键帧间隔调到 1~2 秒 |
| 画面一直模糊 | 码率被瓶颈限制 | 检查 DEgradationPreference 策略是否被设置成仅保帧率 |
实际案例一:生产环境所有视频黑屏,但开发环境正常。
当时排查了半天,最后发现问题出在服务端 WebRTC 网关没有正确配置 TURN 服务器。开发环境的客户端和服务端在同一个局域网,走的是内网 host 候选,能建立连接。生产环境客户端在公网,内网候选全失败,又没有 TURN 做中继,ICE 连接一直建立不起来。加了一组自建 TURN 服务后问题解决。
实际案例二:某些摄像头画面颜色发绿。
这个比较少见,最后定位是摄像头输出的色彩格式是如 H.264 编码时必须转成 YUV420,但某型号设备的固件在转码时色彩矩阵标错了。这种问题前端很难干预,只能协调摄像头端厂商更新固件,或者让流媒体网关强制转换色彩空间。
4.2 WebSocket 状态驱动的常见问题
WebSocket 状态驱动这块的实战问题,比视频流更隐蔽,因为往往是逻辑层的 bug 而不是连接层的故障。
问题一:状态仓库数据更新了,部分组件没有刷新。
排查下来发现是组件的订阅 key 写错了。比如服务端推送的通道名是device-pump-001,组件订阅的却是device-pump-1。前端做了数据规整时把 001 转成了 1,但仓库里的 key 用的还是原始值——两套命名规则不一致,所以订阅失效。解决方法是:统一在前端入口处做一层 key 的映射规整,之后所有组件一律使用规整后的 key。
问题二:WebSocket 一直自动重连,服务端被刷爆。
经典的重连风暴。多组件各自持有一条 WebSocket 连接,服务端一断,所有连接同时触发重连。后来我改成单例连接之后,这个问题从根源上解决了。另外重连退避逻辑也一定要加,否则服务端恢复时所有客户端同一时间涌入,连接数瞬间拉满。
问题三:推送频率很高时,页面图表轨迹出现毛刺。
一开始我以为是数据本身的问题,把原始数据打出来看,发现服务端推送的数据在时间戳上并不是等间距的,有时 500ms 一条,有时突然 2 秒一条。图表组件按时间轴连续插点,两秒间隔处自然出现“跳电线”一样的毛刺。后来我在组件里加了插值平滑策略,对缺失的时间点做线性插值,毛刺问题才解决。
问题四:页面切到后台再回来,数据长时间空白。
浏览器对后台标签页的定时器有节流机制,最小间隔可能放大到 1 分钟以上,这也直接导致重连、心跳这些逻辑被冻结。恢复前台后,WebSocket 可能已经断了,但onclose回调因为定时器冻结而迟迟没触发。我后来在visibilitychange事件里做了显式的连接状态检查和主动重连,这个问题才算彻底解决。
4.3 大屏整体运行的稳定性保障
除了视频和数据的单项问题,大屏系统作为一个整体,还要考虑长时间运行的稳定性。
内存泄漏是大屏项目的头号杀手。一个 7x24 小时运行的大屏,如果存在内存泄漏,运行几天后页面就会卡成幻灯片。WebRTC 播放器的dispose方法里一定要把MediaStream里的轨道全部 stop 掉:
dispose() { if (this.stream) { this.stream.getTracks().forEach((track) => track.stop()); this.stream = null; } if (this.pc) { this.pc.close(); this.pc = null; } }同理,图表组件在销毁时必须调用dispose(),把内部的定时器、事件监听器全部清理干净。
还有一个容易被忽略的地方:长时间运行后,WebSocket 接收到的消息里如果带有不断增长的数组——比如历史数据累积——内存占用会线性增长。这种数据应该做滑动窗口截断,只保留最近 N 条。我在全局的数据规整层统一限制了数组长度,超过 500 条的自动丢弃旧数据,内存表现稳定了很多。
5. 工具选型与环境准备
5.1 媒体服务端的选型与实践
WebRTC 播放离不开服务端网关,目前主流开源方案有中间件类型、流媒体平台类型和商业化产品类型。
中间件类型适合已有流媒体服务、希望增量接入 WebRTC 的场景,可以复用现有音视频处理管线。流媒体平台类型功能更全,集中管理、鉴权、转码、录制都有,适合从零搭建且需要长期演进的大屏系统。商业化产品则胜在开箱即用、支持完善,适合团队人力紧张、追求快速上线的项目。
我个人的建议是:小团队、原型验证阶段用一个轻量的开源媒体服务就够了,但正式上线前一定要把鉴权、时移、录制这些能力提前规划进去,否则后续从开源组件迁移到商业平台时,前端封装的切换成本也不低。
选择时重点考察三个指标:一是并发路数在多大时 CPU 占用还保持合理;二是弱网环境下能否启用 FEC 或者 NACK 重传策略;三是 WebRTC 网关和现有信令服务能否无缝对接。
5.2 前端工程化准备
这套方案对前端工程化的要求其实不低。WebRTC 相关的类型定义、WebSocket 封装、状态仓库、可视化组件四层结构,如果不用 TypeScript 约束接口,多人协作时很容易出现字段命名不一致、类型对不上的情况。我建议至少对消息协议和状态仓库接口定义完整的类型声明。
大屏可视化的渲染层选型上,要看实际需求:纯图表为主的情况,直接用成熟的图表库就能覆盖;有地图场景的,选带地图组件的方案;有 3D 场景的,需要引入 WebGL 渲染管线。我的建议是“优先聚焦业务度量,再考虑表现层炫技”,一个表格能表达清楚的设备状态,没必要强行上 3D 模型。
6. 架构演进与扩展思路
6.1 从单屏到多屏协同的扩展
这套系统最初只服务一块大屏,但状态仓库这种中心化设计天然支持多屏扩展。后端推送一份数据,前端可以起多个大屏实例,各自订阅自己关心的状态切片。比如总控大屏关注整个园区的态势,分控大屏只关注某一片区域的设备状态,互不干扰。WebRTC 视频流也可以按需拉取,分控大屏只拉自己管辖区域的摄像头画面,带宽开销更可控。
从单屏扩展到多屏,需要额外考虑的是前端构建时的路由拆分和环境配置。不同大屏的显示分辨率可能不同,图表组件的自适应布局要在架构层面预留。
6.2 数据回放与历史趋势分析
大屏系统运行几个月后,运维人员一定会提需求:能不能回看某个时间点的现场画面和业务数据?这时候需要把 WebRTC 视频流落地录制,同时把 WebSocket 推送的状态数据按时间戳存库。回放时视频按时间轴拖动,数据按同样的时间轴重建状态仓库,把“实时大屏”变成“历史大屏”。这个能力在前端不需要大幅改动架构,只需要在状态仓库旁边加一个“回放模式”,把数据源从 WebSocket 切换为历史数据文件。
6.3 AI 分析能力的叠加
这点算是我个人的延伸思考。现有大屏只解决“看到现场”和“看到数据”,但真正能给业务带来增量价值的是“看懂现场”。比如在视频流侧叠加 AI 检测能力,识别出人员入侵、设备异常、安全帽佩戴情况,然后把检测结果通过 WebSocket 推送到前端,状态仓库里新增一个alert切片,大屏上自动弹出告警卡片。整套机制和现有架构是完全兼容的——视频流负责提供视觉依据,状态驱动层负责把检测结果转化为视觉反馈,各司其职。
这个架构在设计时就已经把“AI 分析结果作为一种特殊状态数据”考虑进去,因此后续接入并不会造成额外的重构成本。
最后说一点个人体会。做这类大屏项目,最大的坑往往不是某个具体技术不会用,而是“视频流”和“数据流”两拨人、两套思维在碰撞。做视频的同事关注的是 SDP、ICE、编解码这些底层细节,做可视化的同事关心的是组件生命周期、渲染性能、交互体验。这两件事如果各做各的,最后接起来一定会有缝隙。我花了很多精力把前后端的协议统一成一个清晰的规范,把 WebRTC 和 WebSocket 两套通道的接入方式都封装成业务友好的接口,项目推进效率才真正上来。这套架构不是什么高深的技术,但胜在把复杂的东西收敛好,让上层业务只需要关心“展示什么”,不用关心“怎么传过来的”。希望这篇整理对正在做类似系统的朋友有用。