Pixi.JS 动画优化

背景

本次活动为展示类型的活动页,共包括开始、结束以及 8 个场景。每个场景以动画展示为主,每个场景有一个转场动画用于衔接下一个场景,另包含一个弹窗展示。与抖音地图项目相比,本次场景动画更为丰富复杂,但流程控制简单很多,无需回退重播需求,切场景之间有转场分割、不要求有所场景都存在同一个舞台上。但是由于场景多,动画复杂,那么解决性能问题成了重点。

banner

本次活动运用到的技术框架以及库:

名称 说明
PixiJS Canvas / WebGl 库
Greensock 补间动画库
Aniamtion.css CSS 动画库
Vue.js Magic Vue 不多说
vue-scroller Vue 滚动模拟插件

适配

多屏幕尺寸适配

适配实际上是以较粗暴的方式解决:项目的场景长宽比与设计稿一致,再根据不同长宽比的屏幕作裁剪,具体规则是:

如果屏幕长宽比大于设计稿长宽比,则将页面高度缩放至屏幕高度,将多余的宽度移出屏幕外。反之则将页面宽度缩放到屏幕宽度,多余的高度则移出屏幕外砍去头尾。

Canvas 有个小特性,如果指定了 Canvas 元素的 widthheight 属性,那么则定了其宽高比例,之后只要在样式中单独设置 width 或者 height ,那么 Canvas 元素则会按照之前设置的属性中的宽高比进行缩放。

具体实践

首先按照设计稿的宽高约定 Canvas 舞台的宽高:

<canvas id="stage" width="667" height="375"></canvas>

之后再利用 JS 算出 Canvas 元素的宽高,再使用 transform 使其在视口内局中:

const designSize = {
    width: 667,
    height: 375,
};

const screenSize = {
    width: window.innerWidth,
    height: window.innerHeight,
};

const renderSize = {
    width: window.innerWidth,
    height: window.innerHeight,
};

let translateX = 0;
let translateY = 0;

const bodyHeight = document.documentElement.offsetHeight;
screenSize.height = Math.max(bodyHeight, screenSize.height);

const designWHRate = designSize.width / designSize.height;
const pageWHRate = screenSize.width / screenSize.height;

if (pageWHRate < designWHRate) {
    renderSize.height = screenSize.height;
    renderSize.width = Math.ceil(screenSize.height * designWHRate);
} else if (pageWHRate > designWHRate) {
    renderSize.width = screenSize.width;
    renderSize.height = Math.ceil(screenSize.width / designWHRate);
}

translateX = Math.ceil(-(renderSize.width - screenSize.width) / 2);
translateY = Math.ceil(-(renderSize.height - screenSize.height) / 2);

app.style.width = `${renderSize.width}px`;
app.style.height = `${renderSize.height}px`;
app.style.transform = `translate(${translateX}px, ${translateY}px)`;
app.style.webkitTransform = `translate(${translateX}px, ${translateY}px)`;

至于文案等重要信息,则采用根据屏幕可视区域定位,保证其可读性,至于特殊机型可以通过 Media Query 规则做针对性的适配即可。

高清屏幕适配

起因是这样的:为了控制图片资源大小,图片素材均使用了 1 倍图。虽然保证了项目体积没有急剧膨胀,但在 DPI 大于 2 的设备上观感就很糟糕了,严重缺乏品质感。万幸的是,PixiJS 提供了对多倍图的支持,这里统一将高分屏设备设置为二倍图。需要做的是在初始化的时候声明好 resolution 的数值为 2 ,还需要将对应的资源名结尾加上 @2x,好让 PixiJs 识别,代码逻辑按照一倍图去运算,其余的则交给 PixiJs 适配即可。

let resolution = window.devicePixelRatio || 1;

if (resolution >= 3 && !isAndroid) {
    resolution = 2;
    image_resolution = '@2x';
}

