微信小程序实现合成头像

小程序版本:2.19.4
实现效果如图:

Screenshot_20220929_180134_com.tencent.mm.jpg

最近开发了一款可以合成头像的小程序应用,期间碰到了一些尴尬的问题,我这边做出总结,希望能帮广大码农避坑。

关键技术点如下:canvas贴图,wx.canvasToTempFilePath保存相册。

全部代码组织如下:
1、封装唯一的canvas获取

//页面结构
<canvas class="avatar-board" type="2d" id="avatar" canvas-id="avatar"></canvas>
//获取canvas的js段
//--data申明
canvasObj: {
      with: 0,
      height: 0,
      initialized: false,
      canvas: null,
      context: null,
    }
//--获取方法
getCanvas() {
    return new Promise((resolve) => {
      if (this.data.canvasObj.initialized) {
        return resolve();
      }
      const query = wx.createSelectorQuery();
      query
        .select("#avatar") //这里是canvas的id
        .fields({ node: true, size: true })
        .exec((res) => {
          const canvas = res[0].node;
          const context = canvas.getContext("2d");
          const dpr = wx.getSystemInfoSync().pixelRatio; //获取手机dpr
          canvas.width = res[0].width * dpr;
          canvas.height = res[0].height * dpr;
          context.scale(dpr, dpr);
          const canvasObj = {
            canvas,
            context,
            width: res[0].width,
            height: res[0].height,
            initialized: true,
          };
          this.setData({ canvasObj });
          resolve();
        });
    });
  },

2、贴图方法:

drawImage(url) {
    this.getCanvas().then(() => {
      var fillImg = this.data.canvasObj.canvas.createImage();
      fillImg.src = url;
      fillImg.onload = () => {
        const scale =
          this.data.canvasObj.width / Math.max(fillImg.width, fillImg.height); //计算缩放值
        this.data.canvasObj.context.drawImage(
          fillImg,
          0,
          0,
          fillImg.width,
          fillImg.height,
          (this.data.canvasObj.width - fillImg.width * scale) / 2, //实现水平居中
          (this.data.canvasObj.height - fillImg.height * scale) / 2, //实现垂直居中
          fillImg.width * scale,
          fillImg.height * scale
        );
      };
    });
  },

3、存图方法:

var that = this;
    wx.showLoading({
      title: "正在保存",
      mask: true,
    });
    console.log(this.data.canvasObj.context);
    wx.canvasToTempFilePath(
      {
        canvasId: "avatar",
        canvas: that.data.canvasObj.canvas,
        success(res) {
          wx.hideLoading();
          var tempFilePath = res.tempFilePath;
          wx.saveImageToPhotosAlbum({
            filePath: tempFilePath,
            success(res) {
              wx.showModal({
                content: "图片已保存到相册,赶紧晒一下吧~",
                showCancel: false,
                confirmText: "好的",
                confirmColor: "#333",
                success: function (res) {
                  if (res.confirm) {
                  }
                },
                fail: function (res) {},
              });
            },
            fail: function (res) {
              wx.showToast({
                title: res.errMsg,
                icon: "none",
                duration: 2000,
              });
            },
          });
        },
        fail: function (res) {
          console.log(res.errMsg);
          wx.showToast({
            title: res.errMsg,
            icon: "none",
            duration: 2000,
          });
        },
      },
      that
    );

问题1:贴图扭曲。
解决核心代码:

const dpr = wx.getSystemInfoSync().pixelRatio;
canvas.width = res[0].width * dpr;
canvas.height = res[0].height * dpr;
context.scale(dpr, dpr);

问题2:画布存本地相册一报错(canvasToTempFilePath: fail canvas is empty)
解决方案见“存图方法”
必须吐槽一下某度全是复制粘贴的内容,找问题解决方案太费劲。

最后贴出全部代码:
js

