3615 字
18 分钟
B站表情渲染机制研究

Cover Image 爻光 | knmoca

B站表情渲染机制研究#

前言#

最开始看 B 站表情时,很容易先入为主地把它当成普通 emoji。毕竟在评论区里看到一句 啊~是陀螺王来了[吃瓜],直觉上会以为前端只是把某些特殊字符渲染成图片了。

但真往实现里翻,就会发现事情没有这么简单。B 站这套表情机制,至少在目前 PiliNara 实际碰到的场景里,已经分成了几条完全不同的链路:

  • 评论区、私信、直播底部弹幕面板里的“方括号表情”
  • 动态、专栏这种服务端直接下发富文本节点的表情
  • 直播消息里整条消息就是一张图的 uemote

它们表面看起来都叫“表情”,但返回结构、渲染方式、尺寸语义其实都不一样。

本文主要基于 PiliNara 当前实现和上游PiliPlus的实现和实际抓到的返回数据,整理一下 B 站表情的工作机制,以及直播 uemote 这条链路里几个比较容易踩坑的地方。

需要提前说明一点:本文讨论的是评论区、私信、动态、直播底部弹幕面板这些富文本显示链路,不讨论播放器上的 canvas_danmaku 飘屏弹幕层。后者本身就是另一套渲染体系。

B站表情机制的解析与渲染方法#

它并不是普通意义上的 emoji#

[吃瓜][doge] 这种内容,从数据层看通常并不是 Unicode emoji,而是一段普通文本 token

也就是说,后端并不会把它编码成某种特殊字符,而是仍然保留为:

[吃瓜]

前端真正做的事情,其实是:

  1. 先拿到原始文本
  2. 再拿到一份“这个 token 对应哪张图片”的映射表
  3. 最后在渲染时把文本中的命中片段替换成图片 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 -> url
  • text -> size

这样的本地映射,再对消息正文做扫描。命中后同样替换成图片.

这一层和评论区很像,只是协议长得不一样:

  • 评论区是 message + emotes
  • 私信是 content + eInfos

但渲染思想是一样的,依然是“原文 token + 图片映射”。

动态 / 专栏:服务端直接下发表情节点#

动态、专栏再往前走一步,不再要求前端自己从一长串纯文本里拆 [吃瓜] 了。

这类场景里,服务端很多时候直接把内容切成了富文本节点,例如:

  • 普通文本节点
  • 话题节点
  • @ 节点
  • 链接节点
  • RICH_TEXT_NODE_TYPE_EMOJI

前端拿到这种结构之后,直接按节点类型渲染就行。命中 RICH_TEXT_NODE_TYPE_EMOJI 时,直接插入图片,不需要再手写一轮 token 扫描。

这也是为什么动态、专栏这类页面的表情链路通常比评论区更“稳定”:因为服务端已经帮前端把富文本拆好了。

但是这里有个额外的坑,默认的动态获取接口里面,会将原文所有的链接替换成”网页链接”,导致如果普通的获取就会丢失掉文章中的链接,所以需要使用动态详情接口重新获取,在详情接口中,原文链接会被保留.具体做法就这里不细说了

直播底部弹幕面板:emotsuemote 是两套东西#

直播底部弹幕面板是最容易让人误判的一块,因为它里面同时存在两种看起来都像“表情”的东西:

  1. emots
  2. uemote

它们虽然都显示成图,但语义完全不同。

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 个:

  • url
  • emoticon_unique
  • width
  • height

而几类 uemote 的判断,实际上并不是接口额外给了 isOfficialisRoom 这种字段,而是根据 emoticon_unique 的前缀派生出来的本地分类

常见类型目前至少有这几种: 房间专属表情

  • 房间专属表情 room_{{room_id}}_{{int}}
  • 通用表情 (包含一般通用表情和个别频道的特殊表情)official_{{int}}
  • 付费相关表情,比如收藏集,装扮等 upower_[{{emote}}]

从协议设计上看,这个字段更像是 B 站在直播系统里给表情做的一层“类型命名空间”。而真正让前端头疼的地方在于:不同前缀下,width/height 的语义并不统一,感觉官方在瞎写?

这也是后面踩坑的根源。

几类 uemote 的渲染踩坑与处理方式#

这一段是本文最有价值的部分。因为 uemote 真正难的地方,不是“怎么显示一张图”,而是你到底该不该信接口返回的尺寸

1. room_*:当前策略能正常工作的类型#

先看一个正常的例子:

如图: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 / DPRheight / 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,官方却显示成大图#

最离谱的坑出现在这类应援装扮、收藏集、装扮赠送相关表情上。

实际抓到的一个例子如下: 在PiliNara的显示效果 官方客户端显示效果

对应的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 渲染”,基本一定会出错。

真正稳妥的思路反而是:

  1. 先按 emoticon_unique 做分类
  2. 再为不同类别选择不同的尺寸策略
  3. 在没有完整协议文档时,允许保留少量经验修正

这听起来土一点,但在实际逆向和兼容类工作里,往往比“一套优雅大一统公式”更靠谱。

总结#

B 站表情机制并不是一个统一模型,而是几套机制并行存在:

  • 评论区、私信、直播行内表情,本质上是“文本 token + 映射表”
  • 动态、专栏更多是“服务端直接下发富文本节点”
  • 直播 uemote 则是一类“整条消息就是表情图”的独立消息

而真正麻烦的地方不在于“怎么显示图片”,而在于:

同样叫 uemote,不同前缀下的尺寸字段并不遵守同一种语义。

目前基于 PiliNara 的实现和实际抓包,比较稳妥的策略已经收敛成下面这样:

  • room_*:保留现状,继续按 width/height / DPR 处理
  • official_*:在当前基础上额外放大一档,避免显示过小
  • upower_[...]:对部分装扮/收藏集表情,不再信返回的 20x20,而是使用经验源尺寸 162px

这套方案未必是最终答案,但至少它符合一个事实:官方客户端自己也没有完全“照接口尺寸渲染”。既然目标是和官方保持一致,那前端实现就不能只迷信协议字段,还得尊重实际表现。

如果后面继续往下深挖,这条链路真正值得做的事不是再写一层更复杂的缩放公式,而是继续积累样本,把不同 emoticon_unique 家族的行为边界摸清楚。等规则足够稳定之后,再把经验判断收敛成更明确的分类逻辑,这样比一开始就追求“绝对通用解”更现实。

B站表情渲染机制研究
https://blog.170529.xyz/posts/bilibili_emote_rendering_research/
作者
Starfallen
发布于
2026-03-28
许可协议
CC BY-NC-SA 4.0