模仿三星「幕布」和游戏助推器的触控保护:用一层可调透明度的黑盖住屏幕,适合 OLED 省电、挂机,以及防止游戏更新时锁屏。
为什么要做这个东西
三星 One UI 里有个不太起眼但很好用的功能:幕布。一按,整个屏幕变黑,只留时间和电量,手机照常在后台跑。挂机、下载、更新游戏的时候特别好用 —— 屏幕不亮,OLED 省电,也不会因为长时间不碰而锁屏。
问题是这个功能只在三星上有,而且依赖系统签名权限,别的机器拿不到。
所以有了这个项目。它不追求复刻三星的全部细节,核心就一件事:盖一层够黑、但不影响后台运行的幕布。
三种模式,按需选
实现上最大的分歧是「状态栏要不要一起盖住」。这件事没有免费午餐,我做了三条路,主界面里单选框直接选:
| 模式 | 盖住状态栏 | 打扰后台应用 | 需要无障碍 |
|---|---|---|---|
| 普通悬浮窗 · 抢焦点(默认) | ✅ | ⚠️ 后台应用会失去窗口焦点 | 不需要 |
| 普通悬浮窗 · 不抢焦点 | ❌ | ✅ | 不需要 |
| 无障碍覆盖层 | ✅ | ✅ | 需要开一次 |
默认那条走的是前台服务 + TYPE_APPLICATION_OVERLAY,不带 FLAG_NOT_FOCUSABLE。选无障碍但系统里没开的话会自动降级成抢焦点模式,主界面底部那行显示的是实际生效的模式,不是你想用的那个。
幕布运行中也能切模式,会自动重建窗口,不用先关再开。
一个被推翻的结论
这里值得单独说,因为上一版的文档写错了,而且错得理直气壮。
我上一版断言:普通悬浮窗一定盖不住状态栏,系统会忽略它的 setSystemUiVisibility。这个结论是照抄另一个项目(FakeStandby)的参数得出的 —— 而那个项目用的是无障碍覆盖层,天生被系统信任,带不带 FLAG_NOT_FOCUSABLE 都行。照搬到普通悬浮窗上,前提就不成立了。
这次重新拆了 base.apk。Black Screen(io.japp.blackscreen v2.0.7)没有任何无障碍服务,权限只有 SYSTEM_ALERT_WINDOW 加前台服务那一套,没有 root,没有系统签名。它的全屏黑层就是普通悬浮窗:
layoutParams2.type = SDK_INT >= 26 ? 2038 : 2002; // TYPE_APPLICATION_OVERLAY
layoutParams2.gravity = 17; // CENTER
layoutParams2.format = 1; // RGBA_8888,不透明
layoutParams2.flags = 16777984; // LAYOUT_IN_SCREEN | LAYOUT_NO_LIMITS
// 注意:没有 FLAG_NOT_FOCUSABLE
它唯一「藏状态栏」的动作,是给这个视图设了一句:
o21.setSystemUiVisibility(4102);
// 4102 = IMMERSIVE_STICKY(4096) | HIDE_NAVIGATION(2) | FULLSCREEN(4)
所以真正的机制是这样:悬浮窗层级低于状态栏,这没错,我们确实画不到它上面。但状态栏可以被藏掉,这是沉浸模式,任何窗口都能请求。系统采纳谁的请求,看的是这个窗口有没有资格代表屏幕 —— 而资格来自三个条件:全屏(MATCH_PARENT + LAYOUT_NO_LIMITS)、不透明(RGBA_8888,不是 TRANSLUCENT)、能拿到焦点(不带 FLAG_NOT_FOCUSABLE)。
我们原来卡在第三条上。这一版只改了这一条:普通悬浮窗不再带 FLAG_NOT_FOCUSABLE,无障碍那条保持原样。代价是幕布期间后台 app 会失去窗口焦点,个别视频和游戏会因此自动暂停。
顺带一提,上一版用的 root 方案(su -c "settings put global policy_control immersive.full=*")实测同样盖不住状态栏,整条 root 路径已经全部删掉了。方案对比我放在 README 里,七种方案都试过并标注了实测结果。
修掉的几个问题
这一版主要修 bug,其中两个挺有意思:
幕布开启时改等级没反应。 原因是我上一版重写 CurtainOverlayView 时把 SharedPreferences 监听器删了。这里有个容易踩的点:幕布是悬浮窗,不是 Activity,它盖住屏幕时主界面仍处于 resumed 状态,改偏好不会触发任何回调。所以必须显式注册 OnSharedPreferenceChangeListener,同进程主线程调用下 apply() 会同步回调,幕布上的按钮和主界面的滑杆才能两边都实时生效。
点「停止」反而启动。 同一个根因。用幕布上的手势关掉之后,主界面不会走 onResume,按钮文字还停在「停止幕布」;但点击时读的是 isRunning(已经是 false),于是执行了启动。修法是主界面也监听 running 键,让按钮的显示和执行来自同一个来源。
还有一个纯粹是我自己引入的:为了让幕布盖住圆角和挖孔,视图画得比屏幕各大 48dp,导致布局里的 paddingTop="40dp" 换算到屏幕坐标其实是 -8dp,内容跑到屏幕外面去了。第一版只加了上下方向,结果时间被推到左边、电量 pill 跑到右边 —— 同样的 bug 换了个方向。现在四个方向都加回来了,可见留白是顶部 64dp、底部 56dp、左右 40dp,数值都在 dimens.xml 里,觉得不舒服直接改。
滑动关闭的手感也调了:解锁线从屏幕高度的 28% 降到 25%,图标最多跟到解锁线就停住 —— 停住那一刻就是「够了」,不会再诱导你继续拖。顺带修掉了「必须刚好按在圆钮上」的问题,现在整块屏幕都能起手。
界面
主界面是 Material 3 卡片式,权限、模式、显示三块加一个启动按钮。幕布本体上有时间、日期、电量,中央一个关闭钮,底部两个 pill 分别是亮度级别和自动纯黑倒计时,点击循环切换,和主界面实时同步。
有「使用情况访问」权限的话,中央会显示当前前台应用的图标。
装起来用
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force
& .android-envbuild-apk.ps1 .curtain -Task assembleRelease
第一次使用:授权悬浮窗 → 选模式 → 想要无障碍覆盖层就去开一下(不开也能用,自动降级)→ 授权通知(Android 13+)→ 点启动,看底部那行确认实际生效的模式。
最新版本 V1.6,在 Releases 里下。
作者:Deepseek · B站 @z7ui