这里是 LSPosed 模块仓库的发布页 —— 只放 release,不放源码。 源码、构建脚本、开发笔记都在 https://github.com/threevits/hole-square

装法:下载下面 release 里的 APK → LSPosed 里确认作用域勾了「系统界面」→ 重启一次 SystemUI 生效。

挖孔黑方块

一个 APK,两面身份:

  • 可打开的 app —— 有图标、有界面,能预览效果、切换形状
  • LSPosed 模块 —— 在挖孔位置画上所选形状

装上即用,改形状立刻生效,不用重启 SystemUI。

前提

  • ColorOS 系设备(ColorOS / OxygenOS / RealmeUI)+ LSPosed
    • 需要设备有挖孔,并且能读到 ro.oplus.display.screenhole.positon(OPPO 系出厂都带这个属性)
    • 实测通过:一加 Ace 3(PJE110)/ ColorOS 16,SystemUI 16.00.12
  • 默认值是按 Ace 3 实测填的(孔径 82px、孔心 (632,78))。换机型不用改代码 —— 开机时模块会读那个属性拿到位置,你只要在 app 里按「调到黑边刚好消失」的流程重新量一遍大小
  • OShin 那类模块无关:本模块不碰流体云,跟它们不冲突
  • 只在竖屏正立时显示:横屏(以及倒过来的 180°)模块会主动不画 —— 挖孔是面板上的物理位置, 转到侧边去了,而窗口坐标系是跟着内容转的,两套对不上。原因见下面第 7 个坑

它在干什么

挖孔是面板上一个直径 72px 的物理圆洞(前置镜头),它永远是黑的,擦不掉。 而且它不在帧缓冲里 —— Android 截图走 SurfaceFlinger 合成图层,物理孔是面板属性, 不出现在画面里(实测:JD 纯白页面上沿孔中心竖扫,一路 (255,255,255) 无黑点)。

于是在那个位置画什么,结果就是「画的东西 ∪ 物理圆」:

画什么 结果
黑圆 视觉上什么都没变(本来就看不见,是个黑圆)
黑方 孔从圆变方 —— 四个角上原本能看见的真实像素被涂黑了
五角星 星形 ∪ 圆 —— 凹角是轮廓上离中心最近的地方,圆最先从那儿鼓出来

所以每个图案都得把那个圆包进去,而落点(轮廓上离中心最近的那处)每个形状都不一样: 方形看边长、五角星看凹角、横条看淡出段。内置的那几个默认大小就是照这条调出来的。

怎么量出真实孔径和孔心

OPPO 属性给的那组数不是真值。 实测 72px 的方块包不住屏幕上的黑圆, 而且黑边露得不均匀 —— 真实孔径更大、孔心也不在属性标的 (632, 76) 上。

app 里因此给了三个可调量:大小、X 偏移、Y 偏移(都按 px,1px 步进)。 量法:

  1. 先调 X / Y 让黑边四周露得均匀。 规则是「把方块往露黑的那边挪」:露右 X+,露左 X−,露上 Y−,露下 Y+。
  2. 再调大小直到黑边刚好消失。

方形和孔同心,所以「方形刚好包住圆」时边长就等于圆的直径。 最终这组值(边长、X、Y)就是真孔径和真孔心相对属性中心的偏移。

实测结果(一加 Ace 3 / PJE110,2026-10-10)

量 实测 OPPO 属性 差
孔径 ≈82 px 72 px 属性少报 10 px
孔心 x 632 632 0
孔心 y 78 76 +2

也就是 大小=82, X=0, Y=2。 X 为 0 说明水平方向属性是准的;直径差 10px 是「包不住」的主因。 系统的 DisplayCutout(76 宽)同样小于真实孔径。

精度约 ±2px —— 黑边消失的临界点靠肉眼判断,容易略微调过头。

每个形状各存一套参数

大小和 X/Y 偏移是按形状分开存的 —— 方形要 82px 才刚好盖住,五角星得 129px、 圆角矩形 116px,共用一个尺寸的话每切一次形状都得重调。

