Cover Image 爻光 | knmoca
B站表情渲染机制研究
前言
最开始看 B 站表情时,很容易先入为主地把它当成普通 emoji。毕竟在评论区里看到一句 啊~是陀螺王来了[吃瓜],直觉上会以为前端只是把某些特殊字符渲染成图片了。
但真往实现里翻,就会发现事情没有这么简单。B 站这套表情机制,至少在目前 PiliNara 实际碰到的场景里,已经分成了几条完全不同的链路:
- 评论区、私信、直播底部弹幕面板里的“方括号表情”
- 动态、专栏这种服务端直接下发富文本节点的表情
- 直播消息里整条消息就是一张图的
uemote
它们表面看起来都叫“表情”,但返回结构、渲染方式、尺寸语义其实都不一样。
本文主要基于 PiliNara 当前实现和上游PiliPlus的实现和实际抓到的返回数据,整理一下 B 站表情的工作机制,以及直播 uemote 这条链路里几个比较容易踩坑的地方。
需要提前说明一点:本文讨论的是评论区、私信、动态、直播底部弹幕面板这些富文本显示链路,不讨论播放器上的 canvas_danmaku 飘屏弹幕层。后者本身就是另一套渲染体系。
B站表情机制的解析与渲染方法
它并不是普通意义上的 emoji
像 [吃瓜]、[doge] 这种内容,从数据层看通常并不是 Unicode emoji,而是一段普通文本 token。
也就是说,后端并不会把它编码成某种特殊字符,而是仍然保留为:
[吃瓜]前端真正做的事情,其实是:
- 先拿到原始文本
- 再拿到一份“这个 token 对应哪张图片”的映射表
- 最后在渲染时把文本中的命中片段替换成图片 span
所以这类表情在复制时还能拿到 [吃瓜] 原文,并不奇怪。因为原始内容本来就是这个字符串,图片只是显示层效果。
评论区:message + emotes 的经典模式
评论区这条链最典型。
在 gRPC 的评论 Content 结构里,既有原始文本 message,也有 emotes 映射表。PiliPlus 的评论渲染逻辑会把:
content.emotes.keys- 话题
@用户名- URL
这些特殊 token 全部拼进正则,然后对 content.message 做一次扫描,命中 emotes 时替换成 WidgetSpan。
这一套的好处是很明显的:
- 原文可复制
- 表情和普通文本可以混排
- 同一套扫描逻辑还能顺手处理话题、时间戳、链接、投票
评论区表情的尺寸也不是直接看图片原始宽高,而是使用服务端下发的 size 字段,再乘一个前端基准值。目前 PiliPlus 里评论区的实现是:
final size = emote.size.toInt() * 20.0;所以评论区里“有些表情大,有些表情小”,本质上是:
- 服务端决定相对大小等级
- 前端决定最终展示基准
私信:同样是 token 替换,但结构换成了 eInfos
私信并没有复用评论区的 emotes 字段,而是通过 IM 接口单独返回一组 EmotionInfo 列表。
PiliPlus 的做法是把这组 eInfos 转成:
text -> urltext -> size
这样的本地映射,再对消息正文做扫描。命中后同样替换成图片.
这一层和评论区很像,只是协议长得不一样:
- 评论区是
message + emotes - 私信是
content + eInfos
但渲染思想是一样的,依然是“原文 token + 图片映射”。
动态 / 专栏:服务端直接下发表情节点
动态、专栏再往前走一步,不再要求前端自己从一长串纯文本里拆 [吃瓜] 了。
这类场景里,服务端很多时候直接把内容切成了富文本节点,例如:
- 普通文本节点
- 话题节点
@节点- 链接节点
RICH_TEXT_NODE_TYPE_EMOJI
前端拿到这种结构之后,直接按节点类型渲染就行。命中 RICH_TEXT_NODE_TYPE_EMOJI 时,直接插入图片,不需要再手写一轮 token 扫描。
这也是为什么动态、专栏这类页面的表情链路通常比评论区更“稳定”:因为服务端已经帮前端把富文本拆好了。
但是这里有个额外的坑,默认的动态获取接口里面,会将原文所有的链接替换成”网页链接”,导致如果普通的获取就会丢失掉文章中的链接,所以需要使用动态详情接口重新获取,在详情接口中,原文链接会被保留.具体做法就这里不细说了
直播底部弹幕面板:emots 和 uemote 是两套东西
直播底部弹幕面板是最容易让人误判的一块,因为它里面同时存在两种看起来都像“表情”的东西:
emotsuemote
它们虽然都显示成图,但语义完全不同。
emots:行内表情
这类更像评论区那套机制。
直播消息正文仍然是一段文本,比如:
啊这[吃瓜]如图:
同时 extra['emots'] 里会给出:
"[吃瓜]" -> { url, width, height, ... }
前端渲染时扫描正文,命中后把这段方括号 token 换成图片。
这种情况下,图片是“嵌在文本里”的,所以它属于行内表情。
uemote:整条消息就是一张图
而 uemote 不是行内替换,而是整条消息本身就是一个表情消息。
一旦消息里存在 uemote,PiliPlus 当前实现会直接走图片分支,返回一个单独的 WidgetSpan,不再继续渲染 text 文本。
这类消息里 text 字段很多时候更像是:
- 兼容字段
- 语义文本
- 回退文案
但真正显示给用户看的,是 uemote 里的图片。
uemote 的返回解析
目前在 PiliNara 里,直播 uemote 被解析成了一个很轻量的 BaseEmote:
class BaseEmote { late String url; late String emoticonUnique; late double width; late double height; late final isUpower = emoticonUnique.startsWith('upower_'); //以下是PiliNara相较于PiliPlus新增的字段,根据emoticonUnique前缀派生出来的本地分类 late final isOfficial = emoticonUnique.startsWith('official_'); late final isRoom = emoticonUnique.startsWith('room_');}也就是说,当前渲染阶段真正依赖的核心字段只有 4 个:
urlemoticon_uniquewidthheight
而几类 uemote 的判断,实际上并不是接口额外给了 isOfficial、isRoom 这种字段,而是根据 emoticon_unique 的前缀派生出来的本地分类。
常见类型目前至少有这几种: 房间专属表情
- 房间专属表情
room_{{room_id}}_{{int}} - 通用表情 (包含一般通用表情和个别频道的特殊表情)
official_{{int}} - 付费相关表情,比如收藏集,装扮等
upower_[{{emote}}]
从协议设计上看,这个字段更像是 B 站在直播系统里给表情做的一层“类型命名空间”。而真正让前端头疼的地方在于:不同前缀下,width/height 的语义并不统一,感觉官方在瞎写?
这也是后面踩坑的根源。
几类 uemote 的渲染踩坑与处理方式
这一段是本文最有价值的部分。因为 uemote 真正难的地方,不是“怎么显示一张图”,而是你到底该不该信接口返回的尺寸。
1. room_*:当前策略能正常工作的类型
先看一个正常的例子:
如图:
{ "name": "xxx", //用户名 "text": "嘤嘤嘤", //文本内容,但对于uemote来说通常不显示 "uemote": { "url": "http://i0.hdslb.com/bfs/live/904ead78c8c91f5defc18ac506ad6dc5b9cdf694.png", "emoticon_unique": "room_1370893_24538", "width": 162.0, "height": 162.0 }, "extra": { "id": "7d0b33b9d07dcef93da32f42ad69c7786684", "mid": 675119090, "dm_type": 1, "ts": 1774680118, "ct": "2761E891" }}这一类在 PiliNara 当前实现里显示正常,和官方一致。
原因并不是因为代码里专门对 room_* 做了什么高级处理,而是因为现有的缩放规则刚好适配了它的返回值语义。
当前 uemote 渲染逻辑里,除 upower_* 和 official_* 之外,默认会对尺寸做:
width = uemote.width / devicePixelRatio;height = uemote.height / devicePixelRatio;如果 room_* 返回的 162x162 更接近“物理像素尺寸”,那在 Flutter 里除以设备 DPR 之后,就正好变成了合理的逻辑像素尺寸。
也就是说,room_* 之所以没出问题,不是因为协议最规范,而是因为它返回的数据刚好跟当前这套换算规则对上了。
因此当前策略是:
room_*保留现状,不动
2. official_*:直接按现有规则渲染会偏小
再看 official_* 的实际抓包:
{ "name": "xxxxx", "text": "一诺行为", "uemote": { "url": "https://i0.hdslb.com/bfs/live/74049288b8e65ab81ecddfe5d4dc475cd8a56b85.png", "emoticon_unique": "official_54", "width": 195.0, "height": 60.0 }}这类消息最开始在 PiliNara 里的显示会偏小。原因也很扯淡:
- 当前旧逻辑里,它会被当成“普通非 upower 的 uemote”
- 然后直接走
width / DPR、height / DPR
如果设备 DPR 比较高,例如 3 左右,那么:
60 / 3 = 20
视觉上就会很接近一行字的高度,这是非常正常的。
official_* 的返回尺寸是正确的,处理也是正确的,那为啥会偏小呢?原因是官方客户端里这类表情的实际显示尺寸,比接口返回的尺寸要大一些。
在当前版本里,PiliNara 对 official_* 的处理做了一个经验修正:
- 仍然先除以 DPR
- 再在此基础上额外乘
1.25
实现大致是:
width = uemote.width / devicePixelRatio * 1.25;height = uemote.height / devicePixelRatio * 1.25;这不是来自官方文档,而是基于实际对比官方客户端效果后的工程修正。它的优点是实现简单、抖动小、成本低;缺点也很明显,就是没有明确的协议依据,完全是个经验值。
但在没有更完整官方说明的前提下,这已经是比较稳妥的折中。
3. upower_[...]:接口明明给了 20x20,官方却显示成大图
最离谱的坑出现在这类应援装扮、收藏集、装扮赠送相关表情上。
实际抓到的一个例子如下:

对应的API返回是这样的:
{ "name": "珈乐Dollar", "text": "[星绘·沐春灼华 应援装扮_腹黑]", "uemote": { "url": "https://i0.hdslb.com/bfs/garb/ea50e865dab1906e04c87fc268e7259be0782292.png", "emoticon_unique": "upower_[星绘·沐春灼华 应援装扮_腹黑]", "width": 20.0, "height": 20.0 }}如果单看这份返回,很自然会认为这是一张小图表情。
但实际对比官方客户端后会发现,官方根本没有按 20x20 显示,而是按大图展示,视觉尺寸接近图片原始 162x162。
这说明了一个非常重要的事实:
对
upower_[...]这类uemote,接口返回的width/height不能直接信。
这类坑和 official_* 还不一样。official_* 更像是“返回值能参考,但不能机械照搬”;而 upower_[...] 在部分场景里是“返回值本身就和官方客户端的最终显示不一致”。
那怎么办?最开始,一种最直觉的想法是:
- 实时去请求图片原始尺寸
但这个方案实际并不优雅:
- 会增加额外网络请求
- 首帧显示容易先小后大
- 同一张图会重复查询
- 对聊天列表这种高频场景不太友好
因此,PiliNara 目前采取的是最暴力的处理方式:
- 对
upower_*这类消息不再信返回的20x20 - 直接按一个经验源尺寸
162px处理 - 再换算成 Flutter 里的逻辑像素
当前实现是:
const upowerDefaultPx = 162.0;width = upowerDefaultPx / devicePixelRatio;height = upowerDefaultPx / devicePixelRatio;这依然不是来自官方文档,而是基于目前抓到的装扮/收藏集表情样本做出的经验规则。
它的核心思想其实很简单:
room_*可以信接口尺寸official_*半信半疑,需要修正upower_[...]的某些子类根本不能信接口尺寸
4. 为什么不能把所有 uemote 用一套规则吃掉
到这里其实已经能得出一个很明确的结论:
uemote.width/height 在不同类型下,语义并不统一。
至少目前已有样本说明:
room_*:当前更像“物理像素尺寸”,除以 DPR 后结果正确official_*:单纯除以 DPR 会偏小,需要经验放大upower_[...]:至少部分装扮类消息里,接口尺寸和官方最终显示明显不一致
因此如果还坚持“所有 uemote 都统一按 width/height 渲染”,基本一定会出错。
真正稳妥的思路反而是:
- 先按
emoticon_unique做分类 - 再为不同类别选择不同的尺寸策略
- 在没有完整协议文档时,允许保留少量经验修正
这听起来土一点,但在实际逆向和兼容类工作里,往往比“一套优雅大一统公式”更靠谱。
总结
B 站表情机制并不是一个统一模型,而是几套机制并行存在:
- 评论区、私信、直播行内表情,本质上是“文本 token + 映射表”
- 动态、专栏更多是“服务端直接下发富文本节点”
- 直播
uemote则是一类“整条消息就是表情图”的独立消息
而真正麻烦的地方不在于“怎么显示图片”,而在于:
同样叫
uemote,不同前缀下的尺寸字段并不遵守同一种语义。
目前基于 PiliNara 的实现和实际抓包,比较稳妥的策略已经收敛成下面这样:
room_*:保留现状,继续按width/height / DPR处理official_*:在当前基础上额外放大一档,避免显示过小upower_[...]:对部分装扮/收藏集表情,不再信返回的20x20,而是使用经验源尺寸162px
这套方案未必是最终答案,但至少它符合一个事实:官方客户端自己也没有完全“照接口尺寸渲染”。既然目标是和官方保持一致,那前端实现就不能只迷信协议字段,还得尊重实际表现。
如果后面继续往下深挖,这条链路真正值得做的事不是再写一层更复杂的缩放公式,而是继续积累样本,把不同 emoticon_unique 家族的行为边界摸清楚。等规则足够稳定之后,再把经验判断收敛成更明确的分类逻辑,这样比一开始就追求“绝对通用解”更现实。