前面几节的内容中,为了照顾零基础的朋友们,我们尽可能慢下来为大家详尽地梳理了开发过程中涉及到的种种概念和技术原理。我相信阅读到本节的朋友一定已经拥有基本的编程能力和对技术的理解了,所以从本节开始我们将不会再对一些「入门级别的内容」进行特别介绍,开发的进度也会明显加快,如果你感到不适应甚至难以流畅阅读,我个人建议重新回顾一遍前面的内容,给自己「补补课」,尤其是文章中的外链资料,最起码应该点进去看一看,相信你再来到这里的时候,必然可以「一笑而过」👊。

我个人的经验是,学技术切忌速成,慢慢琢磨、养成扎实的技术理解力和解决问题的习惯才最有益。


上节内容中,我们成功让「🎭头像定制」小程序动起来啦,但也仅仅是动起来而已,目前用户只能够「选择当前头像」。在本节中,我们会首先完善其它两项头像选择功能,然后一鼓作气实现后续的所有功能!有了前面几节的积淀,接下来的内容对大家来说应该是小菜一碟 😏

完善头像选择功能

在微信小程序中,「从相册中选择图片」和「调起相机拍照」其实是由同一个 API 提供的,即 wx.chooseImage。参照该项 API 的官方文档和上一节中已经完成的 chooseCurrentAvatar 的处理逻辑,很容易可以完成「选择其它图片」和「拍照作为头像」两项功能。示例代码如下:

  1. <!--miniprogram/pages/index/index.wxml-->
  2. <button open-type="getUserInfo" bindgetuserinfo="chooseCurrentAvatar" class="btn" style="width: 60%; margin: 2.5vh 5vw; padding: 0; font-size: inherit; font-weight: inherit; ">选择当前头像</button>
  3. <view class="btn" bindtap="chooseFromAlbum">选择其它图片</view>
  4. <view class="btn" bindtap="chooseFromCamera">拍照作为头像</view>
Page({
  chooseFromAlbum() {
    wx.chooseImage({
      count: 1,
      sizeType: ['original'],
      sourceType: ['album'],
      success(res) {
        const tempFilePaths = res.tempFilePaths
        appInstance.globalData["avatar"] = tempFilePaths[0]
        wx.navigateTo({
          url: '../avatar-confirm/avatar-confirm'
        })
      }
    })
  },
  chooseFromCamera() {
    wx.chooseImage({
      sizeType: ['original'],
      sourceType: ['camera'],
      success(res) {
        const tempFilePaths = res.tempFilePaths
        appInstance.globalData["avatar"] = tempFilePaths[0]
        wx.navigateTo({
          url: '../avatar-confirm/avatar-confirm'
        })
      }
    })
  },
  // ... 其它数据或逻辑
})

wx.chooseImage 的详细介绍,请查阅:API - 媒体 - 图片 - chooseImage | 微信开放文档

进入到「确认图片」页面,有「更换图片」和「选择贴图」两个按钮功能需要完成。点击「更换图片」按钮的时候会调起一个操作菜单,其中提供了首页中罗列的三种选择头像的选项,点击「更换图片」按钮的时候会跳转到「选择贴图」页面。页面跳转我们已经接触过了,弹出「操作菜单」也有现成的 API,完成这一步之后,示例代码如下:

<!--miniprogram/pages/avatar-confirm/avatar-confirm.wxml-->
<view class="btn" bindtap="chooseAvatar">更换图片</view>
<view class="btn" bindtap="confirmAvatar">选择贴图</view>
// miniprogram/pages/avatar-confirm/avatar-confirm.js
Page({
  chooseAvatar() {
    wx.showActionSheet({
      itemList: ['选择当前头像', '选择其它图片', '拍照作为头像'],
      success: (res) => {
        switch (res.tapIndex) {
          case 0:
            wx.getUserInfo({
              success: (res) => {
                const avatarUrl = res.userInfo.avatarUrl;
                appInstance.globalData['avatar'] = avatarUrl;
                this.setData({
                  avatarUrl: avatarUrl
                })
              }
            })
            break;
          case 1:
            wx.chooseImage({
              count: 1,
              sizeType: ['original'],
              sourceType: ['album'],
              success: (res) => {
                const tempFilePaths = res.tempFilePaths
                appInstance.globalData["avatar"] = tempFilePaths[0]
                this.setData({
                  avatarUrl: tempFilePaths[0]
                })
              }
            })
            break;
          case 2:
            wx.chooseImage({
              sizeType: ['original'],
              sourceType: ['camera'],
              success: (res) => {
                const tempFilePaths = res.tempFilePaths
                appInstance.globalData["avatar"] = tempFilePaths[0]
                this.setData({
                  avatarUrl: tempFilePaths[0]
                })
              }
            })
            break;
        }
      }
    })
  },
  confirmAvatar() {
    wx.navigateTo({
      url: '../sticker-select/sticker-select'
    })
  },
  // ... 其它数据或逻辑
})

