需要帮助

简介

微信小程序一直以来采用的都是 AppService 和 WebView 的双线程模型,基于 WebView 和原生控件混合渲染的方式,微信小程序优化扩展了 Web 的基础能力,保证了在移动端上有良好的性能和用户体验。Web 技术至今已有 30 多年历史,作为一款强大的渲染引擎,它有着良好的兼容性和丰富的特性。 尽管各大厂商在不断优化 Web 性能,但由于其繁重的历史包袱和复杂的渲染流程,使得 Web 在移动端的表现与原生应用仍有一定差距。

为了进一步优化微信小程序性能,提供更为接近原生的用户体验,我们在 WebView 渲染之外新增了一个渲染引擎 Skyline,其使用更精简高效的渲染管线,并带来诸多增强特性,让 Skyline 拥有更接近原生渲染的性能体验。

架构

当微信小程序基于 WebView 环境下时,WebView 的 JS 逻辑、DOM 树创建、CSS 解析、样式计算、Layout、Paint (Composite) 都发生在同一线程,在 WebView 上执行过多的 JS 逻辑可能阻塞渲染,导致界面卡顿。以此为前提,微信小程序同时考虑了性能与安全,采用了目前称为「双线程模型」的架构。

在 Skyline 环境下,我们尝试改变这一情况:Skyline 创建了一条渲染线程来负责 Layout, Composite 和 Paint 等渲染任务,并在 AppService 中划出一个独立的上下文,来运行之前 WebView 承担的 JS 逻辑、DOM 树创建等逻辑。这种新的架构相比原有的 WebView 架构,有以下特点:

  • 界面更不容易被逻辑阻塞,进一步减少卡顿
  • 无需为每个页面新建一个 JS 引擎实例(WebView),减少了内存、时间开销
  • 框架可以在页面之间共享更多的资源,进一步减少运行时内存、时间开销
  • 框架的代码之间无需再通过 JSBridge 进行数据交换,减少了大量通信时间开销

而与此同时,这个新的架构能很好地保持和原有架构的兼容性,基于 WebView 环境的微信小程序代码基本上无需任何改动即可直接在新的架构下运行。WXS 由于被移到 AppService 中,虽然逻辑本身无需改动,但询问页面信息等接口会变为异步,效率也可能有所下降;为此,我们同时推出了新的 Worklet 机制,它比原有的 WXS 更靠近渲染流程,用以高性能地构建各种复杂的动画效果。

新的渲染流程如下图所示:

如果在使用过程中遇到任何问题,可以前往「Skyline 渲染引擎」专区查看说明。

特性

Skyline 以性能为首要目标,因此 CSS 特性上在满足基本需求的前提下进行了大幅精简,目前 Skyline 只保留更现代的 CSS 集合。另一方面,Skyline 又添加了大量的特性,使开发者能够构建出类原生体验的微信小程序。在编码上,Skyline 与 WebView 模式保持一致,仍使用 WXML 和 WXSS 编写界面。在不采用 Skyline 新增特性的情况下,适配了 Skyline 的微信小程序在低版本或未支持 Skyline 的平台上可无缝自动退回到 WebView 渲染。

支持与 WebView 混合使用

微信小程序支持页面使用 WebView 或 Skyline 任一模式进行渲染,Skyline 页面可以和 WebView 页面混跳,故开发者可以页面粒度或分包粒度按需适配。

// page.json
// skyline 渲染
{
    "renderer": "skyline"
}

// webview 渲染
{
    "renderer": "webview"
}
// app.json
{
  "subPackages": [
    {
      "root": "packageA",
      "pages": ["pages/cat"],
      "componentFramework": "glass-easel",
      "renderer": "skyline",
    },
  ],
}


提供更好的性能

Skyline 在渲染流程上较 WebView 更为精简,其对节点的渲染有着更精确的控制,尽量避免不可见区域的布局和绘制,以此来保证更高的渲染性能。WebView 由于其整体设计不同以及兼容性等问题,渲染流水线的实现更加冗长复杂。

在光栅化策略上,Skyline 采用的是同步光栅化的策略,WebView 是异步分块光栅化的策略。两种策略各有千秋,但 WebView 的策略存在一些难以规避的问题,例如:快速滚动会出现白屏问题;滚动过程中的 DOM 更新会出现不同步的问题,进而影响到用户体验。

在此基础上,我们还进一步实现了很多优化点。

1. 单线程版本组件框架

Skyline 下默认启用了新版本的组件框架 glass-easel,该版本适应了 Skyline 的单线程模型,使得建树流程的耗时有效降低(优化 30%-40%),同时 setData 调用也不再有通信开销和序列化开销。

2. 组件下沉

Skyline 内置组件的行为更接近原生体验,部分内置组件(如 scroll-view、swiper 等)借助于底层实现,有更好的性能和交互体验。同时,我们将部分内置组件(如 view、text、image 等)从 JS 下沉到原生实现,相当于原生 DOM 节点,降低了创建组件的开销(优化了 30% 左右)。

3.长列表按需渲染

长列表是一个常用的但又经常遇到性能瓶颈的场景,Skyline 对其做了一些优化,使 scroll-view 组件只渲染在屏节点(用法上有一定的约束),并且增加 lazy mount 机制优化首次渲染长列表的性能,后续我们也计划在组件框架层面进一步支持 scroll-view 的可回收机制,以更大程度降低创建节点的开销。

4. WXSS 预编译

同 WebView 传输 WXSS 文本不同,Skyline 在后台构建微信小程序代码包时会将 WXSS 预编译为二进制文件,在运行时直接读取二进制文件获得样式表结构,避免了运行时解析的开销(预编译较运行时解析快 5 倍以上)。

5. 样式计算更快

Skyline 通过精简 WXSS 特性大幅简化了样式计算的流程。在样式更新上,与 WebView 全量计算不同,Skyline 使用局部样式更新,可以避免对 DOM 树的多次遍历。Skyline 与微信小程序框架结合也更为紧密,例如: Skyline 结合组件系统实现了 WXSS 样式隔离、基于 wx:for 实现了节点样式共享(相比于 WebView 推测式样式共享更为精确、高效)。在节点变更、内联样式和继承样式的更新上,Skyline 也进行了一些优化,从而保证样式计算的性能。

此外,对于 rpx 单位,我们直接在样式计算阶段原生支持,这样避免了在 JS 层面做太多额外的计算。

<!-- 样式共享目前暂未自动识别,可手动声明 list-item 属性开启 -->
<scroll-view type="list" scroll-y>
    <view wx:for="{{list}}" list-item>{{index}}</view>
</scroll-view>

6. 降低内存占用

在 WebView 渲染模式下,一个微信小程序页面对应一个 WebView 实例,并且每个页面会重复注入一些公共资源。而 Skyline 只有 AppService 线程,且多个 Skyline 页面会运行在同一个渲染引擎实例下,因此页面占用内存能够降低很多,还能做到更细粒度的页面间资源共享(如全局样式、公共代码、缓存资源等)。


根除旧有架构的问题

在基于 Web 体系的架构下,微信小程序的部分基础体验会受限于 WebView 提供的能力(特别是 iOS WKWebView 限制更大一些),使得一些技术方案无法做得很完美,留下一些潜在的问题。

1. 原生组件同层渲染更稳定

iOS 下原生组件同层渲染的原理先前有介绍过,本质上是在 WKWebView 黑盒下一种取巧的实现方式,并不能完美融合到 WKWebView 的渲染流程,因此很容易在一些特殊的样式发生变化后,同层渲染会失效。在 Skyline 下可以很好地融合到渲染流程中,因此会更稳定。

2. 无需页面恢复机制

iOS 下 WKWebView 会受操作系统统一管理,当内存紧张时,操作系统就会将不在屏的 WKWebView 回收,会使得微信小程序除前台以外的页面丢失,虽然在页面返回时,我们对页面做了恢复,但页面的状态并不能 100% 还原。在 Skyline 下则不再有该问题。

3. 无页面栈层数限制

由于 WebView 的内存占用较大,页面层级最多有 10 层,而 Skyline 在内存方面更有优势,因此在连续 Skyline 页面跳转(复用同一引擎实例)的情况下,不再有该限制。


全新的交互动画体系

要达到类原生应用的体验,除渲染性能要好外,做好交互动画也很关键。在 Web 体系下,难以做到像素级可控,交互动画衔接不顺畅,究其原因,在于缺失了一些重要的能力。为此,Skyline 提供一套全新的交互动画能力。

1. Worklet 动画

Worklet 机制是 Skyline 交互动画体系的基础,它能够很方便地将 JavaScript 代码跑在渲染线程,那么基于 Worklet 机制的 动画模块,便能够在渲染线程同步运行动画相关逻辑,使动画不再会有延迟掉帧。

2. 手势系统

在原生应用的交互动画里,手势识别与协商是一个很重要的特性,而这块在 Web 体系下是缺失的,因此 Skyline 提供了基于 Worklet 机制的 手势系统。

  • 支持常用手势的识别,如缩放、拖动、双击等,并能够渲染线程同步监听手势、执行手势相关逻辑;
  • 支持手势协商处理,能够在遇到手势冲突(常见于滚动容器下)时决定让哪个手势生效,以实现更顺畅的动画衔接。

3. 自定义路由

