Android悬浮球实战指南:从零构建高效快捷操作控件
1. 为什么你需要一个自己的悬浮球?
如果你用过一些国产手机,比如小米的MIUI或者华为的EMUI,肯定对那个可以满屏幕拖来拖去的小圆点不陌生。点一下,就能快速截图、清理内存、打开常用应用,别提多方便了。这就是悬浮球,一个看似简单,却能极大提升操作效率的“神器”。
但你可能不知道,这个功能我们完全可以自己动手实现,并且集成到自己的App里。想象一下,在你的应用里,用户可以在任何界面快速呼出一个悬浮菜单,一键直达核心功能,或者在你的游戏里,提供一个不遮挡视线的快捷操作面板,这体验一下子就上来了。我做过好几个需要类似功能的产品,比如一个阅读App的全局快捷书签,还有一个工具类App的快速启动器,用户反馈都特别好,觉得用起来很“顺手”。
自己实现悬浮球,听起来好像涉及系统底层,有点复杂?其实不然。它的核心原理就是利用Android的WindowManager,在系统窗口层上“画”一个你自己的View。这个View独立于任何Activity,所以它能悬浮在所有应用之上。接下来,我就带你从零开始,一步步构建一个功能完整、交互流畅的悬浮球控件。我们会搞定拖拽、菜单展开、权限处理这些核心点,还会分享我实际开发中踩过的坑和优化技巧,保证你跟着做就能做出一个可用的、甚至可以直接拿去商用的悬浮球。
2. 搭建舞台:环境准备与核心权限
在开始写代码之前,有两件“地基”级别的事情必须搞定:项目环境和对悬浮窗权限的处理。权限这一关过不去,后面写的所有代码都是白费功夫。
2.1 创建项目与添加依赖
首先,打开你的Android Studio,新建一个项目,选择“Empty Activity”模板就行,语言记得选Kotlin,毕竟现在已经是Kotlin的天下了,写起来更简洁。我这里用的最小SDK版本是API 23(Android 6.0),因为动态权限申请是从这里开始的。当然,为了更好的兼容性,我们后面会处理不同版本的差异。
我们这个悬浮球功能,主要依赖的是Android原生的窗口管理能力,所以不需要引入额外的第三方库。核心就是WindowManager和相关的视图系统API。不过,为了代码更清晰,我们可以把悬浮球的核心逻辑放在一个独立的Service中,这样它就能在后台持续运行,不受Activity生命周期的影响。所以,你需要在AndroidManifest.xml里声明这个Service。
<service android:name=".FloatingBallService" android:enabled="true" android:exported="false" />exported设为false,表示这个服务只供我们自己的应用内部使用,更安全。
2.2 悬浮窗权限:从声明到优雅申请
这是实现全局悬浮球最关键,也最麻烦的一步。Android从6.0开始,将SYSTEM_ALERT_WINDOW权限列为危险权限,用户必须手动授权。而从8.0开始,窗口类型也发生了变化。我们必须处理好这些兼容性问题。
第一步,声明权限。在AndroidManifest.xml的<manifest>标签内添加:
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />注意,这个权限和普通的相机、定位权限不同,它属于“特殊权限”,不能通过ActivityResultContracts.RequestPermission()那种标准方式申请,必须跳转到系统的设置页面。
第二步,检测与申请。我习惯封装一个权限检查的工具函数。这里用ActivityResultLauncher来启动设置页面并接收结果,这是现在推荐的方式。
class MainActivity : AppCompatActivity() { private val overlayPermissionLauncher = registerForActivityResult( ActivityResultContracts.StartActivityForResult() ) { result -> // 从设置页面返回后,再次检查权限 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (Settings.canDrawOverlays(this)) { // 权限已授予,执行之前挂起的操作(比如启动悬浮球) pendingRunnable?.run() } else { Toast.makeText(this, "未授予悬浮窗权限,功能无法使用", Toast.LENGTH_SHORT).show() // 这里可以展示一个友好的提示,引导用户去设置 } } } private var pendingRunnable: Runnable? = null private fun checkOverlayPermission(afterGranted: Runnable) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (Settings.canDrawOverlays(this)) { // 已经有权限,直接执行 afterGranted.run() } else { // 没有权限,保存要执行的任务,并跳转设置 pendingRunnable = afterGranted val intent = Intent( Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:$packageName") ) overlayPermissionLauncher.launch(intent) } } else { // Android 6.0 以下版本默认有权限 afterGranted.run() } } }在按钮点击事件里,这样调用:
findViewById<Button>(R.id.btn_toggle_ball).setOnClickListener { checkOverlayPermission { // 这里启动或停止悬浮球服务 toggleFloatingBall() } }第三步,处理兼容性与用户体验。直接跳转设置对用户来说很突兀。我的经验是,在跳转前,先弹一个自己的解释对话框,告诉用户为什么需要这个权限,比如“用于显示快捷操作悬浮球,方便您快速截图和返回”,这样授权率会高很多。如果用户拒绝了,也不要再频繁弹窗,可以在应用内某个设置项里提供一个醒目的入口,或者当用户尝试使用相关功能时,用Snackbar温和地提示。
对于Android 8.0及以上版本,创建窗口时必须使用TYPE_APPLICATION_OVERLAY类型,旧版本则用TYPE_PHONE或TYPE_SYSTEM_ALERT。这个我们会在创建窗口参数时处理。
3. 绘制核心:创建悬浮球视图与窗口
权限搞定后,我们就可以真正把那个小圆球“画”到屏幕上了。这个过程分为两步:设计它的样子(布局文件),然后把它添加到系统窗口。
3.1 设计悬浮球的“皮肤”(XML布局)
悬浮球本质上就是一个普通的Android View,所以我们用XML来定义它的外观。通常它是一个圆形,我们可以用ShapeDrawable来实现。
悬浮球主体 (drawable/ball_background.xml):
<?xml version="1.0" encoding="utf-8"?> <shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval"> <solid android:color="#4285F4" /> <!-- 主色,可以自定义 --> <size android:width="64dp" android:height="64dp"/> <stroke android:width="2dp" android:color="#FFFFFF"/> <!-- 白色边框 --> </shape>悬浮球主布局 (layout/view_floating_ball.xml):
<?xml version="1.0" encoding="utf-8"?> <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" android:id="@+id/floating_ball_root" android:layout_width="64dp" android:layout_height="64dp" android:background="@drawable/ball_background" android:clickable="true" android:focusable="true"> <ImageView android:id="@+id/iv_ball_icon" android:layout_width="32dp" android:layout_height="32dp" android:layout_gravity="center" android:src="@mipmap/ic_ball_default" /> <!-- 你的图标 --> </FrameLayout>这里用FrameLayout作为根布局,方便以后扩展,比如添加红点通知、状态指示器等。clickable和focusable属性最好设为true,确保它能接收触摸事件。
菜单按钮布局 (layout/view_menu_item.xml): 当点击悬浮球展开菜单时,每个功能按钮也需要一个布局。
<?xml version="1.0" encoding="utf-8"?> <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="56dp" android:layout_height="56dp" android:background="@drawable/menu_item_bg"> <ImageView android:id="@+id/iv_menu_icon" android:layout_width="24dp" android:layout_height="24dp" android:layout_gravity="center" android:scaleType="fitCenter"/> </FrameLayout>菜单按钮的背景menu_item_bg.xml也可以是一个圆形的ShapeDrawable,加上一点阴影效果会更立体。
3.2 用WindowManager将视图“挂”到屏幕上
有了视图,下一步就是把它显示出来。这需要用到WindowManager,你可以把它理解成系统的“窗口管理员”。我们在一个Service里完成这个操作,保证悬浮球能独立于Activity存在。
首先,创建FloatingBallService。在onCreate方法中,我们初始化WindowManager并添加视图。
class FloatingBallService : Service() { private lateinit var windowManager: WindowManager private lateinit var ballView: View private lateinit var layoutParams: WindowManager.LayoutParams override fun onCreate() { super.onCreate() windowManager = getSystemService(WINDOW_SERVICE) as WindowManager createAndShowFloatingBall() } @SuppressLint("ClickableViewAccessibility", "InflateParams") private fun createAndShowFloatingBall() { // 1. 创建窗口布局参数,这是核心! layoutParams = WindowManager.LayoutParams( WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.WRAP_CONTENT, if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { // Android 8.0 及以上必须用这个类型 WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY } else { // Android 8.0 以下使用这个,但需要权限 WindowManager.LayoutParams.TYPE_PHONE }, WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE // 关键标志位 or WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS, // 允许超出屏幕外一点点 PixelFormat.TRANSLUCENT // 支持透明 ).apply { // 设置初始位置为屏幕右上角 gravity = Gravity.TOP or Gravity.END x = 0 y = 100 // 距离顶部100像素 } // 2. 加载我们刚才写的悬浮球布局 ballView = LayoutInflater.from(this).inflate(R.layout.view_floating_ball, null) // 3. 将视图添加到窗口管理器 windowManager.addView(ballView, layoutParams) // 4. 设置触摸监听(拖拽功能下一节讲) setupTouchListener() } override fun onDestroy() { super.onDestroy() // 务必在服务销毁时移除视图,否则会导致内存泄漏甚至系统UI错误 if (::ballView.isInitialized) { windowManager.removeView(ballView) } } // ... onBind 返回 null }这里有几个参数我解释一下:
- type: 窗口类型。
TYPE_APPLICATION_OVERLAY是专门用于应用悬浮窗的类型。 - flags:
FLAG_NOT_FOCUSABLE非常重要,它表示这个窗口不会获取输入焦点,这样它就不会影响下层应用的输入(比如点击、打字)。FLAG_LAYOUT_NO_LIMITS允许窗口坐标稍微超出屏幕,这样在做边缘吸附动画时会更自然。 - gravity: 决定了
x和y坐标的参考系。Gravity.TOP | Gravity.START表示(x, y)坐标是相对于屏幕左上角的。 - format:
PixelFormat.TRANSLUCENT允许窗口有透明区域。
运行你的应用,授予权限后点击按钮启动服务,你应该就能看到一个小圆球出现在屏幕角落了!这是从0到1最关键的一步。
4. 让球动起来:实现拖拽与事件处理
一个不能拖的悬浮球是没有灵魂的。接下来我们实现拖拽功能,并处理好拖拽和点击事件的冲突。
4.1 触摸事件处理与坐标更新
拖拽的本质就是在OnTouchListener的ACTION_MOVE事件中,实时更新悬浮球窗口的x和y坐标。我们需要记录手指按下的初始位置和窗口的初始位置。
private fun setupTouchListener() { ballView.findViewById<View>(R.id.floating_ball_root).setOnTouchListener { v, event -> val screenWidth = Resources.getSystem().displayMetrics.widthPixels val screenHeight = Resources.getSystem().displayMetrics.heightPixels when (event.action) { MotionEvent.ACTION_DOWN -> { // 记录手指按下的初始屏幕坐标 initialTouchX = event.rawX initialTouchY = event.rawY // 记录当前窗口的位置 initialWindowX = layoutParams.x initialWindowY = layoutParams.y // 按下时,标记可能是一个点击事件 isClickAction = true true // 消费事件 } MotionEvent.ACTION_MOVE -> { // 计算手指移动的偏移量 val deltaX = (event.rawX - initialTouchX).toInt() val deltaY = (event.rawY - initialTouchY).toInt() // 判断是否为拖拽:移动距离超过一定阈值(如8像素) if (abs(deltaX) > 8 || abs(deltaY) > 8) { isClickAction = false // 移动距离大,判定为拖拽,不是点击 } // 更新窗口位置 = 初始窗口位置 + 手指偏移量 val newX = initialWindowX + deltaX val newY = initialWindowY + deltaY // 限制位置,防止被拖出屏幕 layoutParams.x = newX.coerceIn(0, screenWidth - v.width) layoutParams.y = newY.coerceIn(0, screenHeight - v.height) // 关键!通过WindowManager更新视图布局 windowManager.updateViewLayout(ballView, layoutParams) true } MotionEvent.ACTION_UP -> { // 手指抬起 if (isClickAction) { // 如果被判定为点击,则触发点击事件(例如展开菜单) performClick() } else { // 如果是拖拽结束,可以在这里添加边缘吸附效果(见下一节) snapToEdge(screenWidth, v.width) } true } else -> false } } }这段代码实现了基本的拖拽。这里有个关键细节:我们通过比较event.rawX和event.rawY(相对于整个屏幕的坐标)来计算偏移,而不是event.x和event.y(相对于视图本身的坐标)。因为窗口在移动,其视图本身的坐标系也在变,用raw坐标更准确。
4.2 解决点击与拖拽的事件冲突
从上面代码可以看到,我们用一个isClickAction布尔变量来区分点击和拖拽。逻辑是:在ACTION_DOWN时假设是点击;在ACTION_MOVE中,如果移动距离超过一个阈值(比如8像素),就判定为拖拽,并将isClickAction设为false;最后在ACTION_UP中,根据这个标志来决定是执行点击逻辑还是拖拽结束逻辑。
这是一个非常经典且实用的冲突解决策略。阈值的大小可以根据实际手感调整,太小了容易误触发拖拽,太大了拖拽反应又会迟钝。我实测下来,8到12个像素是一个比较舒适的区间。
4.3 高级交互:边缘吸附与回弹动画
为了让体验更接近系统级悬浮球,我们可以在拖拽结束后,让小球自动吸附到最近的屏幕边缘。这能防止它停留在屏幕中央遮挡内容。
private fun snapToEdge(screenWidth: Int, viewWidth: Int) { val centerX = layoutParams.x + viewWidth / 2 val targetX: Int // 判断小球中心点更靠近屏幕哪一侧 targetX = if (centerX < screenWidth / 2) { 0 // 吸附到左边缘 } else { screenWidth - viewWidth // 吸附到右边缘 } // 使用属性动画实现平滑移动到边缘 val animator = ValueAnimator.ofInt(layoutParams.x, targetX).apply { duration = 200 // 动画时长200毫秒 interpolator = OvershootInterpolator(0.5f) // 添加一点回弹效果 addUpdateListener { animation -> layoutParams.x = animation.animatedValue as Int windowManager.updateViewLayout(ballView, layoutParams) } start() } // 保存吸附后的位置到SharedPreferences,下次启动时恢复 savePosition(layoutParams.x, layoutParams.y) }这里用ValueAnimator来平滑地改变x坐标。OvershootInterpolator会让动画在到达终点前稍微“冲过”一点再回来,形成一种自然的回弹感,比单纯的线性移动生动很多。
位置持久化也很重要,我们希望用户下次打开悬浮球时,它还在老地方。可以在ACTION_UP或snapToEdge后,将最终的x, y坐标保存到SharedPreferences中。在createAndShowFloatingBall方法里,读取保存的坐标并赋值给layoutParams.x和y即可。
5. 功能扩展:实现炫酷的展开式菜单
单一的悬浮球只是一个按钮,它的威力在于点击后能展开成一个功能菜单。我们要实现一个环绕式展开的菜单,并带有动画效果。
5.1 动态创建与管理菜单按钮
菜单按钮也是通过WindowManager添加的独立窗口。我们需要在点击悬浮球时,动态计算每个按钮应该出现的位置(通常是在悬浮球周围形成一个圆形),然后创建并添加它们。
private val menuItemList = mutableListOf<View>() // 保存所有菜单按钮视图 private var isMenuExpanded = false private fun toggleMenu() { if (isMenuExpanded) { collapseMenu() } else { expandMenu() } isMenuExpanded = !isMenuExpanded } private fun expandMenu() { val centerX = layoutParams.x + ballView.width / 2 // 悬浮球中心X val centerY = layoutParams.y + ballView.height / 2 // 悬浮球中心Y val radius = ballView.width * 1.8f // 菜单按钮分布的半径 val itemCount = 5 // 菜单按钮数量 val startAngle = 270f // 从正上方开始(12点钟方向) for (i in 0 until itemCount) { // 1. 计算每个按钮的坐标(圆形布局) val angle = Math.toRadians((startAngle + i * (360f / itemCount)).toDouble()) val offsetX = (radius * cos(angle)).toInt() val offsetY = (radius * sin(angle)).toInt() val targetX = centerX + offsetX - menuItemSize / 2 // 让按钮中心点落在计算的位置上 val targetY = centerY + offsetY - menuItemSize / 2 // 2. 加载菜单按钮布局 val menuItemView = LayoutInflater.from(this).inflate(R.layout.view_menu_item, null) // 设置图标、文字等 val iconRes = listOfIcons[i] menuItemView.findViewById<ImageView>(R.id.iv_menu_icon).setImageResource(iconRes) // 3. 创建菜单按钮的窗口参数 val itemParams = WindowManager.LayoutParams( menuItemSize, menuItemSize, layoutParams.type, // 使用和主悬浮球相同的类型 WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, PixelFormat.TRANSLUCENT ).apply { gravity = Gravity.TOP or Gravity.START x = centerX - menuItemSize / 2 // 初始位置设在悬浮球中心 y = centerY - menuItemSize / 2 } // 4. 先添加到窗口(初始位置在球心,透明) windowManager.addView(menuItemView, itemParams) menuItemList.add(menuItemView) // 5. 为每个按钮设置点击事件 menuItemView.setOnClickListener { onMenuItemClicked(i) } // 6. 启动位移动画,从球心飞到目标位置 startMenuItemAnimation(menuItemView, itemParams, targetX, targetY, i) } }collapseMenu的逻辑类似,只是动画方向相反,最终移除视图。
5.2 为菜单添加丝滑的动画效果
干巴巴地突然出现菜单很生硬。我们可以使用属性动画让菜单按钮从悬浮球中心“飞”到各自的位置,同时伴随透明度、缩放的变化。
private fun startMenuItemAnimation(view: View, params: WindowManager.LayoutParams, targetX: Int, targetY: Int, index: Int) { // 初始状态:缩小并透明,位于悬浮球中心 view.scaleX = 0.2f view.scaleY = 0.2f view.alpha = 0f // 使用属性动画组 val animatorSet = AnimatorSet().apply { playTogether( ObjectAnimator.ofFloat(view, "scaleX", 1f), ObjectAnimator.ofFloat(view, "scaleY", 1f), ObjectAnimator.ofFloat(view, "alpha", 1f) ) duration = 300 interpolator = OvershootInterpolator() startDelay = index * 60L // 错开每个按钮的动画开始时间,形成序列效果 start() } // 同时,通过ValueAnimator更新窗口位置(WindowManager.LayoutParams的x,y) val xAnimator = ValueAnimator.ofInt(params.x, targetX) val yAnimator = ValueAnimator.ofInt(params.y, targetY) xAnimator.addUpdateListener { animation -> params.x = animation.animatedValue as Int windowManager.updateViewLayout(view, params) } yAnimator.addUpdateListener { animation -> params.y = animation.animatedValue as Int windowManager.updateViewLayout(view, params) } val positionAnimatorSet = AnimatorSet().apply { playTogether(xAnimator, yAnimator) duration = 300 interpolator = AccelerateDecelerateInterpolator() startDelay = index * 60L start() } }这里用了两个并行的动画:一个改变视图自身的缩放和透明度,另一个通过ValueAnimator实时更新它在窗口中的坐标。startDelay让每个按钮的动画依次启动,形成了非常炫酷的环绕展开效果。收起菜单时,反向执行这些动画即可。
5.3 菜单项点击与功能绑定
每个菜单按钮的点击事件onMenuItemClicked(i),可以根据索引i来执行不同的功能。例如:
private fun onMenuItemClicked(index: Int) { when (index) { 0 -> performQuickNote() // 快速笔记 1 -> takeScreenshot() // 截图 2 -> toggleFlashlight() // 手电筒 3 -> clearMemory() // 清理内存 4 -> startRecording() // 开始录屏 } // 点击菜单项后,通常自动收起菜单 collapseMenu() isMenuExpanded = false }这些功能的具体实现,就需要调用系统API或其他业务逻辑了。比如截图可以用MediaProjectionAPI,手电筒用CameraManager。记得处理好权限和异常。
6. 避坑指南:性能优化与兼容性实战
功能做出来只是第一步,让它稳定、流畅、省电,能在各种手机上正常运行,才是考验功力的地方。这里分享几个我踩过坑后总结的经验。
6.1 内存泄漏与生命周期管理
悬浮球通常由Service持有,并且通过WindowManager添加了系统级的视图。这里有两个常见的泄漏点:
- Context泄漏:在
Service中,this就是Service的Context。如果我们在异步任务(如网络请求、耗时计算)中持有这个Context的引用,而任务在Service销毁后还没结束,就会导致Service无法被回收。解决方案:使用applicationContext进行与UI无关的操作,或者确保异步任务能随Service销毁而取消。 - View未移除:在
Service的onDestroy()中,必须调用windowManager.removeView()来移除所有添加的视图(包括主悬浮球和所有菜单按钮)。我见过有人只在onDestroy里移除了主视图,忘了循环移除menuItemList里的菜单按钮视图,导致菜单按钮的窗口一直残留,消耗资源。
一个健壮的onDestroy应该像这样:
override fun onDestroy() { super.onDestroy() // 移除主悬浮球 if (::ballView.isInitialized) { windowManager.removeView(ballView) } // 移除所有菜单按钮 menuItemList.forEach { view -> windowManager.removeView(view) } menuItemList.clear() // 取消所有可能的动画 // ... 取消动画的代码 }6.2 多版本Android的兼容性处理
不同Android版本对悬浮窗的限制天差地别,必须做好判断。
| Android 版本 | 关键变化 | 我们的应对策略 |
|---|---|---|
| < 6.0 (API < 23) | 无需动态申请SYSTEM_ALERT_WINDOW权限。 | 直接使用TYPE_PHONE类型。 |
| 6.0 ~ 7.1 (API 23-25) | 需要动态申请权限。窗口类型仍可用TYPE_PHONE。 | 使用Settings.canDrawOverlays()检查,跳转ACTION_MANAGE_OVERLAY_PERMISSION页面申请。 |
| >= 8.0 (API 26) | 引入TYPE_APPLICATION_OVERLAY,TYPE_PHONE被废弃。 | 必须用TYPE_APPLICATION_OVERLAY。申请权限流程不变。 |
| >= 10 (API 29) | 后台启动Service限制更严。从后台启动的Service无法直接创建悬浮窗。 | 确保悬浮窗在用户有感知的前台操作(如点击通知、前台Service)中启动。 |
| >= 11 (API 30) | 包可见性限制。SYSTEM_ALERT_WINDOW权限在授权后,应用在后台时可能无法立即绘制。 | 引导用户额外开启“显示在其他应用上层”的开关(部分厂商定制)。使用前台Service保持优先级。 |
在代码中,创建窗口参数时要进行版本判断:
val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY } else { WindowManager.LayoutParams.TYPE_PHONE // 或 TYPE_SYSTEM_ALERT }6.3 触摸事件穿透与焦点管理
我们之前设置了FLAG_NOT_FOCUSABLE,这很好,避免了悬浮球抢走输入焦点。但有时候,你可能会发现悬浮球下方的应用按钮点击不灵敏了。这是因为即使窗口不获取焦点,它的触摸事件区域仍然可能“拦截”了事件。
解决方案:在WindowManager.LayoutParams中,还可以设置flags为:
flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCHFLAG_NOT_TOUCH_MODAL:允许窗口区域外的触摸事件传递给后面的窗口。这对于非全屏的悬浮窗很重要。FLAG_WATCH_OUTSIDE_TOUCH:即使触摸事件发生在窗口外部,你也能收到一个特殊的MotionEvent.ACTION_OUTSIDE事件,这可以用来实现“点击外部关闭菜单”的功能。
实测中,对于一个小球,FLAG_NOT_FOCUSABLE通常就够了。如果你做了一个半透明的全屏覆盖层,就需要仔细配置这些标志位。
6.4 省电与后台保活策略
用户最讨厌耗电的应用。悬浮球作为一个常驻的服务,更要注意。
- 使用前台服务 (Foreground Service):从Android 8.0开始,后台服务限制很多。为了让悬浮球更稳定地存活,可以启动一个前台服务。这需要在
Service的onCreate中调用startForeground()并提供一个常驻通知。记得在通知中说明用途,避免用户反感。 - 优化刷新频率:不要在
onTouch的ACTION_MOVE中做任何重计算或IO操作。坐标更新要轻量。 - 适时休眠:如果检测到屏幕关闭,或者用户长时间未交互,可以考虑将悬浮球暂时隐藏或变为一个极简的图标,减少不必要的绘制。监听
ACTION_SCREEN_OFF广播即可。 - 谨慎使用WakeLock:除非必要(例如悬浮球正在录屏),否则不要持有WakeLock阻止手机休眠。
最后,测试、测试、再测试。在不同的厂商ROM(如小米MIUI、华为EMUI)上,悬浮窗的权限管理和后台策略可能有额外的限制,需要针对性地做一些适配和引导提示。把这些细节都处理好,你的悬浮球控件才能真正做到“高效快捷”且“稳定可靠”。
