Optimize + Icon —— 一个 Xposed 模块,专治「图标不对劲」。
三处毛病,一个根源:系统替你决定了图标长什么样,而那个样子不对。
| 功能 | 解决什么 | 默认 |
|---|---|---|
| 强制圆形图标 | 桌面 / 应用列表 / 最近任务里的应用图标形状由 ROM 的 mask 决定(圆角方、水滴、方),不一定是圆。这一路把它一律裁成正圆 | 开 |
| 修复通知小图标 | Android 12 起系统会强行把通知小图标统一着色,没做单色适配的应用会显示成一坨认不出是谁的灰白色块。这一路把它换回能认出是谁的样子 | 开(系统黑白) |
| 强制取色的单色合成 | Android 16 起桌面会给没做单色适配的应用现场生成主题图标遮罩,算法是亮度当 alpha —— 深色字形 + 浅色底的图标整个画反(「颜色反一下」)。这一路自己合成一张极性正确的单色层交上去,桌面那套工厂根本不进(v1.1.0 重写的对比度模型,见「单色合成引擎」) | 开 |
一个管桌面上的应用图标被套上了不想要的形状,一个管通知栏里的小图标被染成了 认不出的颜色,一个管 Android 16 主题图标把整张图的明暗画反 —— 位置不同, 干的都是同一件事:把图标优化回它该有的样子。
三个功能各有独立开关(只想开一个也行),改完不需要重启, 新加载的图标按新设置走。
-
从 Releases 下载最新 APK 并安装 (或按下面的「构建」自行编译)。
-
在 LSPosed / Vector(或其它支持 LibXposed API 102 的框架)里启用模块。 作用域不写死(
staticScope=false):下面这 10 个是模块自带的推荐清单, 任何会显示应用图标的应用都能在管理器里额外勾选 —— 第三方桌面、文件管理器、 带分享面板的社交应用等,只要它自己会画别的应用的图标,勾上就生效:android 系统服务,解析应用信息 system 系统进程,部分 ROM 在这里解析图标 com.android.launcher3 桌面与应用抽屉(类原生 ROM,必选) com.google.android.apps.nexuslauncher 桌面与应用抽屉(Pixel / Nexus,必选) com.android.systemui 状态栏、最近任务、通知 com.android.settings 设置里的应用列表 com.google.android.settings.intelligence 设置搜索与建议里的应用图标 com.android.intentresolver 分享与打开方式的选择列表 com.android.permissioncontroller 权限弹窗里的应用图标 com.google.android.apps.wellbeing 数字健康的应用使用列表两个桌面进程按 ROM 二选一即可:类原生 / AOSP 用
com.android.launcher3, Pixel / Nexus 用com.google.android.apps.nexuslauncher。勾选不存在的那个 不会报错,只是没有进程会加载它。通知功能只需要
com.android.systemui—— 通知是系统界面画出来的。 上面其余几条是给图标那两路用的(裁圆、强制取色的单色合成)。 -
重启(框架不会热加载模块)。
作用域要覆盖所有会解析图标的进程。模块会给应用图标的资源 id 打标记,只有被标记的进程 才认得这个标记;漏掉的进程会拿到无法解析的 id。
早期版本把作用域写死(
staticScope=true),清单外的应用永远拿不到模块 —— 这正是 「覆盖不全」的根因:不是 hook 通道不够,而是那些进程根本没被注入。现已改为staticScope=false,任何应用都能自行勾选。
应用有桌面图标,打开是四页,底部一个悬浮药丸导航 —— 页面按内容归属分, 不按「东西多不多」:
| 页面 | 内容 |
|---|---|
| 图标 | 裁圆开关、未适配图标的单色层开关、效果示意、注意事项、运维按钮(清图标缓存 / 重启进程) |
| 通知 | 通知总开关、作用域、怎么确认生效 |
| 模块 | 三个功能共用的东西:作用域清单、启用步骤、验证命令 |
| 关于 | 检查更新与版本信息 |
- 左右滑动也能切页,和点底栏是双向联动的(当前页由 Pager 统一持有)。
- 底栏是悬浮药丸:内容会从它后面滚过去,选中项展开文字、未选中只留图标。
四格是刻意定的 —— 三格时药丸太短,撑不起悬浮药丸该有的形态。
底色是
surfaceContainerHigh+ 1dpoutlineVariant描边,不是surface(理由见OpticonApp的说明:写死的浅色配色里surface与background是同一个值, 深色下surface还比背景暗 —— 靠阴影在两套主题下都不成立)。 - 各页不再自带品牌头:顶栏已经有 App 名,每页再来一遍标记 + 版本是重复。 页面直接从内容开始,当前在哪一页由底栏高亮表示。 (唯一的例外是「关于」页那块居中的图标本体:它是身份说明,不是每页都来一遍的品牌头, 而且不复述 App 名。)
- 作用域清单用分段按钮(必选 + 推荐 / 全部)左右切换,而不是一个文案会变的 "展开 / 收起"按钮 —— 这两档是并列的,分段控件更直白。
- 动效一律取自
MaterialTheme.motionScheme(MD3 Expressive 的弹簧参数), 没有硬编码时长的tween:Tab 选中胶囊的展开、颜色过渡、作用域清单的展开 (animateContentSize)都跟着主题走。
作用域清单放在「模块」页而不是某个功能页,是因为它本来就有两半:
launcher / settings / system_server 那几条是给图标功能用的(裁圆与单色合成),
而 com.android.systemui 一个进程横跨两条(它既画通知,也画状态栏和最近任务的图标)。
界面全部走 Material 3 Expressive:圆角一律用
MaterialTheme.shapes.*的语义档位、 内容卡一律走共享的OpticonCard(一档底色 + 1dpoutlineVariant描边)、 配色只用colorScheme的语义角色,所以浅色 / 深色两套主题都成立。⚠ 唯一的写死颜色是「关于」页那块图标底板的
@color/opticon_icon_background(品牌淡粉):那是在复刻桌面图标本体,本来就不该跟随取色 —— 见colors.xml里的说明。⚠ 卡片为什么不用
ElevatedCard、字号为什么用bodyMedium:见Common.kt里OpticonCard的 KDoc 与SectionLabel的说明(2026-09-30 按真机截图重做过一轮)。
设置写进自己的 SharedPreferences,模块侧通过本应用暴露的只读 ContentProvider
(com.iamcanincan.opticon.config,只读,按调用方包名认自家人)
跨进程读取,按 1 秒 TTL 重读 —— 所以改完开关立刻生效,不用重启进程。
adb logcat -s Opticon要拿日志做统计(不是只看两眼)之前先把缓冲开大:adb logcat -G 4M。
默认 main 缓冲 256 KiB,而抓取通道一次回传就是 6 行 base64(一张 9 KB 上下)——
开机后桌面重画那一轮能把几百条判据行整个挤出去,事后再 logcat -d 只剩个位数行,
点名的账就点不成了(2026-09-28 实测:重启后 12 分钟,-s Opticon 只读到 3 行)。
这个设置每次重启会回到默认,所以它属于"查之前"的一步,不是一次性配置。
所有功能共用这一个 tag,日志正文里带前缀区分是哪一路。
图标裁圆:正常时每个作用域进程会打印
module loaded in com.android.settings (systemServer=false)
attach in com.android.settings: framework=... api=102
options: circle=true monoInvert=true notify=true (source=provider)
install in com.android.settings, sdk 36
Hooked com.android.settings
桌面进程还会多一行 PixelLauncher: 2 createBadgedIconBitmap hooked(Pixel 上进程名是
com.google.android.apps.nexuslauncher)。
裁圆还会打两类诊断行(都只在每个图标多出来的那一趟量测里):
量一张 X ms → 有留白 / 满幅(不放大)—— 那一趟光栅化的成本(真机暖态中位 0.60ms);- 落到哪一档:
…裁了还剩 X% 外框内切圆 r=… 不透明圆盘 r=… → 放大 X×(裁得好的) /…裁了只剩 X%(<60%)→ 不裁、缩进圆里 body/far=… 放大 X×,圆内补白(裁不动的)。
通知小图标:出现 patched <包名> 2->1 就是替换成功了(图标类型从「资源」变成「位图」)。
桌面主题图标(强制取色):每张未适配的图标一行判据,行首带包名/资源名,报一个应用名 就能 grep 到它那一行;每 40 张一行汇总。
Mono: 42 com.example.appui mipmap/ic_launcher_round … 极性 4ms 极性=不翻 墨L=84 参照L=47 (底L=11 β=19) 死线=39 底占=0.26 族=0
Mono: 40 张遮罩 → 翻 9 不翻 28 无判据 3
极性= 这一张翻不翻、墨L/参照L 两个平均 L*(墨按浓度加权,参照 = 底的亮度按 β 与中灰
混合)、死线 判定用的那一条(参照再暗 8 个 L*)、底占/族 参照占内容框的比例与角上找到的
底色家族数。汇总行的 无判据 是关键:它涨起来说明 getMonochrome() 那道钩子没装上,
于是所有遮罩都不动 —— 不崩、不报错,只有这一个数能看出来。
判据台与真机重放另外打模型自己那一路的数(带占/底占/零/实/端/壳占/降/降力/明暗/框内/ cc/top/小片/空洞/淡/色驱),逐字段解释见 _harness/SelfTest.kt 的用例名 —— 合成那条路
现在不上屏,这些数只在离线跑(判据本身仍然出自同一份 MonochromeMask)。
⚠ 这些数只在图标被重新加载时产生:桌面缓存里是预渲染好的位图,光重启桌面进程只会
重算新加载的那几个,要看全量分布必须先按界面里的「清除缓存并重启桌面」。
options: 那行里的 source= 表示配置是从哪条通道读到的:正常情况下是 provider。
若显示别的来源,说明 provider 没查到,模块在用备选通道或默认值。
装钩的每一条通道都会自己报数:ParcelWrite: 2 hooked / MarkedIconIds: 9 hooked /
PackageInfoCommonUtils: 5 hooked 这类行,挂上几条就报几条;一条都没挂上时报的是
<通道名>: 0 hooked —— 这条通道在本进程没生效(W 级),找不到目标类则是
<通道名>: 找不到 <类名> —— 这条通道在本进程没生效。
所以判断"这台机器上功能完整吗"要看的是有没有 0 行,而不是收尾那句 Hooked <包名> ——
后者只说明加载出口挂上了。
两条特殊:
ParcelWrite: 0 hooked不只是少一条日志 —— 它是"打过标记的0x6e资源 id 别泄漏到未注入进程" 的唯一闸门,挂不上时本进程一个标记都不打(否则会崩掉别人的应用),代价是圆角图标的 构造/批量通道整体不生效,此时会另有一条 E 级说明。- 不是 AOSP / Pixel 桌面的进程会打一行
<包名> 不在已登记桌面的名单里 → 单色合成 / 最近任务通道不挂。 换第三方桌面时这一行是功能不生效的第一原因,需要把桌面包名加进登记名单。
通道挂不上时还会打印原因(... not found / ctors=0 hooked=0),
这些告警只说明该 ROM 上没有这个类(例如某些 ROM 的 SystemUI 里 splash 相关类被 R8 削成空壳),
不影响别的通道。
如果只看到 No icon loader is found, nothing is hooked,说明当前系统里几个加载通道
(Resources.getDrawableForDensity / Resources.getDrawable 与
ApplicationPackageManager.getDrawableInternal / getDrawable)都没挂上,
模块会主动放弃打标记,此时不会有任何图标被改动(也不会弄坏图标)。
- 在
ApplicationInfo/ActivityInfo/ServiceInfo/ProviderInfo/ResolveInfo构造完成后, 把图标资源 id 从0x7f……挪到0x6e……(一个不会有真实资源的 package id)作为标记。 - 在
Resources.getDrawableForDensity()(以及新版本上的ApplicationPackageManager.getDrawableInternal()) 出口认出这个标记,换回原始 id 调用原方法拿到图标,再包成CircleIconDrawable返回。android.R.drawable.sym_def_app_icon(解析不出应用图标时用的兜底)和getArchivedAppIcon(归档应用)也会被同样裁圆。 - 批量通道:上面两步只能盖到「逐个拿图标」的代码路径。Settings 应用列表、分享页、
权限页拿到的图标是 PMS 一次性序列化过来的一整包
PackageInfo/ResolveInfo, 走的是完全不同的路径:Parcel.readTypedList/createTypedArray、BaseParceledListSlice构造、PackageInfoCommonUtils.generate*Info。只 hook 第 1 步 的构造器的话,这些列表里的图标根本不会经过第 2 步的加载出口。 这几条也都挂上同样的「打标记」逻辑,加上markingIconsThreadLocal 防重入, 覆盖才完整。 - 出口还原:
PackageItemInfo.writeToParcel/ResolveInfo.writeToParcel在写 Parcel 之前把0x6e……还原成0x7f……。这条是安全闸门:标记过的图标信息会经 Binder 传给任意应用,而没被注入的应用没有还原能力 —— 拿到一个自己资源表里不存在的 id 会直接Resources$NotFoundException崩在onCreate(真机上表现为第三方应用闪退)。 - system_server 提前注入:
onSystemServerStarting让模块在 PMS 启动前 就装上,客户端拿到的 id 从头就是带标记的。onPackageReady的「android」包名也走同一套 装机函数,installedflag 防重复。 - 最近任务 / 概览:
com.android.quickstep.TaskIconCache.getBitmapInfo在把BitmapDrawable包成BitmapInfo之前,先替换成clipToCircle的结果。 - 冷启动 splash:
SplashscreenContentDrawer$ColorCache$IconColor构造时如果 发现背景是透明的,强制把mIsBgComplex标成true,让系统把整张图标画出来 而不是只画不透明区域(圆形图标圆外透明正好落进这个判定)。 - 设置页自适应包装(Android 15+):
com.android.settings.Utils.getAdaptiveIcon会把非自适应图标自己套一层形状,先把入参换成裁好的,它就原样返回。 CircleIconDrawable继承AdaptiveIconDrawable,形状自己画圆,不依赖系统 mask。 结构 =背景层(宿主的盘色,只给取色合成器看)+ 前景 = 缩到可见区、套过圆形遮罩的图标:- 裁剪基准是"图案实际占的那块",不是整块画布:先把原图标光栅化到 128 边长的一张小图, 量出图案外框与"不透明圆盘"(半径方向上整个落在实心区里的最大圆盘),再按它 cover + 内切。 图案本来就满幅的走满幅早退、逐位不变;真机上那 59/61 张属于这一类。
- 两档:裁得好的 → 内切填满、圆外透明、无白边;裁不动的(按圆裁下去只剩 <60% 图案 —— 宽幅式、铃铛、十字、环一类)→ 不裁:整幅缩进圆里,圆内先铺一层白底(画在前景位图里), 缩量由"主体进安全区 / 尖角不贴边 / 墨量对齐"三条里最紧的一条定。
- 这条窗口判据收在
IconCropWindow,是彩色与染色共用的唯一来源:开着「强制裁圆」时, 未适配图标的单色合成按裁圆定的那个尺寸摆 —— 判据先在没搬过的那一张上算完(浓度、分档、 盘刀、极性一个像素不动),只把算好的交付掩码按同一个窗口重排(under不参与这一手)。 搬过的那一张一律映射到图层的可见区,不是整张 108dp 画布 —— 桌面画图层按 1.5 倍铺进去再裁边, 铺满画布落到屏幕上就是又放大 1.5 倍(量过的账见CHANGELOG.md未发布那节)。倍数超过 1.82× 的整张不搬(那道线的两个邻居是他对着屏点的:uu远程 1.75× 留、openlist 1.90× 拦)。 不搬那个圆:圆是彩色那支 自己DST_OUT的,染色的形状由桌面 mask 决定,烘进去模型会把圆边当成一块底。 ⚠ 已适配的图标(自带 monochrome 层)原样透传,不参与这一手(主人定的范围)。 判据行末的几何=满幅|切1.47×|缩0.78×就是这一趟的读数 —— ⚠ 它只报倍数,两版倍数相同 而屏上大一圈是可能的(落点不进这个数),要对"交付那张"只能比noteDiagnosis的缩略图。 - 遮罩用
EVEN_ODD的「整块矩形 + 圆」现造(重叠处计数为偶 → 成为洞, 填出来的正是圆外那一圈),配合DST_OUT把圆外擦掉。 - 缩放由外层
ViewportDrawable负责,getIntrinsicWidth/Height报原尺寸 / scale。 缩放别在画的时候自己算 —— 那样AdaptiveIconDrawable报出的固有尺寸会只有 2/3。 - 必须重写
draw()且不调super.draw():父类draw()会在合成之后再套一次 系统 mask,形状就变回 ROM 决定的那个(圆角方 / 水滴 / 方)。
- Android 16+(BAKLAVA) 额外 hook
BaseIconFactory.createBadgedIconBitmap(Pixel / AOSP Launcher3), 把IconOptions.drawFullBleed设成false,让 launcher 不再加自己的白圆背景板。 旧版 Android 没有这个开关,hook 自动 no-op。 - 防重入:
markingIcons(打标记时用,防止生成 Info 的几条路径互相嵌套重复打) 和replacingIcon(图标加载时用,防止Resources/APM在同一条链上双层包装) 两个 ThreadLocal。
裁圆的判定收在一处 —— clipToCircle():所有替换通道最终都汇到这里,
开关关掉就把原图标原样交回去(图标 id 本身是换回原始值之后才解析的,
拿到的就是应用原本的图标)。
⚠ 读不到配置的进程会用默认值(功能全开):作用域可以由用户在管理器里额外勾选, 那些进程不在 provider 的包名白名单里,拿不到配置。 对它们而言「关掉开关」不生效。真正决定全局的是 system_server:它在名单里, 读到关闭就不给图标 id 打标记,下游任何进程拿到的都是未经处理的原图标。
Android 16 的「强制动态取色」会给没做单色适配的应用现场生成一张单色遮罩
(com.android.launcher3.icons.MonochromeIconFactory)。它的算法是亮度当 alpha:把图标
画进 ALPHA_8、读回亮度、按这张图自己的 min/max 归一,再叠一条以 128 为中心的对比度曲线。
一句话 —— 它假定「亮的就是墨」。深色字形 + 浅色底的图标因此变成「字形是洞、底色是形」,
看着整个反掉,这就是用户报的那句「颜色反一下」。
现在只有一条路:模块自己合成一张极性正确的单色层交上去,桌面根本不进它自己那套算法。两道钩子(circle/IconHooks.kt):
hookSynthesizedMonochrome钩AdaptiveIconDrawable.getMonochrome()。应用自带单色层就原样 透传(一个像素都不碰);没有的话,把这张图光栅化、交给MonochromeSynth.forIcon→MonochromeMask.build压一张极性正确的剪影直接返回。桌面只在 getter 返回 null 时才去 new 它自己的MonochromeIconFactory—— 合成一接上,那条把亮度当不透明度的路径根本不进。 代码里对这条的原话是「这是主路径,也是唯一路径」。hookMaskFallThrough只数一件事:桌面自己出了多少张遮罩 = 上游合成漏接了多少张。 纯合成模式下这个数应该是 0;它非零是「屏幕上有一批图标是反的」唯一的可见信号, 日志是Mono: N 张走了桌面自己的遮罩…。
「已适配的不动」不用判条件:桌面只在 getMonochrome() 为 null 时才 new 那个 Factory,
自带单色层的应用根本走不到这条路径。
开关是有的(图标页 →「未适配的图标由本模块生成单色层」):关掉 = 完全交回桌面自己处理。 它不是口味旋钮,而是"这台 ROM 上厂商已经修过极性、我们整体不该介入"时的逃生口。 一张一张定"这个应用翻不翻"的调试面板已经删掉了(2026-09-30):它给的是"绕开判据", 而这一路要的是把判据量对。
⚠ 这一路不读桌面任何私有字段 —— 旧版翻转兜底要读 mAlphaBitmap,字段名一变整条静默失效,
那是它被合成换掉的直接原因之一。极性怎么判、为什么"亮的不一定是墨",全在下一节的模型里
(MonochromeMask 的 groundPass 是全仓唯一一份"哪边是图形"的实现,出现第二份就会各自漂移)。
这里曾经的第一条路是翻转兜底:让桌面先算出一张极性错误的遮罩、模块再整张翻回来。 换成合成优先之后它只剩兜底,现在整条已删。
MonochromeMask里那套极性判据 (Polarity/polarity())只剩判据台与性质在用,业务侧已无调用者 —— 别把它当成 "屏幕上那条路还在翻转"的证据。极性怎么判、模型长什么样,见下面的「单色合成引擎」一节。
剪影按内容缓存两层(2026-10-15)。一张剪影现压 350 ms,而桌面每次 bind 都 getConstantState().newDrawable()
交一个新实例 —— 所以按实例建的缓存结构上命中不了:真机一次重启 1594 次合成、里面只有 209 种内容
(同一张最多重压 148 次),串行在一把锁上铺满 760 秒。现在键换成内容本身
(像素, 层底像素, edge, adaptive, flatten, 强制裁圆)(掩码就是这六样的纯函数,少一位就是"换了设置还给旧答案"),
两层:内存 LRU 8 MB(故意不追求装下整个抽屉的 30 MB,扫穿无害,漏下去的落到磁盘而不是落到模型;
负缓存条目一个字节都不占,所以字节那道闸对它们无效,另有一道条数上限 4096 兜住)+
磁盘 48 MB,落在宿主的 cacheDir/opticon-mono/<装机身份+API>/(我们就跑在那个进程里,只能写它自己的地盘;
换到模块 dataDir 是跨 UID)。目录名认 lastUpdateTime 不认版本号,再带 _api<SDK_INT> —— 换装新构建整层作废,
OTA 也整层作废(ALPHA_8 的 rowBytes 这类"像素没变、交付口径变了"只有系统侧会动,内容键盖不住);
否则迭代时看到的是上一版画的那张;拿不到装机身份就整层磁盘关掉,宁可不快不给旧答案。
"判据不成立、放弃合成"那几张也记负缓存(只记内存),它们同样被重压过 148 次。
真机账:热重启 2 次现算 / 1880 次命中,前 300 张上屏 63.5 s → 5.07 s;界面上「说明」那条与
「清除桌面图标缓存」按钮的文案就照着这一段写(那个按钮清的是桌面的 app_icons.db,动不到这一层)。
两条量到零的否定结论记在这里,别再试:并行化合成(1894 次请求全在同一条加载线程上)、
以及按 ConstantState 建一层省掉光栅化(1043 次请求对 1043 个不同 state,桌面连 state 都是每次新建)。
⚠ 后面那一条不能读包装路径:CircleIconDrawable.getConstantState() 每次调用都新建一个匿名 ConstantState,
那条路上"state 永远独一无二"说的是我们的 getter,不是桌面的行为 —— 日志里这一位对自己标 state=-wrap,
换一台桌面重开这条之前先看类名。
把 App 自己给的小图标压成单色剪影,交给系统按主题着色 —— 只此一条,没有可选模式。
⚠ 曾经是两种(另一种「换成桌面彩色图标、原色保留」),2026-09-30 按主人的要求删掉: 那条要成立得钩进 SystemUI 的着色链里改图标颜色,是整条功能里最依赖系统内部实现的一部分, 而实际没人用。已经是单色 / 灰度的图标仍然一律不动。
模块改的是通知对象里的小图标字段(Notification.smallIcon)—— 就是被系统染色成一坨的那个。
它是通知内容区图标的来源,也是状态栏图标的来源,所以不要在状态栏那行上找差异:
真正直观的变化在通知内容区那个小图标,从灰白色块变回能认出是哪个 App 的样子。
⚠ 过去这里还有一句「配套还会阻止系统把替换后的图标重新染回单色」(钩 IconManager#setIcon、
StatusBarIconView#updateIconColor、Notification.Builder#processSmallIconColor)—— 那是
「保留原色」那一档的配套,2026-09-30 随那一档一起删了(ca93cba)。通知侧现在只有黑白一条路。
- 只动没适配的:用和系统同款的灰度判定检测图标是否已做单色适配,已适配的直接跳过 —— 免得把人家本来正确的图标改坏。
- 状态栏只取 alpha 通道渲染小图标 ⇒ 交出去的位图必须有意义的透明背景; 不透明彩色图标必然变纯色块。
- 自适应图标光栅化是「遮罩贴边」的 ⇒ 裁空白、放大填满这类操作对整张是空操作,只对前景层
才有意义。曾经「彩色桌面图标」那一档会先裁掉空白再放大填满(光栅化后内容只占画布中间约 61%,
不填满缩到通知里那点尺寸会又小又糊)—— 黑白这一档不做这件事,
fill()/scaleTo()随那一档 一起删了(ca93cba)。 - 仅注入
com.android.systemui,不碰其他进程。 - 活路径已在真机核过(2026-09-30,XQ-DQ72 / Android 16):SystemUI 里
inflateViews hooked (1 overload(s)),而not resolvable/inflateViews not found/inflateViews patch failed三条各 0 次 —— 这条通道装上了、没静默失败。 ⚠ 同一段时间里patched也是 0 条,那不是"修了 0 次都成功",是这段时间没有哪条通知的 小图标需要被修。判"钩子活着"要看装钩那一行,不能看patched的条数 —— 零条patched加零条错误,在这条通道上读不出任何结论(这个坑踩过一次,靠的正是"每一条早退都要留下名字" 那次修复才分得开"没装上"与"没有东西要修")。
替换只发生在通知行首次 inflateViews 时,已经在通知栏里的那条不会变 ——
要等它重新加载(重新发出,或重启系统界面)。
界面里的三个按钮走的是「App 发广播点名 → 目标进程里的模块自己执行」:
- 清除缓存并重启桌面:删 launcher 自己数据目录里的
app_icons.db*,然后自杀重启 ⚠ 它不删模块自己那层剪影缓存(<launcher 的 cacheDir>/opticon-mono/<装机身份+API>/,上限 48 MB)—— 那一层不需要手动清:键是图标内容,目录名是"哪一次装机",所以图标更新 / 改了「强制圆形图标」/ 升级模块 都会自己重压。反过来说,升级模块后第一次重启会慢一些(那 209 张要现压一遍),之后就快了。 - 重启 SystemUI / 重启其他进程:白名单进程收到就自杀重启
白名单 = launcher3 / nexuslauncher / systemui / settings / settings.intelligence /
intentresolver / permissioncontroller / wellbeing。
⚠ 绝不能含 system / system_server —— 杀它们等于软重启整机。
接收器是动态注册且必须导出的(发送方是另一个 uid),所以入口由权限把闸:
本 APK 自己声明一个 signature 级权限
com.iamcanincan.opticon.permission.PROCESS_CONTROL,registerReceiver 一侧要求它。
同签名才拿得到 —— 没有这一道的话,任何第三方应用发一条
com.iamcanincan.opticon.RESTART_SELF 就能反复杀桌面和 SystemUI。
注意权限只加在接收侧:sendBroadcast 再传一次会变成检查接收方是否持有,
而宿主进程并不持有它。
测之前必须先把目标进程重启过,否则它跑的是旧模块代码、接收器还没注册 (会误判成「按钮没生效」)。
三层 adaptive icon(背景 / 前景 / 单色层),字符是 Oi:背景固定为品牌淡粉 #FCE4EC(Pink 50),全 Android 版本一致,不跟随壁纸取色
- 大号黑字;monochrome 层只留白色剪影,染色后就是主题色的字形。
- 字形从 Google Sans Flex(可变字体)取轮廓算出的像素级 path(
wght=700 / opsz=18 / wdth=100), 别手改坐标,用<group android:pivotX/Y="54" android:scaleX/Y="k">缩放。- 底板颜色固定
@color/opticon_icon_background= 品牌淡粉#FCE4EC(Pink 50), 全 Android 版本一致,不跟随壁纸取色。 (早期曾在 API 31+ 改用system_accent1_100跟随壁纸,后按用户要求改回全版本固定淡粉。) 真正会随主题染色的只有 monochrome 层(见下)。
- 底板颜色固定
- Android 13+ 的启动器打开「主题图标」后,会拿 monochrome 层按壁纸取色染色。
- ⚠
<monochrome>元素必须放在mipmap-anydpi-v33/:它是 API 33+ 才有的元素, 放在没有版本限定符的目录里会被启动器忽略,主题图标就不生效了。 所以mipmap-anydpi/和mipmap-anydpi-v33/两份必须同时存在、内容只差这一行。 - 改几何必须两处同步(
ic_launcher_foreground/ic_launcher_monochrome), 否则主题图标剪影会和桌面图标对不上。 (早先还有第三份ic_brand_mark专供界面使用,已在「界面大改」那次连同ScreenHero/VersionChip一起删掉 —— 界面现在用Icons.Default.*矢量图标。)
Android 16 的「强制动态取色」会给没做单色适配的应用现场生成遮罩,算法是亮度当 alpha —— 深色字形 + 浅色底因此整个反掉。Opticon 的答案是自己合成一张极性正确的单色层直接交上去,桌面那套工厂根本不进。合成器(circle/MonochromeMask.kt,纯数组进出)只问一句:
这个像素与它周围那块底差多少?
- 底 = 局部底色场:内容框四角的颜色家族(≤4,圆角沿对角找窗口)铺成一张 8×8 场,再把每格底色传播到整张画布 —— 一个像素的参照 = 离它最近的那块底。渐变底在每个格子里都有自己那一份,所以渐变不需要并层;拼色底(高德四块、云闪付红白两段)是所有家族的并集,不会剩下半块变墨。
- 差 = CIELAB ΔE76(整数查表,SelfTest 对公开参考值核过),按像素 alpha 收一道覆盖率;
dep = a×[(1−β)+β·ΔE/100]应用两遍(对底 / 对近旁主体)。 - alpha = 两点标定 +
t^0.75曲线:零点 = 底板自己的散度,实心点 = 形层距离分位。「一整块色板」不是被闸门拦掉的,是产生不出来。 - 支路:H1 底参照 / H2 / H3 空气参照 / H0 区域关系四支选支,满幅插画(没有「底」可摘)单独一支按自身明暗铺层次;透明底退回图标自带 alpha + 被围住的内部镂空。
| 刀 | 治什么 |
|---|---|
propagateBands + 桥接 |
粗块连接、1px 缝,细带归属沿连通传播 |
flatVote |
同一块源色被给了两个答案的投票 |
carrierPocket |
与「交成底的自己」同色、却被交成墨的承载面口袋(易班蓝气泡块一类)整袋抹掉 |
separation / SepTone |
源色差得开、交付撞成同一浓度的那一对,把浅而小的挪开 |
| 盘刀 / 膜刀 | 背景层的盘、叠膜各自的误判修补 |
ownBrightPlate |
前景层自带的整块白底不压地板 |
算法的判据全是数字,数字不能靠推演定。_harness/(不入库)是一台纯 JVM 判据台:手写 android.graphics 桩,跑的就是仓里那份真 MonochromeMask.kt。sh regress.sh 一条命令跑 14 道回归闸(性质台 / 单调性审计 / 对表「人画的单色层」/ 兼容性台 / 肉眼账 / 换支账 / 源尺寸账 / 无对岸半灰账……),任何一项比基线差就非零退出;发版前另跑 EDGES=96,192,384,768 四档全扫。细节见 docs/DEVELOPMENT.md。
以下都是量死后有意不修的边界,不是没看见;每条的完整过程(试过什么、数字多少、为什么死、尸体埋在哪)见 docs/KNOWN-LIMITS.md,判据的编年史见 docs/devlog/。
- 一层 alpha 只能表达一个量:与底同色相又同亮度的东西本来就画不出来。仪表环续弧(globalspeed)断口就是这条颜色墙,四条路全部量死。
- 两块都被判成底时,中间那道边留不下:给其中一块降档要先指认「哪块是图形」,那道证据至今量不出来。
- 「色块 vs 线稿」族认了:描边 logo 被填成实心(credentialmanager 等 5 张),主人看图裁定「没什么问题」。
- 满幅插画支的边界:支路判定线画在「纹理 23/24」之间没有能站人的位置;极性固定「暗=墨」,亮主体压暗底的插画会被反。
- 层次细腻与实心冲突时先不伤人:坚果云橡子那类三档真实层次,前提放进来的会有本来正确的图,没收。
- 自带单色层的应用按硬规则一个像素不碰(transparency 优先),对表台上那 62 张人画单色层是验收标准、不是缺陷清单。
- 自适应图标把图形整个放背景层时前景栅格化为空 → 交全 0 掩码(真机语料 1/91 张,且自带单色层不会上屏)。
- 上屏浓度由桌面归一承担;快捷设置磁贴小尺寸图形不处理;通知已适配图标一律不动。
- 合成掩码按内容缓存(内存 + 宿主
cacheDir),热重启几乎全部命中;命中之后仍付一次光栅化(键要像素, 拿像素就得先画一遍;哈希只 0.15 ms)。缓存的失效条件是装机身份(认lastUpdateTime),换构建整层作废。
./gradlew assembleRelease # app/build/outputs/apk/release/app-release.apk
./gradlew lintDebug需要 Android SDK 37(在 local.properties 里配好 sdk.dir)与 JDK 17+。
assembleRelease的签名取仓库根的keystore.properties(.gitignore已覆盖它和*.jks;keystore 本体刻意放在仓库外,见 .gitignore 里那条注释的立场)。 文件不存在时回退到 debug key 并打一条构建警告 —— clone 下来照样能出一个能装的包, 但那个包不能对外发布:debug key 的指纹是公开的(CN=Android Debug), 任何人都能签出「同包名 + 同签名」的 APK,而本模块持有signature级的PROCESS_CONTROL权限(能反复杀桌面 / SystemUI)。 ⚠ 签名换掉之后不能覆盖安装:已装旧签名的设备必须先卸载(本应用自己的配置会丢, 框架侧的作用域选择不受影响)。- release 开了 R8(
isMinifyEnabled+isShrinkResources)。入口类com.iamcanincan.opticon.entry.OpticonModule由 proguard-keep保名 —— 它同时出现在META-INF/xposed/java_init.list里,改包名时四处必须一起改 (namespace/applicationId、proguard-keep、java_init.list、 以及AndroidManifest里 provider 的android:authorities—— 模块侧是按 「包名 +.config」拼 URI 的,漏掉它就是配置通道整条断掉、全线退回默认值)。 - 依赖版本集中在
gradle/libs.versions.toml。⚠material3必须显式写版本, 不能用 BOM 托管:MD3E 的公开 API(MaterialExpressiveTheme)在稳定版里还是 internal。
判据台的完整玩法(各驱动的来历、踩过的量具坑、真机重放通道)在 docs/DEVELOPMENT.md。
- 声明
minApiVersion=102/targetApiVersion=102。框架只按targetApiVersion决定加载方式 (>= 101 即走META-INF/xposed/java_init.list的现代路径)。 - 只调用
io.github.libxposed.api.*,不依赖hiddenapibypass。 staticScope=false:作用域不固定,用户可在管理器里额外勾选任意应用。autoHotReload=false(框架默认值,显式写出):改动代码后必须重启目标进程。exceptionMode=protective(框架默认值,显式写出):hook 里抛出的异常由框架吞掉, 不让单个图标加载失败带崩 launcher / systemui —— 这两个都是常驻关键进程。
| 内容 | 位置 |
|---|---|
| 模块名 | AndroidManifest 的 android:label(@string/app_name) |
| 模块描述 | AndroidManifest 的 android:description(@string/xposed_description) |
| 作用域 | META-INF/xposed/scope.list |
| 模块配置 | META-INF/xposed/module.prop |
| Java 入口 | META-INF/xposed/java_init.list |
管理器读的是 ApplicationInfo.descriptionRes,不是 module.prop 里的 description=。
后者是 API <= 93 的旧写法,混着写会让管理器把模块当「兼容模式」处理,
作用域列表会退化成列出全部已装应用。Manifest 里也不放任何 xposed* meta-data。
另:META-INF/xposed/ 下的文件是原样打进 APK 的(AAPT2 不处理非 res 目录),
所以 module.prop 里一行注释都没有 —— 写什么用户解包就能看到。
字段说明放在 OpticonModule 的 KDoc 和本文档。
com.iamcanincan.opticon
├── entry/OpticonModule 模块入口:按进程分发两套挂钩
├── circle/ ← 功能一:应用图标(裁圆 + 单色合成)
│ ├── IconHooks 各条 hook 通道的安装与拦截逻辑
│ ├── MonochromeSynth 强制取色:画布与坐标 + **极性判据的入口**(光栅化、线程内交接、诊断)
│ ├── MonochromeMask 单色合成引擎:`dep(a,β,ΔE)` 应用两遍(底色场 / 近旁主体),四支选支 + 各刀,上屏与判据台同一份
│ ├── MonoDump 诊断通道:把它实际吃进去的那张位图经 logcat 带回来重放
│ ├── IconCropWindow **图案窗口的唯一判据**(纯函数):彩色那支照它裁,染色那支照它重排剪影
│ └── CircleIconDrawable 圆形 drawable(自适应图标 + 圆形遮罩)
├── notify/ ← 功能二:通知小图标
│ ├── SystemUiHooks 往 SystemUI 里装挂钩
│ ├── NotificationIconPatch 要不要替换、换成什么的判定
│ ├── IconBitmap 位图的取与压单色(通知那半边,桌面已不再共用)
│ └── ToneCheck 单色(已适配)判定
├── debug/ ← 诊断,不参与任何判断
│ └── CorpusCapture adb 触发的语料抓取(真机图标 → 判据台),见「离线判据台」一节
├── runtime/ 三个功能共用
│ ├── ModuleRuntime 进程内共享状态:宿主 Context、配置读取(多通道回退 + TTL)
│ ├── ModulePrefs 配置的键、默认值、界面侧的读写
│ ├── ModuleOptions 运行期行为开关
│ ├── ConfigProvider 只读配置 provider(跨进程把设置交给模块)
│ ├── MemberLookup 反射取成员的薄封装
│ └── OpticonLog 统一的日志 tag
├── ui/
│ ├── MainActivity 入口 Activity(同时是桌面图标)
│ ├── OpticonApp 外壳:顶栏 + 悬浮药丸导航 + Pager(左右滑动切页)
│ ├── Common 各页共用:Section / SectionLabel / IconTile / FloatingNavSpace
│ ├── icon/IconScreen 「图标」页(裁圆 + 单色层 + 运维按钮)
│ ├── notify/NotifyScreen 「通知」页(通知小图标)
│ ├── module/ModuleScreen 「模块」页(作用域 / 步骤 / 验证)
│ ├── about/AboutScreen 「关于」页(检查更新 / 版本信息)
│ └── theme/Theme M3E 主题(动态取色)
└── update/UpdateChecker 检查更新(唯一的联网点)
MIT —— 见 LICENSE。
- v1.1.1(2026-10-14):强制裁圆的内切基准从「整块画布」换成「图案实际占的那块」(外框 + 不透明圆盘,在钉死的 128 边长上量一次); 按圆裁下去留不下 60% 图案的那一类不再硬裁 —— 整幅缩进圆里、圆内补白底,缩量由「主体进安全区 / 尖角不贴边 / 墨量对齐」三条里最严的一条定。
- v1.1.0(2026-10-14):图标单色合成引擎整条重写为对比度模型;判据台 7 闸 → 14 闸。按版本收口的摘要见根目录 CHANGELOG.md; 每一刀的完整过程(为什么收、为什么拒、被哪道闸拦下)见 docs/devlog/。