QQ 气泡主题逆向:文件结构、轮换机制与按需下载
QQ 气泡主题逆向:文件结构、轮换机制与按需下载
环境:POCO 22041216G · Android 13 · Magisk (root) · Termux 目标:
com.tencent.mobileqq结论先行:loopList不只是「本地资源的排列」,它同时是一张资源拉取清单。写进去的 ID,QQ 会自己去下载。
0. 起因
随便找一款免费气泡用,把自制配置覆盖进去,就能白嫖付费气泡——这件事本身不难。难的是搞懂它为什么能work,以及几个反直觉的细节:
- 为什么改了
loopList,第一条消息还是自己选的那个气泡? - 那 34 个本地根本没有的 ID,为什么写进去也能显示?
- 为什么换了图,旧特效还在?
这篇把整条链路拆开讲。

1. 气泡文件在哪
QQ 把所有可下载的个性化资源放在 VAS 资源目录下,气泡是其中之一:
/data/data/com.tencent.mobileqq/files/files/vas_material_folder/bubble_dir/同级还有其它类型的资源,顺带了解一下 QQ 的组织方式:
vas_material_folder/├── bubble_dir/ 气泡├── chat_back_ground/ 聊天背景├── face/ 表情├── font_info/ 字体├── pendant/ 挂件├── namePlate_dir/ 铭牌├── ring/ 铃声└── Gift/ 礼物这个目录在应用私有数据区,普通文件管理器进不去,需要 root:
su -c 'ls /data/data/com.tencent.mobileqq/files/files/vas_material_folder/bubble_dir'我这台设备上,这里有 1158 个条目、368 个气泡,占用约 81 MB。
容易走错的路径
外部存储里有几个名字很像的目录,但都不存气泡:
| 路径 | 实际内容 |
|---|---|
/sdcard/Tencent/MobileQQ/ | 日志、红包、勋章 |
/sdcard/com.tencent.mobileqq/ | 通常是空目录 |
/sdcard/Tencent/blob/ | 临时文件 |
files/files/bubble_info 名字最像,实测是空的,真正的内容在 bubble_dir。
2. 单个气泡包的结构
每个气泡由三部分组成,命名统一为 bubble.android.<ID>:
bubble.android.2120.config.json ← 配置文件bubble.android.2120.static.zip/ ← 静态图(目录,不是压缩包)bubble.android.2120.other.zip/ ← 附加资源(目录,不是压缩包)名字带
.zip,但它们是普通目录。QQ 下载后解压到同名目录里。
static:气泡本体
bubble.android.2120.static.zip/├── aio_user_bg_nor.9.png 气泡底图 128 × 112├── aio_user_pic_nor.9.png 图片消息气泡 128 × 112├── chat_bubble_thumbnail.png 缩略图 70 × 70└── 2_bubble.android.2120.static.zip.sge 资源校验文件other:动画资源
bubble.android.2120.other.zip/├── voice/ 普通动画帧(最常见)│ └── 0001.png ~ 0015.png├── bubbleframe/ 气泡框动画帧(进阶包才有)│ └── 0001.png ~ 0020.png├── shootAnim/ 发射动画(少见)└── config.json 独立配置(进阶包才有).sge 是资源完整性校验文件。自制包通常没有它,反而能绕过校验。
3. config.json 逐字段解析
以「七彩糖果」(2120)为例,格式化后:
{ "id": 2120, "name": "七彩糖果", "color": "0xFFe3712c", "link_color": "0xFF0079ff", "zoom_point": ["63", "60"], "version": 1479107488, "animations": { "stc1": { "align": "TL", "alpha": "false", "count": "15", "cycle_count": "3", "rect": ["0", "0", "56", "52"], "time": "100", "type": "static", "zip_name": "voice" } }, "key_animations": [ { "animation": "stc1", "count": "15", "cycle_count": "3", "key_word": ["你", "我", "她", "他", "好", "哈", "见", "嘿"], "rect": ["0", "0", "56", "52"], "time": "100", "zip_name": "voice" } ], "voice_animation": { "animation": "stc1", "count": "15", "rect": ["0", "0", "56", "52"], "time": "100" }, "loopList": ["2120", "2121", "2122", "2123", "2124", "2125", "2126"]}| 字段 | 作用 | 备注 |
|---|---|---|
id | 内部 ID | 可与文件名不同 |
name | 显示名称 | 空字符串表示无名 |
color | 气泡底色 | ARGB,0xAARRGGBB |
link_color | 链接文字颜色 | 同上 |
zoom_point | 拉伸锚点 | 点九图缩放基准点 |
version | 版本号 | 时间戳形式 |
animations | 动画定义表 | 可定义多个 |
key_animations | 关键词触发动画 | 发特定文字时播放 |
voice_animation | 语音消息动画 | |
loopList | 轮换池 | 见第 4 节 |
bubbleframe_animation | 气泡框动画绑定 | 仅进阶包 |
animation_sets | 动画集定义 | 仅进阶包 |
shadow_blur_color | 气泡阴影色 | 仅部分包 |
动画参数
"count": "15" 帧数"time": "100" 帧间隔(毫秒)"cycle_count": "3" 循环次数"rect": ["0","0","56","52"] 单帧尺寸 [x, y, w, h]"align": "TL" 对齐方式(Top-Left)"zip_name": "voice" 帧所在目录名key_word:关键词触发
发含特定关键词的消息时,气泡会播放对应动画。「七彩糖果」的触发词:
你 我 她 他 它 们 好 哈 见 嘿 啊 吧 不 没 了 的么 吗 嘛 呢 呀 哇 哦 噢 这 那 哪
全部 15 帧铺开:

4. loopList 的真实行为
这是最容易误解的地方,我踩了两个坑。
坑一:首条永远是「选中项」
"loopList": ["2141485", "2120", "2121", "2122", "2123", "2124", "2125", "2126"]实际渲染顺序:
第 1 条消息 → 当前选中的气泡(不是 loopList[0]!)第 2 条消息 → loopList[0]第 3 条消息 → loopList[1]...第 n 条消息 → loopList[(n-2) % len]第一条永远用「你在设置里选中的那个」,loopList 管不到它。
我试过两种改法,都无效:
// 尝试 1:把自己加进轮换池 → 循环不会回到自己"loopList": ["2119327", "2120", "2121", ...]
// 尝试 2:把自己移出轮换池 → 首条还是自己"loopList": ["2120", "2121", "2122", ...]想让第一条显示某个气泡,唯一办法是在 QQ 设置里选中它。
坑二:loopList 会触发按需下载 ⭐
这是我最开始判断错的地方。
我的错误推断:loopList 写了 42 个 ID,本地 bubble_dir 只有 8 个包,所以另外 34 个不显示。
实际结果:连续发消息触发轮换后,34 个包全部自动下载下来了:
发消息前: 1158 个条目发消息后: 1260 个条目 (+102 = 34 个包 × 3 项)结论:QQ 轮换到某个 ID 时,如果本地没有对应文件,会走下载流程。所以 loopList 同时是资源拉取清单:
- ID 只要是官方真实存在的,写进去就能被拉下来
- 不需要事先在气泡商店「拥有」它
- 触发条件是真正发消息,不是改完配置就立刻下载
验证方法:
# 记录基线su -c 'ls /data/data/com.tencent.mobileqq/files/files/vas_material_folder/bubble_dir | wc -l'# 发 10+ 条消息# 再次统计,对比是否增长su -c 'ls /data/data/com.tencent.mobileqq/files/files/vas_material_folder/bubble_dir | wc -l'loopList 填的是文件名,不是 id
# 文件名bubble.android.2119327.config.json ^^^^^^^ ← 填这个
# config 内部{"id": 13909} ← 不是这个两者可以不同,自制包常见这种情况。
5. 点九图(nine-patch)
aio_user_*.9.png 都是 Android 点九图。
在普通 PNG 基础上,最外圈 1 像素不参与显示,而是作为拉伸标记:
┌─────────────────────────────────┐│ ······························ │ ← 上边:横向拉伸区域│ ┌───────────────────────────┐ · ││ │ │ · ││ │ 实际内容区域 │ · │ ← 右边:纵向拉伸区域│ │ │ · ││ └───────────────────────────┘ · ││ ······························ │ ← 下边:内容边界└─────────────────────────────────┘ ↑ 左边:纵向拉伸区域- 黑线标记可拉伸区域
- 四角保持不变
- 中间做像素拉伸或平铺
浏览器直接打开会看到四周 1px 杂边,不是图片损坏。左边是原始文件,右边裁掉 1px:
| 原始(带标记线) | 裁剪后 |
|---|---|
![]() | ![]() |
裁剪后的图片消息气泡:

要干净显示需裁掉:
from PIL import Image
img = Image.open('aio_user_bg_nor.9.png')w, h = img.sizeimg.crop((1, 1, w - 1, h - 1)).save('bubble_bg_clean.png')6. 实战:替换默认气泡样式
目标:把自制包 2119327(内部 id 13909)的外观换成「珠箔银屏」(482),同时保留包身份。
6.1 完整备份(最重要)
P=/data/data/com.tencent.mobileqq/files/files/vas_material_folder/bubble_dirB=/sdcard/Download/bubble_backup
su -c "mkdir -p $B"su -c "cp $P/bubble.android.2119327.config.json $B/"su -c "cp -r $P/bubble.android.2119327.static.zip $B/"su -c "cp -r $P/bubble.android.2119327.other.zip $B/"6.2 替换静态图与动画帧
SRC=bubble.android.482DST=bubble.android.2119327
su -c "cp $P/$SRC.static.zip/aio_user_bg_nor.9.png $P/$DST.static.zip/"su -c "cp $P/$SRC.static.zip/aio_user_pic_nor.9.png $P/$DST.static.zip/"su -c "cp $P/$SRC.static.zip/chat_bubble_thumbnail.png $P/$DST.static.zip/"
su -c "rm -f $P/$DST.other.zip/voice/*.png"su -c "cp $P/$SRC.other.zip/voice/*.png $P/$DST.other.zip/voice/"6.3 对齐 config 参数
所有跟图片尺寸相关的参数必须一起改:
| 字段 | 原值(麻薯) | 新值(珠箔银屏) |
|---|---|---|
name | 颜文字-麻薯小兔 | 珠箔银屏 |
color | 0xFFe99ddd | 0xFF252525 |
link_color | 0xFF007eff | 0xFF0e8cc7 |
zoom_point | ["64","56"] | ["64","56"] |
animations.stc1.count | 16 | 14 |
animations.stc1.rect | ["0","0","128","112"] | ["3","3","60","47"] |
animations.stc1.cycle_count | 3 | 2 |
最稳的做法不是手写 JSON,而是从源包直接派生:
import json
src = "/sdcard/Download/qq_bubble_all/482/bubble.android.482.config.json"d = json.load(open(src, encoding="utf-8"))d["id"] = 13909 # 保留包身份d["loopList"] = ["2120","2121","2122","2123","2124","2125","2126"]json.dump(d, open("/tmp/new.json","w",encoding="utf-8"), ensure_ascii=False, separators=(",",":"))这样能避开手写时把 "time": "100" 写成 "time": ["100"] 这类低级错误。
6.4 清理残留资源(关键!)
这是我踩的最大一个坑。
原包里有 bubbleframe/ 目录,存着翅膀动画的 20 张帧图。即使 config 已经不引用它,它仍然会显示。
QQ 会扫描 other.zip 下的目录结构,不只是 config 引用到的资源。必须物理删除:
su -c "rm -rf $P/bubble.android.2119327.other.zip/bubbleframe"su -c "rm -f $P/bubble.android.2119327.other.zip/config.json"su -c "rmdir $P/bubble.android.2119327.other.zip/shootAnim"换样式时,不仅要换新资源,还要删干净旧资源。
6.5 校验
# 结构应为: sge + voice/su -c "ls -A $P/bubble.android.2119327.other.zip"
# 逐字节校验for f in aio_user_bg_nor.9.png aio_user_pic_nor.9.png chat_bubble_thumbnail.png; do a=$(su -c "md5sum $P/$SRC.static.zip/$f" | cut -d' ' -f1) b=$(su -c "md5sum $P/$DST.static.zip/$f" | cut -d' ' -f1) [ "$a" = "$b" ] && echo "$f OK" || echo "$f MISMATCH"done6.6 生效
su -c "am force-stop com.tencent.mobileqq"重开 QQ 即可。如果没变化,去 设置 → 应用 → QQ → 存储 → 清除缓存(不要清数据)。
7. 数据集:42 个气泡参数
以下数据全部从真实设备提取、逐字段解析。
7.1 七色系列 2120–2126
| ID | 名称 | 气泡色 | 链接色 |
|---|---|---|---|
| 2120 | 七彩糖果 | #e3712c 橙 | #0079ff |
| 2121 | 粉色回忆 | #d64479 玫红 | #0079ff |
| 2122 | 绿色心情 | #367c30 深绿 | #0079ff |
| 2123 | 蓝色清新 | #0a4c70 深青 | #0a8cd4 |
| 2124 | 紫色诱惑 | #714ba9 紫 | #0079ff |
| 2125 | 灰色空间 | #575656 深灰 | #0079ff |
| 2126 | 黑白画映 | #e3e3e3 浅灰 | #0079ff |