// index.js
// 获取应用实例
const app = getApp();
Page({
  data: {
    url: "",
    avatarUrl: "",
    canvasObj: {
      with: 0,
      height: 0,
      initialized: false,
      canvas: null,
      context: null,
    },
    optionList: [
      {
        text: "获取头像",
        type: "auth",
      },
      {
        text: "相册选取",
        type: "album",
      },
      {
        text: "拍照上传",
        type: "camera",
      },
    ],
    value: "所有",
    chooseIndex: -1,
    hideFlag: true, //true-隐藏 false-显示
    animationData: {}, //
  },
  onload(){
    wx.setStorageSync('useCount', '0');
  },
  clearCanvas() {
    return new Promise((resolve) => {
      this.getCanvas().then(() => {
        this.data.canvasObj.context.clearRect(
          0,
          0,
          this.data.canvasObj.width,
          this.data.canvasObj.height
        );
        resolve();
      });
    });
  },
  // 事件处理函数
  changeAvatar(type, redraw) {
    const that = this;
    if (type == "auth") {
      if (redraw) {
        that.clearCanvas().then(() => {
          that.drawImage(this.data.avatarUrl);
        });
        return false;
      }
      wx.getUserProfile({
        desc: "使用头像", // 声明获取用户个人信息后的用途,后续会展示在弹窗中,请谨慎填写
        success: (res) => {
          that.clearCanvas().then(() => {
            var userInfo = res.userInfo;
            that.setData({
              avatarUrl: userInfo.avatarUrl.replace("/132", "/0"),
            });
            that.drawImage(userInfo.avatarUrl.replace("/132", "/0"));
          });
        },
      });
    } else {
      wx.chooseImage({
        count: 1,
        sizeType: ["original", "compressed"],
        sourceType: [type],
        success(res) {
          that.clearCanvas().then(() => {
            // tempFilePath可以作为 img 标签的 src 属性显示图片
            const tempFilePaths = res.tempFilePaths;
            that.setData({
              avatarUrl: tempFilePaths,
            });
            that.drawImage(tempFilePaths);
          });
        },
      });
    }
  },
  changeTemplate(e) {
    if(!this.data.avatarUrl){
      return wx.showToast({
        title: "请先选择头像",
        icon: "none",
        duration: 2000,
      });
    }
    this.setData({
      chooseIndex: e.currentTarget.dataset.idx,
      url: e.currentTarget.dataset.url,
    });
    if (this.data.chooseIndex == -1) {
      this.drawImage(this.data.url);
    } else {
      this.changeAvatar("auth", true);
      setTimeout(()=>{
        this.drawImage(this.data.url);
      },200)
    }
  },
  drawImage(url) {
    this.getCanvas().then(() => {
      var fillImg = this.data.canvasObj.canvas.createImage();
      fillImg.src = url;
      fillImg.onload = () => {
        const scale =
          this.data.canvasObj.width / Math.max(fillImg.width, fillImg.height);
        this.data.canvasObj.context.drawImage(
          fillImg,
          0,
          0,
          fillImg.width,
          fillImg.height,
          (this.data.canvasObj.width - fillImg.width * scale) / 2,
          (this.data.canvasObj.height - fillImg.height * scale) / 2,
          fillImg.width * scale,
          fillImg.height * scale
        );
      };
    });
  },
  getCanvas() {
    return new Promise((resolve) => {
      if (this.data.canvasObj.initialized) {
        return resolve();
      }
      const query = wx.createSelectorQuery();
      query
        .select("#avatar")
        .fields({ node: true, size: true })
        .exec((res) => {
          const canvas = res[0].node;
          const context = canvas.getContext("2d");

          const dpr = wx.getSystemInfoSync().pixelRatio;
          canvas.width = res[0].width * dpr;
          canvas.height = res[0].height * dpr;
          context.scale(dpr, dpr);

          const canvasObj = {
            canvas,
            context,
            width: res[0].width,
            height: res[0].height,
            initialized: true,
          };

          this.setData({ canvasObj });

          resolve();
        });
    });
  },
  // 保存图片到相册
  saveShareImg() {
    const useCount =  wx.getStorageSync("useCount");
    if(Number(useCount)+1==4){
      return wx.showToast({
        title: "您已超过使用次数,可分享好友解锁无限使用次数!",
        icon: "none",
        duration: 2000,
      });
    }else{
      wx.setStorageSync('useCount', Number(useCount) + 1);
    }
    if (!this.data.canvasObj.initialized) {
      return wx.showToast({
        title: "没有可以保存的头像",
        icon: "none",
        duration: 2000,
      });
    }
    var that = this;
    wx.showLoading({
      title: "正在保存",
      mask: true,
    });
    console.log(this.data.canvasObj.context);
    wx.canvasToTempFilePath(
      {
        canvasId: "avatar",
        canvas: that.data.canvasObj.canvas,
        success(res) {
          wx.hideLoading();
          var tempFilePath = res.tempFilePath;
          wx.saveImageToPhotosAlbum({
            filePath: tempFilePath,
            success(res) {
              wx.showModal({
                content: "图片已保存到相册,赶紧晒一下吧~",
                showCancel: false,
                confirmText: "好的",
                confirmColor: "#333",
                success: function (res) {
                  if (res.confirm) {
                  }
                },
                fail: function (res) {},
              });
            },
            fail: function (res) {
              wx.showToast({
                title: res.errMsg,
                icon: "none",
                duration: 2000,
              });
            },
          });
        },
        fail: function (res) {
          console.log(res.errMsg);
          wx.showToast({
            title: res.errMsg,
            icon: "none",
            duration: 2000,
          });
        },
      },
      that
    );
  },
  getOption: function (e) {
    var that = this;
    that.setData({
      value: e.currentTarget.dataset.type,
      hideFlag: true,
    });
    this.changeAvatar(e.currentTarget.dataset.type);
  },
  mCancel: function () {
    var that = this;
    that.hideModal();
  },
  showModal: function () {
    var that = this;
    that.setData({
      hideFlag: false,
    });
    // 创建动画实例
    var animation = wx.createAnimation({
      duration: 400, //动画的持续时间
      timingFunction: "ease", //动画的效果 默认值是linear->匀速,ease->动画以低速开始,然后加快,在结束前变慢
    });
    this.animation = animation; //将animation变量赋值给当前动画
    var time1 = setTimeout(function () {
      that.slideIn(); //调用动画--滑入
      clearTimeout(time1);
      time1 = null;
    }, 100);
  },

  // 隐藏遮罩层
  hideModal: function () {
    var that = this;
    var animation = wx.createAnimation({
      duration: 400, //动画的持续时间 默认400ms
      timingFunction: "ease", //动画的效果 默认值是linear
    });
    this.animation = animation;
    that.slideDown(); //调用动画--滑出
    var time1 = setTimeout(function () {
      that.setData({
        hideFlag: true,
      });
      clearTimeout(time1);
      time1 = null;
    }, 220); //先执行下滑动画,再隐藏模块
  },
  //动画 -- 滑入
  slideIn: function () {
    this.animation.translateY(0).step(); // 在y轴偏移,然后用step()完成一个动画
    this.setData({
      //动画实例的export方法导出动画数据传递给组件的animation属性
      animationData: this.animation.export(),
    });
  },
  //动画 -- 滑出
  slideDown: function () {
    this.animation.translateY(300).step();
    this.setData({
      animationData: this.animation.export(),
    });
  },
  onShareAppMessage: function () {
    const useCount =  wx.getStorageSync("useCount");
    wx.setStorageSync('useCount', "100");
    return {
      title: '我在这里生成了好看的国庆头像,你也快来试试呀',
    }
    // return custom share data when useCountr share.
  },
});

