HarmonyOS 6实战(源码教学篇)— 从马年春晚看弹幕背后的技术点:当鸿蒙遇上“蒙版弹幕”
摘要 今年春晚首次引入直播弹幕并与B站联动,技术亮点包括智能避让人物、立体视觉弹幕和金色特效弹幕。弹幕系统采用数据对象模型管理生命周期,通过60fps刷新实现流畅动画,并控制同屏数量保证性能。B站创新性地使用MediaPipe模型在客户端生成人像遮罩,结合降帧率、后台线程、降分辨率等优化手段,将CPU占用从70%降至5%,显著提升用户体验并延长观看时长30%。该技术方案展现了高性能弹幕系统的实现思
HarmonyOS 6实战(源码教学篇)— 从马年春晚看弹幕背后的技术点:当鸿蒙遇上“蒙版弹幕”
今年春晚的弹幕,有点不一样
想必有很多热心网友发现了,今年春晚第一次搞直播弹幕,还跟B站联动。原本以为就是图个热闹,结果看着看着发现有点意思——弹幕会根据镜头的位置带一些倾斜角以更好的带来视觉立体感,主持人说话的时候,弹幕会主动让开面部;重要时刻飘过的金色弹幕,还自带光影效果。
作为一名技术人, 我去搜索了一下相关数据,B站作为马年总台春晚的独家弹幕视频平台,2月16日春晚直播期间,B站弹幕总数达1.33亿,平均每秒发送7700条。
得,这下彻底没法专心看节目了。
弹幕怎么“活”起来的
先说说最基础的。一条弹幕从生成到消失,其实就是一个数据对象的一生。
export class BulletComment {
public id: number; // 唯一标识
public content: Resource | string; // 弹幕内容
public color: string; // 弹幕颜色
public positionY: number; // Y 轴位置(百分比)
public translateX: number; // X 轴位置(vp)
public speed: number; // 移动速度
public isUserBulletComment: boolean; // 是否用户发送
constructor(content: Resource | string, isUserBulletComment: boolean = false) {
this.id = new Date().getTime();
this.content = content;
this.color = '#FFFFFF';
// 随机 Y 轴位置(4 个轨道)
let randomIndex = Math.floor(Math.random() * 5);
this.positionY = (randomIndex + 1) % 4 * 33;
// 从右侧屏幕外开始
this.translateX = 1000;
// 移动速度
this.speed = 2;
this.isUserBulletComment = isUserBulletComment;
}
}
每条弹幕都有编号、内容、颜色、在屏幕上的垂直位置、水平位置、移动速度。这就像给每个观众发了一张“入场券”,告诉它什么时候从右边进场,走哪条跑道,跑多快。
春晚那天晚上,无数这样的数据对象在系统里生成、移动、消失。我们看到的满屏弹幕,其实就是这些对象在循环刷新。
如何实现流畅的弹幕效果

看直播的时候,弹幕飘得特别顺滑,没有一卡一卡的感觉。这是因为需要实时刷新每条弹幕的位置。
private startAnimation() {
if (this.timerId > 0) {
clearInterval(this.timerId);
}
// 每 16ms 更新一次(约 60fps)
this.timerId = setInterval(() => {
let needUpdate = false;
// 更新所有弹幕位置
this.bulletComments.forEach(item => {
const positionX = item.translateX - item.speed;
if (positionX !== item.translateX) {
item.translateX = positionX;
needUpdate = true;
}
});
// 移除超出屏幕的弹幕
const beforeLength = this.bulletComments.length;
this.bulletComments = this.bulletComments.filter(item => item.translateX > -20);
// 触发 UI 刷新
if (needUpdate || this.bulletComments.length !== beforeLength) {
this.forceUpdate = !this.forceUpdate;
}
}, 16);
}
这段代码干的事很简单:每16毫秒,把每条弹幕往左挪一点,挪到屏幕外面的就扔掉。人眼看连续的画面需要每秒24帧以上,60帧已经远远超出人眼的感知极限,所以会觉得特别流畅。
不过这里有个细节:弹幕太多会互相遮挡,也影响性能。所以最好控制同屏弹幕数量,超过50条就从最老的开始扔掉。春晚也是这么干的——你仔细看,屏幕上永远不会同时出现太多弹幕。
用户弹幕怎么区分
看春晚的时候,我发现有些弹幕的样式不一样。往往在实际项目里,我们可以通过不同的组件样式,去区分用户的类型,比如是是否是会员用户、用户渠道来源等等不同的业务效果,比如代码可以这么写:
if (item.isUserBulletComment) {
// 用户弹幕样式(带边框)
Text(item.content)
.fontSize(14)
.fontColor(item.color)
.translate({ x: `${item.translateX}vp`, y: 0 })
.position({ y: `${item.positionY}%` })
.backgroundColor('rgba(0,0,0,0.4)')
.borderRadius(999)
.borderWidth(1)
.borderColor('rgba(255,255,255,0.6)')
.padding({ top: 4, left: 8, bottom: 4, right: 8 })
.opacity(0.5)
} else {
// 其他弹幕样式
Text(item.content)
.fontSize(14)
.fontColor(item.color)
.translate({ x: `${item.translateX}vp`, y: 0 })
.position({ y: `${item.positionY}%` })
.backgroundColor('rgba(0,0,0,0.4)')
.borderRadius(999)
.padding({ top: 4, left: 8, bottom: 4, right: 8 })
.opacity(0.5)
}
一个简单的边框参数,就让两种弹幕有了身份区别。用户发弹幕的时候,会在数据模型里把isUserBulletComment设为true,渲染的时候就会自动加上白色边框。
蒙版弹幕:B站是怎么让弹幕学会“躲人”的

这次春晚最让人好奇的是弹幕会“躲人”,实际上之前在刷b站的视频时看到这个功能,我个人就比较感兴趣。后来翻到一篇B站的技术分享,才发现这功能背后藏着不少门道。
B站面对的问题其实很直接:弹幕太多会把画面里的人物挡住,影响观看体验。但要是简单地把弹幕都挪到边上去,又少了那种“满屏都是网友”的热闹劲儿。最好是弹幕照飘,但人物不挡。
这个需求听起来简单,实现起来却有两个绕不开的坎。
第一个坎是成本。B站每天要处理海量的视频内容,如果都在服务器端做人像识别,服务器开销太大了。他们需要的是能在用户设备上跑的方案。
第二个坎是性能。设备端跑机器学习模型,搞不好就把CPU占满了,视频卡顿不说,笔记本风扇能转得飞起。用户肯定不答应。
B站最后用MediaPipe的人体分割模型解决了第一个坎。这个模型能在浏览器里实时提取视频中的人像轮廓,生成一个遮罩——人像区域透明,背景区域白色。然后把这个遮罩通过CSS的mask-image属性应用到弹幕层上,弹幕飘到人像区域就会自动被“挖空”。
原理听起来不复杂,但真正难的是第二个坎——性能。
从70%到5%:一场性能优化的接力赛
B站的技术团队一开始把方案跑起来,发现CPU占用直奔70%。这个数字意味着什么?一些性能不好的笔记本,看个视频风扇狂转。必须优化。
他们做的第一件事是降低遮罩的刷新频率。视频一般是30帧/秒,但遮罩没必要刷那么快,人眼对弹幕避让的敏感度没那么高。降到15帧/秒,体验上基本看不出差别,CPU直接降到50%。
第二件事是把耗时的操作挪到后台。原来的实现里,每次生成遮罩都要在当前线程跑一遍完整的流程,其中canvas操作和图片导出特别占资源。他们改用OffscreenCanvas加Web Worker,把这些活儿扔到后台线程去干,主线程只负责展示结果。这一下CPU又降了一截,到33%左右。
第三件事最有意思——给遮罩“降分辨率”。仔细想想,弹幕避让人物需要的是精确轮廓,但不需要高清大图。他们把遮罩的尺寸缩小到360P甚至更低,导出的时候图片从100多KB变成十几KB,渲染压力小多了。这一步优化完,CPU降到了15%。
最后一步:画面里没人的时候,干脆不跑了。用getImageData判断画面里有没有人像区域,没有就直接停掉整个计算流程。这下CPU能降到接近0%。
一圈折腾下来,CPU占用从70%干到5%,体验丝滑,风扇不转,功能上线。B站后来统计,上线这个功能后,用户观看会话时长增加了30%。
虽然上面的代码没有在直接实现动态蒙版,但鸿蒙系统提供了mask接口,理论上可以接入图像识别算法,实时生成蒙版。
或者也可以参考我们系列文章之前的内容,利用MindSpore Lite Kit去转换类似的图像识别算法,参考b站的思路进行优化。
字幕系统:多层级之间的处理

