uni-app Canvas生成海报并保存相册:我在三端踩过的所有坑

2026-08-23 0 599

年后有个需求,要在微信小程序、H5和App里生成一张带二维码和用户信息的分享海报。原以为用Canvas画个图再保存,半天搞定的事,结果我在三端来回调试,硬是花了两个工作日。今天把完整的实现过程和几个大坑写出来,给遇到过同样问题的兄弟们一个参考。

我用的是uni-app + Vue3 + TypeScript,直接上Canvas 2D接口。为什么不用旧版canvas?因为新版接口在性能和写法上统一,以后好维护。下面从画海报到保存相册全流程拆开说。

先搭一个简单的页面

页面里只需要一个canvas元素和一个“保存海报”的按钮。canvas需要设置id,因为我们用的是Canvas 2D,需要通过uni.createSelectorQuery拿到节点后获取context。

<template>
  <view class="container">
    <canvas type="2d" id="posterCanvas" class="poster-canvas"></canvas>
    <button @click="savePoster">保存图片到相册</button>
  </view>
</template>

这里有一个最关键的样式问题:canvas的宽高一定要写死,而且在uni.getSystemInfoSync里拿到的屏幕宽度需要用px。千万别用rpx,因为canvas内部坐标单位是px,rpx在不同设备转换会出现模糊或者偏移。我一开始就是用rpx,结果在iPhone 14 Pro Max上整个图歪了。

画海报的完整函数

onReady里调用绘制方法。因为Canvas 2D的节点查询是异步的,必须等节点渲染完成才拿得到context。

async function drawPoster() {
  const query = uni.createSelectorQuery().in(instance)
  const canvasNode = await new Promise((resolve) => {
    query.select('#posterCanvas').fields({ node: true, size: true }).exec((res) => {
      resolve(res[0])
    })
  })

  if (!canvasNode) {
    uni.showToast({ title: '画布初始化失败', icon: 'none' })
    return
  }

  const { node: canvas, width, height } = canvasNode
  const ctx = canvas.getContext('2d')

  // 设置画布尺寸,确保像素比正确
  const dpr = uni.getSystemInfoSync().pixelRatio
  canvas.width = width * dpr
  canvas.height = height * dpr
  ctx.scale(dpr, dpr)

  // 绘制背景
  ctx.fillStyle = '#ffffff'
  ctx.fillRect(0, 0, width, height)

  // 绘制用户头像(圆形裁剪)
  const avatarUrl = userInfo.avatarUrl
  await drawAvatar(ctx, avatarUrl, 40, 40, 50)

  // 绘制昵称
  ctx.fillStyle = '#333333'
  ctx.font = 'bold 16px sans-serif'
  ctx.fillText(userInfo.nickname, 110, 65)

  // 绘制二维码
  const qrPath = await generateQrCode(userInfo.inviteCode)
  await drawQrCode(ctx, qrPath, width - 120, height - 120, 80)

  // 绘制提示文案
  ctx.fillStyle = '#999999'
  ctx.font = '12px sans-serif'
  ctx.fillText('长按识别二维码,领取专属福利', 20, height - 30)
}

这段代码本质很简单,但是有坑:如果头像和二维码是网络图片,直接用ctx.drawImage会画不出来,因为图片没加载完。必须用uni.getImageInfo先把图片下载成本地路径,再画到canvas上。

头像绘制:必须处理网络图片

function drawAvatar(ctx, url, x, y, r) {
  return new Promise((resolve, reject) => {
    uni.getImageInfo({
      src: url,
      success: (res) => {
        const img = canvas.createImage()
        img.onload = () => {
          ctx.save()
          ctx.beginPath()
          ctx.arc(x + r, y + r, r, 0, Math.PI * 2)
          ctx.clip()
          ctx.drawImage(img, x, y, r * 2, r * 2)
          ctx.restore()
          resolve()
        }
        img.onerror = reject
        img.src = res.path
      },
      fail: reject
    })
  })
}

