QQ 气泡主题逆向:文件结构、轮换机制与按需下载

QQ 气泡主题逆向:文件结构、轮换机制与按需下载

Oct 31 ·
19 Min Read

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:

Terminal window
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:关键词触发

发含特定关键词的消息时,气泡会播放对应动画。「七彩糖果」的触发词:

你 我 她 他 它 们 好 哈 见 嘿 啊 吧 不 没 了 的
么 吗 嘛 呢 呀 哇 哦 噢 这 那 哪
2120 动画预览
2120 动画预览

全部 15 帧铺开:

2120 全部帧
2120 全部帧

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 只要是官方真实存在的,写进去就能被拉下来
  • 不需要事先在气泡商店「拥有」它
  • 触发条件是真正发消息,不是改完配置就立刻下载

验证方法:

Terminal window
# 记录基线
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

Terminal window
# 文件名
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.size
img.crop((1, 1, w - 1, h - 1)).save('bubble_bg_clean.png')

6. 实战:替换默认气泡样式

目标:把自制包 2119327(内部 id 13909)的外观换成「珠箔银屏」(482),同时保留包身份。

6.1 完整备份(最重要)

Terminal window
P=/data/data/com.tencent.mobileqq/files/files/vas_material_folder/bubble_dir
B=/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 替换静态图与动画帧

Terminal window
SRC=bubble.android.482
DST=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颜文字-麻薯小兔珠箔银屏
color0xFFe99ddd0xFF252525
link_color0xFF007eff0xFF0e8cc7
zoom_point["64","56"]["64","56"]
animations.stc1.count1614
animations.stc1.rect["0","0","128","112"]["3","3","60","47"]
animations.stc1.cycle_count32

最稳的做法不是手写 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 引用到的资源。必须物理删除:

Terminal window
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 校验

Terminal window
# 结构应为: 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"
done

6.6 生效

Terminal window
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
七色气泡总览
七色气泡总览

单款缩略图:

2120212121222123
2120212121222123
212421252126
212421252126

7.2 初恋系列 5532–5537

配色偏柔和粉彩,命名带青春感。

ID名称气泡色
5532初恋微醺#f96c46 珊瑚红
5533风花雪月#f458e0 洋红
5534懵懂时光#ffa443 杏黄
5535青葱岁月#32eabd 薄荷绿
5536青梅竹马#3ec6f8 天蓝
5537乍暖还寒#c88eff 淡紫
5532-5537
5532-5537

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
3485-3494
3485-3494

7.4 恋人星空系列 3438–3441

这一组有独特的几何参数,见第 8 节。

ID名称气泡色链接色
3438白色恋人#9f9f9f 银灰#7bebff
3439暗夜天使#000000 纯黑#0025df
3440玫瑰邂逅#fe60c4 玫红#279cff
3441湛蓝流星#279cff 湛蓝#fc27ff
3438-3441
3438-3441

7.5 青春系列 4744–4745

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

7.6 珠玉冰晶系列 478–483

名字全部是珠宝、冰晶、光影类的意象。

ID名称气泡色链接色
478冰灯玉露#0dc722 翠绿#80e9ff
479海洋之星#288fc2 海蓝#80e9ff
480镜中花蕊#fa8dea 粉紫#0079ff
481烁玉流金#fac12c 金黄#80e9ff
482珠箔银屏#252525 墨黑#0e8cc7
483紫气东来#d89afd 淡紫#0079ff
478-483
478-483

7.7 花果茶系列 2498–2503

全部以饮品、水果、花命名,也是唯一包含「重动画包」的一组。

ID名称气泡色链接色特殊
2498冰雪奇缘#7a96ff 冰蓝#ffa200重动画包
2499淡紫香芋#ef94ff 香芋紫#00c709
2500粉色恋人#fe8ad3 粉#928cfe
2501清爽薄荷#00cf66 薄荷绿#0079ff
2502清香抹茶#87c113 抹茶绿#fa7000
2503雅清柠檬#ffc000 柠檬黄#0079ff
2498-2503
2498-2503

8. 三类气泡包

从 42 个样本里可以归纳出三类:

特征基础包进阶包极端包
代表2120–2126、5532–5537、3485–34942141485、2498—
other.zip 内容只有 voice/+ bubbleframe/ + config.json+ shootAnim/
动画帧位置voice/bubbleframe/ 或 voice/两者都有
rect 尺寸60×47、57×54128×112128×112
帧数14–1516–2020
额外字段无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/,它是例外。

2498 动画
2498 动画

全部 20 帧:

2498 全部帧
2498 全部帧

9. 风险提示

  1. 资源校验:.sge 是完整性校验。改资源可能触发校验失败,轻则回退默认气泡,重则触发风控。
  2. 账号安全:修改客户端资源违反用户协议。付费主题的破解尤其敏感,存在封号风险。
  3. 版本升级:QQ 更新可能清理 vas_material_folder,改动会丢。
  4. 不要改 APK:只改数据目录风险相对可控。改 APK 或 hook 客户端,风险高一个量级。

适合:自用美化、理解原理、备份自己已购主题、学习 Android 资源格式、点九图机制。

不适合:传播付费资源、商业用途、任何形式售卖。


10. 结语

QQ 的气泡系统设计得挺巧:

  • loopList 既是轮换方案,又是资源拉取清单
  • key_word 让文字内容触发视觉反馈
  • 点九图 让一张小图适配任意尺寸消息框
  • rect 偏移 让动画飘出气泡边界

最意外的是按需下载这个行为。我一开始根据「文件不存在」推断「不会显示」,结果被实测推翻。这说明在这个系统里,配置文件不只是描述,还是一种指令——写进 loopList 的 ID,会被 QQ 当作「去下载它」的信号。

最后:备份永远是第一步。这次因为备份完整,中途改错才能随时还原。


附录:命令速查

Terminal window
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/"
# 强制重启 QQ
su -c "am force-stop com.tencent.mobileqq"
# 统计条目数(验证按需下载)
su -c "ls $BUBBLE | wc -l"
Last edited Oct 31