运营要一个「分享赚钱」的功能,用户点一下弹出一张带自己头像、昵称和专属二维码的海报,长按保存发朋友圈。听着就是画张图的事,真动手才发现全是细节,画布往哪儿放才不挡页面、网络图片为什么画不上去、头像圆角怎么切、文字超出一行怎么折。
这篇把当时踩过的坑整理成两套完整实现。一套是用旧版 wx.createCanvasContext 封装成组件,哪儿要海报就往哪儿塞;另一套是用新的 type="2d" 画布配一个 Draw 工具类,把 view、image、text 的绘制抽象出来。两套代码都能直接抄,后面会说清楚各自适合什么场景。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
- 小程序里生成海报为什么绕不开 canvas
- 用旧版
createCanvasContext把海报封装成一个可复用组件的完整代码 - 隐藏画布的定位技巧,为什么不能用
display: none - 网络图片、base64 二维码怎么画进画布,域名白名单要配什么
- 圆形头像的裁剪写法,以及
save和restore为什么必须配对 - 新版 canvas 2d 的用法,配一个通用
Draw类处理 view、image、text - 多行文本折行、字号、高清屏模糊这些细节怎么处理
- 保存到相册的授权流程和几个容易忽略的失败分支
# 一、小程序里画海报,为什么只能用 canvas
先把前提说清楚。海报的最终形态是一张图片,用户要长按保存到相册,所以它必须是真正的图片文件,不能是一堆 view 拼出来的页面。小程序没有 html2canvas 那种把 DOM 截图的能力,服务端渲染图片又要多一次网络往返和一套图片服务,于是客户端 canvas 就成了最直接的路。
流程固定是三步。往画布上画背景、图片、文字,调 wx.canvasToTempFilePath 把画布导出成一个临时文件路径,再用 wx.saveImageToPhotosAlbum 存进相册。中间那步是关键,画布本身用户是看不到也保存不了的,能保存的只有导出后的那个临时文件。
理解了这条链路,后面那些奇怪的写法就都有解释了。
# 二、方案一,用旧版 canvas 接口封装成组件
这套是最早写的,基于 wx.createCanvasContext。好处是可以整个封装进一个自定义组件,页面里引入之后调一个方法就出海报。
# 2.1 组件配置
先声明这是个组件,并引入弹层和图标:
{
"component": true,
"usingComponents": {
"van-popup": "@vant/weapp/popup/index",
"van-icon": "@vant/weapp/icon/index"
}
}
"component": true 这一行决定了它是自定义组件而不是页面,弹层用的是 Vant Weapp 的 van-popup,你项目里用别的组件库或者自己写一个都行,不影响绘制逻辑。
# 2.2 结构,画布 + 弹层 + 两个按钮
结构分两块,一块是那个用来画图的 canvas,一块是海报出来之后展示的弹层:
<canvas canvas-id="poster-share" class="poster" />
<view bind:tap="handleShare">
<slot wx:if="{{useSlot}}" />
</view>
<van-popup custom-class="page-van-popup" show="{{ showShare }}" bind:close="shareClose">
<view class="df fxdc h100v w100v" wx:if="{{showShare}}">
<view class="df jcc aic fxa posr mt120" bind:tap="shareClose">
<image class="poster-image" src="{{ tempFilePath }}" mode="aspectFit" />
</view>
<view class="fxn h238 bgfff df aic jcc bdrs16t">
<view class="fxa w0 df aic jcc">
<view class="df fxdc posr">
<button class="posa t0 l0 w100p h100p op0" open-type="share" />
<image class="h108 w108" src="./assets/ic_fenxiang.svg" />
<text class="mt16 c333 fz28">分享好友</text>
</view>
</view>
<view class="fxa w0 df aic jcc">
<view class="df fxdc" bind:tap="savePicture">
<image class="h108 w108" src="./assets/ic_baocun.svg" />
<text class="mt16 c333 fz28">保存图片</text>
</view>
</view>
</view>
</view>
</van-popup>
这里有三个点值得单独拎出来。
弹层里展示的是 tempFilePath,也就是画布导出后的临时文件,不是画布本身。用户看到的、长按的、保存的都是这张图。
「分享好友」那个按钮用了一个绝对定位、透明度为 0 的 button 盖在图标上,open-type="share" 只能挂在 button 上,但设计稿要的是一个图标加一行字。透明按钮盖上去是小程序里最常用的解法,比强行改 button 的默认样式省事。
slot 配合 useSlot 属性,是为了让调用方自己决定触发海报的入口长什么样,有的页面是个悬浮按钮,有的是列表里的一行文字。
# 2.3 样式,重点是把画布藏起来
@import "../../../../libs/wxss/index.wxss";
.container {
width: 100%;
}
.h377 {
height: 377rpx;
}
.h417 {
height: 417rpx;
}
.pb70 {
padding-bottom: 70rpx;
}
.bgview {
height: 651rpx;
opacity: .63;
}
.tip,
.share {
right: 0;
border-radius: 100rpx 0 0 100rpx;
width: 88rpx;
height: 36rpx;
background: #ffd300;
color: #007142;
}
.tip {
top: 75rpx;
}
.share {
top: 140rpx;
}
.title-wrap {
width: 100%;
height: 85rpx;
background: #fff0e5;
}
.shopping-num {
border-radius: 15rpx;
height: 28rpx;
background: linear-gradient(90deg, rgba(255, 64, 0, 1) 0%, rgba(255, 163, 41, 1) 100%);
}
.page-van-popup {
--popup-background-color: transparent;
}
.popup__content {
width: 590rpx;
height: 800rpx;
}
.poster {
position: fixed;
left: 0;
top: -1000000px;
z-index: 1000;
width: 570px;
height: 920px;
pointer-events: none;
}
.nodata {
padding-top: 150rpx;
height: 700rpx;
}
.count-down .van-count-down {
width: 200rpx;
line-height: unset;
font-size: 20rpx;
color: unset;
}
.poster-image {
width: 690rpx;
height: 920rpx;
}
.cffffff88 {
color: #ffffff88;
}
.w252 {
width: 252rpx;
}
样式里最关键的是 .poster 这一段,单独看一遍:
画布用 position: fixed 加 top: -1000000px 挪到了屏幕外面,还加了 pointer-events: none。为什么要这么写?因为旧版 canvas 是原生组件,display: none 或者 visibility: hidden 之后它就不参与渲染了,你画上去的东西导不出来,canvasToTempFilePath 会返回一张空白图甚至直接失败。宽高也不能设成 0,画布的尺寸就是最终图片的尺寸。所以只能让它「存在但看不见」,挪出可视区是最稳的办法。
顺带说一句单位。.poster 的宽高用的是 px 而不是 rpx,因为绘制时传给 drawImage 的坐标是按画布的逻辑像素算的,混用 rpx 会导致你在设计稿上量的坐标和实际画出来的对不上。展示海报的 .poster-image 才用 rpx,那是给用户看的,需要跟着屏幕宽度走。
另外那行 #fff88 是原文的笔误,十六进制颜色不存在五位写法,按类名 cffffff88 推断应该是 #ffffff88,这里改过来了。写错的后果不是报错,是这条颜色规则整条失效,排查时很容易漏掉。
# 2.4 绘制逻辑,一行一行画上去
下面是组件的核心,代码有点长,先看完再拆:
// layer/component/sharePoster/index.js
import { rGetShareErCode } from '../../../../netapi/redPackets/index';
import { KEY_APP_ID } from '../../../../constants/config';
import { stringLimit } from '../../../../utils/string'
Component({
/**
* 组件的属性列表
*/
properties: {
useSlot: {
type: Boolean,
value: false
},
showShare: {
type: Boolean,
value: false
},
pagePath: {
type: String,
value: '/pages/packagesSection/redPacketsDistribute/index',
},
},
/**
* 组件的初始数据
*/
data: {
tempFilePath: '',
},
query: {},
observers: {
},
/**
* 组件的方法列表
*/
methods: {
shareClose() {
this.setData({ showShare: false });
},
// 其余方法见下文
}
})
properties 和 data 这两处我调整了一下。原文里 showShare 同时出现在 properties 和 data 里,小程序自定义组件不允许这样,同名的话行为取决于实现细节,开发者工具也会告警。既然它需要由外部传入控制弹层显隐,就留在 properties 里,data 只保留组件自己维护的 tempFilePath。