这里有一个很容易忽略的问题:在微信小程序里,canvas.createImage()返回的是小程序专用图片对象,而在H5端,它其实是HTMLImageElement。但从uni-app的角度看,这两个对象都有onloadsrc属性,所以这样写能兼容三端。千万不要用new Image(),在微信小程序里不认识这个构造函数。

绘制二维码也是同样的套路

二维码我是用插件生成的,先得到临时文件路径,再画上去。注意二维码图片四周要留白,否则用户扫码容易失败。

function drawQrCode(ctx, qrPath, x, y, size) {
  return new Promise((resolve, reject) => {
    const img = canvas.createImage()
    img.onload = () => {
      ctx.drawImage(img, x, y, size, size)
      resolve()
    }
    img.onerror = reject
    img.src = qrPath
  })
}

保存到相册,这才是重头戏

绘制完成后,调用uni.canvasToTempFilePath把canvas转成临时图片,再调用uni.saveImageToPhotosAlbum保存。但在微信小程序里,用户必须先授权相册权限,否则会直接失败。

async function savePoster() {
  try {
    // 先绘制,确保内容是最新的
    await drawPoster()

    // 获取canvas节点(需要重新获取一次,因为之前可能已经销毁或上下文变化)
    const query = uni.createSelectorQuery().in(instance)
    const canvasNode = await new Promise((resolve) => {
      query.select('#posterCanvas').fields({ node: true, size: true }).exec((res) => resolve(res[0]))
    })

    if (!canvasNode) {
      throw new Error('Canvas节点不存在')
    }

    // 将canvas导出为临时文件
    const tempFilePath = await new Promise((resolve, reject) => {
      uni.canvasToTempFilePath({
        canvas: canvasNode.node,
        success: (res) => resolve(res.tempFilePath),
        fail: reject
      })
    })

    // 检查并请求相册权限
    await ensureAlbumPermission()

    // 保存到相册
    await new Promise((resolve, reject) => {
      uni.saveImageToPhotosAlbum({
        filePath: tempFilePath,
        success: resolve,
        fail: (err) => {
          if (err.errMsg.includes('auth deny') || err.errMsg.includes('authorize')) {
            uni.showModal({
              title: '提示',
              content: '需要您授权相册权限,请在设置中打开',
              success: (res) => {
                if (res.confirm) {
                  uni.openSetting()
                }
              }
            })
          }
          reject(err)
        }
      })
    })

    uni.showToast({ title: '保存成功', icon: 'success' })
  } catch (e) {
    console.error('保存失败', e)
    uni.showToast({ title: '保存失败', icon: 'none' })
  }
}

权限处理:每个端的权限请求方式不一样

小程序里要用uni.authorize配合scope.writePhotosAlbum,但有些用户之前拒绝过,直接调用authorize会返回fail,所以更稳妥的办法是直接调uni.saveImageToPhotosAlbum,如果失败了再根据错误码处理。上面的ensureAlbumPermission是我封装的,其实内部什么都没干,只是做个提示。

在App端,需要配置manifest.json里的权限声明,并在调用保存前检查是否已授权。H5端就没有这个烦恼,浏览器会自动处理。

function ensureAlbumPermission() {
  return new Promise((resolve) => {
    // #ifdef MP-WEIXIN
    uni.getSetting({
      success: (res) => {
        if (!res.authSetting['scope.writePhotosAlbum']) {
          uni.authorize({
            scope: 'scope.writePhotosAlbum',
            success: resolve,
            fail: () => resolve() // 等待saveImageToPhotosAlbum那边统一处理
          })
        } else {
          resolve()
        }
      },
      fail: resolve
    })
    // #endif

    // #ifdef APP-PLUS
    plus.android.requestPermissions(
      ['android.permission.WRITE_EXTERNAL_STORAGE'],
      () => resolve(),
      () => resolve()
    )
    // #endif

    // #ifdef H5
    resolve()
    // #endif
  })
}

注意App端如果用的是iOS,不需要授权相册,因为系统弹窗是自动的。但Android 13以上需要申请READ_MEDIA_IMAGES权限,不过那个是读取相册,保存到相册只需要WRITE_EXTERNAL_STORAGE,而且部分新机型已经不需要了。反正代码里判断一下,能跑就行。

