转载请注明出处:https://blog.csdn.net/sinat_27612147/article/details/78456363
2019-12-24
2019-09-05
master
分支更新!我们先来看下效果 (因录制软件问题,图中的一些按钮的变色了,线条也少了很多像素。。。)
近期一直在做微信小程序,业务上要求在小程序里实现即时通讯的功能。这部分功能需要用到文本和语音输入及一些语音相关的手势操作。所以我写了一个控件来处理这些操作。
聊天输入组件和会话页面组件是两个不同的组件,分别处理不同的业务。
注意:SDK仅支持微信基础库1.4.0及以上版本。
输入组件这部分内容我会从集成、编写控件两个部分来讲解。毕竟大部分人都是想尽快集成来着,所以先说说集成部分。
输入组件相关文件在modules/chat-input
和image
文件夹下,示例页面是pages/chat-input/chat-input
。
聊天输入组件和会话页面组件所有你需要集成的文件,打包后大小在65kb左右,已经很小了。需要注意的是,项目中的原有.gif
文件夹已经迁移到了别的仓库,image
文件夹中有两张用于测试的用户头像,也可以删除掉。。
let chatInput = require('../../modules/chat-input/chat-input');
@import "../../modules/chat-input/chat-input.wxss";
chatInput
chatInput.init(page,
{
systemInfo: wx.getSystemInfoSync(),
minVoiceTime: 1,//秒,最小录音时长,小于该值则弹出‘说话时间太短’
maxVoiceTime: 60,//秒,最大录音时长,大于该值则按60秒处理
startTimeDown: 56,//秒,开始倒计时时间,录音时长达到该值时弹窗界面更新为倒计时弹窗
format:'mp3',//录音格式,有效值:mp3或aac,仅在基础库1.6.0及以上生效,低版本不生效
sendButtonBgColor: 'mediumseagreen',//发送按钮的背景色
sendButtonTextColor: 'white',//发送按钮的文本颜色
extraArr: [{
picName: 'choose_picture',
description: '照片'
}, {
picName: 'take_photos',
description: '拍摄'
}, {
picName: 'close_chat',
description: '自定义功能'
}],
tabbarHeigth: 48
});
page
:这个是指当前的page。systemInfo
:必填。手机的系统信息,用于控件的适配。minVoiceTime
: 最小录音时长,秒,小于该值则弹出‘说话时间太短’maxVoiceTime
: 最大录音时长,秒,填写的数值大于该值则按60秒处理。录音时长如果超过该值,则会保存最大时长的录音,并弹出‘说话时间超时’并终止录音startTimeDown
: 开始倒计时时间,秒,录音时长达到该值时弹窗界面更新为倒计时弹窗extraArr
:非必填。点击右侧加号时,显示的自定义功能。picName
元素的名字就是对应image/chat/extra
文件夹下的png格式
的图片名称,用于展示自定义功能的图片。description
元素用于展示自定义功能的文字说明。tabbarHeight
:非必填。这个也是用于适配。如果你的小程序有tabbar,那么需要填写这个字段,填48就行。如果你的小程序没有tabbar,那么就不要填写这个字段。原因我会在第二篇讲到。format
: 录音格式,有效值:mp3或aac,仅在基础库1.6.0及以上生效,低版本不生效,当然也不会报错。sendButtonBgColor
: 发送按钮的背景色sendButtonTextColor
: 发送按钮的文本颜色在初始化控件之后,监听信息的输入,即可获取到指定类型的信息
//文本信息的输入监听
chatInput.setTextMessageListener(function (e) {
let content = e.detail.value;//输入的文本信息
});
//获取录音之后的音频临时文件
chatInput.recordVoiceListener(function (res, duration) {
let tempFilePath = res.tempFilePath;//语音临时文件的路径
let vDuration = duration;//录音时长
});
//监听录音状态
chatInput.setVoiceRecordStatusListener(function (status) {
switch (status) {
case chatInput.VRStatus.START://开始录音
break;
case chatInput.VRStatus.SUCCESS://录音成功
break;
case chatInput.VRStatus.CANCEL://取消录音
break;
case chatInput.VRStatus.SHORT://录音时长太短
break;
case chatInput.VRStatus.UNAUTH://未授权录音功能
break;
case chatInput.VRStatus.FAIL://录音失败(已经授权了)
break;
}
})
//收起自定义功能窗口
chatInput.closeExtraView();
//自定义功能点击事件
chatInput.clickExtraListener(function (e) {
let itemIndex = parseInt(e.currentTarget.dataset.index);//点击的自定义功能索引
if (itemIndex === 2) {
that.myFun();//其他的自定义功能
return;
}
//选择图片或拍照
wx.chooseImage({
count: 1, // 默认9
sizeType: ['compressed'],
sourceType: itemIndex === 0 ? ['album'] : ['camera'],
success: function (res) {
let tempFilePath = res.tempFilePaths[0];
}
});
});
//新增右下角加号button点击事件
chatInput.setExtraButtonClickListener(function (dismiss) {
console.log('Extra弹窗是否消失', dismiss);
})
会话页面,我将UI封装成了多个template
,最后使用chat-item.wxml
即可,UI相关的代码都放到了chat-page
文件夹中;加载方面的UI放到了loading
文件夹中;image
文件夹中也新增了几张图片。
对于即时通讯方面的sdk,我是用的WebSocket,当然这部分内容仅供参考。你可以引入常见的sdk,比如腾讯的、网易的。
目前实现了三个页面。会话列表页面、好友列表页面、会话页面。
会话列表页面功能:
好友页面功能:
好友页面未实现功能:
并未实现发起聊天功能,仅供演示,如有需要,请自行实现。
会话页面功能:
有网友建议画个流程图,梳理下项目中的各部分关系。
这部分的东西包括WebSocket写完之后发现,并没有很多难点,所以我只说下使用时要注意的几点。
示例页面是pages/chat-list/chat-list
。
代码非常简单。
// pages/chat-list/chat-list.js
/**
* 会话列表页面
*/
Page({
/**
* 页面的初始数据
*/
data: {
conversations: []
},
/**
* 生命周期函数--监听页面加载
*/
onLoad: function (options) {
},
toChat: function (e) {
let item = e.currentTarget.dataset.item;
delete item.latestMsg;
delete item.unread;
wx.navigateTo({
url: `../chat/chat?friend=${JSON.stringify(item)}`
});
},
/**
* 生命周期函数--监听页面显示
*/
onShow: function () {
getApp().getIMHandler().setOnReceiveMessageListener({
listener: (msg) => {
console.log('会话列表', msg);
msg.type === 'get-conversations' && this.setData({conversations: msg.conversations.map(item => this.getConversationsItem(item))})
}
});
getApp().getIMHandler().sendMsg({
content: {
type: 'get-conversations',
userId: getApp().globalData.userInfo.userId//这里获取的userInfo,就是在建立webSocket连接时服务器返回的,作为你的身份信息。
}, success: () => {
console.log('获取会话列表消息发送成功');
},
fail: (res) => {
console.log('获取会话列表失败', res);
}
});
},
getConversationsItem(item) {
let {latestMsg, ...msg} = item;
return Object.assign(msg, JSON.parse(latestMsg));
}
});
就这些代码。结合流程图来看的话,相信你肯定能看懂。看不懂也没事,能理解思想就行,就是 注册监听、发起请求、回调监听、渲染页面。
示例页面是pages/friends/friends
。
同会话列表页面,没什么好说的。
示例页面是pages/chat/chat
。
在chat
文件夹下,我封装了多个类,用于管理消息类型的收发和展示。
MsgManager
是一个收发消息的工厂类,用于统一管理所有消息类型的收发。见msg-manager.js
:
import VoiceManager from "./msg-type/voice-manager";//语音消息的收发和展示相关类
import TextManager from "./msg-type/text-manager";//文本类型消息的收发和展示相关类
import ImageManager from "./msg-type/image-manager";//图片类型消息的收发和展示相关类
import CustomManager from "./msg-type/custom-manager";//自定义类型消息的收发和展示相关类
import IMOperator from "./im-operator";//im行为管理类
export default class MsgManager {
constructor(page) {
this.voiceManager = new VoiceManager(page);
this.textManager = new TextManager(page);
this.imageManager = new ImageManager(page);
this.customManager = new CustomManager(page);
}
showMsg({msg}) {
let tempManager = null;
switch (msg.type) {
case IMOperator.VoiceType:
tempManager = this.voiceManager;
break;
case IMOperator.ImageType:
tempManager = this.imageManager;
break;
case IMOperator.TextType:
case IMOperator.CustomType:
tempManager = this.textManager;
}
tempManager.showMsg({msg});
}
sendMsg({type = IMOperator.TextType, content, duration}) {
let tempManager = null;
switch (type) {
case IMOperator.VoiceType:
tempManager = this.voiceManager;
break;
case IMOperator.ImageType:
tempManager = this.imageManager;
break;
case IMOperator.CustomType:
tempManager = this.customManager;
break;
case IMOperator.TextType:
tempManager = this.textManager;
}
tempManager.sendOneMsg(content, duration);
}
stopAllVoice() {
this.voiceManager.stopAllVoicePlay();
}
}
小程序基础库1.6.0以后不再维护的语音播放和录制接口,我进行了兼容处理。
篇幅问题,各类型消息的相关类代码我就不贴了。想看的去下载吧。
缓存和展示机制:在展示语音或图片类型的消息时,我会优先加载已经存储在本地的文件。在文件类型消息(如语音、图片消息)的showMsg()
方法中先是取消息的本地路径const localVoicePath = FileManager.get(msg)
,如果没有获取到本地路径,就会按照消息中的文件路径信息去下载该文件,并存储下来。这里代码没有贴出来,大家理解意思就行。
那取到的值是什么时候设置的呢?是在发送或接收消息成功后(此时文件已下载成功),以消息的saveKey
为key,存储成功返回的savedFilePath
为data,建立消息和本地存储路径的映射关系,如:FileManager.set(msg, savedFilePath)
;
这里的saveKey
是以消息id msgId
和好友id friendId
,拼接而成的。
存储溢出算法:在存储文件时,我参考了Android LruCache的思想编写了算法,保证在小程序10M存储限制的前提下,存储新的文件,如果溢出了,就移除最旧的文件(跟LruCache溢出时去除不常用文件的思想不太一样)。算法具体内容见小程序性能优化——文件的本地存储10M优化算法。
const MAX_SIZE = 10400000;
let wholeSize = 0;
setTimeout(() => {
wx.getSavedFileList({
success: savedFileInfo => {
let {fileList} = savedFileInfo;
!!fileList && fileList.forEach(item => {
wholeSize += item.size;
});
}
});
});
function saveFileRule(tempFilePath, cbOk, cbError) {
wx.getFileInfo({
filePath: tempFilePath,
success: tempFailInfo => {
let tempFileSize = tempFailInfo.size;
// console.log('本地临时文件大小', tempFileSize);
if (tempFileSize > MAX_SIZE) {
typeof cbError === "function" && cbError('文件过大');
return;
}
wx.getSavedFileList({
success: savedFileInfo => {
let {fileList} = savedFileInfo;
if (!fileList) {
typeof cbError === "function" && cbError('获取到的fileList为空,请检查你的wx.getSavedFileList()函数的success返回值');
return;
}
//这里计算需要移除的总文件大小
let sizeNeedRemove = wholeSize + tempFileSize - MAX_SIZE;
if (sizeNeedRemove >= 0) {
//按时间戳排序,方便后续移除文件
fileList.sort(function (item1, item2) {
return item1.createTime - item2.createTime;
});
let sizeCount = 0;
for (let i = 0, len = fileList.length; i < len; i++) {
if ((sizeCount += fileList[i].size) >= sizeNeedRemove) {
for (let j = 0; j < i; j++) {
wx.removeSavedFile({
filePath: fileList[j].filePath,
success: function () {
wholeSize -= fileList[j].size;
}
});
}
break;
}
}
}
wx.saveFile({
tempFilePath: tempFilePath,
success: res => {
wholeSize += tempFileSize;
typeof cbOk === "function" && cbOk(res.savedFilePath);
},
fail: cbError
});
},
fail: cbError
});
}
});
}
module.exports = {
saveFileRule
};
i-im-handler.js
这个类是IM-SDK的接口规范类。你可以通过继承这个类,然后在子类中实现细节,这样的话可以很方便的接入其他的第三方IM-SDK。
/**
* 由于JavaScript没有接口的概念,所以我编写了这个IM基类
* 将你自己的IM的实现类继承这个类就可以了
* 我把IM通信的常用方法封装在这里,
* 有些实现了具体细节,但有些没实现,是作为抽象函数,由子类去实现细节,这点是大家需要注意的
*/
export default class IIMHandler {
constructor() {
this._isLogin = false;
this._msgQueue = [];
this._receiveListener = null;
}
/**
* 创建IM连接
* @param options 传入你建立连接时需要的配置信息,比如url
*/
createConnection({options}) {
// 作为抽象函数
}
/**
* 发送消息
* @param content 需要发送的消息,是一个对象,如{type:'text',content:'abc'}
* @param success 发送成功回调
* @param fail 发送失败回调
*/
sendMsg({content, success, fail}) {
if (this._isLogin) {
this._sendMsgImp({content, success, fail});
} else {
this._msgQueue.push(content);
}
}
/**
* 消息接收监听函数
* @param listener
*/
setOnReceiveMessageListener({listener}) {
this._receiveListener = listener;
}
closeConnection() {
// 作为抽象函数
}
_sendMsgImp({content, success, fail}) {
// 作为抽象函数
}
}
web-socket-handler-imp.js
这个文件位于modules文件夹下。
这个是IM-SDK的WebSocket实现类,所有的WebSocket基本操作都封装到了这个类中。我截取了重要的几处代码。
import IIMHandler from "../interface/i-im-handler";
export default class WebSocketHandlerImp extends IIMHandler{
constructor() {
super();
this._onSocketMessage();
}
/**
* 创建WebSocket连接
* 如:this.imWebSocket = new IMWebSocket();
* this.imWebSocket.createSocket({url: 'ws://10.4.97.87:8001'});
* 如果你使用本地服务器来测试,那么这里的url需要用ws,而不是wss,因为用wss无法成功连接到本地服务器
* @param options 建立连接时需要的配置信息,这里是传入的url,即你的服务端地址,端口号不是必需的。
*/
createConnection({options}) {
!this._isLogin && wx.connectSocket({
url: options.url,
header: {
'content-type': 'application/json'
},
method: 'GET'
});
}
_sendMsgImp({content, success, fail}) {
wx.sendSocketMessage({
data: JSON.stringify(content), success: () => {
success && success(content);
},
fail: (res) => {
fail && fail(res);
}
});
}
/**
* 关闭webSocket
*/
closeConnection() {
wx.closeSocket();
}
/**
* webSocket是在这里接收消息的
* 在socket连接成功时,服务器会主动给客户端推送一条消息类型为login的信息,携带了用户的基本信息,如id,头像和昵称。
* 在login信息接收前发送的所有消息,都会被推到msgQueue队列中,在登录成功后会自动重新发送。
* 这里我进行了事件的分发,接收到非login类型的消息,会回调监听函数。
* @private
*/
_onSocketMessage() {
wx.onSocketMessage((res) => {
let msg = JSON.parse(res.data);
if ('login' === msg.type) {
this._isLogin = true;
getApp().globalData.userInfo = msg.userInfo;
getApp().globalData.friendsId = msg.friendsId;
if (this._msgQueue.length) {
let temp;
while (temp = this._msgQueue.shift()) {
this.sendMsg({content: {...temp, userId: msg.userInfo.userId}});
}
}
} else {
this._receiveListener && this._receiveListener(msg);
}
})
}
}
毕竟要面向接口编程嘛,这样的话,你使用第三方的IM-SDK的话,就可以直接继承这个IIMHandler
,按接口规范在子类中实现细节就可以了。
也很简单,对吧。
这里面有一个IMFactory,是一个工厂类,它的create()方法返回的是WebSocketHandlerImp
,这个工厂类的代码我就不贴了。
//app.js
import IMFactory from "./modules/im-sdk/im-factory";
App({
globalData: {
userInfo: {},
},
getIMHandler() {
return this.iIMHandler;
},
onLaunch() {
this.iIMHandler = IMFactory.create();
},
onHide() {
// this.iIMHandler.closeConnection();
},
onShow() {
this.iIMHandler.createConnection({options: {url: 'ws://10.4.94.185:8001'}});
}
});
im-operator.js
最后重点说下IM的控制类 IMOperator。
现在在创建IMOperator时,需要你额外传入好友信息,这个信息应该是在会话列表点击时传入的,如下所示:
/**
* 生命周期函数--监听页面加载
*/
onLoad: function (options) {
const friend = JSON.parse(options.friend);
this.initData();
wx.setNavigationBarTitle({
title: friend.friendName
});
this.imOperator = new IMOperator(this, friend);//额外传入好友信息
...
...
},
记住,你所有发送的消息和接收到的消息,都是以文本消息的形式,只是在渲染的时候解析,生成不同的消息类型来展示!!!
createChatItemContent({type = IMOperator.TextType, content = '', duration} = {}) {
if (!content.replace(/^\s*|\s*$/g, '')) return;
return {
content,
type,
conversationId: 0,//会话id,目前未用到
userId: getApp().globalData.userInfo.userId,
friendId: this.getFriendId(),//好友id
duration
};
}
这是生成发送数据的文本的方法。它会返回一个对象。
除自定义消息类型外,其他的无论是自己发送的消息,还是好友的消息,在UI上渲染时,都是以该消息对象的格式来统一的。
createNormalChatItem({type = IMOperator.TextType, content = '', isMy = true, duration} = {}) {
if (!content) return;
const currentTimestamp = Date.now();
const time = dealChatTime(currentTimestamp, this._latestTImestamp);
let obj = {
msgId: 0,//消息id
friendId: this.getFriendId(),//好友id
isMy: isMy,//我发送的消息?
showTime: time.ifShowTime,//是否显示该次发送时间
time: time.timeStr,//发送时间 如 09:15,
timestamp: currentTimestamp,//该条数据的时间戳,一般用于排序
type: type,//内容的类型,目前有这几种类型: text/voice/image/custom | 文本/语音/图片/自定义
content: content,// 显示的内容,根据不同的类型,在这里填充不同的信息。
headUrl: isMy ? this._myHeadUrl : this._otherHeadUrl,//显示的头像,自己或好友的。
sendStatus: 'success',//发送状态,目前有这几种状态:sending/success/failed | 发送中/发送成功/发送失败
voiceDuration: duration,//语音时长 单位秒
isPlaying: false,//语音是否正在播放
};
obj.saveKey = obj.friendId + '_' + obj.msgId;//saveKey是存储文件时的key
return obj;
}
createChatItemContent
生成的JSON格式字符串。自定义消息类型的UI类似于会话页面中的展示聊天时间的UI。
static createCustomChatItem() {
return {
timestamp: Date.now(),
type: IMOperator.CustomType,
content: '会话已关闭'
}
}
发送数据这块目前已经实现了WebSocket通信。
支持发送文本、语音、图片及自定义类型消息。不过因发送文件类型的消息需要上传文件的服务器,所以目前仅支持发送文本消息和自定义类型消息。如果你自己配置好了上传文件的服务器,那么就可以发送语音和图片消息了。
以发送文本消息为例:
首先在输入组件的文本输入监听回调接口中调用this.msgManager
的发送消息方法sendMsg()
chatInput.setTextMessageListener((e) => {
let content = e.detail.value;
this.msgManager.sendMsg({type: IMOperator.TextType, content});
});
在sendMsg
方法中会去判断发送的消息类型,最终都会调用下面的IM发送接口。
onSimulateSendMsg({content, success, fail}) {
//这里content即为要发送的数据
//注意:这里的content是一个对象了,不再是一个JSON格式的字符串。这样可以在发送消息的底层接口中统一处理。
getApp().getIMHandler().sendMsg({
content,
success: (content) => {
//这个content格式一样,也是一个对象
const item = this.createNormalChatItem(content);
this._latestTImestamp = item.timestamp;
success && success(item);
},
fail
});
}
createChatItemContent
方法生成的。createNormalChatItem
生成的消息对象。其他消息的发送方式都是与之类似的。
关于会话页面消息是怎么接收到的,下面的展示了一个完整的主要流程。
下面贴的是核心代码
onSimulateReceiveMsg(cbOk) {
getApp().getIMHandler().setOnReceiveMessageListener({
listener: (msg) => {
if (!msg) {
return;
}
msg.isMy = msg.msgUserId === getApp().globalData.userInfo.userId;
const item = this.createNormalChatItem(msg);
// const item = this.createNormalChatItem({type: 'voice', content: '上传文件返回的语音文件路径', isMy: false});
// const item = this.createNormalChatItem({type: 'image', content: '上传文件返回的图片文件路径', isMy: false});
this._latestTImestamp = item.timestamp;
//这里是收到好友消息的回调函数,建议传入的item是 由 createNormalChatItem 方法生成的。
cbOk && cbOk(item);
}
});
}
this.createNormalChatItem({type: 'text', content: '这是模拟好友回复的消息', isMy: false})
生成的消息对象在上面发送数据接口
代码中可以看到,在接收到数据时,先使用createNormalChatItem
来生成消息类型数据,然后回调onSimulateReceiveMsgCb
函数,即可完成数据的接收。
我是在chat.js
的onLoad
生命周期中注册的监听:
onLoad: function (options) {
const friend = JSON.parse(options.friend);
console.log(friend);
this.initData();
wx.setNavigationBarTitle({
title: friend.friendName
});
this.imOperator = new IMOperator(this, friend);
this.UI = new UI(this);
this.msgManager = new MsgManager(this);
this.imOperator.onSimulateReceiveMsg((msg) => {
//执行到这步时,好友的消息早已经接收到并生成了消息类型数据msg,接下来要做的就是将数据渲染到页面上了
//showMsg()是用于渲染消息类型数据的。
this.msgManager.showMsg({msg})
});
this.UI.updateChatStatus('正在聊天中...');
},
我使用chatItems
来存储所有的消息类型,包括自定义消息类型。
布局怎么写的我就不讲了,有关UI渲染的代码,我全部放在了ui.js
中,自己去看下吧,也都很简单。
最上面说的输入组件,有各种交互情况下的事件回调,在回调函数中处理对应逻辑即可。这部分的所有代码我都放到了chat.js
中。
服务器端是用nodejs开发的,配合客户端实现了简单的IM消息展示逻辑。webSocket所有功能仅供学习和参考,若想商用,请自行开发。
请务必阅读这部分内容,新手请自行学习依赖的安装和gulp的初级使用。项目的运行是没有问题的!!
修改app.js文件中下面配置的url为你本地网络ip
this.imWebSocket.createSocket({url: 'ws://10.4.97.87:8001'});
项目根目录下启动Terminal
需先安装依赖 npm install
执行 npm run server 即可开启WebSocket服务
nodejs-websocket
具体API详见https://www.npmjs.com/package/nodejs-websocket
服务端简单实现了两人实时聊天功能,获取历史消息,获取会话列表、好友列表。需要注意的是,目前消息都是在内存中,重启服务后所有数据会重置!
目前只能两人聊天,而且当两个人同时在线时,后面一个人重新连接了,就会登上另外一个人的号,所以会出现自己发消息,自己收到自己的消息的情况。这时候你可以重启WebSocket服务,两台设备重新进下小程序就可以了。
服务端代码就不贴了。
小程序适合开发轻量级应用,不管你怎么推崇小程序,它都是有性能上的瓶颈问题的。比如说一个页面中加载大量图片会导致占用惊人的内存空间,长列表的无法复用控件问题、重复setData()
造成页面闪动以及存储空间限制在10M等等。因此,使用小程序开发IM,不建议将历史消息存储在本地,也不建议单个页面中加载大量的聊天消息,更何况是有很多图片的聊天消息!这将造成页面卡顿,影响小程序的响应速度!而这些东西目前是无法从技术上优化的!
不过有些东西是可以优化的。我想你的小程序肯定有这样的使用场景,点击一个按钮跳转到另外一个页面,然后在向服务器请求获取数据。因为要先渲染空页面再渲染数据,页面偶尔会闪烁。其实这个是可以优化的。即在点击按钮时就请求数据,这样的话,就能利用页面跳转的200ms左右的时间来完成数据的预加载。如果协议返回够快的话,页面在打开时会立刻渲染出数据。不过问题也就来了,直接这样做的话会让你的页面混有不相干的协议,违法单一职责。那么我为此编写了一个小程序预加载框架,让你既能在页面跳转前就预加载数据,又不会破坏项目的结构。哈哈,为自己打一波广告!
使用中有什么问题的话,可以在博客或GitHub上联系我。我会及时回复。
谢谢大家!
github地址
小程序技术交流请加QQ群:821711186
如有合作意向或是想要推广您的产品,可加QQ:1178545208
Questions 常见问题及解决方案
ChangeLog 更新日志
LICENSE