在线直播源码评论弹幕是如何“练”成的?
提起弹幕(dànmù),大家都会想到「视频弹幕」。视频弹幕是指网友们在观看视频的同时参与评论,即所谓“即时反馈”, 评论以飞行形式横穿屏幕,视觉效果类似多发密集的子弹飞速而过,故称之为“弹幕”。“弹幕”最大的特点就是允许受众在观看直播的同时将评论内容发送到服务器与直播同步播放,这可以让观众的反馈瞬间产生,与主播发生即时互动,甚至可以形成隔空对话。
随着手机竖屏直播时代的到来,弹幕也从原本的“横穿飞行”,衍生出一种新的模式——在屏幕左下方竖向滚动。实际效果如下图所示。
从效果图上我们还看到有几点重要信息:
看似简单,实现的过程中却需考虑如下几点:
DOM
节点,会导致系统卡顿,甚至程序崩溃。一口吃不成胖子,先从效果图显示的布局入手,思考封装这个组件,需要传入哪些参数,哪些是通过properties
由父组件传入,哪些通过data
来维护,与视图进行通信。
data = {toLast: 'item0', // 弹幕滚动索引realBarrage: [], // 实际上屏的弹幕};properties = {barrageHeight: {type: Number,value: 500,}, // 弹幕容器高度config: {type: Object,value: DEFAULT_CONFIG,}, // 弹幕配置tagConfig: {type: Array,value: TAG_DEFAULT_CONFIG,}, // 标签配置信息systemHint: {type: String,value: DEFAULT_SYSTEM_HINT,}, // 系统提示信息};
先来谈谈properties
属性:
const DEFAULT_SYSTEM_HINT ='系统提示:欢迎来到直播间!直享倡导绿色直播,文明互动,购买直播推荐商品时,请确认购买链接描述与实际商品一致,避免上当受骗。如遇违法违规现象,请立即举报!';
const DEFAULT_CONFIG = {INTERVAL: 300, // 刷新频率,默认300msBARRAGE_MAX_COUNT: 50, // 上屏弹幕的最大数量POOL_MAX_COUNT: 50, // 弹幕池(未上屏)弹幕上限BARRAGE_MAX_FRAME: 6, // 屏控处理,每次同时上屏弹幕的数量SLEEP_TIME: 5000, // 弹幕休眠时间,默认5000msCHECK_SLEEP: true, // 是否休眠,休眠超过 SLEEP_TIME ,则开启自动滚动
};
const TAG_DEFAULT_CONFIG = [{bgColor: 'linear-gradient(to right, #fb3e3e, #ff834a)',tagName: '主播',},{bgColor: 'linear-gradient(to top, #ffb365, #ff8c17)',tagName: '号主',},{bgColor: 'linear-gradient(to left, #8bb1ff, #5195ff)',tagName: '粉丝',},
];
然后,再谈谈data
属性:
interface queueI {name?: string; // 用户昵称comment?: string; // 评论内容isMe?: boolean; // 是否自己发送的弹幕tagIndex?: number; // 标签索引,可自定义,默认情况 0-主播,1-号主,2-粉丝systemInfo?: string; // 系统提示消息,如果存在这个属性,则前面四个属性无需存在
}
其中,name
、comment
为普通评论消息的必须属性,systemInfo
为系统消息的必须属性,两者互斥。 弹幕数据tagIndex
属性与标签配置信息中TAG_DEFAULT_CONFIG
数组的索引一一对应,默认情况 0-主播,1-号主,2-粉丝。如果修改标签配置信息,那么tagIndex
属性值的对应关系也要重新梳理。
组件的scroll-into-view
属性支持滚动到对应的子元素 id,所以需要维护toLast
来指定。知道了每个data
与properties
的含义,可以根据设计稿,对布局一把梭了。为了实现可滚动视图区域,组件外层使用
包裹。用到的属性如下表所示:
属性 | 类型 | 说明 | 默认值 | 必填 |
---|---|---|---|---|
scroll-y | boolean | 允许纵向滚动 | false | 否 |
scroll-with-animation | boolean | 在设置滚动条位置时使用动画过渡 | false | 否 |
scroll-into-view | string | 值应为某子元素 id(id 不能以数字开头)。设置哪个方向可滚动,则在哪个方向滚动到该元素 | - | 否 |
lower-threshold | number/string | 距底部/右边多远时,触发 scrolltolower 事件 | 50 | 否 |
bindscrolltolower | eventhandle | 滚动到底部/右边时触发 | - | 否 |
bindscroll | eventhandle | 滚动时触发,event.detail = {scrollLeft, scrollTop, scrollHeight, scrollWidth, deltaX, deltaY} | - | 否 |
bindtouchstart | eventhandle | 手指触摸动作开始 | - | 否 |
bindtouchend | eventhandle | 手指触摸动作开始 | - | 否 |
容器内的子元素包含两部分,一部分的直播间提示消息,放在滚动列表的头部,永远不会被清理。另外一部分是即时消息,分为评论消息和系统消息,放在realBarrage
中,达到数量最大限制时会被清理。
视图布局代码如下所示:
根据需求,对功能进行思考和拆分,抽象出一个通用类,此处定义为QueueBarrage
。这个类维护两个优先级队列,一个是弹幕池,一个是上屏弹幕。服务器推送的弹幕消息进入弹幕池,池子里的弹幕需要进行过滤重排、溢出处理。设置轮询机制,对弹幕进行分批次上屏,同时上屏弹幕也设有溢出处理策略。自己发送的弹幕不走弹幕池,直接上屏。
下列代码是类的属性定义和构造器。
export default class QueueBarrage {queueList: queueI[]; // 弹幕池,包括所有弹幕barrageList: queueI[]; // 上屏弹幕changeCallback; // 弹幕上屏回调config; // 配置信息isPaused: boolean; // 弹幕是否暂停isActive: boolean; // 弹幕是否休眠timer;private checkActiveTimer;constructor(config = {}) {this.config = { ...defaultConFig, ...config };this.queueList = [];this.barrageList = [];this.isPaused = false;this.isActive = false;this.flush();}
}
前面提到过,在李佳琦、薇娅等超级主播的直播间,每秒接收到的消息数以千计,如果直接一股脑地将消息上屏,弹幕便会飞速滚动,那么观众和主播都无法有效地获取内容信息。因此,需要维护一个「弹幕池」,能对弹幕进行优先级排序,有效地过滤掉“低质”弹幕,同时对池子的容量做限制,添加“溢出”处理策略。
代码实现如下所示,将消息放入弹幕池,如果弹幕数量超出最大数量限制,则对弹幕进行过滤,同时删除超出的那部分弹幕。当然,历史弹幕信息为了保证上下文逻辑的严谨性,是无需进行优先级排序的,所以只需要截取超出的部分。为了区分两种弹幕类型,本函数的第二个参数isFilter
用来控制是否进行过滤。
barrageEnterQueue(queue: queueI[], isFilter = true) {queue.forEach(v => {this.queueList.push(v);});// 进入直播间历史弹幕不进行过滤if (this.queueList.length > this.config.POOL_MAX_COUNT) {if (isFilter)this.queueList = filter(this.queueList, this.config.POOL_MAX_COUNT);elsethis.queueList.splice(0,this.queueList.length - this.config.POOL_MAX_COUNT,);}if (!this.isPaused) {!this.timer && this.flush();}}
那么过滤规则是怎么样的呢?这得视业务情况而定,下面贴出本业务的弹幕过滤逻辑:
这里需要注意的是,自己发送的弹幕的权重优先级是最高的,可以走弹幕池,通过过滤提高优先级,但这需要一定的时间消耗。不走弹幕池,直接上屏是比较合理的实现方案。
const CONTENT_FIELD = 'comment';
const REG_MEANINGLESS = /^[\d\s\!\@\#\$\%\^\&\*\(\)\-\=]+$/;
// 权重计算规则
const rules &#61; [function meaningless(this: any, weight) {return this[CONTENT_FIELD] && REG_MEANINGLESS.test(this[CONTENT_FIELD])? weight - 0.5: weight;},function barrage2short(this: any, weight) {return this[CONTENT_FIELD] && this[CONTENT_FIELD].length <3? weight - 0.2: weight;},
];
接下来&#xff0c;便可根据权重计算规则对弹幕进行排序且筛选。第一个参数是弹幕列表&#xff0c;第二个参数是最大数量限制。
export function filter(barrages, limit) &#61;> {barrages.forEach(barrage &#61;> {barrage.weight &#61; rules.reduce((weight, rule) &#61;> {return rule.call(barrage, weight);}, 1);});return barrages.sort((a, b) &#61;> b.weight - a.weight).slice(barrages.length - limit);
};
将消息放入弹幕池后&#xff0c;接下来的操作便是轮询从弹幕池里取固定数量的弹幕上屏。实现逻辑如下所示&#xff1a;
BARRAGE_MAX_FRAME
&#xff08;默认为 6&#xff09;的弹幕&#xff0c;添加到barrageList
&#xff08;上屏弹幕列表&#xff09;中。如上效果图所示&#xff0c;6 条弹幕同时上屏&#xff0c;正好在一个屏幕高度内&#xff0c;不影响用户获取内容信息。barrageList
列表做溢出处理&#xff0c;超出最大数量限制BARRAGE_MAX_COUNT
&#xff08;默认为 50&#xff09;&#xff0c;删除列表头部超出数量的弹幕。这样可以将上屏弹幕的数量维持在一个最大阈值内。如上效果图所示&#xff0c;弹幕列表数量超出阈值&#xff0c;头部的弹幕已经被清理&#xff0c;用户只能获取最新的 50 条弹幕。barrageList
得到更新后&#xff0c;需要执行changeCallback
回调做真正的上屏处理&#xff0c;这个回调涉及业务逻辑操作&#xff0c;需要在类实例化的时候进行赋值。setTimeout
实现轮询机制&#xff0c;每INTERVAL
ms&#xff08;默认为 300&#xff09;执行如上 3 步操作&#xff0c;如果上屏弹幕列表为空&#xff0c;表示没有新的弹幕消息&#xff0c;则不执行上屏回调处理。需要注意的是&#xff0c;这里用setTimeout
模拟setInterval
实现轮询&#xff0c;这是因为当回调函数的执行被阻塞时&#xff0c;setInterval
会产生回调堆积。 private flush() {this.timer &#61; setTimeout(() &#61;> {if (this.queueList.length > 0) {// 从弹幕池中取弹幕this.barrageList &#61; [...this.barrageList,...this.queueList.splice(0, this.config.BARRAGE_MAX_FRAME),];// 判断上屏弹幕是否超过最大限制&#xff0c;如超过&#xff0c;删除旧弹幕if (this.barrageList.length > this.config.BARRAGE_MAX_COUNT) {this.barrageList.splice(0,this.barrageList.length - this.config.BARRAGE_MAX_COUNT,);}// 弹幕上屏this.barrageList.length > 0 &&this.changeCallback &&this.changeCallback(this.barrageList);}this.flush();}, this.config.INTERVAL);}// 弹幕上屏回调函数赋值emitQueueChange(cb) {this.changeCallback &#61; cb;}
用户自己发送的弹幕&#xff0c;不走弹幕池过滤&#xff0c;无视网络质量&#xff0c;无条件优先上屏。代码如下所示&#xff1a;
// 自己的弹幕直出barrageEnterQueueSelf(queue: queueI) {this.barrageList.push(queue);this.changeCallback && this.changeCallback(this.barrageList);}
SLEEP_TIME
ms&#xff08;默认为 5000&#xff09;&#xff0c;且用户不在执行其它操作&#xff0c;弹幕恢复轮询。具体的处理逻辑如下所示&#xff1a;
// 检查弹幕是否激活setActiveAndAutoRestart() {// 如果没有开启if (!this.config.CHECK_SLEEP) return;if (this.checkActiveTimer) {clearTimeout(this.checkActiveTimer);}this.checkActiveTimer &#61; setTimeout(() &#61;> {this.restart();}, this.config.SLEEP_TIME);}// 弹幕暂停滚动pause() {if (this.isPaused) return;this.isPaused &#61; true;if (this.timer) {clearTimeout(this.timer);this.timer &#61; null;}this.setActiveAndAutoRestart();}// 弹幕重新开始滚动restart() {if (!this.isPaused) return;this.isPaused &#61; false;this.queueList.length && this.flush();}
Barrage
类实例&#xff0c;设置弹幕上屏的实际回调方法&#xff0c;通过toLast
值指定列表滚动到该元素&#xff0c;通过更新realBarrage
数据&#xff0c;来更新实际上屏的数据。Barrage
类实例,同时对定时器进行清理。 attached() {// 创建Barrage实例this.barrage &#61; new Barrage(this.properties.config);// 初始化轮询回调方法this.barrage.emitQueueChange(data &#61;> {this.setData({toLast: &#96;item${data.length}&#96;,realBarrage: data,});});// 校准弹幕容器高度this.checkContainerClientHeight();}detached() {this.barrage && this.barrage.destroy();}
父组件可以通过 id 获取弹幕组件的实例&#xff0c;从而调用其封装的方法。用法如下所示&#xff1a;
// 获取弹幕组件实例getBarrageContext() {if (!this.barrageContext) {(this.barrageContext as any) &#61; this.selectComponent(&#39;#wr-live-barrage&#39;);}return this.barrageContext;}// 调用方法示例handleSendBarrage() {(this.getBarrageContext() as any).sendBarrageBySelf(data);}
弹幕组件暴露的三个方法如下表格所示&#xff1a;
API | 说明 | 参数 | 默认值 |
---|---|---|---|
multiPushBarrage | 初始化直播间时&#xff0c;填充数十条历史弹幕数据&#xff0c;组件已经优化&#xff0c;实现分布式上屏 | queueI[] | - |
sendBarrageEnterQueue | 将接收到的弹幕消息放入弹幕池, 第二个参数表示是否执行弹幕过滤规则 | <queueI[], Boolean> | <-, true> |
sendBarrageBySelf | 自己发送的弹幕消息直接上屏 | queueI | - |
三个方法的代码如下所示&#xff0c;都是将弹幕放入弹幕池&#xff0c;唯一的区别是自己发送的弹幕时&#xff0c;需要恢复轮询机制。
// 自己发弹幕sendBarrageBySelf(barrage) {this.barrage.barrageEnterQueueSelf(barrage);this.barrage.restart();}// 即时消息弹幕填充sendBarrageEnterQueue(list, isFilter &#61; true) {this.barrage.barrageEnterQueue(list, isFilter);}// init直播间的时候&#xff0c;历史弹幕填充multiPushBarrage(list) {this.sendBarrageEnterQueue(list, false);}
实现监听滚到到底部&#xff0c;需要先来了解一下浏览器的scrollHegiht
、scrollTop
、clientHegiht
三个属性。
当 clientHeight
&#43; scrollTop
>&#61; scrollHeight
时&#xff0c;表示已经抵达内容的底部了&#xff0c;可以加载更多内容。
列表滚动时&#xff0c;由于当前实际上屏的弹幕列表数量和内容&#xff08;内容长度不一&#xff0c;有的 1 行&#xff0c;有的 2 行&#xff0c;有的 3 行&#xff09;发生变化&#xff0c;需要先校准容器的实际高度&#xff0c;方便后续做滚动到底部判断。同时设置弹幕激活策略&#xff0c;静止 n 秒后恢复滚动。
// 滚动容器会计算高度,并激活弹幕自动恢复handleScrollBarrageContainer({ detail }) {this.containerScrollHeight &#61; detail.scrollHeight;this.checkContainerClientHeight();this.barrage.setActiveAndAutoRestart();}// 校准弹幕容器高度checkContainerClientHeight() {if (!this.containerClientHieghtChecked) {wx.createSelectorQuery().in(this as TrivialInstance).select(&#39;.live-barrage&#39;).fields({size: true,},res &#61;> {if (res) {const { height &#61; 0 } &#61; res;if (height &#61;&#61;&#61; this.containerClientHeight) {this.containerClientHieghtChecked &#61; true;}this.containerClientHeight &#61; height;}},).exec();}}
列表滚动到底部&#xff0c;激活弹幕上屏逻辑。
// 滚动到底部&#xff0c;重新开启刷新策略handleScrollBottom() {if (!this.barrageTouch) {this.barrage.restart();}}
用户触摸屏幕动作开始时&#xff0c;暂停弹幕上屏逻辑。
barrageTouchStart() {this.barrageTouch &#61; true;this.barrage.pause();}
用户触摸动作结束时&#xff0c;判断是否滚动到底部&#xff0c;如果是的话&#xff0c;恢复弹幕上屏逻辑
// 判断弹幕是否到底barrageTouchEnd() {this.barrageTouch &#61; false;wx.createSelectorQuery().in(this as TrivialInstance).select(&#39;.live-barrage&#39;).fields({scrollOffset: true,},({ scrollTop }) &#61;> {if (this.containerClientHeight &#43; scrollTop &#43; 5 >&#61;this.containerScrollHeight) {this.begainScroll &#61; true;this.barrage.restart();}},).exec();}
在线直播源码评论弹幕是如何“练”成的&#xff1f;
本文转载自网络&#xff0c;感谢&#xff08;蔡小真&#xff09;的分享&#xff0c;转载仅为分享干货知识&#xff0c;如有侵权欢迎联系云豹科技进行删除处理