一個超級簡單的 Android 懸浮窗功能實現

上次分享過一篇Android懸浮窗的文,但是後面還是感覺那篇講的不是非常詳細,這次逛CSDN的時候,看見了一篇講“Android 懸浮窗功能”的文,感覺受益匪淺,特意分享給大家。(文比較長,希望大家能堅持看完,末尾有彩蛋。)

(原文作者:csdn 黃林晴)

前言

我們大多數在兩種情況下可以看到懸浮窗,一個是視頻通話時的懸浮窗,另一個是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之後,就再次回到了第一個任務棧。

如果有其他好的解決方案 歡迎留言。

如果需要Java版本的小夥伴 ,留言郵箱就可以了,我看到會發到郵箱哦!

總結

Java版本源碼存至github:https://github.com/huanglinqing123/RemoteView

Android學習是一條漫長的道路,我們要學習的東西不僅僅只有表面的 技術,還要深入底層,弄明白下面的 原理,只有這樣,我們才能夠提高自己的競爭力,在當今這個競爭激烈的世界裏立足。

常言:種一棵樹最好的時間有兩個,一個是十年前,另一個就是現在。

千里之行始於足下,願你我共勉。

如果你覺得本文對你有幫助,記得贊下本文。博主定期更新,一起學習進步!

另外推薦一個做Android最新技術學習視頻的B站up主,B站【Android開發駱駝】希望對大家的學習和工作有幫助。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章