点击关注上方蓝字,阅读更多干货~
1 前言
在现代互联网应用中,大至链路的衔接,小至一个按钮的点击响应,动效是体验无处不在的润滑剂,为用户每一步的操作提供了合理的预期与过渡。而通过动效衔接不同界面或不同响应状态,无论对流畅直观地表达流程意图,还是精雕细琢让体验更丝滑的微动效,动效设计都是应用中非常重要的一环。
图1 动效演示图片
业务伙伴觉得界面转场不够丝滑,害怕用户在该环节造成较多的流失,想在该环节加入动效提高界面表现力,挽留用户进入下一阶段,在交付动效时,往往可能会出现一下几个情况:
情况二:设计伙伴与业务伙伴沟通动效内容并制作对应的MP4演示文件,给到相应的素材,让开发对照MP4进行实现,后续通过比对MP4进行效果验收
情况三:设计伙伴给到静态素材和部分文字参数信息,让开发伙伴进行组合信息实现动效内容
就此我们可以看到进行支持动效内容的接入,要处理的问题非常多,要花费很大的成本进行沟通、理解、验收,无法进行快速高效的交付动效模块。
图2 动效业务背景
沟通成本高:在动效模块的协作中,由于缺乏统一且有效的沟通方式,导致动效的设计、开发和验收过程变得复杂,增加了沟通成本。
动效设计资产未沉淀:现有的动效设计方式,使得动效无法成为可复用的设计资产,从而导致重复设计和开发,增加了团队投入成本。
动效质量控制难:由于动效的复杂性和易变性,仅通过口头沟通或MP4演示文件进行验收,难以确保动效质量。
图3 动效协作中的问题
从上述描述中不难看出,设计与开发关于动效协作主要问题发生在演示demo不完善、动效参数、素材不符合规范、验收成本较大的4个问题上。
本着发现问题,解决问题的原则。我们分析流程中每个环节,通过分析可以得到在最关键的点就是输出与输入时都不存在动效标准,设计与开发都只能对着效果图进行调试参数,从而达到预期效果,这也导致验收阶段出现开发与设计对于动效表现无法达成一致的问题。
图4 设计与前端伙伴协作流程
3.1 常见的动画格式
优点:高兼容性、接入成本低
缺点:最高支持256种颜色、内存占用高、复杂动画文件资源较大
APNG:诞生于2004年,是一个基于 png 的位图动画格式,扩展方法类似主要用于网页的 GIF 89a,仍对传统PNG保留向下兼容,2017年主流浏览器几乎都已经支持 APNG。
缺点:内存占用高、复杂动画文件资源较大、交互性差
Lottie 由 Airbnb 推出,并且迅速在国内外各种大小厂快速推广开来,目前已经是一个非常普遍常用的格式。
缺点:3D效果支持较弱、部分效果内存占用高
针对 Lottie 对缓动曲线解析差带来的性能问题和稳定性问题,我们会有第二种备选方案是 SVGA,不管是导出之后的内存占用,还是在各个端的表现稳定性都会好很多。
MP4 是一套用于音频、视频信息的压缩编码标准,由国际标准化组织(ISO)和国际电工委员会(IEC)下属的“动态图像专家组”(Moving Picture Experts Group,即MPEG)制定,第一版在1998年10月通过。目前已经是一个非常普遍常用的格式
缺点:不支持透明、不支持交互操作
平台兼容性好:支持H5、小程序、APP多平台,AE支持特性是大于APNG/GIF/SVGA,小于MP4
文件资源小:优于GIF、APNG
交互性强:对比GIF、APNG、MP4,Lottie 交互性更强,适应场景更广
内存占用高:相比 SVGA 与 MP4 动画格式,在客户端内存占用会相对较高,影响性能表现
了解到所有的动画格式后,经过优劣势的对比,我们选择了lottie方案,选择它的理由主要是以下几点:
3.3.1 三大环节
建立动效输出规范:制定统一的动效设计规范,包括动效类型、时长、颜色等,形成动效库。这将有助于降低沟通成本,提高动效的复用性。
建立交互式动效平台:借助交互式动效设计平台,设计伙伴可以更直观地设计和展示动效,同时也能让业务伙伴更好地理解和参与动效设计过程。
制定动效验收标准:制定明确的动效验收标准,如动效时长、播放顺序、视觉效果等,以便于验收时能有据可依,确保动效质量。
图5 协作解决方案泳道图
Lottie 动效都是由 AE 软件进行制作,在 AE 中有一个 Bodymovin 插件可以将动效导出为一份 JSON 文件,这样就可以让设计伙伴导出的动效都是同一份标准。
图6 设计与前端伙伴的新协作流程
建立动效资产管理平台,可支持设计伙伴上传动效单元与预览动效效果,确认设计、开发、业务伙伴对于动效交互方式达成一致,避免重复投入的沟通成本。同时可以将已有动效进行沉淀,便于后续的快速复用,降低团队研发成本。
图7 加入平台的新协作流程
接下来就让我们建设一款基于 Lottie 的动效设计平台,快速生成设计师想要的动态效果,极大地提高了设计效率和设计还原度。作为一站式动效制作平台,通过大量的动效素材以及可视化编辑能力,帮助沉淀资产快速得到复用,提高动效模块的交付效率。
动效编辑功能
支持动画的多场景预览
Lottie JSON 内容解析
Lottie preview 预览画布
控制器相关模块编码
JSON转APNG/GIF功能
支持简单类的动画可以直接投入业务使用
平台架构主要分为6层,包含管理端与移动端整体环节(虚线部分表示目前开发中)。
图8 平台系统架构图
shapes:元素集合,内部包含图层动画路径已经填充的颜色与内容等信息
"v": "5.6.10", // 使用bodymovie插件的版本
"fr": 32, // 帧速率
"ip": 0, // 合成开始时间
"op": 64, // 合成持续时间
"w": 750, // 合成宽度
"h": 1334, // 合成高度
"nm": "合成 1", // 合成名
"ddd": 0, // 是否3d图层
"assets": [] // 使用的资源
"layers": [] // 图层集合
"shapes": [] // 元素集合
"markers": [] // 蒙层集合
lottie结构解析函数:
async function resolvingLottieJson(data: string) {
// 首先找到 -> layers -> refId -> assets -> layers >> 复合图层/资源图层
// 其次路径 -> layers -> layers -> shapes >> 元素图层
const info: any = JSON.parse(data)
treeMeatData.value = info
const assetsMap = keyBy(info.assets, 'id')
let result = []
async function queryInfo(list: any, parentNodeKey: string) {
const infoTree = []
for (let index = 0; index < list.length; index++) {
const item = list[index]
item.indexKey = `${parentNodeKey}${parentNodeKey ? '-' : ''}${index}`
item.cl = `layer-inactive ${item.cl ? `${item.cl} ` : ''}class_${item.indexKey}`
if (item.refId) {
const itemAssets = assetsMap[item.refId]
if (itemAssets.layers) item.layers = await queryInfo(itemAssets.layers, item.indexKey)
} else if (item.layers) {
item.layers = await queryInfo(item.layers || [], item.indexKey)
}
if (item.ty !== undefined) infoTree.push(item)
}
return infoTree
}
result = await queryInfo(info.layers, '')
treeData.value = result
return info
}
解析结构 | 解析数据 |
图9 Lottie 文件解析结构示意
在通过解析模块为每个节点创建唯一标识与结构后,下面要处理实时编辑与更新功能,可通过两种方式来完成实时渲染和数据同步。
画布初始化渲染-初始化lottie渲染:
lottie.loadAnimation({
container: document.getElementById('preview-lottie'),
renderer: 'svg',
loop: lottieLoop.value,
autoplay: true,
animationData: data
})
方式一:修改 json fb内容,来完成动态渲染
JSON操作方式:
// 找到对应 json 节点直接替换 p 属性
const asset = lottieData.assets.find(a => a.id === '7')
asset.p = '/himo/light_bg.png'
lottie.loadAnimation({
container: document.getElementById('preview-lottie'),
animationData: json
})
anim.addEventListener('DOMLoaded', () => {
if (anim.renderer.rendererType === 'canvas') { // canvas 模式下的图片替换
anim.renderer.elements[currentEleIndex].img.src = '/himo/light_bg.png'
} else { // svg 模式下的图片替换,前两个参数为固定值
anim.renderer.elements[currentEleIndex].innerElem.setAttributeNS(
'http://www.w3.org/1999/xlink',
'href',
'/himo/light_bg.png'
)
}
})
注意点:在 canvas 模式下替换的图片需要保持与原图片尺寸一致,否则实际动画效果会有问题;svg 模式下则受益于 svg image 元素的特性,不需要如此强的约束。
目前已知 Lottie 具有这个三个阶段,现在可以根据不同阶段提供对应的功能。
图10 Lottie运行流程
<template>
<div class="control-panel">
<div class="play" @click="lottiePlay">
<img :src="playIcon" alt="" />
</div>
<div class="frame">
<a-slider
v-if="lottieFrame > 0"
:value="currentFrame"
:max="lottieFrame"
:tooltip-open="true"
@change="framgChange"
/>
</div>
<div class="loop" @click="setLoop">
<img :src="loopIcon" alt="" />
</div>
</div>
</template>
<script lang="ts" setup>
// 帧数变化响应函数
function framgChange(frameNumber: number) {
// 情况一:如在播放状态 则跳转指定帧数进行播放
// 情况二:如在暂停状态 则跳转指定帧数进行暂停
if (playState.value) lottieAnimation.value.goToAndPlay(frameNumber, true)
else lottieAnimation.value.goToAndStop(frameNumber, true)
currentFrame.value = frameNumber
}
</script>
// 选中对应的操作图层节点
async function activeLayerNode(key: any, data: any) {
const item = data.node.dataRef
let backgroundImage = ''
if (item.ty === 2) {
const assetsMap = keyBy(treeMeatData.value.assets, 'id')
// 图片节点
backgroundImage = assetsMap[item.refId].p
}
editConfig.value = {
ty: item.ty,
backgroundImage
} // 建立对应的编辑配置内容
activeNode.value = item
}
// 监听editConfig配置对象的变化
watch(
editConfig,
async (value, oldValue) => {
const assetsMap = keyBy(treeMeatData.value.assets, 'id')
if (activeNode.value.ty === 2) {
const image = editConfig.value.backgroundImage
// 通过JS对象来更新DOM界面
const element = document.querySelector(`.class_${activeNode.value.indexKey} > image`)
element.setAttribute('href', image)
// 查询原始lottie数据 进行更新处理
let isQuerySuccess = false
const queryInfo = async (list: any) => {
for (let index = 0; index < list.length; index++) {
const item = list[index]
if (item.indexKey === activeNode.value.indexKey) isQuerySuccess = true
if (item.refId) {
const itemAssets = assetsMap[item.refId]
if (itemAssets.layers) item.layers = await queryInfo(itemAssets.layers)
else if (isQuerySuccess) {
for (let index = 0; index < treeMeatData.value.assets; index++) {
const assetsItem = treeMeatData.value.assets
if (assetsItem.id === item.refId) break
}
treeMeatData.value.assets[index].p = image
break
}
} else if (item.layers) {
item.layers = await queryInfo(item.layers || [])
}
}
}
await queryInfo(treeMeatData.value.layers)
return
}
},
{ deep: true }
)
图11 动态图片功能展示图片
首先需要识别当前选中节点的类型
动态颜色编辑代码:
async function activeTreeNode(key: any, data: any) {
const item = data.node.dataRef
// 读取 全部配置颜色 item
const result = await queryLayerFillColor(item) // 递归全部图层节点 找到元素类型的图层 获取到填充颜色
const uniqueMap = {}
const uniqueResult = []
result.forEach((value: any) => {
if (uniqueMap[value.join(',')]) return
uniqueResult.push(value)
uniqueMap[value.join(',')] = true
})
editConfig.value = {
uniqueColors: uniqueResult,
allColors: result,
ty: item.ty,
}
activeNode.value = item
}
watch(
editConfig,
async (value, oldValue) => {
if (activeNode.value.ty === 0) {
let colorIndex = 0
const setLayerFillColor = async (currentNode, colorList) => {
const queryInfo = async (list: any) => {
for (let index = 0; index < list.length; index++) {
const item = list[index]
if (item.indexKey === currentNode.indexKey) {
if (!item.shapes) return
await item.shapes.forEach(async sv => {
sv.it.forEach(i => {
if (i.ty !== 'fl') return
i.c.k = colorList.map((cv, ci) => {
return ci === 3 ? cv : cv / 256
})
})
})
} else if (item.layers) await queryInfo(item.layers || [])
}
}
await queryInfo(treeMeatData.value.layers)
}
// 到达无图层节点后 进行查询填充颜色
const updateFillHandle = (item, index, node) => {
// 填充颜色
item.it.forEach(i => {
if (i.ty !== 'fl') return
colorIndex = colorIndex + 1
const element = document.querySelectorAll(`.class_${node.indexKey} > g`)
forEach(element, valueEle => {
const childrenElement = valueEle.children[0]
const childrenBgColor = allColors[index]
childrenElement.setAttribute(
'fill',
`rgb(${childrenBgColor[0]},${childrenBgColor[1]},${childrenBgColor[2]})`
)
childrenElement.setAttribute('fill-opacity', childrenBgColor[3])
// 找到当前图层dom 进行修改fill
if (childrenBgColor) setLayerFillColor(node, childrenBgColor)
})
})
}
// -> shapes / -> layers
if (activeNode.value.shapes) {
activeNode.value.shapes.forEach(item => {
updateFillHandle(item, colorIndex, activeNode.value)
})
} else if (activeNode.value.layers) {
// 找到填充图层信息
const queryInfo = async (list: any) => {
for (let index = 0; index < list.length; index++) {
const item = list[index]
if (item.refId) {
const itemAssets = assetsMap[item.refId]
if (itemAssets.layers) item.layers = await queryInfo(itemAssets.layers)
} else if (item.layers) {
item.layers = await queryInfo(item.layers || [])
}
if (item.shapes) {
await item.shapes.forEach(async sv => {
await updateFillHandle(sv, colorIndex, item)
})
}
}
}
queryInfo(activeNode.value.layers)
}
}
},
{ deep: true }
)
图12 动态颜色功能展示图片
图13 动效平台功能预览图
简单说明下场景,用户进入活动后可以看到好运红包雨,最终通过开启红包拿到奖励金额的交互动效。动效一共分为四个阶段:倒计时、好运雨、红包出现、红包开启。
可以看到其中的变量分别是:奖励金额、卡片图片、好运名称
发起 ajax 请求,同时播放开启红包的动画
ajax 请求返回后,播放对应的好运卡片与奖励金额
图14 新年接好运活动案例
业务场景是用户通过一段交互动画进入场馆观看HIMO集团的照片展览。该业务场景的动效共分为两个阶段:场馆出现、进入场馆。
0~10 帧是场馆出现交互按钮
11~199 帧是场馆进入动画
图15 海马体艺术馆案例
大致流程是:
先自动执行0~10帧动画,这个时候进行暂停,等待用户交互指定
可以通过图层的 cl 字段来获取到开启按钮的节点,在动效加载完成后绑定 click 事件,等该事件被触发后继续执行动画。
艺术馆处理代码:
const lottieAnimation = lottie.loadAnimation({
container: document.getElementById('guide-an'),
renderer: 'svg',
loop: false,
autoplay: false,
path: guideAn.value
})
lottieAnimation.addEventListener('enterFrame', event => {
const currentFrame = event.currentTime
if (currentFrame >= 10) lottieAnimation.pause()
})
lottieAnimation.addEventListener('DOMLoaded', () => {
const btnJoin = document.querySelector('.btn-join')
if (btnJoin) {
btnJoin.addEventListener('click', debounce(1000, () => {
lottieAnimation.play(11, true)
}))
}
})
4.1.3 抽奖活动工具
0~109 帧「开始抽奖」
这里我们来分析下业务场景可能带来的问题:
抽奖动效交互处理代码:
// 初始化动效
function initAnimation() {
lottieAnimation.value = lottie.loadAnimation({
container: animationContainer.value,
renderer: 'svg',
loop: false,
autoplay: false,
path: props.animationConfig.animationFile
})
proxy.$nextTick(() => {
// 添加点击开始执行动画
openLotteryElement.addEventListener('click', handleAnimationClick)
})
lottieAnimation.value.addEventListener('DOMLoaded', () => animationExecHandler('initExecuteFramesRange'))
}
// 动效执行处理函数
async function animationExecHandler(type) {
const animationActions = {
initExecuteFramesRange: config => { // 初始执行帧范围
return config.initExecuteFramesRange.length === 0 ? [0, true] : config.initExecuteFramesRange
},
openExecuteFramesRange: config => { // 开启执行帧范围
return config.openExecuteFramesRange.length === 0 ? [0, true] : config.openExecuteFramesRange
},
endExecuteFramesRange: config => { // 结束帧执行范围
return config.endExecuteFramesRange.length > 0 ? config.endExecuteFramesRange : null
},
reset: config => { // 重置处理
return config.initExecuteFramesRange.length === 0 ? [0, true] : [config.initExecuteFramesRange[1] - 1, config.initExecuteFramesRange[1]]
}
}
const config = props.animationConfig
const action = animationActions[type]
if (!action) return
const framesRange = action(config)
if (framesRange) {
const [frame, isStop] = framesRange
if (isStop) {
lottieAnimation.value.goToAndStop(frame, true)
} else {
await playAnimation(lottieAnimation.value, framesRange)
}
}
}
// 片段播放封装
function playAnimation(animation, segements) {
return new Promise(resolve => {
animation.playSegments(segements, true)
animation.addEventListener('complete', () => {
resolve()
})
})
}
文章最开始我们有讲到动效存在:实现成本高、沟通成本高和性能难以保证的问题。
但在以上的技术方案中,我们通过 Lottie 解决了动效接入的成本问题,在 Lottie 的基础上建立了动效平台,解决了动效沟通成本的问题,并且可以快速沉淀资产后快速复用,提高了动效的交互效率。通过采用 SVG 的渲染方式,我们相比使用 CSS3 实现方式,性能也得到较大的提升,已满足目前业务所需的目标。
动效平台方案最优秀的地方在于解决了研发成本的同时也解放了开发与设计,让伙伴可以投入到更有意义的工作当中去,不需要针对一个点进行反复沟通,最后得到一个质量不是很佳的动效。通过平台我们可以完成「设计产出动效」、「平台接入动效」、「开发伙伴渲染动画」三个环节的相互连接。
一路走来,从最开始的序列帧 webp 到支持小动画的 svga,再到现在能实现高交互的 Lottie 动效平台,发挥工程师的想法和解决问题的办法,一步步的提高动画模块的交付效率和降低研发投入的成本,让实现复杂动画不再是想象。
本文作者
高尔,来自缦图互联网中心前端团队。
--------END--------
也许你还想看