文章写于2023年8月,内容可能已经过时,请注意辨别

Skyline

什么是SKyline?

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

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

我们为什么要使用Skyline?

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

Skyline 渲染引擎下,页面有两种渲染模式: WebView 和 Skyline,它们通过页面配置中的 renderer字段进行区分。在连续页面间跳转时,可实现自定义路由效果,路由动画的曲线、时长均可交由开发者控制。

在上周,我们已经实现了半屏弹窗效果:这是这周使用Skyline改进后的:

那么,这两个页面有什么区别呢?

  • 第一个页面的半屏弹窗,只是一个div,点击按钮弹出后,只有点击白色div外的部分,才会回到主页,交互效果较弱。

  • 第二页页面的半屏弹窗,基于Skyline的 worklet 动画,通过手势协商机制,实现在半屏内列表往下拉到顶之后,无缝切换到半屏下拉的效果。

通俗来说,第一个半屏没有动画,只能点击弹回。而第二个半屏有交互,更加的“跟手”。

视频效果如下:

通过上述三个例子可以看出,通过新出的Skyline引擎,可以做到几乎媲美原生APP的动画、交互、路由效果!


代码实现

未改进版

这一版没有对半屏效果的实现代码进行封装,会对原来页面的js代码产生很严重的污染,在接下来的改进版本我将对其完整封装成组件的形式,并实现与主页面之间的通信

<view class="open-comment" bind:tap="onTapOpenComment">
  <view class="open-comment-wording">查看链</view>
  <view class="safe-area-inset-bottom"></view>
</view>

<view class="comment-container">
  <!-- 顶部不参与手势协商,单独控制 -->
  <pan-gesture-handler worklet:ongesture="handlePan" style="flex-shrink: 0;">
    <view class="comment-header">
      <view class="close-comment" bind:tap="onTapCloseComment">∨</view>
      我的链
    </view>
  </pan-gesture-handler>
  <!-- 滚动区要与 pan 手势协商 -->
  <pan-gesture-handler id="pan" worklet:should-response-on-move="shouldPanResponse" simultaneous-handlers="{{['scroll']}}" worklet:ongesture="handlePan">
    <vertical-drag-gesture-handler id="scroll" native-view="scroll-view" worklet:should-response-on-move="shouldScrollViewResponse" simultaneous-handlers="{{['pan']}}">
      <scroll-view class="comment-list" scroll-y worklet:adjust-deceleration-velocity="adjustDecelerationVelocity" bindscroll="handleScroll" type="list" show-scrollbar="{{false}}">
      </scroll-view>
    </vertical-drag-gesture-handler>
  </pan-gesture-handler>
</view>
const { shared, timing } = wx.worklet

const GestureState = {
  POSSIBLE: 0, // 0 此时手势未识别,如 panDown等
  BEGIN: 1, // 1 手势已识别
  ACTIVE: 2, // 2 连续手势活跃状态
  END: 3, // 3 手势终止
  CANCELLED: 4, // 4 手势取消,
}

