49、解决微信小程序使用svgaplayer-weapp插件播放卡顿问题

  • 解决问题使用相关技术点:

    • 使用Worker多线程技术;
    • 改动插件源码,提取耗内存操作到Worker中处理;
  • 一、起因
    项目基于uniapp需要在微信小程序播放svga文件,uniapp插件市场搜索后确定使用c-svga插件播放,该插件底层使用svgaplayer-weapp插件,但在使用的过程中发现在IOS小程序中当文件过大直接卡顿无法使用,官网isuse也有人提出,至今没有解决,那关键时刻就只能靠自己了。

  • 二、源码分析
    基于小程序架构设计(视图层和逻辑层)是不能作太消耗内存消耗的操作,当深入分析svgaplayer-weapp插件源码分析后,发现吃内存的有两处,如下:

    • loadWXImage方法中把文件流一帧一帧转成base64(见node_moudle/svgaplayer-weapp/src/player.ts);


      loadWXImage
    • drawFrame 循环一帧一帧的base64绘制到canvas中(见node_moudle/svgaplayer-weapp/src/renderer.ts);


      drawFrame.png
  • 三、解决问题
    问题找到了就要想着解决,思路打开:当后台碰到消耗内存的操作都是启动多线程处理,想着能不能把前端消耗内存操作也放到多线程里解决呢?h5有Web Worker线程,那么小程序也对应有小程序的Worker线程,按照这个思路向下进行,那么需要解决如下问题:

    • 压缩svga文件;
    • 需要改c-svga插件中播放svga的代码;
    • 需要改svgaplayer-weapp插件中源码转成base64代码提取到Worker线程中;
    • 需要改svgaplayer-weapp插件drawFrame绘制代码提取到Worker线程中;(因小程序canvas是基于客户端实现的并且提供了一系列的wx.xxx API操作,但是Worker中又不能使用wx.xxx Api) 这步就只能放弃;
  • 四、插件源码改造

    • svgaplayer-weapp/src/parser.ts增加loadVideoEntity方法,把文件流转成VideoEntity实体类
 /**
   * SunMeng ADD 把文件流转成VideoEntity对象 begin
   * @param data 
   * @returns 
   */
  loadVideoEntity(data: any): Promise<VideoEntity> {
    // console.log('loadVideoEntity  ==== ', data);
    return new Promise((resolver, rejector) => {
      try {
        const inflatedData = inflate(data as any);
        const movieData = ProtoMovieEntity.decode(inflatedData);
        resolver(new VideoEntity(movieData));
      } catch (error) {
        rejector(error);
      }
    })
  }
  // SunMeng ADD 把文件流转成VideoEntity对象 end
  • svgaplayer-weapp/src/player.ts增加setVideoItemBase64和loadWXImageImage方法,主要作用就是把接受Worker中处理好的base64对象再转成img对象给canvas绘制
/**
   *  SunMeng ADD begin 把base64转成img
   * @param videoItem 
   * @param keyedImages 
   */
  async setVideoItemBase64(videoItem?: VideoEntity, keyedImages?: any): Promise<any>{
    this._currentFrame = 0;
    this._videoItem = videoItem;
    if (videoItem) {
      //
      let decodedImages: { [key: string]: any } = {};
      keyedImages.forEach(async (it: any) => {
        decodedImages[it.key] = await this.loadWXImageImage(it.value);
      });
      videoItem.decodedImages = decodedImages;
      //
      // console.log('设置完成后图像信息:', videoItem.decodedImages);
      //
      this._renderer = new Renderer(this._videoItem!, this.ctx!, this.canvas!);
    }else{
      this._renderer = undefined;
    }
    this.clear();
    this._update();
  } 
  /**
   * SunMeng ADD 把图像转成base64
   * @param data 
   * @returns 
   */
  loadWXImageImage(data: string): Promise<any> {
    if (!this.canvas) throw "no canvas";
    return new Promise((res, rej) => {
      const img: WechatMiniprogram.Image = this.canvas!.createImage();
      img.onload = () => {
        res(img);
      };
      img.onerror = (error) => {
        console.log(error);
        rej("image decoded fail.");
      };
        img.src = data;
    });
  }
  // SunMeng ADD begin 把base64转成img
  • src/uni_modules/c-svga/components/c-svga/c-svga.vue中改造,并且使用Worker
