QQ 气泡换皮实操:只改 config 是没有用的

QQ 气泡换皮实操:只改 config 是没有用的

Nov 02 ·
13 Min Read

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. 先定位目标包

「换默认」的第一步不是改文件,而是确认要换成谁,以及那个包在不在本机。

Terminal window
P=/data/data/com.tencent.mobileqq/files/files/vas_material_folder/bubble_dir
# 按名字找
su -c "grep -rl '海洋之星' $P"
# 结果
# bubble.android.479.config.json

479 的配置:

{
"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 字段。它是「单品」,不是轮换组的一员。

检查资源是否完整:

Terminal window
su -c "find $P/bubble.android.479.* -maxdepth 1"
bubble.android.479.config.json
bubble.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 B
bubble.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.png3504 B10383 B
aio_user_pic_nor.9.png268 B10383 B
chat_bubble_thumbnail.png1953 B5243 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 里点名的资源。目录在,就会被加载。

必须物理删除:

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"

删干净之后,结构应该和源包一致:

voice/
su -c "ls -A $P/bubble.android.2119327.other.zip"
# 2_bubble.android.2119327.other.zip.sge

换样式 = 换新资源 + 删干净旧资源。少做一半,旧特效就会留一半。


4. 正确的替换顺序

4.1 备份(唯一不能省的一步)

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

Terminal window
SRC=$P/bubble.android.479.static.zip
DST=$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 替换动画帧

Terminal window
SRC=$P/bubble.android.479.other.zip/voice
DST=$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颜文字-麻薯小兔海洋之星
color0xFFe99ddd0xFF288fc2
link_color0xFF007eff0xFF80e9ff
animations.stc1.count1614
animations.stc1.rect["0","0","128","112"]["3","3","60","47"]
animations.stc1.cycle_count32
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 校验

Terminal window
# 三张静态图逐字节比对
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 一致 OK
voice 14/14 md5 一致 OK
u0_a257:u0_a257 600

4.7 生效

Terminal window
su -c "am force-stop com.tencent.mobileqq"

确认进程真的退了:

Terminal window
su -c "pidof com.tencent.mobileqq" || echo "已停止"

没变化就 设置 → 应用 → QQ → 存储 → 清除缓存(不要清数据)。


5. .sge 怎么办

每个包都带一个 .sge,是资源完整性校验。它记录的是原始图片的校验值。

这次换掉了全部图片,但 .sge 刻意没动——还是 2119327 原来那个。

两种选择:

做法结果
保留旧 .sge(本次)校验值与实际文件不符,可能触发回退默认气泡
换成源包的 .sge校验值对应 479 的图,但包名对不上,一样可能失败

实测:保留旧 .sge 时气泡能正常显示。 说明 .sge 不是每次启动都强校验,或者失败时只做软回退。

想稳妥,也可以直接删掉 .sge——自制包本来就没有这个文件。


6. 一个意外发现:空帧不是坏包

换完 479 的帧之后,我做了一次常规检查:

from PIL import Image
im = Image.open('voice/0001.png').convert('RGBA')
print(im.size, im.getchannel('A').getextrema())
(60, 47) (0, 0)

alpha 全是 0,14 帧里有 5 帧完全透明。逐帧看:

帧alpha 峰值
0001–00030(全透明)
0004254
0005–0011255
0012254
0013–00140

再看 RGB 通道:全部帧都是纯白 (255,255,255)。

这不是坏图。这是 QQ 的高光遮罩层设计:

  • 帧本身不带颜色,只用 alpha 画形状
  • 渲染时与 color(0xFF288fc2 海蓝)做叠加
  • 首尾几帧全透明,是为了做出「渐入渐出」的呼吸感

所以「纯白 + 只有 alpha」完全正常。直接铺在白色背景上看,什么都看不到——必须在深色底上才显形。

深蓝底上才看得见:

479 动画
479 动画

14 帧全铺开:

479 全部帧
479 全部帧

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

479 底图
479 底图

顺带一提,482「珠箔银屏」的帧也是同样情况——首帧 alpha 全 0。这类高光包都长这样。


7. 排查清单

外观没变,按顺序查:

现象原因处理
完全没变化只改了 config,没换图查 static.zip 三张图的字节数
有新图但旧特效还在other.zip 里有残留目录rm -rf 掉 bubbleframe/、shootAnim/ 等
QQ 读不到文件属主变成了 rootchown u0_a257:u0_a257
气泡大小错位rect 没跟源包对齐从源包派生 config
要发很多条才出现目标 ID 追加在 long loopList 末尾改成 loopList: ["<ID>"]
打开什么都看不见帧是纯白+alpha 的高光层铺深色底再看
启动后回退默认.sge 校验失败换源包 .sge 或删除

8. 风险提示

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

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

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


9. 结语

这次的两个坑,本质是同一个问题的两面:

config 是「说明书」,图片是「材料」。只改说明书,房子不会变样;只换材料不撕掉旧墙纸,新墙纸也贴不牢。

具体的教训:

  • 替换前先看字节数,那是资源有没有真的换掉的最快证据
  • 替换后比对 md5,别靠肉眼
  • chown 要跟上,root 写文件不还属主,QQ 直接读不到
  • loopList 想当默认用,就只放一个 ID,别追加在长列表末尾
  • other.zip 里没被引用的目录照样会渲染,必须删

至于那个「全透明帧」——它提醒我一件更基础的事:看不懂的现象,先量一下像素,别急着下判断。


附录:命令速查

Terminal window
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 已停止
Last edited Nov 01