2019年7-9月工作总结

直播小程序开发总结

从7月份开始做直播小程序研究和开发,使用了腾讯云的云通信和互动直播的SDK,所以开发难度不算太大,难度主要在控制视频播放和视频录制的切换以及显示各种状态上。和同事一起合作开发,此次开发收获颇多,主要是对微信小程序运行机制有了更多更深入的了解。因为使用了特殊的视频播放和录制组件,所以使用了原生的小程序并未使用框架进行开发。

填坑之路

1. app的生命周期

项目中因为冷启动和热启动状态改变有很多种情况,所以这部分算是踩了不少的坑。通过登录

冷启动:

微信小程序的冷启动会响应 onLaunch生命周期和onShow生命周期,从分享消息进入的冷启动会携带参数。

热启动:

小程序的热启动会响应onShow生命周期,但是热启动分很多种进入场景,比冷启动处理要复杂很多。

(1). 从后台进入前台:onShow中传入的参数是上一次启动携带的参数,和上一次进入的场景值相同。

(2). 从分享消息进入:onShow中传入的参数是分享消息中携带的参数,并且场景值为分享消息进入的场景值。但是从分享消息进入会unload进入后台时显示的页面。因此注意页面unload时做的业务逻辑

这里需要注意的地方是,如果上一次进入小程序是通过分享消息进入则从后台进入前台携带的参数也是和上一次一样,这里就需要做区分。

我的解决方案是,当小程序进入后台时响应页面的onHide生命周期,将进入后台的页面及部分特定值存到app.globalData.hideLastPage数据中,当小程序热启动响应apponShow生命周期,进行判断进入场景,如果是分享消息进入的场景值需要判断onShow传入的参数和hideLastPage中存储的数据进行比较,再做下一步处理。

2.page的生命周期

当上传图片是会触发onHide生命周期和app和页面的onShow生命周期,因此在页面onShow中的业务逻辑,应注意上传图片时可能会执行onShow中的代码。

路由APInavigateTo会响应页面的onHide生命周期,但是reLaunchredirectTo会响应页面的onUnload生命周期,switchTab会关闭其他非tabbar页面。

3.小程序组件的使用

父子组件传值:

父组件通过properties向子组件进行传值,子组件传值只能通过自定义事件向父组件传值this.triggerEvent

4.视频组件的使用

这部分主要是同事开发部分。

5.聊天组件的开发和IM系统(云通信)

这是我主要负责的部分。学习了腾讯官方给的示例写法。

互动直播群与普通聊天群的最大区别:直播互动群无法查看历史消息,不支持删除群成员和设置群管理员(非群主)等,因为性质不同,所以开发具有一定的差异性。

IM系统在全局设置了轮询和长连接,当小程序异常退出或者进入后台时间过长,轮询和长连接中断后,IM系统会判断当前用户掉线。掉线后需要重新进入群组,否则收不到群组消息。

5.1 踩坑预警
  1. 退群:互动直播群也可以进入多个群,会同时响应多个群的群消息,但是后端做的处理是只会记录当前进入的群,不会记录进入后未退出的群,遇到异常情况(如杀死小程序,关机等)无法正常退出群聊,后端也无法响应退群操作,因此IM系统需要对群消息做筛选处理,不然多个群消息会导致混乱。bug记录:最开始只考虑到了在小程序运行过程中的进入多个群后先进行退群再进群操作,没有考虑到异常退出和物理返回键返回最后一个直播间退群操作问题。解决方案:群消息部分进行非当前群消息过滤和用户物理返回键退出进行退群操作。
  2. IM系统的敏感词:IM系统自带敏感词检测,但范围很小,如需要其他特殊敏感词需进一步自定义。IM系统发送消息响应时间有点长,需要确认IM系统发送成功后再在屏幕上显示消息。这个流程会造成时延,时延过长,用户体验不好。
6.wxs的使用,真香

wxs主要是做数据格式化,本项目中需要格式化后端传过来的区域代码转换成城市名称,性别代码(男1女2)转换成文字,还有图片如果是上传到七牛云的图片需要进行压缩和裁剪处理,因为项目的初始用户信息是从微信授权获取的微信用户信息,头像是已经裁剪好的正方形,而用户上传头像没有给用户裁剪头像的功能,所以使用自动裁剪的方式。

注意:wxs只能使用ES5,不支持ES6,而且require只能导入wxs文件

wxs代码实现

var city = require('./city.wxs')