以上的 ActionSheet 其实有一个小毛病——如果用户没有在首页完成「UserInfo 访问授权」的话,在操作菜单中点击「选择当前头像」调用 wx.getUserInfo 会默认失败。对于这种情况,我们可以提醒用户「使用当前头像需要进行授权」并引导他(她)完成授权,或者,在用户没有完成授权的时候操作菜单中不显示「选择当前头像」的选项。权衡之下,我选择第二种方案,大家可以根据自己的实际情况进行发挥。优化之后,示例代码如下:

// miniprogram/pages/avatar-confirm/avatar-confirm.js
Page({
    data: {
    // 其它数据字段
      _itemList: []
  },
  onLoad: function (options) {
    wx.getSetting({
      success: (res) => {
        this.data['_itemList'] = res.authSetting['scope.userInfo'] ? ['选择当前头像', '选择其它图片', '拍照作为头像'] : ['选择其它图片', '拍照作为头像']
      }
    })
    // 其它逻辑
  },
  chooseAvatar() {
    wx.showActionSheet({
      itemList: this.data['_itemList'],
      success: (res) => {
          // 操作菜单选项逻辑
      }
    })
  }
})

贴图选择与预览

在「选择贴图」页面,我们首先应该去 App 实例的 globalData 字段取得用户选择完毕的头像地址,将其更新到页面中的头像预览区域,该逻辑与「确认图片」页面相似,故不单独做示例。

用户在点选贴图的时候,对应的贴图应该即时更新到页面上方的贴图预览区域,为此,我们需要监听用户的点击事件,并为其指派任务。这里有两种实现方式,其一是为每个贴图 image 组件都绑定点击事件,其二是为贴图列表容器绑定点击事件,相较之下前者响应更加即时,但小程序框架需要同时关注多个组件,而后者响应稍慢一点,小程序框架只需要关注一个组件。鉴于响应时间的差距几乎可以忽略不计,所以我们选择后者。

关于「贴图列表容器」为什么能够监听到「贴图 image 子组件」的被点击事件,请查阅「事件冒泡」相关内容:指南 - 小程序框架 - 事件系统 - 事件详解 | 微信开放文档

点击事件触发的时候,我们可以在为组件绑定的处理方法中以参数的形式获得该次点击操作相关的信息,其中包括被点击组件的部分信息,为了让点击事件的处理逻辑可以拿到被点击贴图的贴图路径,我们需要对 WXML 进行一点改造,通过 dataset 属性将贴图的路径写在组件数据中。改造完成之后代码示例如下:

<!--miniprogram/pages/sticker-select/sticker-select.wxml-->
<scroll-view scroll-x="true" bindtap="previewSticker">
    <image wx:for="{{stickers}}" src="{{item}}" data-src="{{item}}"></image>
</scroll-view>
// miniprogram/pages/sticker-select/sticker-select.js
const appInstance = getApp()

Page({
  data: {
    avatarUrl: "../../assets/avatar.jpg",
    stickerSelected: "",
    stickers: [
      "../../assets/thoughts-sticker-square.png",
      "../../assets/tcb-sticker-square.png",
      "../../assets/tcb-sticker-round-withcorner.png",
      "../../assets/thoughts-sticker.png",
      "../../assets/tcb-sticker.png"
    ]
  },

  onLoad: function (options) {
    this.setData({
      avatarUrl: appInstance.globalData.avatar
    })
  },

  previewSticker(e) {
    let { src } = e.target.dataset;
    this.setData({
      stickerSelected: src
    })
  }
})

关于事件触发之后可以传递给处理方法的额外信息,请查阅:指南 - 小程序框架 - 事件系统 | 微信开放文档

