一次小程序 Canvas 海报真机调试复盘:从空白导出到 iOS 保存失败

这篇记录一个名字海报功能的调试过程。功能很直观:用户选择名字后生成海报,海报包含名字、拼音、字义和诗句,最后保存到相册。

真正的开发过程却不是"调用 Canvas API,得到一张图片"这么简单。

问题一:看不到错误,不代表没有错误

第一版出现过生成失败但页面没有明确反馈的情况。排查后发现,Canvas 节点由弹窗条件渲染,生成函数调用时节点可能还没有完成挂载。

后来先等待页面准备,再把失败转成明确的 UI 状态:生成中、成功、失败和超时分别处理。

js 复制代码
this.generating = true;
try {
  const ctx = uni.createCanvasContext('posterCanvas', this);
  this.drawPoster(ctx);
  ctx.draw(false, () => this.exportPoster());
} catch (_) {
  this.generating = false;
  uni.showToast({ title: '海报生成失败,请重试', icon: 'none' });
}

这里的重点不是 try/catch,而是让页面不会因为一次异步失败永远停留在"生成中"。

问题二:隐藏 Canvas 导出可能挂起

为了不让用户看到绘制过程,曾经尝试把 Canvas 隐藏。结果在部分环境中,隐藏状态影响了导出,页面一直等待。

处理方式是让预览区域保持可见,并给导出增加超时保护。开发者工具中不容易暴露这类差异,所以这一步必须在目标设备上确认。

问题三:绘制回调是导出边界

绘制命令提交后立即导出,是最容易出现空白图的写法。项目后来固定在 draw 回调中开始导出:

js 复制代码
ctx.draw(false, () => {
  const timer = setTimeout(() => {
    this.generating = false;
    uni.showToast({ title: '生成超时,请重试', icon: 'none' });
  }, 8000);

  uni.canvasToTempFilePath({
    canvasId: 'posterCanvas',
    success: ({ tempFilePath }) => {
      clearTimeout(timer);
      this.posterImage = tempFilePath;
      this.generated = true;
      this.generating = false;
    },
    fail: () => {
      clearTimeout(timer);
      this.generating = false;
    }
  }, this);
});

这里的超时只负责让 UI 结束等待,并不会取消底层导出请求。后续如果要进一步增强,还可以为每次生成分配任务编号,忽略旧任务的晚到回调。

问题四:尺寸和坐标必须同时看

Canvas 的展示尺寸、逻辑绘制尺寸和导出尺寸并不是一回事。项目曾经出现过海报内容错位,最后通过统一画布尺寸和绘制坐标解决。

排查时不要只看 CSS 宽高,还要同时确认:

  • Canvas 实际逻辑宽高;
  • 绘制坐标使用的单位;
  • 导出时的 destWidth、destHeight;
  • 图片和字体加载完成的时机。

问题五:iOS 真机直接保存临时文件失败

生成成功后,iOS 真机上直接调用相册保存,曾出现 file not exists。最后使用先落盘再保存的路径:

js 复制代码
const tempPath = this.posterImage;
const fs = uni.getFileSystemManager();

fs.saveFile({
  tempFilePath: tempPath,
  success: ({ savedFilePath }) => {
    this.saveFileToAlbum(savedFilePath);
  },
  fail: () => {
    this.saveFileToAlbum(tempPath);
  }
});

保存完成后再清理落盘副本。落盘失败时保留直接保存的兼容尝试,但不能把这个分支写成所有环境的保证。

问题六:失败以后仍然要能完成任务

保存失败可能是相册权限问题,也可能是临时文件或平台接口问题。项目将权限错误导向授权/设置,将其他错误导向预览:

js 复制代码
const message = (error && error.errMsg) || '';
if (/auth deny|authorize|permission/i.test(message)) {
  this.requestAlbumAuth();
} else {
  uni.previewImage({
    urls: [this.posterImage],
    current: this.posterImage
  });
  uni.showToast({ title: '长按图片保存', icon: 'none' });
}

这条预览兜底很重要。自动保存失败时,至少让用户看到已经生成的真实图片。

Git 修复时间线

这个问题不是一次提交解决的,项目记录大致经历了:

text 复制代码
72a9783  处理静默失败与节点挂载
87951c5  改为可见 Canvas 并增加导出超时
9c7112e  调整 Canvas API 兼容路径
3a0acf2  修正尺寸与坐标
20d80ea  绘制回调后立即导出
437f7eb  生成期间禁止关闭弹窗
4b3a990  补齐授权和预览兜底
 aa10533  修复 iOS 真机 file not exists

提交记录能说明排查方向发生过变化,但不能替代真机测试报告。发布前还需要补充实际机型、微信版本、基础库和修复前后截图。

调试时我现在会记录什么

不再只记录"海报失败",而是记录:

  • 当前阶段:绘制、导出、落盘、相册保存;
  • 错误码或错误类型;
  • 开始和结束时间;
  • 当前任务是否已经结束。

同时避免记录姓名、答案、完整云函数请求和临时文件路径。调试日志应当服务于排查,而不是把用户数据带进日志文件。

复盘结论

这次问题让我把一个功能拆成了六个阶段:节点、绘制、导出、文件、权限和兜底。任何一层都可能在开发者工具正常、真机异常的情况下暴露出来。

这份记录来自字笺 SinoName 的开发过程。下一次遇到类似问题,我会先写清运行环境和复现步骤,再开始改代码。

如果你也遇到过相同问题,欢迎补充机型和基础库版本。没有环境信息的"真机不行",通常还不足以定位问题。

相关推荐
2301_32241428042 天前
springboot学生课堂出勤统计系统15835-计算机毕设 原创(免费领源码+部署教程+开发环境)
java·mysql·随机森林·微信小程序·uni-app·微信开发者工具·推荐算法
2501_915921434 天前
iOS应用从开发到上架App Store的完整步骤详解
android·ios·小程序·https·uni-app·iphone·webview
小蒜学长6 天前
基于SpringBoot+uni-app的闪洁家政系统的设计与实现(代码+数据库+LW)
java·spring boot·后端·uni-app·家政服务
anyup_前端梦工厂6 天前
uni-app 模板 Starter 一键清理 Skill 发布
前端·人工智能·uni-app
anyup_前端梦工厂6 天前
将uni-app工程迁移uni-app x,AI 能帮我们做什么?
前端·人工智能·uni-app
蒿豪郝灬号6 天前
UniApp接入鸿蒙版阿里百川
华为·uni-app·harmonyos
2501_915921437 天前
详细教程:使用Perfdog工具测量iOS应用的功耗性能与优化方法
android·macos·ios·小程序·uni-app·cocoa·iphone
梦曦i7 天前
uni-app、uni-app x 与 Flutter 深度对比:2026年跨平台开发框架选型指南
flutter·uni-app·uni-app x
普马萨特8 天前
uni-app x 定位踩坑实录:H5/小程序/App 统一经纬度与逆地理编码
uni-app
Geek_Vison8 天前
保险APP已经用了Flutter,还能接入小程序吗?
flutter·小程序·uni-app·mpaas·小程序容器