var image = function (src, w, h) {
  var imgSrc = src ? src : ''
  if (imgSrc.indexOf('images.daqinjia.cn') > -1) {
    return imgSrc + '?imageView2/1/w/' + w + '/h/' + h
  }
  return imgSrc
}

var area = function (code) {
  var codeStr = code + ''
  var cityArr = city.city
  var cityStr = ''
  cityArr.map(function(item) {
    var value = codeStr.slice(0, 2)
    if (['11', '12', '31', '50'].indexOf(value) > -1 && value == item.value) { // 直辖市
      cityStr = item.label
    } else if (codeStr.indexOf(item.value) > -1 ) {
      item.children.map(function(city) {
        if (codeStr == city.value) {
          cityStr = city.label
        }
      })
    }
  })
  return cityStr
}

var gender = function(gender) {
  return gender == 1 ? '男' : '女'
}

module.exports = {
  image: image,
  area: area,
  gender: gender
}

wxml中使用

<wxs src="../../utils/format.wxs" module="format" />

<view class="container">
    <view class="avatar-wrapper" data-item="face_url" bind:tap="handleEdit">
        <image class="avatar" mode="aspectFill" src="{{format.image(info.face_url, 140, 140)}}"></image>
    </view>
    <view class="item" data-item="gender" bind:tap="handleEdit">
        <view class="info-title">性别</view>
        <view class="info">
            <text>{{format.gender(info.gender)}}</text>
            <image class="icon-right" src="../../asset/icons/right.png"></image>
        </view>
    </view>
    <view class="item" data-item="area" bind:tap="handleEdit">
        <view class="info-title">地区</view>
        <view class="info">
            <text>{{format.area(info.area)}}</text>
            <image class="icon-right" src="../../asset/icons/right.png"></image>
        </view>
    </view>
</view>

使用wxs之后就不需要监听数据的改变进行格式化数据了,也不需要担心全局数据因为格式化后的被污染。真香预警!

7.<cover-view>的坑

<cover-view>不支持部分样式,如border-topbackground-imageshadow和渐变样式,如果需要设置单边边框需要使用<cover-view>设置height: 1rpx; width: 100%;<cover-image>无法设置mode

8.<picker-view>的坑

设置选中项的特殊样式

<picker-view class="picker" indicator-class="picker-center" value="{{value}}" bind:change="bindChange">
    <picker-view-column>
        <block wx:for="{{age}}" wx:key="*this">
            <view class="picker-item {{ index === value[0] ? 'selected' : '' }}">{{item}}</view>
        </block>
    </picker-view-column>
</picker-view>

设置预设的value值(index),需要在动态设置值之后设置预设值value才有效,否则最开始设置的初始值是无法自动选择的。

initData() {
    const age = []
    for (let i = 18; i <= 88; i++) {
        age.push(i)
    }
    this.setData({
        age
    }, () => {
        this.setData({
            value: [7]
        })
    })
},
9.微信授权

本项目主要使用了用户信息和摄像头、录音的权限。授权分为按钮控制的授权和自动弹窗授权,按钮控制的授权如用户信息,当用户点击授权取消后再次点击按钮可以再次询问用户授权,但是自动弹窗授权如摄像头和录音权限如果用户拒绝授权,下次再重复授权时会自动进入授权失败。

用户信息授权

<button open-type="getUserInfo"  bindgetuserinfo="getUserInfo">
    <text>微信登录</text>
</button>

摄像头和录音授权,因为是需要授权两个权限之后才能进行直播,所以进入直播之前需要检测用户权限。这段代码废了很多脑细胞去想最优解,想到自闭。待优化,取消scope.camerascope.record的局限性。

const getCameraSetting = (options) => {
  const setArr = ['scope.camera', 'scope.record']
  wx.getSetting({
    success(res) {
      let setting = res.authSetting
      if (!setting.hasOwnProperty('scope.camera') || !setting.hasOwnProperty('scope.record')) { // 第一次授权
        setArr.map((item, index) => { // 只要有一个授权失败的就fail
          if (!setting.hasOwnProperty(item)) { // 第一次授权
            _authorize(setting, item, res => { // 异步
              setting = res
              checkAuthorize(setting, setArr, options)
            })
          } else {
            checkAuthorize(setting, setArr, options)
          }
        })
      } else {
        checkAuthorize(setting, setArr, options)
      }
    }
  })
}

const  _authorize = (setting, setType, callback) => {
  wx.authorize({
    scope: setType,
    success() {
      setting[setType] = true
      callback && callback(setting)
    },
    fail() {
      setting[setType] = false
      callback && callback(setting)
    }
  })
}