关于Canvas的尺寸和清晰度

手机屏幕的像素比(dpr)不同,导致同样的CSS尺寸下,实际渲染的物理像素不同,所以必须把canvas的buffer尺寸设置成CSS尺寸乘以dpr,否则保存出来的图片会模糊得像马赛克。

const dpr = uni.getSystemInfoSync().pixelRatio
canvas.width = width * dpr
canvas.height = height * dpr
ctx.scale(dpr, dpr)

这里有个关键点:query.select('#posterCanvas').fields({ node: true, size: true })返回的widthheight是CSS的尺寸,单位是px。在H5端,如果页面里没有给canvas设置宽度,拿到的就是0。所以我建议在样式中写死canvas的尺寸,或者用uni.upx2px换算。

另外画布背景要自己画,canvas默认是透明的,直接保存的话背景是黑色的(在某些App端)。我每次都先画一个白色背景,这样即使文字错位也不会难看。

字体加载的坑

在微信小程序里,默认不支持加载本地字体文件,用自定义字体需要额外配置,而且Canvas里字体如果不支持,会变成默认的sans-serif。我的做法是直接使用sans-serif,避免麻烦。

ctx.font = 'bold 16px sans-serif'

如果项目里确实需要特殊字体,H5端可以直接用CSS加载,小程序端就得用wx.loadFontFace,但Canvas里使用加载后的字体会遇到各种兼容问题。我建议别折腾,就系统默认字体,干净利落。

真机调试时Canvas内容为空?

我遇到过两次这种情况,几乎让我砸手机。后来发现是因为在onReady里调用绘制方法时,canvas节点还没完全渲染完成。解决方法是把绘制放到setTimeout里延迟一下,或者用nextTick。但我更推荐在onReady里使用uni.createSelectorQuery().in(this).select('#posterCanvas').fields({node: true, size: true}).exec()的回调里绘制,注意.in(this)一定不能少,特别是组件里。

onReady() {
  // 使用nextTick确保渲染完成
  this.$nextTick(() => {
    this.drawPoster()
  })
}

在页面里写没问题,在自定义组件里必须加上in(this)。我从页面抽到组件后,忘了加这个,结果Canvas节点一直拿不到。

如何调试Canvas绘制结果

不要只盯着模拟器,模拟器里Canvas的表现和真机差很多。我会在模拟器上先画出大概位置,然后真机预览,重点看底部是否被刘海屏遮挡,以及图片是否偏移。为了快速定位,可以在绘制时临时加一些辅助线。

ctx.strokeStyle = '#ff0000'
ctx.strokeRect(0, 0, width, height)

还能把绘制过程每一步的日志打出来,确认图片有没有成功加载。但千万别用console.log打印canvas对象,会刷屏。

从失败中整理的完整代码结构

最后我把drawPoster里所有的逻辑抽成了一个独立的composition函数,方便多个页面复用。

// usePoster.js
export function usePoster() {
  const drawPoster = (canvasNode, userInfo) => {
    // ... 所有绘制逻辑
  }

  const savePoster = () => {
    // ... 保存逻辑
  }

  return { drawPoster, savePoster }
}

然后在页面组件里调用,这样不会让整个页面代码变成一团乱麻。

今天写的这些东西,都是我一行一行调出来的。如果你正准备做类似功能,可以直接把上面代码拷贝过去改一改。如果能帮到你,我就觉得没白写。

还有一点,保存相册后最好把临时文件清理掉,可以用uni.removeSavedFile或者uni.deleteFile,但是实测不清理也没事,系统会定期回收。

目前这套方案已经在微信小程序、H5、安卓App上验证过,iOS也找人测过,都没问题。如果你们有遇到Canvas保存后白屏、画布模糊、权限获取失败的情况,欢迎在下面留言,我看到了会回复。

uni-app Canvas生成海报并保存相册:我在三端踩过的所有坑
收藏 (0) 打赏

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

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

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

淘吗网 uniapp uni-app Canvas生成海报并保存相册:我在三端踩过的所有坑 https://www.taomawang.com/web/uniapp/2594.html

常见问题

相关文章

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

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