小程序里用 Canvas 做一个本地图片处理工具:水印、压缩、排版与断网可用实践

20 阅读11分钟

打开后可断网处理,图片不上传,安全第一;无广告,完全免费

扫码先体验,需要处理证件照、资料图片、手机照片时,打开就能用。

qrcode.jpg

手机里的图片越来越多,日常要处理图片的场景也越来越多:

  • 上传证件照,尺寸不合适;
  • 发送身份证明、材料截图,担心被别人拿去乱用;
  • 手机空间不够,照片太大又舍不得删;
  • 平台只支持 JPG 或 PNG,图片格式不符合要求;
  • 想简单旋转、翻转、加个文字说明,又不想下载复杂修图软件。

平时做图片处理,很多人第一反应是把图片传到服务端处理:上传、排队、转码、下载。

但如果处理的是证件照、身份证明、合同截图、报名材料这类图片,上传就会带来两个问题:

  • 用户不放心:图片会不会被保存、会不会被二次使用;
  • 链路变复杂:需要服务端、存储、清理任务、内容安全、失败重试。

所以我做这个小程序时,思路反过来了:能不能把图片处理都放在手机本地完成?

最终实现的核心能力包括:

  • 给证件照、资料图添加文字水印;
  • 单个水印支持拖动、双指缩放、双指旋转;
  • 图片压缩;
  • JPG / PNG 格式转换;
  • 一寸照、二寸照、头像等尺寸裁剪;
  • 5 寸、6 寸、A4 照片打印排版;
  • 基础编辑:旋转、翻转、文字、边框;
  • 导出后保存到手机相册。

这篇文章不写产品介绍,主要拆一下小程序端怎么用 Canvas 把这些功能做出来。

一、整体方案:不走业务服务端,图片在本地处理

整个流程可以概括成这几步:

  1. wx.chooseImage 从相册或相机拿到本地临时文件;
  2. wx.getImageInfo 获取图片宽高;
  3. 使用 Canvas 在本地绘制处理后的图片;
  4. wx.canvasToTempFilePath 导出临时图片;
  5. wx.saveImageToPhotosAlbum 保存到相册。

对应到页面功能,大致是这样的:

功能主要 API说明
添加水印Canvas 2D、canvasToTempFilePath自定义文字、颜色、透明度、位置、缩放、旋转
图片压缩wx.compressImage按质量参数压缩成本地临时图
尺寸裁剪Canvas 2D按目标比例 cover 绘制
打印排版Canvas 2D计算纸张内可放多少张照片
格式转换Canvas、fileType导出 JPG / PNG
保存相册wx.saveImageToPhotosAlbum保存到系统相册

这里说的“断网可用”有一个边界:小程序首次加载本身可能需要微信运行环境加载页面资源;但进入功能页、选择本地图片后,核心图片处理不依赖业务服务端,不需要把原图上传到自己的服务器。

二、先把图片变成可处理的数据

选图这一步要拿到三类信息:

  • 图片本地路径;
  • 图片原始宽高;
  • 图片大小,用来展示给用户。

我把小程序回调 API 先简单 Promise 化,后面写异步流程会清楚很多。

