大屏可视化动图渲染
大屏可视化项目中我们常用到动图,一般情况下是UI提供gif动图来实现,也有一些情况下并不适合这种情况。比如当前做的收费站数字化管理平台
使用到了半透明的动图,这个时候UI导出gif动图时,会出现明显的锯齿,没有达到预期效果。这个时候就需要采用png序列帧
的方式,本文主要分享的是动图展示方案。
实际效果
左侧是通过png 序列帧
的方式实现效果,右侧是直接加载gif动图
上面是背景为透明状态下的gif,可以明显地看到锯齿效果
概念介绍
PNG序列帧:一种通过连续播放多张独立的PNG图片来实现动画效果的技术。
-
核心优势
- 高质量透明背景支持:相比GIF格式,PNG支持Alpha通道,能够完美呈现半透明或全透明区域且无边缘锯齿问题。这使得它在需要精细抠图的场景(如UI元素、logo动画)中表现更优。
- 色彩还原度高:采用无损压缩算法,颜色偏差极小,尤其适合包含渐变色或复杂细节的图形。例如,火焰、雨水等粒子特效能更真实地展现。
- 开发可控性强:开发人员可灵活调整播放速度、循环次数及交互逻辑。
-
优点
- 兼容性好:几乎所浏览器都支持,可直接对接开发流程。
- 效果还原度高:逐帧渲染避免压缩损失,动态细节完整保留。
- 灵活编辑:每张图片独立存在,便于后期修改某一帧而不影响整体。
-
缺点
- 源占用大:复杂动画可能产生成百上千张图片,导致加载时间长和内存消耗高。例如,某汽车小程序的加载动画使用了近200张PNG,对性能造成压力。
- 性能瓶颈:大量图片同时加载易引发卡顿,需权衡画质与流畅度。
PNG 序列图、GIF方案比对
特性 | 时序图/代码方案 | GIF方案 |
---|---|---|
文件大小 | ✅ 小(矢量或压缩算法优化) | ❌ 大(尤其长时长动画) |
交互支持 | ✅ 可绑定事件、动态控制 | ❌ 仅循环播放无交互 |
分辨率适配 | ✅ 矢量无损缩放 | ❌ 位图放大模糊/锯齿 |
性能开销 | ✅ 硬件加速渲染 | ❌ CPU频繁解码 |
维护成本 | ✅ 代码可读性强、易于调试 | ❌ 二进制格式难以修改 |
动态数据集成 | ✅ 支持实时数据驱动 | ❌ 静态内容无法变化 |
实战代码
SequencePlayer组件,基于setInterval
<template><div class="sequence-player"><img:src="currentFrame":style="imageStyle"alt="序列动画"class="sequence-img"ref="imgElement"/></div>
</template><script>
export default {name: "SequencePlayer",components: {},props: {// 📝 核心配置项frames: {type: Array,required: true, // 必须传入图片数组validator(value) {return value.every((item) => typeof item === "string");},},frameRate: {type: Number,default: 20, // 默认20FPS},autoStart: {type: Boolean,default: true, // 默认自动播放},imageStyle: {type: Object,default() {return { width: "90px", height: "auto" }; // 默认图片样式},},},data() {return {currentIndex: 0,intervalId: null,isPlaying: false,};},watch: {frames: {immediate: true,handler(newVal) {if (!newVal || newVal.length === 0) {console.warn("⚠️ 警告:未检测到有效图片序列");}},},},computed: {currentFrame() {return this.frames[this.currentIndex];},},methods: {start() {if (this.intervalId || !this.frames?.length) return;this.isPlaying = true;this.intervalId = setInterval(() => {this.currentIndex = (this.currentIndex + 1) % this.frames.length;}, 1000 / this.frameRate);},stop() {clearInterval(this.intervalId);this.intervalId = null;this.isPlaying = false;},reset() {this.stop();this.currentIndex = 0;},jumpTo(index) {if (index >= 0 && index < this.frames.length) {this.currentIndex = index;}},},created() {},mounted() {if (this.autoStart) this.start();},beforeDestroy() {this.stop();},
};
</script><style lang="less" scoped>
.sequence-player {display: flex;justify-content: center;align-items: center;height: 100%;width: 100%;
}
.sequence-img {image-rendering: high-quality;border-radius: 8px;box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);transition: opacity 0.3s ease; /* 可选:添加淡入淡出效果 */
}
</style>
使用方式
<template><div class="simple-sequence"><div class="demo"><SequencePlayer :frame-rate="20" :frames="baseFrames" /></div></div>
</template><script>
import SequencePlayer from "../SequencePlayer.vue";export default {name: "FrameDemo",components: {SequencePlayer,},data() {return {baseFrames: [require("../image/RSU_00000.png"),require("../image/RSU_00001.png"),require("../image/RSU_00002.png"),require("../image/RSU_00003.png"),require("../image/RSU_00004.png"),require("../image/RSU_00005.png"),require("../image/RSU_00006.png"),require("../image/RSU_00007.png"),require("../image/RSU_00008.png"),require("../image/RSU_00009.png"),require("../image/RSU_00010.png"),require("../image/RSU_00011.png"),require("../image/RSU_00012.png"),require("../image/RSU_00013.png"),require("../image/RSU_00014.png"),require("../image/RSU_00015.png"),require("../image/RSU_00016.png"),require("../image/RSU_00017.png"),require("../image/RSU_00018.png"),require("../image/RSU_00019.png"),],};},
};
</script><style lang="less" scoped>
.simple-sequence {display: flex;justify-content: center;align-items: center;height: 100%;width: 100%;
}
</style>
左侧是基于setInterval
实现的,右侧是基于requestAnimationFrame
实现的
SequencePlayerCopy组件,基于requestAnimationFrame
改进点
- 移除
setInterval
依赖
改用requestAnimationFrame
API 实现高性能动画循环。 - 精准计时控制
通过时间戳差值计算实际经过的时间,确保按指定帧率播放。 - 自动启停管理
在组件生命周期内正确处理动画状态(启动/暂停/重置)。 - 性能优化
避免累积误差导致的动画加速问题。
<template><div class="sequence-player"><img:src="currentFrame":style="imageStyle"alt="序列动画"class="sequence-img"ref="imgElement"/></div>
</template><script>
export default {name: "SequencePlayer",components: {},props: {// 📝 核心配置项frames: {type: Array,required: true, // 必须传入图片数组validator(value) {return value.every((item) => typeof item === "string");},},frameRate: {type: Number,default: 20, // 默认20FPS},autoStart: {type: Boolean,default: true, // 默认自动播放},imageStyle: {type: Object,default() {return { width: "90px", height: "auto" }; // 默认图片样式},},},data() {return {currentIndex: 0,animationId: null, // 存储RAF IDlastTimestamp: null, // 上一帧时间戳isPlaying: false, // 播放状态标记accumulatedTime: 0, // 累计等待时间(用于稳定帧率)};},watch: {frames: {immediate: true,handler(newVal) {if (!newVal || newVal.length === 0) {console.warn("⚠️ 警告:未检测到有效图片序列");}},},},computed: {currentFrame() {return this.frames[this.currentIndex];},// 根据目标帧率计算每帧应持续的时间(毫秒)targetFrameDuration() {return 1000 / this.frameRate;},},methods: {/*** 动画主循环 - 使用 requestAnimationFrame 实现* @param {number} timestamp - 当前时间戳(由RAF自动传入)*/animate(timestamp) {if (!this.lastTimestamp) {this.lastTimestamp = timestamp;requestAnimationFrame(this.animate.bind(this));return;}const elapsed = timestamp - this.lastTimestamp;const waitTime = Math.max(0, this.targetFrameDuration - elapsed);if (waitTime > 0) {// 如果未达到目标间隔,设置超时回调继续等待setTimeout(() => {requestAnimationFrame(this.animate.bind(this));}, waitTime);} else {// 时间足够时更新帧并重置计时器this.updateFrame();this.lastTimestamp = performance.now();requestAnimationFrame(this.animate.bind(this));}},/*** 更新当前显示的帧索引*/updateFrame() {this.currentIndex = (this.currentIndex + 1) % this.frames.length;},/*** 启动动画*/start() {if (this.animationId !== null || !this.frames?.length) return;this.isPlaying = true;this.lastTimestamp = performance.now();requestAnimationFrame(this.animate.bind(this));},/*** 停止动画*/stop() {cancelAnimationFrame(this.animationId);this.animationId = null;this.isPlaying = false;this.lastTimestamp = null;},/*** 重置到第一帧并停止播放*/reset() {this.stop();this.currentIndex = 0;},/*** 跳转到指定索引的帧* @param {number} index - 目标帧索引*/jumpTo(index) {if (index >= 0 && index < this.frames.length) {this.currentIndex = index;}},},mounted() {if (this.autoStart) this.start();},beforeDestroy() {this.stop();},
};
</script><style lang="less" scoped>
.sequence-player {display: flex;justify-content: center;align-items: center;height: 100%;width: 100%;
}
.sequence-img {image-rendering: high-quality;border-radius: 8px;box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);transition: opacity 0.3s ease; /* 可选:添加淡入淡出效果 */
}
</style>
使用
<template><div class="simple-sequence"><div class="demo"><SequencePlayerCopy :frame-rate="20" :frames="baseFrames" /></div></div>
</template><script>
import SequencePlayer from "../SequencePlayer.vue";
import SequencePlayerCopy from "../SequencePlayerCopy.vue";export default {name: "FrameDemo",components: {SequencePlayer,SequencePlayerCopy,},data() {return {baseFrames: [require("../image/RSU_00000.png"),require("../image/RSU_00001.png"),require("../image/RSU_00002.png"),require("../image/RSU_00003.png"),require("../image/RSU_00004.png"),require("../image/RSU_00005.png"),require("../image/RSU_00006.png"),require("../image/RSU_00007.png"),require("../image/RSU_00008.png"),require("../image/RSU_00009.png"),require("../image/RSU_00010.png"),require("../image/RSU_00011.png"),require("../image/RSU_00012.png"),require("../image/RSU_00013.png"),require("../image/RSU_00014.png"),require("../image/RSU_00015.png"),require("../image/RSU_00016.png"),require("../image/RSU_00017.png"),require("../image/RSU_00018.png"),require("../image/RSU_00019.png"),],};},
};
</script><style lang="less" scoped>
.simple-sequence {display: flex;justify-content: center;align-items: center;height: 100%;width: 100%;
}
.demo {height: 90px;width: 90px;margin-top: 100px;margin-left: 100px;
}
</style>