//  [img, 0, 0, 200, 200, 0, 0, 200, 196.57142857142856]

wxml

<!--index.wxml-->
<view class="container">
  <view class="bg">
    <image class="img" src="../../images/bg.png"></image>
    <image class="tit" src="../../images/text.png"></image>
  </view>
  <view class="avatar content" bindtap="showModal">
    <canvas class="avatar-board" type="2d" id="avatar" canvas-id="avatar"></canvas>
  </view>
  <!-- <image class="icon icon-left content" src="../../images/icon-left.png"></image>
  <image class="icon icon-right content" src="../../images/icon-right.png"></image> -->
  <scroll-view class="list content" scroll-y="{{false}}" bounces="{{false}}" scroll-x="true">
    <view class="{{ chooseIndex == 0 ? 'item active':'item' }}">
      <image bindtap="changeTemplate" data-idx="0" data-url="../../images/tag1.png" src="../../images/tag1.png"></image>
      <image src="../../images/choosed.png" class="choose"></image>
    </view>
    <view class="{{ chooseIndex == 1 ? 'item active':'item' }}">
      <image bindtap="changeTemplate" data-idx="1" data-url="../../images/tag2.png" src="../../images/tag2.png"></image>
      <image src="../../images/choosed.png" class="choose"></image>
    </view>
    <view class="{{ chooseIndex == 2 ? 'item active':'item' }}">
      <image bindtap="changeTemplate" data-idx="2" data-url="../../images/tag3.png" src="../../images/tag3.png"></image>
      <image src="../../images/choosed.png" class="choose"></image>
    </view>
    <view class="{{ chooseIndex == 3 ? 'item active':'item' }}">
      <image bindtap="changeTemplate" data-idx="3" data-url="../../images/tag4.png" src="../../images/tag4.png"></image>
      <image src="../../images/choosed.png" class="choose"></image>
    </view>
    <view class="{{ chooseIndex == 4 ? 'item active':'item' }}">
      <image bindtap="changeTemplate" data-idx="4" data-url="../../images/tag5.png" src="../../images/tag5.png"></image>
      <image src="../../images/choosed.png" class="choose"></image>
    </view>
    <view class="{{ chooseIndex == 5 ? 'item active':'item' }}">
      <image bindtap="changeTemplate" data-idx="5" data-url="../../images/tag6.png" src="../../images/tag6.png"></image>
      <image src="../../images/choosed.png" class="choose"></image>
    </view>
    <view class="{{ chooseIndex == 6 ? 'item active':'item' }}">
      <image bindtap="changeTemplate" data-idx="6" data-url="../../images/tag7.png" src="../../images/tag7.png"></image>
      <image src="../../images/choosed.png" class="choose"></image>
    </view>
  </scroll-view>
  <view class="btn-box content">
    <image class="btn" bindtap="showModal" mode="aspectFit" src="../../images/btn1.png"></image>
    <image class="btn" bindtap="saveShareImg" mode="aspectFit" src="../../images/btn2.png"></image>
  </view>

  <view class="modal modal-bottom-dialog" hidden="{{hideFlag}}">
 <view class="modal-cancel" bindtap="hideModal"></view>
 <view class="bottom-dialog-body bottom-positon" animation="{{animationData}}">
  <!-- -->
  <view class='Mselect'>
  <view wx:for="{{optionList}}" wx:key="unique" data-type="{{item.type}}" data-value='{{item.text}}' bindtap='getOption'>
   {{item.text}}
  </view>
  </view>
  <view></view>
  <view class='Mcancel' bindtap='mCancel'>
  <text>取消</text>
  </view>
 
 </view>
 </view>

