我们是如何从零到一搭建直播小程序的

一、背景

我们的直播技术是使用的zego的sdk,其实我们和zego对于小程序直播场景是共同成长的,我们帮他们解决了很多问题,发现了很多问题,提出了很多问题,同样他们也帮我们解决了很多问题与提供了很多方案,因为小程序推拉多路流比较复杂,加上没有太多可参考的文章,所以整理出来分享给大家。

二、项目介绍

我们的项目是基于react-hooks + redux+ ts + Taro + Taro-UI搭建的,是一款视频相亲类的微信小程序。

三、技术架构

技术基础

  • webpack的配置(映客脚手架自带)
  • 样式使用scss
  • Typescript的配置
  • 项目的路由的设计和配置
  • 项目目录结构的设计和配置
  • 项目通用的组件封装
  • 项目的分包处理
  • 针对生产环境做的包体积分析
  • eslint加prettier搭配使用,解决每个人编码方式不同导致的冲突,并且设置保存时自动格式化

开发流程

通过dotEnv自定义IK_ENV,通过.env文件定义,来区分该使用不同环境的各种变量,如下:




git commit校验,规范代码提交
"gitHooks": {
  "pre-commit": "lint-staged",
  "commit-msg": "node .bin/verify-commit-msg.js"
},
"lint-staged": {
  "*.{ts, tsx}": [
    "prettier --config --write"
  ]
},
const chalk = require('chalk')
const msgPath = process.env.GIT_PARAMS
const msg = require('fs').readFileSync(msgPath, 'utf-8').trim()

const commitRE =
  /^(revert: )?(feat|fix|polish|docs|style|refactor|perf|test|workflow|ci|chore|types|build|tool|comment|rewrite|Merge|Revert)(\(.+\))?: .{1,50}|Merge|Revert /