形状 默认大小 为什么
无 — 什么都不画,等于"关掉"。用不着调参数
方形 82px 实测孔径。量孔径用的就是它:调到黑边刚好消失,那个边长就是孔径
五角星 129px 视野 145 缩到 129。凹角太近会露圆、顶上尖角太远会顶进状态栏,两头都得顾
圆角矩形 116px 视野 200 里 rect 是 140 宽,缩到 116 时画出来是 81.2px,跟 ⌀82 差 0.8px
渐变横条 260px 它的坐标系就是像素,260 意味着 1:1 —— 这个数就是横条宽度
自定义 SVG 82px 图标按比例缩放进方块,用方形的值当起点

在任一形状下调,只影响那一个;切走再切回来还是你调好的值。 prefs 里每形状一套键(size_px_square、offset_x_star…), provider 只暴露当前形状那一套 —— hook 只关心当前要画什么。

自定义 SVG(可以有任意多个)

形状列表里点「+ 新建自定义」加一项,每项都能改名、删除,各带自己的 SVG 和自己的大小/偏移。 选中某一项时,底部的文本框编辑的就是它。

  • 改名只改显示名。身份靠内部生成的 id —— 拿名字当 key 的话,一改名所有引用就全断了。
  • 删除带确认框,会连同那套装扮一起删掉。删掉的正好是当前选中项就自动退回「方形」。
  • 自定义项列表序列化成 JSON 存在一个键里(prefs 只支持标量,而列表要能任意增删改名)。 SVG 里什么字符都可能有,手写分隔符转义迟早出错,JSON 最省事且 org.json 是 framework 自带的。
  • 从旧版升级时,原来那个单一自定义项会自动迁移成一个命名项,粘过的图不会丢。

新建时给的模板是一段「中心实心、边缘淡出」的光晕 —— 因为物理孔的约束,这种形状最实用(见下)。

解析支持范围

元素:path rect circle ellipse line polyline polygon 属性:fill stroke stroke-width fill-opacity stroke-opacity opacity stroke-linecap stroke-linejoin viewBox,以及内联 style="fill:#f00;…" (优先级高于同名属性 —— Figma / Inkscape 导出的 SVG 大多把属性塞在这里) 颜色:none #RGB #RRGGBB #RRGGBBAA rgb() rgba() transparent currentColor 和一小撮颜色名 变换:<g transform> 的 translate / scale / rotate / matrix 渐变:linearGradient / radialGradient + <stop>,支持 gradientUnits、 gradientTransform、spreadMethod、xlink:href 继承

不支持:<use> <defs> 里的 <clipPath> 滤镜、CSS <style> 块和 class 选择器、 <text>、动画。遇到会跳过那一项,不会整张挂掉。

渐变:怎么调「多久淡到透明」

SVG 里就是一条 alpha 渐变 —— Android 的 LinearGradient / RadialGradient 本来就吃带 alpha 的 ARGB, 所以「淡到透明」不需要特殊处理。淡出宽度由最后一个实心色标的 offset 决定:

淡出宽度(px) = (1 − offset) × 大小 ÷ 2

大小 140px 时:offset 60% → 28px,80% → 14px,86% → 9.8px,96% → 2.8px。 注意大小和淡出是联动的,把大小调大淡出也会按比例变长。

⚠️ 渐变必须「中心实心」才好看

物理挖孔永远纯黑、边界锐利。任何半透明的地方,那个黑圆的硬边都会透出来。

所以「整块从黑淡到透明」会在渐变中间切出一个清晰的黑色圆盘,看着像画坏了。 正确做法是只在圆的外围做淡出 —— 中心保持不透明盖住孔,边缘柔和消失,像一团晕开的光。

下限:实心部分必须还能盖住 ⌀82 的孔。140px 时实心半径要到 41px,即 offset ≥ 41/70 ≈ 59%。

图案按原比例缩放进方块、居中,四周留白是透明的 —— 那个黑圆会从留白处露出来, 所以要么挑个能盖住圆的图形,要么把大小调大。预览里能直接看到。

