背景
为了进一步提升微信小程序的性能,解决当前双线程模型的一些问题,微信小程序基础库 3.0.0 版本带来了新的渲染引擎 Skyline,以下我们通过几个问答来解释 Skyline 是什么以及帮我们解决了什么问题。
什么是双线程模型?
就是逻辑层和渲染层分别在独立的线程,在渲染层运行 WXML 模板和 WXSS 样式,使用 WebView 进行渲染,在逻辑层运行 JS 脚本,使用 JSCore 进行执行。可以参考下图:
双线程模型有什么问题?
- WebView 因为历史原因存在渲染管线臃肿的问题,导致在移动端的表现与原生应用仍然有差距。
- 每个页面都需要实例化一个 JS 引擎(WebView),导致内存占用多,影响应用性能,因此小程序对打开的页面数量有限制。
- 渲染层和逻辑层使用 JSBridge 通信,在复杂场景下(比如拖动元素的交互动画)会存在性能问题,不过可以通过 WXS 来解决这个问题,但直接去除 JSBridge 会更好。
什么是渲染引擎 Skyline?
在双线程模式里,渲染层(WebView)负责DOM 树创建、CSS 解析、样式计算、Layout、composite 和 Paint 等渲染任务,逻辑层(AppService 线程)负责执行 JS 逻辑,而在 Skyline 里,新增渲染线程负责Layout、composite 和 Paint 等渲染任务,而 AppService 线程负责执行 JS 逻辑,DOM 树创建等任务。可以参考下图:
Skyline 是如何解决这些问题的?
在 Skyline 里只需要实例化一个 JS 引擎,减少内存占用,并且线程之间可以直接通信,而不需要借助 JSBridge,减少大量通信时间开销。并且精简了渲染管线,进一步提升渲染性能。
如何使用
可以参考我之前写的一篇文章如何将微信小程序从WebView迁移到Skyline
增强特性
Skyline 基本可以兼容之前的代码(部分需要手动兼容),并且还带来了一些特性,比如 worklet 动画、手势系统、自定义路由、共享元素等,其中最让人眼前一亮的自定义路由,丰富了页面切换的效果,带来了之前只有在原生应用上才有的动画。下面会按照顺序分别介绍下,并提供官方 Demo。
Worklet 动画
在双线程模式里,为了解决交互动画(如拖动元素)时线程间通信的性能问题,引入了 WXS,让部分 JS 逻辑放到 WebView 里执行,以此解决通信时的性能问题,但在 skyline 里,WXS 被移动到了 AppService 中,导致效率会有所下降,所以推出 Worklet 进行替代。
Worklet 动画是为了解决交互动画问题,为此引入了2个新的概念
概念一: worklet 函数
worklet 函数可运行在 JS 线程或者 UI 线程的函数,在函数顶部使用 worklet 指令声明即可,而非 worklet 函数只能运行在 JS 线程中。
js
// 非 worklet 函数
function someFunc(greeting) {
console.log('hello', greeting);
}
// worklet 函数
function otherFunc() {
'worklet';
console.log('hello otherFunc');
}
function someWorklet() {
'worklet'
// 在 worklet 函数里访问 worklet 函数时,可以直接调用,otherFunc 运行在 UI 线程
otherFunc()
// 访问非 worklet 函数时,需使用 runOnJS,将 someFunc 运行在 JS 线程
runOnJS(someFunc)('skyline')
}
someFunc('skyline') // print: hello skyline,将 someFunc 运行在 JS 线程
otherFunc() // print: hello world,将 otherFunc 运行在 JS 线程
wx.worklet.runOnUI(someWorklet)() // print: hello skyline,将 someWorklet 运行在 UI 线程
概念二: 共享变量
因为由 worklet 函数捕获的外部变量,实际上会被序列化后生成在 UI 线程的拷贝,导致在 JS 线程里的后续修改是无法同步的,例如:
js
const obj = { name: 'skyline'}
function someWorklet() {
'worklet'
console.log(obj.name) // 输出的仍旧是 skyline
}
obj.name = 'change name'
wx.worklet.runOnUI(someWorklet)()
这时候就需要使用共享变量,例如:
js
onst { shared, runOnUI } = wx.worklet
const offset = shared(0)
function someWorklet() {
'worklet'
console.log(offset.value) // 输出的是新值 1
}
offset.value = 1
runOnUI(someWorklet)()
而共享变量最大的使用场景就是在交互动画中,例如:
html
<pan-gesture-handler onGestureEvent="handlepan">
<view class="circle"></view>
</pan-gesture-handler>
js
Page({
onLoad() {
// 创建共享变量 offset
const offset = wx.worklet.shared(0);
// 绑定由 worklet 驱动的样式到相应的节点,并且绑定 worklet 函数,当 offset 变化时,该函数会被调用。并且应该该函数 和 handlepan 都是在 UI 线程执行,则不需要绕回 JS 线程。
this.applyAnimatedStyle('.circle', () => {
'worklet';
return {
transform: `translateX${offset.value}px`
};
});
this._offset = offset;
},
handlepan(evt) {
'worklet';
if (evt.state === GestureState.ACTIVE) {
// 接收到事件的变化,修改 offset 值
this._offset.value += evt.deltaX;
}
}
});
手势系统
带来做大的特性是允许代理原生组件内部手势(比如 scroll-view 和 swiper),结合手势协商,就可以实现更为复杂的交互。
例如视频号的评论列表:
html
<view class="page-container">
<view class="placehodler-widget" />
<pan-gesture-handler
tag="pan"
shouldResponseOnMove="shouldPanResponse"
simultaneousHandlers="{{['scroll']}}"
onGestureEvent="handlePan"
>
<vertical-drag-gesture-handler
tag="scroll"
native-view="scroll-view"
shouldResponseOnMove="shouldScrollViewResponse"
simultaneousHandlers="{{['pan']}}"
>
<scroll-view
class="list-wrp"
scroll-y
bounces="{{false}}"
refresher-enabled="{{false}}"
adjustDecelerationVelocity="adjustDecelerationVelocity"
bindscroll="handleScroll"
type="list"
>
<view class="item" wx:for="{{list}}">
<view class="avatar" />
<view class="comment" />
</view>
</scroll-view>
</vertical-drag-gesture-handler>
</pan-gesture-handler>
</view>
js
Page({
/**
* 生命周期函数--监听页面加载
*/
onLoad(options) {
const transY = shared(0);
// 拖动列表
this.applyAnimatedStyle('.list-wrp', () => {
'worklet';
return {
transform: `translateY(${transY.value}px)`
};
});
this.transY = transY;
this.scrollTop = shared(0); // 记录列表滚动距离
this.startPan = shared(false); // 是否拖动列表
},
// 是否需要拖动列表,如果返回 true,则响应拖动列表
shouldPanResponse() {
'worklet';
return this.startPan.value;
},
// 是否需要滚动列表,如果返回 true,则响应滚动列表
shouldScrollViewResponse(pointerEvent) {
'worklet';
if (this.transY.value > 0) return false;// 如果在拖动列表,则禁止响应滚动列表
const scrollTop = this.scrollTop.value;
const {
deltaY
} = pointerEvent;
const result = !(scrollTop <= 0 && deltaY > 0);// 在滚动中或者向上滑动,则响应滚动列表
this.startPan.value = !result;
return result;
},
// 监听到列表拖动事件
handlePan(evt) {
'worklet';
if (evt.state === GestureState.ACTIVE) {
const curPosition = this.transY.value;
const destination = Math.max(0, curPosition + evt.deltaY);
if (curPosition === destination) return;
this.transY.value = destination;
}
// 如果手势结束,则恢复位置
if (evt.state === GestureState.END || evt.state === GestureState.CANCELLED) {
this.transY.value = timing(0);
this.startPan.value = false;
}
},
adjustDecelerationVelocity(velocity) {
'worklet';
const scrollTop = this.scrollTop.value;
return scrollTop <= 0 ? 0 : velocity;
},
// 监听到列表滚动事件
handleScroll(evt) {
'worklet';
this.scrollTop.value = evt.detail.scrollTop;
},
})
自定义路由
支持页面跳转动画
伪代码如下:
js
// 配置 Route Builder
const HalfScreenDialogRouteBuilder = (customRouteContext) => {
/**
* 操作是从 A 页面进入 B 页面
* 进入动画 t: 1->0,退出动画 t: 0->1,B 页面
* 1. 手势拖动时采用原始值
* 2. 页面进入时采用 curve 曲线生成的值
* 3. 页面返回时采用 reverseCurve 生成的值
*/
const handlePrimaryAnimation = () => {
'worklet'
let t = primaryAnimation.value
if (!userGestureInProgress.value) {
t = _curvePrimaryAnimation.value
}
// 距离顶部边距因子
const topDistance = 0.12
// 距离顶部边距
const marginTop = topDistance * screenHeight
// 半屏页面大小
const pageHeight = (1 - topDistance) * screenHeight
// 自底向上显示页面
const transY = pageHeight * (1 - t)
const style = {
overflow: 'hidden',
borderRadius: '10px',
marginTop: `${marginTop}px`,
height: `${pageHeight}px`,
transform: `translateY(${transY}px)`,
}
if (!isSupportOverflow) delete style.overflow
return style
}
/**
* 操作是从 A 页面进入 B 页面
* 压入动画 t: 1->0,压出动画 t: 0->1 A 页面
*/
const handlePreviousPageAnimation = () => {
'worklet'
let t = primaryAnimation.value
if (!userGestureInProgress.value) {
t = _curvePrevAnimation.value
}
// 页面缩放大小
const scale = 0.08
// 距离顶部边距因子
const topDistance = 0.1
// 估算的偏移量
const transY = screenHeight * (topDistance - 0.5 * scale) * t
const radius = 12 * t
const scaleValue = 1 - scale * t
// skyline 1.0.1 版本以下修改 overflow: hidden 有问题
const style = {
borderRadius: `${radius}px`,
overflow: radius > 0 ? 'hidden' : 'visible',
transform: `translateY(${transY}px) scale(${scaleValue})`,
}
if (!isSupportOverflow) delete style.overflow
return style
}
return {
opaque: false,
transitionDuration: 300,
reverseTransitionDuration: 300,
canTransitionTo: true, // 是否与下一个页面联动
canTransitionFrom: true, // 是否与前一个页面联动
handlePrimaryAnimation, // 当前页面的动画
handlePreviousPageAnimation, // 上一个页面的动画
}
}
// 注册 Route Builder
wx.router.addRouteBuilder('HalfScreenDialog', HalfScreenDialogRouteBuilder)
// 跳转页面,从 A 页面进入 B 页面
wx.navigateTo({
url: `/half-page/index?routeType=${routeType}`,
routeType: 'HalfScreenDialog',
});
共享元素动画
共享元素 share-element,可以实现如从商品列表页进入详情页过程中,商品图片在页面间飞跃动画和朋友圈的图片预览放大功能。 仿朋友圈图片预览放大功能伪代码如下:
A 页面
html
<scroll-view type="custom" scroll-y class="scroll-view">
<grid-view
type="aligned"
cross-axis-count="{{crossAxisCount}}"
main-axis-gap="{{gap}}"
cross-axis-gap="{{gap}}"
>
<block wx:for="{{thumbnailList}}" wx:key="id">
<animated-image
style="height: {{cellHeight}}px;"
key="{{item.key}}"
src="{{item.url}}"
data-key="{{item.key}}"
data-url="{{item.url}}"
bind:tap="goDetail"
/>
</block>
</grid-view>
</scroll-view>
B 页面
html
<share-element
class="img-wrp"
key="{{key}}"
shuttle-on-push="from"
transition-on-gesture
bind:tap="back"
>
<image class="img" src="{{imgSrc}}" mode="aspectFit" />
</share-element>
限制
目前 Skyline 还在完善当中,目前还存在一些支持上的限制和差异,具体看如下文档:
总结
渲染引擎 Skyline 在提升应用性能的同时,带来了类似于原生应用的丰富动画效果,非常值得大家尝试。