Tile Service 开发:快速设置面板的小组件

Tile Service 开发:快速设置面板的小组件

Tile Service 开发:快速设置面板的小组件


Tile Service 开发:快速设置面板的小组件


从 NotificationListener 的权限噩梦逃过来


去年做系统工具类应用时,我被 NotificationListenerService 的权限申请流程折磨得够呛。用户得跳转到系统设置里手动开启,返回后还要判断绑定状态,整个链路长得让人绝望。后来翻到 Android 7.0 引入的 Quick Settings Tile API,也就是 TileService,心想这个总该简单点吧——毕竟只是快速设置面板里加个开关,能复杂到哪去。


实际动手之后发现,TileService 的入门门槛确实低,但要把体验做好,坑一点不比 NotificationListener 少。这篇文章把我在几个项目里踩过的具体问题摊开来说,包括一些官方文档不会告诉你的细节。


TileService 的注册与生命周期真相


TileService 本质上是一个绑定式 Service,但和普通 Service 最大的区别在于:它的生命周期完全由 SystemUI 控制,而不是你的应用进程。这意味着你不能用 startService() 启动它,也不能指望它一直存活。


注册方式在 AndroidManifest 里:


<<service
    android:name=".tile.FlashlightTileService"
    android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
    android:exported="true">
    <intent-filter>
        <action android:name="android.service.quicksettings.action.QS_TILE" />
    </intent-filter>
    <meta-data
        android:name="android.service.quicksettings.tile"
        android:resource="@xml/flashlight_tile" />
</service>

android:exported="true"android:permission="android.permission.BIND_QUICK_SETTINGS_TILE" 必须同时存在,缺一不可。这里有个我踩过的坑:如果 exported 写成 false,Tile 根本不会出现在系统编辑列表里,而且没有任何错误日志。Android Studio 的 lint 也不会报警,因为从技术上说这个属性组合是合法的——只是系统会直接忽略你的 Service。


meta-data 指向的 XML 资源在 Android 13 之前是可选的,但从 Android 13(API 33)开始变成了强制要求。格式如下:


<<tile
    android:label="@string/tile_label"
    android:icon="@drawable/ic_flashlight"
    android:description="@string/tile_description" />

Android 13 之前,Tile 的初始图标和标签是在 onTileAdded() 里通过 getQsTile() 动态设置的。现在系统优先读取 XML 里的静态配置,只在动态状态变化时才回调你的代码。这个改动导致我维护的一个旧项目升级 targetSdk 到 33 后直接崩溃——onTileAdded() 里对 getQsTile() 返回 null 没做防御,而 Android 13 下系统首次绑定时的调用时机和之前完全不同。


生命周期方法里,onTileAdded()onTileRemoved() 并不是对称调用的。用户把 Tile 从面板拖到编辑区域的"可用列表"里时,并不会触发 onTileRemoved(),只有彻底从编辑界面删除才会。反过来,用户添加 Tile 时,onTileAdded() 只在首次添加时调用一次,之后即使移除再重新添加,也不会再次触发。这个设计意味着你不能把初始化逻辑全塞在 onTileAdded() 里,得考虑 onStartListening() 作为真正的"每次激活"入口。


getQsTile() 的 null 陷阱与状态同步


TileService 里获取当前 Tile 实例的唯一方式是 getQsTile(),但这个方法的返回值有严重的时序问题。在 onCreate() 里调用它,返回 null。在 onBind() 里调用,返回 null。只有在 onStartListening() 及之后的生命周期方法里,它才保证非空。


更隐蔽的问题是:即使进入了 onStartListening(),如果你在异步回调里(比如网络请求返回、传感器数据更新)再去调用 getQsTile(),仍然可能拿到 null。原因是 SystemUI 可能在任意时刻解绑你的 Service,而你的异步任务对此一无所知。


我现在的防御性写法是这样的:


private fun updateTileState(active: Boolean) {
    val tile = qsTile ?: return
    tile.state = if (active) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE
    tile.updateTile()
}

qsTile 是 Kotlin 对 getQsTile() 的 property 风格访问,但底层还是每次调用方法。关键点是:拿到实例后立刻使用,不要缓存。Tile 对象在 Service 重新绑定后会被替换,缓存旧的引用会导致 updateTile() 调用无效,而且不会抛异常——只是静默失败。