找图案的地方:Iconify(聚合 150+ 套开源图标, 搜索最好用)、Material Symbols、 Lucide / Tabler / Phosphor(都 MIT)、 SVG Repo(能按许可证筛)。 注意许可:MIT / Apache-2.0 / CC0 随便用;CC-BY 要署名;带 NC / ND 的别用在会分享出去的东西上。

描边型图标别慌

Lucide / Tabler / Phosphor 的图标大多是描边型:

<path d="M12 2 L2 22 L22 22 Z" fill="none" stroke="currentColor" stroke-width="2"/>

解析器把 fill="none" 映射成「不填充」、把 stroke / stroke-width / 线帽线接一起读出来,所以描边型图标能正确渲染,不会变成一坨实心黑块。 (currentColor 没有 CSS 上下文,按黑色处理。)

为什么是自己写的解析器

两条近路都堵死了,记在这儿免得以后又想抄近道:

  1. Drawable.createFromXml(res, Xml.newPullParser()) —— 看起来能直接把一段 XML 字符串 变成 VectorDrawable。但 Xml.asAttributeSet() 要求 parser 实现 AttributeSet, 而设备上的 KXmlParser(/apex/com.android.art/javalib/core-libart.jar)实测是:
    .class public Lcom/android/org/kxml2/io/KXmlParser;
    .implements Lorg/xmlpull/v1/XmlPullParser;
    .implements Ljava/io/Closeable;
    .implements Ljava/lang/AutoCloseable;
    会抛异常。(AOSP 那句报错文案 "not a parser created by Xml.newPullParser()" 本身是错的。)
  2. 自己实现 AttributeSet 再走 createFromXmlInner —— Resources.obtainAttributes() 依赖 getAttributeNameResource() 返回 com.android.internal.R.styleable 的数值 ID, 那些 ID 是隐藏的,是个泥潭。

所以自己写:SvgPath.kt(d 属性 → Path,含 M/L/H/V/C/S/Q/T/A/Z 和隐式重复命令)

  • SvgDrawable.kt(XML 遍历 + 样式继承 + 用 Canvas 直接画)。 全公开 API,app 进程和 SystemUI 进程行为完全一致。

结构

src/io/github/threevits/holesquare/
├── Shape.kt           形状枚举 + 取 Drawable(app 和 hook 共用同一份,预览不可能和实际不一致)
├── SvgPath.kt         d 属性 → Path(M/L/H/V/C/S/Q/T/A/Z、隐式重复、圆弧转贝塞尔)
├── SvgDrawable.kt     SVG 文档 → 可画的 Drawable(样式继承 / 内联 style / transform)
├── HoleGeometry.kt    从 OPPO 属性读挖孔位置(app 和 hook 共用)
├── Config.kt          配置存储 / 跨进程读取 / 按形状分存参数
├── ConfigProvider.kt  ← 暴露给 SystemUI 的只读 provider
├── PreviewView.kt     app 里的预览
├── MainActivity.kt    app 主界面(纯 framework View)
├── ShapeView.kt       SystemUI 里那块覆盖层
└── HookEntry.kt       hook 入口

配置怎么跨进程传

app 和 hook 跑在两个进程:app 是「挖孔黑方块」自己,hook 在 SystemUI 里。

  • hook 里调 getSharedPreferences 是错的 —— 那读到的是 SystemUI 自己的 prefs,静默拿到错数据。
  • Android 11+ 之后 /data/data/<pkg> 别的应用也进不去,直接读文件也不行。

所以走 ConfigProvider(exported,只返回一个形状名字符串),hook 用 ContentResolver 查。 实时更新走 ContentObserver:app 改完 notifyChange,hook 那边的观察者就醒了。

queryShapeOrNull 查不到时返回 null 而不是默认值 —— 这点很关键。 SystemUI 启动极早,那时 app 进程还没起来,query 会失败;失败和「用户真的选了方形」 都会得到 SQUARE,混为一谈的话重启后形状就悄悄退回方形且再也纠不回来。 调用方拿到 null 就重试(实测要等到第 68 次、约 912 秒才应答)。

加新形状

  1. Shape.kt 的枚举里加一项 + 在 buildDrawable() 里给出对应 Drawable
  2. 完事。app 的单选框、预览、SystemUI 的覆盖层全都是从 HoleShape.entries 生成的

