运用小程序Skyline技术构建无缝用户体验 —— 同程旅行酒店最佳实践分享
[图片] 动效衔接设计与小程序渲染框架 1、什么是动效衔接设计? 随着互联网技术和设计理念的不断发展,动效设计成为现代 UI 设计中不可或缺的一部分。其中,动效衔接是非常重要的一环。动效衔接设计是指通过巧妙的动效设计,将不同的 UI 元素在动画过程中自然、流畅地衔接起来,从而增强用户的交互体验和视觉感受。在实际应用中,动效衔接设计主要应用于界面转场、信息提示、状态变化等方面,通过顺畅的衔接,降低用户因白屏等待而产生的焦虑。 2、动效衔接设计的意义 (1)极大提高用户体验,让用户感受到界面的流畅和自然,从而增加用户对产品的好感度。 (2)降低用户的操作认知成本,帮助用户更好地理解执行操作后所带来的结果,从而减少用户对产品的困惑。 (3)强化视觉层,让用户更好地区分不同的信息和元素,从而增强视觉层次感。 (4)增加界面的美感度,让界面更加生动有趣,从而提升整体的美感和设计价值。 (5)提升品牌的认知度,让产品更加具有特色和独特性,从而提高品牌的认知度和市场竞争力。 3、什么是小程序渲染框架Skyline? 为了进一步优化小程序性能,小程序在原 webview 渲染引擎之外最新推出 小程序渲染框架Skyline,其使用更精简高效的渲染管线,并拥有诸多增强特性,让它拥有更接近原生渲染的性能体验。新的增强特性有 worklet 动画系统、手势系统、自定义路由、共享元素动画,而且许多常用的组件如 scroll-view、swiper 都有了更高性能的实现。 [图片] [图片] 实践理念和场景拆解 1、动效衔接设计的核心原则 简单而清晰的动效设计,需要遵守以下几个原则: (1)一致性:动效衔接应该与整体设计风格保持一致。包括颜色、字体、动画速度等方面。 (2)可预测性:用户能够感知动画元素的变化关联性,从而增加用户对产品的掌控感和对界面的理解。 (3)反馈性:动效衔接与用户操作相响应,从而帮助用户理解他们的操作所带来的结果。 (4)视觉层次:动效衔接遵循视觉层次原则,让用户区分页面中的上下关系以及三维物理世界的关系层次,给用户清晰的层级区分感知,提高用户体验。 (5)自然性:动效衔接符合物理规律,例如重力、加速度等,从而增强动画的真实感和用户体验。 2、理念孵化与使用场景拆解 以提炼的动态感受为出发点,理性的层面给予了我们大致的产品体验感知,为我们动效理念的建成提供了框架。对此我们将继续从感性层面出发,找寻可传递真实感受的运动现象并加以组合提炼。 本次 同程旅行小程序 以酒店预订链路中核心的相册页面进行应用场景,在用户操作图片的过程中运用小程序渲染框架承接。 (1)将整个过程进行了拆解,首先为了退出时行动的路径更加清晰,做了一个响应设计,当界面向右滑动退出的过程中,相册图片进行缩小,在缩放过程中,会根据位移距离控制缩放的比例,同时蒙层的透明度以及毛玻璃效果也跟随手指移动变化,和相册列表页在视觉上呈现 XY 轴以及上下层级的空间关系,在缩小到一定的比例时,触发震动效果,松手退出到相册列表页。 (2)在交互结束时,图片退回相册列表页原始位置,在返回路径的过程中,根据交互结束时的定位点,来判断运动的方向和距离,计算运动加速度,以及模拟运动加速度带来的惯性回弹的方向和角度变化,加强与模拟真实物理世界的运动定律,和视觉上的动态感知。 结合自然世界的运动规律来看,把页面进入的元素比作是行驶的汽车,用户当作是正在斑马线上行驶的人,将马路作为页面空间。若汽车采用的是缓入运动(加速)的话,马路上的行人则看到的是一辆不断加速向他行驶过来的车辆。因为担心车辆高速的逼近导致刹车不及时的情况,行人便会本能的作出躲闪的反应。其实页面也是一个道理,进入的元素使用加速运动出现过冲的运动感知会让用户体验时产生不适。 [图片] 小程序渲染框架技术开发实践过程剖析 1、开发自定义路由实现此交互,需要 自定义路由动画,因为小程序渲染框架的页面支持自定义跳转动画。当使用自定义路由后,页面跳转时指定路由类型,就会触发自定义路由动画,而不再是默认的从右往左的动画,此处的实现可以使得页面跳转时,没有默认的路由动画,页面将直接以透明的方式渲染在屏幕上,由开发者自己控制页面内元素的动画展示方式,具体实现如下: (1)在图片查看页面配置文件 index.json 中声明 {
"backgroundColor": "#00000000",
"backgroundColorContent": "#00000000", // 设置客户端页面背景为透明
"navigationStyle": "custom",
"renderer": "skyline", // skyline渲染引擎
"disableScroll": true,
"usingComponents": {
}
}
(2)在 wxss 中,设置图片查看页面的 page 节点为透明背景 page {
background: transparent;
}
(3)在 js 中,使用 wx.router.addRouteBuilder(routeType, fn) 来声明自定义路由动画 wx.router.addRouteBuilder('myCustomRoute', function (params) {
const handlePrimaryAnimation = () => {
'worklet';
return {
// 可在此处,根据 params.primaryAnimation.value 的值,来设置页面的动画效果
backgroundColor: `rgba(0,0,0,${ params.primaryAnimation.value })`
};
};
return {
opaque: false,
handlePrimaryAnimation,
barrierColor: '',
barrierDismissible: false,
transitionDuration: 320,
reverseTransitionDuration: 250,
canTransitionTo: true,
canTransitionFrom: false
};
})
(4)在图片列表页面中,使用 x.navigateTo 来跳转页面,并且设置 routeType 为 myCustomRoute wx.navigateTo({
url: '/pages/skyline-image-viewer/index?index=0',
routeType: 'myCustomRoute'
})
[图片] 需配置页面的渲染引擎为 Skyline,并且在跳转时使用 routeType 就可以实现让页面在跳转时没有默认的路由动画。 2、共享元素穿越在连续的页面跳转时,页面间 key 相同的 share-element 节点将产生飞跃特效,还可自定义插值方式和动画曲线,通常作用于图片。为保证动画效果,前后页面的 share-element 子节点结构应该尽量保持一致 <share-element key="share-key">
<view> you code here </view> <!-- 需要注意,share-element 内要求只有一个根节点 -->
</share-element>
[图片] 这时,界面的表现像上面视频一样,是一个连续的动画状态,这完全是由 share-element 来控制的,share-element 的动画原理如下图所示: [图片] 3、接入手势组件,实现图片放大、缩小、平移在图片查看页面有如下结构: <scale-gesture-handle worklet:ongesture="onScaleGestureHandle">
<share-element key="{{ shareKey }}" class="current-item">
<image src="{{ src }}"/>
</share-element>
</scale-gesture-handle>
这里,我们使用小程序渲染框架提供的 手势组件 <scale-gesture-handle>,来实现图片的放大、缩小、平移等手势交互。 注意,所有声明为 worklet 指令的方法它们运行在UI线程,不要在方法中修改普通的变量,因为跨线程的关系,只能修改使用 wx.worklet.shared 声明的变量。 const GestureState = {
POSSIBLE: 0, // 此时手势未识别
BEGIN: 1, // 手势已识别
ACTIVE: 2, // 连续手势活跃状态
END: 3, // 手势终止
CANCELLED: 4 // 手势取消
};
Component({
attached() {
this.shareX = wx.worklet.shared(0);
this.shareY = wx.worklet.shared(0);
this.sharScale = wx.worklet.shared(1);
// 声明共享变量,并且给需要变化的dom,绑定动画
this.applyAnimatedStyle('.current-item', () => {
'worklet';
return {
transform: `translate3d(${this.shareX.value}px, ${this.shareY.value}px, 0) scale(${this.sharScale.value})`
}
});
// 页面所需的数据,需要在 attached 事件里初始化完毕,使其可以参与首帧渲染
this.setData({ src: '...', shareKey: '...' });
},
methods: {
// 当手势组件识别到手势时,触发此回调
onScaleGestureHandle(e) {
'worklet';
const { state } = e;
// 在worklet函数里,不要使用 const {} = this 对this解构
const shareX = this.shareX;
const shareY = this.shareY;
const sharScale = this.sharScale;
if (state === GestureState.BEGIN) {
// 手势已经识别,此时,可以获取到手势的初始值
} else if (state === GestureState.ACTIVE) {
// 手势活跃状态,此时,可以获取到手势的变化值,如平移的距离、缩放的比例等
// 将当前变化的值,设置到 `shared` 变量,就可以改变元素的样式,类似于vue3的数据驱动
shareX.value += e.focalDeltaX;
shareY.value += e.focalDeltaY;
sharScale.value = e.scale;
} else if (state === GestureState.END || state === GestureState.CANCELLED) {
// 手势终止或取消,此时,可以获取到手势的最终值
}
}
}
})
[图片] 4、手势协商(解决手势冲突) 上面的 demo 简单演示如何使用手势组件来做图片交互,但是在图片查看页面中,我们还有其他的手势交互,如图片的左右滑动切换等,一般我们会使用 <swiper> 组件来实现,但是 <swiper>组件的内部实现和 <scale-gesture-handle> 组件,都会监听手势事件,手势组件的事件不支持冒泡的,就会导致下面结构横时: <scale-gesture-handle worklet:ongesture="onScaleGestureHandle">
<swiper>
<swiper-item wx:for="{{ imgs }}">
<share-element key="{{ item.shareKey }}" class="current-item">
<image src="{{ item.src }}"/>
</share-element>
</swiper-item>
</swiper>
</scale-gesture-handle>
使用手势横向滑动时,会优先触发 swiper 的横向切换事件,而无法触发 <scale-gesture-handle> 的手势事件了,这在图片放大时的图片横向移动产生了冲突。此时就需要使用手势协商来解决手势冲突。 什么是手势协商? 手势协商指的是:当页面同时有多个手势交互时,需通过一定的约定来决定哪些手势事件应该被执行,哪些需要被忽略。 小程序渲染框架解决手势冲突的方式,主要是通过手势组件的 tag、simultaneous-handlers、native-view 和 should-response-on-move 来实现 tag:手势组件的标识,用于区分不同的手势组件simultaneous-handlers:手势组件的协商者,表示需要同时触发事件的手势组件的标识should-response-on-move:参与手势时间的派发过程,返回 false时,表示该手势时间不会继续派发native-view:用当前手势组件来代理原生组件内部的手势事件,如<swiper>组件内部的手势事件<swiper> 的内部也是使用了 <horizontal-drag-gesture-handler>手势组件,但是我们不能直接在<swiper>上设置tag来使其参与手势协商,需要用相同的手势组件通过native-view=swiper将其内部的事件代理出来,使其可以参与协商<!-- <scale-gesture-handle> 缩放手势 -->
<!-- <horizontal-drag-gesture-handler> 横向拖动手势 -->
<!-- 通过 simultaneous-handlers=tag 来声明多个手势应该同时触发 -->
<scale-gesture-handle tag="scale"
simultaneous-handlers="{{['swiper']}}"
worklet:ongesture="onScaleGestureHandle">
<!-- 此处使用 native-view=swiper 代理内部的手势组件 -->
<!-- 通过 should-response-on-move=fn 来参与`事件派发`过程,决定手势的事件是否应该派发 -->
<horizontal-drag-gesture-handler tag="swiper"
native-view="swiper"
simultaneous-handlers="{{['scale']}}"
worklet:should-response-on-move="shouldResponseOnMove">
<swiper>
<swiper-item wx:for="{{ imgs }}">
<share-element key="{{ item.shareKey }}" class="current-item">
<image src="{{ item.src }}"/>
</share-element>
</swiper-item>
</swiper>
</horizontal-drag-gesture-handler>
</scale-gesture-handle>
const GuestureMode = {
INIT: 0,
SCALE: 1,
SWIPE: 2,
MOVE: 3
// ...
};
Component({
attached() {
this.GuestureModeShared = wx.worklet.shared(GuestureMode.INIT);
this.shareX = wx.worklet.shared(0);
this.shareY = wx.worklet.shared(0);
this.shareScale = wx.worklet.shared(1);
// 声明共享变量,并且给需要变化的dom,绑定动画
this.applyAnimatedStyle('.current-item', () => {
'worklet';
return {
transform: `translate3d(${this.shareX.value}px, ${this.shareY.value}px, 0) scale(${this.shareScale.value})`
}
});
// ...
},
methods: {
onScaleGestureHandle(e) {
'worklet';
const { state } = e;
if (state === GestureState.BEGIN) {
this.GuestureModeShared.value = GuestureMode.INIT;
} else if (state === GestureState.ACTIVE) {
if(this.GuestureModeShared.value === GuestureMode.INIT) {
this.gestureBefore(e); // 手势类型未知时,判断手势类型
} else {
this.gestureHandle(e); // 手势类型已知时,处理手势事件
}
} else if (state === GestureState.END || state === GestureState.CANCELLED) {
this.GuestureModeShared.value = GuestureMode.INIT;
}
},
// 判断手势类型
gestureBefore(e) {
'worklet';
const { focalDeltaX, focalDeltaY, scale } = e;
if (Math.abs(focalDeltaX) > Math.abs(focalDeltaY)) {
this.GuestureModeShared.value = GuestureMode.SWIPE;
} else if (scale > 1) {
this.GuestureModeShared.value = GuestureMode.SCALE;
} else {
this.GuestureModeShared.value = GuestureMode.MOVE;
}
},
// 处理手势事件
gestureHandle(e) {
'worklet';
if (this.GuestureModeShared.value === GuestureMode.SCALE) {
this.shareScale.value = e.scale;
} else if (this.GuestureModeShared.value === GuestureMode.SWIPE) {
// swiper 切换模式时,这里什么都不用做
} else if (this.GuestureModeShared.value === GuestureMode.MOVE) {
this.shareX.value += e.focalDeltaX;
this.shareY.value += e.focalDeltaY;
}
},
// 用于判断手势事件是否应该派发
shouldResponseOnMove(e) {
'worklet';
return this.GuestureModeShared.value === GuestureMode.SWIPE; // 当模式为SWIPE时,才响应手势事件
}
}
})
[图片] 通过上面的代码,我们实现了手势协商,当用户在图片上进行滑动的操作时,总是会触发 <scale-gesture-handler> 的手势事件,通过对图片当前状态的判断来决定应该触发哪种手势,我们通过此种协商让 <horizontal-drag-gesture-handle> 手势在合适的时机触发,以此避免手势冲突。 5、使用小程序渲染框架时需要注意的一些地方作为一款新的渲染优化方式,开发者使用小程序渲染框架需要注意以下内容,以保证渲染的效果和性能。 (1)自定义路由时首帧渲染&首帧性能优化 小程序渲染框架的首帧渲染对共享元素动画非常重要,若共享元素节点的key 错过首帧设置的话,可能会丢失飞跃动画,所以在使用小程序渲染框架时,共享元素的 key 应该尽量在 attached 中或之前设置到页面,并且在首帧渲染时,应尽可能的减少 UI 层的渲染工作 如下: 1)所需要的数据应尽可能使用提前计算好,避免构建页面时等待太久影响响应速度 2)首次设置的数据应该尽可能的少,避免首次渲染时,页面上的元素过多,导致首帧渲染时间过长,导致动画卡顿(如:不要同时初始化太多的 <swiper-item>) 3)确保首帧渲染时,共享元素的 key 正确的设置,避免在首帧渲染时,由于找不到对应的共享元素,导致动画丢失,看不到飞跃动画 4)由于手势事件触发频繁,应尽量避免大量需要的计算的逻辑高频执行,容易导致机器发烫,或者导致动画卡顿 **worklet 函数的使用** worklet 函数的使用有一些限制,主要是由于它是在 UI 线程执行的,所以 worklet 函数中的 this 并非是页面的 this 实例, 里面所使用到的变量也是通过特殊的 babel 插件转换到UI线程的,需要与逻辑层共用的变量都需要用 wx.worklet.shared 将它声明成共享变量,在 UI 线程调用逻辑层的函数需要使用 wx.worklet.runOnJS (2)与 web 规范的差异 虽然小程序渲染框架尽可能的与 web 规范保持一致,但是由底层渲染引擎的限制,还是有一些差异,如: 1)display: flex 的默认朝向是 column,而不是 row,这需要开发者注意,官方后续会支持 block 布局方式 2)暂不支持 css 伪元素,如 ::after、::before,官方正在支持中 3)position 仅支持 absolute、relative,不支持 sticky,实现滚动吸附的效果需用 sticky-* 组件来配合 scroll-view 实现 ** <share-element> 在非小程序渲染框架运行环境里的表现是什么** 在非小程序渲染框架的运行环境内,<share-element> 组件会被视为一个 <view> 组件,需要做好布局的兼容 6、何时使用小程序渲染框架开发时,请确保小程序开发者工具版本是 最新版 nightly,sdk 版本在 2.30.2+,具体限制可参考 文档。 这些新特性的引入,使得小程序渲染框架在小程序开发中的优势更加明显,开发者可以更加便捷地实现各种复杂的交互效果,并且达到接近原生APP的体验。 [图片] 未来展望 1、个性化产品形态:将会根据不同的用户需求和场景,设计出更加符合用户喜好和习惯的动效衔接,进行组件化调用。 2、更加自然和真实的动效衔接:动效衔接将会更加贴近自然规律和真实物理效应,从而增强动画的真实感和用户体验。 3、更加智能化和自适应的动效衔接:动效衔接将会根据用户的操作行为和使用习惯,自适应调整动画效果,从而提高用户体验和产品效果。 4、扩大产品、设计与开发的协作效应:设计对动效的把控、产品对用户的洞察以及开发对新技术的应用,才可以发挥最大化的协作效应。 附1:本文作者 同程旅行研发工程师 同程旅行体验设计师 同程旅行产品经理 附2:代码片段 相册小程序代码片段(请使用 PC 端浏览器打开):https://developers.weixin.qq.com/s/E979jCmP7oHG 附3:UE标注 [图片] 附4:AB 实验效果 AB 实验显著win0.23% [图片]