- 小程序端视频剪辑的春天来了?
前言-吐槽篇 在这个短视频遍地的时代,小程序端视频剪辑功能似乎一直是缺失状态,前段时间发布了一个视频编辑器的接口,试了下感觉也很鸡肋。也有在社区看到前辈们试水小程序音视频合成初探,研究了一番,其中涉及到几个重要的api:1-视频解码器,将视频解码,获得帧数据绘制到canvas上;2-音视频合成器,负责向原视频中添加音频;3-画面录制器,最后通过画面录制器导出成视频。这里必须要吐槽下小程序的文档,简约派代表,能省略的绝不多写一句,翻一下上述api文档便可石锤,几乎全靠猜,这么复杂的接口也不提供个demo。曾尝试按照上述思路做一些复杂的视频剪辑功能,比如图片视频合成一个,单单解决音频错位的问题就搞得头秃,可以说是步履维艰…… 正文开始 最近发现小程序发布了一款视频剪辑插件 - 微剪,看了下官方提供的Demo还挺有趣的,相比于上述API功能更完善,感觉小程序一直在探索新姿势,真香啊。 小程序:是的,我啥都能做[手动狗头] 本文前半部分旨在结合上述插件,输出一个简单的视频剪辑小程序,感兴趣(不想动手只想白嫖)的同学也可以直接在小程序中搜索「微剪插件演示」,感受一下插件的功能;后半部分着重介绍下插件的一些核心思想和高级用法。 插件接入 微剪以插件的形式提供给开发者,不能单独使用,所以我们必须要先有个小程序主体(不会吧阿sir,这都想白嫖我?如何注册小程序请自行百度好伐)。插件的接入流程也比较简单,官方文档 写的比较清楚,这里就不啰嗦了。目前好像处于公测阶段,可以免费申请使用。 代码开发 上述准备工作做完后,我们就可以在代码中使用微剪插件了,插件暴露了两个组件clip和export,clip是入口组件,export为出口组件,其间的流程完全被插件接管,开发者无需关心也无法干涉(有点霸道总裁的味道了啊),通过一系列简单的配置后就可以看到插件的全貌了。【2020/09/23日 删除】 【2020/09/23日 新增】最新版插件提供两种开发模式,暂称为自动模式(仅通过少许配置即可)和手动模式(利用插件暴露的组件自定义开发,1.3.0版本新增)。先介绍第一种方式,按照下述步骤走一遍,应该就能看到插件的全貌了,问题不大。 step1:app.json 引入插件配置 参考这里:小程序插件配置 [代码]{ "plugins": { "myPlugin": { // 自定义插件名,后面会用到 "version": "1.3.0", // 最新版本号即可 "provider": "wx76f1d77827f78beb" // 插件ID,固定值 } } } [代码] step2:页面中添加插件 新建一个页面,在页面的json文件中引入插件暴露的clip组件 [代码]{ "usingComponents": { "my-clip": "plugin://myPlugin/clip" } } [代码] my-clip: 自定义组件名字,在wxml中使用; myPlugin: 引入插件的时候,在app.json中声明的插件名; clip: 固定值,官方暴露的组件名称。 wxml文件: [代码]<view> <my-clip settings="{{settings}}"></my-clip> </view> [代码] js文件: [代码]Page({ data: { settings: { common: { videoMaxDuration: 30, // 小程序限制最多拍摄30秒 chooseMaxDuration: 1000, // 选择视频的默认时长限制 clipMaxDuration: 60, // 裁切时长的默认限制 } } }, }) [代码] settings有众多配置项,具体可参考官方文档;不设置也可以,插件提供了一套默认的配置项。 PS:这里得吐槽下插件的主题机制,感觉很繁琐。跟平时写vue组件等通过样式覆盖的方案不同,而是通过一系列配置项进行配置,这也意味着只能修改配置项涉及的UI,其他的一律无法修改。这里有个疑问,为什么不采用外部样式类的方案呢?感觉更灵活啊。 到这里,应该就可以在手机上看到插件的全貌了,截取了几张图如下: [图片] [图片] [图片] [图片] step3:导出视频 点击上图的「下一步」按钮,就可以导出视频到相册中了。插件本身也支持自定义导出,下面简单介绍下用法: step3-1:新增导出页 创建一个自定义的导出页,在导出页中引入插件提供的导出组件即可。 json文件: [代码]{ "usingComponents": { "my-export": "plugin://myPlugin/export" } } [代码] wxml文件: [代码]<view> <view>自定义导出界面部分</view> <my-export bindexportsuccess="handleExportSuccess"> <button>自定义导出按钮</button> </my-export> </view> [代码] 然后在js文件中处理导出成功回调即可,导出组件提供了一系列属性,包括水印,导出进度等,具体导出数据结构及属性,可参考官方文档。 step3-2:配置导出页路径 新增上述导出页后,还需要通知插件导出页路径。还记得上文提到的 my-clip 组件中的settings属性吗,此为插件的配置项入口,在里面配置下即可。 [代码] data: { settings: { common: { videoMaxDuration: 30, // 小程序限制最多拍摄30秒 chooseMaxDuration: 1000, // 选择视频的默认时长限制 clipMaxDuration: 60, // 裁切时长的默认限制 exportPagePath: '/pages/export/export' // 自定义导出页面地址 } } } [代码] 事已至此,通过开发工具的预览功能便可以在手机中看到自定义的导出页了。 【2020/09/23日 新增】 高级模式(1.3.0版本新增) 还记得上文提到的简单(自动)模式,我们只用到了插件暴露的两个组件,clip和export,难道插件只暴露了两个组件吗?就这?就这??? 插件从1.3.0版本开始,开放了更多的组件出来,也提供了一种称为【高级接入】的方式,接下来让我们一探究竟: 高级模式需要先了解插件的数据机制,即下文提到的Track驱动模式,在此基础上可以结合插件暴露的组件进行自定义创作。 标准的数据驱动模式 高级模式,即文章开头提到的手动模式,遵循一套标准的Track数据结构,此处引用官方文档的一张图和介绍 [图片] 什么是Track? track顾名思义为轨道,播放器支持多种轨道,如:媒体(视频 或者 图片), 音乐, 特效, 滤镜, 文字 等。 每一种类型的轨道对应不同类型的track,可以简单的理解track就是轨道。 什么是Clip? clip是轨道中的素材片段。举个例子: 你需要编辑三段视频,那么你需要将三个视频片段加入媒体轨道中,那么这里的每个视频片段就对应不同的clip,每个clip的属性会不相同,如: 视频时长,开始结束时间等。 上述官方定义看得有点懵?大白话简单理解,画面播放的时候,我们在某一时刻可以看到(听到)很多元素,比如原始画面的一帧,特效的一部分(几个小心心),滤镜,文字以及音乐等,Track就是这些元素的数据载体,不同的元素由不同类型的Track管理,所以Track是个数组。 随着时间轴的推移,我们又可以看到不同的视频、图片、特效切换效果等等,比如由图片A切换到图片B,再切换到C、由特效A切换到特效B等,这个时候就涉及到clip的概念,clip可以简单理解为上述Track的一个属性,里面存放着原始素材的信息(基本信息,展示时长等),比如上述🌰,负责媒体的Track的clips就得包含图片A、图片B以及图片C的信息,负责特效的Track的clips就得包含特效A和特效B的信息。 官方提供了详细的Track以及Clip的数据接口定义,具体请参考官方文档 数据结构文档 部分,这里只做核心思想解释。 积木已有,创作在你 最新版本(1.3.0)暴露的几个核心组件: wj-player:插件的核心播放器组件,对应的上述简单模式截图里的播放视频的容器; wj-camera:相机组件,可以拍摄视频,也可以从相册选择视频、图片,截图里的图片、视频就是从这来的; wj-clipper:裁切器组件,上述截图中可以看到视频一帧一帧画面的那部分,就是用这个组件实现的。 上述组件即插件实现的核心部分,还有个文字组件(wj-textEditor、向视频中添加文字)和导出组件(wj-export、与自动模式的导出组件基本相同)使用比较简单,每个组件都有各自的属性和方法,具体请参考高级组件API文档。结合标准的Track数据驱动,理论上可以搭配出与插件功能完全一致的视频剪辑体验,当然如果自动模式可以满足业务需求,那白嫖它,做条闲鱼不好吗?如果业务需求需要个性化,可以考虑使用上述手动模式,自力更生,丰衣足食。 光说不练假把式 下面通过一个简单的 相册集小demo 演示一下上述组件的用法,可以下载小程序片段到本地运行(注:由于插件的使用需要先申请资格,所以请填写一个有使用权限的小程序AppID)。主要使用了三个组件,wj-camera(收集素材)、wj-player(展示素材和特效)、wj-export(导出视频),通过camera页面从相册选择图片,然后搭配插件内置的一些特效输出到player页面中预览效果,最后通过export页面导出。 核心代码如下: camera 页面 camera.wxml [代码]<!--pages/camera/camare.wxml--> <wj-camera bindmediachanged="onMediaChanged" > <view class="preview-button" catchtap="onClickDefaultBtn"> <navigator data-source="jump" url="../player/player?type=default" hover-class="none"> <text>随机特效</text> </navigator> </view> <view class="preview-button" style="right:230rpx;width:240rpx" catchtap="onClickPointBtn"> <navigator data-source="jump" url="../player/player?type=wyyl" hover-class="none"> <text>卡点视频(6图)</text> </navigator> </view> </wj-camera> [代码] camera.js [代码]// pages/camera/camare.js Page({ data:{ _media:[] }, onMediaChanged(e){ this.data._media = e.detail.track }, onClickDefaultBtn(){ global.testMedia = JSON.parse(JSON.stringify(this.data._media)) }, // 【万有引力】卡点设置 onClickPointBtn(){ let durationList = [2.6, 2.6, 2.6, 2.8, 2.8, 2.6], startAt = 0 let copyMedia = JSON.parse(JSON.stringify(this.data._media)) copyMedia.clips.forEach((item,index) => { // 更新卡点视频图片播放时长 let duration = durationList[index] item.section.end = duration item.section.duration = duration item.startAt = startAt startAt += duration }); copyMedia.duration = startAt global.testMedia = copyMedia } }) [代码] player 页面 player.wxml [代码]// pages/player/player.js Page({ data: { styleConfig: { height: 1000, width: 750 }, type: 'default' }, onLoad: function (option) { this.setData({ type: option.type }) }, // 生命周期函数--监听页面初次渲染完成 onReady: function () { this.bindPlayer() }, // 绑定player bindPlayer() { this.player = this.selectComponent("#player"); }, // 设置player组件的数据 async onPlayerReady() { let tracks = [] if (this.data.type === 'default') { tracks = this.getDefaultTrack() } else { tracks = this.getWYYLTrack() } this.player.updateData(tracks) // 更新player的数据 global.testExportTracks = tracks // 存储导出页数据 }, // 随机特效 getDefaultTrack() { let startAt = -3, { TRACK_TYPES, CLIP_TYPES, Clip, ClipSection, Track } = global['wj-types'] let mediaTrack = global.testMedia // 获取camera组件输出的media轨道 let effectTrack = new Track({ // 新建一个effect轨道 type: TRACK_TYPES.EFFECT, clips: [] }) let effectList = this.player.getEffects(); // 获取player内置的特效 let randomEffects = new Array(mediaTrack.clips.length).fill(null).map((item, index) => { startAt += 3 return new Clip({ id: `effect-${index}`, type: CLIP_TYPES.EFFECT, key: this._getRandomEffect(effectList).key, section: new ClipSection({ start: 0, end: 3 }), startAt: startAt }) }) effectTrack.clips = randomEffects // 更新effect轨道的clips数据 return [mediaTrack, effectTrack] }, // 【万有引力】卡点特效 getWYYLTrack() { let { TRACK_TYPES, CLIP_TYPES, Clip, ClipSection, Track } = global['wj-types'] let mediaTrack = global.testMedia // 获取camera组件输出的media轨道 let effectTrack = new Track({ // 新建一个effect轨道 type: TRACK_TYPES.EFFECT, clips: [] }) // 定制卡点特效 let effectList = [ { key: 'Swing', duration: 2.6, startAt: 0 }, { key: 'SoulOut', duration: 0.6, startAt: 2.6 }, { key: 'Shining', duration: 2, startAt: 3.2 }, { key: 'SoulOut', duration: 0.6, startAt: 5.2 }, { key: 'Blink', duration: 2, startAt: 5.8 }, { key: 'SoulOut', duration: 0.6, startAt: 7.8 }, { key: 'LightCircle', duration: 2.2, startAt: 8.4 }, { key: 'SoulOut', duration: 0.6, startAt: 10.6 }, { key: 'FlowingLight', duration: 2.2, startAt: 11.2 }, { key: 'SoulOut', duration: 0.6, startAt: 13.4 }, { key: 'Heart', duration: 2, startAt: 14 }, ] let randomEffects = new Array(mediaTrack.clips.length * 2 - 1).fill(null).map((item, index) => { return new Clip({ id: `effect-${index}`, type: CLIP_TYPES.EFFECT, key: effectList[index].key, section: new ClipSection({ start: 0, end: effectList[index].duration }), startAt: effectList[index].startAt }) }) effectTrack.clips = randomEffects // 更新effect轨道的clips数据 let musicTrack = new Track({ // 新建一个music轨道 type: TRACK_TYPES.MUSIC, clips: [] }) let bgMusic = new Clip({ id: 'music', type: CLIP_TYPES.MUSIC, info: { tempFilePath: "https://imgcache.qq.com/operation/dianshi/other/wanyouyinli.c7f973d906d9b8a3e7db90a90a7874d01454614b.mp3", }, section: new ClipSection({ start: 0, end: 1000 }), startAt: 0 }) musicTrack.clips = [bgMusic] return [mediaTrack, effectTrack, musicTrack] }, // 跳转至导出页 goExport() { wx.navigateTo({ url: '../export2/export' }) }, // 获取随机特效 _getRandomEffect(effectList = []) { let index = Math.floor(Math.random() * effectList.length) return effectList[index] } }) [代码] export 页面 export.wxml [代码]<wj-export tracks="{{exportTracks}}" bindexportsuccess="onExportSuccess" bindready="onExportReady"> <button>导出视频</button> </wj-export> [代码] export.js [代码]// pages/export2/export.js Page({ data: { exportTracks: [] }, // 设置导出数据 onExportReady() { this.setData({ exportTracks: global.testExportTracks }) }, // 导出成功回调 onExportSuccess(e){ let res = e.detail; wx.saveVideoToPhotosAlbum({ filePath: res.tempFilePath, success: res => { wx.showToast({ title: "已保存至相册" }); } }); } }) [代码] camera和export页面比较简单,这里就不啰嗦了。主要说下player页面的实现思路,player的Track数据暂时只涉及到了三种:media类型、effect类型以及music类型(卡点模式用),其他的暂未使用。 随机模式:针对每张图随机获得一个特效key(wj-player组件内置了一些特效,直接使用即可),然后设置其时长与media类型的clip时长一致即可(默认是3s)。 卡点模式:根据音乐节奏调整了下每张照片的展示时长。 这样一个简单的照片集应用就做出来啦,so easy,妈妈再也不用担心我的发际线了。 卡点模式最终的成果物 点此预览 PS:使用过程中发现 wj-player 组件和 wj-export 组件无法在同一个页面中使用,不知道是不是我的姿势不对,有待后续验证;还有就是素材切换的时候没有转场动画,显得有些僵硬;另外看了下,特效倒是挺多的,但是没办法一键输出卡点视频,缺少一些常见的视频模板,自己做卡点视频特别费劲,要微调好久…… 自动挡虽然好开,但是真正的赛车手都用手动挡[手动狗头结束] 总结 作为小程序端的一款视频剪辑工具,麻雀虽小五脏俱全,基础的功能是齐全的。个人使用下来整体体验还不错,接入也比较简单,官方配套文档也很完善,也期待官方后续可以提供更丰富的功能、组件。 最后,对视频剪辑小程序感兴趣的同学不防申请插件尝试一下,在官方基础api让人如此头秃的情况下,使用插件或许是个不错的选择。
2020-09-28 - 【云开发】用云开发实现订阅消息
背景 10月12日微信官方发布了小程序模板消息能力调整的通知 https://developers.weixin.qq.com/community/develop/doc/00008a8a7d8310b6bf4975b635a401 相比之前的有了较大的调整,主要的调整就是从开发者主动下发消息改为用户自主订阅,也就是说必须要用户手动订阅才行,而订阅消息又分为了一次性订阅消息和长期性订阅消息,长期性订阅消息需要有特定公共服务业务这里就不介绍了,本文主要讲的是一次性订阅消息。 开发准备 如果还没有开通订阅消息的要在小程序后台里开通订阅消息后添加需要的模板,添加完后的红框中的变量就是订阅消息接口要用到的data了 [图片] [图片] 编写云函数 新建云函数,appid跟secret填你自己的即可, 由于订阅消息需要access_token,所以在使用订阅消息接口前要先获取access_token,这里我把他们都放在一个云函数里了 新建好后要安装依赖,这里用到了[代码]request-promise[代码]模块,所以要在该目录下[代码]npm install request-promise[代码] 代码如下 [图片] [图片] 页面调用 [图片] 传入对应的参数即可 返回结果 [图片] [图片] 总结 总的来说并不难,只是有个地方要注意一下,由于之前没怎么用过云开发,不知道它返回的数据结构会有差异,比如在获取access_token的时候发现一个特别之处,在小程序里进行调用的时候,成功的话返回是下图这种格式的 [图片] 但是本地调试的时候返回又是这种格式的 [图片] 少了外面一层object,导致我判断access_token一直报错,调试了好久才发现,好在最后是可以成功获取到。 再来说这次调整,作为用户的我来说我觉得是很ok的,起码我可以选择不接收订阅消息,不像之前那样莫名其妙的就收到了一个订阅消息还不知道是哪个程序的,我是很反感这种的。但是对于一些企业来说可能就不是那么友好了,降低了触达用户的机会。但是不管怎样,身为开发者的可是有得忙了。 代码就不放了,都在图片里,自己动手敲一遍比较好 相关接口 订阅接口 获取access_token接口 下发消息接口
2019-10-14 - 【笔记】小程序图表组件库
可能大家都知道echarts, 百度 echarts阿里 AntV 传送门 https://www.echartsjs.com/zh/tutorial.html#%E5%9C%A8%E5%BE%AE%E4%BF%A1%E5%B0%8F%E7%A8%8B%E5%BA%8F%E4%B8%AD%E4%BD%BF%E7%94%A8%20ECharts 这里笔记记录下一个新的图表组件选择 微信小程序图表charts组件,Charts for WeChat small app https://github.com/xiaolin3303/wx-charts 有没有用过的同学,谈谈
2020-03-23 - 关于微信小程序部分类目报备审核说明
自微信小程序平台上线以来,为了保障小程序内容合规,发布时事新闻、具有社交属性或以视频、电台为载体的小程序需在上线前,完成向省/自治区/直辖市属地网信部门申请报备的工作。特别是,为避免小程序违法违规风险,UGC小程序需要对用户发布的内容做好安全审查措施。 对于开发者们持续以来关注的报备审核相关疑问,做以下说明: 报备审核范围 经与主管部门确认,以类目为范围界定需要报备的小程序,具体报备类目如下: [图片] 社交类目的小程序帮助用户与用户之间产生连接,为用户提供即时通讯、互动、交友、UGC内容发布与分享、直播、直播答题等功能;文娱-视频、文娱-FM/电台类目指小程序内有以视频、电台为载体的内容。时政信息类目小程序内发布政治(含法律法规)、经济、军事、外交、农业、历史、社会突发事件等各种类型的文章/图片/视频/音频的新闻报道; 平台在代码审核环节会确认内容和类目的一致性。如果小程序内涉及有关内容,却没有相关类目,会造成审核不通过并要求补充相应类目。 [图片] 报备审核流程 [图片] 1. 微信侧小程序代码审核:确认是否进入报备审核 在小程序涉及报备类目的版本首次提审时,微信侧审核通过后,自动进入报备审核流程。若微信侧审核不通过,不会进入报备审核流程,下次通过微信侧审核时小程序将自动进入报备审核流程。 小程序涉及报备类目的版本更新提审时,若小程序特定类目曾完成过报备审核,新版本未涉及其他报备类目,无需进入报备审核流程。 撤回代码审核将中止流程,再次提交时若涉及敏感类目会重新进入报备审核。 2、进入属地网信部门复核 开发者在小程序MP后台通知中心收到以下通知,代表小程序进入报备审核流程。从该通知发出时间起,7个自然日内将完成报备审核(境外非企业主体小程序除外)。 [图片] 属地网信部门工作人员会根据当前小程序的主体信息及代码提审信息进行安全审核。也可能会通过开发者预留在微信小程序MP后台的手机号主动联系开发者确认或补充材料。建议开发者在进入报备审核流程后,及时关注和接听来访电话,避免遗漏审核信息,影响报备审核流程。 若开发者收到当地网信部门联系时,请注意核实对方身份,先与其确认真实性后(可通过对方来电、微信、QQ、邮箱等方式询问确认),再提供相关材料。 3、审核周期及预上线说明 报备审核需7个自然日审核周期(不包括境外非企业主体小程序),暂无加急渠道。如遇紧急上线等特殊情况,建议开发者自行联系省/自治区/直辖市属地网信部门。因属地不同,无法列举联系方式,建议自行查看属地相关政府网站查找联系方式。 7个自然日审核周期开发者若未收到审核结果,将收到预上线通知,则开发者可进行版本发布。境外非企业主体小程序无预上线流程。 [图片] 在预上线期间,属地网信部门仍然可能对小程序进行报备审核反馈。预上线的小程序若存在违规或未允许内容,未通过报备审核,将以站内信的形式通知开发者。未通过报备审核的开发者需要及时下架小程序服务,整改小程序内容。 报备审核前重要准备 提供UGC自定义发布内容功能的小程序,需要在小程序内接入官方“内容安全检测接口”校验用户输入的文本/图片,拦截政治、色情、违法等敏感词。用户自定义发布的内容包括:昵称/花名、个人资料签名/日志/聊天/评论、头像/表情/相片、直播等各种场景。其格式内容包括但不限于短文本、长内容、图片或视频等。请开发者做好小程序内容安全识别工作,接入微信公众平台内容安全API(imgSecCheck、msgSecCheck、mediaCheckAsync)能力,以及通过其他技术或人工审核手段做好内容审核,校验用户输入的文本/图片,拦截政治、色情、违法等敏感词,保证用户上传的内容安全健康。 为辅助小程序顺利通过报备审核,代码审核环节会针对小程序的安全保障能力进行敏感类关键词检测,未接入官方“内容安全检测接口”的小程序将无法通过代码审核。开发者需提前做好小程序内容安全识别工作,以免小程序无法通过审核延误版本迭代。若小程序发布后出现UGC内容安全违规问题,也将按平台规则进行处置。 为了满足网信部门的监管要求,平台希望开发者能够明晰小程序报备审核流程,合理规划小程序上线周期,主动预留时间,做好审核准备,配合平台完成向省/自治区/直辖市网信部门报备的工作,保障小程序服务的准时上线。
2022-01-14 - Lottie-前端实现AE动效
项目背景 在海外项目中,为了优化用户体验加入了几处微交互动画,实现方式是设计输出合成的雪碧图,前端通过序列帧实现动画效果: [图片] 序列帧: [图片] 动画效果: [图片] 序列帧: [图片] 帧动画的缺点和局限性比较明显,合成的雪碧图文件大,且在不同屏幕分辨率下可能会失真。经调研发现,Lottie是个简单、高效且性能高的动画方案。 Lottie是可应用于Android, iOS, Web和Windows的库,通过Bodymovin解析AE动画,并导出可在移动端和web端渲染动画的json文件。换言之,设计师用AE把动画效果做出来,再用Bodymovin导出相应地json文件给到前端,前端使用Lottie库就可以实现动画效果。 [图片] Bodymovin插件的安装与使用 关闭AE 下载并安装ZXP installer https://aescripts.com/learn/zxp-installer/ 下载最新版bodymovin插件 https://github.com/airbnb/lottie-web/blob/master/build/extension/bodymovin.zxp 把下载好的bodymovin.zxp拖到ZXP installer [图片] 打开AE,在菜单首选项->常规中勾选☑️允许脚本写入文件和访问网络(否则输出JSON文件时会失败) [图片] 在AE中制作动画,打开菜单窗口->拓展->Bodymovin,勾选要输出的动画,并设置输出文件目录,点击render [图片] 打开输出目录会看到生成的JSON文件,若动画里导入了外部图片,则会在images中存放JSON中引用的图片 前端使用lottie 静态URL https://cdnjs.com/libraries/lottie-web NPM [代码]npm install lottie-web [代码] 调用loadAnimation [代码]lottie.loadAnimation({ container: element, // 容器节点 renderer: 'svg', loop: true, autoplay: true, path: 'data.json' // JSON文件路径 }); [代码] vue-lottie 也可以在vue中使用lottie [代码] import lottie from '../lib/lottie'; import * as favAnmData from '../../raw/fav.json'; export default { props: { options: { type: Object, required: true }, height: Number, width: Number, }, data () { return { style: { width: this.width ? `${this.width}px` : '100%', height: this.height ? `${this.height}px` : '100%', overflow: 'hidden', margin: '0 auto' } } }, mounted () { this.anim = lottie.loadAnimation({ container: this.$refs.lavContainer, renderer: 'svg', loop: this.options.loop !== false, autoplay: this.options.autoplay !== false, animationData: favAnmData, assetsPath: this.options.assetsPath, rendererSettings: this.options.rendererSettings } ); this.$emit('animCreated', this.anim) } } [代码] loadAnimation参数 参数名 描述 container 用于渲染动画的HTML元素,需确保在调用loadAnimation时该元素已存在 renderer 渲染器,可选值为’svg’(默认值)/‘canvas’/‘html’。svg支持的功能最多,但html的性能更好且支持3d图层。各选项值支持的功能列表在此 loop 默认值为true。可传递需要循环的特定次数 autoplay 自动播放 path JSON文件路径 animationData JSON数据,与path互斥 name 传递该参数后,可在之后通过lottie命令引用该动画实例 rendererSettings 可传递给renderer实例的特定设置,具体可看 Lottie动画监听 Lottie提供了用于监听动画执行情况的事件: complete loopComplete enterFrame segmentStart config_ready(初始配置完成) data_ready(所有动画数据加载完成) DOMLoaded(元素已添加到DOM节点) destroy 可使用addEventListener监听事件 [代码]// 动画播放完成触发 anm.addEventListener('complete', anmLoaded); // 当前循环播放完成触发 anm.addEventListener('loopComplete', anmComplete); // 播放一帧动画的时候触发 anm.addEventListener('enterFrame', enterFrame); [代码] 控制动画播放速度和进度 可使用anm.pause和anm.play暂停和播放动画,调用anm.stop则会停止动画播放并回到动画第一帧的画面。 使用anm.setSpeed(speed)可调节动画速度,而anm.goToAndStop(value, isFrame)和anm.goToAndPlay可控制播放特定帧数,也可结合anm.totalFrames控制进度百分比,比如可传anm.totalFrames - 1跳到最后一帧。 [代码]anm.goToAndStop(anm.totalFrames - 1, 1); [代码] 这样的好处是可以把相关联的JSON文件合并,通过anm.goToAndPlay控制动画状态的切换,如下图例中一个JSON文件包含了2个动画状态的数据: [图片] 图片资源 JSON文件里assets设置了对图片的引用: [图片] 若想统一修改静态资源路径或者设置成绝对路径,可在调用loadAnimation时传入assetsPath参数: [代码]lottie.loadAnimation({ container: element, renderer: 'svg', path: 'data.json', assetsPath: 'URL' // 静态资源绝对路径 }); [代码] 功能支持列表 即使用bodymovin成功输出了JSON文件(没有报错),也会出现动效不如预期的情况,比如这是在AE中构建的形象图: [图片] 但在页面中渲染效果是这样的: [图片] 这是因为使用了不支持的Merge Paths功能 [图片] 因此对设计师而言,创建Lottie动画和往常制作AE动画有所不同,此文档记录了Bodymovin支持输出的AE功能列表,动画制作前需跟设计师沟通好,根据动画加载平台来确认可使用的AE功能。 除此之外,尽量遵循官方文档里对设计过程的指导和建议: 动画简单化。创建动画时需时刻记着保持JSON文件的精简,比如尽可能地绑定父子关系,在相似的图层上复制相同的关键帧会增加额外的代码,尽量不使用占用空间最多的路径关键帧动画。诸如自动跟踪描绘、颤动之类的技术会使得JSON文件变得非常大且耗性能。 建立形状图层。将AI、EPS、SVG和PDF等资源转换成形状图层否则无法在Lottie中正常使用,转换好后注意删除该资源以防被导出到JSON文件。 设置尺寸。在AE中可设置合成尺寸为任意大小,但需确保导出时合成尺寸和资源尺寸大小保持一致。 不使用表达式和特效。Lottie暂不支持。 注意遮罩尺寸。若使用alpha遮罩,遮照的大小会对性能产生很大的影响。尽可能地把遮罩尺寸维持到最小。 动画调试。若输出动画破损,通过每次导出特定图层来调试出哪些图层出了问题。然后在github中附上该图层文件提交问题,选择用其他方式重构该图层。 不使用混合模式和亮度蒙版。 不添加图层样式。 全屏动画。设置比想要支持的最宽屏幕更宽的导出尺寸。 设置空白对象。若使用空白对象,需确保勾选可见并设置透明度为0%否则不会被导出到JSON文件。 预览效果 由于以上所说的功能支持问题会导致输出动画效果不确定性,设计师和前端之间有个动画效果联调的过程,为了提高联调效率,设计师可先进行初步的效果预览,再把文件交付给前端。 方法1:输出预览HTML文件 渲染前设置所要渲染的文件 [图片] 勾选☑️Demo选项 [图片] 在输出的文件目录中就可找到可预览的demo.html文件 方法2:LottieFiles分享平台 把生成的JSON文件传到LottieFiles平台,可播放、暂停生成文件的动画效果,可设置图层颜色、动画速度,也可以下载lottie preview客户端在iOS或Android机子上预览。 [图片] LottieFiles平台还提供了很多线上公开的Lottie动画效果,可直接下载JSON文件使用 [图片] 交互hack Lottie的不足之处是没有对应的API操纵动画层,若想做更细化的动画处理,只能直接操作节点来实现。比如当播放完左图动画进入惊讶状态后,若想实现右图随鼠标移动而控制动画层的简单效果: [图片][图片] 开启调试面板可以看到,lottie-web通过使用<g>标签的transform属性来控制动画: [图片] 当元素已添加到DOM节点,找到想要控制的<g>标签,提取其transform属性的矩阵值,并使用rematrix解析矩阵值。 [代码]onIntroDone() { const Gs = this.refs.svg.querySelectorAll('svg > g > g > g'); Gs.forEach((node, i) => { // 过滤需要修改的节点 ... // 获取transform属性值 const styleArr = node.getAttribute('transform').split(','); styleArr[0] = styleArr[0].replace('matrix(', ''); styleArr[5] = styleArr[5].replace(')', ''); const style = `matrix(${styleArr[0]}, ${styleArr[1]}, ${styleArr[2]}, ${styleArr[3]}, ${styleArr[4]}, ${styleArr[5]})`; // 使用Rematrix解析 const transform = Rematrix.parse(style); this.matrices.push({ node, transform, prevTransform: transform }); } } [代码] 监听鼠标移动,设置新的transform属性值。 [代码]onMouseMove = (e) => { this.mouseCoords.x = e.clientX || e.pageX; this.mouseCoords.y = e.clientY || e.pageY; let x = this.mouseCoords.x - (this.props.browser.width / 2); let y = this.mouseCoords.y - (this.props.browser.height / 2); const diffX = (this.mouseCoords.prevX - x); const diffY = (this.mouseCoords.prevY - y); this.mouseCoords.prevX = x; this.mouseCoords.prevY = y; this.matrices.forEach((matrix, i) => { let translate = Rematrix.translate(diffX, diffY); const product = [matrix.prevTransform, translate].reduce(Rematrix.multiply); const css = `matrix(${product[0]}, ${product[1]}, ${product[4]}, ${product[5]}, ${product[12]}, ${product[13]})`; matrix.prevTransform = product; matrix.node.setAttribute('transform', css); }) } [代码] 进一步优化 看到一个方法,在AE中将图层命名为[代码]#id[代码]格式,生成的SVG相应的图层id会被设置为id,命名为[代码].class[代码]格式,相应的图层class会被设置为class [图片] 试了下的确可以,如下图,因此可通过这个方法快速找到需要操作的动画层,进一步简化代码: [图片] 小结 Lottie的缺点在于若在AE动画制作的过程不注意规范,会导致数据文件大、耗内存和性能的问题;Lottie-web的官方文档不够详尽,例如assetsPath参数是在看源码的时候发现的;开放的API不够齐全,无法很灵活地控制动画层。 而优点也很明显,Lottie能帮助提高开发效率,精简代码,易于调试和维护;资源文件小,输出动画效果保真;跨平台——Android, iOS, Web和Windows通用。 总的来说,Lottie的引用可以替代传统的GIF和帧动画,灵活利用好提供的属性和方法可以控制动画的播放,但需注意规范设计和开发的流程,才可以更高效地完成动画的制作与调试。
2019-03-25 - 初试小程序接入three.js
看着小程序下的canvas日渐完善,特别是2.7.0库下新增了WebGL,终于可以摆脱原来用wx.createCanvasContext创建的2d上下文(不知为何在使用魔改后three.js中的CanvasRenderer渲染画面就是很慢,捕获JavaScript Profiler看着就是慢在draw方法上)。 不过理想很丰满,现实很骨感,想要在小程序上用three.js依然要来个大改造。让我们开始吧 官方文档里提供了一段如何获取WebGL Context的代码: [代码]Page({[代码][代码] [代码][代码]onReady() {[代码][代码] [代码][代码]const query = wx.createSelectorQuery()[代码][代码] [代码][代码]query.select([代码][代码]'#myCanvas'[代码][代码]).node().exec((res) => {[代码][代码] [代码][代码]const canvas = res[0].node[代码][代码] [代码][代码]const gl = canvas.getContext([代码][代码]'webgl'[代码][代码])[代码][代码] [代码][代码]console.log(gl)[代码][代码] [代码][代码]})[代码][代码] [代码][代码]}[代码][代码]})[代码]我们就从这里入手 首先先写个wxml: [代码]<[代码][代码]canvas[代码] [代码]type[代码][代码]=[代码][代码]"webgl"[代码] [代码]id[代码][代码]=[代码][代码]"webgl"[代码] [代码]width[代码][代码]=[代码][代码]"{{canvasWidth||(320*2)}}"[代码] [代码]height[代码][代码]=[代码][代码]"{{canvasHeight||(504*2)}}"[代码] [代码]style[代码][代码]=[代码][代码]'width:{{canvasStyleWidth||"320px"}};height:{{canvasStyleHeight||"504px"}};'[代码] [代码]bindtouchstart[代码][代码]=[代码][代码]'onTouchStart'[代码] [代码]bindtouchmove[代码][代码]=[代码][代码]'onTouchMove'[代码] [代码]bindtouchend[代码][代码]=[代码][代码]'onTouchEnd'[代码][代码]></[代码][代码]canvas[代码][代码]>[代码]其中width和height是设置画布大小的,style中的width和height是设置画布的实际渲染大小的 然后js: [代码]onReady:[代码][代码]function[代码][代码](){[代码][代码] [代码][代码]var[代码] [代码]self = [代码][代码]this[代码][代码];[代码][代码] [代码][代码]var[代码] [代码]query = wx.createSelectorQuery().select([代码][代码]'#webgl'[代码][代码]).node().exec((res) => {[代码][代码] [代码][代码]var[代码] [代码]canvas = res[0].node;[代码][代码] [代码][代码]requestAnimationFrame = canvas.requestAnimationFrame;[代码][代码] [代码][代码]canvas.width = canvas._width;[代码][代码] [代码][代码]canvas.height = canvas._height;[代码][代码] [代码][代码]canvas.style = {};[代码][代码] [代码][代码]canvas.style.width = canvas.width;[代码][代码] [代码][代码]canvas.style.height = canvas.height;[代码][代码] [代码][代码]self.init(canvas);[代码][代码] [代码][代码]self.animate();[代码][代码] [代码][代码]})[代码][代码] [代码][代码]}[代码]先模拟dom构造一个canvas对象,然后传入init方法中,我们在这里创建场景、相机、渲染器等 [代码]init: [代码][代码]function[代码] [代码](canvas) {[代码][代码]...[代码][代码] [代码][代码]camera = [代码][代码]new[代码] [代码]THREE.PerspectiveCamera(20, canvas.width / canvas.height, 1, 10000);[代码][代码] [代码][代码]scene = [代码][代码]new[代码] [代码]THREE.Scene();[代码][代码]...[代码][代码] [代码][代码]renderer = [代码][代码]new[代码] [代码]THREE.WebGLRenderer({ canvas: canvas, antialias: [代码][代码]true[代码] [代码]});[代码][代码] [代码][代码]}[代码]这样一个最基础的三维场景就搭好了,然后继续执行animate方法,开始渲染场景 [代码]animate:[代码][代码]function[代码][代码]() {[代码][代码] [代码][代码]requestAnimationFrame([代码][代码]this[代码][代码].animate);[代码][代码] [代码][代码]this[代码][代码].render();[代码][代码] [代码][代码]}[代码]接下来尝试跑一下three.js提供的例子 webgl_geometry_colors : [图片] 锯齿问题比较严重,暂时没找到解决办法,但总体来说还是可以的,至少场景渲染出来了 由于暂时没想到如何改造CanvasTexture,我把例子中的 [代码]var[代码] [代码]canvas = document.createElement( [代码][代码]'canvas'[代码] [代码]);[代码][代码]canvas.width = 128;[代码][代码]canvas.height = 128;[代码][代码]var[代码] [代码]context = canvas.getContext( [代码][代码]'2d'[代码] [代码]);[代码][代码]var[代码] [代码]gradient = context.createRadialGradient( canvas.width / 2, canvas.height / 2, 0, canvas.width / 2, canvas.height / 2, canvas.width / 2 );[代码][代码]gradient.addColorStop( 0.1, [代码][代码]'rgba(210,210,210,1)'[代码] [代码]);[代码][代码]gradient.addColorStop( 1, [代码][代码]'rgba(255,255,255,1)'[代码] [代码]);[代码][代码]context.fillStyle = gradient;[代码][代码]context.fillRect( 0, 0, canvas.width, canvas.height );[代码][代码]var[代码] [代码]shadowTexture = [代码][代码]new[代码] [代码]THREE.CanvasTexture( canvas );[代码]替换成 webgl_geometries 例子中的TextureLoader [代码]var[代码] [代码]shadowTexture = [代码][代码]new[代码] [代码]THREE.TextureLoader().load(canvas,[代码][代码]'../../textures/UV_Grid_Sm.jpg'[代码][代码]);[代码]可能有人会发现load方法中传入的参数多了一个canvas,因为小程序提供的api没法直接创建Image对象,仅有一个Canvas.createImage()方法可以创建Image对象。因此我们还需要改造一下TextureLoader中的load方法,先看一下原版中的load方法: [代码]Object.assign( TextureLoader.prototype, {[代码] [代码] [代码][代码]crossOrigin: [代码][代码]'anonymous'[代码][代码],[代码] [代码] [代码][代码]load: [代码][代码]function[代码] [代码]( url, onLoad, onProgress, onError ) {[代码] [代码] [代码][代码]var[代码] [代码]texture = [代码][代码]new[代码] [代码]Texture();[代码] [代码] [代码][代码]var[代码] [代码]loader = [代码][代码]new[代码] [代码]ImageLoader( [代码][代码]this[代码][代码].manager );[代码][代码] [代码][代码]loader.setCrossOrigin( [代码][代码]this[代码][代码].crossOrigin );[代码][代码] [代码][代码]loader.setPath( [代码][代码]this[代码][代码].path );[代码] [代码] [代码][代码]loader.load( url, [代码][代码]function[代码] [代码]( image ) {[代码]其中实际调用了ImageLoader来加载图片,在看看ImageLoader: [代码]Object.assign( ImageLoader.prototype, {[代码] [代码] [代码][代码]crossOrigin: [代码][代码]'anonymous'[代码][代码],[代码] [代码] [代码][代码]load: [代码][代码]function[代码] [代码]( url, onLoad, onProgress, onError ) {[代码] [代码] [代码][代码]if[代码] [代码]( url === undefined ) url = [代码][代码]''[代码][代码];[代码] [代码] [代码][代码]if[代码] [代码]( [代码][代码]this[代码][代码].path !== undefined ) url = [代码][代码]this[代码][代码].path + url;[代码] [代码] [代码][代码]url = [代码][代码]this[代码][代码].manager.resolveURL( url );[代码] [代码] [代码][代码]var[代码] [代码]scope = [代码][代码]this[代码][代码];[代码] [代码] [代码][代码]var[代码] [代码]cached = Cache.get( url );[代码] [代码] [代码][代码]if[代码] [代码]( cached !== undefined ) {[代码] [代码] [代码][代码]scope.manager.itemStart( url );[代码] [代码] [代码][代码]setTimeout( [代码][代码]function[代码] [代码]() {[代码] [代码] [代码][代码]if[代码] [代码]( onLoad ) onLoad( cached );[代码] [代码] [代码][代码]scope.manager.itemEnd( url );[代码] [代码] [代码][代码]}, 0 );[代码] [代码] [代码][代码]return[代码] [代码]cached;[代码] [代码] [代码][代码]}[代码] [代码] [代码][代码]var[代码] [代码]image = document.createElementNS( [代码][代码]'http://www.w3.org/1999/xhtml'[代码][代码], [代码][代码]'img'[代码] [代码]);[代码] [代码] [代码][代码]function[代码] [代码]onImageLoad() {[代码] [代码] [代码][代码]image.removeEventListener( [代码][代码]'load'[代码][代码], onImageLoad, [代码][代码]false[代码] [代码]);[代码][代码] [代码][代码]image.removeEventListener( [代码][代码]'error'[代码][代码], onImageError, [代码][代码]false[代码] [代码]);[代码] [代码] [代码][代码]Cache.add( url, [代码][代码]this[代码] [代码]);[代码] [代码] [代码][代码]if[代码] [代码]( onLoad ) onLoad( [代码][代码]this[代码] [代码]);[代码] [代码] [代码][代码]scope.manager.itemEnd( url );[代码] [代码] [代码][代码]}[代码] [代码] [代码][代码]function[代码] [代码]onImageError( event ) {[代码] [代码] [代码][代码]image.removeEventListener( [代码][代码]'load'[代码][代码], onImageLoad, [代码][代码]false[代码] [代码]);[代码][代码] [代码][代码]image.removeEventListener( [代码][代码]'error'[代码][代码], onImageError, [代码][代码]false[代码] [代码]);[代码] [代码] [代码][代码]if[代码] [代码]( onError ) onError( event );[代码] [代码] [代码][代码]scope.manager.itemError( url );[代码][代码] [代码][代码]scope.manager.itemEnd( url );[代码] [代码] [代码][代码]}[代码] [代码] [代码][代码]image.addEventListener( [代码][代码]'load'[代码][代码], onImageLoad, [代码][代码]false[代码] [代码]);[代码][代码] [代码][代码]image.addEventListener( [代码][代码]'error'[代码][代码], onImageError, [代码][代码]false[代码] [代码]);[代码] [代码] [代码][代码]if[代码] [代码]( url.substr( 0, 5 ) !== [代码][代码]'data:'[代码] [代码]) {[代码] [代码] [代码][代码]if[代码] [代码]( [代码][代码]this[代码][代码].crossOrigin !== undefined ) image.crossOrigin = [代码][代码]this[代码][代码].crossOrigin;[代码] [代码] [代码][代码]}[代码] [代码] [代码][代码]scope.manager.itemStart( url );[代码] [代码] [代码][代码]image.src = url;[代码] [代码] [代码][代码]return[代码] [代码]image;[代码] [代码] [代码][代码]},[代码]document.createElementNS这种东西肯定是没法存在的,没办法,把canvas传进来用createImage方法创建Image对象,改造后: [代码]Object.assign( ImageLoader.prototype, {[代码] [代码] [代码][代码]crossOrigin: [代码][代码]'anonymous'[代码][代码],[代码] [代码] [代码][代码]load: [代码][代码]function[代码] [代码]( canvas,url, onLoad, onProgress, onError ) {[代码] [代码] [代码][代码]if[代码] [代码]( url === undefined ) url = [代码][代码]''[代码][代码];[代码] [代码] [代码][代码]if[代码] [代码]( [代码][代码]this[代码][代码].path !== undefined ) url = [代码][代码]this[代码][代码].path + url;[代码] [代码] [代码][代码]url = [代码][代码]this[代码][代码].manager.resolveURL( url );[代码] [代码] [代码][代码]var[代码] [代码]scope = [代码][代码]this[代码][代码];[代码] [代码] [代码][代码]var[代码] [代码]cached = Cache.get( url );[代码] [代码] [代码][代码]if[代码] [代码]( cached !== undefined ) {[代码] [代码] [代码][代码]scope.manager.itemStart( url );[代码] [代码] [代码][代码]setTimeout( [代码][代码]function[代码] [代码]() {[代码] [代码] [代码][代码]if[代码] [代码]( onLoad ) onLoad( cached );[代码] [代码] [代码][代码]scope.manager.itemEnd( url );[代码] [代码] [代码][代码]}, 0 );[代码] [代码] [代码][代码]return[代码] [代码]cached;[代码] [代码] [代码][代码]}[代码] [代码] [代码][代码]//var image = document.createElementNS( 'http://www.w3.org/1999/xhtml', 'img' );[代码][代码] [代码][代码]console.log([代码][代码]this[代码][代码], canvas);[代码][代码] [代码][代码]var[代码] [代码]image = canvas.createImage();[代码] [代码] [代码][代码]function[代码] [代码]onImageLoad() {[代码] [代码] [代码][代码]//image.removeEventListener( 'load', onImageLoad, false );[代码][代码] [代码][代码]//image.removeEventListener( 'error', onImageError, false );[代码][代码] [代码][代码]image.onload = [代码][代码]function[代码] [代码]() { };[代码][代码] [代码][代码]image.onerror = [代码][代码]function[代码] [代码]() { };[代码] [代码] [代码][代码]Cache.add( url, [代码][代码]this[代码] [代码]);[代码] [代码] [代码][代码]if[代码] [代码]( onLoad ) onLoad( [代码][代码]this[代码] [代码]);[代码] [代码] [代码][代码]scope.manager.itemEnd( url );[代码] [代码] [代码][代码]}[代码] [代码] [代码][代码]function[代码] [代码]onImageError( event ) {[代码] [代码] [代码][代码]//image.removeEventListener( 'load', onImageLoad, false );[代码][代码] [代码][代码]//image.removeEventListener( 'error', onImageError, false );[代码][代码] [代码][代码]image.onload = [代码][代码]function[代码] [代码]() { };[代码][代码] [代码][代码]image.onerror = [代码][代码]function[代码] [代码]() { };[代码] [代码] [代码][代码]if[代码] [代码]( onError ) onError( event );[代码] [代码] [代码][代码]scope.manager.itemEnd( url );[代码][代码] [代码][代码]scope.manager.itemError( url );[代码] [代码] [代码][代码]}[代码] [代码] [代码][代码]//image.addEventListener( 'load', onImageLoad, false );[代码][代码] [代码][代码]//image.addEventListener( 'error', onImageError, false );[代码][代码] [代码][代码]image.onload = onImageLoad;[代码][代码] [代码][代码]image.onerror = onImageError;[代码] [代码] [代码][代码]if[代码] [代码]( url.substr( 0, 5 ) !== [代码][代码]'data:'[代码] [代码]) {[代码] [代码] [代码][代码]if[代码] [代码]( [代码][代码]this[代码][代码].crossOrigin !== undefined ) image.crossOrigin = [代码][代码]this[代码][代码].crossOrigin;[代码] [代码] [代码][代码]}[代码] [代码] [代码][代码]scope.manager.itemStart( url );[代码] [代码] [代码][代码]image.src = url;[代码] [代码] [代码][代码]return[代码] [代码]image;[代码] [代码] [代码][代码]},[代码]然后TextureLoader的load方法也改一下传参顺序: [代码]Object.assign( TextureLoader.prototype, {[代码] [代码] [代码][代码]crossOrigin: [代码][代码]'anonymous'[代码][代码],[代码] [代码] [代码][代码]load: [代码][代码]function[代码] [代码]( canvas,url, onLoad, onProgress, onError ) {[代码] [代码] [代码][代码]var[代码] [代码]texture = [代码][代码]new[代码] [代码]Texture();[代码] [代码] [代码][代码]var[代码] [代码]loader = [代码][代码]new[代码] [代码]ImageLoader( [代码][代码]this[代码][代码].manager );[代码][代码] [代码][代码]loader.setCrossOrigin( [代码][代码]this[代码][代码].crossOrigin );[代码][代码] [代码][代码]loader.setPath( [代码][代码]this[代码][代码].path );[代码] [代码] [代码][代码]loader.load( canvas,url, [代码][代码]function[代码] [代码]( image ) {[代码]OK! 这个例子代码我放在https://github.com/leo9960/xcx_threejs,大家可以接着研究一下。潜力还是比较大的,比如我拿它搞了个全景展示 [图片] ---------------------------------------------------------------------- 2019.5.26 新上传了全景展示的范例,基于panolens.js,欢迎围观
2019-05-26 - 小程序构建骨架屏的探索
首屏 一般情况下,在首屏数据未拿到之前,为了提升用户的体验,会在页面上展示一个loading的图层,类似下面这个 [图片] 其中除了菊花图以外网上还流传这各种各样的loading动画,在PC端上几乎要统一江湖了,不过最近在移动端上面看到不同于菊花图的加载方式,就是这篇文章需要分享的Skeleton Screen,中文称之为"骨架屏" 概念 A skeleton screen is essentially a blank version of a page into which information is gradually loaded. 在H5中,骨架屏其实已经不是什么新奇的概念了,网上也有各种方案生成对应的骨架屏,包括我们经常使用的知乎、饿了么、美团等APP都有应用骨架屏这个概念 图片来源网络,侵删 [图片] 方案 先从H5生成骨架屏方案开始说起,总的来说H5生成骨架屏的方案有2种 完全靠手写HTML和CSS方式给每个页面定制一套骨架屏 利用预渲染的方式生成静态骨架屏 第一套方案,毫无疑问是最简单最直白的方式,缺点也很明显,假如页面布局有修改的话,那么除了修改业务代码之外还需要额外修改骨架屏,增加了维护的成本。 第二套方案,一定程度上改善了第一套方案带来的维护成本增加的缺点,主要还是使用工具预渲染页面,获取到DOM节点和样式,保留页面结构,覆盖样式,生成灰色块盖在原有文本、图片或者是canvas等节点上面,最后将生成的HTML和CSS打包出来,就是一个带有骨架屏的页面。最后再利用webpack工具将生成的骨架屏插入到HTML里面,详细的话可以看看饿了么的分享,这里就不多描述了。 调研了下H5生成骨架屏的方案,对于小程序生成骨架屏的方案也有了一个大致的想法,主要有2个难点需要实现 预渲染 获取节点 预渲染 再说回饿了么提供的骨架屏的方案,使用 puppeteer 渲染页面(或者使用服务端渲染,vue或者react都有提供相应的方案),拿到DOM节点和样式,这里有一点需要注意的是,页面的渲染是需要初始化的数据,数据的来源可以是初始化的data(vue)或者mock数据,当然小程序是无法直接使用 puppeteer 来做预渲染(有另外的方案可以实现),需要利用小程序初始化的 data + template 渲染之后得到一个初始化结构作为骨架屏的结构 [代码]//index.js Page({ data: { motto: 'Hello World', userInfo: { avatarUrl: 'https://wx.qlogo.cn/mmopen/vi_32/SYiaiba5faeraYBoQCWdsBX4hSjFKiawzhIpnXjejDtjmiaFqMqhIlRBqR7IVdbKE51npeF6X1cXxtDQD2bzehgqMA/132', nickName: 'jay' }, lists: [ 'aslkdnoakjbsnfkajbfk', 'qwrwfhbfdvndgndghndeghsdfh', 'qweqwtefhfhgmjfgjdfghaefdhsdfgdfh', ], showSkeleton: true }, onLoad: function () { const that = this; setTimeout(() => { that.setData({ showSkeleton: false }) }, 3000) } }) //index.wxml <view class="container"> <view class="userinfo"> <block> <image class="userinfo-avatar skeleton-radius" src="{{userInfo.avatarUrl}}" mode="cover"></image> <text class="userinfo-nickname skeleton-rect">{{userInfo.nickName}}</text> </block> </view> <view style="margin: 20px 0"> <view wx:for="{{lists}}" class="lists"> <icon type="success" size="20" class="list skeleton-radius"/> <text class="skeleton-rect">{{item}}</text> </view> </view> <view class="usermotto"> <text class="user-motto skeleton-rect">{{motto}}</text> </view> <view style="margin-top: 200px;"> aaaaaaaaaaa </view> </view> [代码] 有了上面的 data + template 之后,就有了一个初始化的页面结构,接下来就需要拿到节点信息 节点 小程序基础库1.4.0之后小程序基础库提供了一组新的API,可用于获取节点信息,具体API戳这里。 跟H5方式一样,根据class或者id获取节点信息,不同的是只能获取到当前的节点信息,无法获取到其父或者子节点信息,所以只能手动给需要渲染骨架屏的节点添加相应的class或者id [代码]<view class="container"> <view class="userinfo"> <block> <image class="userinfo-avatar skeleton-radius" src="{{userInfo.avatarUrl}}" mode="cover"></image> <text class="userinfo-nickname skeleton-rect">{{userInfo.nickName}}</text> </block> </view> <view style="margin: 20px 0"> <view wx:for="{{lists}}" class="lists"> <icon type="success" size="20" class="list skeleton-radius"/> <text class="skeleton-rect">{{item}}</text> </view> </view> <view class="usermotto"> <text class="user-motto skeleton-rect">{{motto}}</text> </view> <view style="margin-top: 200px;"> aaaaaaaaaaa </view> </view> [代码] 约定2个特殊的class作为获取节点信息的标记[代码]skeleton-rect[代码]和[代码]skeleton-radius[代码],在页面中获取相应的[代码]top[代码]、[代码]left[代码]、[代码]width[代码]、[代码]height[代码]进行骨架屏的绘制 结果 [图片] 具体的调用方式和源码,请看 github ,最后求start 总结 上文有说到小程序也可以使用 page-skeleton-webpack-plugin 方式一样生成骨架屏,最重要的一点就是需要将小程序跑在chrome上面,后面的流程就一样了,至于怎么将小程序跑在chrome上面呢?可以利用 wept ,缺点就是目前作者已经停止维护这个工具了,不支持新版小程序的API。 说回来我这个生成骨架屏的方案,其实跟 page-skeleton-webpack-plugin 有点相似,不同的是,page-skeleton-webpack-plugin 采用离线渲染的方式生成静态骨架屏插入路由中,而我采用运行时先渲染页面默认结构,然后根据默认结构再绘制骨架屏。从性能角度出发确实不如 page-skeleton-webpack-plugin,但是也差不了多少了,主要还是小程序并没有提供类似服务端渲染的方案。目前从使用上来讲,还是有点小麻烦,需要默认数据撑开页面结构,需要给相应的节点添加class,后面有时间再研究下有没有更好的方案吧~~~
2019-02-20 - Wxml2Canvas -- 快速生成小程序分享图通用方案
Wxml2Canvas库,可以将指定的wxml节点直接转换成canvas元素,并且保存成分享图,极大地提升了绘制分享图的效率。目前被应用于微信游戏圈、王者荣耀、刺激战场助手等小程序中。 github地址:https://github.com/wg-front/wxml2canvas 一、背景 随着小程序应用的日渐成熟,多处场景需要能够生成分享图便于用户进行二次传播,从而提升小程序的传播率以及加强品牌效应。 对于简单的分享图,比如固定大小的背景图加几行简短文字构成的分享小图,我们可以利用官方提供的canvas接口将元素直接绘制, 虽然繁琐了些,但能满足基本要求。 对于复杂的分享图,比如用户在微信游戏圈发表完话题后,需要将图文混排的富文本内容生成分享图,对于这种长度不定,内容动态变化的图片生成需求,直接利用官方的canvas接口绘制是十分困难的,包括但不限于文字换行、表情文字图片混排、文字加粗、子标题等元素都需要一一绘制。又如王者荣耀助手小程序,需要将十人对局的详细战绩绘制成分享图,包含英雄数据、装备、技能、对局结果等信息,要绘制100多张图片和大量的文字信息,如果依旧使用官方的接口一步一步绘制,对开发者来说简直就是一场噩梦。我们急需一种通用、高效的方式完成上述的工作。 在这样的背景下,wxml2cavnas诞生了,作为一种分享图绘制的通用方案,它不仅能快速的绘制简单的固定小图,还能直接将wxml元素真实地转换成canvas元素,并且适配各种机型。无论是复杂的图文混排的富文本内容,还是展现形式多样的战绩结果页,都可以利用wxml2cavnas完美地快速绘制并生成所期望的分享图片。 二、Wxml2Canvas介绍及示例 1. 介绍 Wxml2Cavnas库,是一个生成小程序分享图的通用方案,提供了两种绘制方式: 封装基础图形的绘制接口,包括矩形、圆形、线条、图片、圆角图片、纯文本等,使用时只需要声明元素类型并提供关键数据即可,不需要再关注canvas的具体绘制过程; wxml直接转换成canvas元素,使用时传入待绘制的wxml节点的class类名,并且声明绘制此节点的类型(图片、文字等),会自动读取此节点的computedStyle,利用这些数据完成元素的绘制。 2. 生成图示例 下面是两张极端复杂的分享图。 2.1 游戏圈话题 [图片] 点击查看完整长图 2.2.2 王者荣耀战绩 [图片] 点击查看完整大图 三、小程序的特性及局限 小程序提供了如下特性,可供我们便捷使用: measureText接口能直接测量出文本的宽度; SelectorQuery可以查询到节点对应的computedStyle。 利用第一条,我们在绘制超长文本时便于文本的省略或者换行,从而避免文字溢出。 利用第二条,我们可以根据class类名,直接拿到节点的样式,然后将style转换成canvas可识别的内容。 但是和html的canvas相比,小程序的canvas局限性很多。主要体现在如下几点: 不支持base64图片; 图片必须下载到本地后才能绘制到画布上; 图片域名需要在管理平台加入downFile安全域名; canvas属于原生组件,在移动端会置于最顶层; 通过SelectorQuery只能拿到节点的style,而无法获取文本节点的内容以及图片节点的链接。 针对以上问题,我们需要将base64图片转换jpg或png格式的图片,实现图片的统一下载逻辑,并且离屏绘制内容。针对第五条,好在SelectorQuery可以获取到节点的dataset属性,所以我们需要在待绘制的节点上显示地声明其类型(imgae、text等),并且显示地传入文本内容或图片链接,后文会有示例。 四、Wxml2Canvas使用方式 1. 初始化 首先在wxml中创建canvas节点,指定宽高: [代码] <canvas canvas-id="share" style="height: {{ height * zoom }}px; width: {{ width * zoom }}px;"> </canvas> [代码] 引入代码库,创建DrawImage实例,并传入如下参数: [代码] let DrawImage = require('./wxml2canvas/index.js'); let zoom = this.device.windowWidth / 375; let width = 375; let height = width * 3; let drawImage = new DrawImage({ element: 'share', // canvas节点的id, obj: this, // 在组件中使用时,需要传入当前组件的this width: width, // 宽高 height: height, background: '#161C3A', // 默认背景色 gradientBackground: { // 默认的渐变背景色,与background互斥 color: ['#17326b', '#340821'], line: [0, 0, 0, height] }, progress (percent) { // 绘制进度 }, finish (url) { // 画完后返回url }, error (res) { console.log(res); // 画失败的原因 } }); [代码] 所有的数字参数均以iphone6为基准,其中参数width和height决定了canvas画布的大小,规定值是在iphone6机型下的固定数值; zoom参数的作用是控制画布的缩放比例,如果要求画布自适应,则应传入 windowWidth / 375,windowWidth为手机屏幕的宽度。 2. 传入数据,生成图片 执行绘制操作: [代码] drawImage.draw(data, this); [代码] 执行绘制时需要传入数据data,数据的格式分为两种,下面展开介绍。 2.1 基础图形 第一种为基础的图形、图文绘制,直接使用官方提供接口,下面代码是一个基本的格式: [代码] let data = { list: [{ type: 'image', url: 'https://xxx', class: 'background_image', // delay: true, x: 0, y: 0, style: { width: width, height: width } }, { type: 'text', text: '文字', class: 'title', x: 0, y: 0, style: { fontSize: 14, lineHeight: 20, color: '#353535', fontFamily: 'PingFangSC-Regular' } }] } [代码] 如上,type声明了要元素的类型,有image、text、rect、line、circle、redius_image(圆角图)等,能满足绝大多数情况。 class类名指定了使用的样式,需要在style中写出,符合css样式规范。 delay参数用来异步绘制元素,会把此元素放在第二个循环中绘制。 x,y用来指定元素的起始坐标。 将css样式与元素分离的目的是便于管理与复用。 此种方式每个元素都相互独立,互不影响,能够满足自由度要求高的情况,可控性高。 2.2 wxml转换 第二种方式为指定wxml元素,自动获取,下面是示例: [代码] let data = { list: [{ type: 'wxml', class: '.panel .draw_canvas', limit: '.panel' x: 0, y: 0 }] } [代码] 如上,type声明为wxml时,会查找所有类名为draw_canvas的节点,并且加入到绘制队列中。 class传入的第一个类名限定了查询的范围,可以不传,第二个用来指定查找的节点,可以定义为任意不影响样式展现的通用类名。 limit属性用来限定相对位置,例如,一个文本的位置(left, top) = (50, 80), class为panel的节点的位置为(left, top) = (20, 40),则文本canvas上实际绘制的位置(x, y) = (50 - 20, 80 -40) = (30, 40)。如果不传入limit,则以实际的位置(x, y) = (50, 80)绘制。 由于小程序节点元素查询接口的局限,无法直接获取节点的文本内容和图片标签的src属性,也无法直接区分是文本还是图片,但是可以获取到dataset,所以我们需要在节点上显示地声明data-type来指明类型,再声明data-text传入文字或data-url传入图片链接。下面是个示例: [代码] <view class="panel"> <view class="panel__img draw_canvas" data-type="image" data-url="https://xxx"></view> <view class="panel__text draw_canvas" data-type="text" data-text="文字">文字</view> </view> [代码] 如上,会查询到两个节点符合条件,第一个为image图片,第二个为text文本,利用SelectorQuery查询它们的computedStyle,分别得到left、top、width、height等数据后,转换成canvas支持的格式,完成绘制。 除此之外,下面的示例功能更加丰富: [代码] <view class="panel"> <view class="panel__text draw_canvas" data-type="background-image" data-radius="1" data-shadow="" data-border="2px solid #000"></view> <view class="panel__text draw_canvas" data-type="text" data-background="#ffffff" data-padding="2 3 0 0" data-delay="1" data-left="10" data-top="10" data-maxlength="4" data-text="这是个文字">这是个文字</view> </view> [代码] 如上,第一个data-type为background-image,表示读取此节点的背景图片,因为可以通过computedStyle直接获取图片链接,所以不需要显示传入url。声明data-radius属性,表示要将此图绘成乘圆形图片。data-border属性表示要绘制图片的边框,虽然也可以通过computedStyle直接获取,但是为了避免非预期的结果,还是要声明传入,border格式应符合css标准。此外,图片的box-shadow等样式都会根据声明绘制出来。 第二个文本节点,声明了data-background,则会根据节点的位置属性给文字增加背景。 data-padding属性用来修正背景的位置和宽高。data-delay属性用来延迟绘制,可以根据值的大小,来控制元素的层级,data-left和data-top用来修正位置,支持负值。data-maxlength用来限制文本的最大长度,超长时会截取并追加’…’。 此外,data-type还有inline-text,inline-image等行内元素的绘制,其实现较为复杂,会在后文介绍。 五、Wxml2Canvas实现原理 1. 绘制流程 整个绘制流程如下: [图片] 因为小程序的限制,只能在画布上绘制本地图片,所以统一先对图片提前下载,然后再绘制,为了避免图片重复下载,内部维护一个图片列表,会对相同的图片链接去重,减少等待时间。 2. 基本图形的实现 基础图形的绘制比较简单,内部实现只是对基础能力的封装,使用者不用再关注canvas的绘制过程,只需要提供关键数据即可,下面是一个图片绘制的实现示例: [代码] function drawImage (item, style) { if(item.delay) { this.asyncList.push({item, style}); }else { if(item.y < 0) { item.y = this.height + item.y * zoom - style.height * zoom; }else { item.y = item.y * zoom; } if(item.x < 0) { item.x = this.width + item.x * zoom - style.width * zoom; }else { item.x = item.x * zoom; } ctx.drawImage(item.url, item.x, item.y, style.width * zoom, style.height * zoom); ctx.draw(true); } } [代码] 如上,x,y值坐标支持传入负值,表示从画布的底部和右侧计算位置。 3. Wxml转Canvas元素的实现 3.1 computedStyle的获取 首先需要获取wxml的样式,代码示例如下: [代码] query.selectAll(`${item.class}`).fields({ dataset: true, size: true, rect: true, computedStyle: ['width', 'height', ...] }, (res) => { self.drawWxml(res); }) [代码] 3.2 块级元素的绘制 对于声明为image、text的元素,默认为块级元素,它们的绘制都是独立进行的,不需要考虑其他的元素的影响,以wxml节点为圆形的image为例,下面是部分代码: [代码] if(sub.dataset.type === 'image') { let r = sub.width / 2; let x = sub.left + item.x * zoom; let y = sub.top + item.y * zoom; let leftFix = +sub.dataset.left || 0; let topFix = +sub.dataset.top || 0; let borderWidth = sub.borderWidth || 0; let borderColor = sub.borderColor; // 如果是圆形图片 if(sub.dataset.radius) { // 绘制圆形的border if(borderWidth) { ctx.beginPath() ctx.arc(x + r, y + r, r + borderWidth, 0, 2 * Math.PI) ctx.setStrokeStyle(borderColor) ctx.setLineWidth(borderWidth) ctx.stroke() ctx.closePath() } // 绘制圆形图片的阴影 if(sub.boxShadow !== 'none') { ctx.beginPath() ctx.arc(x + r, y + r, r + borderWidth, 0, 2 * Math.PI) ctx.setFillStyle(borderColor); setBoxShadow(sub.boxShadow); ctx.fill() ctx.closePath() } // 最后绘制圆形图片 ctx.save(); ctx.beginPath(); ctx.arc((x + r), (y + r) - limitTop, r, 0, 2 * Math.PI); ctx.clip(); ctx.drawImage(url, x + leftFix * zoom, y + topFix * zoom, sub.width, sub.height); ctx.closePath(); ctx.restore(); }else { // 常规图片 } } [代码] 如上,块级元素的绘制和基础图形的绘制差异不大,理解起来也很容易,不再多述。 3.3 令人头疼的行内元素的绘制 当wxml的data-type声明为inline-image或者inline-text时,我们认为是行内元素。行内元素的绘制是一个难点,因为元素之前存在关联,所以不得不考虑各种临界情况。下面展开细述。 3.3.1 纯文本换行 对于长度超过一行的行内元素,需要计算出合适的换行位置,下图所示的是两种临界情况: [图片] [图片] 如上图所示,第一种情况为最后一行只有一个文字,第二种情况最后一行的文字长度和宽度相同。虽然长度不同,但都可通过下面代码绘制: [代码] let lineNum = Math.ceil(measureWidth(text) / maxWidth); // 文字行数 let sinleLineLength = Math.floor(text.length / lineNume); // 向下取整,保证多于实际每行字数 let currentIndex = 0; // 记录文字的索引位置 for(let i = 0; i < lineNum; i++) { let offset = 0; // singleLineLength并不是精确的每行文字数,要校正 let endIndex = currentIndex + sinleLineLength + offset; let single = text.substring(currentIndex, endIndex); // 截取本行文字 let singleWidth = measureWidth(single); // 超长时,左移一位,直至正好 while(singleWidth > maxWidth) { offset--; endIndex = currentIndex + sinleLineLength + offset; single = text.substring(currentIndex, endIndex); singleWidth = measureWidth(single); } currentIndex = endIndex; ctx.fillText(single, item.x, item.y + i * style.lineHeight); } // 绘制剩余的 if(currentIndex < text.length) { let last = text.substring(currentIndex, text.length); ctx.fillText(last, item.x, item.y + lineNum * style.lineHeight); } [代码] 为了避免计算太多次,首先算出大致的行数,求出每行的文字数,然后移位索引下标,求出实际的每行的字数,再下移一行继续绘制,直到结束。 3.3.2 非换行的图文混排 [图片] 上图是一个包含表情图片和加粗文字的混排内容,当使用Wxml2Canvas查询元素时,会将第一行的内容分为五部分: 文本内容:这是段文字; 表情图片:发呆表情(非系统表情,image节点展现); 表情图片:发呆表情; 文本内容:这也; 加粗文本内容:是一段文字,这也是文字。 对于这种情况,执行查询computedStyle后,会返回相同的top值。我们把top值相同的元素聚合在一起,认为它们是同一行内容,事实也是如此。因为表情大小的差异以及其他影响,默认规定top值在±2的范围内都是同一行内容。然后将top值的聚合结果按照left的大小从左往右排列,再一一绘制,即可完美还原此种情况。 3.3.3 换行的图文混排 当混排内容出现了换行情况时,如下图所示: [图片] 此时的加粗内容占据了两行,当我们依旧根据top值归类时,却发现加粗文字的left值取的是第二行的left值。这就导致加粗文字和第一部分的文字的top值和left值相同,如果直接绘制,两部分会发生重叠。 为了避免这种尴尬的情况,我们可以利用加粗文字的height值与第一部分文字的height值比较,显然前者是后者的两倍,可以得知加粗部分出现了换行情况,直接将其放在同组top列表的最后位置。换行的部分根据lineHeight下移绘制,同时做记录。 最后一部分的文本内容也出现了换行情况,同样无法得到真正的起始left值,并且其top值与上一部分换行后的top值相同。此时应该将他的left值追加加粗换行部分的宽度,正好得到真正的left值,最后再绘制。 大多数的行内元素的展现形式都能以上述的逻辑完美还原。 六、总结 基于基础图形封装和wxml转换这两种绘制方式,可以满足绝大多数的场景,能够极大地减少工作量,而不需要再关注内部实现。在实际使用中,二者并非孤立存在,而更多的是一起使用。 [图片] 如上图所示,对于列表内容我们利用wxml读取绘制,对于下部的白色区域,不是wxml节点内容,我们可以使用基础图形绘制方式实现。二者的结合更加灵活高效。 目前Wxml2Canvas已经在公司内部开源,不久会放到github上,同时也在不断完善中,旨在实现更多的样式展现与提升稳定性和绘制速度。 如果有更好的建议与想法,请联系我。
2019-02-28