</view>

wxss

/**index.wxss**/
.container {
  position: relative;
  background-size: cover;
  width: 100%;
  height: 100vh;
  display: flex;
  align-items: center;
  justify-self: center;
  flex-direction: column;
  position: relative;
}
.container .bg {
  position: absolute;
  width: 100%;
  height: 100%;
  left: 0;
  top: 0;
  z-index: -1;
}
.container .bg .img {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.container .bg .tit {
  position: absolute;
  top: 60rpx;
  left: 50%;
  width: 622rpx;
  height: 122rpx;
  transform: translateX(-50%);
}
.avatar {
  margin-top: 120rpx;
  width: 300rpx;
  height: 300rpx;
  border: 5px solid #fff;
  border-radius: 10rpx;
  overflow: hidden;
}
.list {
  box-sizing: border-box;
  width: 700rpx;
  height: 238rpx;
  white-space: nowrap;
  background: #ffcbab;
  border-radius: 20rpx;
  padding: 20rpx;
  overflow: hidden;
  border: 4px solid rgba(255,255,255,0.3);
}
.list .item {
  display: inline-block;
  width: 180rpx;
  height: 180rpx;
  box-sizing: border-box;
  background: #fff;
  border-radius: 10rpx;
  overflow: hidden;
}
.list .item {
  position: relative;
}
.list .item .choose {
  display: none;
}
.list .item.active .choose {
  display: block;
  width: 50rpx;
  height: 50rpx;
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%)
}
.list .item image { 
  width: 100%;
  height: 100%;
  object-fit: contain;
}
.list .item~.item {
  margin-left: 20rpx;
}
.container .icon {
  position: absolute;
  bottom: 50%;
  width: 40rpx;
  height: 40rpx;
  padding: 10rpx;
  margin-bottom: -170rpx;
  background: #ffcbab;
  border-radius: 50%;
}
.container .icon-left {
  left:4rpx;
}
.container .icon-right {
  right:4rpx;
}
.avatar-board {
  width: 100%;
  height: 100%;
  background: #fff;
}
.btn-box {
  width: 700rpx;
  display: flex;
  justify-content:space-between;
  align-items: center;
}
.btn-box .btn {
  width: 360rpx;
  height: 160rpx;
  object-fit: contain;
  color: #333;
  font-size: 32rpx;
}
.content {
  position: relative;
  z-index: 2;
}

.arrow{
  display:inline-block;
  border:6px solid transparent;
  border-top-color:#000;
  margin-left:8px;
  position:relative;
  top:6rpx;
 }
 /* ---------------------------- */
 /*模态框*/
 .modal{position:fixed; top:0; right:0; bottom:0; left:0; z-index:1000;}
 .modal-cancel{position:absolute; z-index:2000; top:0; right:0; bottom: 0; left:0; background:rgba(0,0,0,0.3);}
 .bottom-dialog-body{width:100%; position:absolute; z-index:3000; bottom:0; left:0;background:#dfdede;}
 /*动画前初始位置*/
 .bottom-positon{-webkit-transform:translateY(100%);transform:translateY(100%);}
  
  
 /* 底部弹出框 */
 .bottom-positon{
  text-align: center;
 }
 .Mselect{
  margin-bottom: 20rpx;
 }
 .Mselect view{
  padding: 32rpx 0;
  background: #fff;
  font-size: 32rpx;
 }
 .Mselect view:not(:last-of-type){
  border-bottom: 1px solid #dfdede;
 }
 .Mcancel{
  color: #999;
  background: #fff;
  padding: 26rpx 0;
 }

最后希望本文能对大家在小程序图片合成方案上有所帮助。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 196,099评论 5 462
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 82,473评论 2 373
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 143,229评论 0 325
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,570评论 1 267
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,427评论 5 358
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,335评论 1 273
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,737评论 3 386
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,392评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,693评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,730评论 2 312
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,512评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,349评论 3 314
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,750评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,017评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,290评论 1 251
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,706评论 2 342
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,904评论 2 335

推荐阅读更多精彩内容