if (!commitRE.test(msg)) {
  console.error(
    `  ${chalk.bgRed.white(' ERROR ')} ${chalk.red(`invalid commit message format.`)}\n\n` +
      chalk.red(`  Proper commit message format is required for automated changelog generation. Examples:\n\n`) +
      `    ${chalk.green(`feat: add 'comments' option`)}\n` +
      `    ${chalk.green(`fix: handle events on blur (close #28)`)}\n\n` +
      chalk.red(`  You can also use ${chalk.cyan(`npm run commit`)} to interactively generate a commit message.\n`)
  )
  process.exit(1)
}

如果不按照规则提交则会提交失败并提示该如何提交

也可以运行npm run commit

"commit": "git-cz"


ts写法定义一个网络请求,能给严格要求请求参数与返回值,防止开发人员代码错误
api.ts
import { serviceFetch } from 'config/api-roots'
export interface resData<T = any> {
  data: T
  error_msg: string
  dm_error: number
}
/**
 * 获取关注状态接口返回值
 */
interface getFollowStatusApiRes {
  /**
   * 关注状态,0为未关注,1已关注
   */
  follow_status: number
  /**
   * 关注公众号状态,0为未关注,1为已关注
   */
  subscribe_status: number
  /**
   * 是否展示关注按钮,1展示,0不展示
   */
  is_show_follow: number
  /**
   * 头像框
   */
  resource_frame_img: string
}
/**
 * 获取关注状态接口
 */
export const getFollowStatusApi: (params: { target_uid?: number }) => resData<getFollowStatusApiRes> = serviceFetch({
  url: '/api/relation/follow/get_follow_status',
  method: 'POST',
  errorTips: true
})

如果写错了,则会提示,取返回字段也能精准提示



构建上线时的处理

传统的上线人工成本太高,因为需要人为处理的事情太多,对此我们写了一个脚本来将这一系列流程串起来。
1、自动升级项目版本号
2、自动生成changlog
3、自动将打包出来的包上传至微信小程序后台
4、自动发送合并请求
5、自动发送钉钉通知(构建人,构建环境,构建分支,构建版本)
6、因为微信审核有限制,有些功能在提交审核时不能展示出来,所以需要让产品/运营人员更改与检查审核版本号,通过钉钉@功能实现

build脚本
const ora = require('ora')
const { exec, execSync } = require('child_process')
const inquirer = require('inquirer')
let colors = require('colors')
const ChatBot = require('dingtalk-robot-sender')
const spinner = ora('正在构建...')
const ci = require('miniprogram-ci')
const robot = new ChatBot({
  webhook:
    'https://oapi.dingtalk.com/robot/send?access_token=xxx'
})
spinner.color = 'yellow'
colors.setTheme({
  silly: 'rainbow',
  input: 'grey',
  verbose: 'cyan',
  prompt: 'grey',
  info: 'green',
  data: 'grey',
  help: 'cyan',
  warn: 'yellow',
  debug: 'blue',
  error: 'red'
})
const verMap = {
  '补丁版本递增0.0.1': 'patch',
  '小版本递增0.1.0': 'minor',
  '大版本递增1.0.0': 'major'
}
let selectVersion = ''
const openUrl = 'https://code.inke.cn/xxx'
/**
 * 获取提交人
 */
const lastCommitAuthor = execSync(`git config user.name`).toString().trim()
/**
 * 获取当前分支
 */
const currentBranch = execSync(`git symbolic-ref --short -q HEAD`).toString().trim()
/**
 * git diff
 */
function gitDiff(option, tips) {
  const diff = execSync(`git diff ${option}`).toString().trim() // 获取新增文件
  const arrDiff = diff.split('\n').filter(Boolean)
  if (arrDiff.length) {
    console.log(
      `📛 当前分支 ${currentBranch} 有${tips},请提交后再执行该命令`.error,
      `📦 ${tips}文件如下: ${arrDiff}`.error
    )
    process.exit(0)
  }
}
/**
 * 检查当前分支是否还有未提交代码
 */
function checkUnCommitedCode() {
  gitDiff('--name-only', '未提交的代码') // diff 是否有新增文件
  gitDiff('--name-only --cached', '未提交的代码') // diff 已添加到缓存区的文件
}
async function buildEnv() {
  checkUnCommitedCode()
  const { buildEnv } = await inquirer.prompt([
    {
      type: 'list',
      name: 'buildEnv',
      message: '请选择构建环境',
      choices: ['test', 'beta', 'online']
    }
  ])
  if (buildEnv === 'online') {
    try {
      execSync(`bash .bin/check.sh`)
    } catch (e) {
      console.log(`有未合并的请求,请先完成合并请求`.red, e.status)
      robot.text('存在未合并的请求,打包失败,请先合并', {
        atMobiles: ['18310536322'],
        isAtAll: false
      })
      process.exit(1)
    }
    const { version } = await inquirer.prompt({
      type: 'list',
      name: 'version',
      default: '补丁版本递增0.0.1',
      message: '选择构建版本类型',
      choices: ['none', '补丁版本递增0.0.1', '小版本递增0.1.0', '大版本递增1.0.0']
    })
    selectVersion = version
    if (version !== 'none') {
      try {
        execSync(`npm version ${verMap[version]} --no-git-tag`)
        console.log(`更新版本号完成`.info)
        execSync('npm run changelog')
        console.log(`生成changelog完成`.info)
      } catch (error) {
        console.log(`更新版本号失败${error}`.error)
      }
    }
  }
  spinner.start()
  exec(`npm run build:weapp:${buildEnv}`, (e, sdtout, sdterr) => {
    const { version: currentVersion } = require('../package.json')
    let noMergeDingContent = `
              打包完成✅
              构建人: ${lastCommitAuthor}
              构建版本: ${currentVersion}
              构建环境: ${buildEnv}
              构建分支: ${currentBranch}
              该版本已自动上传,请在小程序后台选为体验版
            `
    let hasMergeDingContent = `
              打包完成✅
              构建人: ${lastCommitAuthor}
              构建版本: ${currentVersion}
              构建环境: ${buildEnv}
              构建分支: ${currentBranch}
              合并地址: ${openUrl}
              合并请求已创建,辛苦大佬合并~
            `
    spinner.stop()
    if (e) {
      console.log(`构建失败:${e}`.error)
    } else {
      console.log(sdtout)
      console.log(sdterr)
      let content = noMergeDingContent
      let at = {}
      if (buildEnv === 'online') {
        try {
          if (selectVersion !== 'none') {
            content = hasMergeDingContent
            at = {
              atMobiles: ['18310536322'],
              isAtAll: false
            }
          } else {
            content = noMergeDingContent
          }
          if (selectVersion !== 'none') {
            try {
              execSync(`bash .bin/mr.sh`)
              console.log(`创建合并请求完成`.info)
            } catch (e) {
              console.log(`创建合并请求失败`.red, e.status)
            }
          }
        } catch (error) {
          if (selectVersion !== 'none') {
            console.log(`创建合并请求失败${error}`.error)
          }
        }
      }
      robot.text(content, at).then(async res => {
        if (!res.data.errcode) {
          console.log(`钉钉通知完成`.info)
          const project = new ci.Project({
            appid: 'xxxx',
            type: 'miniProgram',
            projectPath: 'build/weapp',
            privateKeyPath: '.bin/private.xxxx.key',
            ignores: ['node_modules/**/*']
          })
          const uploadResult = await ci.upload({
            project,
            version: currentVersion,
            desc: `构建人:${lastCommitAuthor}`,
            setting: {
              es6: false,
              minifyJS: true,
              minifyWXML: true
            },
            onProgressUpdate: console.log
          })
          console.log('自动上传已完成'.green)
        } else {
          console.log(`钉钉通知失败${res.data.errmsg}`.error)
        }
      })
    }
  })
}
buildEnv()
mr.sh
#!/usr/bin

# 提交MR

PROJECT_ID=xxx
PRIVATE_TOKEN="xxx"
# CURRENT_BRANCH=`git symbolic-ref --short -q HEAD`
CURRENT_BRANCH=`git rev-parse HEAD`

echo ${CURRENT_BRANCH}
TIME=$(date "+%Y%m%d%H%M%S")
NEW_BRANCH="TaroBuild-${TIME}"
GITLAB_HOST="https://code.inke.cn"

# 创建分支
curl --request POST --header "PRIVATE-TOKEN: ${PRIVATE_TOKEN}" "${GITLAB_HOST}/api/v4/projects/${PROJECT_ID}/repository/branches?branch=${NEW_BRANCH}&ref=${CURRENT_BRANCH}"
echo "\n提交MR: 添加「${NEW_BRANCH}」分支完成!"

# MR data
DATA="{\"id\":\"${PROJECT_ID}\",\"source_branch\":\"${NEW_BRANCH}\",\"target_branch\":\"master\",\"private_token\":\"${PRIVATE_TOKEN}\",\"title\":\"Script Build Auto Submit Merge Resquest\",\"description\":\"构建自动提交merge request\"}"
# 创建MR
curl -X POST -d "${DATA}" -H "Content-Type: application/json, PRIVATE-TOKEN: ${PRIVATE_TOKEN}" "${GITLAB_HOST}/api/v4/projects/${PROJECT_ID}/merge_requests"
echo "\n提交MR: 提交MR完成!"
check.sh
#!/usr/bin

PRIVATE_TOKEN="xxx"
GITLAB_HOST="https://code.gitlab.cn"
PROJECT_ID=xxx

MR_LIST=`curl -X GET --header "PRIVATE-TOKEN: ${PRIVATE_TOKEN}" "${GITLAB_HOST}/api/v4/projects/${PROJECT_ID}/merge_requests?state=opened"`
if [ ${MR_LIST} != '[]' ];then
  echo '存在未合并的请求'
  exit 1
else
  echo '继续执行'
fi


自动生成的changelog,每期做了什么一目了然


通过脚本自动上传的构建包

我们和产品同学与测试同学达成了一个约定的模式,每次上线与测试开发只管运行命令打出来并自动上传至小程序后台,其他的比如体验版小程序的切换、小程序的提审、审核通过后小程序的分批发布与全量发布等能在后台操作的事情全都由她们根据自己的需要去做。
这样我们的开发同学只需要运行一条yarn build命令,其他的什么都不用管了,大大的提高了效率。不然分工就会不明确,全都得由开发同学去控制。




如果选择线上环境,则会询问要构建的版本,默认递增0.0.1

问题和解决方案

因为微信小程序有着严格的包体积限制,打包上传时主包体积不能超过2M,开发环境预览和真机调试时主包体积不能超过4M,所以我们需要对包体积做一些处理,对此我们做了以下操作:
1、分包(主包放首页的业务,其他的都放到分包中,分包可以分多个,按业务模块进行分包,大家都知道就不多说了)
2、模块与npm包的按需加载,比如taro-ui、lodash等
3、开发环境开启代码压缩
4、如果超过了包体积限制,则开启包体积分析插件,看看哪些地方还需要优化,如下图

四、业务架构

我们业务中对于其他小程序需要说的主要还是音视频与IM,其他的我个人觉得倒是没必要详细说,但有一些有用的和一些坑点还是可以分享给大家。

小程序直播


房间内音视频与消息架构

#####(1)进房流程

  • 进入房间之前
    封装成公用方法,通过点击首页卡片调用,点击别人分享的小程序卡片调用,点击别人资料页直播中状态头像调用,大厅中同意邀请时调用,点击房间飘屏时调用等等。
    1、先调用服务端加入房间接口
    2、判断自己是否在推流,推流中不允许跳转
    3、判断要进入的房间类型
    4、实现跳转对应房间并且把服务端返回的房间信息与是否邀请等参数带到房间中

  • 进入房间之后
    以下流信息是指流id与流的附加信息。

    将服务端返回的live_id作为房间号,加入zego房间、加入融云房间,将房间中使用到的信息与用户角色等全都放在redux中,可供各个组件使用。

    对于推流端,需要将推流人的信息放到流的额外信息中,但zego只支持传字符串,所以我们需要把要传的参数做一下字符串处理

    将流相关的信息全都组装在一起,比如麦位信息、流id、麦克风静音状态、是否是免费上麦、用户性别等,通过传参放到每个我们自定义的流组件中

    收到断线重连时,需要先停止推流,然后重新开始推流,重试三次不成功,则提示。

    点击静音时,需要给要静音的人发送消息,然后目标人收到消息时去将自己的麦克风关掉,并且更新自己的流信息

    上麦之前校验是否开启音视频权限

    接收到流变化之后对麦位上的流进行对应的新增和删除

    收到zego房间断开连接状态时,需要对比断开的房间与当前房间是否一致(坑点)

  • 如何决定是推流还是拉流
    我们的业务最多能有七路流,位置信息是根据服务端返回的,是根据身份来判断是否推流,我们目前有四种,
    1房主(一直推流)、2麦上嘉宾(推流,下麦时停止推流并切换身份为3)、3麦下观众(拉流)、4隐身人督导(拉流)
    每一个麦位都是放的我们自己封装的组件,组件内根据传入的身份自己判断显示推流组件和拉流组件

    推流组件是用的小程序自带的组件,拉流组件是用的小程序自带的组件,开通需要类目审核

  • 退出房间
    将房间相关的redux全部重新初始化
    清空所有定时器
    调用服务端退出房间接口
    销毁所有监听事件
    退出融云房间与zego房间

(2)异常场景处理

关于切后台的情况,我们整理了如下表现

  • 推流情况下,当切后台超过90s,zego的服务端(心跳时间90s)会将流删除,回到前台的时候我们需要暂停推流同时调用小程序组件的停止推流,然后重新zego推流加小程序组件推流,注意需要zego和推流组件同时停止与开始。

  • 推流情况下,切后台90s内回来,则不需要做其他操作,zego会自动重连

  • 拉流情况下,对于切后台不需要做任何处理,相对稳定,只要有流过来就能展示

  • 如果因为网络问题,zego连接中状态和已连接状态相差90s以上5分钟以内,则需要重新执行上述关流推流,5分钟一直都是连接中状态,zego会自动走到断开连接回掉。

  • 如果房间异常断开或者网络异常登录超时,则需要重新加入zego房间,然后判断自己之前是否在麦,在麦执行用户重新推流逻辑,重推3次依然失败,则退出房间

(3)性能优化
  • 对于流:因为是多路流,所以比较吃性能和网络,可以将推流组件的maxBitrate参数设置的小一些,分辨率也可以小一些,我们这边设置的最大码率为七人间150,三人间300,宽高比设置为3:4,视频宽高为180*240。可能大家看这个参数觉得会糊,请大家放心,我们经过实践,这样不会看着特别模糊。

  • 对于网络请求:wx.request发起的耗时超过 300ms 的请求并发数不超过 10 个,调用时需要节流处理

  • 对于节点数:页面内显示超过1000个节点会白屏,需要合理处理,比如对于列表使用虚拟滚动

  • 对于连续送礼场景等,能够连续收到消息不断更新redux的,则先存到队列中,将数据批量传给redux

  • 对于同时要更改多个redux的namespace中内容的,可以使用redux-batch进行整合更改,而不是调用多条

  • 代码编写时最好使用usecallback与usememo,对于更新频繁的,要严格使用usecallback或者shouldComponentUpdate等进行对比,避免多次渲染

  • 对于需要加载的不会变化的静态资源,比如游戏的动画文件,可以首次下载下来,放到用户本地,后面直接从本地获取

  • 大文件预加载,但要注意取舍,全都放在首页会占用主包体积。能上cdn的尽量上cdn~

(4)问题与解决方案
  • live-pusher推流组件中的autopush属性需要设置为false,因为都是坑,开发中会遇到-1301的错误码,是小程序的兼容性bug。顺便说下-1301的解决方案

    1. 在有问题的机器上面,将 autopush 设置为 false,避免自动推流;
    2. 在开始推流前,设置摄像头和麦克风为关闭状态
    3. 拿到实际可推流的 url 后,先开启摄像头、麦克风,再启动推流
    4. 在推流的组件 bindstatechange 绑定的 onPushStateChange 事件回调 code 为 -1307 -1301 等错误是,停止推流、 重新推流
  • 使用zego时,他们提供两种接入方式,需要选择旧版本,因为新版本不支持Taro。

  • 如何使直播切换了页面还能听到声音:需要将LivePlayer组件的autoPauseIfNavigate和autoPauseIfOpenNavigate属性设置为false

  • 静音失效问题:需要将livePusher实例的setMICVolume方法的volume字段设置为0,livepush组件的属性的enableMic有时会不生效

  • 在小程序端,用户的手机左滑退出我们无法控制,所以可以在页面组件销毁时认为他退出了房间

小程序美颜

使用的是小程序自带的推流组件美颜,在房间外选择完美白滤镜后带入到房间内的推流组件中。遇到黑屏的情况,需要先停止预览再启动预览

小程序支付


我们使用的v2版本,signType选择的‘MD5’,其实这里调试还是比较费时间的,但主要还是和后端联调时后端返回的参数有问题。小程序的支付相比于公众号的支付,前端要简单的多。
前端需要将用户的wx.login的code码传给后端,后端可以去换取openid,noncestr、sign、prepayid等参数返回给前端,前端去调用wx.requestPayment方法,将上面服务端返回的参数传给微信,就能够成功调起微信支付,成功与失败都有相关回掉。

小程序分享


对于分享,比较复杂的就是分享进房,如果被分享的用户是登录过的,则不需要做太多处理,但真实场景往往是未登录的情况居多,所以需要先跳转登录,开始我们的方案是将参数带到登录页,但随着业务越来越复杂,我们放弃了这种方式,采用将参数直接放到redux中。但是思想都一样,就是要把参数记录下来。

小程序动画

小程序中播放动画是使用canvas进行播放,使用spine格式和svga格式两种动画,推荐spine格式,但仍然需要对旧的svga格式的礼物动画做兼容

  • 对于svga格式的礼物,使用svgaplayer-weapp这个库,如果遇到动画播放慢的问题,则需要将文件提前加载。

  • 对于spine格式的礼物,因为社区上对于spine格式动画解决方案很少,所以我们自己封装了支持spine动画的库。
    在使用过程中我们发现了一个问题,就是动画无法播放时,小程序canvas 2d 的画布有 4096 大小限制。播放一些礼物的spine动画时,礼物过大,需要将礼物尺寸除2,然后赋值给canvas。

  • 播放动画音效的坑:对于小程序ios的静音模式createInnerAudioContext声音不播放问题,需要全局把wx.setInnerAudioOption的obeyMuteSwitch设置为false,这样即使是在静音模式下,也能播放声音。

小程序升级

我们目前全都是采用的强制升级,只有确定按钮,点击确定就会立刻进行重启并升级。代码如下

更新小程序代码
/*
 * @Date: 2022-05-30 15:27:36
 * @LastEditors: 王大伟
 * @LastEditTime: 2022-06-15 22:00:21
 */
import Taro from '@tarojs/taro'
export const updateWeapp = (isForce?: boolean): void => {
  function tipModal(updateManager) {
    Taro.showModal({
      title: '更新提示',
      content: '新版本已经准备好,是否马上重启小程序?',
      showCancel: false,
      success: function (res) {
        if (res.confirm) {
          // 新的版本已经下载好,调用 applyUpdate 应用新版本并重启
          updateManager.applyUpdate()
        } else {
          if (isForce) {
            // 新版本的小程序需要等下一次冷启动才会使用,所以让用户强制更新
            tipModal(updateManager)
          }
        }
      }
    })
  }
  if (process.env.TARO_ENV === 'weapp') {
    const updateManager = Taro.getUpdateManager()
    updateManager.onCheckForUpdate(function (res) {
      // 请求完新版本信息的回调
      console.log('是否有新版本: ', res)
      if (res.hasUpdate) {
        updateManager.onUpdateReady(function () {
          console.log('准备好更新')
          tipModal(updateManager)
        })

        updateManager.onUpdateFailed(function () {
          // 新的版本下载失败
          console.log('新的版本下载失败,走自动异步更新')
        })
      }
    })
  }
}

小程序webview

配置与公众号。小程序中加载webview,只能全凭加载,而且网页的域名需要在小程序后台配置成业务域名,有一种情况是不需要的,就是加载与此小程序相关联的公众号页面时,这需要去公众号的后台去配置关联小程序。

小程序图片

低端机型不支持webp,需要对图片做处理,检测到不支持webp格式则展示jpg格式图片,不然无法加载,检测是否支持webp格式代码如下

webp图片支持检测
/**
 * webp图片支持检测
 * @returns support 是否支持webp图片
 */
function detectWebp() {
  if (_webpSupport === undefined) {
    let support = false

    try {
      let _wx$getSystemInfoSync = wx.getSystemInfoSync(),
        platform = _wx$getSystemInfoSync.platform,
        system = _wx$getSystemInfoSync.system
      // 检测IOS系统webp支持
      let versionResult = /[0-9.]*$/.exec(system)
      let systemVersion = versionResult ? versionResult[0] : ''
      let iosSystemSupport = platform === 'ios' && !!systemVersion && compareVersion(systemVersion, '14.0') >= 0

      support = platform === 'devtools' || platform === 'android' || iosSystemSupport

      _webpSupport = support
    } catch (e) {
      console.log(e)
      _webpSupport = false
    }
  }
  return _webpSupport
}
function compareVersion(v1, v2) {
  v1 = v1.split('.')
  v2 = v2.split('.')
  const len = Math.max(v1.length, v2.length)
  while (v1.length < len) {
    v1.push('0')
  }
  while (v2.length < len) {
    v2.push('0')
  }
  for (let i = 0; i < len; i++) {
    const num1 = parseInt(v1[i])
    const num2 = parseInt(v2[i])
    if (num1 > num2) {
      return 1
    } else if (num1 < num2) {
      return -1
    }
  }
  return 0
}