页面间中转进行自定义的转场动画,在原生应用里也是一个很常见的交互动画。在原来的微信小程序架构下,每个页面都是独立的 WebView 渲染,互相隔离,其跨页能力是基本不具备的。因此,Skyline 提供了基于 Worklet 机制的 自定义路由模块,能实现市面上大多数页面转场动画效果。

4. 共享元素动画

支持 跨页面共享元素,能够很方便地将上一个页面的元素“共享”到下一个页面,并伴随着过渡动画,同时支持了一套可定制化接口,能实现自定义的过渡动画。

5. 内置组件扩展

对内置组件的扩展也是重要一环,特别是 scroll-view 组件,很多交互动画与滚动息息相关,Skyline 添加了很多在 Web 下很难做到又非常重要的特性。

  • 内置下拉刷新的实现,并完善相关事件。原来 WebView 的实现基于 transform,性能不够好且动画衔接不顺畅。
  • 提供“下拉二楼”交互的机制。
  • 提供 sticky 吸顶组件,能很方便地实现吸顶元素交错切换。
  • 使 scroll-view 组件在内容未溢出时也能滚动,让用户得到及时的交互反馈。
  • 为 scroll-view 组件提供更多控制能力,如最小触发滚动距离(min-drag-distance)、滚动结束事件(scrollend)、滚动原因(isDrag)等。
  • 提供原生的 swiper 实现,相比 WebView 基于 transform 的实现,性能更好。


更多的高级能力

除了交互动画的系列能力外,借助 Skyline 的优势,我们还提供了很多高级特性。

1. 提供 grid-view 瀑布流组件

瀑布流是一种常用的列表布局方式,得益于 Skyline 在布局过程中的可控性,我们直接在底层实现并提供出来,渲染性能要比 WebView 更优。

2. 提供 snapshot 截图组件

大多数微信小程序都会基于 canvas 实现自定义分享图的功能,一方面,需要通过 canvas 绘图指令手动实现,较为繁琐;另一方面,在分享图的布局较复杂时,或者在制作长图时会受限于系统对 canvas 尺寸限制,canvas 的方案实现成本都会很大。得益于 Skyline 在渲染过程中的可控性,Skyline 能直接对 WXML 子树进行截图,因此我们直接提供了截图组件,这样能复用更完善的 WXSS 能力,极大降低开发成本。

3. scroll-view 组件支持列表反转

在聊天对话的场景下,列表的滚动常常是反向的(往底部往上滚动),若使用正向滚动来模拟会有很多多余的逻辑,而且容易出现跳动,而 scroll-view 提供的 reverse 属性很好的解决这一问题。


还有更多计划提供出来的特性,请详见特性状态

Skyline 渲染引擎 / 概览 / 性能对比

性能对比

首屏耗时是衡量渲染性能一个最重要的指标。微信小程序的首屏耗时可以从上一个页面的点击到下一个页面 FCP(First Contentful Paint)的时间来衡量,首屏性能的好坏会影响上一个页面点击时的响应速度,以及下一个页面的白屏时间。

目前已经有一些微信小程序以 Skyline 模式在线上运行,以下取微信小程序助手的线上数据,可以看出 Skyline 的首屏时间比 WebView 快 66%,并且手机性能越低端,差异就越明显。

在内存占用方面,由于线上微信小程序未能较准确获取内存数据,我们在本地收集了一些测量数据,测量方法均是测 n 次取平均值,并且采用两个指标,一个是打开微信小程序示例首页,静置 30s 后采集数据,另一个是切 Tab 页面,同样静默后采集数据。

测量数据如下图所示(单位 M),可以看出,单个页面 Skyline 的占用比 WebView 减少 35%,两个页面 Skyline 的内存占用比 WebView 减少 50%,随着打开的页面变多,内存差距越明显。

与内存占用的测量类似,CPU 利用率的测量数据如下图所示(单位 %),也能看出,Skyline 相对 WebView 也有不错的提升。

效果对比

这里,我们分别录制了微信小程序助手在 Skyline 与 WebView 下的操作视频,从视频中的对比可以更直观地感受到区别。

视频录制的测试机为 OPPO R17,其中左边是 Skyline,右边是 WebView

快速体验

环境要求

目前,安卓微信 8.0.33、iOS 微信 8.0.34 起内置了 Skyline 渲染引擎,可先更新到该版本,预览时通过强切开关打开,方可体验 Skyline。

快速体验

以下微信小程序已适配 Skyline,可直接扫码打开体验。

扫码打开微信小程序助手,其中首页、切换微信小程序、版本查看、成员管理和成员申请均已适配。

扫码微信小程序示例,可进入 交互动画 tab 页体验 Skyline 的新特性。

演示案例

以下是用 Skyline 实现的各种常见交互动画的示例,可通过视频直接预览效果,也可直接扫码在移动端上体验。

通讯录
使用 scroll-view 自定义模式,配合 sticky 吸顶布局容器,实现通讯录字母交错吸顶的效果。
点击查看源代码
半屏
基于 worklet 动画,通过手势协商机制,实现在半屏内列表往下拉到顶之后,无缝切换到半屏下拉的效果。
点击查看源代码
分段半屏
通过 worklet 动画、手势协商,实现分段半屏,处于不同位置时联动半屏后的地图改变缩放比例。
点击查看源代码
相册
使用自定义路由、共享元素动画、手势系统等实现列表中图片共享放大过渡到图片预览页效果,并实现预览图片的手势交互。
点击查看源代码
Tab 指示条
利用 swiper 切换时的逐帧回调,配合 worklet 动画实现 tab 指示条顺滑切换的效果。
点击查看源代码
卡片转场
scroll-view 瀑布流模式配合共享元素动画实现卡片柔性转场效果。
点击查看源代码
搜索栏吸附
scroll-view 吸顶布局结合 worklet 布局轻松实现搜索栏吸附效果。
点击查看源代码
沉浸式商品浏览
微信小程序手势 + worklet 在页面中实现广告、商品无缝切换。
点击查看源代码

微信小程序 Skyline 基础组件支持与差异

通用特性

特性 支持情况
无障碍访问 暂时只支持 aria-role、label、hidden、disabled
深色模式 支持
原生组件 都支持同层渲染
WeUI v2 支持

组件支持情况

总体来说,常用的组件基本都已经支持,已经标记为废弃的特性在 Skyline 下不会考虑支持。下面列出基础组件的整体情况,具体细节可以跳转到对应组件文档查看。

组件
支持情况 组件差异与备注
text 基本支持 内联文本只能用 text 组件;可以通过 span 组件与 text 或 image 内联;
view / cover-view 完全支持 涉及文本节点请参考 text 组件
image / cover-image 基本支持 SVG 支持度已经完善;部分不常用的 mode 未支持
button 完全支持
scroll-view 完全支持 需要显式指定 type="list";部分属性不需要对齐;额外支持大量新特性
swiper / swiper-item 完全支持 增强了大量特性
input / textarea 完全支持 光标选区、菜单略有不同
navigator 完全支持 只能嵌套 text 组件或文本节点;可以通过 span 组件与 text 或 image 内联
map 完全支持 开发者工具暂时不支持调试,请使用真机预览
canvas 完全支持 开发者工具暂时不支持调试,请使用真机预览
radio / radio-group 完全支持
label 完全支持
video 基本支持 全屏在 3.3.0 版本已经支持,投屏暂时不支持,开发者工具暂时不支持调试,请使用真机预览
checkbox / checkbox-group 完全支持
picker 完全支持
camera 完全支持 开发者工具暂时不支持调试,请使用真机预览
root-portal 完全支持
form 完全支持
ad 完全支持
official-account 完全支持
functional-page-navigator 支持中
live-player / live-pusher 完全支持
picker-view 基本支持 indicator-class 和 mask-style 属性暂时不支持
voip-room 完全支持
rich-text 完全支持 渲染结果可能略有不同,涉及到样式支持度;当 mode=web 时则完全与 webview 对齐
match-media 待考虑
keyboard-accessary 待考虑 可以通过 input 的 worklet:onkeyboardheightchange 回调实现
page-meta 基本支持 与全局滚动相关的属性不支持
editor 暂不考虑
web-view 暂不考虑 建议承载 web-view 的页面单独配置 "renderer": "webview"
movable-area / movable-view 暂不考虑 可以用手势配合 worklet 动画方案替代
page-container 基本支持
share-element 完全支持 与 WebView 使用方式不同,特性有所增强
icon 完全支持
progress 暂不考虑
slider 完全支持
switch 完全支持
xr-frame 暂未支持
navigation-bar 不考虑 Skyline 只能用自定义导航
open-data 完全支持 已废弃的特性不支持

Skyline 新增组件

组件
组件说明
span 用于支持内联文本和 image、navigator 的混排
snapshot 截图组件
sticky-header、sticky-section 吸顶布局容器
nested-scroll-header、nested-scroll-body 嵌套 scroll-view 场景中使用的节点,仅支持作为 <scroll-view type="nested"> 模式的直接子节点
list-view 列表布局容器,仅支持作为 <scroll-view type="custom"> 模式的直接子节点或 sticky-section 组件的直接子节点
grid-view Skyline 下的网格布局容器和瀑布流布局容器
draggable-sheet 半屏可拖拽组件
double-tap-gesture-handler 双击时触发手势
force-press-gesture-handler iPhone 设备重按时触发手势
horizontal-drag-gesture-handler 横向滑动时触发手势
long-press-gesture-handler 长按时触发手势
pan-gesture-handler 拖动(横向或纵向)时触发手势
scale-gesture-handler 多指缩放时触发手势
tap-gesture-handler 点击时触发手势
vertical-drag-gesture-handler 纵向滑动时触发手势

