返回笔记首页

7.3 小程序高级功能 - 实战技巧精选

主题配置

简历描述模板

版本一:注重功能实现

plain
负责小程序高级功能开发,封装了20+个通用组件覆盖90%业务场景。自定义导航栏支持渐变
色、毛玻璃、沉浸式等10+种效果,TabBar实现角标、红点、动画等高级特性。开发分享海
报生成器,支持多层图文混排、二维码嵌入、一键保存分享,日均生成海报10万+张。接入
实时音视频能力,实现1对1视频通话和多人会议,音视频连接成功率99.5%。开发蓝牙设备
管理模块,支持自动扫描、连接、数据传输,适配50+种蓝牙设备。

版本二:强调技术深度

plain
主导小程序组件化架构设计,建立组件开发规范和最佳实践。深入研究小程序组件通信机制,
封装了事件总线、props/emits、provide/inject等多种通信方案。自定义导航栏使用
getMenuButtonBoundingClientRect精确适配胶囊按钮位置,支持iOS/Android差异化处理。
分享海报采用Canvas 2D API绘制,实现了文字自动换行、图片裁剪、渐变背景等复杂效果。
实时音视频基于live-pusher和live-player组件,优化了延迟、卡顿、回声等问题。蓝牙通
信实现了数据分包、校验、重传等可靠传输机制。

版本三:突出业务价值

plain
作为小程序技术专家,推动高级功能落地和业务增长。自定义导航栏方案使品牌识别度提升
40%,用户对界面的好评率从68%提升至85%。分享海报功能上线后,分享转化率提升3倍,
新用户获取成本降低60%。实时音视频能力支撑在线咨询业务,用户满意度达92%,客服效
率提升50%。蓝牙功能拓展了智能硬件场景,设备连接成功率99%,用户投诉率下降80%。组
件库复用率达85%,新功能开发周期缩短40%。

SOP 标准回答

Q1: 自定义导航栏是怎么实现的?

标准回答

plain
小程序默认的导航栏样式固定,不能满足设计需求。我们需要沉浸式导航栏、渐变色背景,
只能自定义。

实现自定义导航栏主要有三步:

第一步是在app.json里设置"navigationStyle": "custom",隐藏默认导航栏。这样整个页
面就是全屏的了,包括状态栏区域。

第二步是获取系统信息,计算导航栏高度。调用wx.getSystemInfo拿到statusBarHeight(状
态栏高度),然后调用wx.getMenuButtonBoundingClientRect拿到右上角胶囊按钮的位置信
息。导航栏高度 = 胶囊bottom + 胶囊height - statusBarHeight。

第三步是开发导航栏组件。在页面顶部放一个自定义组件,高度设置成刚才计算的值。左边
是返回按钮和标题,右边留出胶囊按钮的位置。状态栏区域设置成透明或者跟导航栏同色。

有几个坑要注意:

一是iOS和Android的胶囊按钮位置不一样。iOS的胶囊按钮靠右,Android的居中偏右。我用
胶囊按钮的right值判断,动态调整导航栏布局。

二是刘海屏适配。iPhone X这些刘海屏,状态栏高度是44px,普通屏幕是20px。我用
statusBarHeight动态设置顶部padding,确保内容不被刘海遮挡。

三是返回逻辑。自定义导航栏后,默认的返回按钮没了,要自己实现。我监听返回按钮点击,
判断页面栈深度,如果只有1层就switchTab到首页,否则navigateBack。

四是标题颜色。iOS状态栏文字颜色跟随navigationBarTextStyle,但自定义导航栏后这个配
置失效了。我根据导航栏背景色深浅,动态设置标题和返回按钮的颜色,深色背景用白色文
字,浅色背景用黑色文字。

这个自定义导航栏做好后,可以实现各种酷炫效果。渐变色背景、毛玻璃效果、滚动渐变、
沉浸式布局,产品经理特别满意。用户反馈界面更漂亮了,品牌感更强了。

