CANVAS绘图踩坑记录

4,194 阅读6分钟

最近做了移动端生成图片并且上传的需求,踩了不少坑,这里记录一下。由于本次使用canvas主要功能集中在绘制网络图片以及生成/上传图片,因此本文多为和图片相关的记录。

绘图部分

onload

最开始使用canvas直接加载网络图片的时候,忘记考虑图片加载的问题了,因此直接上手写image.src = xxx接着就是ctx.drawImage,最后发现网络图片根本没有被绘制上去,这才想起来图片必须要先加载完之后才能使用ctx.drawImage去绘制,否则图片没有加载完,canvas直接绘制一张空的图片。

export class Canvas {
    // code here...
    // 加载图片
    addImage(src: string) {
        return new Promise((resolve, reject) => {
            const img = new Image();
            img.onload = () => {
                resolve(img);
            }
            img.src = src;
        });
    }
    // code here...
}

图片跨域问题

修改完毕之后,本来以为这次代码能跑起来了,却发现由于访问了CDN图片地址,Image默认不支持访问跨域资源,必须要手动指定crossOrigin属性才可以跨域访问图片。

export class Canvas {
    // code here...
    // 加载图片
    addImage(src: string) {
        return new Promise((resolve, reject) => {
            const img = new Image();
            img.crossOrigin = 'anonymous';
            img.onload = () => {
                resolve(img);
            }
            img.src = src;
        });
    }
    // code here...
}

渲染的层级关系

图片加载出来之后,本来感觉问题已经解决了,但是发现我本来应该渲染的三张图片,最后只出来了一张背景图,另外两个图都不见了,但是在代码里面打印日志是可以看到canvas确实执行了这两张图片的渲染逻辑。

查了查资料发现canvas只能按照渲染的先后顺序来展示绘图的层级关系,无法手动指定层级,因此我们想要在背景图上方绘制图片的话,必须要等到背景图绘制完毕之后才能继续执行其他的逻辑。

export class Canvas {
    canvas: HTMLCanvasElement;
    ctx: CanvasRenderingContext2D;
    constructor() {
        // other code
        this.canvas = document.createElement('canvas');
        this.ctx = this.canvas.getContext('2d')!;
        // other code
    }
    // code here...
    addImage(src: string) {
        // ...
    }
    drawImage() {
        const bg = 'https://xxx';
        this.addImage(bg).then((img: ImageBitmap) => {
            this.ctx.drawImage(img, 0, 0, img.width, img.height);
            // 在这之后才能继续绘制其他图片
            this.addImage(xxx);
        });
    }
}

绘制圆形图片

所有绘制都结束之后,产品突然过来跟我说,希望在最后面加上一行用户信息的区域,包括用户头像和用户名,本来以为是很简单的工作,按照上面的逻辑再绘制一张图片和一段文字即可,后来发现用户头像的图片都是方形的,但是产品希望要一张圆形头像图片,在css中只需要很简单一行border-radius: 50%的东西让我很头疼。

尝试了各种办法都失败之后,上网查了查资料才发现原来context还有保存和还原方法,再配合clip裁切就可以完成一个圆形图片了!

export class Canvas {
    constructor() {
        // ...
    }
    addImage() {
        // ...
    }
    drawImage() {
        // ...
    }
    drawUserInfo() {
        // other code
        const src = 'https://xxx';
         this.addImage(src).then((img: ImageBitmap) => {
            this.ctx.save();
            this.ctx.arc(x, y, r, 0, Math.PI * 2);
            this.ctx.clip();
            this.ctx.drawImage(img, x, y, img.width, img.height);
            this.ctx.restore();
        });
    }
}

这样先保存当前画布的状态,然后通过arc在头像图片的位置绘制一个圆形,然后裁切掉多余的部分,接着绘制头像,最后再恢复画布状态即可。

优化部分

3x图

以上步骤就进行完之后,我测试了一下绘制图片并且上传CDN的功能,一切正常!

然后满心欢喜的展示这张图片的时候,发现在手机上展示出来的实在太模糊了,甚至连文字都看不清楚。