Skyline WXSS 样式支持与差异

模块支持

模块 支持情况 备注
CSS Animation 安卓 8.0.37,iOS 8.0.39,支持情况见下表
背景与边框 常用的基本支持,详见属性支持
盒子模型 支持 border-box 和 content-box,没有 BFC
Inline 布局 × 开发中
Inline-Block 布局 × 仅支持在 text 组件里的嵌套结构使用,完整版本开发中
Block 布局 详见开启默认 Block 布局
Flex 布局 包括 inline-flex 布局
字体 基本支持,也支持自定义字体
Positioned 布局 支持情况见下表。sticky 可使用 sticky-header/sticky-section 替代
CSS Transition
CSS Variable(CSS 变量) 安卓 8.0.35,iOS 8.0.38
Media queries 只支持 DarkMode
Font-face 只支持 ttf 格式

选择器支持

类别 示例 支持度 备注
通配选择器 * {} ×
元素选择器 tag {}
类选择器 .class {}
ID 选择器 #id {}
分组选择器 a, b {}
直接子代选择器 a > b {}
后代选择器 a b {}
属性选择器 [attr] {} ×
一般兄弟选择器 a ~ b {} 8.0.49
紧邻兄弟选择器 a + b {} 8.0.49
伪类选择器 :active {} 支持 :first-child / :last-child;微信 8.0.49 起(对应 Skyline 1.3.0)支持 :not / :only-child / :empty;微信 8.0.50 起(对应 Skyline 1.3.3)支持 :nth-child
伪元素选择器 ::before {} 只支持 ::before 和 ::after

属性支持

样式属性 支持格式 默认值 备注
display none / flex / block flex 默认值可通过配置改成 block
position relative / absolute / fixed relative fixed 在微信客户端 8.0.43 版本开始支持,只支持相对于窗口 viewport 定位,不支持 top / left / bottom / right 默认值 auto 解析,z-index 只作用在兄弟节点;sticky 可使用 sticky-header/sticky-section 替代
overflow hidden / visible visible scroll 不支持,只能通过 scroll-view 实现;不支持单独设置 overflow-x/y
pointer-events auto / none auto
box-sizing border-box / content-box border-box
transform none / <transform-function> none
transform-origin left / center / right / top / bottom / {1, 2} 50% 50%
z-index <float> 0 不支持层叠上下文,只对兄弟节点生效;不支持在 scroll-view 下的直接子节点上应用
visibility visible / hidden visible
color black
opacity <float> 1
align-items stretch / center / flex-start / flex-end / baseline stretch
align-self auto / stretch / center / flex-start / flex-end / baseline auto
align-content stretch / center / flex-start / flex-end / space-between / space-around auto
justify-content center / flex-start / flex-end / space-between / space-around / space-evenly flex-start
flex-direction row / row-reverse / column / column-reverse column
flex-wrap nowrap / wrap / wrap-reverse nowrap
flex-grow <float> 0
flex-shrink <float> 1
flex-basis auto
order <float> 0
gap <length> 0
flex 简写属性,支持解析但以展开属性为准
background-color transparent
background-image none / none 不支持多张图片
background-size contain / cover / [ | auto]{1, 2} auto
background-position left / center / right / top / bottom / 0 0 完全支持 <bg-position>#,请参考 MDN
background-repeat repeat-x / repeat-y / repeat / no-repeat repeat
background 简写属性,支持解析但以展开属性为准
width auto
height auto
min-width auto
min-height none
max-width auto
max-height none
left auto
right auto
top auto
bottom auto
padding {1,4} 0 0 0 0
padding-left 0
padding-top 0
padding-right 0
padding-bottom 0
margin {1,4} 0 0 0 0
margin-left 0
margin-top 0
margin-right 0
margin-bottom 0
border-left-width 3
border-left-style none
border-left-color black 默认值与网页不同,网页默认值是 currentcolor
border-top-width 3
border-top-style none
border-top-color black 默认值与网页不同,网页默认值是 currentcolor
border-right-width 3
border-right-style none
border-right-color black 默认值与网页不同,网页默认值是 currentcolor
border-bottom-width 3
border-bottom-style none
border-bottom-color black 默认值与网页不同,网页默认值是 currentcolor
border-width 简写属性,支持解析但以展开属性为准
border-style 简写属性,支持解析但以展开属性为准
border-color 简写属性,支持解析但以展开属性为准
border-left 简写属性,支持解析但以展开属性为准
border-right 简写属性,支持解析但以展开属性为准
border-top 简写属性,支持解析但以展开属性为准
border-bottom 简写属性,支持解析但以展开属性为准
border 简写属性,支持解析但以展开属性为准
box-shadow none / inset? && {2,4} && ? none 不支持多个叠加
border-top-left-radius {1, 2} 0 当 border-radius 不为 0 时,四边的 border-width 可以不一样,但四边的 border-color 和 border-style 必须一致
border-top-right-radius {1, 2} 0 当 border-radius 不为 0 时,四边的 border-width 可以不一样,但四边的 border-color 和 border-style 必须一致
border-bottom-left-radius {1, 2} 0 当 border-radius 不为 0 时,四边的 border-width 可以不一样,但四边的 border-color 和 border-style 必须一致
border-bottom-right-radius {1, 2} 0 当 border-radius 不为 0 时,四边的 border-width 可以不一样,但四边的 border-color 和 border-style 必须一致
border-radius 简写属性,支持解析但以展开属性为准
transition-property none / all / transform / opacity 等 all 基本都支持,暂不一一列举
transition-duration <time> 0
transition-timing-function
transition-delay <time> 0
transition 简写属性,支持解析但以展开属性为准
font 简写属性,支持解析但以展开属性为准;不支持 caption / icon 等系统字体;
font-size 16px 不支持百分比;不支持 keyword(如 smaller 等)
line-height normal / <number> / / <percent> normal
text-align left / center / right / justify / start / end start
font-weight normal / bold / <float> normal
white-space normal / nowrap / normal
text-overflow clip / ellipsis clip 仅作用于文本节点
word-break normal / break-all normal
word-spacing normal / normal
letter-spacing normal / normal
font-family serif / sans-serif / monospace / cursive / fantasy / <string>
font-style normal / italic normal
text-decoration-line none / underline / overline / line-through none 仅作用于文本节点
text-decoration-style solid / double / dotted / dashed / wavy solid 仅作用于文本节点
text-decoration-color black 仅作用于文本节点;默认值和网页不同,网页默认值是 currentcolor
text-decoration 简写属性,支持解析但以展开属性为准;当前仅支持设置一种类型;暂不支持复合使用 text-decoration
text-shadow none / ? && {2,3} none
backdrop-filter none / [] none 不支持多个函数;不支持 drop-shadow;不支持 url;与 opacity 混合时有问题;blur 在某些情况下表现不一致;
filter none / [] none 不支持多个函数;不支持 drop-shadow;不支持 url;
mask-image none / none 不支持多张图片
animation-delay <time> 0
animation-direction normal / reverse / alternate / alternate-reverse normal
animation-duration <time> 0
animation-fill-mode forwards / both none none 和 backwards 暂时不支持,表现都等同于 forwards
animation-iteration-count infinite / <number> 1
animation-name none / <custom-ident> none
animation-timing-function
animation 简写属性,支持解析但以展开属性为准
will-change auto / contents auto 声明绘制边界,优化渲染性能

类型支持列表

类别 格式 支持度 备注
auto
px
rem
em ×
rpx
vw
vh
vmin
vmax
ratio
env() 只支持 safe-area-inset-* 系列
calc()
color keywords
transparent
currentColor × 考虑支持
rgb[a]
#RRGGBB / #RGB
hsl
hsla
url()
linear-gradient()
radial-gradient()
conic-gradient()
none
hidden
solid
dashed
dotted
brightness() 多个函数暂时不支持
contrast()
saturate()
huerotate()
invert()
opacity()
grayscale()
specia()
drop-shadow ×
deg
grad
rad
turn
ease
ease-in
ease-out
ease-in-out
linear
cubic-bezier
steps
step-start
step-end

开启默认Block布局

在微信小程序的 Skyline 渲染模式下,节点默认使用 flex 布局。你可以通过以下配置,将其切换为默认的 block 布局。

平台 最低版本
Android 8.0.34
IOS 8.0.36
开发者工具 Nightly Build (1.06.2304262)
基础库 2.31.1

app.jsonpage.json 中配置:

rendererOptions: {
  "skyline": {
    "defaultDisplayBlock": true,
  }
}

开启默认ContentBox盒模型

在微信小程序的 Skyline 渲染模式下,节点默认使用 border-box 盒模型。你可以通过以下配置,将其切换为默认的 content-box 盒模型。

平台 最低版本
Android 8.0.42
IOS 8.0.42
开发者工具 Nightly Build (1.06.2310092)
基础库 3.1.0

app.jsonpage.json 中配置:

rendererOptions: {
  "skyline": {
    "defaultContentBox": true,
  }
}

开启tag选择器全局匹配

在微信小程序的 Skyline 渲染模式下,tag 选择器会遵循样式隔离机制,而 WebView 模式下则不受此约束。你可以通过配置 tagNameStyleIsolation: legacy 来对齐 WebView 的表现。如果指定 tagNameStyleIsolation: isolated,则会遵循样式隔离机制。

平台 最低版本
Android 8.0.51
IOS 8.0.51
开发者工具 Nightly Build (1.06.2409032)
基础库 3.6.0

app.jsonpage.json 中配置:

rendererOptions: {
  "skyline": {
    "tagNameStyleIsolation": "legacy",
  }
}

开启scroll-view自动撑开

在微信小程序的 Skyline 渲染模式下,scroll-view 默认需要指定宽高才能撑开。你可以通过以下配置,将其切换为根据内容自动撑开。

平台 最低版本
Android 8.0.54
IOS 8.0.54
基础库 3.7.2

app.jsonpage.json 中配置:

rendererOptions: {
  "skyline": {
    "enableScrollViewAutoSize": true,
  }
}

开启keyframe样式全局共享

微信小程序的 Skyline 渲染模式下,@keyframe 规则会遵循样式隔离机制,而 WebView 模式下则不受此约束。你可以通过配置 tagNameStyleIsolation: legacy 来对齐 WebView 的表现。如果指定 tagNameStyleIsolation: isolated,则会遵循样式隔离机制。

平台 最低版本
Android 8.0.57
IOS 8.0.57
基础库 3.8.0

app.jsonpage.json 中配置:

rendererOptions: {
  "skyline": {
    "keyframeStyleIsolation": "legacy",
  }
}

worklet 动画

微信小程序采用双线程架构,渲染线程(UI 线程)和逻辑线程(JS 线程)分离。JS 线程不会影响 UI 线程的动画表现,如滚动效果。但引入的问题是,UI 线程的事件发生后,需跨线程传递到 JS 线程,进而触发开发者回调,当做交互动画(如拖动元素)时,这种异步性会带来较大的延迟和不稳定。

worklet 动画正是为解决这类问题而诞生的,使得微信小程序可以做到类原生动画般的体验。

立即体验

使用 worklet 动画能力时确保以下两项:

  • 确保开发者工具右上角 > 详情 > 本地设置里的 将 JS 编译成 ES5 选项被勾选上 (代码包体积会少量增加)
  • worklet 动画相关接口仅在 Skyline 渲染模式下才能使用

首先,我们需要了解一些相关概念。

概念一:worklet 函数

一种声明在开发者代码中,可运行在 JS 线程或 UI 线程的函数,函数体顶部有 'worklet' 指令声明。

worklet 函数定义

function someWorklet(greeting) {
  'worklet';
  console.log(greeting);
}

// 运行在 JS 线程
someWorklet('hello') // print: hello

// 运行在 UI 线程
wx.worklet.runOnUI(someWorklet)('hello') // print: [ui] hello

worklet 函数间相互调用

const name = 'skyline'

function anotherWorklet() {
  'worklet';
  return 'hello ' + name;
}

// worklet 函数间可互相调用
function someWorklet() {
  'worklet';
  const greeting = anotherWorklet();
  console.log('another worklet says ', greeting);
}

wx.worklet.runOnUI(someWorklet)() // print: [ui] another worklet says hello skyline

从 UI 线程调回到 JS 线程

function someFunc(greeting) {
  console.log('hello', greeting);
}

function someWorklet() {
  'worklet'
  // 访问非 worklet 函数时,需使用 runOnJS
  // someFunc 运行在 JS 线程
  runOnJS(someFunc)('skyline')
}

wx.worklet.runOnUI(someWorklet)() // print: hello skyline

概念二:共享变量

JS 线程创建,可在两个线程间同步的变量。

const { shared, runOnUI } = wx.worklet

const offset = shared(0)
function someWorklet() {
  'worklet'
  console.log(offset.value) // print: 1
  // 在 UI 线程修改
  offset.value = 2
  console.log(offset.value) // print: 2
}
// 在 JS 线程修改
offset.value = 1

runOnUI(someWorklet)()

shared 函数创建的变量,我们称为 sharedValue 共享变量。用法上可类比 vue3 中的 ref,对它的读写都需要通过 .value 属性,但需注意的是它们并不是一个概念。sharedValue 的用途主要如下。

跨线程共享数据

worklet 函数捕获的外部变量,实际上会被序列化后生成在 UI 线程的拷贝,如下代码中, someWorklet 捕获了 obj 变量,尽管我们修改了 objname 属性,但在 someWorklet 声明的位置,obj 已经被序列化发送到了 UI 线程,因此后续的修改是无法同步的。

const obj = { name: 'skyline'}
function someWorklet() {
  'worklet'
  console.log(obj.name) // 输出的仍旧是 skyline
}
obj.name = 'change name'

wx.worklet.runOnUI(someWorklet)() 

sharedValue 就是用来在线程间同步状态变化的变量。

const { shared, runOnUI } = wx.worklet

const offset = shared(0)
function someWorklet() {
  'worklet'
  console.log(offset.value) // 输出的是新值 1
}
offset.value = 1

runOnUI(someWorklet)() 

驱动动画

worklet 函数和共享变量就是用来解决交互动画问题的。相关接口 applyAnimatedStyle 可通过页面/组件实例访问,接口文档参考

<view id="moved-box"></view>
<view id="btn" bind:tap="tap">点击驱动小球移动</view>
Page({
  onLoad() {
    const offset = wx.worklet.shared(0)
    this.applyAnimatedStyle('#moved-box', () => {
      'worklet';
      return {
        transform: `translateX(${offset.value}px)`
      }
    })
    this._offset = offset
  },
  tap() {
    // 点击时修改 sharedValue 值,驱动小球移动
    this._offset.value = Math.random()
  }
})

当点击按钮 #btn 时,我们用随机数给 offset 进行赋值,小球会随之移动。

applyAnimatedStyle 接口的第二个参数 updater 为一个 worklet 函数,其捕获了共享变量 offset,当 offset 的值变化时,updater 会重新执行,并将返回的新 styleObject 应用到选中节点上。

当然,光看这个例子,跟用 setData 看好像没有什么区别。但当 worklet 动画和手势结合时,就产生了质变。

示例用法

手势处理

<pan-gesture-handler onGestureEvent="handlepan">
  <view class="circle"></view>
</pan-gesture-handler>
Page({
  onLoad() {
    const offset = wx.worklet.shared(0);
    this.applyAnimatedStyle('.circle', () => {
      'worklet';
      return {
        transform: `translateX(${offset.value}px)`
      };
    });
    this._offset = offset;
  },
  handlepan(evt) {
    'worklet';
    if (evt.state === GestureState.ACTIVE) {
      this._offset.value += evt.deltaX;
    }
  }
});

当手指在 circle 节点上移动时,会产生平滑的拖动效果。handlepan 回调触发在 UI 线程,同时我们修改了 offset 的值,会在 UI 线程产生动画,不必再绕回到 JS 线程。

查看更多手势处理的示例代码

自定义动画曲线

<view id="moved-box"></view>
<view id="btn" bind:tap="tap">点击驱动小球移动</view>
const { shared, Easing, timing } = wx.worklet
Page({
  onLoad() {
    const offset = shared(0)
    this.applyAnimatedStyle('#moved-box', () => {
      'worklet';
      return {
        transform: `translateX(${offset.value}px)`
      }
    })
    this._offset = offset
  },
  tap() {
    /**
     * 目标值 300
     * 动画时长 200ms
     * 动画曲线 Easing.ease
     */
    this._offset.value = timing(300, {
      duration: 200,
      easing: Easing.ease
    })
  }
})

内置如 timingspring 等常见动画方式的封装方法,开发者可自定义动画曲线,同时可对不同的动画类型进行组合、重复,形成交织动画。

查看更多不同动画类型用法

查看更多缓动函数的示例代码

相关接口

  • 基础类型 shared、 derived、cancelAnimation

  • 工具函数 runOnUI、runOnJS

  • 动画类型 timing、spring、decay

  • 组合动画 sequence、repeat、delay

  • 缓动函数 Easing

  • 页面实例方法 applyAnimatedStyle、clearAnimatedStyle

注意事项

worklet 函数内部有一些调用上的限制需要留意

  1. 页面/组件实例中定义的 worklet 类型回调函数,内部访问 wx 上的接口,可按如下方式,通过 runOnJS 调回到 JS 线程。
  2. worklet 函数引用的外部变量,对象类型将被 Object.freeze 冻结,使用时需直接访问对象上具体的属性。
<tap-gesture-handler onGestureEvent="handleTap">
  <view class="circle" >showModal</view>