if (isAndroid) {
    resolution = 1;
    image_resolution = '';
}

let app = new PIXI.Application({
    // other config...
    resolution: resolution,
});

不过二倍图也会带来性能问题,具体优化措施下文有说讲。

性能优化

首先,过度优化是不必要的,以下是根据自测结果作特定优化。不过开发完毕后项目在低端机器上效果并不不完美,适当的降级处理

WebGl vs Canvas

PixiJS 支持 WebGl 或者 Canvas 模式渲染,不同的设备上性能还有差异。iOS 下 Canvas 2D 性能较优,故统一使用 Canvas 渲染。但 Android 下情况有些复杂,经过自测大概得出这样的规律,较老的设备运行 Canvas 模式性能较佳且怪异情况较少,而较新的设备则使用 WebGl 更优,于是采取的策略如下:

let forceCanvas = true;

if (isAndroid && OS_version >= 6 && ua.indexOf('SM901') === -1) {
    forceCanvas = false;
}

if (ua.indexOf('MI 5') === 1) {
    forceCanvas = false;
}
// 这里比较特殊的是 小米 5 与 锤子 SM901 需要特殊处理
// 小米 5 是必须使用 WebGl 后者是必须使用 Canvas

不过粗暴的按照操作系统、操作系统版本以及白名单去匹配渲染模式,不是最优解,下次应该通过性能检测或者特性检测去判别。

内存占用优化

俗话说”有借有还、再借不难“,所谓“借”就是声明的内存空间,“还”即释放内存。借了不还,很容易导致内存占用过高或则内存泄漏,最终会导致 WebView 卡顿甚至引发客户端的无响应甚至崩溃,所以声明的内存空间一定要释放掉!尽管 JavaScript 有自动垃圾回收机制,但并不代表我们无需关心内存管理,毕竟内存回收算法不是银弹。

分析

这次需要借助 Chrome DevTools 中的 Performance 工具,为了更好的模拟移动端设备,最好把 CPU slowdown 打开。录制完毕后我们即可得到一份报告,这里我们要关注的是下方 JS Heap 曲线状态。

一般来说 JS Heap 曲线图有升有落代表着内存的开销与回收,如果 JS Heap 有不断增大的趋势则存在内存泄漏的可能。更具体的分析内存参考这里:解决内存问题

chrome dev tools

本次活动页是由多个场景串联组成,无重播回退需求,当上一个场景播放完毕则该场景无存在必要了,很适合在上一个场景结束后将上一个场景的资源释放掉。

内存回收

内存回收实际上就是销毁对象的引用,比如下面的方式,尽管 JavaScript 的内存回收机制能够自动处理好垃圾回收,这里只是作说明演示。

let arr = [];
// 尽管 arr 会被自动回收
arr = null;
PixiJS

PixiJS 的 Container 拥有 destroy(option) 方法,其中 option 可传入一个 Object 配置具体的销毁选项,或者 Boolean 值用于统一设置 option 中所有的选项值。关于参数说明具体见下表:

字段 类型 说明
option Bolean option 设置的全部开关
Object
字段 默认 类型 说明
children false Boolean 是否销毁子元素
texture false Boolean 是否去对应子元素的 texture
baseTexture false Boolean 是否去销毁子元素对应的 baseTexture

申明的各种 Sprite 都是被添加到每个 Container 中的,只需调用 destroy(true) PixiJS 能够自动销毁其中的子孙精灵以及引用的材质资源,还是很方便的,无需逐个销毁了👍!

let scene = new PIXI.Container;
scene.destroy(true);
scene = null;

其余的不在舞台中的 Sprite 也需要去除掉,注意排查。除此之外还有一些 ticker 中添加的回调函数也许记得清除掉。

let playfn = () => {
  // do something  
};
app.ticker.add(playFn);
app.ticker.remove(playFn);
playFn = null;
GreenSock 对象