状态同步的另一个坑是 updateTile() 的调用线程。官方文档说必须在主线程调用,但实际测试下来,Android 12 之前的版本在后台线程调用似乎也能工作,只是有概率出现状态更新延迟。Android 12(API 31)开始严格了,后台线程调用 updateTile() 会直接抛 IllegalStateException,错误信息是 "Tile state can only be updated from the main thread"。这个改动没有出现在 Android 12 的 behavior changes 官方列表里,我是在 Firebase Crashlytics 里收到一堆崩溃后才定位到的。


点击事件与锁屏交互的暗坑


用户点击 Tile 时,系统回调 onClick()。这里有个行为差异:如果设备处于锁屏状态,点击事件照样会投递,但你的 Service 运行在一个受限的上下文里。


具体限制包括:

  • 不能启动 Activity(会抛 IllegalStateException
  • 不能显示非系统权限的 Dialog
  • 后台启动限制适用,即使你的应用有 SYSTEM_ALERT_WINDOW 权限

  • 我在做一个 VPN 开关 Tile 时踩了这个坑。用户锁屏状态下点击 Tile 期望切换 VPN 状态,但 VPN 连接需要弹出一个系统对话框确认(某些 ROM 的行为),结果整个交互卡住。最后方案是:锁屏状态下点击,只改变 Tile 的视觉状态为"等待中",发送一个延迟广播,等用户解锁后由广播接收器完成实际切换。


    判断锁屏状态用 KeyguardManager


    val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
    if (keyguardManager.isKeyguardLocked) {
        // 锁屏状态,只做状态缓存,不触发实际业务
        pendingStateChange = true
        return
    }

    onClick() 的执行时间也有讲究。如果你在方法里做同步耗时操作(比如直接读写 SharedPreferences 的 apply 不算,但 commit 或者数据库操作),SystemUI 会ANR。实测阈值大约是 5 秒,但别赌这个。所有业务逻辑都应该 post 到后台线程,Tile 状态更新回主线程。


    动态图标与标签的极限


    Tile 支持通过 Tile.setIcon() 更换图标,但这个 API 的限制比想象中多。图标必须是 Icon 类型,创建方式有三种:createWithResource()createWithBitmap()createWithAdaptiveBitmap()createWithResource() 最稳定,但要求资源在系统可访问的包内——也就是你的应用本身。createWithBitmap() 理论上支持运行时生成的图像,但我在 Android 10 的某些设备上遇到过图标不显示的问题,logcat 里能看到 SystemUI 进程的 ResourceNotFoundException,实际上是在跨进程传递 Icon 时 Bitmap 序列化失败。


    标签更新用 Tile.setLabel(),但这里有个视觉限制:快速设置面板给每个 Tile 的标签区域高度固定,大约能显示 2 行中文,每行 4-5 个字。超出部分不会截断显示,而是直接不显示多余字符,没有省略号。我在做一个显示当前网络速度的 Tile 时,把"下载: 12.5 MB/s"这种字符串塞进去,发现数字部分经常消失,后来改成纯数字"12.5↑"才勉强可用。


    Android 14 新增了一个 setSubtitle() 方法,可以设置副标题。这个 API 在 Android 14 设备上能显示两行信息,但 Android 13 及之前的系统直接忽略这个调用,不会抛异常,只是不显示。做版本适配时得注意检测:


    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
        tile.subtitle = "副标题内容"
    }

    跨进程通信与数据持久化


    TileService 运行在 SystemUI 的进程上下文中,但你的应用主进程可能完全没启动。这意味着你不能依赖单例对象、内存缓存、或者 Application 类的初始化来传递数据。


    我最初的做法是在 onClick() 里直接操作业务逻辑,但很快发现:如果用户近期没有打开过主应用,TileService 的进程是独立启动的,之前在主进程里设置的任何全局状态都是默认值。比如一个亮度调节 Tile,我在主应用里让用户自定义了调节步长,存成一个 var stepSize = 10,TileService 里直接读这个变量,结果永远是 10,因为新进程里这个变量从未被修改过。


    正确的数据通道只有这几个:

  • SharedPreferences:跨进程读写在 Android 上本来就不安全,但 TileService 和主应用同包名,用 Context.MODE_PRIVATE 实际上走的是同一个文件,只是有缓存不一致风险。建议用 SharedPreferences.OnSharedPreferenceChangeListener 做同步,但注意这个 listener 只在同一进程内有效。
  • ContentProvider:官方推荐的跨进程方案,但为一个小 Tile 写 CP 太重了。
  • Broadcast / Service 绑定:TileService 发广播给主进程,或者 bind 主进程的一个 Service。但主进程如果没启动,bind 会触发启动,冷启动耗时可能让用户感觉点击后"没反应"。

  • 我现在的方案是 DataStore + 文件锁。DataStore 基于文件和 Kotlin 协程,本身不保证跨进程,但配合 FileChannel 锁可以做到安全读写。不过代码量确实上去了,这是 TileService 架构设计的一个固有代价。


    唤醒与后台限制的实战


    Android 8.0 的后台执行限制对 TileService 的影响很微妙。onClick() 回调本身不受 BackgroundServiceLimit 限制,因为 SystemUI 绑定你的 Service 时带有系统权限。但从 onClick() 里启动的后续操作,比如启动一个 JobIntentService,或者发送一个需要应用唤醒的广播,就会受到限制。


    我做的一个具体案例是:Tile 控制一个需要网络请求的功能。点击后,我在 onClick() 里启动了一个 WorkManager 任务。Android 10 以下没问题,Android 10+ 的 doze 模式下,WorkManager 任务被延迟到维护窗口执行,用户感觉"点了没反应"。加急任务(setExpedited())需要 Android 12+,而且配额有限。


    最后的折中方案是:Tile 点击只做两件事,1)更新 Tile 状态为"处理中"(Tile.STATE_UNAVAILABLE 配合自定义图标);2)通过 startForegroundService() 启动一个前台 Service 执行实际任务。前台 Service 必须显示通知,但用户通常能接受——毕竟他们刚主动点击了 Tile。这个方案在 Android 8 到 14 的所有版本上表现一致,代价是多一个通知。


    厂商定制 ROM 的兼容性地狱


    这是 TileService 开发最头疼的部分。Android 的 Quick Settings 面板是 SystemUI 的一部分,而各厂商对 SystemUI 的修改深度远超 AOSP。


    小米 MIUI 的一个已知问题:Tile 的 onClick() 如果连续快速触发两次(用户双击),第二次事件会被丢弃,但 onStartListening()onStopListening() 的配对会乱掉。表现就是 Tile 显示为"激活"状态,但实际业务逻辑没执行。我加的 workaround 是点击后 500ms 内设置一个标志位忽略后续点击,同时把 Tile 状态强制设为 STATE_UNAVAILABLE 直到业务确认完成。


    OPPO ColorOS 的问题更隐蔽:自定义 Tile 的图标在暗色模式下对比度异常。AOSP 的 SystemUI 会根据面板背景自动反色图标,但 ColorOS 的某些版本只反色系统内置 Tile,第三方 Tile 的图标保持原样。如果图标本身是黑色,在暗色面板下直接消失。解决方式是在 onStartListening() 里检测当前主题,动态更换图标资源。检测方法没有官方 API,只能用反射读 UiModeManager 的内部状态,或者更实际一点:准备一套带白色描边的图标,在两种主题下都能看清。


    三星 OneUI 的 Tile 编辑界面把第三方 Tile 放在很靠下的位置,用户需要滚动多屏才能找到。这个没有技术 workaround,只能引导用户在添加后"长按编辑"把 Tile 拖到靠前位置。我在应用里加了一个简单的图文引导,但转化率依然很低。


    华为 HarmonyOS 2.0/3.0 兼容 Android 12 的 API 级别,但 TileService 的 onTileAdded() 在某些机型上完全不调用。这意味着依赖 onTileAdded() 做初始化的逻辑会失效。我的防御措施是在 onStartListening() 里加一个"首次运行检测":如果 SharedPreferences 里没有初始化标记,就补执行一次初始化。


    性能数据:启动延迟与内存占用


    TileService 的冷启动性能是我重点测试过的。测试设备 Pixel 6,Android 14,应用本身已经安装但后台无进程。


    从用户点击 Tile 到 onClick() 回调的延迟:

  • 应用进程已存活:约 30-50ms
  • 应用进程需冷启动:约 200-400ms
  • 应用进程冷启动 + 加载大量初始化(比如 MultiDex、Firebase):可达 800ms+

  • 这个延迟用户是能感知到的。快速设置面板的设计预期是"即时反馈",如果点击后 Tile 状态 500ms 没变,用户会以为没点到,重复点击。我的优化措施是把 TileService 所在进程尽量减少依赖:单独一个 :tile 进程,AndroidManifest 里 android:process=":tile",这个进程不加载任何业务库,只负责状态管理和跨进程通信。


    内存方面,一个空 TileService 的进程基础开销约 15-20MB(Android 14,64位)。如果和主应用同进程,加上业务代码轻松过 100MB。独立进程的方案对低端机更友好,但代价是跨进程通信复杂度。


    一个完整的踩坑案例:自适应亮度 Tile


    最后说一个我花了一周多才搞定的功能:做一个 Tile 来切换"自适应亮度"开关。


    看起来简单,设置里就有这个开关,API 应该是 Settings.System.SCREEN_BRIGHTNESS_MODE


    Settings.System.putInt(
        contentResolver,
        Settings.System.SCREEN_BRIGHTNESS_MODE,
        if (enabled) Settings.System.SCREEN_BRIGHTNESS_MODE_AUTOMATIC 
        else Settings.System.SCREEN_BRIGHTNESS_MODE_MANUAL
    )

    Settings.System 的写入需要 android.permission.WRITE_SETTINGS,这个权限在 Android 6.0+ 需要特殊申请:用户得跳转到系统设置页手动允许你的应用"修改系统设置"。这和 TileService 追求的"一键操作"完全矛盾。


    替代方案是用 DevicePolicyManagersetSystemSetting(),但要求设备 owner 权限,普通应用不可能有。


    再替代方案是反射调用 IHardwareServicesetAutoBrightness(),但 Android 9 开始非 SDK 接口限制(greylist)把这个路堵死了,Android 11+ 直接黑名单。


    最终的 workaround 非常丑陋:Tile 点击后启动一个透明主题的 Activity,这个 Activity 在 onResume() 里用 WindowManager.LayoutParamsscreenBrightness 字段强制设置一个固定亮度值,模拟"关闭自适应"的效果。但这不是真正的系统级切换,只是覆盖当前应用的亮度,而且用户离开应用后失效。


    真正的系统级自适应亮度切换,没有用户手动授权 WRITE_SETTINGS 的前提下,第三方应用做不到。这个结论是我翻遍了 AOSP 源码、XDA 论坛、多个厂商的开发者文档后确认的。最后产品方案改成:Tile 点击后弹出一个系统亮度面板的 overlay,让用户自己点一下——至少比下拉设置再点三次少两步操作。


    这个案例说明 TileService 的能力边界受限于 Android 的安全模型,有些功能看起来该有,实际就是拿不到。做 Tile 之前最好先确认目标操作是否有无需特殊权限的公开 API,否则容易做到一半卡死。


    最后一点:Tile 的可见性控制


    Android 10 新增了 TileService.requestListeningState(),允许应用主动请求 SystemUI 绑定 Tile 并进入 listening 状态。这个 API 的设计意图是让应用能在后台事件发生时更新 Tile 外观(比如网络状态变化),但调用它有一个隐藏前提:Tile 必须已经被用户添加到面板里。如果 Tile 还在编辑列表的"可用"区域,requestListeningState() 静默失败,返回 void 没有任何回调。


    判断 Tile 是否被添加的官方 API 不存在。onTileAdded()onTileRemoved() 的不对称调用,加上移除后再添加不会重复触发 onTileAdded(),意味着你没法维护一个准确的"已添加"状态。


    我用的土办法:在 onStartListening() 里记录一个时间戳,在 onStopListening() 里记录另一个。如果当前时间距离最后一次 onStartListening() 很近,认为 Tile 大概率在面板上。这个判断不精确,但配合 requestListeningState() 的调用,能减少大量无效请求。


    Android 14 新增了 isTileAdded() 方法,终于解决了这个问题。但版本适配代码又得加一层:


    val isAdded = if (Build.VERSION.SDK_INT >= 34) {
        isTileAdded
    } else {
        // fallback 到时间戳启发式判断
        System.currentTimeMillis() - lastStartListeningTime < 5000
    }

    isTileAdded 在 Android 14 上是同步返回,内部走的是和 SystemUI 的跨进程查询,实测延迟 1-3ms,可用。

    Rust 写 Android 系统服务,Google 的新尝试 2026-06-08
    Play Store 的评分算法更新,评论权重变化 2026-06-08

    评论区