</tap-gesture-handler>
const { runOnJS, timing } = wx.worklet
Page({
  data: {
    msg: 'Skyline'
  },
  onLoad() {
    const toValue = 100
    const showModal = this.showModal.bind(this)
    timing(toValue, { duration: 300 }, () => {
      'worklet'
      runOnJS(showModal)(msg)
    })
  },
  handleTap() {
    'worklet'

    // 非常重要!!!
    // const { msg } = this.data
    // 通过解构 this.data 访问 msg,此时 this.data 将被 Object.freeze 冻结,会导致 setData 无法生效
    // 而通过 this.data.msg 则不会冻结 this.data
    const msg = `hello ${this.data.msg}`

    // 非常重要!!!
    // Page method 必须通过 this.methodName.bind(this) 访问
    const showModal = this.showModal.bind(this)

    // 场景一:返回 JS 线程
    runOnJS(showModal)(msg)

    // 场景二:动画完成回调里返回 JS 线程
    const toValue = 100
    timing(toValue, { duration: 300 }, () => {
      'worklet'
      runOnJS(showModal)(msg)
    })

    // 场景三:调用其它 worklet 函数
    this.doSomething()
  },
  doSomething() {
    'worklet'
  },
  showModal(msg) {
    wx.showModal({
      title: msg // title: hello skyline
    })
  },
})

手势系统

业务开发中,我们常需要监听节点 touch 事件,处理拖拽、缩放相关逻辑。由于 Skyline 采用双线程架构,在进行这样的交互动画时,会具有较大的异步延迟,这点可以参考 wxs 响应事件

Skylinewxs 代码运行在 JS 线程,而事件产生在 UI 线程,因此 wxs 动画 性能有所降低,为了提升微信小程序交互体验的效果,我们内置了一批手势组件,使用手势组件的优势包括

  1. 免去开发者监听 touch 事件,自行计算手势逻辑的复杂步骤
  2. 手势组件直接在 UI 线程响应,避免了传递到 JS 线程带来的延迟

效果展示

下图演示了使用手势、协商手势实现的拖动小球,半屏弹窗手势拖动关闭,分段半屏等效果。点击查看更多 Skyline 示例。

扫码微信小程序示例,分别体验 基础手势协商手势 新特性

手势组件

组件名称 触发时机
<tap-gesture-handler> 点击时触发
<double-tap-gesture-handler> 双击时触发
<scale-gesture-handler> 多指缩放时触发
<force-press-gesture-handler> iPhone 设备重按时触发
<pan-gesture-handler> 拖动(横向/纵向)时触发
<vertical-drag-gesture-handler> 纵向滑动时触发
<horizontal-drag-gesture-handler> 横向滑动时触发
<long-press-gesture-handler> 长按时触发

工作原理

手势组件为虚组件,真正响应事件的是其直接子节点。下方代码中,我们给 container 节点添加了两种类型的手势监听。

  1. 当在屏幕上横向滑动时,horizontal-drag 手势节点的回调将被触发;
  2. 当在屏幕上纵向滑动时,vertical-drag 手势节点的回调将被触发。
<horizontal-drag-gesture-handler>
  <vertical-drag-gesture-handler>
     <view id="container"></view>
  </vertical-drag-gesture-handler>
</horizontal-drag-gesture-handler>

触摸屏幕时,渲染引擎会从内到外对手势监听器进行手势识别,当某个手势监听器满足条件时,其余的手势监听器将会失效。如在 scroll-view 内部添加纵向的手势监听时,将会阻断 scroll-view 内的手势监听器,导致无法滑动。

<scrol-view>
  <vertical-drag-gesture-handler>
     <view id="container"></view>
  </vertical-drag-gesture-handler>
</scroll-view>

需要注意的是,pan 类型的判定条件比 vertical-drag 要宽松,因此纵向滑动时,vertical-drag 将会响应,而 pan 则会失效。当横向滑动时,pan 类型则会响应。

<vertical-drag-gesture-handler>
  <pan-gesture-handler>
     <view id="container"></view>
  </pan-gesture-handler>
</vertical-drag-gesture-handler>

通用属性

属性 类型 默认值 必填 说明
tag string 声明手势协商时的组件标识
worklet:ongesture eventhandler 手势处理回调
worklet:should-response-on-move callback 手指移动过程中手势是否响应
worklet:should-accept-gesture callback 手势是否应该被识别
simultaneous-handlers Array<string> [] 声明可同时触发的手势节点
native-view string 代理的原生节点类型

native-view 支持的枚举值有 scroll-viewswiper。滚动容器纵向滚动时,使用 <vertical-drag-gesture-handler> 手势组件代理内部手势,横向滚动时,则使用 <horizontal-drag-gesture-handler>

  • eventhandler 类型是事件回调,无返回值
  • callback 类型是开发者注册到组件的回调函数,会在适当时机被执行以读取返回值
  • 所有的回调都只能传入一个 worklet 回调

事件回调参数

worklet:should-response-on-move

返回的参数 pointerEvent 各字段如下。每次触摸移动时进行回调,返回 false 时,则对应的手势组件无法收到该次 move 事件。

属性 类型 说明
identifier number Touch 对象的唯一标识符
type string 事件类型
deltaX number 相对上一次,X 轴方向移动的坐标
deltaY number 相对上一次,Y 轴方向移动的坐标
clientX number 触点相对于可见视区左边缘的 X 坐标
clientY number 触点相对于可见视区上边缘的 Y 坐标
radiusX number 返回能够包围接触区域的最小椭圆的水平轴 (X 轴) 半径
radiusY number 返回能够包围接触区域的最小椭圆的垂直轴 (Y 轴) 半径
rotationAngle number 返回一个角度值,表示上述由radiusX 和 radiusY 描述的椭圆为了尽可能精确地覆盖用户与平面之间的接触区域而需要顺时针旋转的角度
force number 用户对触摸平面的压力大小
timeStamp number 事件触发的时间戳
Page({
  shouldResponseOnMove(pointerEvent) {
    'worklet'
    return false
  }
})

worklet:should-accept-gesture

用法如下,框架手势识别生效时进行回调,由开发者决定手势是否生效。以 Pan 手势为例。

手指触摸屏幕时进入 State.Possible 状态,shouldAcceptGesture 返回 false 后进入 State.CANCELLED 状态,返回 true 后进入 State.Begin 状态,可继续接收手续 move 事件。

Page({
  shouldAcceptGesture() {
    'worklet'
    return false
  }
})

worklet:ongesture

不同类型手势组件返回的参数如下

tap / double-tap

属性 类型 说明
state number 手势状态
absoluteX number 相对于全局的 X 坐标
absoluteY number 相对于全局的 Y 坐标

pan / vertical-drag / horizontal-drag

属性 类型 说明
state number 手势状态
absoluteX number 相对于全局的 X 坐标
absoluteY number 相对于全局的 Y 坐标
deltaX number 相对上一次,X 轴方向移动的坐标
deltaY number 相对上一次,Y 轴方向移动的坐标
velocityX number 手指离开屏幕时的横向速度(pixel per second)
velocityY number 手指离开屏幕时的纵向速度(pixel per second)

scale

属性 类型 说明
state number 手势状态
focalX number 中心点相对于全局的 X 坐标
focalY number 中心点相对于全局的 Y 坐标
focalDeltaX number 相对上一次,中心点在 X 轴方向移动的坐标
focalDeltaY number 相对上一次,中心点在 Y 轴方向移动的坐标
scale number 放大或缩小的比例
horizontalScale number scale 的横向分量
verticalScale number scale 的纵向分量
rotation number 旋转角(单位:弧度)
velocityX number 手指离开屏幕时的横向速度(pixel per second)
velocityY number 手指离开屏幕时的纵向速度(pixel per second)
pointerCount number 跟踪的手指数
  • 多指滑动时,focalXfocalY 为多个触摸点中心焦点的坐标
  • 单指滑动时,pointerCount = 1,此时效果同 pan-gesture-handlerscale 手势是 pan 的超集。

long-press

属性 类型 说明
state number 手势状态
absoluteX number 相对于全局的 X 坐标
absoluteY number 相对于全局的 Y 坐标
translationX number 相对于初始触摸点的 X 轴偏移量
translationY number 相对于初始触摸点的 Y 轴偏移量
velocityX number 手指离开屏幕时的横向速度(pixel per second)
velocityY number 手指离开屏幕时的纵向速度(pixel per second)

force-press

属性 类型 说明
state number 手势状态
absoluteX number 相对于全局的 X 坐标
absoluteY number 相对于全局的 Y 坐标
pressure number 压力大小

手势状态

所有手势 worklet:ongesture 回调均会返回一个 state 状态字段。

enum State {
  // 手势未识别
  POSSIBLE = 0,
  // 手势已识别
  BEGIN = 1,
  // 连续手势活跃状态
  ACTIVE = 2,
  // 手势终止
  END = 3,
  // 手势取消
  CANCELLED = 4,
}

我们将手势分为如下两种类型:

  1. 离散手势:tapdouble-tap,仅触发一次
  2. 连续手势:其它类型的手势组件,随手指拖动会触发多次

tap-gesture-handler 手势组件返回的 state 始终为 1。

pan-gesture-handler 手势组件在一个完整的拖动过程中,state 会按如下方式改变

  1. 手指刚接触屏幕时,state = 0
  2. 移动一小段距离,pan 手势判定生效时,state = 1
  3. 继续移动,state = 2
  4. 手指离开屏幕 state = 3

由于嵌套的手势会产生冲突(仅有一个最终判定识别生效),因此连续手势 state 的变化可能有如下一些情景,开发者需要根据 state 值来处理一些异常情况。

  1. POSSIBLE -> BEGIN -> ACTIVE -> END 正常流程
  2. POSSIBLE -> BEGIN -> ACTIVE -> CANCELLED 提前中断
  3. POSSIBLE -> CANCELLED 手势未识别