Q2: 分享海报是怎么生成的?

标准回答

plain
分享海报功能很常见,用户点击分享按钮,生成一张海报图片,保存到相册,分享到朋友圈。
我们用Canvas来实现。

具体流程是这样的:

第一步是创建Canvas。在wxml里放一个canvas组件,设置type="2d"启用新版Canvas API。
设置宽高比如750x1334,跟设计稿一致。

第二步是绘制背景。用canvas的drawImage方法绘制背景图。如果是渐变背景,用
createLinearGradient创建渐变对象,填充矩形。

第三步是绘制内容。按设计稿从上到下绘制各个元素:头像、昵称、商品图、商品名称、价
格、二维码。用drawImage绘制图片,fillText绘制文字。

文字绘制有个坑,Canvas不支持自动换行,长文本会超出边界。我写了个函数,根据Canvas
宽度和字体大小,自动计算每行能放多少字,手动分行绘制。

图片要先下载到本地。调用wx.getImageInfo下载网络图片,拿到本地临时路径,再用
drawImage绘制。如果图片加载失败,用默认图兜底。

二维码用wx.request调用后端接口生成,返回base64或者URL,下载后绘制到Canvas上。位
置通常是右下角,大小根据设计稿调整。

第四步是导出图片。调用canvas.toTempFilePath把Canvas内容导出成临时文件,拿到文件
路径。然后调用wx.saveImageToPhotosAlbum保存到相册,或者用wx.previewImage预览。

第五步是性能优化。Canvas绘制是个重操作,我做了几个优化:一是绘制前显示loading,
防止用户等待。二是绘制完成后缓存图片路径,下次直接用缓存,不用重新绘制。三是把绘
制逻辑放到Web Worker里,不阻塞主线程。

还有个细节是清晰度。直接绘制的图片在高清屏上会模糊。我把Canvas宽高乘以dpr(设备
像素比),比如在iPhone X上宽高乘以3,绘制后再缩放回原大小,这样图片就清晰了。

这个海报生成功能上线后效果很好,分享转化率提升了3倍,用户说海报很漂亮,愿意分享。
我们还做了个海报模板库,运营可以在后台配置海报样式,不用改代码,灵活性很强。

难点与亮点分析

难点一:自定义导航栏完整实现

javascript
// components/navbar/navbar.js

Component({
  properties: {
    title: {
      type: String,
      value: ''
    },
    bgColor: {
      type: String,
      value: '#ffffff'
    },
    textColor: {
      type: String,
      value: '#000000'
    },
    showBack: {
      type: Boolean,
      value: true
    }
  },

  data: {
    statusBarHeight: 0,
    navBarHeight: 0,
    menuButtonInfo: null
  },

  lifetimes: {
    attached() {
      this.setNavBarInfo()
    }
  },

  methods: {
    setNavBarInfo() {
      const systemInfo = wx.getSystemInfoSync()
      const menuButtonInfo = wx.getMenuButtonBoundingClientRect()

      const statusBarHeight = systemInfo.statusBarHeight
      const navBarHeight = (menuButtonInfo.top - statusBarHeight) * 2 + menuButtonInfo.height

      this.setData({
        statusBarHeight,
        navBarHeight,
        menuButtonInfo
      })
    },

    handleBack() {
      const pages = getCurrentPages()

      if (pages.length === 1) {
        wx.switchTab({
          url: '/pages/index/index'
        })
      } else {
        wx.navigateBack()
      }
    }
  }
})
xml
<!-- components/navbar/navbar.wxml -->
<view class="navbar" style="background-color: {{bgColor}}">
  <view class="navbar-status" style="height: {{statusBarHeight}}px"></view>

  <view class="navbar-content" style="height: {{navBarHeight}}px">
    <view wx:if="{{showBack}}" class="navbar-back" bindtap="handleBack">
      <text class="icon-back" style="color: {{textColor}}">返回</text>
    </view>

    <view class="navbar-title">
      <text style="color: {{textColor}}">{{title}}</text>
    </view>

    <view class="navbar-placeholder" style="width: {{menuButtonInfo.width}}px"></view>
  </view>
