
简介这是一份面向 Android 中高级开发者与移动 UI 学习者的智能锁屏实战源码包围绕锁屏界面的视觉设计、交互逻辑、安全机制与性能优化展开适合用于课程设计、组件化练习与锁屏功能二次开发。压缩包共 57 个文件、约 722KB以 6 个 Java 源文件与 18 个 class 编译产物为主体配合 11 个 xml 布局与配置、9 个 png 图标切图另有 jar 依赖库、properties 工程配置及 apk、dex 可运行产物构成一个可直接导入 Eclipse 的完整工程。工程涵盖壁纸服务、通知管理、指纹与图案解锁、快捷功能入口、主题与字体自定义、动画与手势识别等模块并附 proguard 混淆配置与 JavaApk 源码说明文档便于读者对照阅读解锁流程、通知隐私过滤与内存占用控制的实现思路。目前已有 249 人学习适合希望从零拆解一套轻量锁屏应用结构、积累系统服务集成经验的开发者参考。1. 锁屏不是一张静态壁纸Android 智能UI锁屏在解决谁的什么问题Android 智能UI锁屏这个词听起来像给锁屏换一套皮肤真正落到工程里却是两件事拼在一起让一个界面稳定地出现在系统锁屏之上以及让这个界面的内容随场景变化。前者跟 Keyguard、前台服务、全屏通知、后台启动限制打交道后者跟信号采集、打分决策、版面降级打交道。做这两件事的人通常不是想炫技而是想让用户在不点亮主界面的那三秒里直接看到今天最该看的两三条信息——通勤时的路况、充电时的进度、深夜时的勿扰入口。适合已经能写 Activity、懂一点 Compose、被“锁屏上浮层一闪而过”折磨过的 Android 开发者也适合想评估这条技术路线值不值得投人的技术负责人。2. 锁屏界面的三条落地路径系统锁屏扩展、全屏 Activity 与锁屏小组件怎么选2.1 系统锁屏归谁管为什么普通应用贴不上去先把边界说清楚后面很多坑就不用踩了。Android 的锁屏界面Keyguard是由系统 UI 进程持有并绘制的窗口普通三方应用的窗口默认在 Keyguard 之下用户点亮屏幕看到的第一层是系统锁屏你的 Activity 即使已经onResume也可能被压在下面。KeyguardManager.isKeyguardLocked()读到的就是这层状态它只告诉你“锁着还是没锁”不给你任何绘制权限。所以“自定义锁屏”在三方应用里从来不是“替换系统锁屏”而是“在系统锁屏之上申请一个可见窗口”。这个可见窗口有两种形态一种是被授予了showWhenLocked的 Activity它在锁屏状态下仍然可见另一种是通过通知的全屏 Intent 在锁屏上弹出。两条路都要过系统的一道审核——你要证明这个界面属于用户此刻真的需要的场景而不是广告。我一般会先让团队里所有人跑一遍判断如果去掉亮屏那一瞬间的展示你的功能是否还能成立如果不成立说明你确实需要锁屏层如果只是“想多一个曝光入口”那锁屏是投入产出比最差的位置因为后面每一个适配坑都得付一次成本。2.2 三条落地路径的权限门槛与适配成本落地路径我见过的主要三种各自的能力上限差别很大选错了后面全是返工。路径可控范围权限与门槛适配成本适合的场景锁屏 ActivityshowWhenLocked全屏界面、任意 Compose 布局、动效、手势需要用户开启通知部分版本对后台启动有限制需前台服务或全屏通知拉起中高需处理分辨率、深色、内存、生命周期深度定制的资讯卡、日程卡、充电面板通知 全屏 Intent一个由系统渲染的通知和一次全屏拉起USE_FULL_SCREEN_INTENT声明且高版本对非通话/闹钟类收紧中主要受系统放行策略影响强时效提醒、一次性展示锁屏小组件 / RemoteViews宿主容器内的有限控件尺寸由宿主给依赖宿主支持控件白名单受限低但天花板明显轻量信息块、简单状态展示选型上我的建议很直接要“智能排版”就老老实实走锁屏 Activity因为只有这条路的布局能力足以支撑动态卡片编排RemoteViews 那套只适合做“固定位置放一个数字”。至于全屏 Intent把它当成拉起锁屏 Activity 的一个触发器而不是界面本身——这样即使某天系统收紧了放行策略你的界面还在只是拉起方式要换。顺带说一句全屏通知的分类不要乱填。为了更容易被放行就把分类伪造成通话或闹钟是典型的短期省事长期翻车应用商店审核和用户投诉两头都躲不过。2.3 跑通一个最小可用的锁屏界面先把最小闭环跑起来别一上来就写智能逻辑。第一步是声明宿主 Activity注意主题必须是半透明或独立主题否则解锁瞬间会闪一下黑屏。!-- AndroidManifest.xml锁屏宿主 Activity 的最小声明 -- activity android:name.lockscreen.LockHostActivity android:exportedfalse android:launchModesingleInstance android:excludeFromRecentstrue android:showWhenLockedtrue android:turnScreenOntrue android:themestyle/Theme.LockHost / !-- Theme.LockHost 建议继承无 ActionBar 的深色主题 窗口背景设为半透明避免解锁时出现纯黑闪帧 --第二步在代码里做版本回落。showWhenLocked这个 Activity 方法从 Android 8.1API 27才有低版本仍然要用窗口 flag这段分支我建议直接抄不要每次重写。class LockHostActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // API 27 起优先使用 Activity 级方法系统能正确感知窗口属性 if (Build.VERSION.SDK_INT Build.VERSION_CODES.O_MR1) { setShowWhenLocked(true) // 锁屏状态下保持可见 setTurnScreenOn(true) // 亮屏时直接进入本界面 } else { window.addFlags( WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON ) } // 关键取舍不主动 requestDismissKeyguard。 // 用户可能只想瞥一眼强行解锁会打断后续操作也容易触发安全策略。 setContent { LockRoot() } } // 解锁后及时清理可见性标记避免界面残留在主屏之上 override fun onDestroy() { if (Build.VERSION.SDK_INT Build.VERSION_CODES.O_MR1) { setShowWhenLocked(false) setTurnScreenOn(false) } super.onDestroy() } }这段代码里有两个参数值得单独说。setShowWhenLocked(true)决定的是“锁屏时窗口是否可见”它不改锁屏状态用户依然需要指纹或密码才能进主界面setTurnScreenOn(true)决定的是“亮屏时是否直接显示本 Activity”它只对点亮屏幕这个动作生效不影响用户主动返回。两个都开才是“亮屏即见”的效果。低版本那两个 flag 名字很像但语义一致唯一区别是清理时要用clearFlags。第三步用全屏 Intent 拉起。这里的参数更微妙setFullScreenIntent(pi, true)的第二个参数是“是否在锁屏时也显示 heads-up”设成true才会在锁屏上直接弹出。private fun fireLockSurface() { val intent Intent(this, LockHostActivity::class.java).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) } val pi PendingIntent.getActivity( this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) val notification NotificationCompat.Builder(this, CHANNEL_ID) .setSmallIcon(R.drawable.ic_lock_badge) .setContentTitle(今日概览) .setPriority(NotificationCompat.PRIORITY_HIGH) // 低于 HIGH 不会触发全屏 .setCategory(NotificationCompat.CATEGORY_REMINDER) // 按真实业务分类勿伪造 .setFullScreenIntent(pi, true) .setAutoCancel(true) .build() NotificationManagerCompat.from(this).notify(NOTIFY_ID, notification) }PRIORITY_HIGH及以上才会进入全屏放行队列PRIORITY_DEFAULT只会静默挂在通知栏NOTIFY_ID要固定否则每次触发都新建一条通知用户会看到满屏同款。还有一点全屏 Intent 只是“请求拉起”系统有权降级成普通通知所以你的界面必须能在“被降级为通知”时也说得过去不能把全部信息塞在一个只有锁屏才渲染的布局里。3. 场景信号怎么来把时间、充电、使用频次变成可决策的特征向量3.1 哪些信号真的可用权限、精度与刷新成本“智能”这件事很多人第一反应是接一个模型。锁屏这个位置恰恰相反可用信号少、刷新成本高、容错空间小规则打分往往比模型更稳因为你能对用户解释“为什么今天给你看这个”。我按获取成本把信号分成三档。零成本档本地时间、星期、充电状态、屏幕亮灭、解锁事件。这些不需要任何权限ACTION_USER_PRESENT动态注册就能拿到注意 8.0 之后静态注册失效。中成本档应用使用统计需要PACKAGE_USAGE_STATS特殊权限用户得去设置页手动开授权率通常不高要做好“没授权就降级”的设计。高成本档位置、日历、健康数据权限弹窗重、隐私敏感锁屏这种高频场景我不建议碰收益覆盖不了打扰成本。充电状态是我最推荐的一个信号因为它的语义特别干净插着电通常意味着用户在桌面或床边停留时间长适合展示信息密度高的卡片没插电则要克制别在电量 15% 的时候给他推一条耗电的视频卡片。// 用粘性广播读一次当前电量与充电状态不需要注册常驻监听 val filter IntentFilter(Intent.ACTION_BATTERY_CHANGED) val status registerReceiver(null, filter) val isCharging status?.getIntExtra(BatteryManager.EXTRA_STATUS, -1) ?.let { it BatteryManager.BATTERY_STATUS_CHARGING || it BatteryManager.BATTERY_STATUS_FULL } ?: false val level status?.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) ?: -1传null作为 receiver 拿的是粘性广播读一次就走不会留下常驻监听这在锁屏启动路径上很重要——多一个常驻监听就多一份被系统判定为后台耗电的风险。3.2 把场景离散成特征向量的写法连续值直接进决策函数是灾难因为同样的“18:03”和“18:47”会算出完全不同的结果导致版面抖动。我一般先做离散化把一天切成几个语义时段把频次切成几档把应用类别归成几类。/** 锁屏决策用的上下文快照全部字段都是离散值 */ data class LockContext( val timeBucket: TimeBucket, // 深夜 / 早间 / 通勤 / 工作 / 晚间 val weekdayType: WeekdayType, // 工作日 / 休息日 val charging: Boolean, val batteryLow: Boolean, // 电量 20% val unlockDensity: Int, // 近 1 小时解锁次数分 0/1-3/4 三档 val topCategory: AppCategory // 社交 / 内容 / 工具 / 无授权 ) enum class TimeBucket { NIGHT, MORNING, COMMUTE, WORK, EVENING } enum class WeekdayType { WORKDAY, WEEKEND } enum class AppCategory { SOCIAL, CONTENT, TOOL, UNKNOWN } fun buildContext(now: LocalDateTime, unlockCount: Int, usage: UsageSnapshot): LockContext { val bucket when (now.hour) { in 0..5 - TimeBucket.NIGHT in 6..8 - TimeBucket.MORNING in 9..11, in 17..19 - TimeBucket.COMMUTE in 12..16 - TimeBucket.WORK else - TimeBucket.EVENING } // 解锁密度做三档归并避免 2 次和 3 次被当成两种不同场景 val density when { unlockCount 0 - 0 unlockCount 3 - 1 else - 2 } return LockContext( timeBucket bucket, weekdayType if (now.dayOfWeek.value 6) WeekdayType.WEEKEND else WeekdayType.WORKDAY, charging usage.charging, batteryLow usage.level in 0..20, unlockDensity density, topCategory usage.topCategory ) }TimeBucket的边界不是随便切的它对齐的是用户行为切换点而不是整点早间和通勤之间差着一个“出门”动作晚间和工作之间差着一个“下班”动作。unlockDensity三档归并的意义在于抗抖动实测里 1 次和 3 次解锁对应的行为几乎没有区别但如果你把它当连续值同一个用户在同一时段会看到两套版面这种“玄学跳动”是最容易被投诉的问题。3.3 决策引擎规则打分比模型更适合锁屏打分函数的写法很朴素每张候选卡片对当前上下文有一个权重取前 N 张同分按固定优先级排序保证稳定。关键不是算法多花哨而是每一个权重都能说清楚来源。卡片类型深夜早间通勤工作充电中电量低日程提醒0.20.90.70.80.60.4通勤路况0.00.61.00.20.30.1充电进度0.50.30.10.21.00.3内容推荐0.10.20.50.10.40.0勿扰入口1.00.20.10.30.20.2这张表是起点不是终点真实项目里我会让它可配置下发方便按人群调。注意“电量低”那一列内容推荐直接给 0.0——不是它不重要是这个时候推内容会让用户觉得你没分寸感。data class CardSpec(val id: String, val type: CardType, val basePriority: Int) fun rankCards(ctx: LockContext, cards: ListCardSpec, limit: Int): ListCardSpec cards.asSequence() .map { it to weightOf(it.type, ctx) } .filter { it.second 0.0 } // 权重为 0 直接淘汰不占位 .sortedWith( compareByDescendingPairCardSpec, Double { it.second } .thenByDescending { it.first.basePriority } // 同分用固定优先级保证不抖 .thenBy { it.first.id } // 最后按 id 兜底结果可复现 ) .take(limit) .map { it.first } .toList() fun weightOf(type: CardType, ctx: LockContext): Double { val byTime TIME_TABLE[type]?.get(ctx.timeBucket) ?: 0.0 val byCharge if (ctx.charging) CHARGE_BOOST[type] ?: 0.0 else 0.0 val byBattery if (ctx.batteryLow) LOW_BATTERY_PENALTY[type] ?: 0.0 else 0.0 // 三项相加后截断到 [0,1]避免权重叠加失控 return (byTime byCharge byBattery).coerceIn(0.0, 1.0) }三段排序里thenBy { it.first.id }这一行看着多余实际上是保命的sortedWith对相等元素不保证原序一旦分数字典是哈希结构每次启动的卡片顺序都可能不同用户会觉得界面在乱跳。limit参数按屏高给常规直板机上 2 到 3 张是舒适区超过 4 张阅读完成率会明显下降。4. 版面怎么生成Compose 动态卡片与 RemoteViews 的尺寸约束4.1 锁屏可用空间是不确定的用测量代替设计稿锁屏和普通页面最大的区别是可用高度完全不由你决定。系统状态栏、指纹区、通知叠层、刘海、折叠屏展开态每一项都会吃掉几十到上百 dp。设计稿上排得下的三张卡到某些机型上第三张只剩一条边。解法是用BoxWithConstraints拿运行时的真实约束而不是写死 dp 断点。断点本身还是要定但它判断的是“实际给到我的高度”不是“这台设备叫什么型号”。enum class Band { COMPACT, REGULAR, EXPANDED } Composable fun LockSurface(snapshot: LockSnapshot) { BoxWithConstraints( modifier Modifier .fillMaxSize() .windowInsetsPadding(WindowInsets.systemBars) // 先扣掉系统栏再谈断点 ) { val band when { maxHeight 420.dp - Band.COMPACT // 小屏或分屏态只放一张 maxHeight 680.dp - Band.REGULAR // 主流直板机放两张 else - Band.EXPANDED // 折叠展开或平板可放三张 } val limit when (band) { Band.COMPACT - 1 Band.REGULAR - 2 Band.EXPANDED - 3 } val cards remember(snapshot, band) { rankCards(snapshot.context, snapshot.candidates, limit) } CardColumn(cards cards, band band) } }windowInsetsPadding一定要在测量之前加否则你算出来的高度是含状态栏的断点会整体偏移一档表现出来就是“同一台机器有时候显示两张有时候显示一张”。remember的 key 里带上band也很关键不然旋转或分屏变化时卡片数量不会重算。4.2 卡片编排与降级三档布局的生成逻辑降级不只是减少数量还要降信息密度。我的做法是每张卡定义三套内容槽位完整版带标题、摘要、副操作精简版只有标题和一行摘要最小版只有标题。档位往下走的时候先砍槽位再砍数量。Composable fun CardColumn(cards: ListCardSpec, band: Band) { Column( modifier Modifier.fillMaxWidth().padding(horizontal 20.dp), verticalArrangement Arrangement.spacedBy(12.dp) ) { cards.forEach { card - LockCard( card card, // 密度随档位下降EXPANDED 全量COMPACT 只留标题 density when (band) { Band.EXPANDED - Density.FULL Band.REGULAR - Density.COMPACT Band.COMPACT - Density.MINIMAL } ) } } }spacedBy(12.dp)这个间距在小屏上要降到 8dp因为卡片之间的留白在低高度下是最先该让位的东西。Density枚举不要用布尔值后面加了“中等密度”你会想改回来。4.3 深色与对比度锁屏上最容易翻车的视觉参数锁屏底图是用户自己设的可能是纯白雪山也可能是纯黑夜景你的卡片如果用了固定半透明背景在其中一种上必然看不清。稳妥方案是给卡片一层带模糊的遮罩再叠一层自适应对比度的文字色。参数建议值说明卡片背景不透明度0.55 ~ 0.72低于 0.55 浅色壁纸上文字发灰卡片圆角20 ~ 24 dp太小显得像系统通知太大挤压内容标题字号16 ~ 18 sp锁屏观看距离比应用内更远正文行数上限2 行超过 2 行在小屏上会被截断主操作触控区≥ 48 dp锁屏上误触代价高提示不要给锁屏卡片加长按拖拽排序。锁屏状态下触摸事件经常被系统手势区截走用户会以为是应用卡了。5. 避坑与排查锁屏上线的 5 类典型翻车现场5.1 五类翻车现场的现象、原因与处置第一类界面一闪而过锁屏上根本留不住。现象是通知栏能看到点亮屏幕瞬间闪过一帧就回到系统锁屏。原因通常是全屏 Intent 被系统降级或者宿主 Activity 缺少前台服务支撑在后台启动限制下被直接拦截。解决方式是给拉起路径配一个短时前台服务类型选与业务相符的并接受“系统有权降级”这个前提把通知本身做成可读的完整信息别指望一定弹得出来。第二类解锁后界面残留主屏上盖着一层卡片。现象是用户解锁进桌面你的锁屏布局还浮在上面点哪都没反应。原因是可见性标记没清理或者主题背景是透明的但窗口没销毁。解决是在onDestroy和onPause里都清一遍setShowWhenLocked(false)同时给主题设一个不透明的兜底背景避免透明窗口在没有内容时留下一个“鬼影层”。第三类定时刷新在后台失效卡片内容隔夜不变。现象是早上看到的还是昨晚的通勤信息。原因是用了WorkManager做周期任务它的最小周期是 15 分钟且会被 Doze 进一步压后换成精确闹钟又要额外权限。我的处理是把“定时刷新”改成“事件驱动”亮屏、解锁、充电插入这三个广播触发一次重算这几个时机点天然覆盖了用户真正会看锁屏的时刻。第四类解锁几次之后明显卡顿内存曲线一路上扬。现象是首次进入很流畅来回锁屏十几次后掉帧。原因是每次创建都把整棵 Compose 树和壁纸 Bitmap 重建了一遍图片缓存没有复用。解决是把壁纸模糊结果按屏幕尺寸做一级缓存Activity 用singleInstance复用实例并把非首屏卡片做成懒加载。第五类某些机型上卡片被裁掉半截折叠屏展开后更严重。现象是同一套代码在不同设备上差出一整张卡的高度。原因是断点用了固定 dp 或者screenHeight没考虑系统栏和多窗口。解决是统一走BoxWithConstraints加windowInsetsPadding并且在分屏态下强制降到 COMPACT 档。5.2 一个可复用的排查顺序出问题时不要一上来就怀疑布局代码按这个顺序走会快很多。先确认KeyguardManager.isKeyguardLocked()的实际返回值判断当前到底是不是锁屏态再确认窗口属性有没有生效用dumpsys window看目标 Activity 的 flags 里有没有对应标记然后确认拉起路径是被放行还是被降级看通知是否以 heads-up 形式出现过最后才看渲染用帧耗时定位是测量阶段还是绘制阶段的问题。# 查当前锁屏状态与目标窗口的可见性标记 adb shell dumpsys window | grep -E mDreamingLockscreen|isStatusBarKeyguard|LockHostActivity # 查目标进程近期有没有被后台限制掐掉 adb shell dumpsys activity processes | grep -A 5 your.package.name # 抓一次亮屏到首帧的耗时重点看 measure 与 draw adb shell am trace-ipc start --buffer 8000 adb shell am trace-ipc stop --dump-file /data/local/tmp/lock.trace这个顺序的价值在于它把“界面不显示”拆成了四层独立问题状态不对、属性没生效、拉起被拦、渲染太慢。层与层之间的排查手段完全不同混在一起查只会浪费一整天。6. 进阶用离线回放和功耗观测验证「智能」是不是真的智能规则打分写完之后最难回答的问题不是“能不能跑”而是“它到底比固定版面好多少”。我的做法是把决策函数抽成纯函数——不碰 Context、不碰系统 API、输入LockContext加候选列表输出排序结果——然后拿线上日志回放。// 纯函数签名同样的输入必然得到同样的输出才配拿来回放 fun decide(ctx: LockContext, candidates: ListCardSpec, limit: Int): ListString rankCards(ctx, candidates, limit).map { it.id } // 回放驱动读取一段时间的上下文快照统计版面切换频率 fun replay(records: ListPairLockContext, ListCardSpec): ReplayReport { var switches 0 var lastTop: String? null records.forEach { (ctx, cards) - val top decide(ctx, cards, 3).firstOrNull() if (top ! null lastTop ! null top ! lastTop) switches lastTop top } // 切换率过高说明离散化不够用户会觉得版面在乱跳 return ReplayReport(switchRate switches.toDouble() / records.size) }回放要盯的指标不多但每一个都得有阈值。指标观测方式健康区间超标说明版面切换率离线回放 15% / 天时段切分太细或权重抖动首帧耗时亮屏到首帧日志 250 ms卡片构建或图片解码太重唤醒次数增量电池统计对比 5 次 / 小时事件驱动被写成了轮询卡片点击率埋点与固定版面对比低于基线说明“智能”没带来价值功耗观测这一步别省我吃过亏早期版本为了“实时感知场景”注册了三个常驻监听功能测试全过用户侧反馈是待机耗电明显变差。后来改成粘性广播读一次加事件驱动唤醒次数直接掉了一个数量级。锁屏这个位置离用户太近任何一点功耗异常都会被认为是你的问题哪怕实际上不是。现在我做这类功能有个固定习惯先写纯函数再写界面先用回放跑一周数据再上线灰度宁可第一版看起来“不够智能”也不要让用户觉得锁屏在跟他抢注意力。希望帮到你。本文还有配套的精品资源点击获取