如果你负责过 Android 系统侧跟 Keyguard 相关的项目,特别是做过锁屏日期、锁屏卡片这一类功能,那“Slice”这三个字多半会让你头疼不已。Slice 本来是给系统搜索、快捷设置这类宿主场景设计的 UI 模板机制,上手简单,但真正把它接到锁屏这种对时序、生命周期、跨进程通信都极端敏感的界面里,才会发现坑全在后面。比如标题里这个非常典型的场景:冷启动第一次进锁屏,日期显示完全正常,之后按电源键锁屏再点亮,日期区域就空了。这种 Bug 一看就是生命周期或缓存问题,但如果没把 Slice 从宿主到 Provider 的完整加载链路掰开,排查起来真的像大海捞针。这篇文章我会把这条链路彻底拆开,再从现象到根因、从定位到修复,给出一整套能落地的思路。
1. 问题背景与整体现象
1.1 Slice 是什么?锁屏日期为什么非要选它
先解释一下 Slice 到底是个什么东西。Slice 是 Android 9 引入的“可嵌入 UI 模板”机制,它把原本在 App 内部渲染的界面片段,通过一个content://形式的 URI 暴露给其他宿主应用。宿主拿到这个 URI 之后,不需要知道内容是哪个应用提供的,也不需要自己写一套完全一样的 View 去解析数据,只需要用一个通用容器把 Slice 渲染出来就行。常见的使用场景是系统搜索框里搜索联系人、设置页里的快捷开关、Launcher 里的应用建议卡片。它本质上是把“界面内容”变成了可以在进程之间传递的数据结构。
那锁屏日期选 Slice 的理由也很直接。很多 ROM 或者定制系统在做锁屏时,不希望把日期、日程、天气、通知摘要这些能力全写死在 SystemUI 里,那样耦合太重。更好的做法是把这些信息统一封装成一个 SliceProvider,锁屏这边只放几个 SliceView,各按 URI 订阅自己想要的内容。这样整个锁屏像是一个“容器”,数据源可以独立更新、独立重启,也能复用到其他宿主上,思路很符合模块化。所以如果你在代码里看到锁屏日期区域是一个 SliceView,不要觉得奇怪,这是一条很常见的定制路径。
顺带提醒一个搜索坑:Android 的 Slice 是"l-i-c-e",跟 JavaScript 数组的 splice、Kotlin 集合的 slice 完全是两码事。我见过不下五个人搜资料时被带进语法教程里出不来,排查半天发现是查错方向了。
1.2 问题现象与复现步骤
我这边遇到的现象很规整,不是那种偶发概率问题,几乎是稳定复现。第一次开机,系统以锁屏状态进入桌面,锁屏右上角的日期区域能正常显示“6月12日 星期三”这样的内容;用电源键锁定屏幕,再按电源键点亮,锁屏界面就出来了,但日期区域空空如也。再锁再亮,有时能好,有时一直白着。
排这个 Bug 之前,我建议先把问题分成两类:一类是日期区域整个 View 没被添加进 Keyguard 的窗口,另一类是 View 在、但 Slice 数据没有渲染出来。区分方法很简单:打开开发者选项里的“显示布局边界”,锁定屏幕看一眼那个区域有没有框;或者用 UIAutomator dump 当前界面层级,找找日期节点的 resource-id 是否还在。如果你能看到控件节点但里面没有文本,那基本可以锁定是 Slice 数据链路的问题,可以直接跳到第 3 节;如果节点都没了,那问题大概率出在 Keyguard 自身的视图控制器上,跟 Slice 没直接关系。我这次遇到的属于前者,View 一直存在,只是内容没有刷新出来。
1.3 为什么说这类问题不能只看 SliceView 的 setSlice
很多人的第一反应是去查 SliceView 是不是被错误地设置成了空数据,或者是不是setSlice被调用了但内容不合法。但实际上,Slice 从 Provider 到宿主并不是一次简单的本地调用,中间隔着进程、Binder、权限校验和异步回调四层。一旦哪一层在锁屏这个特殊场景下没走通,最后表现出来的症状几乎全是“日期区域空白”。你盯着 SliceView 看半天,很可能什么都看不出来,因为它确实什么都没拿到。
所以排这类问题,必须把“Slcie 数据是怎么从 Provider 一路传到 SliceView 的”这条链路完整读一遍。这就像排查网络请求,你不可能只看前端按钮回调,得把 DNS、连接、响应体全部拉出来看。
2. Slice 加载链路拆解
2.1 Provider 侧:Slice 数据是怎么组织并送出来的
先看数据源头。SliceProvider 本质上也是一个 ContentProvider,只不过它提供的不是一个简单的 Cursor 或文件流,而是一个层级化的Slice对象。一个Slice内部包含多个SliceItem,每个 SliceItem 可以是文本、图标、Action、InputRange 等等。日期条目的 Slice 结构通常很简单:一个文本 item 显示日期,一个 Action item 响应点击,整体通过ListSlice.buildListSlice之类的构建器组装出来。
Provider 在 Manifest 里的注册也跟普通 ContentProvider 很像,但多了一个 Slice 特有的 intent-filter。标准的声明大致是这样:
<provider android:name=".provider.DateSliceProvider" android:authorities="com.example.date.provider" android:exported="true"> <intent-filter> <action android:name="android.intent.action.SLICE_META_DATA" /> </intent-filter> </provider>系统通过android.intent.action.SLICE_META_DATA这个 action 识别出这是一个 SliceProvider。后续宿主在绑定时,就会直接向这个 authority 发起 binder 调用,最终走到 Provider 的onBindSlice方法。这里有一个非常关键的参数:Set<SliceSpec>。SliceSpec 记录了宿主支持的 Slice 版本和最大兼容范围,Provider 需要根据这个集合返回对应版本的数据,否则宿主端可能解析不了。日期场景一般不会出版本问题,但如果是跨 Android 版本做兼容,这个点要留意。
很多团队写日期类 Slice 时,会在onBindSlice里做一次时间格式化并返回一个静态构建好的 Slice 对象。问题就出在这个“静态”上。如果是冷启动后第一次绑定,时间当然是新的,显示正常;但 Provider 进程如果一直存活,它返回的可能是同一个缓存对象,里面的日期文本永远停在上次格式化的时候,或者压根就不触发通知更新。后面我会单独讲缓存策略。
2.2 Host 侧:SliceHost、SliceView 与绑定回调
宿主这边,锁屏日期 View 通常是一个自定义控件,内部组合了SliceView。要显示 Slice 内容,宿主需要拿到一个SliceHost或直接通过SliceManager绑定 URI。以SliceView直接绑定为例,核心流程是:
- 构造
SliceView,在onAttachedToWindow时执行绑定操作; - 绑定方法接收 URI、支持的 SliceSpec 集合,以及一个回调;
- 回调里拿到 Slice 对象后,调用
setSlice完成渲染。
我在修复这个问题时画过一个简化时序,大概是这样的:锁屏窗口创建 -> SliceView attach -> bindSlice 发起异步请求 -> Provider onBindSlice 返回 -> binder 回传 -> 主线程回调 setSlice -> SliceAdapter 解析并创建子 View。这个链路任何一步漏了,日期都出不来。
比较坑的一点是:SliceManager.bindSlice是异步回调,回调回来的线程不保证是主线程。如果你在 binder 线程里直接 setSlice,会触发 View 线程检查的异常;就算不崩,也可能因为绘制时机不对导致内容不显示。第一次开机时系统负载高,回调时序可能偶发落在主线程,后面锁屏就变成了 binder 线程,这种“一次能显示一次不能”的情况我遇到不止一次。所以回调里切换主线程是基本操作,但在系统代码里经常会被省略,值得在 review 时重点盯。
2.3 跨进程链路里的权限与 Binder 坑
Slice 既然是跨进程能力,就绕不开权限校验和 Binder 生命周期。宿主在 bindSlice 一个 URI 时,系统会检查这个 URI 对当前调用方是否授权。常见的授权方式是 Provider 声明自己的权限,或者通过grantSlicePermission单独授权给某个包名。如果权限没配对,bindSlice的回调会直接走上失败分支,日志可能连报错都没有,表现出来就是宿主拿到了一个空的 Slice。
另一个更隐蔽的是 Binder 死亡通知。Provider 进程一旦被系统回收,宿主持有的 Provider 连接就成了死连接。正常情况下 SliceManager 会收到死亡通知做清理,但如果宿主只绑定了 URI 而没有监听任何连接状态,再次请求时可能拿到的是一段失效的 binder 引用,返回空数据。在锁屏这种长时间后台、很容易触发系统内存回收的场景里,这个问题被放得很大。后面讲修复的时候,重连机制和进程保活治理必须一起考虑。
3. 锁屏场景下的异常根因分析
3.1 锁屏界面的生命周期和普通页面完全不同
普通的 Activity 生命周期是onCreate到onDestroy一条线走下来的,但锁屏界面在 SystemUI 里根本不是 Activity,它是一套基于窗口和 View 树的管理系统。Keyguard 的显示与隐藏由 WindowManager、SystemUI 的 ViewRoot、用户解锁状态、Doze 模式共同控制,没有一个标准的onResume可以依赖。很多开发者容易把 Activity 那套习惯带进来,在“初始化”里绑定 Slice,在“销毁”里解绑,结果发现锁屏 View 还没销毁但窗口已经隐藏了,或者窗口重新显示但初始化不会再走第二次。
SliceView 本身也带有强烈的 View 生命周期色彩:它跟随窗口 attach 到 Window 上时才会真正创建自己的内容,detach 之后内部的渲染状态可能会被重置。如果宿主只在第一次创建时绑定,后面锁屏重新显示时没有重新触发绑定,那日期区域就会一直空白。所以在锁屏这种场景里,不能拿 Activity 的生命周期去理解 SliceHost 的绑定时机,得跟着 View attach/detach 走。
3.2 冷启动正常、后续异常的典型时间线复盘
我来复盘一下这个 Bug 的具体时间线。第一次开机时,系统刚起完,Keyguard 首次创建,Provider 和宿主进程几乎同时被拉起。这个时候,Provider 进程还活着,Binder 连接是新的,宿主在初始化阶段绑定 Slice,所有回调都在正常时序里完成,所以日期成功显示了。
然后用户按电源键锁屏。Keyguard 窗口隐藏,SliceView 从 View 树上 detached,绑定关系可能被解掉。再按电源键点亮,Keyguard 窗口重新显示,SliceView 重新 attach。到这里是关键:如果代码没有在重新 attach 时主动重新绑定 Slice,那么 SliceView 内部只拿到了一个空的或失效的 Slice。更有甚者,Provider 进程可能在这一段时间里被系统回收,宿主连 binder 引用都断了,重绑也未必能立刻恢复。于是你看到的现象就是“只有第一次开机正常,后面全空白”。
还有一种情况是 Provider 进程没死,但 Provider 端把 Slice 数据缓存成了一块静态内存。锁屏反复锁了几次之后,时间跨了分钟或小时,Provider 却没有通过ContentResolver.notifyChange通知宿主刷新,宿主拿到的永远是缓存里那一份旧的或者不完整的 Slice。这种根因也完全符合题目描述。
3.3 追根因的几种主流方向
我把这个 Bug 的根因归纳成三个大方向,排错时可以照着对号入座。
| 现象特征 | 最可能根因 | 排查重点 |
|---|---|---|
| 第一次正常,解锁后再锁屏空白 | 宿主没有在重新显示时重新绑定 | SliceHost 绑定/解绑是否配对了 attach/detach |
| 冷启动正常,运行一段时间后空白 | Provider 进程被回收,Binder 失效后没有重连 | Provider 存活状态、Binder 死亡监听 |
| 日期显示旧值,或偶尔空白 | Provider 端缓存了静态 Slice,没有正确刷新 | notifyChange是否被调用,是否复用同一 Slice 对象 |
| 亮屏瞬间闪一下再空白 | Doze / 低功耗模式下渲染帧未提交 | SliceView 所在 View 是否被标记为invisible,Surface 是否刷新 |
| 绑定回调没回来,或抛 SecurityException | Slice URI 权限校验失败 | exported 配置、GrantSlicePermission、SliceSpec 版本匹配 |
这里要说明一下,最后一种“权限校验失败”在系统内部应用(比如 SystemUI 和系统设置)之间一般不会发生,因为签名一致通常能拿到隐式授权。但只要日期 Provider 是独立 APK,或者涉及第三方应用提供的 slice,权限这关就一定要查。我遇到过 Provider 是系统应用、宿主也是系统应用、但两端签名不同导致授权失败的例子,表现就是冷启动第一次能显示,后面再打开就再也拿不到数据了。
回到我这个场景,日志里 SliceHost 的绑定回调其实没触发,Provider 的onBindSlice也没有再被调用,基本可以确定是宿主侧没有在重新 attach 时重新绑定,属于第一个方向。
4. 修复与工程落地
4.1 用 attach/detach 对称管理绑定与解绑
修复的核心原则只有一句话:Slice 的绑定和解绑,必须和锁屏 View 的 attach/detach 严格对称。不能放在初始化函数里只执行一次,也不能依赖 Activity 的 onStart/onStop,因为锁屏 View 的显示与否不受 Activity 生命周期直接控制。
我当时改的方案是自定义一个DateSliceView,在onAttachedToWindow里发起绑定,在onDetachedFromWindow里解绑,同时在回调里判断当前 View 是否还处于 attach 状态。示意伪代码大概是这样:
class DateSliceView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : SliceView(context, attrs) { private val callback = object : SliceView.SliceCallback { override fun onSliceUpdated(slice: Slice?) { // 回调不一定在主线程,切到主线程再设置 post { if (isAttachedToWindow) { setSlice(slice) } } } } override fun onAttachedToWindow() { super.onAttachedToWindow() startBinding() } override fun onDetachedFromWindow() { super.onDetachedFromWindow() stopBinding() } private fun startBinding() { // 绑定日期 slice uri sliceManager.bindSlice( DateSliceContract.URI, DateSliceContract.SUPPORTED_SPECS, callback ) } private fun stopBinding() { // 解绑,避免重复回调 sliceManager.unbindSlice(DateSliceContract.URI, callback) } }这里有几个细节值得注意。第一,回调里一定要判断isAttachedToWindow,因为异步回调回来时 View 可能已经被 detach 了,这时候调setSlice不仅无用,还可能引起下一轮状态混乱。第二,绑定和解绑要成对出现,如果只绑定不解绑,slice 回调会一直订阅着,锁屏窗口反复横跳几次之后就会出现重复 setSlice、内容闪烁。第三,bindSlice不能放在onFinishInflate里,因为那时 View 还没有真正加入窗口;也不能只放一次,因为锁屏 View 每次重新显示都会重新走 attach。
改完这个之后再复测,锁屏、解锁、再锁屏,日期每次都能正常出现了,说明问题主链路已经恢复。
4.2 日期类 SliceProvider 的刷新与缓存策略
修完宿主侧,Provider 侧也不能拖后腿。日期类 Slice 有一个天然属性:内容会随时间变化。就算宿主每次都正常绑定,如果 Provider 端返回的是同一个静态 Slice 对象,显示的日期也永远是 Provider 第一次绑定那个时间点。
Provider 端有两个处理思路。如果日期这块数据量很小,我建议直接在onBindSlice里实时组装,每次绑定都取当前时间重新格式化,不做任何缓存。系统锁屏日期这种内容,格式化一个日期字符串的 CPU 开销几乎可以忽略,没必要引入缓存复杂度。
如果确实因为 Slice 结构复杂、构建成本高而需要缓存,那必须配一套失效通知机制。常见做法是让 Provider 监听日期变化或者定时刷新,在日期变更时主动调用ContentResolver.notifyChange(uri, null),宿主收到通知后再重新拉取数据。只缓存不通知,等于埋了一个“日期显示旧值”的定时炸弹。
还要注意:Provider 的onBindSlice是可能被并发调用的,多个宿主同时绑定时不要共用同一个可变 Slice 实例。Slice 对象内部是 Parcelable 结构,复用可变对象在跨进程传输时容易出现字段串了的问题。我一般倾向于每次构建新的 Slice 实例,除非你能保证它完全不可变。
4.3 进程回收、Binder 重连和亮屏联动刷新
宿主侧绑定修好之后,仍有小概率遇到 Provider 进程被杀、Binder 断连导致的空白。尤其是现在很多 ROM 对后台进程回收非常激进,一个不活跃的 SliceProvider 在锁屏后台待几分钟就可能被回收。系统虽然会尽力维护 ContentProvider 连接,但跨进程的 Binder 引用一旦死亡,重新建立连接需要时机。
针对这个,我做了两层防护。第一,在 Provider 的 AndroidManifest 里把进程隔离出来,比如设置android:process=":slice",避免 Provider 进程和宿主进程一起被回收;第二,在锁屏重新显示、点亮屏幕这些系统广播事件里,主动触发一次rebindSlice。锁屏场景里最常见的广播就是ACTION_SCREEN_ON和ACTION_USER_PRESENT。收到ACTION_SCREEN_ON时,日期 View 即将显示,这时重新绑定 Slice,数据是最新的,成功率也最高。注意ACTION_USER_PRESENT需要声明RECEIVE_USER_PRESENT权限,如果不想申请额外权限,只监听ACTION_SCREEN_ON也够用。
这里还要提一个容易被忽略的点:ACTION_SCREEN_ON广播的接收时机可能早于 Keyguard 窗口真正显示,如果 View 还没 attach,过早绑定又会被后面的 detach 清掉。所以广播处理器里不要直接绑定,应该设置一个标志位,真正执行绑定还是在onAttachedToWindow里,广播只负责触发重新绑定。我的实现是在onAttachedToWindow里先检查是否需要请求重新绑定,如果 Provider 连接已经失效就重新初始化 SliceManager 再绑定。
5. 常见问题速查与排错技巧
5.1 定位问题最有效的几组日志和命令
Slice 问题排查起来容易谜,是因为日志太安静。系统日志里默认不会把每次绑定都打出来,所以排查第一步是给链路加日志。Provider 的onBindSlice入口、宿主的绑定回调、失败回调三个点一定要打点,收到什么状态一目了然。
如果不想改代码,先用命令看现场:
# 查 provider 是否存活,authorities 是否注册成功 adb shell dumpsys activity providers | grep -A 8 "com.example.date.provider" # 看 slice manager 侧的权限和 uri 授权状态 adb shell dumpsys slice # 过滤关键 TAG,观察绑定过程 adb logcat -s SliceHost SliceView SliceProvider KeyguardClockdumpsys activity providers比较稳,能确认 Provider 进程是否在、ContentProvider 是否注册成功。dumpsys slice有些版本输出很简略,拿不到太多有效信息,但至少能看出权限记录有没有异常。真正解决问题的还是源码里的日志,日志一加,绑定链路走到哪一步断了立刻知道。
5.2 快速排错清单
我整理了一份自己平时排查用的问题清单,按顺序过一遍,大多数 Slice 加载问题都能定位:
- Provider 注册对不对?
intent-filter里的SLICE_META_DATAaction 有没有写全? - Provider 进程还活着吗?如果挂了,宿主有没有重连逻辑?
- 权限授没授?宿主包名有没有被 grant 过,Provider 的 exported 和 permissions 是否冲突?
- 宿主绑定 URI 的字符串和 Provider authority 是否一致?大小写、路径写错是最低级也最常见的坑。
bindSlice回调有没有触发?回调里切片数据是不是为 null?如果回调一直没触发,查 binder 连接和权限。- slice 触发了,但
setSlice后不显示?检查当前线程是否主线程,View 是否 attach,SliceSpec 版本是否匹配。 - 显示一次后再也不刷新?查 Provider 有没有
notifyChange,宿主有没有在重新 attach 时重新绑定。
5.3 额外的避坑建议
最后分享两个额外体会。第一,不要过度设计 SliceProvider。锁屏日期这种轻量功能,Slice 链路原本已经够长,再引入缓存、多级 spec 兼容、复杂的 Binder 重连只会让问题更难查。保持 Provider 无状态、每次构建新 Slice,是成本最低的维护方式。
第二,排查时可以用系统设置或者 Launcher 里的某个 Slice 入口打开同一个 URI,看一下是否显示正常。这个操作能把问题一刀切开:如果其他宿主也能正常显示,问题一定出在锁屏这边的生命周期管理;如果其他宿主也显示不了,那就是 Provider 或权限层的事。我经常用这种“第三者视角”帮自己快速缩小排查范围。
说实话,Slice 这个机制本身并不复杂,复杂的是它把跨进程、异步、权限、生命周期全塞到了一起。我踩过几次坑之后,总结出来的唯一原则就是:凡是 SliceHost 的绑定解绑,一定要和锁屏 View 的 attach/detach 严格对称;凡是日期类 Slice,Provider 端一定不要缓存旧对象。把这两条用上,绝大多数“第一次正常后面不显示”的问题都能在早期被消灭。如果你现在还在被锁屏日期不显示折磨,先别猜,去日志里把 bindSlice 的回调打出来,问题基本就浮出水面了。