<template>
    <!-- #ifdef H5||APP-PLUS -->
    <view class="c-svga" :style="{width,height}" :svgaData="svgaData" :change:svgaData="svga.render" :fun='fun' :change:fun='svga.callPlayer'>
        <div :id='myCanvasId'></div>
    </view>
    <!-- #endif -->
    <!-- #ifdef MP -->
        <view class="c-svga" >
            <canvas class="canvas" :id="myCanvasId" type="2d"></canvas>
        </view>
    <!-- #endif -->
</template>
<script>
    /**
     * c-svga svga组件
     * @property {String} canvasId 画布id
     * @property {String} width 图像宽度 默认750rpx 单位rpx/px
     * @property {String} height 图像高度 默认750rpx 单位rpx/px
     * @property {String} src svga文件地址
     * @property {Boolean} autoPlay 是否自动播放 默认true
     * @property {Number} loops 动画循环次数,默认值为 0,表示无限循环
     * @property {Boolean} clearsAfterStop 默认值为 true,表示当动画结束时,清空画布
     * @property {String} fillMode 默认值为 Forward,可选值 Forward / Backward,当 clearsAfterStop 为 false 时,Forward 表示动画会在结束后停留在最后一帧,Backward 则会在动画结束后停留在第一帧。
     * @property {Boolean} isOnChange 是否开启播放进度监听 默认false false时不触发Frame Percentage监听
     * @event {Function()} loaded 监听svga文件加载完成
     * @event {Function()} finished 监听动画停止播放 loop!=0时生效
     * @event {Function()} frame 监听动画播放至某帧
     * @event {Function()} percentage 监听动画播放至某进度
     * 组件内方法统一使用 call(funName, args) 调用player实例方法 详见文档
     * */
    import uuid from './js/uuid.js'
    // import { Parser,Player} from 'svgaplayer-weapp/dist/svgaplayer.weapp.src.js'
    import { Parser,Player } from 'svgaplayer-weapp'
    //
    let workers = '';
    // wx.createWorker('workers/index.js');
    //
    export default {
        name:"c-svga",
        props: {
            canvasId: {
                type: String
            },
            width: {
                type: String,
                default: '100%'
            },
            height: {
                type: String,
                default: '100%'
            },
            src: {
                type: String,
                required: true
            },
            autoPlay: { //是否自动播放
                type: Boolean,
                default: true
            },
            loops: { //动画循环次数,默认值为 0,表示无限循环。
                type: Number,
                default: 0
            },
            clearsAfterStop: { //默认值为 true,表示当动画结束时,清空画布。
                type: Boolean,
                default: true
            },
            fillMode: { //默认值为 Forward,可选值 Forward / Backward,当 clearsAfterStop 为 false 时,Forward 表示动画会在结束后停留在最后一帧,Backward 则会在动画结束后停留在第一帧。
                type: String,
                default: 'Forward'
            },
            contentMode: { // 默认值mode: "Fill" | "AspectFill" | "AspectFit"
                type: String,
                default: 'Fill'
            },
            isOnChange: {
                type: Boolean,
                default: false
            }
        },
        emits: ['loaded', 'finished', 'frame', 'percentage'],
        data() {
            return {
                fun:{}
            }
        },
        computed: {
            myCanvasId() {
                if (!this.canvasId) {
                    return 'c' + uuid(18)
                } else {
                    return this.canvasId
                }
            },
            svgaData(){
                return {
                    myCanvasId: this.myCanvasId,
                    width: this.width,
                    height:this.height,
                    src: this.src,
                    autoPlay:this.autoPlay,
                    loops: this.loops,
                    clearsAfterStop:this.clearsAfterStop,
                    fillMode:this.fillMode,
                    isOnChange:this.isOnChange
                }
            }
        },
        watch:{
            svgaData(){
                // #ifdef MP
                this.render()
                // #endif
            }
        },
        methods: {
            call(name, args) {
                this.fun = {name,args}
                // #ifdef MP
                this.callPlayer(this.fun)
                // #endif
            },
            // #ifdef MP
            getContext(){
                return new Promise((resolve) => {
                    const {
                        pixelRatio
                    } = uni.getSystemInfoSync()

                    uni.createSelectorQuery()
                        .in(this)
                        .select(`#${this.myCanvasId}`)
                        .fields({
                            node: true,
                            size: true
                        })
                        .exec(res => {
                            const {
                                width,
                                height
                            } = res[0]
                            const canvas = res[0].node;
                            console.log('canvas ==== ', canvas);
                            resolve({
                                canvas,
                                width,
                                height,
                                pixelRatio
                            })
                        })
                })
            },
            /**
             * SunMeng ADD 读取文件流
             */
            getFileSystemManager(src){
                //
                return new Promise((resolver, rej) => {
                    wx.getFileSystemManager().readFile({
                        filePath: src || this.src,
                        success: async (res) => {
                            //
                            let inflatedData = res.data;
                            resolver(inflatedData);
                        },
                        fail: (err)=>{
                            resolver('');
                        }
                    });
                });// end Promise
            },
            /**
             * 事件处理
             */
            playerEvent(){
                this.$emit('loaded')
                if (this.autoPlay) {
                    this.player.startAnimation();
                }
                this.player.onFinished(() => { //只有在loop不为0时候触发
                    // console.log('动画停止播放时回调');
                    this.$emit('finished');
                })
                if (this.isOnChange) {
                    this.player.onFrame(frame => { //动画播放至某帧后回调
                        // console.log(frame);
                        try {
                            this.$emit('frame', frame)
                        } catch (e) {
                            //TODO handle the exception
                            console.error('err frame', e);
                        }
                    });
                    // 动画播放至某进度后回调
                    this.player.onPercentage(percentage => { 
                        // console.log(percentage);
                        try {
                            this.$emit('percentage', percentage)
                        } catch (e) {
                            //TODO handle the exception
                            console.error('percentage', e);
                        }
                    });
                }// end if
            },
            async render(){
                if(!this.src) return
                if (!this.player) {
                    this.parser = new Parser;
                    this.player = new Player;
                    await this.player.setCanvas('#' +this.myCanvasId,this)
                }
                this.player.stopAnimation()
                this.player.setContentMode(this.contentMode)
                this.player.loops = this.loops
                this.player.clearsAfterStop = this.clearsAfterStop
                this.player.fillMode = this.fillMode
                // console.time("test");

                // 安卓走原来代码,ios会有卡顿特殊处理
                if (uni.getSystemInfoSync().platform === 'android') {
                    const videoItem = await this.parser.load(this.src);
                    await this.player.setVideoItem(videoItem);
                    // 事件处理 
                    this.playerEvent();
                }else{
                    // SunMeng ADD 处理IOS卡顿问题
                    let inflatedData = await this.getFileSystemManager(this.src);
                    if(inflatedData){
                        console.log("文件数据:", inflatedData);
                        // 二进制数据转成VideoEntity类型
                        let videoItem = await this.parser.loadVideoEntity(inflatedData);
                        console.log('VideoEntity文件数据类型:', videoItem);
                        //works异步处理begin
                        if(!workers){
                            wx.preDownloadSubpackage({
                                packageType: "workers", 
                                success :(res)=> {
                                    console.log('下载workers分包成功!');
                                    workers = wx.createWorker('workers/index.js');
                                    // 二进制数据传入workers中置换base64
                                    workers.postMessage({ inflatedData: inflatedData });
                                    // 监听worker子线程返回数据
                                    workers.onMessage(async (res) => {
                                        console.log('worker子线程返回数据:', res);
                                        // 使用后及时销毁 Worker
                                        workers.terminate();
                                        workers = null;
                                        //
                                        let keyedImages = res.keyedImages;
                                        //
                                        await this.player.setVideoItemBase64(videoItem, keyedImages);
                                        // 事件处理 
                                        this.playerEvent();
                                    });// end workers.onMessage
                                },
                                fail :(err)=> {
                                    console.log('下载workers分包失败:', err);
                                }
                            });// end preDownloadSubpackage
                        }// end if workers
                    }else{
                        // 读取本地文件失败处理...
                    } // end getFileSystemManager
                } // end 设备判断
            },
            callPlayer(val){
                if (!val.name) return;
                let {
                    name,
                    args
                } = val
                // console.log(name, args);
                if (Array.isArray(args)) {
                    this.player[name](...args)
                } else {
                    this.player[name](args)
                }
            },
            // #endif
            // #ifndef MP
            receiveRenderData(val) {
                // console.log(val);
                this.$emit(val.name, val.val)
            }
            // #endif
        },
        mounted() {
            // #ifdef MP
            this.render()
            // #endif
        },
        onBeforeDestroy() {
            // #ifdef MP
            this.player.stopAnimation()
            this.player.clear()
            this.parser = null
            this.player = null
            // #endif
        },
    }
