Web 仿 App 动画竟然引出了“性能杀手”

avatar
前端工程师 @上海阅文信息技术有限公司

本文作者:杨晔

原创声明:本文为阅文前端团队 YFE 成员出品,请尊重原创,转载请联系公众号 ( id: yuewen_YFE ) 获取授权,并注明作者、出处和链接。

背景

在我参与开发的对话小说项目过程中,我们发现创意类的活动对拉升转化数据很有帮助。经过调研,这款对话式小说产品的用户群体大多数都是比较年轻的 90-95 后,所以最后结论是希望以目前业界年轻化 APP 流行的交互形式 —— 《滑卡片》对推书活动做一次改版,也同时希望这个页面能和产品本身结合作为一个常驻功能页,我们先来看一下最终的实现效果:

图片

是不是挺流畅?接下来我会按照当时开发的思路和过程来讲述开发中经历了什么。

参考

在极为用心的设计师交付设计稿后,她还特地使用 flinto ⤵️做了交互原型来辅助我达到策划预期的效果。

图片

图片
《flinto 交互稿》

见到这份贴心的交互稿后,我首先想到的就是先去参考即刻 App 中的探索页,以及交友软件《探探》的交互形式,他们的交互效果分别如下:

图片
《探探 App 》

图片
《即刻 App - 探索》

两者效果非常相似吧?😏但和我这次需求不同的是:我们的页面是内嵌在起点读书 App 内的 H5,而以上两者皆是由原生 App 开发实现的效果,所以我对“能否高度还原”以及”如何保证良好的性能”还是产生了一点担忧 🤔。

尝试

样式重构思路 在获取真实数据和开发复杂逻辑之前,我先用草图整理了一下实现思路:

图片
如图所示:

  • 初始状态为3张卡片叠在一起,要有 3D 立体感,在拖拽的时候能露出后面两张
  • 拖拽第一张时卡片需要跟随手指滑动方向,超过一定距离放开手指后卡片飞出,后面的卡片自动往前推进一张,页面中始终需要 3 张卡片可见状态。

根据以上思路,既然要有 3D 立体感和推进动效,如果单独使用 z-index 来实现肯定不能满足,所以我选择使用 translateZ 来搭配完成这个堆叠卡片的推进效果,因为他能更好的显示出三维空间景深。如此一来,卡片往前推进和被扔出的卡片自动飞出等动效都可以完全交给 CSS3 动画过渡来完成。

样式代码(主要结构属性)

 .card_container {
  position: relative;
  width: 6.86rem;
  height: 8.96rem;
  perspective: 1000px;
  perspective-origin: 50% 150%;
  -webkit-perspective: 1000px;
  -webkit-perspective-origin: 50% 150%;
}
.card {
  transform-style: preserve-3d;
  width: 100%;
  height: 100%;
  position: absolute;
  opacity: 0;
}

堆叠的卡片需要有一个父容器,让所有堆叠的卡片产生 3D 透视效果。

HTML 和绑定方法

<div class="card_container">
  <div
    v-for="(item,index) in dataArr"
    :key="item.id"
    ondragstart="return false"
    class="card"
    :style="[cardTransform(index),indexTransform(index)]"
    @touchstart.stop.capture="touchStart($event,index)"
    @touchmove.stop.capture="touchMove($event)"
    @touchend.stop.capture="touchEnd($event,index)"
    @mousedown.stop.capture="touchStart($event)"
    @mousemove.stop.capture="touchMove($event)"
    @mouseup.stop.capture="touchEnd"
    @transitionend="onTransitionEnd(index)"
  >
</div>

我们还需要一些关键变量来记录一些可能实时变化的属性:

// 当前展示的图片index
currentIndex: 0,
// 记录偏移量
displacement: {
  x: 0,
  y: 0
},
// 位置信息
position: {
  start: { x: 0, y: 0 },
  end: { x: 0, y: 0 },
  direction: 1, // 滑动方向,左是-1,右是1
  swipping: false // 是否在拖动交换过程中
},
// 记录每一个丢出去的方向
directionArr: [],
// 显示图片的堆叠数量
visible: 3,
// 视口宽度
winWidth: 0,
//  滑动阈值
slideWidth: 70,
// 超过阈值时的自动偏移量
offsetWidth: 120,