贴图选择好之后就可以进行头像的合成了,点击页面下方的「确认」按钮即代表开始合成。合成需要用到「头像」和「贴图」两个素材,为了避免用户在没有选择贴图的情况下误触「确认」按钮引发意料之外的问题,我们还需要在确认按钮点击事件的处理逻辑里加一点小小的判断,确保用户已经选择了贴图,如果没有选择的话就弹出提示,示例代码如下:

<!--miniprogram/pages/sticker-select/sticker-select.wxml-->
<view class="btn" bindtap="confirmSticker">确认</view>
Page({
    confirmSticker() {
    if (!this.data.stickerSelected) {
      wx.showToast({
        title: '🔊 请先选择贴图',
        icon: 'none'
      })
      return
    }
    // 头像合成逻辑
  },
  // 其它数据字段或逻辑
})

头像合成与输出

合成头像的时候我们需要用到画布组件「canvas」和与之配套的 API,大抵逻辑是我们依次将图片绘制到画布上,然后将画布上绘制的内容导出为图片。

首先我们在页面中添加一个 canvas 组件,并将它移到视图千里之外,不要在用户眼皮子底下绘制,为此我们需要加工一下页面的 WXML,辅以简单的 WXSS 样式,示例代码如下:

<!--miniprogram/pages/sticker-select/sticker-select.wxml-->
<view class="area-avatar">
    <image src="{{avatarUrl}}"></image>
    <image wx:if="{{stickerSelected}}" class="sticker-preview" src="{{stickerSelected}}"></image>
    <canvas id="avatarCanvas" type="2d"></canvas>
</view>
/* miniprogram/pages/sticker-select/sticker-select.wxss */
canvas {
  position: absolute;
  top: 0;
  left: 900%;
  width: 100%;
  height: 100%;
}

canvas 组件添加完成之后,页面中是看不到这个它的,但在开发阶段大家可以先将其设置到视图之内,待合成头像的功能都调试无误之后,再将其移出视图。

canvas 组件的详细介绍请查阅:组件 - 画布 - canvas | 微信开放文档

合成头像的逻辑与准备画布一样枯燥,我们先通过 SelectorQuery 相关的 API 获取画布实例以获得在上面绘制内容的操作权,然后依次调用将图片绘制上去和导出为图片 API 即可,示例代码如下(略长,附简单注释):

// miniprogram/pages/sticker-select/sticker-select.js
const appInstance = getApp()

Page({
    confirmSticker() {
      // 此处是判断贴图已经选择的逻辑

    const query = wx.createSelectorQuery()
    query.select('#avatarCanvas')                       // 指定选择器
      .fields({ node: true, size: true })               // 指定需要获取的内容
      .exec((res) => {                                                                    // 设置完毕,开始执行,执行结果以参数形式传给回调函数
        let { width, height, node: canvas } = res[0]    // 从结果中解构出需要的内容,包括 canvas 组件的尺寸和组件实例
        const ctx = canvas.getContext('2d')             // 拿到可操作的画布上下文

        // 设置画布的大小
        const dpr = wx.getSystemInfoSync().pixelRatio
        canvas.width = width * dpr
        canvas.height = height * dpr
        ctx.scale(dpr, dpr)

          // 在画布上绘制图片之前需要先将图片转换为可操作的图片对象
        const avatar = canvas.createImage()
        avatar.onload = () => {
          // 绘制头像
          ctx.drawImage(avatar, 0, 0, width, height)
          // 头像绘制完成之后绘制贴图
          const sticker = canvas.createImage()
          sticker.onload = () => {
            ctx.drawImage(sticker, 0, 0, width, height)
            // 贴图绘制完成之后导出为图片
            wx.canvasToTempFilePath({
              canvas: canvas,
              quality: 1,
              width: Math.round(width),
              height: Math.round(height),
              success(res) {
                // 将结果图片地址存储在 App 实例下
                appInstance.globalData.composedAvatarUrl = res.tempFilePath
                // 跳转至「合成完毕」页面
                wx.navigateTo({
                  url: '../composition-complete/composition-complete'
                })
              }
            })
          }
          sticker.src = this.data.stickerSelected
        }
        avatar.src = this.data.avatarUrl
      })
  }
})

以上代码在绘制完成之后顺便将「结果图片」的地址存储在 App 实例下,我们在「合成完毕」页面中可直接获取并显示在视图中。除了显示头像以外,我们还需要完成该页面中「再做一个」和「保存至本地」两个按钮功能,点击「再做一个」的时候跳转回首页即可,「保存至本地」调用 wx.saveImageToPhotosAlbum API,该项 API 需要获得访问用户存储的权限,在首次调用的时候,小程序框架会默认调起弹窗请求用户授权,但如果用户明确拒绝,之后对该权限相关 API 的调用都会默认失败,所以我们需要做用户拒绝授权的兼容处理,引导他(她)们完成授权。示例代码如下:

<!--miniprogram/pages/composition-complete/composition-complete.wxml-->
<view class="fullview flex-column-around-center">
    <view class="area-avatar">
        <image src="{{avatarUrl}}"></image>
    </view>
    <view class="width-full flex-column-around-center">
        <view class="btn btn-default" bindtap="composeAgain">再做一个</view>
        <view class="btn btn-primary" bindtap="saveToAlbum">保存至本地</view>
    </view>
</view>
// miniprogram/pages/composition-complete/composition-complete.js
const appInstance = getApp()

Page({
  data: {
    avatarUrl: ''
  },

  onLoad: function (options) {
    this.setData({
      avatarUrl: appInstance.globalData['composedAvatarUrl']
    })
  },

  composeAgain() {
    wx.navigateTo({
      url: '../index/index'
    })
  },

  _saveToAlbum() {
    wx.saveImageToPhotosAlbum({
      filePath: this.data.avatarUrl,
      success: () => {
        wx.showToast({
          title: '🖼 成功保存至相册',
          icon: 'none',
        })
      },
      fail() {
        wx.showToast({
          title: '🌪 被风吹走了,请重试',
          icon: 'none'
        })
      }
    })
  },
  saveToAlbum() {
    wx.getSetting({
      complete: (res) => {
        if (!res.authSetting['scope.writePhotosAlbum']) {
          wx.authorize({
            scope: 'scope.writePhotosAlbum',
            success: () => {
              this._saveToAlbum
            },
            fail: () => {
              wx.showModal({
                title: '请求授权',
                content: '😓 你不授权我怎么帮你存,给你一次机会,打开相册的存储权限。',
                confirmText: '现在就去',
                cancelText: '下次一定',
                success: (res) => {
                  if (res.confirm) {
                    wx.openSetting({
                      complete: (res) => {
                        if (!res.authSetting['scope.writePhotosAlbum']) {
                          wx.showToast({
                            title: '☝ 耍我,不伺候了',
                            icon: 'none'
                          })
                        } else {
                          this._saveToAlbum()
                        }
                      },
                    })
                  }
                  if (res.cancel) {
                    wx.showToast({
                      title: '行呗 😑',
                      icon: 'none'
                    })
                  }
                }
              })
            }
          })
        } else {
          this._saveToAlbum()
        }
      },
    })
  }
})

上面我们在与用户进行权限交涉的过程中,多次涉及到了 wx.saveImageToPhotosAlbum 的调用,为了避免无谓的重复,我们将其简单提取为一个单独的方法,然后直接在回调分支中调用。让用户手动去设置权限并不是一件非常友好的事情,所以我在交涉过程中加入了一些俏皮话,稍微提升一点体验。

关于「用户权限」相关的内容,请查阅:指南 - 开放能力 - 授权 | 微信开放文档API - 开放接口 - 授权 - authorize | 微信开放文档API - 开放接口 - 设置 - getSetting | 微信开放文档API - 开放接口 - 设置 - openSetting | 微信开放文档

至此,「🎭头像定制」小程序简单版本的大体功能就全部完成啦 ,请给自己点个赞叭!

小结

对我个人而言,这节内容是目前为止最为乏味的一节,一方面,发挥想象力和思维碰撞的部分在产品规划的时候已经都被消费殆尽了,在功能实现的过程中几乎没有创造性可言,另一方面,这个小应用的代码量少得可怜,在我们完成下一步功能规划之前几乎没有优化的意义(可优化,但在下一阶段的功能理清楚之前很容易陷入过度优化,反而会带来麻烦)。但对于新接触开发的朋友来说,我能够想象出当你看到从自己指尖一行一行敲打出来的代码跃然于屏幕之上的那种喜悦,由衷地为你能够抽出时间经历这样一段有趣的体验而高兴!

下一节中,我们将会一起对过去几节的开发经历做一次总的回顾,对产品的一些小细节做必要的打磨 ~ 以及使用一个我为你准备的秘密科技让我们的应用变得更加 Sexy 😉 满意之后,我们将它发布出去,收获慕名而来的第一波用户!

by-nc-sa-4.0.png
This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.