单款缩略图:
| 2120 | 2121 | 2122 | 2123 |
|---|---|---|---|
![]() | ![]() | ![]() | ![]() |
| 2124 | 2125 | 2126 |
|---|---|---|
![]() | ![]() | ![]() |
7.2 初恋系列 5532–5537
配色偏柔和粉彩,命名带青春感。
| ID | 名称 | 气泡色 |
|---|---|---|
| 5532 | 初恋微醺 | #f96c46 珊瑚红 |
| 5533 | 风花雪月 | #f458e0 洋红 |
| 5534 | 懵懂时光 | #ffa443 杏黄 |
| 5535 | 青葱岁月 | #32eabd 薄荷绿 |
| 5536 | 青梅竹马 | #3ec6f8 天蓝 |
| 5537 | 乍暖还寒 | #c88eff 淡紫 |

7.3 宝石花草系列 3485–3494
名字全部取自植物、宝石、意象,配色最鲜艳。
| ID | 名称 | 气泡色 | 链接色 |
|---|---|---|---|
| 3485 | 碧玉玲珑 | #00d8bc 碧绿 | #76aaff |
| 3486 | 橙红年代 | #ff7f66 橙红 | #8bbcff |
| 3487 | 春回绿意 | #90e028 嫩绿 | #48cdff |
| 3488 | 醇香奶茶 | #ffc39f 奶茶 | #3560ec |
| 3489 | 红袖添香 | #da404d 正红 | #8bbcff |
| 3490 | 袅袅紫烟 | #c1abff 淡紫 | #8bbcff |
| 3491 | 蔷薇之吻 | #ff9fbc 玫粉 | #29d2f0 |
| 3492 | 青蓝湖泊 | #6fc0ff 湖蓝 | #ff946f |
| 3493 | 秀雅青绿 | #c9b300 橄榄 | #48cdff |
| 3494 | 紫藤花语 | #de86ff 藤紫 | #8bbcff |