</view>

难点二:Canvas海报生成器

javascript
// utils/posterGenerator.js

class PosterGenerator {
  constructor(canvas, config) {
    this.canvas = canvas
    this.ctx = canvas.getContext('2d')
    this.config = config
    this.dpr = wx.getSystemInfoSync().pixelRatio
  }

  async generate() {
    const { width, height } = this.config

    // 设置Canvas尺寸(考虑dpr)
    this.canvas.width = width * this.dpr
    this.canvas.height = height * this.dpr
    this.ctx.scale(this.dpr, this.dpr)

    // 绘制背景
    await this.drawBackground()

    // 绘制内容
    await this.drawContent()

    // 导出图片
    return this.exportImage()
  }

  async drawBackground() {
    const { background } = this.config

    if (background.type === 'image') {
      const image = await this.loadImage(background.url)
      this.ctx.drawImage(image, 0, 0, this.config.width, this.config.height)
    } else if (background.type === 'gradient') {
      const gradient = this.ctx.createLinearGradient(0, 0, 0, this.config.height)
      background.colors.forEach((color, index) => {
        gradient.addColorStop(index / (background.colors.length - 1), color)
      })
      this.ctx.fillStyle = gradient
      this.ctx.fillRect(0, 0, this.config.width, this.config.height)
    }
  }

  async drawContent() {
    const { elements } = this.config

    for (const element of elements) {
      await this.drawElement(element)
    }
  }

  async drawElement(element) {
    const { type, x, y, width, height } = element

    if (type === 'image') {
      const image = await this.loadImage(element.url)

      if (element.circle) {
        this.drawCircleImage(image, x, y, width)
      } else {
        this.ctx.drawImage(image, x, y, width, height)
      }
    } else if (type === 'text') {
      this.drawText(element)
    } else if (type === 'qrcode') {
      await this.drawQRCode(element)
    }
  }

  drawCircleImage(image, x, y, size) {
    this.ctx.save()
    this.ctx.beginPath()
    this.ctx.arc(x + size / 2, y + size / 2, size / 2, 0, Math.PI * 2)
    this.ctx.closePath()
    this.ctx.clip()
    this.ctx.drawImage(image, x, y, size, size)
    this.ctx.restore()
  }

  drawText(element) {
    const { x, y, text, fontSize, color, maxWidth, lineHeight } = element

    this.ctx.font = `${fontSize}px sans-serif`
    this.ctx.fillStyle = color

    if (maxWidth) {
      const lines = this.wrapText(text, maxWidth, fontSize)
      lines.forEach((line, index) => {
        this.ctx.fillText(line, x, y + index * lineHeight)
      })
    } else {
      this.ctx.fillText(text, x, y)
    }
  }

  wrapText(text, maxWidth, fontSize) {
    const lines = []
    let currentLine = ''

    for (const char of text) {
      const testLine = currentLine + char
      const metrics = this.ctx.measureText(testLine)

      if (metrics.width > maxWidth) {
        lines.push(currentLine)
        currentLine = char
      } else {
        currentLine = testLine
      }
    }

    if (currentLine) {
      lines.push(currentLine)
    }

    return lines
  }

  async drawQRCode(element) {
    // 生成二维码(调用后端接口或使用第三方库)
    const qrcodeUrl = await this.generateQRCode(element.content)
    const image = await this.loadImage(qrcodeUrl)
    this.ctx.drawImage(image, element.x, element.y, element.size, element.size)
  }

  async generateQRCode(content) {
    // 调用后端接口生成二维码
    const res = await wx.request({
      url: '/api/qrcode/generate',
      method: 'POST',
      data: { content }
    })
    return res.data.url
  }

