
简介一套基于Kotlin开发的Android多功能悬浮球工程集成桌面录屏快捷操作、悬浮窗控制、手势识别、屏幕录制性能优化与UI定制适用于安卓系统辅助操作、游戏录制、教学演示及远程协作等场景面向Android开发者与移动端功能增强爱好者。工程源码结构完整共48个文件核心为8个Kotlin源文件、16个XML布局与配置另有gradle构建脚本、properties配置、Java辅助类、PNG图标及说明文档等整体体量仅216KB便于快速阅读与二次开发。已有132人学习。资源附有说明文件、附赠资源文档与清晰目录结构可直接导入Android Studio对照源码理解悬浮窗管理、手势监听、录屏参数优化等关键实现也可按需修改UI主题与交互布局适合有一定Android基础的开发者用于工具类App开发或移动端辅助功能研究。1. 悬浮球不是新鲜UI是把手边高频操作压缩成一次点击录屏这件事操作成本往往不在录制本身而在入口。游戏打到关键团战想录下来得先切出去、下拉工具栏、找到录屏按钮、再切回游戏教学演示时讲完一个步骤才想起没开录制前面那段等于白讲远程协助时一边看对方屏幕一边解释悬浮球反而成了最不遮挡视线的工具。基于Kotlin开发的Android多功能悬浮球就是把桌面录屏、快捷操作、悬浮窗控制、手势识别这些能力全部塞进一个几十dp的小球里单击出菜单双击开关录屏长按拖动位置。这篇笔记按悬浮窗权限选型、MediaProjection录屏链路、手势映射、性能避坑的顺序展开中间会贴出能直接落地的Kotlin代码和关键参数。2. 悬浮窗权限与窗口类型先弄懂系统凭什么让你悬浮2.1 三种悬浮窗类型对比TYPE_APPLICATION_OVERLAY 为什么是唯一选择悬浮窗的本质是系统WindowManager托管的独立Window它不属于任何Activity层级在所有应用窗口之上。开发者第一次做悬浮球最容易犯的错是直接new一个View丢给addView结果不是闪退就是显示不出来因为没指定窗口类型。窗口类型决定了两件事悬浮窗能出现在什么层级以及系统对它有哪些限制。Android 8.0API 26之前常用的悬浮窗类型是TYPE_PHONE那时候悬浮球工具基本都靠它。API 26之后TYPE_PHONE被标记废弃原因是它和系统电话UI的层级耦合太深普通应用继续用会导致来电界面被遮挡、SystemAlertWindow权限被滥用等问题。替代方案是TYPE_APPLICATION_OVERLAY它是专门给普通应用做悬浮窗用的。还有一类TYPE_ACCESSIBILITY_OVERLAY只有开启无障碍服务才能创建悬浮球工具一般不会用因为强制依赖用户去开无障碍授权路径太长。窗口类型最低API是否需要权限适用场景TYPE_PHONEAPI 1SYSTEM_ALERT_WINDOWAPI 26前主流现已废弃TYPE_APPLICATION_OVERLAYAPI 26SYSTEM_ALERT_WINDOW悬浮球、录制控制条、快捷面板TYPE_ACCESSIBILITY_OVERLAYAPI 22无障碍服务无障碍点击代理悬浮球不建议用选型结论很直接targetSdk高于26的项目统一用TYPE_APPLICATION_OVERLAY不要做版本兼容切回TYPE_PHONE。这个类型在Android 8.0到15上行为稳定配合SYSTEM_ALERT_WINDOW权限可以覆盖几乎所有悬浮球需求。有一点要注意即使声明了权限部分国产ROM仍然会在后台限制悬浮窗显示需要引导用户去系统的「悬浮窗管理」单独开启应用权限这个问题放到第5章展开。2.2 申请 SYSTEM_ALERT_WINDOW跳转设置页与返回校验SYSTEM_ALERT_WINDOW属于特殊权限不归运行时权限那一套Manifest里声明了也不会自动弹授权框必须跳系统设置页让用户手动打开。常见做法是先检查Settings.canDrawOverlays没权限就弹引导对话框用户点确认再跳转。class FloatingBallManager(private val context: Context) { fun hasOverlayPermission(): Boolean { return Settings.canDrawOverlays(context) } fun requestOverlayPermission(activity: Activity) { val intent Intent( Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse(package:${activity.packageName}) ) // 部分国产ROM会替换成自家安全中心页面这里只做系统级跳转 activity.startActivity(intent) } }这段逻辑里两个点容易翻车一是Uri必须是package:包名不能直接传Settings.ACTION_MANAGE_OVERLAY_PERMISSION不带Uri的形式否则部分机型跳不到应用专属设置页二是权限页返回后不能依赖onActivityResult立刻刷新状态某些ROM的返回回调会丢必须在onResume里再检查一次状态。override fun onResume() { super.onResume() if (!floatingBallManager.hasOverlayPermission()) { floatingBallManager.removeBall() // 权限没开就隐藏入口而不是反复弹窗 binding.floatEntry.isVisible false } }权限不满足时最忌讳的是在onResume里弹一个又一个Toast或Dialog用户刚从设置页回来还没站稳就被打断。我一般会让入口直接隐藏把「要不要开悬浮窗」的决定权交给用户等他主动点击功能入口时再引导。这样既不会造成权限弹窗循环也不会在系统设置页和App之间来回跳。2.3 最小悬浮球实现创建、拖拽、销毁一套带走悬浮球的核心是一个自定义View加一套WindowManager.LayoutParams。View负责绘制圆形小球和响应触摸LayoutParams负责定义窗口层级、尺寸、位置和焦点策略。class BallView(context: Context) : View(context) { private val paint Paint(Paint.ANTI_ALIAS_FLAG).apply { color 0xFF1B8EFF.toInt() style Paint.Style.FILL } // 录屏中状态小圆点 private val statusDotPaint Paint(Paint.ANTI_ALIAS_FLAG).apply { color 0xFFFF4D4F.toInt() } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) val radius width / 2f canvas.drawCircle(radius, radius, radius - 2f, paint) if (isRecording) { canvas.drawCircle(radius, radius, width / 8f, statusDotPaint) } } var isRecording false set(value) { field value invalidate() } }窗口参数里最核心的就四个字段类型、flag、像素格式、位置。类型用TYPE_APPLICATION_OVERLAY已经说过FLAG_NOT_FOCUSABLE让悬浮球不抢焦点否则点击屏幕时输入法会收起、后台Activity会失焦这是悬浮球体验崩掉的第一大原因FLAG_NOT_TOUCH_MODAL配合触摸事件处理让除悬浮球之外的区域仍然可以正常响应。private fun createWindowParams(): WindowManager.LayoutParams { val size dp(54) return WindowManager.LayoutParams( size, size, WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY, WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL, PixelFormat.TRANSLUCENT ).apply { gravity Gravity.TOP or Gravity.START x dp(12) y dp(200) } }浮窗位置用gravity加x/y组合控制。这里用了TOP|STARTx代表离屏幕左侧距离y代表离顶部距离。悬浮球的拖动逻辑则在View的onTouchEvent里完成move事件时更新params.x和params.y调用windowManager.updateViewLayout。override fun onTouchEvent(event: MotionEvent): Boolean { when (event.actionMasked) { MotionEvent.ACTION_DOWN - { downX event.rawX downY event.rawY } MotionEvent.ACTION_MOVE - { params.x (startParamsX event.rawX - downX).toInt() params.y (startParamsY event.rawY - downY).toInt() windowManager.updateViewLayout(this, params) } } return true }拖拽时建议用rawX/rawY而不是getX/getY因为getX是相对父容器的坐标悬浮球作为根View直接addView进WindowManager时坐标基准容易混乱。销毁时要注意removeView的时机必须在主线程调用否则会抛异常。这套最小实现跑通之后再往里面加手势识别、录屏、快捷面板才有基础。3. 把录屏能力接进悬浮球MediaProjection 授权到前台服务3.1 MediaProjection 授权获取弹框之后 token 怎么传录屏能力在Android里没有「直接开始录」的API必须先通过MediaProjectionManager.createScreenCaptureIntent()弹一个系统授权框用户确认后拿到一组凭证resultCode和resultData。这两样东西是后续创建MediaProjection实例的唯一钥匙而且只能消费一次服务重启后不能靠旧数据重新创建必须重新发起授权。授权流程推荐用Activity Result API旧的onActivityResult已经废弃而且悬浮球工具往往涉及权限检查、引导弹窗多个流程Result API的registerForActivityResult写起来更清晰。private val captureLauncher registerForActivityResult( ActivityResultContracts.StartActivityForResult() ) { result - if (result.resultCode RESULT_OK result.data ! null) { val data result.data ?: returnregisterForActivityResult val serviceIntent Intent(this, RecordService::class.java).apply { putExtra(RecordService.EXTRA_RESULT_CODE, result.resultCode) putExtra(RecordService.EXTRA_RESULT_DATA, data) action RecordService.ACTION_START } ContextCompat.startForegroundService(this, serviceIntent) } } fun startCapture() { val manager getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager captureLauncher.launch(manager.createScreenCaptureIntent()) }这里一个隐蔽的坑result.data作为Intent里面包含了MediaProjection所需的Binder会话信息不能把它持久化到磁盘或序列化保存跨进程传递时直接放进Intent即可。有些实现图省事把data转成Base64存本地下次启动直接恢复这在部分Android版本上会拿到一个不可用的MediaProjection症状是创建VirtualDisplay成功但画面全黑。token只能活在进程内这是系统层面的安全设计别绕。3.2 MediaRecorder 参数与 VirtualDisplay 绑定分辨率、码率、帧率怎么配拿到MediaProjection之后接下来用MediaRecorder做编码器再用createVirtualDisplay把屏幕画面镜像到MediaRecorder的Surface上。MediaRecorder的参数直接影响成片质量配置错了要么模糊要么卡顿。private fun setupMediaRecorder(): MediaRecorder { val windowManager getSystemService(Context.WINDOW_SERVICE) as WindowManager val metrics windowManager.defaultDisplay.let { display - val point Point() display.getRealSize(point) point } val recorder if (Build.VERSION.SDK_INT Build.VERSION_CODES.S) { MediaRecorder(context) } else { Suppress(DEPRECATION) MediaRecorder() } recorder.setVideoSource(MediaRecorder.VideoSource.SURFACE) recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) recorder.setVideoEncoder(MediaRecorder.VideoEncoder.H264) recorder.setVideoSize(metrics.x, metrics.y) recorder.setVideoFrameRate(30) recorder.setVideoEncodingBitRate(8_000_000) recorder.setOutputFile(getOutputFile()) recorder.prepare() return recorder }参数选择上分辨率直接取真实屏幕宽高不要为了省性能砍一半否则录出来文字全是糊的教学演示场景根本没法看。比特率按分辨率区间给1080p给6-8Mbps1440p给10-12Mbps再高意义不大因为H264在高码率下文件体积膨胀很快画质提升却不明显。帧率30就够悬浮球录屏场景没有电竞级流畅度需求60帧会让中端机编码压力陡增。屏幕分辨率建议比特率适用场景720p4-6 Mbps低端机备用1080p6-8 Mbps多数教学与游戏录制1440p10-12 Mbps2K屏录屏或远程协助演示VirtualDisplay创建时有个参数最容易被忽略densityDpi必须用当前屏幕的densityDpi不能自己算一个或写死。dpi设置错了录出来画面比例正确但整体发虚或者界面元素比实际偏大偏小。代码里直接用resources.displayMetrics.densityDpi即可。private fun createVirtualDisplay(recorder: MediaRecorder): VirtualDisplay { val metrics windowManager.defaultDisplay.let { display - val point Point() display.getRealSize(point) point } return mediaProjection!!.createVirtualDisplay( record-display, metrics.x, metrics.y, resources.displayMetrics.densityDpi, DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, recorder.surface, null, null ) }需要注意createVirtualDisplay传进去的宽高建议用getRealSize而不是getSize。getSize在部分多窗口、折叠屏场景下拿到的是窗口可用区域不是物理屏幕尺寸会造成录屏画面只有一半或局部拉伸。3.3 录屏服务前台化Android 14 的 mediaProjection 类型声明录屏必须放在前台服务里原因有两条一是Android 10开始后台启动前台服务受限悬浮球点击后直接startService在后台会抛异常二是录屏过程中用户切到别的应用服务拿不到前台唤醒能力会被系统判定为后台行为批量回收。前台服务的通知同时承担了「正在录屏」的状态提示用户从状态栏就能看到。Android 14targetSdk 34开始录屏类前台服务必须声明类型。以前startForeground只要给通知就行现在系统会校验服务和清单里的foregroundServiceType是否匹配不匹配直接崩。class RecordService : Service() { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { when (intent?.action) { ACTION_START - { if (Build.VERSION.SDK_INT Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { startForeground( NOTIFICATION_ID, buildNotification(), ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION ) } else { startForeground(NOTIFICATION_ID, buildNotification()) } setupRecorderAndProjection(intent) } ACTION_STOP - stopRecording() } return START_NOT_STICKY } }清单声明同样不能少targetSdk 34时需要在service节点上加android:foregroundServiceTypemediaProjection配套新增权限FOREGROUND_SERVICE_MEDIA_PROJECTION。这个权限是normal级别声明了就会自动授予不用运行时弹窗。Android 13及以下设备走老路径三个文件各改一处即可兼容。服务启动后悬浮球需要通过回调感知录屏状态。常见做法是在服务里暴露一个伴生对象的状态监听悬浮球注册进去录屏开始和结束时刷新UI。别用EventBus这类框架这个场景只有一个订阅者普通回调就够。companion object { var onStateChanged: ((Boolean) - Unit)? null fun start(context: Context, resultCode: Int, data: Intent) { val intent Intent(context, RecordService::class.java).apply { action ACTION_START putExtra(EXTRA_RESULT_CODE, resultCode) putExtra(EXTRA_RESULT_DATA, data) } ContextCompat.startForegroundService(context, intent) } }悬浮球点击录屏按钮后整个链路是悬浮球发送start Intent → 服务进入前台 → MediaProjection从resultCode和data恢复 → MediaRecorder绑定VirtualDisplay → 首帧到达后真正开始录制。这条链路每一步都有对应的坑尤其是首帧和token重复消费问题第5章逐一排查。4. 手势识别与交互映射单击、双击、长按如何不打架4.1 手势识别器与拖拽判定事件分发顺序决定手感悬浮球只有不到60dp的触控区域却要承载单击、双击、长按、拖拽四类手势设计不好就是「点一下弹出两个菜单」或者「想拖动却触发了单击」。手写事件判断很容易漏掉多点触控、快速移动等边界情况我一般直接用GestureDetector配合SimpleOnGestureListener把自己从复杂的ACTION_UP/ACTION_DOWN时序里解放出来。关键在事件分发的顺序先判断拖拽意图再让GestureDetector消费。如果onTouchEvent里把所有事件都先传给GestureDetector拖拽和单击就会互相干扰——手指一移动GestureDetector把事件识别为scroll而你的拖拽逻辑收不到完整事件流。override fun onTouchEvent(event: MotionEvent): Boolean { when (event.actionMasked) { MotionEvent.ACTION_DOWN - { downX event.rawX downY event.rawY isDragging false } MotionEvent.ACTION_MOVE - { if (!isDragging hypot(event.rawX - downX, event.rawY - downY) touchSlop ) { isDragging true } if (isDragging) { updatePosition(event) } } MotionEvent.ACTION_UP - { if (isDragging) { snapToEdge() isDragging false } } } // 拖拽状态下不触发单击/双击/长按 if (!isDragging) { gestureDetector.onTouchEvent(event) } return true }touchSlop是ViewConfiguration.get(context).scaledTouchSlop系统给的手势位移阈值通常几十个像素。用它的意义在于手指轻微抖动不至于立刻进入拖拽模式保持单击的稳定性。长按手势里GestureDetector默认500ms才触发触发后同样把isDragging置为true这样用户长按之后直接滑动就是拖拽不用松手再按一次。4.2 手势到动作的映射表单击面板、双击录屏、长按拖动悬浮球的交互映射有一个设计原则越容易误触的手势绑定的操作越轻。单击最容易误触发所以绑定「展开快捷面板」这种可以随时关闭的动作双击是刻意的绑定录屏开关长按进入拖拽因为拖拽本身就是移动位置不会和录屏抢状态。手势触发动作备注单击展开快捷面板面板包含录屏、截图、音量、亮度双击开始/停止录屏和单击通过GestureDetector内置双击间隔区分长按进入拖拽模式长按后滑动直接移动位置松手边缘吸附球自动贴到最近的屏幕边缘单击和双击并存会有一个副作用单击的响应会被延迟约300msGestureDetector需要等待确认这不是双击。这块体验上无解所以面板弹出速度会有可感知的延迟解决思路是把面板动画设短一点120ms弹出整体手感就能找回来。GestureDetector的配置通常在BallView初始化里完成SimpleOnGestureListener只需重载用到的回调。private val gestureDetector GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() { override fun onSingleTapConfirmed(e: MotionEvent): Boolean { showQuickPanel() return true } override fun onDoubleTap(e: MotionEvent): Boolean { toggleRecording() return true } override fun onLongPress(e: MotionEvent) { isDragging true startParamsX params.x startParamsY params.y } })4.3 边缘吸附动画悬浮球松手后自己归位悬浮球放在屏幕正中间会挡住内容常规做法是松手后自动吸附到左边缘或右边缘。吸附算法的判定很简单看悬浮球中心点离哪条边近就贴哪条边留出4dp左右的边距防止贴死后用户找不到球。这里有个最容易踩的坑给悬浮球做吸附动画不能用view.animate().x()因为悬浮球的位置由WindowManager.LayoutParams.x/y控制不是View自身的translationX。属性动画改的是translationXparams还是原来那套坐标动画过程中一有updateViewLayout就会跳回原位。正确做法是ValueAnimator持续更新params.x。private fun snapToEdge() { val screenWidth resources.displayMetrics.widthPixels val ballWidth width val centerX params.x ballWidth / 2f val targetX if (centerX screenWidth / 2f) dp(4) else screenWidth - ballWidth - dp(4) animator?.cancel() animator ValueAnimator.ofInt(params.x, targetX).apply { duration 180 interpolator DecelerateInterpolator() addUpdateListener { params.x it.animatedValue as Int windowManager.updateViewLayout(thisBallView, params) } start() } }动画执行期间用户再次按下悬浮球必须立即cancel动画否则动画会继续变更params.x和手指拖拽的坐标叠加在一起球会乱跳。我在代码里每次snapToEdge开头先animator?.cancel()拖拽的ACTION_DOWN里也加一行cancel确保动画只在松手后那一下生效。5. 踩坑与排查录屏黑帧、悬浮球被回收、权限被回收导致的崩溃5.1 录屏前两秒黑屏或整段黑帧现象录制的MP4文件能播放但开头有1到2秒纯黑画面严重时整个视频都是黑的只有声音或只有画面第一帧。原因主要有三个。第一MediaRecorder.start()调用太早Surface还没有输出任何帧就开始写入编出来的全是空数据。第二MediaProjection的VirtualDisplay挂在主线程但主线程被悬浮球点击事件的UI刷新占用首帧到达延迟。第三部分设备存在DRM保护应用层无法采集受保护内容这种情况换任何参数都录不出来。解决MediaRecorder启动前先等VirtualDisplay输出首帧常见的可靠做法是延迟300ms再start同时开启VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR这个flag在多数设备上能保证内容完整镜像。Handler(Looper.getMainLooper()).postDelayed({ if (mediaRecorder ! null) { mediaRecorder?.start() } }, 300)判断是否为DRM保护的黑屏可以先录一个系统桌面测试如果桌面能录出来只有某个特定应用黑屏那就是DRM保护别折腾参数。如果桌面也全黑检查VirtualDisplay的尺寸和densityDpi是否与真实屏幕一致。5.2 悬浮球从后台回来后消失现象应用切后台一段时间再回来悬浮球不见了通知栏里录屏服务可能还在也可能整个进程都被杀了。原因悬浮球是进程内资源进程被LMKlow memory killer回收后再次返回时会重新创建Activity但不会自动恢复悬浮球。部分ROM的锁屏清理还会把前台服务一起杀通知栏都看不到。解决录屏期间服务必须前台化前台服务能显著降低被回收概率但不是绝对保险。最稳妥的兜底是在Activity的onResume里做状态自检权限还在但悬浮球没显示就重新addView。override fun onResume() { super.onResume() if (floatingBallManager.hasOverlayPermission() !floatingBallManager.isShowing()) { floatingBallManager.show() } }isShowing不建议用isAttachedToWindow判断悬浮View在部分机型上即使窗口被系统移除isAttachedToWindow仍然返回true。用一个Boolean字段自己在addView时置true、removeView时置false比任何系统API都可靠。另外startForegroundService启动后必须在5秒内调startForeground否则系统直接抛RemoteServiceException这个崩溃在后台录屏场景非常常见。5.3 悬浮窗权限被回收后应用闪退现象用户进系统设置把「悬浮窗」权限关掉下一次打开App或点击悬浮球时直接闪退logcat定位到WindowManager.addView。原因权限关闭的瞬间系统会移除已显示的悬浮窗但应用内floatingBallManager对象还持有ballView引用再调用addView或updateViewLayout时系统校验权限不通过抛SecurityException或BadTokenException。解决所有窗口操作套一层try/catch捕获WindowManager.BadTokenException、SecurityException和IllegalArgumentException权限异常时不做重试直接移除引用。Synchronized fun removeBallSilently() { try { windowManager?.removeView(ballView) } catch (e: Exception) { // SecurityException / BadTokenException 一律不往上抛 Log.w(FloatingBall, remove ball failed: ${e.message}) } isShowing false }再配合第2章说的onResume权限检查权限没了就把入口隐藏。这两个动作加起来基本能保证悬浮窗权限被回收时应用不会崩。用户如果想继续用重新打开功能入口引导去开权限即可。5.4 录制文件卡顿、掉帧、音画不同步现象录出来文件播放卡顿系统录屏期间界面掉帧明显个别设备上声音比画面快。原因码率设得太高H264编码器在低端SoC上跑不满30帧录屏分辨率按2K屏幕走但编码器是硬件复用同一时间有其他编码任务时直接被抢占低电量模式下CPU降频实时编码跟不上屏幕输出速度。解决码率按章节3.2的表给别用「越高越好」的思路。录屏前通过PowerManager检查电池状态低电量时自动降分辨率或提示用户。不要用60fps30fps在绝大多数硬件上都能稳定编码。验证掉帧不能靠肉眼要在VirtualDisplay.Callback里统计帧率。onFramePending每来一帧回调一次5秒做一次平均低于24fps就要降码率或分辨率。var frameCount 0 var lastCheckTime 0L val vdCallback object : VirtualDisplay.Callback() { override fun onFramePending() { frameCount val now SystemClock.elapsedRealtime() if (now - lastCheckTime 5000) { val fps frameCount * 1000f / (now - lastCheckTime) Log.i(RecordPerf, 最近5秒平均帧率: $fps) frameCount 0 lastCheckTime now } } }5.5 Android 14 前台服务类型缺失导致的崩溃现象targetSdk升到34后在Android 14设备上点录屏必崩logcat里有明显的Missing type mediaProjection in the foreground service definition。原因Android 14强制要求startForeground传入前台服务类型清单里service节点也要声明foregroundServiceType两者必须匹配。漏了任何一处Android 14会直接终止服务启动过程。解决清单里service节点补android:foregroundServiceTypemediaProjection权限新增FOREGROUND_SERVICE_MEDIA_PROJECTION代码里调startForeground时给类型。对应代码段已在3.3给出这里补充一个容易漏的点AppOpsManager检查媒体投影权限被用户关闭时要像悬浮球权限一样做兜底否则也会因为能力缺失而崩溃。if (Build.VERSION.SDK_INT Build.VERSION_CODES.Q !appOpsManager.checkOpNoThrow( AppOpsManager.OP_PROJECT_MEDIA, Process.myUid(), packageName ) ) { // 提示用户重新授权不要在缺权限时直接启动录制 stopSelf() }6. 性能验证与UI定制把悬浮球做成可维护的配置项6.1 外观与状态做成配置对象悬浮球UI定制最怕的是把尺寸、颜色、透明度散落在View和Manager代码里。我习惯建一个BallConfig数据类集中管外观参数录屏状态只改状态标志View内部根据配置和状态决定画什么、画多大。data class BallConfig( val diameterPx: Int 54.dp, val idleColor: Int 0xFF1B8EFF.toInt(), val recordingColor: Int 0xFFFF4D4F.toInt(), val panelCornerRadiusPx: Int 16.dp, val alpha: Float 0.9f )有了配置对象后面做「夜间模式」「低透明度」「大尺寸触控区」都只是改数据不用改绘制逻辑。录屏中的状态变化通过invalidate触发重绘中间那个小圆点切换颜色用户一眼就能分辨当前是否在录制。6.2 验证录制流畅度的两个手段界面上的流畅度很容易被感知但要有数据支撑。第一个手段是章节5.4的帧率统计录制5秒后看平均fps第二个手段是adb命令直接看系统投影会话是否正常。adb shell dumpsys media.projection输出里能看到当前存在的投影会话、VirtualDisplay的尺寸和所属包名。如果App已经不在列表里说明MediaProjection已经被系统回收要么token失效要重新授权要么服务被杀。这个命令在录屏排查黑屏和掉帧时比logcat更直观。还有一个我自己常用的习惯在悬浮球点击到MediaRecorder.prepare好的路径上打日志重点看点击到服务启动、服务启动到编码器就绪、编码器就绪到首帧这三段耗时。超过1秒用户就会觉得「点下去没反应」这个时间主要花在Intent传递和MediaRecorder初始化上优化空间不大但至少要知道慢在哪一段。6.3 给这类工具留的一个降级习惯做了几年悬浮类工具我固定下来的习惯是把权限不满足当成正常状态而不是异常。悬浮窗权限被关就隐藏入口而不是反复弹窗录屏授权被拒就在快捷面板里把录屏按钮置灰并写明原因前台服务类型不兼容就降级到普通截图提示。所有能力都做成可降级的整体稳定性反而比堆一堆异常保护更好。悬浮球这种工具最怕的不是功能少是权限一变动就闪退、一回后台就消失这些坑踩过一遍后面的项目就顺了。希望帮到你。本文还有配套的精品资源点击获取