/**
 * 检查是否全部授权
 */
const checkAuthorize = (setting, setArr, options) => {
  let flag = new Array(setArr.length)
  setArr.map((item, index) => {
    if (setting.hasOwnProperty(item) && !setting[item]) {
      flag[index] = false
    } else if (setting.hasOwnProperty(item) && setting[item]) {
      flag[index] = true
    }
  })
  if (!flag.includes(false) && !flag.includes(undefined)) { // 全部授权
    options.success && options.success()
  } else if (flag.includes(false) && !flag.includes(undefined) ) { // 没有全部授权
    options.fail && options.fail()
  }
}
10.特殊字符

设置了强制换行样式之后发现特殊字符(如——、!)超出界限后不会进行自动换行,而且项目中的用户初始昵称是根据用户的微信昵称来设置的,所以试出来微信昵称中部分特殊字符也不支持,和项目做了统一,部分特殊字符不支持设置为昵称,这样就避免了特殊字符无法换行的问题。

if (/[ˇ<>\/'"·…——“”‘’]/igm.test(nickname)){
    common.showIconToast({
        title: '昵称中包含特殊字符,请修改重试', 
        icon: 'none'
    })
}

但是聊天框输入特殊字符没有做限制依旧要实现超出换行的样式

.chat-item {
  margin: 24rpx 24rpx 0;
  display: flex;
  flex-direction: row;
  align-items: center;
  flex-shrink: 0;
  width: fit-content; /*宽度根据子元素动态设置*/
  border-radius: 22rpx;
  background-color: rgba(0, 0, 0, 0.4);
}

.chat-content {
  display: inline-block;
  padding: 5rpx 18rpx;
  max-width: 100%;
  font-size: 24rpx;
  color: #fff;
  word-break: break-word; /*MDN上表示已经废弃的CSS样式,但是依旧好用*/
  box-sizing: border-box;
}
11.换行样式

通常中文和数字无法像英文一样自动断行所以要进行强制换行。

/* 通常处理强制换行 */
word-break: break-all;
/* 但是遇到一行的特殊字符如!、——,强制换行则会出现问题 但是break-word快要废弃,可替换方案微信小程序还未支持*/
word-break: break-word;
12.动态根据中英文内容设置输入长度
12.1 使用正则判断中文字符进行长度计算,按照自定义规则,这里有个问题微信小程序会自动识别表情符算三个字符
/**
 * 动态计算输入值长度,一个汉字算2个字符,len,value
 */
const computeFont = (len, value) => {
  const list = value.match(/[\u4e00-\u9fa5]/g)
  let newValue = value
  let newLen = list ? len - list.length * 1 : len
  if (list && list.length >= Math.floor(len / 2)) { 
    newValue = newValue.slice(0, Math.floor(len / 2))
    newLen = Math.floor(len / 2)
  }
  return { newLen, newValue }
}
12.2 将string转为byte,别人实现的,参考,这里有个问题是未按照特定的一个汉字算两个字符规则计算长度
const stringToByte = str => {
  var bytes = new Array();
  var len, c;
  len = str.length;
  for (var i = 0; i < len; i++) {
    c = str.charCodeAt(i);
    if (c >= 0x010000 && c <= 0x10FFFF) {
      bytes.push(((c >> 18) & 0x07) | 0xF0);
      bytes.push(((c >> 12) & 0x3F) | 0x80);
      bytes.push(((c >> 6) & 0x3F) | 0x80);
      bytes.push((c & 0x3F) | 0x80);
    } else if (c >= 0x000800 && c <= 0x00FFFF) {
      bytes.push(((c >> 12) & 0x0F) | 0xE0);
      bytes.push(((c >> 6) & 0x3F) | 0x80);
      bytes.push((c & 0x3F) | 0x80);
    } else if (c >= 0x000080 && c <= 0x0007FF) {
      bytes.push(((c >> 6) & 0x1F) | 0xC0);
      bytes.push((c & 0x3F) | 0x80);
    } else {
      bytes.push(c & 0xFF);
    }
  }
  return bytes;
}

代码逻辑和实现部分

1.多个条件处理
1.1 使用map去做if条件判断,用map的方式减少了if条件判断写的长度,但是该应用场景不太典型
const { page, status, roomId } = this.globalData.hideLastPage || {}
const showRoomId = options.query.roomId
const flagMap = new Map([
    ['red_path', path.includes('red')],
    ['red_page', page === 'red'],
    ['red_status', status === constants.userType.RED_ANCHOR],
    ['audience_path', path.includes('audience')],
    ['audience_page', page === 'audience'],
    ['audience_status', [constants.userType.MAN_ANCHOR, constants.userType.WOMAN_ANCHOR].includes(status)],
    ['audience_roomId', Number(roomId) === Number(showRoomId)]
])
const redShow = flagMap.get('red_path') && flagMap.get('red_page') && flagMap.get('red_status') // true 主播从red页面onHide后再onShow回来
const audienceShow = flagMap.get('audience_path') && flagMap.get('audience_page') && flagMap.get('audience_status') && flagMap.get('audience_roomId') // true 小主播从audience页面onHide后再onShow回来不进行拉流处理
if (!redShow && !audienceShow) {
    this.getUserStatus(res => {
        if (res.user_type === constants.userType.RED_ANCHOR) {
            wx.redirectTo({
                url: `/pages/live/red/red?nickname=${res.live_room_name}`
            })
        } else if ([constants.userType.MAN_ANCHOR, constants.userType.WOMAN_ANCHOR].includes(res.user_type)) {
            wx.redirectTo({
                url: `/pages/live/audience/audience?roomId=${res.live_id}&groupId=${res.group_id}`
            })
        }
    })
}
1.2 switch判断,如果一个函数内需要多个判断,一般都使用switch改写,但是注意case需要break,不然会继续向下运行
handleError(code) {
      let title = ''
      switch (true) {
        case [-2301].includes(code):
          this.triggerEvent('roomEvent', {
            type: roomEventType.playerNoNet,
            gender: this.data.info.gender === 1 ? 'male' : 'female',
            isRed: this.data.isRed
          })
          break
        case [2103].includes(code):
          console.log(code, '网络断连, 已启动自动重连')
          break
        case [2104, 2107].includes(code):
          // title = '网络状态不稳定'
          break
        case [2001, 2004].includes(code):
          if (!this.data.isRed && 2001 === code) return //忽略小主播拉流组件的2001状态码 红娘需要2001就显示 小主播不需要
          errTimer && clearTimeout(errTimer)
          this.triggerEvent('roomEvent', {
            type: roomEventType.success,
            status: code,
            gender: this.data.info.gender === 1 ? 'male' : 'female',
            isRed: this.data.isRed
          })
          break
        case [1].includes(code):
          console.log('播放失败')
          title = '网络错误'
          this.triggerEvent('roomEvent', {
            type: roomEventType.playerError,
            identity: this.data.info.id
          })
          backTimer = setTimeout(()=>{
            wx.switchTab({
              url: '/pages/home/home',
            })
          }, 1500)
          break
        default:
          break
      }
      title && common.showIconToast({
        title: title,
        icon: 'none',
        duration: 1500
      })
    },

常犯错误(BUG)及代码优化

  1. 数据处理:数据更新分为更新后需要更新view层显示和不更新view层显示,更新view层显示需要在data中定义,不需要更新view层的则不要在view中定义,避免不必要的setData

    粗心大意的bug:locked后忘记解除,触底加载需要进行locked,不然会容易重复加载同一页。按钮点击和加载中状态需要locked,避免重复触发和重复加载。

    其他bug:object数据更新最好进行属性赋值更新。数据初始化赋值问题。

  2. 数据缓存:在做小程序的时候有考虑到数据缓存问题,微信小程序性能报告中也有提到重复请求接口需要进行数据缓存的问题,但是很多数据需要实时更新,比如剩余钱数和收益(这个部分不知道需不需要单独出api进行更新数据,获取用户信息api中有这部分内容),但是部分数据如排行榜一天更新一次不会实时更新可以做数据缓存,同时设置缓存过期时间。代码review时有说到配置信息和礼物列表一般很少改变,可以作为缓存信息+过期时间,每天刷新一次就好(刷新频率根据运营需求)。

  3. 类使用:因为根据课程学习的小程序开发,在原经验上多增加了ES6语法的使用。类方法和类的静态方法区别在于类方法要在类实例化后才能使用,而类的静态方法是不需要类实例化使用的,实例化后不能使用静态方法,其中类实例化可以保存数据状态。因此在本项目中model类都不需要进行实例化,可以将类的方法改为静态方法进行调用。注意this指向。

  4. 错误处理:会容易忘记错误处理,就登录问题而言,小程序需要成功登录两个系统(小程序后端和IM系统)才算登录成功,顺序执行(先登录小程序后端,再登录IM系统),仅做了IM系统登录失败后需要用户确认点击重新登录,没有考虑到登录小程序后端的错误处理。