function getFileSize(filePath) {
  return new Promise((resolve) => {
    wx.getFileSystemManager().getFileInfo({
      filePath,
      success: (res) => resolve(res.size || 0),
      fail: () => resolve(0)
    })
  })
}
​
function getImageInfo(src) {
  return new Promise((resolve, reject) => {
    wx.getImageInfo({
      src,
      success: resolve,
      fail: reject
    })
  })
}
​
function formatBytes(size) {
  if (!size) return '--'
  if (size < 1024) return `${size} B`
  if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`
  return `${(size / 1024 / 1024).toFixed(2)} MB`
}

选图后,把这些信息写入页面状态:

chooseImage() {
  wx.chooseImage({
    count: 1,
    sizeType: ['original'],
    sourceType: ['album', 'camera'],
    success: async (res) => {
      const filePath = res.tempFilePaths[0]
      const pickedSize = res.tempFiles && res.tempFiles[0] ? res.tempFiles[0].size : 0
​
      try {
        wx.showLoading({ title: '正在打开图片' })
        const info = await getImageInfo(filePath)
        const fileSize = pickedSize || await getFileSize(filePath)
​
        this.setData({
          imagePath: info.path || filePath,
          originalSize: formatBytes(fileSize),
          originalWidth: info.width,
          originalHeight: info.height,
          resultPath: '',
          resultSize: '--'
        })
​
        await this.renderPreview()
      } finally {
        wx.hideLoading()
      }
    }
  })
}

这一步完成后,后面的水印、裁剪、排版、转换都可以基于 imagePath + width + height 做本地绘制。

三、水印为什么要用“双 Canvas”

水印页用了两个 Canvas:

  • 一个是用户看到的预览 Canvas;
  • 一个是隐藏的导出 Canvas。

预览 Canvas 要适配手机屏幕,通常会把图片缩小后完整展示;导出 Canvas 则要使用原图尺寸,保证保存后的图片足够清晰。

WXML 结构大致是这样:

<canvas
  type="2d"
  id="watermarkPreviewCanvas"
  class="preview-canvas"
  style="height: {{previewHeightPx}}px;"
  catchtouchstart="onPreviewTouchStart"
  catchtouchmove="onPreviewTouchMove"
  catchtouchend="onPreviewTouchEnd"
  catchtouchcancel="onPreviewTouchEnd"
></canvas>
​
<canvas
  type="2d"
  id="watermarkOutputCanvas"
  class="hidden-canvas"
></canvas>

这里有两个细节:

第一,触摸事件用的是 catchtouch*,不是 bindtouch*。因为水印拖动时如果事件继续冒泡,下面的页面滚动区域也会跟着动,用户会感觉“事件穿透”。

第二,预览 Canvas 上会画辅助边框,提示用户水印可以拖动;但导出 Canvas 不画辅助边框。这个区别靠一个参数控制,避免“看到的拖动框被保存到相册里”。

四、Canvas 2D 节点获取与图片加载

小程序新版 Canvas 2D 推荐通过节点方式获取:

function queryCanvas(page, selector) {
  return new Promise((resolve, reject) => {
    wx.createSelectorQuery()
      .in(page)
      .select(selector)
      .fields({ node: true, size: true })
      .exec((res) => {
        const item = res && res[0]
        if (!item || !item.node) {
          reject(new Error(`Canvas not found: ${selector}`))
          return
        }
        resolve(item)
      })
  })
}
​
function loadCanvasImage(canvas, src) {
  return new Promise((resolve, reject) => {
    const image = canvas.createImage()
    image.onload = () => resolve(image)
    image.onerror = reject
    image.src = src
  })
}

预览时要处理 DPR,不然文字和图片容易发糊:

async renderPreview() {
  if (!this.data.imagePath) return
​
  const item = await queryCanvas(this, '#watermarkPreviewCanvas')
  const canvas = item.node
  const dpr = wx.getSystemInfoSync().pixelRatio || 1
  const width = item.width
  const height = item.height
​
  canvas.width = Math.round(width * dpr)
  canvas.height = Math.round(height * dpr)
​
  const ctx = canvas.getContext('2d')
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
​
  const image = await loadCanvasImage(canvas, this.data.imagePath)
  this.drawWatermarkCanvas(ctx, image, width, height, true)
  this.setData({ isPreviewReady: true })
}

五、预览图和导出图如何保持一致

预览图是“图片完整放进一个固定区域”,类似 object-fit: contain

这里用一个 getContainRect 计算图片在预览 Canvas 里的实际位置:

function getContainRect(boxWidth, boxHeight, imageWidth, imageHeight) {
  const scale = Math.min(boxWidth / imageWidth, boxHeight / imageHeight)
  const width = imageWidth * scale
  const height = imageHeight * scale
​
  return {
    x: (boxWidth - width) / 2,
    y: (boxHeight - height) / 2,
    width,
    height
  }
}

预览时用 contain 区域,导出时用原图区域:

drawWatermarkCanvas(ctx, image, canvasWidth, canvasHeight, containPreview) {
  const rect = containPreview
    ? getContainRect(canvasWidth, canvasHeight, this.data.originalWidth, this.data.originalHeight)
    : { x: 0, y: 0, width: canvasWidth, height: canvasHeight }
​
  ctx.clearRect(0, 0, canvasWidth, canvasHeight)
  ctx.fillStyle = '#ffffff'
  ctx.fillRect(0, 0, canvasWidth, canvasHeight)
  ctx.drawImage(image, rect.x, rect.y, rect.width, rect.height)
​
  const text = this.getWatermarkText()
  if (!text) return
​
  const fontSize = this.getFontSize(rect.width, rect.height)
  const family = this.getFontFamily()
​
  ctx.save()
  ctx.globalAlpha = this.data.watermarkOpacity
  ctx.fillStyle = this.data.watermarkColor
  ctx.font = `600 ${fontSize}px ${family}`
  ctx.textBaseline = 'middle'
​
  if (this.data.watermarkPosition === 'tile') {
    this.drawTileText(ctx, text, rect, fontSize)
  } else {
    this.drawSingleText(ctx, text, rect, fontSize, containPreview)
  }
​
  ctx.restore()
}

这里我没有直接存水印的像素坐标,而是存了比例坐标:

watermarkXRatio: 0.78,
watermarkYRatio: 0.84,
watermarkScale: 1,
watermarkRotate: 0

这样预览图和原图尺寸不一样时,也能映射到同一相对位置。

六、单个水印:拖动、缩放、旋转怎么做

单个水印的绘制靠四步:

  1. 移动到水印中心点;
  2. 旋转;
  3. 缩放;
  4. 绘制文字。
drawSingleText(ctx, text, rect, fontSize, showSelection) {
  ctx.textAlign = 'center'
  ctx.save()
  ctx.translate(
    rect.x + rect.width * this.data.watermarkXRatio,
    rect.y + rect.height * this.data.watermarkYRatio
  )
  ctx.rotate(this.data.watermarkRotate * Math.PI / 180)
  ctx.scale(this.data.watermarkScale, this.data.watermarkScale)
  this.drawTextWithShadow(ctx, text, 0, 0)
​
  if (showSelection) {
    this.drawSelectionBox(ctx, text, fontSize)
  }
​
  ctx.restore()
}

预览里的虚线框和四角圆点只在 showSelection 为 true 时画:

drawSelectionBox(ctx, text, fontSize) {
  const metrics = ctx.measureText(text)
  const paddingX = Math.max(12, fontSize * 0.45)
  const paddingY = Math.max(8, fontSize * 0.28)
  const width = metrics.width + paddingX * 2
  const height = fontSize * 1.4 + paddingY * 2
  const x = -width / 2
  const y = -height / 2
  const handle = Math.max(8, fontSize * 0.26)
​
  ctx.save()
  ctx.globalAlpha = 0.95
  ctx.strokeStyle = '#9333ea'
  ctx.lineWidth = Math.max(2, fontSize * 0.06)
  ctx.setLineDash && ctx.setLineDash([8, 6])
  ctx.strokeRect(x, y, width, height)
  ctx.setLineDash && ctx.setLineDash([])
​
  ctx.fillStyle = '#ffffff'
  ;[
    [x, y],
    [x + width, y],
    [x, y + height],
    [x + width, y + height]
  ].forEach(([cx, cy]) => {
    ctx.beginPath()
    ctx.arc(cx, cy, handle, 0, Math.PI * 2)
    ctx.fill()
    ctx.stroke()
  })
​
  ctx.restore()
}

效果如下:

水印拖动与缩放旋转

七、手势计算:一指移动,双指缩放和旋转

触摸开始时,记录初始状态:

onPreviewTouchStart(e) {
  if (!this.data.imagePath || this.data.watermarkPosition === 'tile') return
​
  const touches = e.touches || []
  if (touches.length === 1) {
    this.touchState = {
      mode: 'move',
      startX: touches[0].clientX,
      startY: touches[0].clientY,
      startRatioX: this.data.watermarkXRatio,
      startRatioY: this.data.watermarkYRatio
    }
    return
  }
​
  if (touches.length >= 2) {
    const gesture = this.getGesture(touches)
    this.touchState = {
      mode: 'gesture',
      startDistance: gesture.distance,
      startAngle: gesture.angle,
      startScale: this.data.watermarkScale,
      startRotate: this.data.watermarkRotate
    }
  }
}

一指移动时,把手指位移换算成图片区域里的比例变化:

if (this.touchState.mode === 'move' && touches.length === 1) {
  const dx = touches[0].clientX - this.touchState.startX
  const dy = touches[0].clientY - this.touchState.startY
  const rect = this.getPreviewImageRect()
​
  this.setData({
    watermarkXRatio: clamp(this.touchState.startRatioX + dx / rect.width, 0.05, 0.95),
    watermarkYRatio: clamp(this.touchState.startRatioY + dy / rect.height, 0.08, 0.92),
    showGestureGuide: false,
    resultPath: ''
  }, () => this.schedulePreview())
}

双指时计算两根手指之间的距离和角度:

getGesture(touches) {
  const dx = touches[1].clientX - touches[0].clientX
  const dy = touches[1].clientY - touches[0].clientY
  return {
    distance: Math.sqrt(dx * dx + dy * dy) || 1,
    angle: Math.atan2(dy, dx) * 180 / Math.PI
  }
}

再通过“当前距离 / 初始距离”得到缩放比例,通过角度差得到旋转角度:

if (this.touchState.mode === 'gesture' && touches.length >= 2) {
  const gesture = this.getGesture(touches)
  const scale = gesture.distance / this.touchState.startDistance
  const rotate = gesture.angle - this.touchState.startAngle
​
  this.setData({
    watermarkScale: clamp(this.touchState.startScale * scale, 0.45, 2.8),
    watermarkRotate: this.touchState.startRotate + rotate,
    showGestureGuide: false,
    resultPath: ''
  }, () => this.schedulePreview())
}

这里加 clamp 是为了避免用户把水印缩到看不见,或者放大到完全挡住图片。

八、平铺水印:适合防二次使用

除了单个水印,还可以做平铺水印。平铺水印通常用于证件照、合同截图这类“不希望被二次使用”的场景。

核心就是按照固定间距循环绘制,并给文字一个倾斜角度:

drawTileText(ctx, text, rect, fontSize) {
  const stepX = clamp(fontSize * Math.max(text.length, 6) * 1.65, 160, rect.width * 0.75)
  const stepY = clamp(fontSize * 4.2, 110, rect.height * 0.42)
  const startX = rect.x - stepX
  const endX = rect.x + rect.width + stepX
  const startY = rect.y - stepY
  const endY = rect.y + rect.height + stepY
​
  ctx.textAlign = 'center'
​
  for (let y = startY; y < endY; y += stepY) {
    for (let x = startX; x < endX; x += stepX) {
      ctx.save()
      ctx.translate(x, y)
      ctx.rotate(-25 * Math.PI / 180)
      this.drawTextWithShadow(ctx, text, 0, 0)
      ctx.restore()
    }
  }
}

水印效果:

水印预览效果

九、导出图片并保存到相册

导出时使用隐藏 Canvas,而且直接使用原图尺寸:

async addWatermark({ autoSave = false } = {}) {
  if (!this.data.imagePath || this.data.isProcessing) return
​
  try {
    this.setData({ isProcessing: true })
    wx.showLoading({ title: autoSave ? '正在保存' : '正在添加水印' })
​
    const item = await queryCanvas(this, '#watermarkOutputCanvas')
    const canvas = item.node
    canvas.width = this.data.originalWidth
    canvas.height = this.data.originalHeight
​
    const ctx = canvas.getContext('2d')
    const image = await loadCanvasImage(canvas, this.data.imagePath)
    this.drawWatermarkCanvas(ctx, image, canvas.width, canvas.height, false)
​
    const resultPath = await this.exportCanvas(canvas)
​
    if (autoSave) {
      await this.savePathToAlbum(resultPath)
    }
  } finally {
    wx.hideLoading()
    this.setData({ isProcessing: false })
  }
}

canvasToTempFilePath 可以控制导出格式和质量:

exportCanvas(canvas) {
  return new Promise((resolve, reject) => {
    wx.canvasToTempFilePath({
      canvas,
      width: this.data.originalWidth,
      height: this.data.originalHeight,
      fileType: 'jpg',
      quality: 0.92,
      destWidth: this.data.originalWidth,
      destHeight: this.data.originalHeight,
      success: (res) => resolve(res.tempFilePath),
      fail: reject
    }, this)
  })
}

保存到相册:

savePathToAlbum(filePath) {
  return new Promise((resolve) => {
    wx.saveImageToPhotosAlbum({
      filePath,
      success: () => {
        wx.showToast({ title: '已保存到相册', icon: 'success' })
        resolve()
      },
      fail: () => {
        wx.showModal({
          title: '需要相册权限',
          content: '打开相册权限后,才能保存图片。',
          confirmText: '去打开',
          success: (res) => {
            if (res.confirm) wx.openSetting()
            resolve()
          }
        })
      }
    })
  })
}

这里有个上线时常见的坑:开发者工具里能保存,不代表生产环境一定能保存。生产环境还要关注相册权限、隐私协议授权、失败回调里的 errMsg。排查时不要只看“相册权限开没开”,最好把失败原因完整打出来。

十、图片压缩:能用系统 API 就不手写

压缩图片可以直接使用 wx.compressImage。质量参数是 0 到 100:

const qualityMap = {
  low: 0.3,
  medium: 0.6,
  high: 0.9
}
​
wx.compressImage({
  src: this.data.imagePath,
  quality: qualityMap[this.data.selectedQuality] * 100,
  success: (res) => {
    this.setData({
      processedUrl: res.tempFilePath
    })
  }
})

压缩完成后再读取文件大小,就可以给用户展示“压缩后大小”和“节省比例”:

const fs = wx.getFileSystemManager()
fs.getFileInfo({
  filePath: res.tempFilePath,
  success: (fileInfo) => {
    const original = parseFloat(this.data.originalSize) || 1000
    const compressed = fileInfo.size / 1024
    const savePercent = Math.round((1 - compressed / original) * 100)
​
    this.setData({
      compressedSize: compressed.toFixed(1) + ' KB',
      savePercent: Math.max(0, savePercent),
      processedUrl: res.tempFilePath
    })
  }
})

十一、照片打印排版:先算格子,再逐个绘制

打印排版的需求是:用户选择一寸照、二寸照等尺寸后,把照片自动铺到 5 寸、6 寸或 A4 纸上。

这个问题其实就是一个简单的排版算法:

  1. 确定纸张宽高;
  2. 确定单张照片宽高;
  3. 扣掉边距和间距;
  4. 计算行列数;
  5. 居中铺满。
getPrintLayout(photoWidth, photoHeight, paperWidth, paperHeight) {
  const margin = Math.max(36, Math.round(Math.min(paperWidth, paperHeight) * 0.04))
  const gap = Math.max(18, Math.round(Math.min(paperWidth, paperHeight) * 0.016))
  const cols = Math.floor((paperWidth - margin * 2 + gap) / (photoWidth + gap))
  const rows = Math.floor((paperHeight - margin * 2 + gap) / (photoHeight + gap))
​
  if (cols < 1 || rows < 1) {
    return { count: 0, slots: [] }
  }
​
  const usedWidth = cols * photoWidth + (cols - 1) * gap
  const usedHeight = rows * photoHeight + (rows - 1) * gap
  const startX = Math.round((paperWidth - usedWidth) / 2)
  const startY = Math.round((paperHeight - usedHeight) / 2)
  const slots = []
​
  for (let row = 0; row < rows; row += 1) {
    for (let col = 0; col < cols; col += 1) {
      slots.push({
        x: startX + col * (photoWidth + gap),
        y: startY + row * (photoHeight + gap),
        width: photoWidth,
        height: photoHeight
      })
    }
  }
​
  return { count: slots.length, slots }
}

绘制时每个格子都做一次裁剪,保证图片不会画出照片框:

layout.slots.forEach((slot) => {
  ctx.save()
  ctx.beginPath()
  ctx.rect(slot.x, slot.y, slot.width, slot.height)
  ctx.clip()
  this.drawCoverImage(ctx, image, slot.x, slot.y, slot.width, slot.height)
  ctx.restore()
​
  ctx.strokeStyle = '#ddd6fe'
  ctx.lineWidth = 2
  ctx.strokeRect(slot.x, slot.y, slot.width, slot.height)
})

其中 drawCoverImage 模拟的是 CSS 里的 object-fit: cover

drawCoverImage(ctx, image, x, y, width, height) {
  const scale = Math.max(width / this.data.imageWidth, height / this.data.imageHeight)
  const drawWidth = this.data.imageWidth * scale
  const drawHeight = this.data.imageHeight * scale
  const drawX = x + (width - drawWidth) / 2
  const drawY = y + (height - drawHeight) / 2
  ctx.drawImage(image, drawX, drawY, drawWidth, drawHeight)
}

打印排版页面:

照片打印排版

十二、格式转换:JPG 要先铺白底

PNG 可能有透明通道,但 JPG 没有透明通道。如果直接把透明 PNG 导出成 JPG,透明区域可能变黑。

所以导出 JPG 前先铺一层白底:

drawToCanvas(width, height) {
  return new Promise((resolve) => {
    const ctx = wx.createCanvasContext('formatCanvas', this)
    ctx.clearRect(0, 0, width, height)
​
    if (this.data.selectedFormat === 'jpg') {
      ctx.setFillStyle('#ffffff')
      ctx.fillRect(0, 0, width, height)
    }
​
    ctx.drawImage(this.data.imagePath, 0, 0, width, height)
    ctx.draw(false, () => setTimeout(resolve, 80))
  })
}

导出时根据用户选择设置 fileType

wx.canvasToTempFilePath({
  canvasId: 'formatCanvas',
  fileType: this.data.selectedFormat,
  width,
  height,
  destWidth: width,
  destHeight: height,
  quality: 0.92,
  success: (res) => resolve(res.tempFilePath),
  fail: reject
}, this)

十三、基础编辑:旋转、翻转、边框、文字

基础编辑本质上还是 Canvas 变换。

旋转和翻转的关键是先把坐标移动到画布中心:

ctx.save()
ctx.translate(outputWidth / 2, outputHeight / 2)
ctx.rotate(this.data.rotate * Math.PI / 180)
ctx.scale(this.data.flip ? -1 : 1, 1)
ctx.drawImage(
  image,
  -this.data.imageWidth / 2,
  -this.data.imageHeight / 2,
  this.data.imageWidth,
  this.data.imageHeight
)
ctx.restore()

如果图片旋转 90 度或 270 度,输出画布宽高要互换:

const rotated = this.data.rotate % 180 !== 0
const outputWidth = rotated ? this.data.imageHeight : this.data.imageWidth
const outputHeight = rotated ? this.data.imageWidth : this.data.imageHeight

文字标注和边框也都可以直接画在导出 Canvas 上:

if (this.data.border) {
  const lineWidth = Math.max(8, Math.round(Math.min(outputWidth, outputHeight) * 0.018))
  ctx.lineWidth = lineWidth
  ctx.strokeStyle = '#9333ea'
  ctx.strokeRect(lineWidth / 2, lineWidth / 2, outputWidth - lineWidth, outputHeight - lineWidth)
}
​
if (this.data.textMark) {
  const fontSize = Math.max(28, Math.round(Math.min(outputWidth, outputHeight) * 0.055))
  const margin = Math.max(24, fontSize)
  ctx.font = `600 ${fontSize}px "PingFang SC", "Microsoft YaHei", sans-serif`
  ctx.textBaseline = 'bottom'
  ctx.textAlign = 'left'
  ctx.fillStyle = '#ffffff'
  ctx.shadowColor = 'rgba(0,0,0,0.45)'
  ctx.shadowBlur = 8
  ctx.fillText(this.data.textMark, margin, outputHeight - margin)
  ctx.shadowColor = 'transparent'
}

十四、几个实践中的坑

1. 预览和导出不能共用同一个尺寸

预览 Canvas 为了适配手机屏幕,尺寸通常很小;导出图要用原图尺寸。否则用户保存出来的图会明显变糊。

2. 辅助框不要画进导出图

拖动水印时需要虚线框和四角控制点帮助用户理解“这里可以拖”,但这些只是交互辅助,不应该进入最终图片。

解决方式是把绘制函数做成同一套逻辑,但传入 showSelectioncontainPreview 参数区分预览和导出。

3. 长图会盖住下面操作区

如果图片是长图,预览区不能无限长,否则下面的配置项和保存按钮会被挤没。更好的做法是固定预览高度,让图片 contain 到固定区域内,配置区保持可操作。

4. 触摸事件要阻止穿透

水印拖动时,Canvas 下方页面也在滚动,是因为触摸事件冒泡。这里用 catchtouchstart / catchtouchmove 可以让拖动更稳定。

5. 内容安全接口和本地处理的边界

如果图片或文案要上传到平台、进入社区内容流,应该接入微信内容安全能力,比如文本检测、图片检测。

但如果功能只是“用户选择本地图片 -> 本地 Canvas 处理 -> 保存回自己的相册”,这条链路没有业务服务端存储,也不把图片发布给其他用户。是否接内容安全,要看产品是否存在上传、发布、分享生成内容到公共场景的链路。

十五、总结

用小程序做本地图片处理,核心并不复杂:

  • wx.chooseImage 拿本地图片;
  • wx.getImageInfo 获取宽高;
  • 用 Canvas 2D 做绘制、变换和排版;
  • wx.canvasToTempFilePath 导出结果;
  • wx.saveImageToPhotosAlbum 保存到相册;
  • 压缩场景优先使用 wx.compressImage

真正需要花时间打磨的是这些细节:

  • 预览和导出保持一致;
  • 长图场景不遮挡操作;
  • 手势拖动不穿透;
  • 辅助框不进入最终图片;
  • JPG 转换时处理透明背景;
  • 保存相册时处理权限和生产环境失败原因;
  • 用尽量少的按钮完成完整操作。

这类工具最适合放在小程序端做:打开即用,不依赖复杂后端,用户处理敏感图片时也更安心。