GreenSock 也提供了销毁函数 kill(),具体用法参考文档:kill()。单次播放动画,直接在 onComplete 回调函数中直接 kill():

TweenMax.to(target, duration, {
    repeat: 1,
    onComplete() => {
        this.kill();
    }
});

循环动画的话拿到引用,再 kill() 即可:

let tweenMax = TweenMax.to(target, duration, {
    repeat: -1,
});
tweenMax.kill();
tweenMax = null;
setInterval

还有一些动画是采用定时器实现的,定时器不用了,也得记得销毁,反正也就是顺手的事情:

let timer = window.setInterval(() => {
    // do something
}, duration);
clearInterval(timer);
timer = null;

多倍图的取舍

Android 机器存在高分屏但是性能较低的机器,在这些机器上采用二倍图渲染会带来严重的性能问题。这次没做好的是根据机器的实际性能去做匹配,而是一刀切。最终很遗憾的是,针对所有的安卓设备最终采取的都是一倍图渲染,因为很多 Android 机型搭载了较高的 DPI 的屏幕但机器性能却不足以支撑高画质。(其实这个锅 APP 的 WebiVew 也得背一半)。

转场动画场景优化

本项目场景转化中使用了很多缩放镜头,如果单纯使用真实的逻辑缩放去处理动画,那么起始的场景将会非常巨大,对机器的性能要求也会很高。于是在最开始对接的时候就提出了使用序列帧替代,具体实现不细说,参考

资源加载

本次资源加载的优化还是脱离不了老生常谈的优化资源的数量以及大小,这里不再赘述。另域名发散因 Canvas 图片跨域问题涉及到需修改 CDN 服务器 CORS 设置放弃,实际上在以往项目中域名发散策略来提升资源加载速度提升还是非常明显的,幸好 CDN 支持 HTTP/2 请求协议也无需强调域名发散策略。后续查询可通过某些方案绕过浏览器对 Canvas 图片跨域限制,但因未实践过故不再展开细述,具体参考视差长图 H5 实践。不过这么做也带来了一个问题就是,巨量的序列帧图片,解法下文见。

视差长图 H5 实践

预加载与懒加载

本次活动页静态资源多达令人😱的 600 多张。所以在加载页全量加载势必会导致用户长时间的等待,跳出率会很高,由于本次活动页是按照多个场景划分的,场景之间且为顺序展示。可以利用这个特性做优化。

最终采取分布加载的策略:在加载等待页面加载前两个场景,其余场景在 Loading 结束后静默加载,如其余场景在前两个场景运行完毕尚未加载完毕会提示用户等待。这样的加载策略能够使用户更快的体验到活动页内容。不过后 6 个场景图片资源也比较多,如果用户不想继续浏览之后的场景对流量的浪费还是挺大的,后续应该优化成场景资源逐个预先加载,用户只需等待第一个场景加载完毕即可开始体验,如若中途退出损失的也只是下一个场景的资源加载流量,不过在不稳定的移动网络中增加了用户等待的几率,最佳实践还需与 UED 配合,添加合适的用户等待提示。

png2jpg

很多不带透明通道的 png 格式图片转化为 jpg 格式能够获得更小的体积。比如很多场景的背景图,还有部分需序列帧图,都非常适合使用 jpg 去替代 png,估算节约图片大小 50% 左右,对于本项目部分动画使用序列帧实现,着实非常给力。因此因地制宜选择合适的图片格式能对资源大小的优化带来不小的成果。当然也有很多优秀图片格式如 webp 也可尝试。

Bugs

写不出 bug 的程序员已经被神许以永生了,作为普通码农的我哪能不出 bug ?此次选取几个在个人经验范围内比较特殊的 bug 作为记录防止后续再次踩坑。

禁止滚动与区域滚动无效

其实类似问题在移动端挺常见的:浮窗内有一滚动区域,该区域使用系统自带滚动,具体代码如下:

.scroll {
    max-height: 100px;
    overflow-y: auto;
    -webkit-overflow-scrolling: touch;
}

但发现滚动区域滚动有几率无法触发滚动,经过排查发现是对文档阻止了 touchmove 事件,修改为在弹窗出现时取消事件阻止,但会导致弹窗出现时页面还是可滚动的。

document.body.addEventListener(
    'touchmove',
    (e) => {
        if (!this.showAlert) {
            e.preventDefault();
            e.stopPropagation();
        }
    },
    {
        passive: false,
        capture: true
    }
);

最后解决方案是将页面改为 fixed 定位避免页面滚动,滚动区域无效的问题使用 vue-scroller 替代系统默认的滚动行为。不过多引入一个 Vue 组建有些浪费,下次可以尝试这个解决方案:移动端滚动穿透问题完美解决方案

iOS 下 linear-gradient 失效

弹窗滚动区域底部有一层渐变层,最开始本着节约图片资源的原则使用 CSS 渐变解决。但在 iOS 下表现不如预期本该是透明的部分被渲染成了黑色。

.layer {
    background-image: linear-gradien(90deg, #fff, transparent);
    background-image: -webkit-linear-gradien(90deg, #fff, transparent);
}

最终简单粗暴的使用图片替代渐变。后查询相关资料所知:Safari 中需使用 RGBA 中的 alpha 也就是透明通道设置为 0 也可实现预期的透明渐变效果,可参考 Apple Developer 中的 关于 Using Gradients 的说明。也就是说 Safari 中渐变背景图不支持 transparent 关键字。

华为机型动画卡顿

这个 Bug 有点教人苦笑不得,产生原因是华为机型搭载魔改后的 Android 版本(所谓的 EMum UI)认为的人为的降低了应用的刷新率,应用程序内搭载的 WebView 自然也在所难免。至于解法也令人无奈,如监测到用户触控屏幕则可恢复发到正常刷新率。

在牺牲用户体验的前提下用这种投机取巧的方式去提升机器的续航,脑中莫名响起那句华而不实,为所欲为!另外对缺失了 Google 主导,混乱的 Android 国内环境表示无奈,默默对封闭对开发者有好的 iOS 点一个赞,

结语

本次项目其实动画实现也有很多值得去说的闪光点:比如怎么展现车在隧道公路中行驶,飞机冲入云霄…动画非常有趣但是实现起来真是是费时费力,还要注意动画还原度的问题。很多动画是直接跟设计师一起从 AE 中扣数值,反复调试,开发与 UI 同学都苦不堪言,还有本文的重点性能上的优化。投入与产出,从工时上不成正比。后续想找一些方案去替代人肉复读 UI 动画的方案,也会从抽空整理成文。


附:自测结果

由于之前的项目有过会引发客户端崩溃的案例,而本次项目场景更加多且动画也更为复杂

机型 结果 备注 解法
iPhone 6P ✔️ 个别场景略有掉帧
iPhone X ✔️ 需底部适配
iPhone 8p ✔️
OPPO R9 Plustm A ✔️
Huawei P20 ✖️ 掉帧严重 🤷‍♂️系统调度问题
Pixel XL ✔️ 发热
Google Nexus 6p ✔️
锤子 M1 ✔️
小米 5 ✔️ WebGL 模式,Canvas 模式会引发崩溃
Huawei Meta 8 ✔️ WebGL 模式较为流畅
Huawei Meta 10 ✔️ WebGL 模式,屏幕需触发点击后流畅
Huawei P10 ✔️ WebGL 模式,屏幕需触发点击后流畅
Nexus 6P ✖️
低端机
坚果 R1 ✔️ 偶发崩溃 forceCanvas: false可以解决,多次重现未复现
Vivo Y33 ✔️ 崩溃 优化内存占用后勉强走完流程
iPhone 5S ✔️ 发热

发表评论

电子邮件地址不会被公开。 必填项已用*标注