Drawable 只要在自己 bounds 里画满就行。注意 Drawable.draw() 的约定是"画在 getBounds() 里", 但 canvas 不会自动平移到 bounds 原点 —— 框架自带的 ColorDrawable 内部自己做了偏移, 手写 Drawable 必须自己加 bounds.left/top,否则尺寸对、位置却钉在 (0,0)。

samples/ —— 现成的图案和生成器

samples/
├── fade-bar.svg    中间实心、左右平滑淡出的横条(260×82)
├── star.svg        圆角五角星(145×145,内切圆 43px)
└── make-star.py    五角星生成器,改参数重跑就行

make-star.py

python3 -I make-star.py                            # 默认:0.47 内比、尖角大圆
python3 -I make-star.py --tip 0.22                 # 角再圆一点
python3 -I make-star.py --inner-ratio 0.40         # 星瘦一点(自动放大补偿)
python3 -I make-star.py --out my-star.svg

会打印「app 里的大小填多少能 1:1 渲染」,抄进去就行。

两条关键数学(脚本注释里也写了):

① 圆角要是真圆弧,控制点不能随手取。 顶点内角 θ、圆角半径 t:

切点到顶点的距离    d = t / tan(θ/2)
弧的转角            Δ = π − θ
控制点离切点的距离   k = (4/3)·tan(Δ/4)·t

从切点画三次贝塞尔,控制点沿「切点→顶点」方向偏移 k。少了那个系数,弧会鼓或者瘪。 (d 不能超过相邻边长的一半,否则圆角把自己吃掉。)

② 星的内切圆由凹角决定,不是尖角。 轮廓上离中心最近的地方是凹角顶点。凹角一圆,边界整体往中心收,内切圆半径大约掉到 r − 2.24·t_valley。所以凹角几乎不能圆 —— 要盖住半径 r_hole 的洞,必须 r − 2.24·t_valley ≥ r_hole,只能靠加大外半径补偿。本项目要盖 ⌀82 的挖孔, 所以脚本的 --inscribed 默认 43。

尖角就没这个顾虑,它离中心远,想圆多少圆多少 —— 正好也是「别搞成尖尖的」想要的。

fade-bar.svg 怎么调

想改什么 改哪儿
整体长度 <rect> 的 width 和渐变的 x2(offset 记得按新宽度重算 = x ÷ 宽度)
实心段宽度 中间那两个 offset。左缘 x = 中心 − 宽度/2
淡出手感 尾部那个 alpha(s) = 3s² − 2s³ 换成别的曲线重新采样
两端圆角 <rect> 加 rx/ry(上限 = 半个高度),去掉就是直角

alpha 一律用 smoothstep 而不是线性 —— 线性末端斜率不为零,alpha 撞到 0 会留下一条 看得出来的终止线。

踩过的八个坑

1. 窗口类型不能是 2038。 第一版用 TYPE_APPLICATION_OVERLAY(2038),层序只有 111000,被控制中心盖住、锁屏也看不到。 实测层序表:

111000  HoleSquare(2038)      ← 太低
151000  StatusBar
171000  NotificationShade    ← 控制中心是独立窗口,比 2038 高一整档
561000  NavigationBar
571000  ScreenDecorOverlay   ← 系统画圆角/挖孔装饰的窗口

ScreenDecorOverlay 是 ty=NAVIGATION_BAR_PANEL(2024),flags 是 NOT_FOCUSABLE NOT_TOUCHABLE NOT_TOUCH_MODAL LAYOUT_IN_SCREEN,私有 flags 有 IS_ROUNDED_CORNERS_OVERLAY —— 它画的屏幕圆角在锁屏上照样可见,证明这层在键盘锁之上。 所以改用 2024,提到 571000,控制中心和锁屏一起解决。

查层序:

adb shell dumpsys window windows | awk '
/^  Window #[0-9]+ Window\{/ { n=$0; sub(/.*u0 /,"",n); sub(/\}:.*/,"",n) }
/mBaseLayer=/ { if (match($0,/mBaseLayer=[0-9-]+/)) printf "%s %s\n", substr($0,RSTART+11,RLENGTH-11), n }
' | sort -n