7.4 恋人星空系列 3438–3441
这一组有独特的几何参数,见第 8 节。
| ID | 名称 | 气泡色 | 链接色 |
|---|---|---|---|
| 3438 | 白色恋人 | #9f9f9f 银灰 | #7bebff |
| 3439 | 暗夜天使 | #000000 纯黑 | #0025df |
| 3440 | 玫瑰邂逅 | #fe60c4 玫红 | #279cff |
| 3441 | 湛蓝流星 | #279cff 湛蓝 | #fc27ff |

7.5 青春系列 4744–4745
| ID | 名称 | 气泡色 |
|---|---|---|
| 4744 | 迷幻青春 | #a26ced 紫罗兰 |
| 4745 | 青涩年华 | #d8c859 芥末黄 |

7.6 珠玉冰晶系列 478–483
名字全部是珠宝、冰晶、光影类的意象。
| ID | 名称 | 气泡色 | 链接色 |
|---|---|---|---|
| 478 | 冰灯玉露 | #0dc722 翠绿 | #80e9ff |
| 479 | 海洋之星 | #288fc2 海蓝 | #80e9ff |
| 480 | 镜中花蕊 | #fa8dea 粉紫 | #0079ff |
| 481 | 烁玉流金 | #fac12c 金黄 | #80e9ff |
| 482 | 珠箔银屏 | #252525 墨黑 | #0e8cc7 |
| 483 | 紫气东来 | #d89afd 淡紫 | #0079ff |

7.7 花果茶系列 2498–2503
全部以饮品、水果、花命名,也是唯一包含「重动画包」的一组。
| ID | 名称 | 气泡色 | 链接色 | 特殊 |
|---|---|---|---|---|
| 2498 | 冰雪奇缘 | #7a96ff 冰蓝 | #ffa200 | 重动画包 |
| 2499 | 淡紫香芋 | #ef94ff 香芋紫 | #00c709 | |
| 2500 | 粉色恋人 | #fe8ad3 粉 | #928cfe | |
| 2501 | 清爽薄荷 | #00cf66 薄荷绿 | #0079ff | |
| 2502 | 清香抹茶 | #87c113 抹茶绿 | #fa7000 | |
| 2503 | 雅清柠檬 | #ffc000 柠檬黄 | #0079ff |

8. 三类气泡包
从 42 个样本里可以归纳出三类:
| 特征 | 基础包 | 进阶包 | 极端包 |
|---|---|---|---|
| 代表 | 2120–2126、5532–5537、3485–3494 | 2141485、2498 | — |
other.zip 内容 | 只有 voice/ | + bubbleframe/ + config.json | + shootAnim/ |
| 动画帧位置 | voice/ | bubbleframe/ 或 voice/ | 两者都有 |
rect 尺寸 | 60×47、57×54 | 128×112 | 128×112 |
| 帧数 | 14–15 | 16–20 | 20 |
| 额外字段 | 无 | bubbleframe_animation、animation_sets | 同上 |
| 占比 | 绝大多数 | 少数 | 极少 |
rect 前两位不是 0 的含义
42 个样本里 rect 只有 5 种取值:
rect | 说明 | 出现场景 |
|---|---|---|
["0","0","60","47"] | 标准帧 | 5532–5537、3485–3494、4744–4745、478–483 |
["3","3","60","47"] | 带 3px 偏移 | 同上,多数 |
["8","2","60","47"] | 带 8px 水平偏移 | 2499–2503 |
["0","0","57","54"] | 略高 | 3440–3441 |
["0","0","128","112"] | 大帧 | 2141485、2498 |
[x, y, w, h] 的前两位不是 0,说明帧图里有留白,动画在图片内的偏移位置。这是 QQ 实现「特效飘出气泡边界」的技术手段。
静态图尺寸
几乎所有包的 aio_user_bg_nor.9.png 都是 128×112,只有 2499–2503 是 126×110(小 2px)。
这个尺寸在 zoom_point 里也有体现:大多数是 [64,56] 或 [65,53],正好是 128×112 或 130×106 的一半。
特例一:2141485「在说什么呢」
"name": "在说什么呢""color": "0xFF000000" 纯黑"link_color": "0xFF9e9e9e" 银灰"zoom_point": ["57", "56"]"count": "0" 帧数 0"cycle_count": "100" 循环 100 次结构最特殊:
2141485.other.zip/├── bubbleframe/ ← 有气泡框动画├── config.json ← 有独立配置├── shootAnim/ ← 有发射动画└── voice/它的 key_word 不是日常用语,而是素材库分类标签:
画风-可爱、题材-二次元、题材-萌物、目标人群-学生、目标人群-朋友、目标人群-二次元、主色调-黄色系、情绪表达-中立、情绪表达-开心、情绪表达-温馨这些是内容平台后台打标签用的词,正常用户永远不会打出来。说明它可能是从素材库直接导出的原始包,没做二次加工。
再加上 count: "0"(帧数 0)配合 cycle_count: "100"(循环 100 次),这在设计上矛盾——实际应该是没有动画的静态气泡。
特例二:2498「冰雪奇缘」
"count": "20" 20 帧"rect": ["0","0","128","112"] 帧尺寸 128×112"zoom_point": ["67","58"]结构:
2498.other.zip/├── bubbleframe/ ← 20 帧,128×112└── voice/ ← 空目录!唯一一个 voice/ 为空、完全靠 bubbleframe/ 驱动的包。其他包的帧都在 voice/,它是例外。

