使用 uniapp + canvas 在小程序端生成海报,并解决图片模糊的问题
使用 uniapp + canvas 在小程序端生成海报,并解决图片模糊的问题
背景
想为答题小程序中的模拟考试功能,增加一个弹出显示考试成绩海报的功能,可以让用户下载图片。使用了原生的 canvas 绘制,但是发现图片模糊。
参考了网上其他网友的方法,在画布上进行绘制的时候,先放大尺寸,然后在 scale 缩小,或者 setTransform 缩小,发现都没解决。另辟蹊径,最终使用 image 图片显示,解决了这个问题。
模糊的原因
这就牵扯出一个概念,物理分辨率 和 逻辑分辨率。现在设备基本上都是高分屏,通常说的我们说的某个手机分辨率是物理分辨率,比如 iPhone 13 Pro 的物理分辨率是 1170 x 2532,但是逻辑分辨率是 390 x 844,也就是说,物理分辨率是 3 倍于逻辑分辨率( dpr = 3)。我们一般操作都是基于逻辑分辨率,但是实际显示的时候,会根据 dpr 进行放大或者缩小。
假设我们现在 canvas 的大小都是 10 * 10,那么 canvas 画完的照片中就会有 100 个(像素)点,也就是只有 100 个点的信息;但是到了高清屏中(如 dpr = 3),我们需要 400 个点的信息,原来的点不够用怎么办?于是就会有一套算法来自动生成这些点的信息,从而造成了模糊。
canvas 尺寸概念
Canvas 有两种 width、height:
一种是 width、height 属性,一般称其为画布尺寸,即图形绘制的地方。默认值分别为 300px、150px。
<canvas id="canvas" width="300" height="300"></canvas>另一种是 css 样式里的 width、height 属性,可通过内联样式、内部样式表或外部样式表设置。一般称其为画板尺寸,用于渲染绘制完成的图形。默认值为空。
<canvas id="canvas" style="width:300px; height:300px;"></canvas>我自己的理解,使用 style 设置的宽高,相当于一个视窗,透过它可以看到画布的内容。比如我上边背景提到的,canvas 绘制的图片模糊,可以在绘制的时候对画布进行放大,但是若是超过了 style 设置的宽高,那么图片就会显示不全。
解决办法
一开始想的是,画布在绘制图片的时候,直接对尺寸进行放大,但是这样会超过 style 的宽高,图片显示不完整。如果再进行缩放,图片就又会模糊。
其实有个好办法可以解决,可以在要展示海报的位置放一个 image,然后通过 image 的 src 属性,将放大后的 canvas 画布的内容转为图片,这样就可以避免图片模糊的问题了。
<template>
<view class="painter" >
<!-- 用来制作海报的 canvas -->
      <canvas
        canvas-id="canvasShareId"
        :style="{
          width: 360 * pixelRatio + 'px',
          height: 640 * pixelRatio + 'px',
        }"></canvas>
      <u-popup
        :round="10"
        closeable
        :show="show"
        @close="close"
        @open="open"
        :bgColor="'transparent'"
        mode="center">
        <!-- 担心有的机型太老,海报显示不完整,做一个溢出滚动 -->
        <view
          style="overflow: scroll"
          :style="{ height: Number(screenHeight) - 120 + 'px' }">
          <view>
            <!-- 使用 image 图片显示,解决图片模糊的问题 -->
            <!-- 图片尺寸,是我根据海报尺寸定的,比例要对 -->
            <u--image
              width="680rpx"
              height="1280rpx"
              :src="tempFilePath"
              mode="aspectFit"></u--image>
          </view>
          <view class="canvas-btn-box">
            <view class="btn-item"
              ><u-button icon="weixin-fill" class="btn-item" open-type="share"
                >分享好友</u-button
              ></view
            >
            <view class="btn-item">
              <u-button icon="download" class="btn-item" @click="downLoad"
                >保存图片</u-button
              ></view
            >
          </view>
        </view>
      </u-popup>
    </view>
