当前位置: 首页 > news >正文

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_PHONETYPE_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作为根布局,方便以后扩展,比如添加红点通知、状态指示器等。clickablefocusable属性最好设为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: 决定了xy坐标的参考系。Gravity.TOP | Gravity.START表示(x, y)坐标是相对于屏幕左上角的。
  • format:PixelFormat.TRANSLUCENT允许窗口有透明区域。

运行你的应用,授予权限后点击按钮启动服务,你应该就能看到一个小圆球出现在屏幕角落了!这是从0到1最关键的一步。

4. 让球动起来:实现拖拽与事件处理

一个不能拖的悬浮球是没有灵魂的。接下来我们实现拖拽功能,并处理好拖拽和点击事件的冲突。

4.1 触摸事件处理与坐标更新

拖拽的本质就是在OnTouchListenerACTION_MOVE事件中,实时更新悬浮球窗口的xy坐标。我们需要记录手指按下的初始位置和窗口的初始位置。

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.rawXevent.rawY(相对于整个屏幕的坐标)来计算偏移,而不是event.xevent.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_UPsnapToEdge后,将最终的x, y坐标保存到SharedPreferences中。在createAndShowFloatingBall方法里,读取保存的坐标并赋值给layoutParams.xy即可。

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添加了系统级的视图。这里有两个常见的泄漏点:

  1. Context泄漏:在Service中,this就是Service的Context。如果我们在异步任务(如网络请求、耗时计算)中持有这个Context的引用,而任务在Service销毁后还没结束,就会导致Service无法被回收。解决方案:使用applicationContext进行与UI无关的操作,或者确保异步任务能随Service销毁而取消。
  2. View未移除:在ServiceonDestroy()中,必须调用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_OVERLAYTYPE_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_TOUCH
  • FLAG_NOT_TOUCH_MODAL:允许窗口区域外的触摸事件传递给后面的窗口。这对于非全屏的悬浮窗很重要。
  • FLAG_WATCH_OUTSIDE_TOUCH:即使触摸事件发生在窗口外部,你也能收到一个特殊的MotionEvent.ACTION_OUTSIDE事件,这可以用来实现“点击外部关闭菜单”的功能。

实测中,对于一个小球,FLAG_NOT_FOCUSABLE通常就够了。如果你做了一个半透明的全屏覆盖层,就需要仔细配置这些标志位。

6.4 省电与后台保活策略

用户最讨厌耗电的应用。悬浮球作为一个常驻的服务,更要注意。

  1. 使用前台服务 (Foreground Service):从Android 8.0开始,后台服务限制很多。为了让悬浮球更稳定地存活,可以启动一个前台服务。这需要在ServiceonCreate中调用startForeground()并提供一个常驻通知。记得在通知中说明用途,避免用户反感。
  2. 优化刷新频率:不要在onTouchACTION_MOVE中做任何重计算或IO操作。坐标更新要轻量。
  3. 适时休眠:如果检测到屏幕关闭,或者用户长时间未交互,可以考虑将悬浮球暂时隐藏或变为一个极简的图标,减少不必要的绘制。监听ACTION_SCREEN_OFF广播即可。
  4. 谨慎使用WakeLock:除非必要(例如悬浮球正在录屏),否则不要持有WakeLock阻止手机休眠。

最后,测试、测试、再测试。在不同的厂商ROM(如小米MIUI、华为EMUI)上,悬浮窗的权限管理和后台策略可能有额外的限制,需要针对性地做一些适配和引导提示。把这些细节都处理好,你的悬浮球控件才能真正做到“高效快捷”且“稳定可靠”。

http://www.cnnetsun.cn/news/1262309.html

相关文章:

  • Python实战:单目三维重建从原理到实现
  • 掌握UI-TARS-desktop:解锁自然语言控制电脑的智能交互体验
  • Citizens2全栈指南:从架构设计到落地实践
  • 双网卡内外网共存:静态IP与路由表优化解决0.0.0.0冲突
  • 高效长读长测序数据过滤:Chooper与NanoFilt性能对比及实战指南
  • 人工智能篇---NLP(自然语言处理)
  • 5倍效率提升:BiliTools AI视频总结如何重构你的内容消费方式
  • 深入浅出解析Faster RCNN中的RPN网络工作原理
  • CE修改器实战指南:从精确值扫描到动态修改的进阶技巧
  • 基于STM32与MPU6050的智能跌倒预警系统设计与实现
  • 英伟达斥资20亿美元投资Nebius “循环投资”泡沫争议再起
  • 【CVPR26-美国伊利诺伊大学】视觉-语言模型中的链路追踪:理解多模态思维的内部机制
  • WorkBuddy 安装与远程配置笔记
  • 深入浅出策略模式:一个C++模板实现的灵活策略管理库
  • SFT构造数据的一些经验
  • 打卡信奥刷题(2945)用C++实现信奥题 P5864 [SEERC 2018] Broken Watch
  • 丝杆升降机如何正确选型?参数、工况、电机匹配一篇讲透
  • MATLAB R2021B环境下基于Takagi-Sugeno-Kang模糊神经网络的时间序列...
  • LeetCode102.二叉树层序遍历
  • 霸王餐券预扣库存场景下,如何防止同一用户多设备并发“薅羊毛”
  • 力扣解题-200. 岛屿数量
  • 国内代理 IP 的类型:住宅 IP、机房 IP、移动 4G/5G IP 区别
  • AI 内容导出乱、格式崩、公式变?我开发了这只鸭子帮我全解决了
  • 智能楼道灯控制器(有完整资料)
  • AI安全应用实战复盘:一场2小时的深度交流,我们聊了什么?
  • esp网络时钟
  • 【无线通信】基于matlab统计信道的低复杂度旋转和位置优化为6D可移动天线无线通信【含Matlab源码 15160期】
  • 2026年,一周就能背完的web前端八股文!(春招最新附答案)!
  • 根据文章举报数量确定推荐
  • NineData 社区版 V4.10.0 发布:新增 9 条异构数据库复制链路,DevOps 能力再升级!