这时我才意识到我们平时用的图片都是3x或者2x图,现在我按照UI稿的360px宽度绘制的这张图片只是1x图,在我们高分辨率的手机上展示出来就会很模糊,因此为了让图片不模糊,我也需要将图片变为3x图。

因此将绘图时候所有的宽高及其他数组全部都×3.

this.width = 360 * 3;
this.height = 500 * 3;

this.ctx.drawImage(bg, 0, 0, this.width * 3, this.height * 3);
// 其他改动同理

这样改动之后,展示出来的图片就非常清晰了!

缓存图片导致绘制卡主

由于需要加载多张图片,因此我这里需要监听所有图片都加载并且绘制成功之后,才能执行最终的canvas.toBlob()逻辑并且上传图片。

export class Canvas {
    constructor() {
        // ...
        this.loadedImageNumber = 0;
    }
    // code here
    imageOnLoaded() {
        this.loadedImageNumber++;
        if(this.laodedImageNumber >= 3) {
            // upload image
        }
    }
}

结果pm和qa同学测试的时候,经常发现上传过程卡住了,一直处于loading状态,后来经过无数次尝试和排查问题之后,发现因为前面有些图片已经加载过一次了,这里的图片有可能从浏览器的缓存里面获取了,因此根本不会执行onload函数,这样就导致this.loadedImageNumber一直达不成大于等于3的条件,所以就卡在loading状态了。

解决办法是完善一下addImage函数,在监听onload的同时判断一下img的状态,如果是complete的话也执行一遍回调逻辑,顺便也加了一下关于onerror的处理。

export class Canvas {
    // code here...
    // 加载图片
    addImage(src: string) {
        return new Promise((resolve, reject) => {
            const img = new Image();
            img.onload = () => {
                resolve(img);
            }
            img.onerror = () => {
                // error callback
                reject();
            }
            img.src = src;
            if(img.complete) {
                resolve(img);
            }
        });
    }
    // code here...
}

这样测试了一下所有图片都可以正常被加载出来了~

polyfill

上面问题解决了之后,QA同学有反馈有一些低端手机依然卡在loading状态,我本来还以为是图片绘制依然有问题,然后我借过手机来调试了一下,发现并不是卡在图片绘制过程,而是canvas.toBlob()的时候报错了,于是后面的逻辑都卡住了。

export class Canvas {
    // code here
    imageOnLoaded() {
        this.loadedImageNumber++;
        if(this.laodedImageNumber >= 3) {
            this.canvas.toBlob(blob => {
                // 真正的上传函数
                this.uploadImage(blob);
            },
            'image/jpeg',
            1.0
            )
        }
    }
    // code here
}

再次上网查了查资料,发现需要打polyfill才行。

github地址:JavaScript-Canvas-to-Blob

网上很多使用介绍的文章,或者直接看github官网的readme也很容易看懂。

if(__BROWSER__) {
    // import canvas toblob polyfill
    require('blueimp-canvas-to-blob');
}
export class Canvas {
    // ...
}

__BROWSER__webpack.DefinePlugin定义的客户端渲染环境。

这下感觉应该万事大吉了。

优化图片大小

然后就又被QA同学找了。。

QA同学反馈说图片上传太慢了,弱网情况下要loading很久才会结束,或者甚至直接到后端接口返回超时了也还没有结束图片上传。

我抓包看了看图片上传的接口,发现确实有点慢,因为生成的图片体积太大了,足足有2.6M多

问了一下同事,原来是最初图片模糊的时候,我想要提高图片质量,改成3x图的同时又在toBlob()的时候指定图片质量是1.0,所以导致了图片体积过大。

把这里的图片质量指定为0.8左右之后体积一下就降下来了,并且图片质量其实并没有改变多少。

export class Canvas {
    // code here
    imageOnLoaded() {
        this.loadedImageNumber++;
        if(this.laodedImageNumber >= 3) {
            this.canvas.toBlob(blob => {
                // 真正的上传函数
                this.uploadImage(blob);
            },
            'image/jpeg',
            0.8
            )
        }
    }
    // code here
}

到这里这个功能总算是完成了 OwO