一个 LSPosed 模块,用来统一修改 ColorOS / OxygenOS 状态栏上所有元素的颜色与透明度(通知图标除外)。
针对 OnePlus 13(PJZ110)/ ColorOS 16 / Android 16 实测开发并验证,作用域 com.android.systemui。
| 元素 | 是否受控 |
|---|---|
| 时钟、功耗/温度文字、网速文字 | ✅ |
| WiFi、移动信号(5G/4G)、VPN、NFC、蓝牙图标 | ✅ |
| 电量图标(圆形 / 横向 / 竖向三种样式) | ✅ |
| 下拉控制中心、锁屏、通知栏 | ❌ 一律不碰 |
| 左侧通知图标 | ❌ 不碰 |
电量图标另有一套更细的规则(见下文「电量图标的着色边界」)。
这个模块最核心的设计是:不去猜"谁在给这个元素上色",而是直接判断"这块东西属于哪个窗口 / 哪棵视图树"。前面若干个版本失败,全部是因为用了前者。
系统栏元素的上色方式并不统一,实测有三条互不相干的路径:
| 通道 | 覆盖对象 | 判定边界 |
|---|---|---|
Paint#setColor(绘制期) |
时钟、功耗、网速等文字 | 只在状态栏窗口绘制期间生效,且只改中性色(R=G=B) |
| 状态栏视图树扫描 | WiFi、信号、VPN、NFC、蓝牙等图标 | 从 StatusBarWindowView 出发遍历,只处理右半屏、排除 Notification* 子树 |
Drawable#setColor / tint |
电量图标的各个部件 | 按 drawable 实例 + 充电状态判定 |
三条通道都遵守同一条安全规则:饱和色(充电绿、低电量红)一律不改,只改中性白/灰。否则会把系统状态色一起染掉。
实测日志显示,WiFi/信号图标的 tint 是在子线程里设置的,而且那时 View 还没挂到窗口:
10237: 11706: 11706 [icon-tint] pre-attach ImageView #ccffffff -> #ff00c7ff
主线程是 5807,一次都没出现过。后果有两个,早期版本全栽在这里:
- 设置时
isAttachedToWindow()恒为 false,判断不了窗口归属; View.post()在子线程上只是把 Runnable 塞进那个线程的 RunQueue,永远不会被执行 —— "延迟补色"和"挂载后复核"两版代码日志里一条都没打出来。
所以改成反过来做:周期性(80ms)从状态栏窗口根视图遍历真实视图树,把 ImageView 的 tint 改成目标色。边界天然正确,新增图标(VPN/NFC/蓝牙)不需要加白名单。
另外维护一个 SB_ICON_VIEWS 集合记录"已确认是状态栏图标"的 View —— 布局变化(网速文字变长把左边图标挤动)时框架会重新下发白色 tint,有了它就能当帧改掉,不会闪白。
反编译 CircleBatteryContentDrawable#setColors(III) 得到的真实语义:
p1 → mCircleFrontPaint 随电量变化(充电时系统会染成绿色 #ff24b232)
p2 → mCircleBackPaint 背景底环
p3 → mCircleChargingPaint + circleFramePaint(30%) + 闪电
据此定义的规则:
| 部分 | 未充电 | 充电 |
|---|---|---|
| 背景 / 边框 / 闪电 | 自定义 RGBA | 自定义 RGBA |
| 随电量变化的部分 | 自定义 RGBA | 只取 alpha,RGB 用系统默认(绿) |
"随电量变化的那一份"不靠下标猜,而是靠颜色特征识别:充电时它是唯一被系统染成高饱和状态色的参数。这样圆形、横向、竖向三种样式自动都成立。
充电状态的判定有三层兜底(横向/竖向电量继承的是 BatteryBarDrawableBase,没有 getChargeIconId()):
- 实例状态方法
getChargeIconId()/getSpecialColor() - 公开字段
chargingBgIconId/chargeIconId - 颜色特征:任一参数是高饱和状态色
alpha 按各参数原有的相对值缩放(原alpha × 配置alpha / 255),不能统一成配置的绝对值 —— 横向/竖向电量的电量信息就编码在 p1 的 alpha 里(实测 #f0ffffff → #b5ffffff 随电量变),拉平就会"图标始终填满"。
闪电有两条不同的实现,都要处理:
- 圆形:
chargingDrawable.setTintList(...),但 tintMode 是SRC_ATOP—— 该模式输出 alpha 取目标 alpha,把透明度塞进 tint 颜色里在数学上无效,必须另外调Drawable#setAlpha - 横向/竖向:闪电是 Bitmap(字段
chargingBitmap),用chargePaint画出,没有 tint 可设 —— 给chargePaint挂PorterDuffColorFilter(SRC_IN)
- JDK(实测 17 / 21 均可)
- Android SDK:
platforms/android-34/android.jar、build-tools/34.0.0/lib/d8.jar aapt2(本仓库使用 Debian 打包的原生 arm64 版本:/usr/lib/android-sdk/build-tools/debian/aapt2)apksigner、zipalign、keytool
工具链不在仓库里,路径通过环境变量覆盖,与 NativePowerMenu 的做法一致:
SDK_DIR=/path/to/sdk ./build.sh # 一次性指定 SDK 根目录
ANDROID_JAR=... D8_JAR=... AAPT2=... ./build.sh # 或逐个覆盖默认值:SDK_DIR=/root/work/lsp/sdk、AAPT2=/usr/lib/android-sdk/build-tools/debian/aapt2。
依赖缺失时脚本会直接报 FATAL: 缺少构建依赖 … 并退出,不会产出一个半成品 APK。
./build.sh脚本用自身所在目录作为仓库根,换位置不需要改脚本。
仓库放在 /sdcard/Code/StatColor(与 NativePowerMenu 同目录)时有两处与普通文件系统不同,
本仓库已经处理好:
-
sdcardfs不保存 Unix 权限位,./dumpsmali.sh这类脚本的可执行位会丢失。 仓库内已设core.fileMode=false,git 不再把权限变化报成改动; 脚本用bash dumpsmali.sh调用即可。 -
文件属主是手机用户(非容器内 root),git 会报 dubious ownership。需要:
git config --global --add safe.directory /sdcard/Code/StatColor
产物为 StatColor-<versionName>.apk(版本号从 StatColor/AndroidManifest.xml 读取,避免手改两处不一致)。
脚本是 set -e + set -o pipefail,并且会断言 R.java、classes.dex 存在 —— 早期版本出现过"javac 失败但仍产出签名 APK"的假成功,这个断言就是为它加的。
首次构建若没有 statcolor.keystore 会自动生成一个自签名密钥。换密钥会导致签名变化,设备上必须先卸载旧版才能安装。
aapt2 compile → aapt2 link → javac → 分包打 jar → d8 → zipalign → apksigner
其中"分包打 jar"是关键:Xposed API 的 stub(de.robv.android.xposed.*)只能用于编译期解析,绝不能进 dex,否则会和 LSPosed 注入的真实 XposedBridge 冲突。所以 com.* 和 de.* 分开打包,stub 只通过 d8 --classpath 参与。
模块界面写出的配置有三条读取路径,按顺序回退:
- 文件镜像
/data/local/tmp/statcolor.conf(0644,由界面向su写入) XSharedPreferencesSharedPreferences
第一条是主力。原因是 Android 7.0 起 MODE_WORLD_READABLE 会抛 SecurityException,模块进程读不到应用的私有 pref 文件。
注入侧对配置做了 1 秒 TTL 的缓存 —— 否则每次 recolor() 都要读一次文件,视图树扫描根本跑不起来。
StatColor/
src/com/statcolor/app/Hook.java 注入逻辑主体
src/com/statcolor/app/Config.java 颜色解析 / 透明度 / pref 读写
src/com/statcolor/app/Module.java IXposedHookLoadPackage 入口
src/com/statcolor/ui/MainActivity.java 设置界面(纯 Java 构建,无 XML 布局)
res/values/strings.xml 界面文案
res/drawable/ic_edit.xml 色块上的铅笔图标
assets/xposed_init 模块入口类名
AndroidManifest.xml 作用域、版本、主题
stub/de/robv/android/xposed/ 编译期 Xposed API 桩(不入 dex)
build.sh 一键构建
dexlist.py 纯 Python 的 dex 类/方法列表工具
DumpSmali.java / dumpsmali.sh 从 dex 抽出指定类的 smali
FindRefs.java / findrefs.sh 扫描"谁引用了某个方法/字段"
设置界面没有任何 XML 布局,整屏在 MainActivity 里用框架控件搭出来,挂在
@android:style/Theme.DeviceDefault.DayNight 上。这是与参考项目 NativePowerMenu 统一的 AOSP 设计语言:
- 颜色一律从框架主题属性取,不硬编码、不打包 Material Components:
colorBackgroundFloating(卡片底)、textColorPrimary/textColorSecondary(文字)、colorAccent(强调色) - 卡片 18dp 圆角 + 半像素描边;行间 1px 分隔线(
textColorSecondaryat 0x1F) - 小标题 13sp、加粗、全大写、
letterSpacing 0.06、强调色 - 主按钮胶囊形(半径 = 高度/2,高 52dp),文字色按强调色亮度取反;次要操作是无底色文字按钮
- 深浅色完全跟随系统,不需要
values-night - 标题栏在
onCreate里显式隐藏(主题是 DayNight 而非 NoActionBar 变体)
因此 minSdk 提到 31(Theme.DeviceDefault.DayNight 自 API 29 起才存在),targetSdk 34。
适配新 ROM / 新版本时,靠猜是没用的,需要直接读 SystemUI 的机器码。这套工具就是为此写的:
# 依赖
apt install libsmali-java
# 取出 SystemUI 的 dex
adb shell su -c 'cp /system_ext/priv-app/SystemUI/SystemUI.apk /sdcard/'
unzip -o /sdcard/SystemUI.apk 'classes*.dex' -d sysui/
# 列出某个类声明了哪些方法(纯 Python,无需 Java)
python3 dexlist.py "battery/ui/drawable/"
# 抽某个类的 smali
./dumpsmali.sh sysui/classes6.dex "battery/ui/drawable/CircleBatteryContentDrawable;"
# 谁调用了 Drawable#setTint
./findrefs.sh sysui/classes4.dex "drawable/Drawable;->setTint("FindRefs 是这套工具里最有用的一个 —— WiFi/信号图标的上色路径就是靠它定位到
WifiViewBinder$bind$1$1$3$1.emit() → $iconView.setImageTintList(...) 的。
注意:
dexlist.py解析class_data_item时,direct methods 与 virtual methods 的method_idx_diff各自从 0 累加,不是连续的。不按这个规则解析会一路错位。
- 只针对 ColorOS / OxygenOS 的 SystemUI 类名适配。换 ROM 大概率要在
Hook.java顶部的类名数组里补路径。 - 电量样式的字段名(
chargingDrawable、chargingBgIconId、chargePaint)是反编译得到的,ROM 更新后可能变化,此时对应功能会静默失效(日志里会有no chargingDrawable field之类的提示)。 - 修改后需重启 SystemUI 生效(保存按钮会尝试自动重启)。若颜色误设为全透明导致看不清,在 LSPosed 里停用模块并重启 SystemUI 即可恢复。
- 应用界面不渲染真实状态栏:把真实状态栏画进别的 App 需要
MediaProjection或 root 截图,都不适合设置界面。界面里是纯文本预览(当前系统时间),颜色与透明度与真机一致。
模块日志在 /data/adb/lspd/log/modules_*.log,关键标记:
| 标记 | 含义 |
|---|---|
=== vX.Y enter com.android.systemui |
模块已加载(没有这行说明模块根本没跑,后面所有现象都不必分析) |
[sweep] ... / [sweep-skip] ... |
视图树扫描改色 / 因位置被跳过 |
[icon-tint] known-icon ... |
快路径命中,框架重设 tint 被当帧拦下 |
[batt-draw] ... charging=true/false |
电量图标的充电判定结果 |
[batt-bolt] bolt ... |
闪电上色 / 透明度 |
tint err ... |
某个钩子抛异常(早期 WiFi 全白就是这里的 NoSuchMethodException) |
配置是否正确写出可以用界面上的「自诊断」按钮检查。