</script>


<!-- #ifndef MP -->

    <!-- #ifdef VUE3 -->
    <script lang="renderjs" src='./js/render.js' module='svga'></script>
    <!-- #endif -->
    
    <!-- #ifdef VUE2 -->
    <script lang="renderjs" module='svga'>
    import svgaRender from "./js/render.js"
    export default {
        mixins:[svgaRender]
    }
    </script>
    <!-- #endif -->

    
<!-- #endif -->
<style lang="scss" scoped>
    .c-svga {
        width: 100%;
        height: 100%;
        // width: v-bind(width);
        // height: v-bind(height);

        /* #ifndef MP */
        div {
            width: 100%;
            height: 100%;
        }

        /* #endif */

        .canvas {
            width: 100%;
            height: 100%;
        }
    }
</style>
  • Worker代码,项目根目录创建workers目录并创建index.js
/**
* @author: SunMeng
* @desc: workers中处理小程序svga文件解析
*/
"use strict";
const { ProtoMovieEntity } = require("./protobuf.weapp")
const { inflate } = require("./pako");
const { VideoEntity }  = require("./video.weapp");
/**
* 将 Uint8Array 转为 Base64 字符串
* @param {*} uint8Array 
* @returns 
*/
let uint8ArrayToBase64 = function (uint8Array){
const BASE64_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
let base64 = '';
const bytes = uint8Array;
const byteLength = bytes.byteLength;
const byteRemainder = byteLength % 3;
const mainLength = byteLength - byteRemainder;
// 处理每3字节一组
for (let i = 0; i < mainLength; i += 3) {
  const chunk = (bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2];
  base64 += BASE64_CHARS[(chunk >> 18) & 0x3F];
  base64 += BASE64_CHARS[(chunk >> 12) & 0x3F];
  base64 += BASE64_CHARS[(chunk >> 6) & 0x3F];
  base64 += BASE64_CHARS[chunk & 0x3F];
}
// 处理剩余1或2字节
if (byteRemainder === 1) {
  const chunk = bytes[mainLength];
  base64 += BASE64_CHARS[(chunk >> 2) & 0x3F];
  base64 += BASE64_CHARS[(chunk << 4) & 0x3F];
  base64 += '==';
} else if (byteRemainder === 2) {
  const chunk = (bytes[mainLength] << 8) | bytes[mainLength + 1];
  base64 += BASE64_CHARS[(chunk >> 10) & 0x3F];
  base64 += BASE64_CHARS[(chunk >> 4) & 0x3F];
  base64 += BASE64_CHARS[(chunk << 2) & 0x3F];
  base64 += '=';
}
return base64;
}