并不是所有的连续手势均有 POSSIBLE 状态,如 scale-gesture-handler 手势组件,当双指触摸后松手,state 变化如下:

  1. 双指触摸屏幕,state = 1, pointerCount = 2
  2. 双指放大操作,state = 2, pointerCount = 2
  3. 双指离开屏幕,state = 3, pointerCount = 1,之后会相继回调 a. state = 1, pointerCount = 1 b. state = 2, pointerCount = 1 c. state = 3, pointerCount = 0

注意事项

  • 手势组件仅在 Skyline 渲染模式下才能使用
  • 手势组件为虚组件,不会进行布局,手势组件上设置 styleclass 是无效的
  • 手势组件仅能含有一个直接子节点,否则不生效
  • 手势组件的父组件样式会直接影响其子节点
  • 手势组件的回调函数均需声明为 worklet 函数
  • 手势不同于普通 touch 事件,不会进行冒泡
  • 手势组件的 eventhandler / callback 均需声明为 worklet 函数,回调在 UI 线程触发

使用方法

示例代码

在开发者工具中预览效果

Chaining API init 函数示例代码

在开发者工具中预览效果

示例一:监听拖动手势

<pan-gesture-handler on-gesture-event="handlePan">
  <view></view>
</pan-gesture-handler>
Page({
  handlePan(evt) {
    "worklet";
    console.log(evt.translateX);
  },
});

示例二:监听嵌套的手势

<horizontal-drag-gesture-handler on-gesture-event="handleHorizontalDrag">
  <vertical-drag-gesture-handler on-gesture-event="handleVerticalDrag">
    <view class="circle">one-way drag</view>
  </vertical-drag-gesture-handler>
</horizontal-drag-gesture-handler>

示例三:代理原生组件内部手势

对于 <scroll-view><swiper> 这样的滚动容器,它们内部也是通过手势来处理滚动操作的。和 web 相比,skyline 提供了更底层的访问机制,这样在做一些复杂交互时,可以做到更细致、分阶段的控制。

<vertical-drag-gesture-handler
  native-view="scroll-view"
  should-response-on-move="shouldScrollViewResponse"
  should-accept-gesture="shouldScrollViewAccept"
  on-gesture-event="handleGesture"
>
  <scroll-view
    scroll-y
    type="list"
    adjust-deceleration-velocity="adjustDecelerationVelocity"
    bindscroll="handleScroll"
  >
    <view class="item" wx:for="{{list}}">
      <view class="avatar" />
      <view class="comment" />
    </view>
  </scroll-view>
</vertical-drag-gesture-handler>

以纵向滚动的 <scroll-view> 为例,可以使用 <vertical-drag-gesture-handler> 这个手势组件,并设置 native-view="scroll-view" 来代理它内部的手势。

滚动事件

当滚动列表时,手势组件的事件回调和 <scroll-view>scroll 事件回调都会触发,它们的区别在于:

  1. scroll 事件只在滚动时触发,当滚动到顶部或底部后,就不再回调了
  2. on-gesture-event 手势回调在手指在屏幕上滑动时会一直触发,直到手指松开

手势控制

在前面介绍连续手势状态时,我们知道手势有自己的识别过程。例如 vertical-drag 手势,当手指触摸时是 POSSIBLE 状态,移动一小段距离后才被识别为 BEGIN 状态,这时就称为手势被识别(ACCEPT)。

1. 手势识别

should-accept-gesture 属性允许开发者注册一个 callback,并返回一个布尔值,参与到手势识别的过程中。当返回 false 时,这次触摸手势就不再生效,相关的 <scroll-view> 组件也无法滚动。

2. 事件派发

should-response-on-move 属性允许开发者注册一个 callback,并返回一个布尔值,参与到事件派发的过程中。当返回 false 时,当次的 move 事件就不再派发,相关的 <scroll-view> 也不会继续滚动。这个回调在手指移动过程中会持续触发,可以随时改变,从而控制滚动容器继续滚动或暂停滚动。

Page({
  // 这里返回 false,则 scroll-view 无法滚动
  // should-accept-gesture 会在手势识别的一开始触发一次
  // should-response-on-move 是在 move 过程中不断触发
  shouldScrollViewAccept() {
    'worklet'
    return true
  },

  // 这里返回 false,则 scroll-view 无法滚动
  shouldScrollViewResponse(pointerEvent) {
    'worklet';
    return true;
  },

  // 手指滑动离开滚动组件时,指定衰减速度
  adjustDecelerationVelocity(velocity) {
    'worklet';
   return velocity;
  },

  // scroll-view 滚动到边界后,手指滑动,scroll 事件不再触发
  handleScroll(evt) {
    'worklet';
  },

  // scroll-view 滚动到边界后,手指滑动,手势回调仍然会触发
  handleGesture(evt) {
    'worklet'
  },
});

示例四:手势协商

有些场景下,我们会遇到手势冲突。如下代码所示,存在嵌套的 <vertical-drag-gesture-handler> 组件,我们希望 outer 手势组件来处理纵向的拖动,inner 手势组件处理列表的滚动,但实际上只有 inner 的手势回调会触发。

嵌套的同类型手势组件,当内层的手势被识别后,外层的手势组件就不会被识别了。

<vertical-drag-gesture-handler tag="outer">
  <vertical-drag-gesture-handler tag="inner" native-view="scroll-view">
    <scroll-view scroll-y></scroll-view>
  </vertical-drag-gesture-handler>
</vertical-drag-gesture-handler>

但上面这种场景其实很常见,比如视频号的评论列表,列表的滚动和整个评论区的拖动衔接得非常流畅。手势协商机制就是用来解决这类问题的,使用起来也很简单,simultaneous-handlers 属性声明多个手势可以同时触发。

<vertical-drag-gesture-handler tag="outer" simultaneous-handlers="{{["inner"]}}">
  <vertical-drag-gesture-handler tag="inner" simultaneous-handlers="{{["outer"]}}" native-view="scroll-view">
    <scroll-view scroll-y></scroll-view>
  </vertical-drag-gesture-handler>
</vertical-drag-gesture-handler>

这时,outerinner 手势组件的 on-gesture-event 回调会依次触发,结合上面提到的手势控制原理,就能实现预期的效果。完整代码可以参考示例 demo

自定义路由

微信小程序采用多 WebView 架构,页面间跳转形式十分单一,仅能从右到左进行动画。而原生 App 的动画形式则多种多样,如从底部弹起,页面下沉,半屏等。

Skyline 渲染引擎下,页面有两种渲染模式: WebViewSkyline,它们通过页面配置中的 renderer 字段进行区分。在连续的 Skyline 页面间跳转时,可实现自定义路由效果。

效果展示

下方为半屏页面效果,点击可查看更多 Skyline 示例。

扫码打开微信小程序示例,交互动画 – 基础组件 – 自定义路由 即可体验。

使用方法

建议先阅读完 worklet 动画 和 手势系统 两个章节,它们是自定义路由的基础内容。

接口定义

自定义路由相关的接口

  • 页面跳转 wx.navigateTo
  • 路由上下文对象 wx.router.getRouteContext
  • 注册自定义路由 wx.router.addRouteBuilder
type AddRouteBuilder = (routeType: string, routeBuilder: CustomRouteBuilder) => void

type CustomRouteBuilder = (routeContext: CustomRouteContext, routeOptions: Record<string, any>) => CustomRouteConfig

interface SharedValue<T> {
  value: T;
}

interface CustomRouteContext {
  // 动画控制器,影响推入页面的进入和退出过渡效果
  primaryAnimation: SharedValue<number>
  // 动画控制器状态
  primaryAnimationStatus: SharedValue<number>
  // 动画控制器,影响栈顶页面的推出过渡效果
  secondaryAnimation: SharedValue<number>
  // 动画控制器状态
  secondaryAnimationStatus: SharedValue<number>
  // 当前路由进度由手势控制
  userGestureInProgress: SharedValue<number>
  // 手势开始控制路由
  startUserGesture: () => void
  // 手势不再控制路由
  stopUserGesture: () => void
  // 返回上一级,效果同 wx.navigateBack
  didPop: () => void
}

interface CustomRouteConfig {
  // 下一个页面推入后,不显示前一个页面
  opaque?: boolean;
  // 是否保持前一个页面状态
  maintainState?: boolean;
  // 页面推入动画时长,单位 ms
  transitionDuration?: number;
  // 页面推出动画时长,单位 ms
  reverseTransitionDuration?: number;
  // 遮罩层背景色,支持 rgba() 和 #RRGGBBAA 写法
  barrierColor?: string;
  // 点击遮罩层返回上一页
  barrierDismissible?: boolean;
  // 无障碍语义
  barrierLabel?: string;  
  // 是否与下一个页面联动,决定当前页 secondaryAnimation 是否生效
  canTransitionTo?: boolean;
  // 是否与前一个页面联动,决定前一个页 secondaryAnimation 是否生效
  canTransitionFrom?: boolean;
  // 处理当前页的进入/退出动画,返回 StyleObject
  handlePrimaryAnimation?: RouteAnimationHandler;
  // 处理当前页的压入/压出动画,返回 StyleObject
  handleSecondaryAnimation?: RouteAnimationHandler;
  // 处理上一级页面的压入/压出动画,返回 StyleObject 基础库 <3.0.0> 起支持
  handlePreviousPageAnimation?: RouteAnimationHandler;
  // 页面进入时是否采用 snapshot 模式优化动画性能 基础库 <3.2.0> 起支持
  allowEnterRouteSnapshotting?: boolean
  // 页面退出时是否采用 snapshot 模式优化动画性能 基础库 <3.2.0> 起支持
  allowExitRouteSnapshotting?: boolean
  // 右滑返回时,可拖动范围是否撑满屏幕,基础库 <3.2.0> 起支持,常用于半屏弹窗
  fullscreenDrag?: boolean
  // 返回手势方向 基础库 <3.4.0> 起支持
  popGestureDirection?: 'horizontal' | 'vertical' | 'multi'
}