再给 style 绑上 2 个初始化的方法。 cardTransform 用来初始化每张卡片的样式,indexTransform 用来初始化第一张卡片的样式。

// 初始化每张卡片的样式
cardTransform (index) {
    let style = {}
    //卡片自动位移距离(飞出屏幕多远)
    let offset = 0
    if (this.directionArr[index] === 1) {
      offset = 800
    } else if (this.directionArr[index] === -1) {
      offset = -800
    }
    
    style['z-index'] = this.currentIndex - index + this.visible 
    style['transform'] = `translate3d(0,0,${(this.currentIndex - index) * 60}px)`

  //让藏在后面的卡片缩小样式堆叠在一起并透明不显示。一旦飞走一张,下一张卡片会自动过渡动画往前推进
  if (index - this.currentIndex < 0) {
    style['opacity'] = 0
    style['transform'] = `translate3d(${this.position.end.x + offset}px,${this.position.end.y}px,${(this.currentIndex - index) * 60}px) rotate(${this.position.direction * -65}deg)`
  }

  // 非手势滑动状态才添加过渡动画
  if (!this.position.swipping) {
    style['transitionTimingFunction'] = 'ease'
    style['transitionDuration'] = 300 + 'ms'
  }
  return style
},
// 第一张卡片的样式
indexTransform (index) {
  let style = {}
  if (index === this.currentIndex) {
    style['transform'] = `translate3d(${this.displacement.x}px,${this.displacement.y}px,${(this.currentIndex - index) * 60}px) rotate(${this.displacement.x / this.winWidth * -65}deg)`
  }
  // 非手势滑动状态才添加过渡动画
  if (!this.position.swipping) {
    style['transitionTimingFunction'] = 'ease'
    style['transitionDuration'] = 300 + 'ms'
  }

  return style
 }

之后的拖拽卡片 touch 事件就相当于以前写拖拽 DIV 那样简单容易,返回上一张和背景过渡等细节的方法这里就不再做过多的代码展示了。

到此为止,使用了四本数的 mock 数据,一切都很顺利,动画也非常流畅:

图片

App Webview crash 😱

接着我开始请求真实数据,并做了一系列的优化,比如:

  1. 全机型适配卡片屏幕居中。
  2. 记录用户操作,拖拽扔出时的方向存入 localStorage (用户再次打开时看到的第一张卡片依然是之前离开时的,体验更像是在App内)
  3. 优化减少请求,首次进页面时加载 2 张图片,之后每飞走一张卡片时加载下一张图片。

优化之后,在 PC Chrome 移动端模式下一切看起来都是那么顺利,我自以为不会有什么问题,最后发布到测试环境用 App 扫码打开后看到的却是这一幕:

我一开始对性能的担忧终于还是发生了,App 内直接发生了崩溃,我再尝试用移动端浏览器打开,并没有发生崩溃,但是操作起来很不流畅,再回到 PC 上体验了一次,依然感知不到有什么卡顿,我想可能是由于手机硬件不如 PC, 发生崩溃的原因可能是 3D 渲染或者性能方面出现了问题。根据这个思路,我打算从数据上进行一次对比查看导致崩溃的关键要素是什么。

性能对比

首先使用 Chrome 自带的 Performance 进行了长达 7 秒的页面录制,在 7 秒钟我疯狂的对卡片操作了一番,最后得出的性能图如下:

图片

除了有一个小警告:Handler took 之外并证明不了什么严重的问题。 我打算再监控一下渲染性能,我从 Chrome 的更多工具里调起了 Rendering 面板

图片

在所有的选项全部打上勾后,造成问题的原因一下子就暴露了!

图片

OMG 😱,帧率只有 18 fps,而且原来所有的卡片都重合在了一起并进行了渲染。我马上意识到开发中的错误点:那些隐藏的卡片虽然 把透明度设置为了 0,但看不见并不代表不会被渲染,那些被隐藏的卡片在每一次卡片飞出动画后都在实时被渲染推进动画,严重损耗了性能。

也就是说,opacity 造成了页面的大量 reflow,这时我才想起,opacity 和 visibility 都会造成回流,而只要有 reflow 必定会造成 repaint,只有 display:none 可以避雷,因为它彻底脱离了文档流,在开发这个需求以来,我一直在优化页面还原度和动效,却忘记了这重要的一点。

