QQ 气泡换皮实操:只改 config 是没有用的
QQ 气泡换皮实操:只改 config 是没有用的
环境:POCO 22041216G · Android 13 · Magisk (root) · Termux 目标:
com.tencent.mobileqq结论先行:config 只决定「用哪套参数的哪个 ID」,外观永远来自static.zip与other.zip里的图片。两层不同步,改完等于没改。
0. 起因
上一篇《QQ 气泡主题逆向》讲清了文件结构与 loopList 的真实规则。但那篇偏「读」,这一篇讲「写」——把默认气泡换成「海洋之星」(479)。
中间我以为改完了,实际没有。原因很朴素:我只改了 JSON,一张图都没换。
这篇把这次操作完整记下来,包括两个当场踩到的坑:
- 为什么改完 config、重启 QQ,气泡纹丝不动?
- 为什么换了新图,旧特效还挂在上面?
- 为什么「空帧」不是坏包?

1. 先定位目标包
「换默认」的第一步不是改文件,而是确认要换成谁,以及那个包在不在本机。
P=/data/data/com.tencent.mobileqq/files/files/vas_material_folder/bubble_dir
# 按名字找su -c "grep -rl '海洋之星' $P"
# 结果# bubble.android.479.config.json479 的配置:
{ "name": "海洋之星", "color": "0xFF288fc2", "link_color": "0xFF80e9ff", "shadow_blur_color": "0xFFFFFFFF", "zoom_point": ["64", "56"], "id": 479, "version": 1690441188, "key_animations": [ { "key_word": ["海洋", "玻璃", "质感", "水晶", "蓝色"], "animation": "stc1", "count": "14", "cycle_count": "2", "rect": ["3", "3", "60", "47"], "time": "100", "zip_name": "voice" } ], "animations": { "stc1": { "type": "static", "count": "14", "cycle_count": "2", "rect": ["3", "3", "60", "47"], "time": "100", "zip_name": "voice" } }}注意:479 没有 loopList 字段。它是「单品」,不是轮换组的一员。
检查资源是否完整:
su -c "find $P/bubble.android.479.* -maxdepth 1"bubble.android.479.config.jsonbubble.android.479.static.zip/ ├── 2_bubble.android.479.static.zip.sge 26165 B ├── aio_user_bg_nor.9.png 10383 B ├── aio_user_pic_nor.9.png 10383 B └── chat_bubble_thumbnail.png 5243 Bbubble.android.479.other.zip/ ├── 2_bubble.android.479.other.zip.sge ├── config.json └── voice/0001~0014.png.sge 与图片同一时刻写入,说明这是未被动过的官方原包,可以直接当素材源。
2. 坑一:只改 config,外观不会变
我最初的做法
把 479 写进自制包 2119327 的 config:
d['name'] = '海洋之星'd['color'] = '0xFF288fc2'd['link_color'] = '0xFF80e9ff'd['loopList'] = ['479']am force-stop 重启 QQ,发消息——还是旧气泡。
原因
config.json 里没有任何一项描述「长什么样」。它只描述:
- 文字和链接用什么颜色(
color/link_color) - 动画怎么切(
count/rect/time/cycle_count) - 用哪个目录的帧(
zip_name) - 轮换顺序(
loopList)
真正画出来的像素,全部来自 static.zip 里的三张图和 other.zip 里的帧。 config 改得再对,图没换,渲染结果就不变。
这是我上一轮的错误:把「配置正确」误当成「替换完成」。
判断有没有真的换掉
对比文件大小,一眼就能看出来:
| 文件 | 改前 | 改后(=479) |
|---|---|---|
aio_user_bg_nor.9.png | 3504 B | 10383 B |
aio_user_pic_nor.9.png | 268 B | 10383 B |
chat_bubble_thumbnail.png | 1953 B | 5243 B |
aio_user_pic_nor.9.png 只有 268 B——那是个几乎空的占位图。这种尺寸差异,说明资源层根本没动。
3. 坑二:残留目录会抢走渲染
原包里躺着这些东西:
bubble.android.2119327.other.zip/├── 2_bubble.android.2119327.other.zip.sge├── bubbleframe/ ← 20 张翅膀动画帧├── config.json ← 独立配置,写着「天使恶魔」└── voice/bubbleframe/ 在 config 里完全没有被引用。但删掉 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 "ls -A $P/bubble.android.2119327.other.zip"# 2_bubble.android.2119327.other.zip.sge换样式 = 换新资源 + 删干净旧资源。少做一半,旧特效就会留一半。
4. 正确的替换顺序
4.1 备份(唯一不能省的一步)
P=/data/data/com.tencent.mobileqq/files/files/vas_material_folder/bubble_dirB=/sdcard/Download/qq_bubble_2119327_备份D="$B/资源备份_$(date +%s)"
su -c "mkdir -p $D"su -c "cp -a $P/bubble.android.2119327.static.zip $D/static.zip"su -c "cp -a $P/bubble.android.2119327.other.zip $D/other.zip"su -c "cp -a $P/bubble.android.2119327.config.json $D/"su -c "chmod -R 777 $D"用 -a 保留时间戳和权限,还原时不会引入额外差异。
4.2 替换静态图
SRC=$P/bubble.android.479.static.zipDST=$P/bubble.android.2119327.static.zip
for f in aio_user_bg_nor.9.png aio_user_pic_nor.9.png chat_bubble_thumbnail.png; do su -c "cp $SRC/$f $DST/$f && chown u0_a257:u0_a257 $DST/$f && chmod 600 $DST/$f"done属主必须改回 u0_a257:u0_a257(QQ 的 uid)。用 root 直接 cp 过去,属主会变成 root,QQ 读不到。
4.3 替换动画帧
SRC=$P/bubble.android.479.other.zip/voiceDST=$P/bubble.android.2119327.other.zip/voice
su -c "rm -f $DST/*.png"su -c "cp $SRC/*.png $DST/"su -c "chown -R u0_a257:u0_a257 $DST && chmod 600 $DST/*.png"先 rm 再 cp,不要直接覆盖。源包 14 帧、旧包 14 帧,数量相同但内容不同;如果数量不一致,直接覆盖会留下多余的旧帧。
4.4 对齐 config
尺寸相关参数必须跟源包一致:
| 字段 | 旧值 | 新值(479) |
|---|---|---|
name | 颜文字-麻薯小兔 | 海洋之星 |
color | 0xFFe99ddd | 0xFF288fc2 |
link_color | 0xFF007eff | 0xFF80e9ff |
animations.stc1.count | 16 | 14 |
animations.stc1.rect | ["0","0","128","112"] | ["3","3","60","47"] |
animations.stc1.cycle_count | 3 | 2 |
loopList | ["2120"…"2126"] | ["479"] |
最稳的做法是从源包派生,不要手写:
import json, collections
src = "/sdcard/Download/qq_bubble_all/479/bubble.android.479.config.json"d = json.load(open(src, encoding="utf-8"), object_pairs_hook=collections.OrderedDict)
d["id"] = 13909 # 保留自制包身份d["loopList"] = ["479"] # 只留 479 → 每条消息都是海洋之星
json.dump(d, open("/tmp/new.json", "w", encoding="utf-8"), ensure_ascii=False, separators=(",", ":"))4.5 关于 loopList:默认气泡怎么算
上一篇说过两条规则:
第 1 条消息 → 当前选中的气泡(loopList 管不到)第 n 条消息 → loopList[(n-2) % len]这次要的是「默认就是海洋之星」,所以两个念头都试过:
| 写法 | 效果 |
|---|---|
["2120","2121",…,"482"] | 482 排在第 8 位,要发到第 8 条才出现 |
["479"] | 池子只有一项,从第 2 条起全是 479 |
["479"] 才是「默认」的正确写法。把目标 ID 追加到长列表末尾,是很容易犯的错——看起来改了,实际要等轮换轮到它。
4.6 校验
# 三张静态图逐字节比对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/bubble.android.479.static.zip/$f" | cut -d' ' -f1) b=$(su -c "md5sum $P/bubble.android.2119327.static.zip/$f" | cut -d' ' -f1) [ "$a" = "$b" ] && echo "OK $f" || echo "FAIL $f"done
# 14 帧逐字节比对for i in $(seq -w 1 14); do a=$(su -c "md5sum $P/bubble.android.479.other.zip/voice/00$i.png" | cut -d' ' -f1) b=$(su -c "md5sum $P/bubble.android.2119327.other.zip/voice/00$i.png" | cut -d' ' -f1) [ "$a" = "$b" ] || echo "FAIL 00$i.png"done
# 属主su -c "stat -c '%U:%G %a' $P/bubble.android.2119327.config.json"实测结果:
static 3/3 md5 一致 OKvoice 14/14 md5 一致 OKu0_a257:u0_a257 6004.7 生效
su -c "am force-stop com.tencent.mobileqq"确认进程真的退了:
su -c "pidof com.tencent.mobileqq" || echo "已停止"没变化就 设置 → 应用 → QQ → 存储 → 清除缓存(不要清数据)。
5. .sge 怎么办
每个包都带一个 .sge,是资源完整性校验。它记录的是原始图片的校验值。
这次换掉了全部图片,但 .sge 刻意没动——还是 2119327 原来那个。
两种选择:
| 做法 | 结果 |
|---|---|
保留旧 .sge(本次) | 校验值与实际文件不符,可能触发回退默认气泡 |
换成源包的 .sge | 校验值对应 479 的图,但包名对不上,一样可能失败 |
实测:保留旧 .sge 时气泡能正常显示。 说明 .sge 不是每次启动都强校验,或者失败时只做软回退。
想稳妥,也可以直接删掉 .sge——自制包本来就没有这个文件。
6. 一个意外发现:空帧不是坏包
换完 479 的帧之后,我做了一次常规检查:
from PIL import Imageim = Image.open('voice/0001.png').convert('RGBA')print(im.size, im.getchannel('A').getextrema())(60, 47) (0, 0)alpha 全是 0,14 帧里有 5 帧完全透明。逐帧看:
| 帧 | alpha 峰值 |
|---|---|
| 0001–0003 | 0(全透明) |
| 0004 | 254 |
| 0005–0011 | 255 |
| 0012 | 254 |
| 0013–0014 | 0 |
再看 RGB 通道:全部帧都是纯白 (255,255,255)。
这不是坏图。这是 QQ 的高光遮罩层设计:
- 帧本身不带颜色,只用 alpha 画形状
- 渲染时与
color(0xFF288fc2海蓝)做叠加 - 首尾几帧全透明,是为了做出「渐入渐出」的呼吸感
所以「纯白 + 只有 alpha」完全正常。直接铺在白色背景上看,什么都看不到——必须在深色底上才显形。
深蓝底上才看得见:

