上一篇文章介绍了 [AndroidDesktopClock](https://github.com/xuchaoji/AndroidDesktopClock) —— 一款把旧手机变成桌面摆钟的时钟应用。它解决的是"**看时间**"这件事。

但摆钟用久了会发现一个尴尬:手机明明就在手边,想看个别的应用还得先退出时钟、再进桌面、再翻应用列表。既然它已经常驻在桌面上了,为什么不干脆把它做成真正的桌面?

于是有了这篇文章的主角 —— ClockLauncher(待机桌面):一个默认全屏显示待机时钟、右滑呼出应用列表,可以直接设为系统默认桌面的 Android Launcher。


一、 它到底是什么

一句话:它既是摆钟,也是桌面。

待机时钟

装上之后,按 HOME 键进入的不是系统桌面,而是这个全屏的大号时钟。手机摆在床头时它是摆钟,拿起来用时右滑就是完整的应用列表 —— 不需要在"时钟模式"和"正常模式"之间做任何切换。

它把 AndroidDesktopClock 的桌面时钟能力整套移植了过来(天气卡片、CPU 监控、实时网速、防烧屏、夜间模式一个不少),在此之上补齐了一个 Launcher 本该有的全部行为。


二、 核心功能

1. 真·全屏待机时钟

默认页是全屏的:状态栏和导航栏全部隐藏,并且放开了刘海/挖孔区域的限制。

这一点值得单独说:即使是沉浸式应用,如果没声明 layoutInDisplayCutoutMode,系统仍会把挖孔那条带子排除在窗口之外。在这台设备上表现为窗口高度是 2280 而不是屏幕的 2388 —— 看着"全屏"了,实际顶部留着 108px 的非内容区。改完之后横竖屏才真正铺满。

时钟页全屏、应用列表页保留状态栏(搜索框按真实状态栏高度让位),两种系统栏策略跟随翻页自动切换。

2. 右滑呼出应用列表

应用列表

  • 全量应用扫描 + 中文拼音智能排序
  • 顶部实时搜索过滤,输入即筛
  • 自适应网格(竖屏 4 列,横屏 6~9 列)
  • 单击启动;长按弹出快捷菜单:打开 / 系统详情 / 卸载 / 复制包名
  • 监听包变更广播,装完新应用列表自动刷新

呼出方式给了四种,不用记:右滑、左滑、上滑、点底部提示条。返回键和 HOME 键都会把你送回时钟页。

3. 分类归口的设置中心

上一版最受吐槽的一点是:所有设置项平铺在一个滚动列表里,找一项要滑半天。这次改成了两级结构——主界面只列 7 个分类入口,每个入口带实时状态摘要(比如"面板开 · 140×220dp · 透明度 100%"),点进去才是该分类的完整面板:

设置中心

分类 内容
⏰ 时钟样式与时间格式 颜色、阴影、字号 36~180sp、粗体、格式预设/自定义
状态组件 日期、电量、网速各自独立的开关/颜色/字号/粗体
CPU 监控面板 开关、面板宽高、背景透明度
天气组件 城市绑定、坐标、刷新、卡片模式、文字与卡片外观
夜间模式 手动/定时、时段、最低亮度、极暗遮罩深度
手势与屏幕亮度 亮度模式、自动亮度范围、实时环境光
排版布局与预设 拖拽排版、恢复默认、预设增删改、JSON 导入导出

4. 可视化拖拽排版

进入排版模式后,时钟、日期、电量、网速、天气卡片、CPU 面板六个组件都可以用手指拖到任意位置,带金色辅助边框,顶部浮动栏「恢复默认 / 完成保存」。

坐标按屏幕比例存储,所以换分辨率、横竖屏切换都能正确还原。没自定义过则走系统自适应布局。

5. 自动亮度(含三种模式)

摆钟有个很实际的痛点:白天房间亮、晚上关灯,固定亮度不是刺眼就是看不清。所以做了三种亮度模式:

  • 手动:手势 / 滑块,沿用原来的行为
  • 自动(环境光):读取光线传感器自动调节
  • 跟随系统:窗口不覆盖亮度,交还给系统(含系统自带自动亮度)

自动亮度这里有个细节:人眼对亮度的感知近似对数而不是线性,所以映射用的是 log10(lux) 而不是 lux 本身:

亮度 = 最暗档 + (最亮档 - 最暗档) × log10(lux + 1) / log10(3001)

0 lux 落最暗档,约 3000 lux(明亮室内/阴天)到最亮档。再加 0.30 系数的低通滤波、以及"变化不足 1% 就不下发"的门限,避免云影和灯光闪烁造成亮度抖动。

夜间模式优先级最高:夜间模式期间自动亮度让位(不会把屏幕"调亮"破坏睡眠场景),退出后自动恢复。


三、 开发踩坑记

功能描述看文档就够了,这里记录开发过程中真正花了时间的几个坑 —— 它们都不是"写不出来",而是"写完看着对,用起来不对"。

坑 1:CPU 核心数在另一台设备上对不上

现象很直接:我这边显示 8 核,换一台设备只显示 4 核。

根因是核心总数取了 Runtime.getRuntime().availableProcessors()。这个 API 返回的不是物理核心数,而是"本进程当前可用的核",会被两件事影响:

  • ROM 通过 cpuset/cgroup 把前台应用绑到部分核心(8 核机只给 4 核很常见)
  • 内核热插拔,部分核心 offline 时不参与

正确做法是问内核要声明的全部 CPU:

// 优先 /sys/devices/system/cpu/possible  → "0-7"
// 回退 /sys/devices/system/cpu/present
// 再回退扫描 cpuN 目录
// availableProcessors() 只作最后兜底

配套还改了两处:/proc/stat 原来按文件行序读取,离线核心会导致 cpu4 的数据被错位画到 cpu0 上,改成按真实 CPU id 对齐;time_in_state 原来任何一核读不到就整体返回 null(整块面板变空),改成单核容错。

现在面板和日志都会标注核心编号与在线数,比如 共 8 核 [C0..C7] 来源=possible 在线=8,肉眼可直接核对。

坑 2:阴影颜色只能选黑色

用户反馈"文字背景阴影只能选黑色,选不了别的颜色"。查下来是个数学退化问题:

取色器内部用 HSV 存色。阴影色默认是 #FF000000,黑色的 V(明度)= 0,而亮度滑块初始化时直接读了这个 V:

brightnessSeek.setProgress(Math.round(paletteView.getValue() * 100)); // 黑色 → 0
int getColor() { return Color.HSVToColor(new float[]{hue, saturation, value}); } // value=0

于是无论点哪个色相、哪个饱和度,HSVToColor(h, s, 0) 永远返回黑色。

更糟的是画色域时用了 canvas.saveLayerAlpha(..., value * 255, ...) —— V=0 时整个调色板 alpha 为 0,完全看不见。所以用户看到的是"一个空白的取色器,怎么点都只有黑色"。

这也解释了为什么文字颜色一直正常:它默认是 #FFFFFF,V=1,两条路径都成立。只有默认黑色的阴影会踩中。

修复:取色时若 V≤0.05 自动抬到满亮度并同步滑块;色域预览保留 35% 最低可见度;另外补了 10 个常用色快捷色块,换彩色阴影一键到位。

坑 3:弹窗"隔空取色"

修上面那个 bug 时,我注意到一个反常现象:点"阴影颜色"打开取色器后,颜色值里立刻出现一个我没点过的颜色,而且每次都恰好是手指坐标处的颜色。

原因是弹窗是在手指仍按在屏幕上时创建的(点击事件触发),系统会把这次手势剩余的 UP 事件投递给新出现的窗口。而取色器对 ACTION_UP 是无条件取色的:

if (action == DOWN || action == MOVE || action == UP) { hue = ...; notifyColor(); }

所以手指还没离开,颜色就被改掉了。同样的问题也让长按桌面打开设置中心时,抬手瞬间会误点进第一个分类。

修复是加了一个 TouchGuardLayout:弹窗显示后 320ms 内吞掉所有触摸事件,等手指抬起再放行。设置中心和取色器都已接入。

坑 4:滑动调亮度"消失了"

用户反馈左侧上下滑动调亮度失效。查下来是两个问题叠加:

① 感应区被我自己改窄了。 上一轮修右滑手势时,为了避开 ViewPager2 的横向滑动,把左区从 max(72dp, 屏宽/6) 缩到了 48dp —— 在这台 3x 密度设备上只有 144px,横屏 2280px 宽下仅占 6%,实际够不到。

② 子 View 吞掉了 DOWN 事件。 这个更隐蔽:touchDownX/Y 原本只在 onTouchEvent 的 DOWN 里记录。但天气卡片是 clickable 的,手指落在它上面时 DOWN 被卡片消费,父容器根本收不到 DOWN,于是起点是脏数据,手势永远判定不成立。

修复:感应区恢复为 max(80dp, 屏宽/6);在 onInterceptTouchEvent 的 DOWN 阶段就记录起点,不依赖 onTouchEvent 能否收到 DOWN;再按"起手方向"区分纵向调亮度与横向翻页(大幅上滑仍优先呼出应用列表)。

坑 5:全屏了,但没完全全屏

用户说"桌面默认全屏,当前非全屏,可以看到状态栏"。第一层原因是显然的:原来的代码只设了 LAYOUT_FULLSCREEN,那只是"让内容绘制到状态栏下方",并没有隐藏状态栏,要真隐藏得加 FULLSCREEN + IMMERSIVE_STICKY。

但改完发现还是有条边没铺满 —— 窗口实际是 1080×2280,屏幕是 1080×2388。差的 108px 是摄像头挖孔:没声明 layoutInDisplayCutoutMode 时,系统默认把它排除在窗口之外(横屏同理,窗口只有 2280×1080)。

还顺手处理了沉浸式的一个特性:IMMERSIVE_STICKY 在用户下拉出状态栏后会被系统清除,所以重写了 onWindowFocusChanged,重新拿到焦点时按当前页重新声明,否则下拉一次状态栏就永久失效了。


四、 技术选型

  • 纯 Java + 原生 View 体系,无 XML 布局(界面全部代码构建),无第三方运行时依赖
  • 仅依赖 AndroidX(appcompat / recyclerview / viewpager2)+ Material
  • minSdk 24 / targetSdk 35,compileSdk 35 + AGP 8.7.3 + Gradle 8.9
  • 翻页用 ViewPager2(时钟页 ⇄ 应用列表页),系统栏策略跟随 OnPageChangeCallback 切换
  • CPU 数据源三级降级:/proc/stat 真实占用率 → time_in_state 窗口平均频率 → scaling_cur_freq 瞬时频率。部分 ROM 屏蔽 /proc/stat 时会如实标注为"频率曲线",不把频率冒充成占用率

五、 开源地址与下载

安装后如果想让它接管 HOME 键,在应用内「设置中心 → 默认桌面与系统」一键设置,或走系统「设置 → 应用 → 默认应用 → 主屏幕」。

小提示:目前 Release 用的是 CI 的 debug 签名,和自行本地构建的版本签名不同,覆盖安装会提示签名冲突。如果需要长期可升级,建议在仓库配置正式签名密钥(工作流已支持 KEYSTORE_BASE64 等 Secrets)。

如果你也有抽屉里吃灰的旧手机,不妨拿它试试 —— 插上电,摆在桌角,它既是一台安静的摆钟,也是一个随时能用的桌面。