先把结论放前面:微信语音通话那种悬浮窗,技术上并不复杂,核心就三件事——WindowManager、WindowManager.LayoutParams、还有一套能陪你折腾半天的生命周期管理。很多人一听到“悬浮窗”就以为是系统级悬浮窗,要申请“悬浮窗权限”,要在设置页里手动开启,实际上微信语音通话用的是应用内悬浮窗,走的是Activity级别的Window机制,根本不需要悬浮窗权限。搞明白这一点,方向就不会跑偏。
这篇文章会按照我实际做过的方案,把整个实现思路、关键代码、踩坑记录全部过一遍。适合有Android基础、想在自己项目里做类似“语音通话最小化”效果的开发者,也适合刚接触Window机制、对悬浮窗原理还不透的朋友。
1. 先搞清楚:你要做的是“应用内悬浮窗”,不是系统级悬浮窗
1.1 两种悬浮窗的区别
悬浮窗按作用域分成两种:一种是应用内悬浮窗,窗口只出现在自己App的界面之上,离开App就看不见了;另一种是系统级悬浮窗,需要申请SYSTEM_ALERT_WINDOW权限,窗口能悬浮在所有应用之上,甚至能显示在桌面。
微信语音通话的悬浮窗属于前者。你切到其他App时,微信会退回到后台,通话悬浮窗只在你回到微信后还在,它本质上是驻扎在微信自己的Task里的一个小窗口。如果你的需求是“退出应用后悬浮窗仍然可见”,那才需要系统级悬浮窗。
这个区分决定了整个实现方案的走向。应用内悬浮窗不需要权限申请,不涉及系统设置页的引导,也不需要处理Android 6.0以上的悬浮窗权限弹窗,开发成本低一大截。但代价是悬浮窗的“生命周期”和Activity绑定,Activity销毁了窗口就没了,所以做的时候要在合适的时机重建。
1.2 为什么微信选应用内方案
微信没有采用系统级悬浮窗,一是因为系统级悬浮窗的权限弹窗在国产ROM上极其不可控——MIUI、EMUI、Flyme各家对权限的管理策略差异很大,用户不授权就完全没法用;二是因为语音通话悬浮窗的目的是“方便用户在微信内继续操作”,不需要跨应用显示。我在实际项目里也碰到过类似需求,当时产品想做一个“边看视频边回复消息”的悬浮窗,最后评估还是用了应用内方案,理由很简单:用户只有在App内部才有回复消息的场景,跨应用场景根本不存在。
所以动手之前先问自己一句:“我的悬浮窗需要出现在其他应用上面吗?”不需要的话,老老实实做应用内悬浮窗,省下的权限适配工作量够你写好多业务代码了。
2. 整体架构与窗口管理设计
2.1 WindowManager的基本用法
Android里所有窗口都是通过WindowManager添加的,Activity的setContentView底层也是这个机制。实现悬浮窗的通用套路是:
WindowManager windowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); WindowManager.LayoutParams params = new WindowManager.LayoutParams( WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.TYPE_APPLICATION_PANEL, WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, PixelFormat.TRANSLUCENT ); windowManager.addView(suspendView, params);关键在第三个参数window type。TYPE_APPLICATION_PANEL表示这是一个应用内的面板窗口,它要跟一个“宿主窗口”关联,悬浮窗会显示在宿主Activity之上。还有个参数是token,用来和宿主绑定,一般通过Activity.getWindow().getAttributes().token获取。
用TYPE_APPLICATION_PANEL的好处是窗口不会跑到其他应用上去,即使用户切到别的App,这个悬浮窗也会跟着宿主Task隐藏,不会产生跨应用的视觉残留。这是微信语音通话悬浮窗能保持行为干净的重要原因。
如果你想省掉token的关联,可以用TYPE_APPLICATION_ATTACHED_DIALOG,但实际体验下来,这玩意儿在某些机型上层级不对,容易出现“悬浮窗跑到Dialog下面”的尴尬问题。我个人推荐直接上TYPE_APPLICATION_PANEL,坑最少。
2.2 布局结构设计
悬浮窗的布局要讲究。微信语音通话悬浮窗分为两个状态:展开态和收起态。展开态是一个大概120dp宽的圆角卡片,显示对方名字、通话时长、麦克风开关、挂断按钮;收起态是缩小成一个小圆球,通常是一个50dp左右的圆形,只显示通话中图标或头像。
我建议把两种状态做成两个独立的View,不要试图用一个View去调整尺寸和内容。原因有二:第一,动画变化如果用scale动画,内容会跟着变形,圆形头像会被压成椭圆;第二,状态切换时View自身的measure/layout逻辑会变得复杂,遇到文字换行、按钮排列的问题非常难调。
更好的做法是维护两个View实例,通过addView和removeView做切换。切换时用TranslateAnimation或ScaleAnimation做一个过渡动画,视觉上顺畅很多。代码层面用一个Status枚举维护当前状态:
public enum FloatStatus { EXPANDED, COLLAPSED }切换状态时先判断当前状态,旧View移除,新View添加,加动画时不打断用户的视觉连贯性。在动画结束后再做状态变更回调,避免在动画过程中用户又触发了一次点击导致状态错乱。
2.3 任务栈与Activity生命周期策略
悬浮窗的生命周期必须和宿主Activity同步。最常见的做法是监听宿主Activity的onPause、onStop、onDestroy,分别处理悬浮窗的隐藏、恢复和销毁。
有个细节很多新手会忽略:语音通话悬浮窗的“最小化”动作本身,其实是一个新的Activity。微信的实现里,通话界面会启动一个透明或者小尺寸的Activity承载悬浮窗,而原来的通话主界面在后台被finish或者只是stop了。
我在项目里采用的方式是:通话Activity检测到用户按下Home键或者点了“最小化”按钮时,不是finish自己,而是启动一个FloatActivity,把悬浮窗内容放进去。这样悬浮窗虽然不是传统意义的“窗口”,但拥有了Activity的完整生命周期,切后台、回前台、内存回收时系统都能正确处理。
这种方法唯一的代价是多一个Activity的启动开销,但在现在的手机性能面前基本可以忽略。换来的是生命周期处理变得极其清晰:onCreate里addView,onDestroy里removeView,不会出现窗口和Activity生命周期不同步的泄漏问题。
2.4 为什么不用View加到DecorView上
有人会问:“我在Activity的ContentView上加一个View,设置TranslationX/Y不也能实现拖动效果吗?何必用WindowManager?”
能用,但不推荐。加到DecorView上的View会随着页面跳转、键盘弹起、沉浸式布局变化而错乱。尤其通话过程中如果拉起了一个权限弹窗或者系统键盘,DecorView的布局会被重新测量,悬浮窗的位置就会窜。WindowManager管理的窗口是独立层级,不受Activity布局变化影响,悬浮窗在该在的位置就在哪,痛少很多。
另外,加在WindowManager里的View可以设置FLAG_NOT_FOCUSABLE,避免悬浮窗抢焦点,微信语音通话的悬浮窗即使展开状态下,点击里面的按钮也不会让背后的Activity失焦,用户操作体验和直接操作Activity没有区别。
3. 核心实现:初始化、拖拽与动画细节
3.1 初始化WindowManager.LayoutParams
初始化这一步是整个悬浮窗实现的地基,参数设置有讲究。我常用的参数配置:
WindowManager.LayoutParams params = new WindowManager.LayoutParams(); params.width = dp2px(120); params.height = dp2px(64); params.type = WindowManager.LayoutParams.TYPE_APPLICATION_PANEL; params.flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE; params.format = PixelFormat.TRANSLUCENT; params.gravity = Gravity.TOP | Gravity.START; params.x = 0; params.y = 0; params.token = activity.getWindow().getAttributes().token; params.windowAnimations = android.R.style.Animation_Translucent;width和height可以是固定值,也可以用WRAP_CONTENT。固定值的好处是拖拽计算的边界更可控,但内容如果动态变化(比如时间从“12:34”变成“12:34:56”)可能会出现文字溢出。所以我更推荐用一个完整的布局容器,外层固定尺寸,内层自适应。
FLAG_NOT_FOCUSABLE是必须的。如果不加这个flag,悬浮窗会抢占焦点,按下返回键时先关闭悬浮窗而不是先处理Activity的返回逻辑,这种体验很怪。还有一些额外flag可以按需加:FLAG_NOT_TOUCH_MODAL(不阻塞Activity的其他触摸事件)、FLAG_LAYOUT_NO_LIMITS(允许窗口超出屏幕边界,拖拽动画更自然)。
3.2 拖拽与跟手效果
拖拽的核心是监听悬浮窗的onTouchEvent,或者直接在addView前给View设置OnTouchListener。监听ACTION_DOWN记录初始坐标和触摸点位置,ACTION_MOVE时计算偏移并更新params.x和params.y。
case MotionEvent.ACTION_MOVE: { params.x = initialX + (int) (event.getRawX() - downRawX); params.y = initialY + (int) (event.getRawY() - downRawY); windowManager.updateViewLayout(suspendView, params); return true; }注意一定用getRawX/getRawY,因为悬浮窗本身的getX/getY是相对父容器的坐标,在窗口更新时会变得很不可靠。我第一版实现就用了event.getX(),结果拖拽到一半悬浮窗会突然跳一下,排查了很久才发现是坐标系的锅。
updateViewLayout调用频率很高,ACTION_MOVE期间几乎每帧都在调。首先要保证不要在主线程里做耗时操作。其次建议在移动过程中用params.x/params.y直接赋值,不要反复new LayoutParams对象,免得触发额外的GC。实测下来,这个方案在绝大多数手机上都能保持跟手,即使是在低端机上,性能也能接受。
3.3 拖拽结束吸附边缘
微信语音通话悬浮窗拖出去之后,松手会自动滑到屏幕边缘。这个是悬浮窗体验的关键,就要靠动画把窗口移动到位。
我常用的做法是拖拽松手后,判断悬浮窗中心点离屏幕哪条边近,然后计算目标坐标:
int screenWidth = getScreenWidth(); int centerX = params.x + viewWidth / 2; int targetX; if (centerX < screenWidth / 2) { targetX = 0; } else { targetX = screenWidth - viewWidth; }然后用ValueAnimator或ObjectAnimator做位移动画,动画时长150ms到200ms,插值器用DecelerateInterpolator。动画过程中需要实时调用updateViewLayout。
用ObjectAnimator可以直接对自定义属性做动画,写法比较简洁。需要注意一点:动画结束时一定要在onAnimationEnd里更新params的最终值,防止动画因为某种原因中断后坐标没对上。
吸附边缘后还有个细节:如果悬浮窗不小心被拖到了屏幕最顶上或最底下,得处理上下边界。通常的做法是让悬浮窗始终停留在屏幕可见区域内,至少留一点边缘阻尼,避免用户无法完全拖出去。
3.4 点击和长按手势的冲突处理
悬浮窗既要能拖动,又要能点击展开、长按实现更多功能。这就涉及到手势判定。我的策略是用触摸移动距离判断:ACTION_DOWN之后如果移动超过系统TouchSlop阈值,就认为是拖动,否则松手时按点击处理。
if (event.getAction() == MotionEvent.ACTION_UP) { if (Math.abs(event.getRawX() - downRawX) < touchSlop && Math.abs(event.getRawY() - downRawY) < touchSlop) { performClick(); } }TouchSlop可以通过ViewConfiguration.get(context).getScaledTouchSlop()拿到。注意这里的performClick不是View自带的performClick,而是自己去触发悬浮窗的展开逻辑,不要依赖ViewConfiguration的Clickable属性,否则会出现拖动结束还会触发Click的误判,非常烦人。
3.5 展开收起动画
展开收起动画我建议用两种方式配合:尺寸变化用View的layoutParams动画,内容切换用透明度渐变。
展开时:把CollapsedView的alpha动画设为0,ExpandedView的alpha从0到1,同时把ExpandedView的宽高从0或小值撑到目标尺寸。收起时反过来。
这个动画看起来简单,但有个常见的坑:动画过程中要不断更新params.width和params.height,如果直接用View的layoutParams而不是WindowManager的LayoutParams,窗口不会正确变化。必须用updateViewLayout(params)一起更新宽高和坐标。
如果你觉得这里太绕,有另一个偷懒方案:悬浮窗本身尺寸不变,切换时直接用setVisibility去切换内部的两个子View。缺点是整体尺寸始终等于较大状态那一个,视觉上不够“灵动”。考虑到语音通话悬浮窗不是高频交互,我推荐还是做一次真正的尺寸动画,体验会好很多。
3.6 生命周期与内存泄漏规避
悬浮窗持有Activity的Context引用是最容易内存泄漏的地方。用context.getApplicationContext()创建View,会导致WindowManager无法正确添加窗口(没有Window token的窗口添加不了),所以实际只能用Activity的Context。
规避方法:在Activity的onDestroy里必须removeView,并且把View的引用置空。最好再配合一个BaseActivity声明周期回调来统一管理:
@Override protected void onDestroy() { super.onDestroy(); if (windowManager != null && suspendView != null) { windowManager.removeView(suspendView); suspendView = null; } }还有一个常见泄漏场景:View里注册了BroadcastReceiver或者EventBus,悬浮窗销毁时没有注销。我踩过一次坑,悬浮窗里有一个计时器Textview,用Handler每秒更新一次,结果Activity销毁后Handler还活着,导致整个Activity泄漏。后来用了LifecycleObserver统一处理:
class FloatLifecycleObserver implements DefaultLifecycleObserver { @Override public void onDestroy(LifecycleOwner owner) { removeFloatWindow(); } }这个方案比在各种Callback里手动remove要干净得多,而且和Activity的Lifecycle绑定得更紧密,推荐。
4. 实战排障:常见问题与排查实录
4.1 悬浮窗不显示 / 显示了但拿不到焦点
最常见的“不显示”原因是type选错了。用TYPE_APPLICATION_PANEL的时候,如果没有传token,或者token传的是null,窗口会创建失败,而且Activity还不报错,日志里也看不到明显异常。
要检查三处:
- 确保token来自你要悬浮的Activity的Window,不是Application的。
- 确保addView调用在Activity的onCreate之后,不要在Application.onCreate这种没有Window的环境里调用。
- 检查params.width/height是否有值,如果两个都是0或者MATCH_PARENT,可能窗口被撑满了或者被裁掉了。
拿不到焦点的问题,基本是FLAG_NOT_FOCUSABLE没设置全。注意除了flag设置,View本身不能设置clickable/focusable为true,不然还是会抢占焦点。我在做通话悬浮窗时遇到过一次:Toast弹完后悬浮窗突然不可点击了,最后发现是有个后台进程把窗口的某些flag改了,排查起来非常隐蔽。
这种问题推荐在addView后打日志,把params的flags、focusable状态都打出来,比对正常和异常路径,效率会高很多。
4.2 拖拽后悬浮窗坐标跳变
坐标跳变三个高发原因:
- 使用了getX()而不是getRawX(),导致在触摸过程中基准坐标系被updateViewLayout改变了。
- 屏幕旋转后没有重新计算悬浮窗位置,导致悬浮窗跑到屏幕外。
- 横竖屏切换时params.x/params.y没有重置,窗口显示在系统栏下面。
横竖屏的处理比较麻烦,我的方案是监听ConfigurationChanged,在屏幕方向变化时暂停窗口动画,把悬浮窗重新摆到屏幕中间或者原来的相对位置。如果懒得做动画,至少在onConfigurationChanged里reset一次params坐标到默认值,别让用户“找不到悬浮窗”。
4.3 updateViewLayout频繁调用触摸卡顿
拖拽本身每帧都在updateViewLayout,部分中低端机上会卡。优化的思路有三个:
- 在拖拽过程中关闭悬浮窗内部的动画,比如Textview的计时刷新频率降低到1秒一次,不要在每帧都触发invalidate。
- 减少View层级,悬浮窗的布局不要嵌套太深,避免每帧measure/layout太重。
- 用FLAG_LAYOUT_NO_LIMITS让窗口不限制在屏幕边界,可以减少系统对窗口的layout边界计算。
实测下来,这三招用完,哪怕在骁龙4系的机器上拖拽都基本不掉帧了。不过这也要看你悬浮窗内部内容的复杂度,如果里面有个视频预览SurfaceView,那性能的瓶颈就在SurfaceView的合成上,这种场景建议把悬浮窗做成单独的表面,而不是普通View。
4.4 悬浮窗和软键盘冲突
如果悬浮窗需要弹出输入框(比如蓝牙耳机名称编辑),软键盘会把悬浮窗顶上去,甚至挡住。通常用adjustResize和adjustPan解决,但悬浮窗不属于普通Activity的ContentView,adjustResize对它无效。
我的方案是监听ViewTreeObserver.OnGlobalLayoutListener,软键盘弹出时如果是展开态,就把悬浮窗往上移动一段距离;收起时恢复。高度可以通过rootView.getRootWindowInsets()算出键盘高度,然后直接调整params.y,注意params.y的值要加上原来的偏移量,不要直接覆盖。
4.5 双窗口重叠:多次调用addView
Activity恢复时可能触发多次onResume,导致addView被重复调用,出现两个悬浮窗叠在一起。解决办法是addView之前检查:
if (suspendView.getParent() == null) { windowManager.addView(suspendView, params); }但View.getParent()在WindowManager里不能保证100%可靠,因为窗口移除后parent可能为null了。更稳妥的方式是维护一个AtomicBoolean状态标记,或者统一交给LifecycleObserver回调去add/remove,不要直接在onResume里裸调用addView。
4.6 悬浮窗遮挡应用内容
有网友问过“悬浮窗会不会挡住App自己的按钮”,答案会。应用内悬浮窗显示在Activity之上,会拦截触摸事件。如果悬浮窗恰好覆盖了某个重要的操作按钮,用户体验会很糟糕。
解决办法有两个方向:
一是降低悬浮窗尺寸,用小圆点形态尽量减少遮挡面积。
二是收到对应用户的明显操作时,自动切换成收起态。比如用户在悬浮窗周围滑动时,把悬浮窗往边缘再吸附一点。
微信语音通话悬浮窗用的是第一种思路,收起状态是一个很小的头像,几乎不遮挡内容。等到用户需要操作时才展开,展开状态是主动行为,不打扰正常操作。
4.7 某些机型上悬浮窗不响应触摸
小米部分机型上,TYPE_APPLICATION_PANEL的窗口触摸事件会被系统拦截,表现为悬浮窗点击没反应。这类问题一般是系统对窗口类型的触摸分发策略有差异。稳妥做法是换成TYPE_APPLICATION_ATTACHED_DIALOG或者TYPE_PHONE,但要评估是否需要系统权限。
如果做的是系统级悬浮窗,那就绕不开权限申请。我的经验是:先把权限判断逻辑写好,权限没通过时给用户一个引导浮层,点击跳转到设置页,并在onResume里重新检查权限结果。不推荐用反射去操作系统悬浮窗开关,这属于黑科技,在Android 10以后的机型上基本都被封了,稳定性不值得投入。
5. 进阶扩展:从应用内悬浮窗升级到系统级悬浮窗
5.1 权限申请与适配
如果产品经理非要“退出App之后还能看悬浮窗”,那就老老实实做系统级悬浮窗,绕不开权限。
Android 6.0以上用Settings.canDrawOverlays(context)检查,没有权限时用Intent跳转设置页:
Intent intent = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:" + getPackageName())); startActivity(intent);需要声明SYSTEM_ALERT_WINDOW权限:
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />系统级悬浮窗的type要改为TYPE_APPLICATION_OVERLAY(Android 8.0以上强制使用),目标API在29以下才可以用TYPE_PHONE。窗口层级上,TYPE_APPLICATION_OVERLAY不低于普通Activity,但低于系统弹窗,千万别做弹窗浮层悬浮在上面,会被系统管理机制限制。
5.2 系统级悬浮窗的冻结与恢复
系统级悬浮窗最大的痛苦是息屏后、切App后的状态管理。建议用服务承载悬浮窗,而不是Activity。服务在后台运行,通过onStartCommand接收命令来addView/removeView,这样即使Activity被回收,窗口还能存在。
但注意:Android 8.0后后台服务在熄屏一段时间后会被系统冻结,悬浮窗不一定能实时响应触摸。我的经验是语音通话类场景必须保持前台服务,利用前台服务通知的“call”类型或者“mediaPlayback”类型来保活。如果不保活,用户的耳机控制马上就失灵了,体验极差。
5.3 和画中画模式的取舍
如果你需要的是“上层的、不遮挡的小窗”,Android自带画中画(PIP)模式在Android 8.0以上也覆盖了大多数场景。PIP模式有系统级窗口能力,有自动的动画和尺寸管理,不用自己写拖拽逻辑,省很多事。
但PIP模式的UI布局比较受限,只能用RemoteViews或一个简单的Activity内容,自定义程度远不如WindowManager方案。微信语音通话悬浮窗没选PIP,我猜测是因为需要高度自定义的UI和交互——音量滑条、麦克风控制、联系人信息,PIP模式下实现这些很痛苦。
如果你只是想让视频电话在通话中变成小窗,优先评估PIP;如果有复杂的交互和UI,再考虑WindowManager方案。
5.4 内存与性能的进阶考虑
系统级悬浮窗常驻内存,需要更严格地控制View的等级和内存占用。不要在悬浮窗里加载高分辨率图片,不要用多层半透明叠加,这些都会增加合成开销。
我自己的经验法则是:悬浮窗View层级控制在3层以内,背景层用圆角Drawable而不是Bitmap,图片用VectorDrawable或者压缩后的位图,Textview减少重绘次数。实测150dp的悬浮窗内容,内存占用控制在5MB以内问题不大,超出这个数说明布局或者图片加载逻辑有问题,该优化就优化。
6. 悬浮窗功能还可以怎么玩
做完了基础功能后,有些扩展点还是挺有意思的,我把个人试过的几个方向列一下:
6.1 多Surface播放器
如果你在悬浮窗里放一个视频通话画面,那就不能只用普通View了,需要SurfaceView或者TextureView。注意SurfaceView有单独的Surface,悬浮窗的背景层和Surface之间可能会出现黑色闪屏,处理方法是给SurfaceView设置zOrderOnTop或zOrderMediaOverlay。
我在项目里实测过TextureView比SurfaceView稳定一些,因为它走的是硬件层合成,不会穿帮。但TextureView的内存开销稍微更大,而且不支持在低端机器上做画面旋转,SDK 26前有兼容问题。在悬浮窗里播视频,推荐用TextureView。
6.2 语音动画与视觉反馈
通话悬浮窗里加一个声波动画,原地画圈圈或者波动条,会让用户觉得“这个悬浮窗是活的”。实现方式就是自定义View,在onDraw里根据音量大小画不同长度的线段,或者用Canvas.drawCircle画扩散波纹。
这里要控制好动画帧率,每帧更新幅度不要太大,否则CPU占用率飙升。实测下来,6dB音量区间映射6-7根动感条,30fps的动画,CPU占用可以控制在2%以内。再加一个自定义属性动画,整体效果就很像样了。
6.3 手势区域与防误触
悬浮窗上的按钮如果太小,用户容易误触。我的做法是把可点击区域扩大,用TouchDelegate或者直接加大按钮的透明响应区。比如一个24dp的关闭按钮,实际可点击区域是32dp甚至40dp,Android的点击目标最小建议是48dp,悬浮窗按钮受尺寸限制,但也尽量做到40dp。
另一个思路:悬浮窗整体可以上下滑动调节音量,按钮就不做那么密集了。微信就是这么做的,上下滑动调节音量,左右滑动切换通话对象,交互效率很高。实现并不复杂,就是onTouchEvent里根据滑动方向和距离映射到不同的功能。
6.4 双指缩放
如果你想给悬浮窗加“缩放”功能,双指pinch手势判定可以用ScaleGestureDetector,监听缩放比例后动态修改params.width/height,同时要重新计算params.x/params.y保持中心点不变。这个交互在视频悬浮窗中很实用,但是涉及的计算量会多一些,要注意边界判断,别让窗口小于最小尺寸或者大于屏幕的一半。
7. 写在最后:我做这个功能的一些体会
悬浮窗功能看起来就是个“小窗口”,但真正深度做完一遍后,你会发现它牵扯到Android整个窗口体系、事件分发体系、生命周期管理,还考验你对各种机型适配的耐心。
我个人的一点体会是:悬浮窗的实现并不难,难的是“何时显示、何时隐藏、何时允许拖、何时不允许拖”这些状态判断。一定要把状态机设计好,先画清楚状态流转图,再动手写代码。等代码写完了,再回头优化动画和交互细节。如果一开始就直接写拖拽和动画,后面加状态逻辑会改得怀疑人生。
另外一个经验是,悬浮窗类的功能一定要有完善的日志开关,Debug模式下把每次addView/removeView/updateViewLayout、参数变更都打出来。这个问题排查太重要了。好多“神奇消失”的悬浮窗bug,最后都是靠日志比对定位到是某个回调里多了一次removeView。
最后再分享一个细节:布局文件里的悬浮窗背景,不要用纯色Drawable,用圆角Drawable加一点半透明,能做到微信那种“毛玻璃”的伪效果。我需要特别说明,真正的毛玻璃效果要用RenderEffect或者Elevation加模糊,但大多数项目根本不需要,一个圆角半透明Drawable配合android:elevation的阴影,视觉上已经非常干净了。
希望这篇文章能帮你把Android悬浮窗这件事彻底吃透。如果有什么实现细节没有写清楚的地方,按上面的思路自己动手做一遍,应该能踩掉80%的坑。踩完剩下的20%,欢迎来交流,一起填坑。