type RouteAnimationHandler = () => { [key: string] : any}

默认路由配置

const defaultCustomRouteConfig = {
  opaque: true,
  maintainState: true,
  transitionDuration: 300,
  reverseTransitionDuration: 300,
  barrierColor: '',
  barrierDismissible: false,
  barrierLabel: '',
  canTransitionTo: true,
  canTransitionFrom: true,
  allowEnterRouteSnapshotting: false,
  allowExitRouteSnapshotting: false,
  fullscreenDrag: false,
  popGestureDirection: 'horizontal'
}

示例模板

以下是注册自定义路由的一份示例模板(未添加手势处理部分),完整实现半屏路由效果见示例代码。

const customRouteBuiler = (routeContext: CustomRouteContext) : CustomRouteConfig => {
  const {
    primaryAnimation,
    secondaryAnimation,
    userGestureInProgress
  } = routeContext

  const handlePrimaryAnimation: RouteAnimationHandler = () => {
    'worklet'
    let t = primaryAnimation.value
    if (!userGestureInProgress.value) {
      // select another curve, t = xxx
    }
    // StyleObject
    return {}
  }

  const handleSecondaryAnimation: RouteAnimationHandler = () => {
    'worklet'
    let t = secondaryAnimation.value
    if (!userGestureInProgress.value) {
      // select another curve, t = xxx
    }
    // StyleObject
    return {}
  }

  return {
    opaque: true,
    handlePrimaryAnimation,
    handleSecondaryAnimation
  }
}

// 在页面跳转前定义好 routeBuilder
wx.router.addRouteBuilder('customRoute', customRouteBuiler)

// 跳转新页面时,指定对应的 routeType
wx.navigateTo({
  url: 'xxxx',
  routeType: 'customRoute'
})

工作原理

以半屏效果为例,路由前后页面记为 A 页、B 页,一个路由的生命周期中,会经历如下阶段:

  1. push 阶段 :调用 wx.navigateToB 页自底向上弹出,A 页下沉收缩
  2. 手势拖动:在 B 页上下滑动时,路由动画随之变化
  3. pop 阶段 :调用 wx.navigateBackB 页向下关闭,A 恢复原样

细分到每个页面,在上述阶段会有以下动画方式

  1. 进入/退出动画
  2. 压入/压出动画
  3. 手势拖动
  • push 阶段,B 页进行的是进入动画,A 页进行的是压入动画;
  • pop 阶段,B 页进行的是退出动画,A 页进行的是压出动画;

可以看到在路由过程中,前后两个页面动画进行了联动。在自定义路由模式下,我们可以对动画各个阶段的时长、曲线、效果以及是否联动进行自定义,以实现灵活多变的页面专场效果。

路由控制器

当打开新页面时,框架会为其创建两个 SharedValue 类型的动画控制器 primaryAnimationsecondaryAnimation,分别控制进入/退出动画和压入/压出动画。

页面的进入和退出可指定不同的时长,但进度变化始终在 0~1 之间。仍以半屏效果为例,路由前后页面记为 A 页、B 页。

push 阶段

  1. B 页对应的 primaryAnimation0 -> 1 变化,做进入动画
  2. A 页对应的 secondaryAnimation0 -> 1 变化,做压入动画

pop 阶段

  1. B 页对应的 primaryAnimation1 -> 0 变化,做退出动画
  2. A 页对应的 secondaryAnimation1 -> 0 变化,做压出动画

其中,AsecondaryAnimation 的值始终与 BprimaryAnimation 的值同步变化。

通常页面的进入和退出可能采用不同的动画曲线,可通过对应的状态变量 primaryAnimationStatussecondaryAnimationStatus 来区分当前处于哪一阶段,ts 定义如下

enum AnimationStatus {
  // 动画停在起点
  dismissed = 0,
  // 动画从起点向终点进行
  forward = 1,
  // 动画从终点向起点进行
  reverse = 2,
  // 动画停在终点
  completed = 3,
}

primaryAnimationStatus 为例,页面进入和退出过程中变化情况如下

  1. push 阶段:dismissed -> forward -> completed
  2. pop 阶段:completed -> reverse -> dismissed

路由手势

在页面推入后,除了调用 wx.navigateBack 接口返回上一级外,还可以通过手势来处理,例如 iOS 上常见的右滑返回。自定义路由模式下,开发者可根据不同的页面转场效果,来选取所需的退出方式,如半屏效果可采用下滑返回。关于手势监听的内容,可参考 手势系统 一章,路由手势仅是在其基础上,补充了几个路由相关的接口。

startUserGesturestopUserGesture 两个函数总是成对调用的,startUserGesture 调用后 userGestureInProgress 的值会加 1

当开发者自行修改 primaryAnimation 的值来控制路由进度的时候,就需要调用这两个接口。由于手势拖动过程中通常采用不同的动画曲线,可通过 userGestureInProgress 值进行判断。

当手势处理后确定需要返回上一级页面时,调用 didPop 接口,作用等同 wx.navigateBack

路由联动

路由动画过程中,默认前后两个页面是一起联动的,可通过配置项关闭。

  1. canTransitionTo:是否与下一个页面联动,栈顶页面该属性置为 false ,推入下一页面时,则栈顶页面始终不动
  2. canTransitionFrom:是否与前一个页面联动,新推入页面该属性置为 false,则栈顶页面始终不动

路由上下文对象

由示例模版可见,自定义路由的动画效果就是根据 CustomRouteContext 上下文对象上的路由控制器,编写适当的动画更新函数来实现。

CustomRouteContext 上下文对象还可在页面/自定义组件中通过 wx.router.getRouteContext(this) 读取,进而在手势处理过程中访问,通过对 primaryAnimation 值的改写实现页面手势返回。

小技巧:可在 CustomRouteContext 对象上添加一些私有属性,在页面中进行读取/修改。

多类型路由跳转

考虑这样的场景,从页面 A 可能跳转到 B 页和 C 页,但具有不同的路由动画

  1. A -> B 时,希望实现半屏效果,A 需要下沉收缩
  2. A -> C 时,希望采用普通路由,A 需要向左移动

跳转下一级页面时的动画由 handleSecondaryAnimation 控制,这样就需要在定义 ACustomRouteBuilder 时考虑所有的路由类型,实现较为繁琐。

基础库 3.0.0 版本起,自定义路由新增 handlePreviousPageAnimation 接口,用于控制上一级页面的压入/压出动画。

const customRouteBuiler = (routeContext: CustomRouteContext) : CustomRouteConfig => {
  const { primaryAnimation } = routeContext

  const handlePrimaryAnimation: RouteAnimationHandler = () => {
    'worklet'
    let t = primaryAnimation.value
    // 控制当前页的进入和退出
  }

  const handlePreviousPageAnimation: RouteAnimationHandler = () => {
    'worklet'
    let t = primaryAnimation.value
    // 控制上一级页面的压入和退出
  }

  return {
    handlePrimaryAnimation,
    handlePreviousPageAnimation
  }
}

A 跳转到 B 时, AsecondaryAnimation 的值始终与 BprimaryAnimation 的值同步变化。

我们可以在定义 BCustomRouteBulder 时,通过 primaryAnimation 得知当前路由进度,handlePreviousPageAnimation 返回的 StyleObject 会作用于上一级页面。

同时也不再需要提前声明 A 为自定义路由,在此之前 A 跳转 B 希望实现半屏效果时,A 也必须定义为自定义路由。

完整的示例可参考如下代码,借助 handlePreviousPageAnimation 可去掉对 secondaryAnimation 的依赖,简化代码逻辑。

在开发者工具中预览效果

实际案例

下面以半屏效果为例,讲解自定义路由的具体实现过程,完整代码见示例代码。

路由前后页面分别记为 A 页和 B 页,需要分别为其注册自定义路由。未注册任何自定义路由效果时,新打开的页面 B 会立即覆盖显示在 A 页上。

Step-1 页面进入动画

我们先分别简单实现 首页 -> A 页 -> B 页的进入动画,再一步步进行完善。

对于 A 页面,进入方式为自右向左,通过 transform 平移实现。

function ScaleTransitionRouteBuilder(customRouteContext) {
  const {
    primaryAnimation
  } = customRouteContext

  const handlePrimaryAnimation = () => {
    'worklet'
    let t = primaryAnimation.value
    const transX = windowWidth * (1 - t)
    return {
      transform: `translateX(${transX}px)`,
    }
  }
  return {
    handlePrimaryAnimation
  }
}