弹幕热闹,字幕也不能落下。我们发现字幕层级有一些细节,它的层级在人像之上,但却可以被其他的弹幕遮挡,我们先实现一个基础的字幕系统。
@Component
export struct CaptionFontView {
@Prop captionFont: CaptionFont;
close: (captionFont: CaptionFont | null) => void = () => {};
// 字体家族选择
@Builder
FontFamily(text: ResourceStr) {
Button(text)
.fontSize(16)
.fontColor(this.captionFont.family === text ? Color.Black : Color.White)
.backgroundColor(
this.captionFont.family === text ?
'rgba(255,255,255,0.9)' :
'rgba(255,255,255,0.1)'
)
.onClick(() => {
this.captionFont.family = text;
})
}
// 字体大小选择
@Builder
FontSize(text: ResourceStr) {
Button(text)
.fontSize(16)
.fontColor(this.captionFont.size === Number(text) ? Color.Black : Color.White)
.backgroundColor(
this.captionFont.size === Number(text) ?
'rgba(255,255,255,0.9)' :
'rgba(255,255,255,0.1)'
)
.onClick(() => {
this.captionFont.size = Number(text);
})
}
// 字体颜色选择
@Builder
FontColor(captionFontColor: ResourceColor) {
Column() {
// 选中标记
if (this.captionFont.color === captionFontColor) {
Column()
.width(12)
.height(12)
.borderRadius('50%')
.border({ width: 1, color: Color.Gray })
}
}
.width(24)
.height(24)
.borderRadius('50%')
.backgroundColor(captionFontColor)
.justifyContent(FlexAlign.Center)
.onClick(() => {
this.captionFont.color = captionFontColor;
})
}
build() {
Column() {
// ... 布局代码
}
}
}
这个组件是一个独立的设置面板,可以选择字体、大小、颜色。用户改设置的时候,字幕会实时变化,所见即所得。
字幕文件的格式也很标准,就是最常见的SRT:
1
00:00:00,000 --> 00:00:03,000
这是第一条字幕
2
00:00:03,000 --> 00:00:06,000
这是第二条字幕
系统通过AVPlayer加载字幕文件,然后监听字幕更新事件:
subtitleUpdateFunction(): void {
if (this.avPlayer) {
this.avPlayer.on('subtitleUpdate', (info: media.SubtitleInfo) => {
if (info) {
let text = info.text || '';
let startTime = info.startTime || 0;
let duration = info.duration || 0;
// 更新当前字幕内容
this.currentCaption = text;
} else {
this.currentCaption = '';
}
});
}
}
播放器走到哪个时间点,就显示对应的字幕内容。简单可靠。
发弹幕这事儿,也有讲究

发弹幕最怕什么?打字打到一半,节目演完了。所以好的弹幕输入体验,应该能自动暂停视频,发完再继续。
// 横屏模式下的弹幕输入框
TextInput({
text: this.bulletCommentInput,
placeholder: $r('app.string.placeholder')
})
.backgroundColor('rgba(255,255,255,0.4)')
.placeholderColor('rgba(255,255,255,0.7)')
.fontColor(Color.White)
.height(30)
.layoutWeight(1)
.onFocus(() => {
// 输入时暂停视频
this.pausePlay();
this.isInputtingBulletComment = true;
})
.onBlur(() => {
// 失焦时恢复播放
this.resumePlayback();
this.isInputtingBulletComment = false;
})
.onChange((value: string) => {
this.bulletCommentInput = value;
})
// 发送按钮
Button() {
Image($r('app.media.send_bulletcomment'))
.width(24)
.height(24)
}
.onClick(() => {
// 关闭键盘
try {
inputMethod.getController().stopInputSession();
} catch (exception) {
hilog.error(0x0000, TAG, `stopInputSession failed: ${exception.message}`);
}
this.sendBulletComment();
})
点击输入框,视频暂停;点击发送,键盘收起,视频继续。这套逻辑虽然简单,但很贴心——不会让你错过任何精彩瞬间。
一些没写进代码的观察
翻完这些代码和B站的技术文章,再看今年春晚的弹幕,感觉不太一样了。
那些看似随意飘过的文字,背后是一套完整的算法在运转:数据模型、动画循环、性能优化、交互设计,每个环节都得考虑到位。
-
弹幕轨道为什么要分成四层?因为实验证明四层最舒服,太少显得冷清,太多互相遮挡。
-
为什么用户弹幕要加白边?因为要让观众感觉到“这是我发的”,有参与感。
-
为什么要限制同屏弹幕数量?因为要保证每条弹幕都能被看清楚,而不是一锅粥。
B站那套从70%到5%的优化路径也让我想起一件事:很多时候,技术方案看起来很美,但要真正落地,得靠这种一点点抠细节的功夫。而他们最终换来的是用户会话时长增加30%——用户不会知道你用了什么技术,他们只知道“在这儿看视频很舒服”。
这些细节,看节目的时候根本注意不到,但缺了任何一个,体验都会打折扣。
我突然有点期待明年的春晚在技术上,又会玩出什么新花样。
参考资料
更多推荐


所有评论(0)