/**
* 把svga图片转成base64
* @param {*} data 
* @returns 
*/
let loadWXImage = function(data){
//
return new Promise((res, rej) => {
  try{
    let base64 = "data:image/png;base64," + uint8ArrayToBase64(data);
    res(base64);
  }catch(e){
    console.log('获取图片base64失败,原因:', e);
    rej("image decoded fail.");
  }
});
};

/**
* worker接收主线程消息
*/
worker.onMessage(async (res) => {
console.log("Worker线程收到信息:", res);
// 二进制文件数据
let { inflatedData } = res;
//
let movieData = ProtoMovieEntity.decode(inflate(inflatedData));
// VideoEntity实体类
let videoItem = new VideoEntity(movieData);
// 如果存在数据
let keyedImages = [];
if(videoItem){
  keyedImages = await Promise.all(
    Object.keys(videoItem.spec.images).map(async (it) => {
      try {
        const data = await loadWXImage(videoItem.spec.images[it]);
        return { key: it, value: data };
      } catch (error) {
        return { key: it, value: undefined };
      }
    })
  );
  // let decodedImages = {};
  // keyedImages.forEach(function (it) {
  //   decodedImages[it.key] = it.value;
  // });
}// end videoItem
// 返回结果给主线程
worker.postMessage({
  // decodedImages: decodedImages,
  keyedImages: keyedImages
});
}); // enddd worker.onMessage
  • src/manifest.json中mp-weixin增加workers配置
"mp-weixin" : {
      "workers" : {
          "path" : "workers",  // workers文件目录
          "isSubpackage" : true  // 启用分包处理
      },
  ......忽略其它相关配置
  • 结案陈词
    改动后的代码在IOS中测试已经明显没有卡顿现象能正常使用了,问题解决,完美~~~~

github获取改动后插件代码

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 227,428评论 6 531
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 98,024评论 3 413
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 175,285评论 0 373
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 62,548评论 1 307
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 71,328评论 6 404
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 54,878评论 1 321
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 42,971评论 3 439
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 42,098评论 0 286
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 48,616评论 1 331
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 40,554评论 3 354
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 42,725评论 1 369
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 38,243评论 5 355
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 43,971评论 3 345
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 34,361评论 0 25
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 35,613评论 1 280
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 51,339评论 3 390
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 47,695评论 2 370

推荐阅读更多精彩内容