全部 20 帧:

9. 风险提示
- 资源校验:
.sge是完整性校验。改资源可能触发校验失败,轻则回退默认气泡,重则触发风控。 - 账号安全:修改客户端资源违反用户协议。付费主题的破解尤其敏感,存在封号风险。
- 版本升级:QQ 更新可能清理
vas_material_folder,改动会丢。 - 不要改 APK:只改数据目录风险相对可控。改 APK 或 hook 客户端,风险高一个量级。
适合:自用美化、理解原理、备份自己已购主题、学习 Android 资源格式、点九图机制。
不适合:传播付费资源、商业用途、任何形式售卖。
10. 结语
QQ 的气泡系统设计得挺巧:
loopList既是轮换方案,又是资源拉取清单key_word让文字内容触发视觉反馈- 点九图 让一张小图适配任意尺寸消息框
rect偏移 让动画飘出气泡边界
最意外的是按需下载这个行为。我一开始根据「文件不存在」推断「不会显示」,结果被实测推翻。这说明在这个系统里,配置文件不只是描述,还是一种指令——写进 loopList 的 ID,会被 QQ 当作「去下载它」的信号。
最后:备份永远是第一步。这次因为备份完整,中途改错才能随时还原。
附录:命令速查
BUBBLE=/data/data/com.tencent.mobileqq/files/files/vas_material_folder/bubble_dir
# 列出所有气泡配置su -c "ls $BUBBLE | grep config.json"
# 统计气泡总数su -c "ls $BUBBLE/*.config.json | wc -l"
# 查看某个气泡配置su -c "cat $BUBBLE/bubble.android.2120.config.json"
# 看文件结构su -c "find $BUBBLE/bubble.android.2120.* -type f"
# 批量导出所有气泡名for f in $BUBBLE/*.config.json; do id=$(basename $f | grep -o '[0-9]*') name=$(su -c "grep -o '\"name\":\"[^\"]*\"' $f" | head -1 | sed 's/.*:"//;s/"//') echo "$id $name"done
# 提取到外部存储su -c "cp $BUBBLE/bubble.android.2120.config.json /sdcard/Download/"
# 强制重启 QQsu -c "am force-stop com.tencent.mobileqq"
# 统计条目数(验证按需下载)su -c "ls $BUBBLE | wc -l"