</template>
<script>
export default {
  data() {
    return {
      canvasInstance: null, //海报实例
      currentImg:
        'https://***.png', // 海报背景图
      show: false,
      tempFilePath: '',
      pixelRatio: 1,
      screenHeight: null,
      level: null,
    }
  },
  onLoad(){
    uni.getSystemInfo({
      success: (res) => {
        this.pixelRatio = res.pixelRatio; //获取设备像素比,就是上文中的 dpr
        this.screenHeight = res.screenHeight; //屏幕宽度
      },
    });
    this.painterInit()
  },
  methods: {
    open() {},
    close() {
      this.show = false;
    },
    painterInit() {
      this.show = true;
      this.canvasInstance = uni.createCanvasContext('canvasShareId');
      // 开始绘制
      this.drawBackgroundImage();
    },
    drawBackgroundImage() {
      uni.showLoading({ title: 'loading...', icon: 'none' });
      // 因为用的是线上图片,所以需要先下载图片。如果是本地图片,可以直接 drawImage
      uni.downloadFile({
        url: this.currentImg, // https图片要在微信后台配置downloadFile合法域名
        success: (res) => {
          if (res) {
            // 绘制背景图
            this.canvasInstance.drawImage(
              res.tempFilePath, // 图片源 本地资源直接传入路径 "/static/images/xx.png"
              0, // 图像的左上角在目标canvas上 X 轴的位置
              0, // 图像的左上角在目标canvas上 Y 轴的位置
              360 * this.pixelRatio, // 在目标画布上绘制图像的宽度,允许对绘制的图像进行缩放
              640 * this.pixelRatio // 在目标画布上绘制图像的高度,允许对绘制的图像进行缩放
            );
            // 绘制文字
            let scoreFontSize = Number(this.pixelRatio) * 14 + 'px';
            this.canvasInstance.font = `${scoreFontSize} '黑体' `;
            this.canvasInstance.fillText(
              `生产知识成绩:100分`,
              40 * this.pixelRatio,
              424 * this.pixelRatio
            );
            this.canvasInstance.fillText(
              `生产管理能力成绩:100分`,
              40 * this.pixelRatio,
              444 * this.pixelRatio
            );
            let levelFontSize = Number(this.pixelRatio) * 28 + 'px';
            this.canvasInstance.font = `${levelFontSize} 'STXingkai' `;
            this.canvasInstance.fillText(
              '优秀',
              115 * this.pixelRatio,
              492 * this.pixelRatio
            );
            let timeFontSize = Number(this.pixelRatio) * 10 + 'px';
            this.canvasInstance.font = `${timeFontSize} '黑体' `;
            this.canvasInstance.fillText(
              `${formatDate(new Date())}`,
              240 * this.pixelRatio,
              632 * this.pixelRatio
            );
            // 开始生成
            this.canvasInstance.draw(false, () => {
              // 把绘制好的画布转为临时路径
              uni.canvasToTempFilePath({
                x: 0, // 画布x轴起点
                y: 0, // 画布y轴起点
                width: 360 * this.pixelRatio,
                height: 640 * this.pixelRatio,
                destWidth: 360 * this.pixelRatio, // 输出图片宽度(默认为 width * 屏幕像素密度)
                destHeight: 640 * this.pixelRatio, // 输出图片高度(默认为 height * 屏幕像素密度)
                quality: 1, // 图片的质量,取值范围为 (0, 1],不在范围内时当作1.0处理
                fileType: 'png',
                canvasId: 'canvasShareId',
                success: (res) => {
                  this.tempFilePath = res.tempFilePath;
                },
              });
            });
          }
        },
        complete() {
          uni.hideLoading();
        },
      });
    },
    onShareAppMessage() {
      return {
        title: '邀请好友一起来拿证!',
        path: '/pages/index/index', //页面 path ,必须是以 / 开头的完整路径
        imageUrl: this.tempFilePath,
        desc: '赶快跟我一起来拿证!',
      };
    },
    onShareTimeline() {
      return {
        title: '邀请好友一起来拿证!',
        path: '/pages/index/index', //页面 path ,必须是以 / 开头的完整路径
        imageUrl: this.tempFilePath,
        desc: '赶快跟我一起来拿证!',
      };
    },
    // 保存图片
    downLoad() {
      uni.showLoading({ title: 'loading...', icon: 'none' });
      uni.canvasToTempFilePath({
        x: 0, // 画布x轴起点
        y: 0, // 画布y轴起点
        width: 360 * this.pixelRatio,
        height: 640 * this.pixelRatio,
        destWidth: 360 * this.pixelRatio, // 输出图片宽度(默认为 width * 屏幕像素密度)
        destHeight: 640 * this.pixelRatio, // 输出图片高度(默认为 height * 屏幕像素密度)
        quality: 1, // 图片的质量,取值范围为 (0, 1],不在范围内时当作1.0处理
        canvasId: 'canvasShareId',
        success: function (res) {
          // 在H5平台下,tempFilePath 为 base64
          uni.saveImageToPhotosAlbum({
            filePath: res.tempFilePath, // 图片文件路径,临时文件路径或者是永久文件路径,不支持网络图片路径
            success: function () {
              setTimeout(() => {
                // success 触发 => complete 触发(hideLoading 会关闭 showToast) => 所以使用异步;
                uni.showToast({
                  title: '下载成功!',
                  duration: 2000,
                });
              }, 100);
            },
            fail() {
              // 未开启授权
              uni.authorize({
                scope: 'scope.writePhotosAlbum', // 发起授权
                success() {
                  uni.showToast({
                    title: '授权成功,请重新下载!',
                    icon: 'none',
                    duration: 2000,
                  });
                },
                // 如果以前拒绝过这个授权 那么直接进入 fail 回调 使用 openSetting 打开设置进行重新授权
                fail() {
                  uni.showToast({
                    title: '请允许保存图片授权!',
                    icon: 'none',
                    duration: 2000,
                  });
                  uni.openSetting({
                    success() {
                      uni.showToast({
                        title: '授权成功,请重新下载!',
                        icon: 'none',
                        duration: 2000,
                      });
                    },
                    fail() {
                      uni.showToast({
                        title: '授权失败',
                        icon: 'none',
                        duration: 2000,
                      });
                    },
                  });
                },
              });
            },
            complete() {
              uni.hideLoading();
            },
          });
        },
      });
    },
  }
}
</script>
<style>
.painter {
  margin-top: 500px;
}
.canvas-btn-box {
  display: flex;
  justify-content: space-around;
}
.canvas-btn-box .btn-item {
  width: 32%;
}
</style>如果使用了下载图片功能,千万注意
如果出现了开发环境、或者真机调试环境可以下载,但是上线后无法下载图片的情况,请检查以下两点:
- 一定要在小程序微信公众平台配置 downloadFile 合法域名;
- 一定要在小程序微信公众平台《服务内容声明》中,去完善《用户隐私保护指引》;