优化

知道了问题的关键就好办多了,opacity 依然要保留,因为推进动效的过渡需要透明度来美化,光用 display 会变得非常生硬。既然用的是 VUE,那就更好办了,首先给数据中的数组全部添加上 display 属性,默认为 false,然后给 card 元素绑上了 :class="{display:item.display}",再将 css 的 card 样式全部设置为 display:none

<div class="card_container">
  <div
    v-for="(item,index) in dataArr"
    :key="item.id"
    ondragstart="return false"
    class="card"
    :style="[cardTransform(index),indexTransform(index)]"
    @touchstart.stop.capture="touchStart($event,index)"
    @touchmove.stop.capture="touchMove($event)"
    @touchend.stop.capture="touchEnd($event,index)"
    @mousedown.stop.capture="touchStart($event)"
    @mousemove.stop.capture="touchMove($event)"
    @mouseup.stop.capture="touchEnd"
    @transitionend="onTransitionEnd(index)"
    :class="{display:item.display}"
  >
</div>

在需要显示的时候让它变为 true,随即样式变为 block 。

.card.display {
  display: block;
  opacity: 1;
}

举个例子,比如我在 touchEnd 时有一个卡片移动的方法 moveNext。

touchEnd () {
  this.position.swipping = false
  this.position.end['x'] = this.displacement.x
  this.position.end['y'] = this.displacement.y

  // 判断滑动距离超过设定值时,自动飞出
  if (this.displacement.x > this.slideWidth) {
    this.moveNext(1) //往右
  } else (this.displacement.x < -this.slideWidth) {
    this.moveNext(-1)  //往左
  } 
  this.$nextTick(() => {
    this.displacement.x = 0
    this.displacement.y = 0
    this.isDrag = false
  })
}

我们就可以在 moveNext 时对 index 进行操作。moveNext 中需要对当前显示的第一张卡片和后面堆叠的都添加显示,已经消失的卡片变为隐藏,如此循环无缝衔接。另外,由于数据是不确定的,为避免某些极端情况(例如首张卡片再往前或者最后倒数几张后都会出现没有更多卡片的情况,所以还需要做细节容错处理)。

moveNext (direction) {
  this.position.direction = direction

  // 防止在最后倒数几张时操时出错
  try {
    this.dataArr[this.currentIndex + 3].display = true
  } catch (e) {

  }

  // 防止在第一张时操作出错
  if (this.currentIndex > 0) {
    try {
      this.dataArr[this.currentIndex - 1].display = false
    } catch (e) {

    }
  }
  
  this.currentIndex++ //每次让下一张卡片往前推进,反之 -- 就是返回上一张
  !direction ? this.position.end['x'] -= this.offsetWidth : this.position.end['x'] += this.offsetWidth
  this.position.end['y'] += this.offsetWidth / 2
 }

在一番调整优化后,我重新调起了 Rendering 面板查看结果:

图片

和预想的一样,帧数达到正常的 60 fps,不管如何操作,始终只有 3 张卡片是可见(被渲染的),性能得到了大大提升,重新回到 App 中访问也没有再遇到崩溃的问题。

扫码体验(使用起点 APP 查看效果更佳)

图片

总结

经过这次 App webview 引起的崩溃事件,我从中吸取到了一些经验和总结,也希望对阅读此文章的你有所帮助 😊。

  1. 用 Web 模拟 App 原生动画时,特别是在移动端,使用高阶属性去实时动态地改变元素时需要特别谨慎。
  2. “肉眼感知”并不准确,也不能作为衡量依据,一切要以开发工具中的性能数据为基准来证明。
  3. reflow 和 repaint 在 PC 端只要不是怀有明知山有虎,偏向虎山行的心态去写代码,几乎不会引发性能问题,但是移动端的渲染能力和 PC 端差了一大截,一个不小心,由 CSS 引发 reflow 和 repaint 就会成为移动端的“性能杀手”。所以,在完成需求和动效前,对自己的方案提前进行一次性能的心理预期也是很有必要的,在考量页面性能的时候分析 reflow 和 repaint 也算是一个切入点。

查看更多分享,请关注阅文集团前端团队公众号: