uni-app 适配微信 Skyline:worklet 动画与手势系统完整落地

2026-09-27 0 997

去年做一个内容社区项目,主页面是个卡片瀑布流。设计稿上有个效果:卡片随滚动缩放,滚到屏幕中央的那张最大,越往上下越小,视觉上像一排卡片在轨道上滑动。看着挺好看,实现起来也不复杂——监听 scroll-view 的 scroll 事件,每个卡片根据自己的位置算一个 scale 值。

结果上线测试的时候问题来了。低端 Android 机上一划就掉帧,帧率大概在 35 到 45 之间 fluctuates,能感受到明显的卡。iOS 和中高端 Android 倒还好。

排查了半天,最后定位到两点。第一,scroll 事件通过 setData 传到视图层,每次都要做一次跨线程通信。一秒钟十几帧,一秒钟十几次 setData,视图层忙不过来。第二,每个 card 的 scale 值都在 JS 线程里算,算完再一条条 setData 下去,JS 线程一旦忙着处理其他逻辑,动画也跟着卡。

当时想过很多方案。用 CSS 的 transform 加 scroll-timeline?小程序不支持。改成 CSS 动画?做不到滚动联动。用 canvas 自己画?工作量太大,交互也做不了。

后来同事提了一句:试试 Skyline 吧,worklet 就是为这种场景做的。折腾了两天,效果出乎意料。同样的代码结构,低端机上跑到了 55 帧以上,稳定不掉。

这篇文章把两天的踩坑记录整理一遍。内容集中在三块:怎么开启 Skyline、worklet 和 sharedValue 到底是什么、以及三个实际用到的案例。

一、Skyline 和 webview 到底差在哪

微信小程序默认的渲染方式是 webview。页面里的 DOM 结构会被翻译成 webview 里的 HTML,由浏览器的渲染引擎负责绘制。逻辑层(JS 线程)和渲染层(webview 线程)是分开的,两边通过 setData 通信。

这套架构已经用了很多年,稳定、兼容性好,缺点也很明确:所有和数据相关的动画都要走一遍跨线程通信。滚动事件从渲染线程报到 JS 线程,JS 算完结果再发回渲染线程,一来一回至少两帧的延迟。做静态页面没问题,做高频联动动画跟不上。

Skyline 的思路是把渲染引擎换掉。它不用 webview 了,自己实现了一套渲染管线,组件布局和绘制都在渲染线程完成。同时它引入了 worklet——一段可以直接在渲染线程执行的函数。滚动事件的响应、动画的插值、样式的更新,全都在渲染线程内部完成,不经过 JS 线程。

这就是性能差异的来源。不是说 Skyline 的渲染更快,而是它把”动画”这件事从跨线程通信变成了线程内计算,少了通信环节的延迟。

二、开启 Skyline 的步骤

开启分成两个层面:全局配置和页面级配置。

全局开关

在 manifest.json 的 mp-weixin 节点下加上这几项:

{
  "mp-weixin": {
    "appid": "你的appid",
    "setting": {
      "urlCheck": false,
      "es6": true,
      "minified": true
    },
    "renderer": "skyline",
    "componentFramework": "glass-easel",
    "lazyCodeLoading": "requiredComponents",
    "skylineRenderEnable": true
  }
}

四个配置项的作用分别是:

renderer 指定渲染引擎,可选 webview 或者 skyline。componentFramework 必须配合改成 glass-easel,这是微信新的组件框架,旧框架和 Skyline 不兼容。lazyCodeLoading 开启按需注入,Skyline 下对这个配置有依赖。skylineRenderEnable 是 uni-app 特有的开关,会让编译器识别 worklet 相关的语法。

要注意 componentFramework 一旦改成 glass-easel,整个小程序的组件实现都换了一套。有一些第三方组件库在老框架上行为正常,换到新框架之后会出现样式偏差或者事件不响应,这个后面细讲。

页面级开关

全局打开之后,也可以让部分老页面继续用 webview 渲染。在 pages.json 里:

{
  "pages": [
    {
      "path": "pages/index/index",
      "style": {
        "renderer": "skyline",
        "componentFramework": "glass-easel",
        "navigationStyle": "custom",
        "disableScroll": true
      }
    },
    {
      "path": "pages/settings/index",
      "style": {
        "renderer": "webview"
      }
    }
  ]
}

开关是按页面生效的,加了 renderer: "skyline" 的页面走新引擎,没加的走老的。这个设计很实用——迁移不用一次性完成,可以从一两个页面开始试。

另外提一句,用 Skyline 的页面一般都建议加 disableScroll: true,把页面级的滚动关掉,由页面里的 scroll-view 自己处理。因为在 Skyline 下页面滚动和 viewport 的行为跟 webview 不太一样,交给 scroll-view 更可控。

三、worklet 和 sharedValue 是什么

这两个概念是 Skyline 的核心,绕不开。

worklet 是一段能在渲染线程跑的 JS

写法和普通函数一样,只是在函数体第一行加了一个指令:

function double(x) {
  'worklet'
  return x * 2
}

加了这行标记之后,编译器会把整个函数序列化成一段字节码,随页面一起发给渲染线程。等到需要执行的时候,渲染线程直接在本地跑它,不需要再和 JS 线程通信。

有个关键限制:worklet 函数里只能访问被显式标记为可在渲染线程访问的值。闭包捕获的外部变量会被”快照”一次——也就是说,你在 worklet 创建的时候看它是什么值,之后它在 JS 线程里怎么变,worklet 里还是拿旧值。

let counter = 0

const double = () => {
  'worklet'
  return counter * 2   // counter 是快照,永远是 0
}

counter = 10
// double() 在渲染线程执行时,读到的 counter 还是 0

要跨线程共享可变状态,得用 sharedValue。

sharedValue 是一个跨线程可读写的容器

// #ifdef MP-WEIXIN
const { shared } = wx.worklet

const progress = shared(0)
// #endif

通过 shared() 创建的值,在 JS 线程里用 progress.value 访问和修改。在 worklet 里也是 .value,但读到的永远是最新值。

修改 sharedValue 不会触发 setData,也不会引起页面重渲染。它只是一个共享的内存槽,谁读谁拿到当前值。真正驱动 UI 更新的是另一套机制——useAnimatedStyle。

useAnimatedStyle 把 sharedValue 映射到样式

// #ifdef MP-WEIXIN
const { shared, useAnimatedStyle, withSpring } = wx.worklet

const scale = shared(1)

const cardStyle = useAnimatedStyle(() => {
  return {
    transform: `scale(${scale.value})`,
  }
})
// #endif

模板里把 cardStyle 绑到元素的 :style 上。之后只要 scale.value 变化,样式就会在渲染线程内自动应用,不经过 setData,也不走 JS 线程。

<view class="card" :style="cardStyle">
  <text>卡片内容</text>
</view>

这三个东西连起来就是 Skyline 动画的最小闭环:用 sharedValue 存状态,用 worklet 算值,用 useAnimatedStyle 把值映射到样式。全过程都在渲染线程完成,没有一处 setData。

四、案例一:滚动缩放的卡片列表

把开头那个需求重写一遍。目标是滚动时每张卡片根据自己离屏幕中心的距离算一个缩放比例。

先写滚动容器:

<template>
  <scroll-view
    class="card-scroll"
    scroll-y
    :style="{ height: viewportHeight + 'px' }"
    @scroll="onScroll"
  >
    <view
      v-for="(item, index) in cards"
      :key="item.id"
      class="card-slot"
    >
      <ScaleCard
        :title="item.title"
        :index="index"
        :scroll-y="scrollY"
        :card-height="CARD_HEIGHT"
      />
    </view>
  </scroll-view>
</template>

<script setup>
import ScaleCard from '@/components/ScaleCard.vue'

const CARD_HEIGHT = 220
const cards = Array.from({ length: 30 }, (_, i) => ({
  id: i,
  title: `卡片 ${i + 1}`,
}))

// #ifdef MP-WEIXIN
const { shared } = wx.worklet

const scrollY = shared(0)

const onScroll = (e) => {
  'worklet'
  scrollY.value = e.detail.scrollTop
}
// #endif
</script>

注意 onScroll 是一个 worklet 函数。在 Skyline 下,@scroll 事件的回调可以直接标记成 worklet,事件数据在渲染线程处理,不经过 JS 线程。这是整个方案里最关键的优化点——原方案里 setData 的通信开销全在这里省掉了。

子组件负责把自己那部分缩放逻辑写清楚:

<!-- components/ScaleCard.vue -->
<template>
  <view class="card" :style="cardStyle">
    <text class="card-title">{{ title }}</text>
  </view>
</template>

<script setup>
const props = defineProps({
  title: String,
  index: { type: Number, required: true },
  scrollY: { type: Object, required: true },
  cardHeight: { type: Number, required: true },
})

// #ifdef MP-WEIXIN
const { useAnimatedStyle, interpolate } = wx.worklet

const cardStyle = useAnimatedStyle(() => {
  const slotTop = props.index * props.cardHeight
  const center = props.scrollY.value + 375
  const distance = Math.abs(center - (slotTop + props.cardHeight / 2))
  const progress = Math.min(distance / (props.cardHeight * 2), 1)

  return {
    transform: `scale(${interpolate(progress, [0, 1], [1, 0.86])})`,
    opacity: interpolate(progress, [0, 1], [1, 0.55]),
  }
})
// #endif
</script>

这里假设了视口高度是 750rpx 对应的 375px,实际项目里应该用一个响应式的值。为了代码简单起见,直接写了常量。

interpolate 是 worklet 环境内置的插值函数,语义和常见的 easing 库一样:给一个输入值和一个值域映射表,返回对应的输出。它的执行是纯函数式的,不会引入额外开销。

可以在 worklet 里用 setTimeout 吗

不能。渲染线程里没有 timer、没有 requestAnimationFrame、也没有 fetch。你能做的只有纯计算和读写 sharedValue。

需要延迟动画的时候用 withTiming 或者 withSpring 包装一下值:

import { shared, withSpring } from 'wx.worklet'  // 伪代码示意

const scale = shared(1)
scale.value = withSpring(1.2, { damping: 18, stiffness: 180 })

这些包装函数会返回一个”动画描述对象”,赋值给 sharedValue 的时候会自动开始动画。这也是 Skyline 里唯一的做动画方式,没有 keyframe 也没有 transition,全靠这个。

五、案例二:可拖拽排序的列表

手势系统是另一块常用能力。拿一个常见的场景练手:任务清单里上下拖动重新排序。

<template>
  <view class="list" :style="{ height: totalHeight + 'px' }">
    <view
      v-for="(item, index) in items"
      :key="item.id"
      class="row"
      :style="rowStyles[index]"
      @touchstart="startDrag(index, $event)"
      @touchmove="onDrag($event)"
      @touchend="endDrag"
    >
      <text>{{ item.label }}</text>
    </view>
  </view>
</template>

<script setup>
import { ref, computed } from 'vue'

const ROW_HEIGHT = 64
const items = ref([
  { id: 1, label: '设计首页' },
  { id: 2, label: '对接接口' },
  { id: 3, label: '写单测' },
  { id: 4, label: '部署上线' },
])

const totalHeight = computed(() => items.value.length * ROW_HEIGHT)

// #ifdef MP-WEIXIN
const { shared, useAnimatedStyle, withSpring } = wx.worklet

const draggingIndex = shared(-1)
const draggingOffset = shared(0)

const rowStyles = items.value.map((_, i) => {
  return useAnimatedStyle(() => {
    const isDragging = draggingIndex.value === i
    const shift = isDragging ? draggingOffset.value : 0

    return {
      transform: `translateY(${i * ROW_HEIGHT + shift}px)`,
      zIndex: isDragging ? 10 : 1,
      boxShadow: isDragging
        ? '0 8px 24px rgba(0,0,0,.12)'
        : 'none',
    }
  })
})

let startY = 0

const startDrag = (index, e) => {
  startY = e.touches[0].clientY
  draggingIndex.value = index
  draggingOffset.value = 0
}

const onDrag = (e) => {
  'worklet'
  if (draggingIndex.value === -1) return
  draggingOffset.value = e.touches[0].clientY - startY
}

const endDrag = (e) => {
  // 在 JS 线程处理真正的数组重排,这里要同步一次
  if (draggingIndex.value === -1) return

  const targetIndex = Math.round(
    (draggingIndex.value * ROW_HEIGHT + draggingOffset.value) / ROW_HEIGHT
  )
  const clamped = Math.max(0, Math.min(items.value.length - 1, targetIndex))

  if (clamped !== draggingIndex.value) {
    const arr = [...items.value]
    const [moved] = arr.splice(draggingIndex.value, 1)
    arr.splice(clamped, 0, moved)
    items.value = arr
  }

  draggingIndex.value = -1
  draggingOffset.value = 0
}
// #endif
</script>

这段代码里有几个细节值得说。

第一,拖拽的视觉位移完全由 worklet 计算,但数组的真实重排在 JS 线程。因为 items 是一个响应式数组,改它需要走 JS 线程和 setData。这个双轨结构是 Skyline 里比较常见的写法——轻量的位置动画走 worklet,重量的数据操作还是走 JS。

第二,真实的排序发生在松手的瞬间,而不是拖动过程中。拖动过程中视觉上只移动被拖拽的那一项,其他项不动。要做到”其他项让位”的效果,还需要额外的逻辑:根据被拖拽项的位置,动态调整其他项的 translateY。这部分不展开了,思路是把每个 item 的当前位置和目标占位都存进 sharedValue,useAnimatedStyle 里算一下让位偏移就行。

第三,动画收尾可以用 withSpring 让它有个小弹跳:

draggingOffset.value = withSpring(0, { damping: 20, stiffness: 220 })

这样就避免了直接归零的突兀感。

手势和 scroll-view 的冲突

上面这个例子我没用 Gesture.Pan(),用的是原生 touchstart、touchmove、touchend。原因很简单:如果列表本身在一个 scroll-view 里,用手势系统会跟滚动冲突,需要一个额外的激活阈值才能分开。

真实项目如果要用手势系统,大概是这样:

// #ifdef MP-WEIXIN
const pan = wx.worklet.Gesture.Pan()
  .activeOffsetY([-12, 12])
  .onStart(() => {
    'worklet'
    draggingIndex.value = currentIndex
  })
  .onUpdate((e) => {
    'worklet'
    draggingOffset.value = e.translationY
  })
  .onEnd(() => {
    'worklet'
    // 结算
  })
// #endif

activeOffsetY([-12, 12]) 的意思是:手指垂直方向位移超过 12 像素之后,才把手势识别成 Pan 而不是滑动。这个阈值是用来区分”用户想滚动页面”和”用户想拖动元素”的。阈值设太小,滚动容易误触发拖拽;设太大,拖拽要划很久才启动。

12 是个经验值,实际项目里根据行高和滚动惯性调整。行高越大的场景,阈值可以设得越宽松。

六、案例三:吸顶分组

通讯录那种 A、B、C 分组的效果,Skyline 有一个专门的组件 sticky-section 来做。相比 webview 里靠 position: sticky 硬撑,Skyline 的实现更稳定——sticky 的层级、切换时机、和滚动容器的关系都由引擎处理,不用自己算偏移。

<template>
  <scroll-view class="contacts" scroll-y>
    <sticky-section
      v-for="group in groupedContacts"
      :key="group.letter"
    >
      <sticky-header>
        <view class="section-header">{{ group.letter }}</view>
      </sticky-header>

      <view
        v-for="contact in group.items"
        :key="contact.id"
        class="contact-row"
      >
        <text>{{ contact.name }}</text>
      </view>
    </sticky-section>
  </scroll-view>
</template>

<script setup>
import { computed } from 'vue'

const contacts = [
  { id: 1, name: '安琪', letter: 'A' },
  { id: 2, name: '白川', letter: 'B' },
  { id: 3, name: '陈默', letter: 'C' },
  // ...
]

const groupedContacts = computed(() => {
  const map = new Map()
  for (const c of contacts) {
    if (!map.has(c.letter)) map.set(c.letter, [])
    map.get(c.letter).push(c)
  }
  return Array.from(map, ([letter, items]) => ({ letter, items }))
})
</script>

sticky-section 和 sticky-header 是成对的组件。前者是分组容器,后者是吸顶部分。当 group 滚出可视区时,它的 header 会自动被”推”出去,下一个 header 顶上来。整个过程的视觉连贯度是由引擎负责的,不像 webview 里需要小心处理 z-index 和 transform 的组合。

要强调一下,这两个组件是 Skyline 独有的。在 webview 渲染的页面里用它们会报”组件不存在”。所以跨页面复用代码的时候,得用条件编译包一下。

七、多端差异与降级策略

写到这里你可能已经意识到一个问题:Skyline 是微信小程序独有的能力,项目里如果有 App 端、H5 端、支付宝小程序端,不可能只在微信上写一套、其他端写另一套。

解决办法是条件编译。uni-app 提供的语法可以按平台切分代码:

// #ifdef MP-WEIXIN
// 微信端专用代码
// #endif

// #ifndef MP-WEIXIN
// 非微信端的降级代码
// #endif

把 worklet 部分抽成 hook

实际项目里更合理的做法是把 worklet 相关的逻辑抽成 hook,各端返回不同实现,业务层统一调用:

// composables/useScrollScale.js

export function useScrollScale(cardHeight) {
  // #ifdef MP-WEIXIN
  const { shared, useAnimatedStyle } = wx.worklet

  const scrollY = shared(0)

  const bindScroll = (e) => {
    'worklet'
    scrollY.value = e.detail.scrollTop
  }

  const makeStyle = (index) => useAnimatedStyle(() => {
    const slotTop = index * cardHeight
    const center = scrollY.value + 375
    const distance = Math.abs(center - (slotTop + cardHeight / 2))
    const progress = Math.min(distance / (cardHeight * 2), 1)
    return {
      transform: `scale(${1 - progress * 0.14})`,
      opacity: 1 - progress * 0.45,
    }
  })

  return { bindScroll, makeStyle }
  // #endif

  // #ifndef MP-WEIXIN
  // 其他端用普通 scroll 事件加 CSS transition 兜底
  const scrollY = ref(0)

  const bindScroll = (e) => {
    scrollY.value = e.detail.scrollTop
    // 用 requestAnimationFrame 节流
    if (rafId) return
    rafId = requestAnimationFrame(() => {
      rafId = 0
    })
  }

  const makeStyle = (index) => {
    return computed(() => {
      const slotTop = index * cardHeight
      const center = scrollY.value + 375
      const distance = Math.abs(center - (slotTop + cardHeight / 2))
      const progress = Math.min(distance / (cardHeight * 2), 1)
      return {
        transform: `scale(${1 - progress * 0.14})`,
        opacity: 1 - progress * 0.45,
        transition: 'transform .1s linear, opacity .1s linear',
      }
    })
  }

  return { bindScroll, makeStyle }
  // #endif
}

业务组件里只 import 这个 hook,看不到任何平台差异。这样的结构在维护上最省事,降级策略也容易在某个平台出问题时单独调整。

判断当前是否处于 Skyline

有些场景下需要在运行时判断,比如做一些性能埋点或者动态加载策略:

// #ifdef MP-WEIXIN
const isSkyline = (() => {
  try {
    return typeof wx.worklet !== 'undefined'
      && !!wx.getSkylineInfoSync
      && wx.getSkylineInfoSync()?.isSupported
  } catch (e) {
    return false
  }
})()
// #endif

wx.getSkylineInfoSync() 会返回当前页面是否用 Skyline 渲染,以及基础库版本是否满足要求。在启动时用它来判断,比硬性检查全局对象更稳。

八、六个踩过的坑

1. worklet 里访问外部变量是快照语义

前面提过,但这里再强调一次,因为它是最容易出问题的地方。

let baseScale = 1

const style = useAnimatedStyle(() => {
  // baseScale 是创建 style 那一刻的值,永远不变
  return { transform: `scale(${baseScale})` }
})

baseScale = 1.5   // 改了也没用

解决方案:要么用 sharedValue(跨线程可变),要么重建 useAnimatedStyle。前者的写法是:

const baseScale = shared(1)

// JS 线程里改:
baseScale.value = 1.5

// worklet 里读:
const style = useAnimatedStyle(() => ({
  transform: `scale(${baseScale.value})`,
}))

2. style 对象里的属性名要用 camelCase

// 错误
return { 'font-size': '14px' }

// 正确
return { fontSize: '14px' }

和常规 Vue inline style 对象一样。但如果是从 CSS 里复制粘贴过来的,很容易带上连字符,然后样式就静默不生效,排查起来挺费劲。

3. position: fixed 在 Skyline 下不生效

Skyline 的布局模型参考了更严格的流式布局,不支持和 webview 一样的 fixed 定位语义。要用吸顶效果,应该用 sticky-header;要做悬浮按钮,应该把它放在页面根节点上,用绝对定位加一个固定的父容器。

<!-- 在 webview 里能用,在 Skyline 里不行 -->
<view class="back-to-top">...</view>

<!-- 换成 -->
<view class="page-root">
  <scroll-view>...</scroll-view>
  <view class="floating-btn">...</view>
</view>

把 fixed 换成绝对定位,并且保证父容器覆盖整个视口。这个改动不大,但如果不小心在某个地方留了 fixed,可能会出现元素完全消失的情况——不报错,也不显示。

4. 开发者工具和真机行为差异很大

微信开发者工具的 Skyline 模拟一直不太准。有些 worklet 相关的问题在工具里看不出来,在真机上才会暴露;反过来也有——工具里报的错在真机上不一定复现。

结论是:所有关于 Skyline 的调试都要在真机上看。开发者工具用来快速验证逻辑,真机用来确认表现。项目里应该准备至少一台低端安卓和一台 iPhone,两边都要过一遍。

5. 组件库的兼容性

把 componentFramework 切到 glass-easel 之后,很多老的第三方组件会出问题。常见的有:

自定义组件内部用了 this.setData 的写法,在一些边缘场景下不触发更新。组件的样式在 skyline 下布局不一致,因为 skyline 的盒模型更严格,有些 overflow 和 position 组合的行为跟 webview 不同。有些组件用到了 wx.createSelectorQuery,skyline 下这个 API 的可用性有限。

做法是:迁移前先列一遍项目里用到的第三方组件,逐个验证。遇到不兼容的要么升级版本、要么替换、要么把用到它的页面暂时保留在 webview 渲染。

6. 基础库版本要求

不同 Skyline 能力的可用版本不完全一样。sticky-section 是比较晚才稳定的,shared 更早。写兼容代码的时候要注意判断基础库版本。

const version = wx.getAppBaseInfo().SDKVersion
const [major, minor, patch] = version.split('.').map(Number)

if (major > 3 || (major === 3 && minor >= 3)) {
  // 可以用比较新的 API
}

实际项目里更省事的做法是:在 manifest.json 里声明一个最低基础库版本,让不满足的用户走 webview 分支。这样代码里就不用到处判断版本了。

九、什么时候该上 Skyline

用了一年多,我的判断是可以分成三类。

第一类是明显该用的场景。列表滚动动画、拖拽排序、手势驱动的手势系统、复杂的滚动联动效果。这些场景在 webview 下要么做起来很难,要么性能上不去。Skyline 在这些地方的优势非常直观。

第二类是可以用但不着急的。普通的内容展示页面、表单页面、设置页面。这些页面本身没什么动画,迁移到 Skyline 的收益只是”更现代的渲染引擎”这种模糊的描述,实际用户感知不到。留在 webview 反而能避免组件库兼容问题。

第三类是不该用的。大量依赖 web-view 组件的页面、依赖复杂表单控件和编辑器组件的页面、大量使用老版本第三方组件的页面。这些页面迁移成本高,收益低。

分页面对待,是这两年以来我做 Skyline 迁移最大的心得。不用全上,也不用全不上。一个项目里 3 到 5 个高频交互页面用 Skyline,其余页面留给 webview,混合运行完全没问题。这样既能拿到性能收益,又能把迁移风险和兼容性问题控制在一个可以接受的范围内。

十、写在最后

回到开头那个列表。

最后落地的时候,我们没有把整个 App 迁移到 Skyline,只把主内容浏览页和搜索页改了。剩下二十多个页面继续用 webview。改动两周完成,把主页面从 40 帧拉到了 58 帧左右,用户可在客服那边没有收到任何关于”卡”的反馈了。

这两年小程序生态的演进速度是挺快的。很多以前需要各种奇技淫巧才能做到的效果,慢慢都有了原生的实现方式。Skyline 就是其中一个比较典型的例子——它没有引入新的编程模型,也没有让整个生态推倒重来,只是把渲染引擎换掉了,然后让 worklet 和 sharedValue 这两个东西去处理那些最需要性能的部分。

如果你的项目里也有那种”做得出效果,但性能上不去”的页面,值得试一下。不一定全站迁移,从一个页面开始就好。

uni-app 适配微信 Skyline:worklet 动画与手势系统完整落地
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 uniapp uni-app 适配微信 Skyline:worklet 动画与手势系统完整落地 https://www.taomawang.com/web/uniapp/2826.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务