目录介绍
01.整体概述
- 1.1 项目背景
- 1.2 遇到问题
- 1.3 基础概念
- 1.4 设计目标
- 1.5 收益分析
02.Window概念
- 2.1 Window添加View
- 2.2 Window的概念
- 2.3 LayoutParams
- 2.4 WMS流程梳理
03.悬浮窗技术要点
- 3.1 业务思考点分析
- 3.2 关键技术要点
- 3.3 应用悬浮窗
- 3.4 添加浮窗源码流程
- 3.5 理解WMS原理
- 3.6 拖拽回弹吸附
04.开发重要步骤
- 4.1 悬浮窗实现流程
- 4.2 请求悬浮窗权限
- 4.3 初始化悬浮窗
- 4.4 设置悬浮窗参数
- 4.5 添加View到悬浮窗
- 4.6 悬浮窗拖拽实现
- 4.8 悬浮窗权限适配
- 4.9 LayoutParam坑
05.方案基础设计
- 5.1 整体架构图
- 5.2 UML设计图
- 5.3 关键流程图
- 5.4 接口设计图
- 5.5 模块间依赖关系
06.其他设计说明
- 6.1 性能设计
- 6.2 稳定性设计
- 6.3 异常设计
- 6.4 事件上报设计
07.遇到的问题和坑
- 7.1 处理输入法层级关系
- 7.2 边界逻辑关闭悬浮窗
- 7.3 点击多次打开页面
- 7.4 Home键遇到的问题
01.整体概述
1.1 项目背景
业务场景分析
- 以视频通话为例,在视频通话时,我们打开其他应用或点击Home键退出时或点击缩放图标,悬浮窗会显示在其他应用之上,给人的假象是通话页面变小了,点击悬浮窗回到通过页面,悬浮窗消失。退出通话页面悬浮窗消失。
市面上常见的悬浮窗,如微信视频通话功能,有如下特点:
- 整屏页面能切换到一个小的悬浮窗;悬浮窗能运行在其他app上方;悬浮窗能跳回整屏页面,并且悬浮窗消失
需求悬浮窗效果
- 点击缩小按钮,将当前远端视屏加载进悬浮窗,且悬浮窗可拖拽,不影响其他界面焦点;点击悬浮窗可返回原来的Activity
1.2 遇到问题
什么是悬浮窗
- 全局悬浮窗在许多应用中都能见到,点击Home键,小窗口仍然会在屏幕上显示。注意:悬浮窗注意申请权限!
那么开发全局悬浮窗属于那一类呢?
- 属于系统窗口,相当于跟Toast是一个级别的。针对悬浮窗的展示和移除,则可以模仿Toast中addView和removeView操作……
视频通话Activity如何最小化
- Activity本身自带了一个moveTaskToBack(boolean nonRoot),我们要实现最小化只需要调用moveTaskToBack(true)传入一个true值就可以了,但是这里有一个前提,就是需要设置Activity的启动模式为singleInstance模式,两步搞定。
- 注:activity最小化后重新从后台回到前台会回调onRestart()方法。点击悬浮窗开启activity会回调onNewIntent(注意可以setIntent(intent)一下)
1.3 基础概念
Window 有三种类型,分别是应用 Window、子 Window 和系统 Window。
- 应用Window:z-index在1~99之间,它往往对应着一个Activity。
- 子Window:z-index在1000~1999之间,它往往不能独立存在,需要依附在父Window上,例如Dialog等。
- 系统Window:z-index在2000~2999之间,它往往需要声明权限才能创建,例如Toast、状态栏、系统音量条、错误提示框都是系统Window。
这些层级范围对应着 WindowManager.LayoutParams 的 type 参数
- 如果想要 Window 位于所有 Window 的最顶层,那么采用较大的层级即可,很显然系统 Window 的层级是最大的。
Android显示系统分为3层
- UI框架层:负责管理窗口中View组件的布局与绘制以及响应用户输入事件
- WindowManagerService层:负责管理窗口Surface的布局与次序
- SurfaceFlinger层:将WindowManagerService管理的窗口按照一定的次序显示在屏幕上
WMS(WindowManagerService)相关概念
- Window:它是一个抽象类,具体实现类为 PhoneWindow ,它对 View 进行管理。Window是View的容器,View是Window的具体表现内容;
- WindowManager:是一个接口类,继承自接口 ViewManager ,从它的名称就知道它是用来管理 Window 的,它的实现类为 WindowManagerImpl;
- WMS:是窗口的管理者,它负责窗口的启动、添加和删除。另外窗口的大小和层级也是由它进行管理的;
1.4 设计目标
目前开发悬浮窗的方案有以下几种
- 第一种:写在base里面或者监听所有activity生命周期,这样每次启动一个新的Activity都要往页面上addView一次,耦合性比较强。
- 第二种:采用在Window上添加View的形式,相当于是全局性的悬浮窗。封装成库,暴露Api给开发者调用。
- 第三种:采用服务Service,然后在Service中采用WindowManager添加和移除View操作。那么在Activity中想要展示弹窗则需要通过广播通信,让Service收到广播处理逻辑。移植性比较弱!
悬浮窗设计目标
- 良好的接口设计,可以设置各种自定义视图,支持拖动和拖拽吸附到边缘。强大的Api方法和傻瓜式调用链路。
展示悬浮窗能否想Popup那样依附在某控件位置
- 我在写悬浮窗库时,思考能否想Popup那种有showAsDropDown方法Api,可以显示在某个View的重心位置,然后在设置x和y偏移量。这个是可以做到的,加上这个Api方便库的强大使用!
1.5 收益分析
悬浮窗收益
- 提高产品的用户体验,app推到后台,或者推出页面做其他操作(比如查看信息),这个时候浮窗功能主要是增加通话的友好
技能收益
- 下沉为功能基础库,可以方便各个产品线使用,提高开发的效率。避免跟业务解耦合。使用场景有:音视频,直播,debug悬浮工具等……
悬浮窗库代码
02.Window概念
2.1 Window添加View
先看一个简单的案例。在主屏幕上添加一个TextView并展示,并且这个TextView独占一个窗口。
TextView mview = new TextView(context); WindowManager mWindowManager = (WindowManager) getSystemService(Context.WINDOW_SERVICE); WindowManager.LayoutParams wmParams = new WindowManager.LayoutParams(); wmParams.type = WindowManager.LayoutParams.TYPE_TOAST; wmParams.width = 800; wmParams.height = 800; mWindowManager.addView(mview, wmParams);
对Window添加View的流程步骤分析
- WindowManager.addView添加窗口之前,TextView的onDraw不会被调用,也就说View必须被添加到窗口中,才会被绘制。只有申请了依附窗口,View才会有可以绘制的目标内存。
- 当APP通过WindowManagerService的代理向其添加窗口的时候,WindowManagerService除了自己进行登记整理,还需要向SurfaceFlinger服务申请一块Surface画布,其实主要是画布背后所对应的一块内存,只有这一块内存申请成功之后,APP端才有绘图的目标,并且这块内存是APP端同SurfaceFlinger服务端共享的,这就省去了绘图资源的拷贝。
- APP端是可以通过unLockCanvasAndPost直接同SurfaceFlinger通信进行重绘的,就是说图形的绘制同WMS没有关系,WMS只是负责窗口的管理,并不负责窗口的绘制。
2.2 Window的概念
Window是个抽象类,PhoneWindow是Window唯一的实现类。PhoneWindow像是一个工具箱,封装了三种工具:
- DecorView、WindowManager.LayoutParams、WindowManager。
- 其中DecorView和WindowManager.LayoutParams负责窗口的静态属性,比如窗口的标题、背景、输入法模式、屏幕方向等等。WindowManager负责窗口的动态操作,比如窗口的增、删、改。
- Window抽象类对WindowManager.LayoutParams相关的属性(如:输入法模式、屏幕方向)都提供了具体的方法。而对DecorView相关的属性(如:标题、背景),只提供了抽象方法,这些抽象方法由PhoneWindow实现。
Window并不是真实地存在着的,而是以View的形式存在。
- Window本身就只是一个抽象的概念,而View是Window的表现形式。要想显示窗口,就必须调用WindowManager.addView(View view, ViewGroup.LayoutParams params)。
- 参数view就代表着一个窗口。在Activity和Dialog的显示过程中都会调用到wm.addView(decor, l);所以Activity和Dialog的DecorView就代表着各自的窗口。
2.3 WindowManager
在了解WindowManager管理View实现之前,先了解下WindowManager相关类图以及Activity界面各层级显示关系;
2.4 LayoutParams
WindowManager.LayoutParams这个类用于提供悬浮窗所需的参数,其中有几个经常会用到的变量:
- type值用于确定悬浮窗的类型,一般设为2002,表示在所有应用程序之上,但在状态栏之下。
- flags值用于确定悬浮窗的行为,比如说不可聚焦,非模态对话框等等,属性非常多,大家可以查看文档。
- gravity值用于确定悬浮窗的对齐方式,一般设为左上角对齐,这样当拖动悬浮窗的时候方便计算坐标。
- x值用于确定悬浮窗的位置,如果要横向移动悬浮窗,就需要改变这个值。
- y值用于确定悬浮窗的位置,如果要纵向移动悬浮窗,就需要改变这个值。
- width值用于指定悬浮窗的宽度。
- height值用于指定悬浮窗的高度。
那么这个里面如何计算悬浮窗上下左右的位置呢?比如有个场景悬浮窗和音视频页面放大和缩小就需要拿到悬浮窗位置
- 普通View如何拿到上下左右位置,可以采用sourceView.getGlobalVisibleRect(visibleRect),简单来说就是对目标view在父view映射,然后从屏幕左上角开始计算,然后保存到rect中。
- 悬浮窗View如何拿到上下左右位置,left = layoutParams.x;top = y,right = x + layoutParams.width;bottom = y + layoutParams.height
03.悬浮窗技术要点
3.1 业务思考点分析
针对窗口缩小或者悬浮窗需要考虑几个重要的点:
- 悬浮窗体的比例以及层级,层级要在statusBar之下且在activity之上,这样才能保证其不会被其他业务界面覆盖;
- 悬浮框显示后,内部的内容如何无缝衔接继续显示;
3.2 关键技术要点
悬浮窗权限判断
- 这个需要注意针对不同的版本需要适配权限。注意网上说有什么方法可以绕过权限申请,这个是不可能的事情。同时要注意,部分手机判断悬浮窗权限Api可能失效……
将view添加到悬浮窗上
- 利用addView将View添加在window上,同样的,WindowManager.LayoutParams.type可以设置View的层级,防止被其他业务界面所覆盖。
3.3 应用悬浮窗
应用内悬浮窗实现流程
- 1.获取WindowManager;2.创建悬浮View;3.设置悬浮View的拖拽事件;4.添加View到WindowManager中
对于应用悬浮窗来说,Android版本对其影响不大。
- Type为TYPE_APPLICATION:只要Activity建立了,就可以添加。
- Type为TYPE_APPLICATION_ATTACHED_DIALOG:需要在Activity获取焦点,并且用户可操作时才可添加。
3.4 添加浮窗源码流程
悬浮窗添加流程:
- -> WindowManager.addView 这个是调用ViewManager接口的addView方法添加视图
- -> WindowManagerImpl.addView 接着会调用具体实现类
- -> WindowManagerGlobal.addView 在这个方法中会找到核心的ViewRootImpl,这个Impl相当于是root根
- -> ViewRootImpl.setView 最后会调用setView将view设置出来,mWindowSession在创建ViewRootImpl对象时被实例化
- -> WindowSession.addToDisplay(AIDL进行IPC)
- -> WindowManagerService.addWindow()
- -> ViewRootImpl.setView
从 WindowManager 到 WMS 的具体流程如下所示:
这里讲解一下AIDL交互的流程逻辑
- 主要分析是ViewRootImpl#setView()到WindowManagerService.addWindow()的这个过程,涉及到跨进程通信。
- 1.ViewRootImpl#setView()过程。mWindowSession是IWindowSession对象。在创建ViewRootImpl对象时被实例化。
- 2.WindowManagerGlobal#getWindowSession()过程。getWindowManagerService()通过AIDL返回WindowManagerService实例。之后调用WindowManagerService#openSession()。
- 3.WindowManagerService#openSession()过程。返回一个Session对象。也就是说在ViewRootImpl#setView()中调用的是mWindowSession.addToDisplay,其实就是Session#addToDisplay()。
- 4.Session#addToDisplay()过程。mService是个WindowManagerService对象,也就是说最后调用的是WindowManagerService#addWindow()
- 5.WindowManagerService#addWindow()过程。mWindowMap是个Map实例,将WindowManager添加进WindowManagerService统一管理。至此,整个添加视图操作解析完毕。
WindowManager.updateViewLayout()解析
- 和addView()过程一样,最终会进入到WindowManagerGlobal#updateViewLayout()。将传入的View设置参数之后,更新mRoot中View的参数。
WindowManager.removeView()解析
- 和上面过程一样,最终会进入到WindowManagerGlobal#removeView()。这个过程要稍微麻烦点,首先调用root.die(),接着将View添加进mDyingViews。
- ViewRootImpl#die()中,参数immediate默认为false,也就是说这里只是发送了一个what=MSG_DIE的空消息。ViewRootHandler收到这条消息会执行doDie()。
- 经过一圈效验最终还是回到WindowManagerGlobal中移除View
3.6 拖拽回弹吸附
先看微信效果
- 当你拖动微信悬浮窗的时候,手指松开,这个时候悬浮窗回到边缘,会有一个很友好的动画过渡效果。而并非是改变位置那么生硬。
为何做该功能
- 拖拽回到边缘,如果是直接调用updateLocation,那太生硬了。
如何做友好动画
- 这里可以添加属性动画,给动画设置时间,然后在动画执行获取坐标值。然后再更改位置,这样就比较连贯,效果更好一些。
04.开发重要步骤
4.1 悬浮窗实现流程
应用内悬浮窗实现流程
- 第一个是获取WindowManager,然后设置相关params参数。注意配置参数的时候需要注意type
- 第二个是添加xml或者自定义view到windowManager上
- 第三个是处理拖拽更改view位置的监听逻辑,分别在down,move,up三个事件处理业务
- 第四个是吸附左边或者右边,大概的思路是判断手指抬起时候的点是在屏幕左边还是右边
4.2 请求悬浮窗权限
关于悬浮窗的权限
- 当API<18时,系统默认是有悬浮窗的权限,不需要去处理;
- 当API >= 23时,需要在AndroidManifest中申请权限,为了防止用户手动在设置中取消权限,需要在每次使用时check一下是否有悬浮窗权限存在;
Settings.canDrawOverlays(this)
- 当API > 25时,系统直接禁止用户使用TYPE_TOAST创建悬浮窗。
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
4.3 初始化悬浮窗
第一步:首先创建WindowManager
//创建WindowManager windowManager = (WindowManager)applicationContext.getSystemService(Context.WINDOW_SERVICE); layoutParams = new WindowManager.LayoutParams();
4.4 设置悬浮窗参数
第一步:创建LayoutParams
layoutParams = new WindowManager.LayoutParams();
第二步:LayoutParam设置
wmParams.type = WindowManager.LayoutParams.TYPE_TOAST; wmParams.width = 800; wmParams.height = 800; mWindowManager.addView(mview, wmParams);
4.5 添加View到悬浮窗
界面触发悬浮窗代码如下:
// 新建悬浮窗控件 View view = LayoutInflater.from(this).inflate(R.layout.float_window, null); view.setOnTouchListener(new FloatingOnTouchListener()); // 将悬浮窗控件添加到WindowManager windowManager.addView(view, layoutParams);
- 需要注意的是,在隐藏悬浮窗的时候,最好是移除一下,下次需要显示的时候再添加。
4.6 悬浮窗拖拽实现
如何实现悬浮窗可随手指拖动?
- 思路非常简单,监听悬浮窗那个onTouchListener即可,在刚点击的ACTION_DOWN(手指按下)事件中记录当前的x,y位置,然后在每次移动(ACTION_MOVE事件)后获取到本次移动的位置,二者相减就是需要移动的位置,这是自定义view的最基本操作了。
如何实现悬浮窗左右边的吸顶效果?
- 监听到手指抬起(UP事件)的动作后,判断当前位置是靠近左边还是右边,靠近左边就以位置动画的方式平移到左边,靠近右边就平移到右边。
4.8 悬浮窗权限适配
权限配置和请求,这一块倒是没什么坑
- 在当Android7.0以上的时候,需要在AndroidManifest.xml文件中声明SYSTEM_ALERT_WINDOW权限
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" /> <uses-permission android:name="android.permission.SYSTEM_OVERLAY_WINDOW" />
4.9 LayoutParam坑
LayoutParam的坑!!!!
- WindowManager的addView方法有两个参数,一个是需要加入的控件对象View,另一个参数是WindowManager.LayoutParam对象。
- LayoutParam里的type变量。需要注意一个坑!!!!!!这个变量是用来指定窗口类型的。在设置这个变量时,需要对不同版本的Android系统进行适配。
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { layoutParams.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY; } else { layoutParams.type = WindowManager.LayoutParams.TYPE_PHONE; }
在Android 8.0之前,悬浮窗口设置可以为TYPE_PHONE,这种类型是用于提供用户交互操作的非应用窗口。
- 而Android 8.0对系统和API行为做了修改,包括使用SYSTEM_ALERT_WINDOW权限的应用无法再使用一下窗口类型来在其他应用和窗口上方显示提醒窗口:
- 如果需要实现在其他应用和窗口上方显示提醒窗口,那么必须该为TYPE_APPLICATION_OVERLAY的新类型。
如果在Android 8.0以上版本仍然使用TYPE_PHONE类型的悬浮窗口,则会出现如下异常信息:
android.view.WindowManager$BadTokenException: Unable to add window android.view.ViewRootImpl$W@f8ec928 -- permission denied for window type 2002
05.方案基础设计
5.1 整体架构图
5.2 UML设计图
悬浮窗整体UML类图
06.其他设计说明
6.1 性能设计
性能设计在该库中主要涉及两点
- 第一个如果是用在activity中,那么则需要注意内存泄漏的问题,需要释放activity上下文的引用
- 第二个如果是用在全局,那么需要注意添加view避免重复添加(如果已经添加则首先要移除),然后销毁的时候把FloatWindow各种属性设置成null清理
6.2 稳定性设计
如何避免窗口移动,移动后松手的瞬间触发了点击事件
- 首先设置一个布尔标记值(触摸移动标记),在手指按下去(ACTION_DOWN)的时候设置为false。
- 然后在移动(ACTION_MOVE)的时候,如果用户移动了手指,那么就拦截本次触摸事件,从而不让点击事件生效。
- 最后在手指抬起(ACTION_UP,ACTION_CANCEL)的时候,返回记录的触摸移动标记。如果是true表示自己消费事件,则不会让点击事件生效。
这个地方需要注意两点
- 第一点:为何要监听ACTION_CANCEL事件,是因为手指拖动,快速拖动到窗口外,这个时候没有手指抬起操作,那么监听事件结束主要是增强边界逻辑。
- 第二点:怎么判断滑动?因为点击click也会执行down,move,up等一连串事件。这个时候就要判断最小move距离是否大于系统最小触摸距离,如果是则为拖动,否则是点击!
如何解决滑出指定距离又滑入当作是点击事件bug
- 这个这个,可以当作一种增强逻辑,但是但是手指操作不出来,先放着……
6.3 异常设计
针对悬浮窗的添加,移除和更新操作需要增加catch操作。那么为何要这样操作,模仿吐司。如下所示:
try { mWindowManager.addView(mDecorView, mWindowParams); } catch (NullPointerException | IllegalStateException | IllegalArgumentException | WindowManager.BadTokenException e) { // 如果这个 View 对象被重复添加到 WindowManager 则会抛出异常 // java.lang.IllegalStateException: View has already been added to the window manager. } //下面这个是更新view try { mWindowManager.updateViewLayout(mDecorView, mWindowParams); } catch (IllegalArgumentException e) { // 当 WindowManager 已经消失时调用会发生崩溃 // IllegalArgumentException: View not attached to window manager }
- 参考系统级别的Toast,其实悬浮窗跟吐司一样,设置系统层级后,对addView增加catch操作。
try { mWM.addView(mView, mParams); } catch (WindowManager.BadTokenException e) { /* ignore */ }
6.4 事件上报设计
在悬浮窗中,有一部分代码添加上了catch操作。那么能否把这一部分的异常当作事件上报到APM上来
- 第一种方案:依赖APM,然后调用api进行事件上报,显然这种是不可行的。因为该功能库是不想依赖太大的外部库。
- 第二种方案:采用接口+实现类,通过反射的形式去调用。但这样又感觉不太好,采用Class.forName要避免混淆导致类找不到。
- 第三种方案:采用抽象类+实现类,将实现类的对象设置到抽象类中调用,实现类在壳工程做具体操作。
具体实现步骤如下所示
- 举一个简单的例子说明该思路,比如,我在悬浮窗依赖接口层,然后调用代码如下所示
ExceptionReporter.reportCrash("Float FloatWindow updateViewLayout", e);
然后,在app壳工程中具体操作如下所示
ExceptionReporter.setExceptionReporter(ExceptionHelperImpl()) public class ExceptionReporterImpl extends ExceptionReporter { @Override protected void reportCrash(Throwable throwable) { //壳工程中可以拿到APM,比如上传到bugly平台上 } @Override protected void reportCrash(String tag, Throwable throwable) { } }
07.遇到的问题和坑
7.1 处理输入法层级关系
先看一下问题
- 微信里的悬浮窗是在输入法之下的,所以交互的同学也要求悬浮窗也要在输入法之下。查看了一下WindowManager源码,悬浮窗的优先级TYPE_APPLICATION_OVERLAY,上面大字写着明明是在输入法之下的,但是实际表现是在输入法之上。
7.2 边界逻辑关闭悬浮窗
先看一下问题
- 谷歌坑人的地方,都没地方设置这个悬浮窗是否只用到app内,所以默认在桌面上也会显示自己的悬浮窗。
- 比如在微信里显示其他app的悬浮窗,这种糟糕的体验可想而知,用户不给你卸载就真是奇迹了。
尝试解决这个问题
- 为了解决这个问题,最初的实现方式是对所有经过的activity进行记录,显示就加1,页面被挂起就减1,如果减到当前计数为0时说明所有页面已经关闭了,就可以隐藏悬浮窗了。
- 实际上这么做还是有问题的,在部分手机上如果是在首页按返回键的话仍然不能隐藏,这个又是系统级的兼容性问题。
- 为了解决这问题,后面又做了一个处理,通过注册registerActivityLifecycleCallbacks监听app的前后台回调,检测到如果当前首页被销毁时,应该将悬浮窗进行隐藏。
7.3 点击多次打开页面
问题说明一下
- 如果你的悬浮窗点击事件是打开页面的话,这里需要注意了,别忘了将这个打开的页面的启动模式设置为singleTop或者是singleTask,从而复用同一个,远离一直按返回的地狱操作。
7.4 Home键遇到的问题
先说一下遇到问题的场景
- 按home退到桌面从桌面点击应用图标又从启动页重新启动的,挺奇怪的。点击home键按道理说是不会推出MainActivity的呀
先说下代码逻辑
- 语音/视频通话界面activity 配置 android:launchMode=“singleInstance” 模式,切换到悬浮框调用 moveTaskToBack(true)方法,能启动小窗口,通话页面退到后台。
调试中发现的问题
- 通话界面按home键,之前的activity销毁了,日志发现走了onDestroy,重新点击app图标,MainActivity相关页面重新onCreate(相当于重新启动app了)。
- 因为通话页面是singleInstance模式,此时有两个任务栈,按Home键后再从任务程序中切回,此时应用只保留了第二个任务栈,已经失去了和第一个任务栈的关系,finish之后无法在回到第一个任务栈。
该问题解决方案
- 给通话界面设置taskAffinity,如果不设置的话,按下home键时系统会清理最近不活动的和application相同的taskAffinity的所有处于后台的栈,taskAffinity默认与application是同一个。
- 给通话页面设置taskAffinity之后,MainActivity所在后台栈就不会被清理。需要注意:若想在taskAffinity属性生效,需要在启动该Activity时设置Flag为FLAG_ACTIVITY_NEW_TASK。