2. view.background = drawable 换形状时画面纹丝不动。 日志明明打了「形状已切到五角星」,屏幕上还是方块。改成自己 onDraw 画就正常了。

3. 更阴的:不清画布,旧图形赖着不走。 改成自绘之后,View 没有背景 → 框架不认为它 opaque → 硬件渲染器复用上一帧的缓冲, 五角星是画在旧的方块上面的,看着还是方块。 修法两件套:窗口 format 改 TRANSLUCENT,onDraw 开头 canvas.drawColor(TRANSPARENT, CLEAR)。 (窗口本来就该是半透明的 —— 星角之间、横条上下都是空的,没盖到的地方得透过去看见后面。)

4. 丢到 Handler 上跑的代码,异常会被吞。 install() 一开始没包 try,异常被 Handler 吃掉,表现为「窗口加上了、日志没了、 后面的 ContentObserver 也没注册」,现场一片安静。现在整个包在 try 里并打日志。

5. RadioGroup 只认直接子 View。 它靠 PassThroughHierarchyChangeListener 给子 View 挂内部监听器,而只对直接是 RadioButton 的子 View 生效。为了在一行里并排放「改名/删除」按钮,把 RadioButton 包进 LinearLayout 之后,点单选框只会让按钮自己亮起来,RadioGroup 完全不知情 —— OnCheckedChangedListener 不触发,旧的选中项也不会取消(可能两个同时亮)。

修法两条缺一不可:

  • 给每个 RadioButton 自己挂 setOnClickListener { shapeGroup.check(id) }
  • 重建列表后调一次 shapeGroup.check(当前选中项) —— 光设 isChecked = true 只让按钮亮, RadioGroup 内部记的 mCheckedId 还是 -1,用户点下一个时它去"取消旧的"会找不到目标

6. check() 只点亮按钮,真正的处理在 group 的监听器里。 重写界面时把 shapeGroup.setOnCheckedChangeListener 整段丢了,结果按钮点得动、灯会亮, 但什么都不发生 —— 选中项既不保存也不上屏。存储里是旧值、界面上亮的是新值,两边对不上。

教训:测交互不能只看「控件状态对不对」,得去查副作用(配置有没有真的落盘)。

7. 横竖屏:窗口坐标系是跟着内容转的,物理孔不是。 第一版把窗口用 Gravity.TOP or LEFT 钉死在竖屏坐标上,竖屏好好的,一转横屏那块黑方块 就飘到屏幕别处去了。根因是两套坐标系不同步:

  • 挖孔钉在面板上,物理位置不动。竖屏在顶部正中,横屏就跑到屏幕左/右侧的垂直正中。
  • 窗口的 x/y 是逻辑坐标,跟着内容转。横屏时逻辑空间变成 2780×1264 并且整体转了 90°, 同一组数字指向屏幕上另一个物理点。

真要挪过去,得把自然坐标按旋转映射一遍(AOSP CoordinateTransforms 那套: ROTATION_90 → (y, W₀ − x)、ROTATION_270 → (H₀ − y, x))。本模块没那么做 —— 选的是横屏干脆不画:ShapeView.showShape = false 就不画画。窗口虽然还在, 但它本来就是 TRANSLUCENT、而且 onDraw 开头会 CLEAR 一遍,所以「不画」等于全透明, 屏幕上跟窗口不存在一样。比 removeView/addView 稳 —— 旋转过程中摘窗口会撞上 ViewRoot 重建那一类竞态。

配套两条:

  • app 锁竖屏(manifest 里 screenOrientation="portrait")。预览和「偏移量」都是按竖屏坐标算的, 放开横屏的话,用户横着调出来的偏移值会被当成竖屏向量用,方向直接歪掉。
  • 180° 也算不显示。它是竖屏但是倒的,孔的物理位置同样跑到屏幕底部,照画一样是块飘着的方块。

