Android 实现悬浮窗功能
前言
我们大多数在两种情况下可以看到悬浮窗,一个是视频通话时的悬浮窗,另一个是360卫士的悬浮球,实现此功能的方式比较多,这里以视频通话悬浮窗中的需求为例。编码实现使用kotlin。java版本留言邮箱即可。
业务场景
以微信视频通话为例,在视频通话时,我们打开其他应用或点击home键退出时或点击缩放图标,悬浮窗会显示在其他应用之上,给人的假象是通话页面变小了,点击悬浮窗回到通过页面,悬浮窗消失。退出通话页面悬浮窗消失。
业务场景技术分析
在编码之前,我们必须将流程整理好,这样更有利于编码的实现。实现一个功能如果需要10分钟,思考的时间是7分钟,编码占用的时间只是三分钟。
1.悬浮窗可以显示在其他应用或launchers之上,这个肯定需要悬浮窗权限,而悬浮窗权限属于特殊权限,所以只能通过引导用户去打开无法像危险权限那样直接申请。可以做到后台显示则说明悬浮窗是一个service。
2.通话页面隐藏时悬浮窗显示,通话页面显示时悬浮窗隐藏,可以看出悬浮窗和activity的生命周期相关联,所以悬浮窗的service和通话页面的activity是通过bind去绑定的。
3.既然service和activity是通过bind去绑定的,说明当悬浮窗显示的时候,通话activity虽然不可见但仍在运行。
结合上述技术问题分析,我们倒叙一一通过编码实现
悬浮窗实现方案
实现效果
准备工作
首先我们新建一个项目,项目中有两个activity,我们在第二个activity编写通话模拟页面。在第二个页面的原因我们后面会讲到。
如何将acitivity置于后台
其实很简单,我们调用一个方法即可
movetasktoback(true);
这个方法的含义就是将当前的任务战置于后台,so,为什么我要在第二个activity中实现的原因之一,因为默认的activity的启动模式是标准模式,而上面方法会将任务栈置于后台而不是一个单独的activity,所以我们为了显示悬浮窗时不影响操作软件的其他功能,我们要将通话页面的activity设置为singleinstance,这样当调用上面方法的时候只是将通话页面所在的activity栈置于后台,如果你还不了解启动模式可以移步至上一篇文章:activity的启动模式。
我们现在在右上方的点击事件中添加上述代码,可以看到通话页面的activity的已经在后台运行了。
判断是否有悬浮窗权限
点击左上角图标时,我们要先判断当前app是否有悬浮窗权限,首先我们在配置文件中添加,悬浮窗的权限。
<uses-permission android:name="android.permission.system_alert_window" />
(很多文章标题都是悬浮窗如何绕过权限,什么设置类型为toast或者phone,我想说不可能的事,toast类型的虽然部分机型可以显示但是就是一个普通的tosat会自动消失)
那么我们如何判断是否有悬浮窗权限呢,这一块不同厂商处理方案可能不一样,这里我们用一种通用的处理方案,测试表明除了(vivo部分)无效,其他多数机型都ok。并且vivo部分机型微信通话也不会弹出提示(这我就放心了~)
fun zoom(v: view) { if (build.version.sdk_int >= build.version_codes.m) { if (!settings.candrawoverlays(this)) { toast.maketext(this, "当前无权限,请授权", toast.length_short) globaldialogsingle(this, "", "当前未获取悬浮窗权限", "去开启", dialoginterface.onclicklistener { dialog, which -> dialog.dismiss() startactivityforresult(intent(settings.action_manage_overlay_permission, uri.parse("package:" + packagename)), 0) }).show() } else { movetasktoback(true) val intent = intent(this@main2activity, floatwinfowservices::class.java) hasbind = bindservice(intent, mvideoserviceconnection, context.bind_auto_create) } } }
我们通过settings.candrawoverlays(this)
来判断当前应用是否有悬浮窗权限,如果没有,我们弹窗提示,通过
startactivityforresult(intent(settings.action_manage_overlay_permission, uri.parse("package:" + packagename)), 0)
跳转到开启悬浮窗权限页面。如果悬浮窗权限已开启,直接将当前任务栈置于后台,开启服务即可。
其实回调方法,并没有直接告诉我们是否授权成功,所以我们需要在回调中再次判断
override fun onactivityresult(requestcode: int, resultcode: int, data: intent) { if (requestcode == 0) { if (build.version.sdk_int >= build.version_codes.m) { if (!settings.candrawoverlays(this)) { toast.maketext(this, "授权失败", toast.length_short).show() } else { handler().postdelayed({ val intent = intent(this@main2activity, floatwinfowservices::class.java) intent.putextra("rangetime", rangetime) hasbind = bindservice(intent, mvideoserviceconnection, context.bind_auto_create) movetasktoback(true) }, 1000) } } } }
这里我们可以看到回调中延迟了1秒,因为测试发现某些机型反应“过快”,收到回调的时候还以为没有授权成功,其实已经成功了。
绑定service我们需要一个serviceconnection对象
internal var mvideoserviceconnection: serviceconnection = object : serviceconnection { override fun onserviceconnected(name: componentname, service: ibinder) { // 获取服务的操作对象 val binder = service as floatwinfowservices.mybinder binder.service } override fun onservicedisconnected(name: componentname) {} }
main2activity的完整代码如下所示:
/** * @author huanglinqing */ class main2activity : appcompatactivity() { private val chronometer: chronometer? = null private var hasbind = false private val rangetime: long = 0 override fun oncreate(savedinstancestate: bundle?) { super.oncreate(savedinstancestate) setcontentview(r.layout.activity_main2) } fun zoom(v: view) { if (build.version.sdk_int >= build.version_codes.m) { if (!settings.candrawoverlays(this)) { toast.maketext(this, "当前无权限,请授权", toast.length_short) globaldialogsingle(this, "", "当前未获取悬浮窗权限", "去开启", dialoginterface.onclicklistener { dialog, which -> dialog.dismiss() startactivityforresult(intent(settings.action_manage_overlay_permission, uri.parse("package:" + packagename)), 0) }).show() } else { movetasktoback(true) val intent = intent(this@main2activity, floatwinfowservices::class.java) hasbind = bindservice(intent, mvideoserviceconnection, context.bind_auto_create) } } } internal var mvideoserviceconnection: serviceconnection = object : serviceconnection { override fun onserviceconnected(name: componentname, service: ibinder) { // 获取服务的操作对象 val binder = service as floatwinfowservices.mybinder binder.service } override fun onservicedisconnected(name: componentname) {} } override fun onactivityresult(requestcode: int, resultcode: int, data: intent) { if (requestcode == 0) { if (build.version.sdk_int >= build.version_codes.m) { if (!settings.candrawoverlays(this)) { toast.maketext(this, "授权失败", toast.length_short).show() } else { handler().postdelayed({ val intent = intent(this@main2activity, floatwinfowservices::class.java) intent.putextra("rangetime", rangetime) hasbind = bindservice(intent, mvideoserviceconnection, context.bind_auto_create) movetasktoback(true) }, 1000) } } } } override fun onrestart() { super.onrestart() log.d("remoteview", "重新显示了") //不显示悬浮框 if (hasbind) { unbindservice(mvideoserviceconnection) hasbind = false } } override fun onnewintent(intent: intent) { super.onnewintent(intent) } override fun ondestroy() { super.ondestroy() } }
新建悬浮窗service
新建悬浮窗service floatwinfowservices,因为我们使用的bindservice,我们在onbind方法中初始化service中的布局
override fun onbind(intent: intent): ibinder? { initwindow() //悬浮框点击事件的处理 initfloating() return mybinder() }
service中我们通过windowmanager来添加一个布局显示。
/** * 初始化窗口 */ private fun initwindow() { winmanager = application.getsystemservice(context.window_service) as windowmanager //设置好悬浮窗的参数 wmparams = params // 悬浮窗默认显示以左上角为起始坐标 wmparams!!.gravity = gravity.left or gravity.top //悬浮窗的开始位置,因为设置的是从左上角开始,所以屏幕左上角是x=0;y=0 wmparams!!.x = winmanager!!.defaultdisplay.width wmparams!!.y = 210 //得到容器,通过这个inflater来获得悬浮窗控件 inflater = layoutinflater.from(applicationcontext) // 获取浮动窗口视图所在布局 mfloatinglayout = inflater!!.inflate(r.layout.remoteview, null) // 添加悬浮窗的视图 winmanager!!.addview(mfloatinglayout, wmparams) }
悬浮窗的参数主要设置悬浮窗的类型为
windowmanager.layoutparams.type_application_overlay
8.0 以下可设置为:
wmparams!!.type = windowmanager.layoutparams.type_phone
代码如下所示:
private //设置window type 下面变量2002是在屏幕区域显示,2003则可以显示在状态栏之上 //设置可以显示在状态栏上 //设置悬浮窗口长宽数据 val params: windowmanager.layoutparams get() { wmparams = windowmanager.layoutparams() if (build.version.sdk_int >= build.version_codes.o) { wmparams!!.type = windowmanager.layoutparams.type_application_overlay } else { wmparams!!.type = windowmanager.layoutparams.type_phone } wmparams!!.flags = windowmanager.layoutparams.flag_not_focusable or windowmanager.layoutparams.flag_not_touch_modal or windowmanager.layoutparams.flag_layout_in_screen or windowmanager.layoutparams.flag_layout_inset_decor or windowmanager.layoutparams.flag_watch_outside_touch wmparams!!.width = windowmanager.layoutparams.wrap_content wmparams!!.height = windowmanager.layoutparams.wrap_content return wmparams }
当点击悬浮窗的时候回到activity2页面,并且悬浮窗消失,所以我们只需要给悬浮窗添加点击事件
linearlayout!!.setonclicklistener { startactivity(intent(this@floatwinfowservices, main2activity::class.java)) }
当service走到ondestory的时候将view移除,对于activity2页面来说 当onresume的时候 解绑service,当onstop的时候 绑定service。
从效果图中我们可以看到悬浮窗可以拖拽的,所以还要设置触摸事件,当移动距离超过某个值的时候让ontouch消费事件,这样就不会触发点击事件了。这个算是view比较基础的知识,相信大家都明白了。
//开始触控的坐标,移动时的坐标(相对于屏幕左上角的坐标) private var mtouchstartx: int = 0 private var mtouchstarty: int = 0 private var mtouchcurrentx: int = 0 private var mtouchcurrenty: int = 0 //开始时的坐标和结束时的坐标(相对于自身控件的坐标) private var mstartx: int = 0 private var mstarty: int = 0 private var mstopx: int = 0 private var mstopy: int = 0 //判断悬浮窗口是否移动,这里做个标记,防止移动后松手触发了点击事件 private var ismove: boolean = false private inner class floatinglistener : view.ontouchlistener { override fun ontouch(v: view, event: motionevent): boolean { val action = event.action when (action) { motionevent.action_down -> { ismove = false mtouchstartx = event.rawx.toint() mtouchstarty = event.rawy.toint() mstartx = event.x.toint() mstarty = event.y.toint() } motionevent.action_move -> { mtouchcurrentx = event.rawx.toint() mtouchcurrenty = event.rawy.toint() wmparams!!.x += mtouchcurrentx - mtouchstartx wmparams!!.y += mtouchcurrenty - mtouchstarty winmanager!!.updateviewlayout(mfloatinglayout, wmparams) mtouchstartx = mtouchcurrentx mtouchstarty = mtouchcurrenty } motionevent.action_up -> { mstopx = event.x.toint() mstopy = event.y.toint() if (math.abs(mstartx - mstopx) >= 1 || math.abs(mstarty - mstopy) >= 1) { ismove = true } } else -> { } } //如果是移动事件不触发onclick事件,防止移动的时候一放手形成点击事件 return ismove } }
floatwinfowservices所有代码如下所示:
class floatwinfowservices : service() { private var winmanager: windowmanager? = null private var wmparams: windowmanager.layoutparams? = null private var inflater: layoutinflater? = null //浮动布局 private var mfloatinglayout: view? = null private var linearlayout: linearlayout? = null private var chronometer: chronometer? = null override fun onbind(intent: intent): ibinder? { initwindow() //悬浮框点击事件的处理 initfloating() return mybinder() } inner class mybinder : binder() { val service: floatwinfowservices get() = this@floatwinfowservices } override fun oncreate() { super.oncreate() } /** * 悬浮窗点击事件 */ private fun initfloating() { linearlayout = mfloatinglayout!!.findviewbyid<linearlayout>(r.id.line1) linearlayout!!.setonclicklistener { startactivity(intent(this@floatwinfowservices, main2activity::class.java)) } //悬浮框触摸事件,设置悬浮框可拖动 linearlayout!!.setontouchlistener(floatinglistener()) } //开始触控的坐标,移动时的坐标(相对于屏幕左上角的坐标) private var mtouchstartx: int = 0 private var mtouchstarty: int = 0 private var mtouchcurrentx: int = 0 private var mtouchcurrenty: int = 0 //开始时的坐标和结束时的坐标(相对于自身控件的坐标) private var mstartx: int = 0 private var mstarty: int = 0 private var mstopx: int = 0 private var mstopy: int = 0 //判断悬浮窗口是否移动,这里做个标记,防止移动后松手触发了点击事件 private var ismove: boolean = false private inner class floatinglistener : view.ontouchlistener { override fun ontouch(v: view, event: motionevent): boolean { val action = event.action when (action) { motionevent.action_down -> { ismove = false mtouchstartx = event.rawx.toint() mtouchstarty = event.rawy.toint() mstartx = event.x.toint() mstarty = event.y.toint() } motionevent.action_move -> { mtouchcurrentx = event.rawx.toint() mtouchcurrenty = event.rawy.toint() wmparams!!.x += mtouchcurrentx - mtouchstartx wmparams!!.y += mtouchcurrenty - mtouchstarty winmanager!!.updateviewlayout(mfloatinglayout, wmparams) mtouchstartx = mtouchcurrentx mtouchstarty = mtouchcurrenty } motionevent.action_up -> { mstopx = event.x.toint() mstopy = event.y.toint() if (math.abs(mstartx - mstopx) >= 1 || math.abs(mstarty - mstopy) >= 1) { ismove = true } } else -> { } } //如果是移动事件不触发onclick事件,防止移动的时候一放手形成点击事件 return ismove } } /** * 初始化窗口 */ private fun initwindow() { winmanager = application.getsystemservice(context.window_service) as windowmanager //设置好悬浮窗的参数 wmparams = params // 悬浮窗默认显示以左上角为起始坐标 wmparams!!.gravity = gravity.left or gravity.top //悬浮窗的开始位置,因为设置的是从左上角开始,所以屏幕左上角是x=0;y=0 wmparams!!.x = winmanager!!.defaultdisplay.width wmparams!!.y = 210 //得到容器,通过这个inflater来获得悬浮窗控件 inflater = layoutinflater.from(applicationcontext) // 获取浮动窗口视图所在布局 mfloatinglayout = inflater!!.inflate(r.layout.remoteview, null) chronometer = mfloatinglayout!!.findviewbyid<chronometer>(r.id.chronometer) chronometer!!.start() // 添加悬浮窗的视图 winmanager!!.addview(mfloatinglayout, wmparams) } private //设置window type 下面变量2002是在屏幕区域显示,2003则可以显示在状态栏之上 //设置可以显示在状态栏上 //设置悬浮窗口长宽数据 val params: windowmanager.layoutparams get() { wmparams = windowmanager.layoutparams() if (build.version.sdk_int >= build.version_codes.o) { wmparams!!.type = windowmanager.layoutparams.type_application_overlay } else { wmparams!!.type = windowmanager.layoutparams.type_phone } wmparams!!.flags = windowmanager.layoutparams.flag_not_focusable or windowmanager.layoutparams.flag_not_touch_modal or windowmanager.layoutparams.flag_layout_in_screen or windowmanager.layoutparams.flag_layout_inset_decor or windowmanager.layoutparams.flag_watch_outside_touch wmparams!!.width = windowmanager.layoutparams.wrap_content wmparams!!.height = windowmanager.layoutparams.wrap_content return wmparams } override fun onstartcommand(intent: intent, flags: int, startid: int): int { return super.onstartcommand(intent, flags, startid) } override fun ondestroy() { super.ondestroy() winmanager!!.removeview(mfloatinglayout) } }
实际应用中需要考虑的一些其他问题
在使用使用的过程中,我们肯定会遇到其他问题:
1.用户使用过程中,可能会直接按home键,这个时候如何提示呢?
产生问题原因:因为用户按home键之后,开发者无法重写home键逻辑,此时应用不在前台运行,无法弹窗提醒,此时用户点击app图标进入的是第一个栈,这个时候用户就没有进入通话页面的入口了。
解决方案:
第一种解决方案 我们可以仿照微信那样去做,就是在整个通话过程中开启一个前台通知,用户点击通知时进入通话页面。
第二种解决方案 就是检测应用是否在前台,当通话页面在运行的时候,并且应用重新回到前台,我们广播到其他页面,提示权限引导即可。
2.用户在通话页面(singleinstance模式),点击home键
应用在后台运行的时候,通话结束,activity被finish,此时从任务程序中切回应用你会发现打开的竟然是通话页面!
这个问题简单的说就是,如果你在通话页面呼叫某人,通话过程中按home键,然后电话挂断,此时你从任务程序中切回应用,会再次呼叫这个人,也就是这种状态下重新回到了oncreate方法。
问题产生原因:
1.因为通话页面是singleinstance模式,此时有两个任务栈,按home键后再从任务程序中切回,此时应用只保留了第二个任务栈,已经失去了和第一个任务栈的关系,finish之后无法在回到第一个任务栈。
解决方案:
1.(不推荐)通话页面不使用singleinstance模式,这种情况下,在通话过程中无法操作软件的其他功能,一般都不采取。
2.(我目前的解决方案)设置一个标记位,标记当前是否在通话,在oncreate中如果通话已经结束了,跳转到一个过渡页面(标准模式),过渡页面中finish,就可以了,添加过渡页面的原因是我们不知道上一个页面是哪里,因为我们收到来电可能是任意页面,我们我们在过渡页面finsh之后,就再次回到了第一个任务栈。
总结
以上所述是小编给大家介绍的android 实现悬浮窗功能,希望对大家有所帮助