Component({
  data: {
  },
  lifetimes: {
    created() {
      this.transY = shared(1000)
      this.scrollTop = shared(0)
      this.startPan = shared(true)
      this.commentHeight = shared(1000)
    },
    ready() {
      const query = this.createSelectorQuery()
      // ready 生命周期里才能获取到首屏的布局信息
      query.select('.comment-container').boundingClientRect()
      query.exec((res) => {
        this.transY.value = this.commentHeight.value = res[0].height
      })
      // 通过 transY 一个 SharedValue 控制半屏的位置
      this.applyAnimatedStyle('.comment-container', () => {
        'worklet'
        return { transform: `translateY(${this.transY.value}px)` }
      })
    },
  },
  methods: {
    onTapOpenComment() {
      this.openComment(300)
    },
    openComment(duration) {
      'worklet'
      this.transY.value = timing(0, { duration })
    },
    onTapCloseComment() {
      this.closeComment()
    },
    closeComment() {
      'worklet'
      this.transY.value = timing(this.commentHeight.value, { duration: 200 })
    },
    // shouldPanResponse 和 shouldScrollViewResponse 用于 pan 手势和 scroll-view 滚动手势的协商
    shouldPanResponse() {
      'worklet'
      return this.startPan.value
    },
    shouldScrollViewResponse(pointerEvent) {
      'worklet'
      // transY > 0 说明 pan 手势在移动半屏,此时滚动不应生效
      if (this.transY.value > 0) return false
      const scrollTop = this.scrollTop.value
      const { deltaY } = pointerEvent
      // deltaY > 0 是往上滚动,scrollTop <= 0 是滚动到顶部边界,此时 pan 开始生效,滚动不生效
      const result = scrollTop <= 0 && deltaY > 0
      this.startPan.value = result
      return !result
    },
    handlePan(gestureEvent) {
      'worklet'
      if (gestureEvent.state === GestureState.ACTIVE) {
        const curPosition = this.transY.value
        const destination = Math.max(0, curPosition + gestureEvent.deltaY)
        if (curPosition === destination) return
        this.transY.value = destination
      }

      if (gestureEvent.state === GestureState.END || gestureEvent.state === GestureState.CANCELLED) {
        if (gestureEvent.velocityY > 500 && this.transY.value > 50) {
          this.closeComment()
        } else if (this.transY.value > this.commentHeight.value / 2) {
          this.closeComment()
        } else {
          this.openComment(100)
        }
      }
    },
    adjustDecelerationVelocity(velocity) {
      'worklet'
      const scrollTop = this.scrollTop.value
      return scrollTop <= 0 ? 0 : velocity
    },
    handleScroll(evt) {
      'worklet'
      this.scrollTop.value = evt.detail.scrollTop
    },
  },
})
{
  "usingComponents": {},
  "disableScroll": true,
  "navigationStyle": "custom",
  "componentFramework": "glass-easel",
  "renderer": "skyline"
}
page {
  display: flex;
  flex-direction: column;
  padding-top: env(safe-area-inset-top);
  width: 100vw;
  height: 100vh;
  color: #1A191E;
}
page, view {
  box-sizing: border-box;
}
pan-gesture-handler, vertical-drag-gesture-handler {
  display: flex;
  flex-direction: column;
  overflow: hidden;
}
.container {
  flex: 1;
  width: 100vw;
  min-height: auto;
  overflow: hidden;
}
.container image {
  width: 100vw;
}

.open-comment {
  display: flex;
  flex-direction: column;
  flex-shrink: 0;
  width: 100%;
  background-color: white;
}
.open-comment-wording {
  height: 66px;
  display: flex;
  justify-content: center;
  align-items: center;
}
.safe-area-inset-bottom {
  height: env(safe-area-inset-bottom);
}

.comment-container {
  width: 100vw;
  height: 70vh;
  display: flex;
  flex-direction: column;
  position: absolute;
  bottom: 0;
  z-index: 999;
  background-color: white;
  border-top-left-radius: 20px;
  border-top-right-radius: 20px;
  box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.2);
  transform: translateY(100%);
}
.comment-header {
  width: 100%;
  font-size: 16px;
  text-align: center;
  padding: 15px 0;
}
.close-comment {
  position: absolute;
  left: 20px;
  font-size: 10px;
  font-weight: bold;
  background-color: #F8F8F8;
  border-radius: 100%;
  width: 20px;
  height: 20px;
  line-height: 20px;
  z-index: 1;
}
.comment-list {
  flex: 1;
  overflow: hidden;
}
.comment-item {
  padding: 0 20px 20px;
  font-size: 13px;
  line-height: 1.4;
}
.main-comment, .sub-comment {
  display: flex;
  flex-direction: row;
}
.sub-comment {
  padding: 10px 22px 0;
}

.user-head-img {
  width: 33px;
  height: 33px;
  border-radius: 50%;
  margin-top: 5px;
}
.others {
  flex: 1;
  margin-left: 10px;
}
.user-name {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.content {
  margin-top: 2px;
}

改进版(组件)

在JS中写了Page与Component,Page里的函数怎么也运行不了,于是查找微信小程序component和page的区别?

  1. 功能区别:组件是一个可复用的部件,可以在不同的页面之间共享;页面是小程序中最基本的单位,提供了用户与应用的交互界面。

  2. 文件结构区别:组件是单独的一个文件夹,包含了自己的 wxml、js 和 wxss 文件;页面也是单独的一个文件夹,但是它需要使用组件来组成页面的内容。

  3. 生命周期函数区别:组件有自己独特的生命周期函数,例如 attached、detached 等;页面也有自己的生命周期函数,例如 onLoad、onShow 等。

  4. 数据绑定区别:组件的数据绑定使用的是 properties 和 data;页面的数据绑定使用的是 data。

两者不能在一个JS中共存,所以要求作者对半屏组件封装,将page与compoent分离。

{完整代码包下载, https://lovexl-oss.oss-cn-beijing.aliyuncs.com/bed/202404022319089.zip, haofont hao-icon-book}
{tabs-item 组件代码}

跟上面的未改进版没什么太大区别,详情请下载代码包查看

{/tabs-item} {tabs-item 主页面代码}




{/tabs-item}