8. 边打字边 clamp:打什么数字都变成 8,只有 9 幸存。 大小输入框的 MIN_SIZE_PX 是 8。用户想打 15,刚敲下 1, afterTextChanged → pick() → clampSize(1) = 8 → apply() 把 8 回写进输入框, 接着那个 5 接上去就成了 85。打 9 没事 —— 9 刚好 ≥ 8,夹不着。 偏移框同理:clampOffset 上限 200,想打 300 的话打到第三位就跳成 200。

阴的地方在于 pick() 的注释早就写明了规矩("不把 clamp 后的值写回输入框"), 但 apply() 里那句 fields.forEach { view.setText(...) } 照写不误 —— 注释写了规矩,代码没守。

修法不是删掉回写 —— 回写本身是必需的(按 +/− 要更新、切形状要刷新全部框)。 要给它开一个"谁正在打字"的出口:

  • 从输入框打进来那次带 fromTyping = true → apply(..., skipField = 那个框),只跳它一个
  • 按 +/− 不带这个参数,照常回写(不然按了没反应)
  • 光标离开输入框时再对齐一次,把"夹过之后真正生效的值"显示出来 (打字期间框里可能显示 1 而实际是 8,不对齐就是在给用户看假数)

教训:"用户正在输入中的中间态"和"已经定稿的值"必须分开。 任何"输入即校验即回写"的控件都有这个坑。

构建

./build.sh
adb install -r build/module.apk

手搓构建(kotlinc + d8 + aapt2),不用 Gradle。只需要自备 Android SDK,其余全自动:

依赖 怎么来
build-tools;34.0.0 + platforms;android-34 自备,用 ANDROID_SDK_ROOT 指向 SDK(默认 ~/Android/Sdk)
Kotlin 编译器 2.4.20 首次构建自动从 Maven Central 下到 ./tools/(约 63 MB,之后走缓存)
Xposed API(tools/api-82.jar,25 KB) 随仓库提交,不用管
签名钥匙 缺失时自动生成到项目根的 debug.keystore(已在 .gitignore 里)

想用自己的钥匙设 KEYSTORE,工具链想放别处设 TOOLS_DIR。

干净环境验证过:把项目拷到空目录、不给任何工具链,直接跑 ./build.sh 能出 APK。

没有 AndroidX —— 界面全用代码搭,不碰 R 资源(只有图标和 xposedscope 走 res/, 由 aapt2 在链接期解析)。

装完之后需要重启一次 SystemUI 模块才生效:

adb shell su -c 'killall com.android.systemui'

⚠️ 别连着反复执行这条 —— LSPosed 把 SystemUI 反复死亡当成系统不稳定, 累计 4 次会自动进入安全模式,届时所有 Xposed 模块全部停用直到重启。 只在装完模块需要生效时来一次。

许可证

MIT

验证

模块日志不在 logcat 里,在 LSPosed 的日志文件:

adb shell su -c 'cat $(ls -t /data/adb/lspd/log/modules_*.log | head -1)' | grep 挖孔黑方块

正常应该看到:

[挖孔黑方块] 已挂上 com.android.systemui.SystemUIApplication#onCreate
[挖孔黑方块] 窗口已就位 129x129 @ (567, 14),属性里的挖孔 72x72 @ (596, 40),屏幕旋转 0°(竖屏,显示)
[挖孔黑方块] onDraw #1 五角星 129x129
[挖孔黑方块] provider 一直没应答,保持默认配置
[挖孔黑方块] 已切到 「五角星」129px 偏移 (0, 3)
[挖孔黑方块] onDraw #2 五角星 129x129

provider 一直没应答 是正常的:开机时 SystemUI 比 app 进程先起来, provider 查不到,模块会重试 12 次(约 18 秒)。等 app 起来后通过 ContentObserver 补上 (就是那条「已切到」)。不是错误。

看实际效果直接截图量像素(这块覆盖层是真实图层,会出现在截图里):

adb shell screencap -p /sdcard/s.png && adb pull /sdcard/s.png
# 方形:孔心周围 82×82 全黑
# 五角星:中心连成一片黑,星角之间能看见背景透出来