14 帧全铺开:

底图裁掉点九图 1px 边之后:

顺带一提,482「珠箔银屏」的帧也是同样情况——首帧 alpha 全 0。这类高光包都长这样。
7. 排查清单
外观没变,按顺序查:
| 现象 | 原因 | 处理 |
|---|---|---|
| 完全没变化 | 只改了 config,没换图 | 查 static.zip 三张图的字节数 |
| 有新图但旧特效还在 | other.zip 里有残留目录 | rm -rf 掉 bubbleframe/、shootAnim/ 等 |
| QQ 读不到文件 | 属主变成了 root | chown u0_a257:u0_a257 |
| 气泡大小错位 | rect 没跟源包对齐 | 从源包派生 config |
| 要发很多条才出现 | 目标 ID 追加在 long loopList 末尾 | 改成 loopList: ["<ID>"] |
| 打开什么都看不见 | 帧是纯白+alpha 的高光层 | 铺深色底再看 |
| 启动后回退默认 | .sge 校验失败 | 换源包 .sge 或删除 |
8. 风险提示
- 资源校验:
.sge是完整性校验。改资源可能触发失败,轻则回退,重则触发风控。 - 账号安全:修改客户端资源违反用户协议,付费主题尤其敏感,存在封号风险。
- 版本升级:QQ 更新可能清理
vas_material_folder,改动会丢。 - 不要改 APK:只改数据目录风险相对可控,改 APK 或 hook 客户端风险高一个量级。
适合:自用美化、理解原理、备份已购主题、学习 Android 资源格式。
不适合:传播付费资源、商业用途、任何形式售卖。
9. 结语
这次的两个坑,本质是同一个问题的两面:
config 是「说明书」,图片是「材料」。只改说明书,房子不会变样;只换材料不撕掉旧墙纸,新墙纸也贴不牢。
具体的教训:
- 替换前先看字节数,那是资源有没有真的换掉的最快证据
- 替换后比对 md5,别靠肉眼
chown要跟上,root 写文件不还属主,QQ 直接读不到loopList想当默认用,就只放一个 ID,别追加在长列表末尾other.zip里没被引用的目录照样会渲染,必须删
至于那个「全透明帧」——它提醒我一件更基础的事:看不懂的现象,先量一下像素,别急着下判断。
附录:命令速查
P=/data/data/com.tencent.mobileqq/files/files/vas_material_folder/bubble_dir
# 按名字找包su -c "grep -rl '海洋之星' $P"
# 看包结构su -c "find $P/bubble.android.479.* -maxdepth 1"
# 看字节数(判断资源有没有换)su -c "ls -l $P/bubble.android.479.static.zip/"
# 看属主su -c "stat -c '%U:%G %a %n' $P/bubble.android.*.config.json"
# 全量导出 id + 名称for f in $P/*.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 "am force-stop com.tencent.mobileqq"
# 确认已停su -c "pidof com.tencent.mobileqq" || echo 已停止