  loadImage(url) {
    return new Promise((resolve, reject) => {
      const image = this.canvas.createImage()
      image.onload = () => resolve(image)
      image.onerror = reject
      image.src = url
    })
  }

  exportImage() {
    return new Promise((resolve, reject) => {
      wx.canvasToTempFilePath({
        canvas: this.canvas,
        success: (res) => resolve(res.tempFilePath),
        fail: reject
      })
    })
  }
}

export default PosterGenerator

真实项目经验

经验一:自定义导航栏适配

plain
我们产品要做个沉浸式的导航栏,背景色要跟页面内容融合,还要支持渐变。小程序默认导
航栏做不了,只能自定义。

第一步是把默认导航栏隐藏,在app.json设置"navigationStyle": "custom"。这样页面就
全屏了,包括状态栏区域都可以自定义。

然后遇到第一个问题,怎么知道导航栏应该多高?不同机型状态栏高度不一样,iPhone X是
44px,普通手机是20px。我调用wx.getSystemInfo拿到statusBarHeight,这个是状态栏高度。

但光有状态栏高度还不够,还要知道导航栏内容区的高度。我发现右上角胶囊按钮是固定的,
可以用它来定位。调用wx.getMenuButtonBoundingClientRect拿到胶囊按钮的top、height、
bottom。导航栏高度算法是:(胶囊bottom - 状态栏高度) * 2 + 胶囊height。这个公式在
各种机型上都准确。

第二个问题是iOS和Android胶囊按钮位置不一样。iOS的胶囊靠右,Android的偏中间一点。
我用胶囊的right值判断,iOS的right比较大,Android的小一些。根据这个动态调整导航栏
标题的位置,确保不会被胶囊遮挡。

第三个问题是返回逻辑。自定义导航栏后,默认的返回按钮没了,要自己实现。我监听返回
按钮点击,用getCurrentPages()拿到页面栈,如果只有1层说明没有上一页,就switchTab
到首页。否则navigateBack返回上一页。

还有个细节是状态栏文字颜色。iOS的状态栏文字颜色跟随navigationBarTextStyle配置,
但自定义导航栏后这个失效了。我根据导航栏背景色的亮度,自动选择黑色或白色文字。背
景色深用白色,背景色浅用黑色,保证文字清晰可见。

这个自定义导航栏做完后,设计师特别满意,说终于可以做出漂亮的沉浸式界面了。用户反
馈界面更精致了,品牌感更强了。

面试常见追问

Q: 自定义导航栏如何处理安全区域? A: 主要是statusBarHeight状态栏高度。在导航栏顶部加一个view,高度设置为statusBarHeight,确保内容不会被刘海或状态栏遮挡。iPhone X这些刘海屏,statusBarHeight是44px,普通屏幕是20px,API会自动返回正确的值。

Q: Canvas绘制海报如何保证清晰度? A: Canvas宽高要乘以设备像素比(dpr)。比如设计稿是750x1334,在iPhone X(dpr=3)上,Canvas实际宽高应该是2250x4002。绘制完成后缩放回原大小,这样图片在高清屏上就清晰了。还要注意图片资源本身要用高清图,至少@2x。

Q: 分享海报生成慢怎么优化? A: 几个方向。一是缓存,生成过的海报保存路径,下次直接用。二是图片预加载,页面onLoad时就开始下载图片,不要等用户点分享才下载。三是异步绘制,Canvas绘制放到Web Worker或者setData之后,不阻塞主线程。四是简化设计,减少绘制元素数量,能用背景图的不用Canvas绘制。

Q: 小程序组件通信有哪些方式? A: 主要四种。一是properties/emit,父子组件通信,跟Vue的props/emit类似。二是selectComponent获取组件实例,直接调用方法。三是全局事件总线,用getApp().eventBus发布订阅。四是全局状态管理,用Pinia或者Vuex。选哪种看场景,父子组件用properties/emit,跨级或兄弟组件用事件总线或状态管理。