⚠️ 注意截图看不到物理孔(那是面板属性,不进帧缓冲),所以截图上五角星是干净的星; 真机上那块圆还会参与合成 —— 包不住的地方(凹角、淡出段)会露出来。预览视图画的是并集,跟真机一致。

失效了怎么办

  • 没有「窗口已就位」 → 加窗被拒或 SystemUIApplication 类名变了
  • 有「窗口已就位」但形状一直是默认的 → provider 没应答,看「形状同步为」那条是第几次尝试
  • 形状切了但画面不变 → 回到坑 2/3,确认窗口是 TRANSLUCENT 且 onDraw 开头有清画布

反编译参考:SystemUI 的 dex 在 /system_ext/priv-app/SystemUI/SystemUI.apk, 挖孔相关在 com.oplus.systemui.statusbar.util.OplusStatusBarUtils 和 com.oplus.systemui.statusbar.OplusScreenDecorationsExImpl。

Releases

1.0.3

Stable

10/11/2026, 12:08:50 PM

1.0.3

从 1.0.1 一次跨了两个版本(1.0.2 只在本机装着测过,没单独发)。

内置形状 2 个 → 5 个

形状 默认大小 默认偏移
① 无 — —
② 方形 82 (0, +2)
③ 五角星 129 (0, +3)
④ 圆角矩形 116 (0, +2)
⑤ 渐变横条 260 (0, +2)
  • 每个形状都带自己的默认大小和偏移,都是实测调出来的 —— 装完开箱即用,不用自己一个个试
  • 「无」放在第一个:一个像素都不画,等于"关掉",不用卸载模块
  • 三角形删掉了

横屏不再乱画

挖孔是面板上的物理位置,不跟着屏幕内容转;而覆盖层的窗口坐标是跟着内容转的。
竖屏下两套坐标重合所以一直没露馅,一转到横屏,那块黑方块就飘到屏幕另一个地方去了。

现在非竖屏正立(横屏、以及倒过来的 180°)一律不画。窗口还在,但一个像素都不画 ——
它本来就是半透明的、每帧开头还会清一次缓冲区,所以"不画"等于全透明,
屏幕上跟窗口不存在一样。

修了两个 bug

  1. 大小输入框"打什么数字都变成 8" —— 最小边长是 8,敲下 1 当场被夹成 8 再回写进框里,
    后面接个 5 就成了 85(只有 9 因为 ≥ 8 而幸免)。偏移框同理(上限 200)。
    现在打字中的那个框不会被回写打断,光标离开时再对齐显示。
  2. 旋转日志打错 —— 把 Surface.ROTATION_90 的常量值 1 当角度打成了"旋转 1°"。

升级

同包名同签名,覆盖安装即可,已有参数不会丢。装完重启一次 SystemUI 才生效。

Assets

1

1.0.1

Stable

10/11/2026, 11:03:36 AM

1.0.1

本模块仓库的首个发布。版本号接续源码仓库 —— 1.0.0 → 1.0.1 改的是包名
(为满足本仓库的 application ID 要求,com.threevits.holesquare → io.github.threevits.holesquare),功能一行没动。

功能

  • 在挖孔位置盖一层可调的形状,把面板上那个物理黑圆视觉上变成你想要的图形
  • 内置方形/三角形,另可加任意多个自定义 SVG 项(可增删改名,各带自己的大小和偏移)
  • app 里按真实比例实时预览,改完立刻生效,不用重启 SystemUI
  • 自带 SVG 解析器:路径/描边/多色/渐变/内联 style/transform

前提

  • ColorOS 系设备(ColorOS / OxygenOS / RealmeUI)+ LSPosed,设备要有挖孔
  • 作用域只有「系统界面」(manifest 里声明了 xposedscope,装上通常自动勾好)
  • 实测:一加 Ace 3(PJE110)/ ColorOS 16,SystemUI 16.00.12
  • 换机型不用改代码:挖孔位置开机时从 ro.oplus.display.screenhole.positon 读,在 app 里重新量一遍大小和偏移即可
  • 默认值是按 Ace 3 实测填的(孔径 82px、孔心 (632,78))—— 注意 OPPO 属性里写的是 72px,那个数不对

完整说明见 README。

Assets

1