对于 B 页面,进入方式为自底向上,也是通过 transform 平移实现,但需要对页面大小、圆角进行修改。

const HalfScreenDialogRouteBuilder = (customRouteContext) => {
  const {
    primaryAnimation,
  } = customRouteContext

  const handlePrimaryAnimation = () => {
    'worklet'
    let t = primaryAnimation.value
    // 距离顶部边距因子
    const topDistance = 0.12
    // 距离顶部边距
    const marginTop = topDistance * screenHeight
    // 半屏页面大小
    const pageHeight = (1 - topDistance) * screenHeight
    // 自底向上显示页面
    const transY = pageHeight * (1 - t)
    return {
      overflow: 'hidden',
      borderRadius: '10px',
      marginTop: `${marginTop}px`,
      height: `${pageHeight}px`,
      transform: `translateY(${transY}px)`,
    }
  }

  return {
    handlePrimaryAnimation,
  }
}

页面跳转效果如下,可以看到由于采用线性曲线(未对 t 做任何变换),动画有些呆板,同时未区分进入/退出动画。在 B 页完全进入后,A 页变的不可见。

Step-2 自定义动画曲线

B 页为例,根据 AnimationStatus 值,采用不同的动画曲线,同时设置 opaquefalse,使得路由动画完成后仍显示 A 页面。

const { Easing, derived } = wx.workelt

const Curves = {
  linearToEaseOut: Easing.cubicBezier(0.35, 0.91, 0.33, 0.97),
  easeInToLinear: Easing.cubicBezier(0.67, 0.03, 0.65, 0.09),
  fastOutSlowIn: Easing.cubicBezier(0.4, 0.0, 0.2, 1.0),
  fastLinearToSlowEaseIn: Easing.cubicBezier(0.18, 1.0, 0.04, 1.0),
}

function CurveAnimation({ animation, animationStatus, curve,reverseCurve }) {
  return derived(() => {
    'worklet'
    const useForwardCurve = !reverseCurve || animationStatus.value !== AnimationStatus.reverse
    const activeCurve = useForwardCurve ? curve : reverseCurve
    const t = animation.value
    if (!activeCurve) return t
    if (t === 0 || t === 1) return t
    return activeCurve(t)
  })
}
const HalfScreenDialogRouteBuilder = (customRouteContext) => {
  const {
    primaryAnimation,
    primaryAnimationStatus,
  } = customRouteContext

  // 1. 页面进入时,采用 Curves.linearToEaseOut 曲线
  // 2. 页面退出时,采用 Curves.easeInToLinear 曲线
  const _curvePrimaryAnimation = CurveAnimation({
    animation: primaryAnimation,
    animationStatus: primaryAnimationStatus,
    curve: Curves.linearToEaseOut,
    reverseCurve: Curves.easeInToLinear,
  })

  const handlePrimaryAnimation = () => {
    'worklet'
    let t = _curvePrimaryAnimation.value
    ... // 其余内容等上面的代码一致
  }

  return {
    opaque: false,
    handlePrimaryAnimation,
  }
}

这里的区别仅在于,当前的进度不再直接读取 primaryAnimation 的值。封装的 CurveAnimation 函数会根据 AnimationStatus 判断是处于进入还是退出状态,从而选择不同的动画曲线。微信小程序框架提供了多种曲线类型,可进一步参考 worklet.Easing。改进后的页面转场效果如下

Step-3 页面联动效果

B 页进入时,A 页作压入动画,由 secondaryAnimation 控制。接下来,我们为其添加下沉效果,实现和 B 页的联动。

function ScaleTransitionRouteBuilder(customRouteContext) {
  const {
    primaryAnimation
  } = customRouteContext

  const handlePrimaryAnimation = () => {
    'worklet'
    ...
  }

  const _curveSecondaryAnimation = CurveAnimation({
    animation: secondaryAnimation,
    animationStatus: secondaryAnimationStatus,
    curve: Curves.fastOutSlowIn,
  })

  const handleSecondaryAnimation = () => {
    'worklet'
    let t = _curveSecondaryAnimation.value
    // 页面缩放大小
    const scale = 0.08
    // 距离顶部边距因子
    const topDistance = 0.1
    // 估算的偏移量
    const transY = screenHeight * (topDistance - 0.5 * scale) * t 
    return {
      overflow: 'hidden',
      borderRadius: `${ 12 * t }px`,
      transform: `translateY(${transY}px) scale(${ 1 - scale * t })`,
    }
  }

  return {
    handlePrimaryAnimation,
    handleSecondaryAnimation
  }
}

通过对 A 页作 scaletranslate 变换实现下沉效果。AsecondaryAnimation 的值始终与 BprimaryAnimation 的值保持同步。

页面是否联动还可通过 canTransitionTocanTransitionFrom 两个属性进行配置,可在开发者工具上修改体验。

Step-4 手势返回

目前动画效果已经基本实现,还需要最后一步,手势返回。对于半屏效果,我们为 A 页添加右滑返回手势,B 页添加下滑返回手势。

以最常见的右滑返回为例,这里只截取松手后的手势处理部分代码,拖动过程实现较为简单,可参考示例代码。

page({
  handleDragEnd(velocity) {
    'worklet';
    const {
        primaryAnimation,
        stopUserGesture,
        didPop
    } = this.customRouteContext;

    let animateForward = false;
    if (Math.abs(velocity) >= 1.0) {
      animateForward = velocity <= 0;
    } else {
      animateForward = primaryAnimation.value > 0.5;
    }
    const t = primaryAnimation.value;
    const animationCurve = Curves.fastLinearToSlowEaseIn;
    if (animateForward) {
        const duration = Math.min(
          Math.floor(lerp(300, 0, t)),
          300,
        );
        primaryAnimation.value = timing(
          1.0, {
            duration,
            easing: animationCurve,
          },
          () => {
            'worklet'
            stopUserGesture();
          },
        );
    } else {
      const duration = Math.floor(lerp(0, 300, t));
      primaryAnimation.value = timing(
        0.0, {
          duration,
          easing: animationCurve,
        },
        () => {
          'worklet'
          stopUserGesture();
          didPop();
        },
      );
    }
  },
})

首先根据松手时的速度和位置,决定是否要真正返回上一级。

  1. 向右滑动且速度大于 1
  2. 或者速度较小时,已拖动超过屏幕 1/2

满足以上条件时,确定返回。通过 timing 接口,为 primaryAnimation 添加过渡动画,使其变化到 ,最后调用 didPop 。否则使其变化到 1,恢复到拖动前的状态。

这里需要注意的是,当需要对 primaryAnimation 值手动修改,自由掌控其过渡方式时,才需要调用 startUserGesturestopUserGesture 接口。

右滑手势已经在示例代码中封装成 swipe-back 组件,开发者可直接使用。下滑手势返回逻辑基本一致,仅一些数值上略有差异。

最后的实现效果如图

设置页面透明

一些自定义路由效果下,需要实现页面透明背景,这里对 Skylinewebview 模式下背景色的层级关系进行说明。

自定义路由下的页面背景色

Skyline 模式下使用自定义路由方式跳转页面,页面背景色有如下几层

  1. 页面背景色:可通过 page 选择器在 wxss 中定义,默认为白色
  2. 页面容器背景色:可在页面 json 文件中通过 backgroundColorContent 属性定义,支持 #RRGGBBAA 写法,默认白色
  3. 自定义路由容器背景色,由路由配置项中返回的 StyleObject 控制,默认透明
  4. 控制是否显示前一个页面,由路由配置项中的 opaque 字段控制,默认不显示

当需要设置下一个页面渐显进入时,可简单设置

  1. 页面背景色透明: page { background-color: transparent; }
  2. 页面容器背景色透明: backgroundColorContent: "#ffffff00"

查看自定义路由页面渐显示例

webview 下的页面背景色

对比看下,webview 模式下的页面背景色

  1. 页面背景色:可通过 page 选择器在 wxss 中定义,默认为透明
  2. 页面容器背景色:可在页面 json 文件中通过 backgroundColorContent 属性定义,支持 #RRGGBB 写法,默认白色
  3. 窗口背景色:可通过 wx.setBackgroundColor 接口或页面配置修改,默认为白色

示例代码

在开发者工具中预览效果

预设路由

为了降低开发成本,基础库预设了一批常见的路由动画效果。

routeType 最低基础库版本
wx://bottom-sheet 3.1.0
wx://upwards 3.1.0
wx://zoom 3.1.0
wx://cupertino-modal 3.1.0
wx://cupertino-modal-inside 3.1.0
wx://modal-navigation 3.1.0
wx://modal 3.1.0

使用方法

只需要在路由跳转时,指定对应的 routeType 即可。

注意:以上路由效果都可以通过自定义路由来实现,可以参考示例代码中的源码文件,自定义你需要的效果。

wx.navigateTo({
  url: 'xxx',
  routeType: 'wx://modal'
})

示例代码

在开发者工具中预览效果

效果演示

wx://bottom-sheet
半屏弹窗
wx://upwards
向上进入
wx://zoom
放大进入
wx://cupertino-modal-inside
从 wx-cupertino-modal 跳转到 wx-cupertino-modal-inside
wx://modal-navigation
从 wx-cupertino-modal 跳转到 wx-modal-navigation
wx://modal
从 wx-modal 跳转到 wx-modal-navigation