Android 懸浮窗 System Alert Window

AnRFDev發表於2022-01-06

懸浮窗能顯示在其他應用上方。桌面系統例如Windows,macOS,Ubuntu,開啟的程式能以視窗形式顯示在螢幕上。
受限於螢幕大小,安卓系統中主要使用多工切換的方式和分屏的方式。視訊播放,視訊對話可能會採用懸浮窗功能(例如手Q,微信的視訊通話)。應用留下一個視訊(通話)視窗,使用者可以返回安卓桌面,或者去其他app的介面操作。
前面我們探討了懸浮activity的實現方式,並結合CameraX預覽來實現應用內攝像頭預覽懸浮Activity。這些是在app內實現的懸浮activity效果。

本文我們用一個例子來展示Android懸浮窗的實現方法和注意事項。

本文例子啟用了dataBinding

    dataBinding {
        enabled = true
    }

SYSTEM_ALERT_WINDOW許可權

manifest裡申明許可權SYSTEM_ALERT_WINDOW

<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />

請求了這個許可權後,app的許可權管理中會有「顯示懸浮窗」的許可權選項。後面我們會引導使用者去開啟這個許可權。

標題中“System Alert Window”即SYSTEM_ALERT_WINDOW

懸浮窗的介面

準備layout檔案floating_window_1.xml,它作為懸浮窗的介面。

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/f_root"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="#00897B"
    android:gravity="center_horizontal"
    android:orientation="vertical"
    android:padding="4dp">

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:gravity="center_horizontal"
        android:text="懸浮窗\n an.rustfisher.com "
        android:textColor="#FFFFFF"
        android:textSize="16sp" />

    <TextView
        android:id="@+id/f_btn1"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="20dp"
        android:background="#00897B"
        android:text="開啟控制頁面"
        android:textColor="#FFFFFF" />

    <View
        android:layout_width="100dp"
        android:layout_height="1dp"
        android:layout_margin="16dp"
        android:background="#eaeaea" />

    <TextView
        android:id="@+id/exit_btn"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:background="#00897B"
        android:text="關閉懸浮窗服務"
        android:textColor="#FFFFFF" />
</LinearLayout>

懸浮窗service

新建一個Service FloatingWindowService。它來執行建立/銷燬懸浮窗的操作。
完整程式碼如下。

// package com.rustfisher.tutorial2020.service.floating;
import android.app.Service;
import android.content.Intent;
import android.graphics.PixelFormat;
import android.os.Build;
import android.os.IBinder;
import android.util.DisplayMetrics;
import android.util.Log;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowManager;

import androidx.annotation.Nullable;

import com.rustfisher.tutorial2020.R;

/**
 * 懸浮窗的服務
 *
 * @author an.rustfisher.com
 * @date 2022-01-05 23:53
 */
public class FloatingWindowService extends Service {
    private static final String TAG = "rfDevFloatingService";

    private WindowManager windowManager;
    private View floatView;

    @Nullable
    @Override
    public IBinder onBind(Intent intent) {
        return null;
    }

    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        Log.d(TAG, "onStartCommand , " + startId);
        if (floatView == null) {
            Log.d(TAG, "onStartCommand: 建立懸浮窗");
            initUi();
        }
        return super.onStartCommand(intent, flags, startId);
    }

    @Override
    public void onDestroy() {
        Log.d(TAG, "onDestroy");
        super.onDestroy();
    }

    private void initUi() {
        DisplayMetrics metrics = getApplicationContext().getResources().getDisplayMetrics();
        int width = metrics.widthPixels;
        int height = metrics.heightPixels;

        windowManager = (WindowManager) getSystemService(WINDOW_SERVICE);
        LayoutInflater inflater = (LayoutInflater) getBaseContext().getSystemService(LAYOUT_INFLATER_SERVICE);
        floatView = (ViewGroup) inflater.inflate(R.layout.floating_window_1, null);

        int layoutType;
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            layoutType = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY;
        } else {
            layoutType = WindowManager.LayoutParams.TYPE_TOAST;
        }

        WindowManager.LayoutParams floatLp = new WindowManager.LayoutParams(
                (int) (width * (0.4f)),
                (int) (height * (0.3f)),
                layoutType,
                WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
                PixelFormat.TRANSLUCENT
        );

        floatLp.gravity = Gravity.CENTER;
        floatLp.x = 0;
        floatLp.y = 0;

        windowManager.addView(floatView, floatLp);

        floatView.findViewById(R.id.f_btn1).setOnClickListener(v -> {
            stopSelf();
            windowManager.removeView(floatView);
            Intent backToHome = new Intent(getApplicationContext(), FloatingCmdAct.class);
            backToHome.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
            startActivity(backToHome);
        });
        floatView.findViewById(R.id.exit_btn).setOnClickListener(v -> {
            stopSelf();
            windowManager.removeView(floatView);
        });

        floatView.setOnTouchListener(new View.OnTouchListener() {
            final WindowManager.LayoutParams floatWindowLayoutUpdateParam = floatLp;
            double x;
            double y;
            double px;
            double py;

            @Override
            public boolean onTouch(View v, MotionEvent event) {
                switch (event.getAction()) {
                    case MotionEvent.ACTION_DOWN:
                        x = floatWindowLayoutUpdateParam.x;
                        y = floatWindowLayoutUpdateParam.y;
                        px = event.getRawX();
                        py = event.getRawY();
                        break;
                    case MotionEvent.ACTION_MOVE:
                        floatWindowLayoutUpdateParam.x = (int) ((x + event.getRawX()) - px);
                        floatWindowLayoutUpdateParam.y = (int) ((y + event.getRawY()) - py);
                        windowManager.updateViewLayout(floatView, floatWindowLayoutUpdateParam);
                        break;
                }
                return false;
            }
        });
    }
}

onStartCommand方法中我們可以知道,啟動這個服務時,如果沒有懸浮窗floatView,則去建立一個。

WindowManager提供了與視窗管理器(window manager)溝通的介面。
顯示View到視窗(螢幕)上,用的是WindowManager提供的方法addView。移除呼叫removeView

layout型別需要指定

  • WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
  • WindowManager.LayoutParams.TYPE_TOAST

LayoutInflater用來建立floatView

floatViewOnTouchListener,用來執行拖動操作。

回到控制activity時,需要flag:Intent.FLAG_ACTIVITY_NEW_TASK,否則報錯AndroidRuntimeException

android.util.AndroidRuntimeException: 
    Calling startActivity() from outside of an Activity  context requires the FLAG_ACTIVITY_NEW_TASK flag.

一個小問題:Service的生命週期方法執行在主執行緒(UI執行緒)上嗎?

Service相關概念請參考Service綜述

activity

提供一個啟動服務的地方。

layout

<?xml version="1.0" encoding="utf-8"?>
<layout>

    <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:gravity="center_horizontal"
            android:orientation="vertical">

            <Button
                android:id="@+id/setting_window_btn"
                style="@style/NormalBtn"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:layout_marginTop="4dp"
                android:text="懸浮窗許可權" />

            <Button
                android:id="@+id/start_btn"
                style="@style/NormalBtn"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:layout_marginTop="4dp"
                android:text="啟動服務" />

            <Button
                android:id="@+id/end_btn"
                style="@style/NormalBtn"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:layout_marginTop="4dp"
                android:text="停止服務" />

        </LinearLayout>

    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

控制

為了方便從懸浮窗出發跳回這個activity,啟動模式設定為singleTop

<activity
    android:name=".service.floating.FloatingCmdAct"
    android:launchMode="singleTop" />

可根據具體需求選擇啟動模式。

以下是FloatingCmdAct的完整程式碼

// package com.rustfisher.tutorial2020.service.floating;
import android.content.Intent;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.provider.Settings;
import android.util.Log;
import android.widget.Toast;

import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.databinding.DataBindingUtil;

import com.rustfisher.tutorial2020.R;
import com.rustfisher.tutorial2020.databinding.ActFloatingCmdBinding;

/**
 * 啟動服務前的介面
 *
 * @author an.rustfisher.com
 * @date 2022-01-05 14:57
 */
public class FloatingCmdAct extends AppCompatActivity {
    private static final String TAG = "rfDevFloatingCmd";

    private ActFloatingCmdBinding mBinding;

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        mBinding = DataBindingUtil.setContentView(this, R.layout.act_floating_cmd);

        mBinding.settingWindowBtn.setOnClickListener(v -> {
            if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
                startActivity(new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:" + getPackageName())));
            } else {
                Toast.makeText(getApplicationContext(), "API < " + android.os.Build.VERSION_CODES.M, Toast.LENGTH_SHORT).show();
            }
        });
        mBinding.startBtn.setOnClickListener(v -> {
            startService(new Intent(getApplicationContext(), FloatingWindowService.class));
            Toast.makeText(getApplicationContext(), "啟動服務", Toast.LENGTH_SHORT).show();
        });
        mBinding.endBtn.setOnClickListener(v -> {
            stopService(new Intent(getApplicationContext(), FloatingWindowService.class));
            Toast.makeText(getApplicationContext(), "停止服務", Toast.LENGTH_SHORT).show();
        });

        if (!checkOverlayDisplayPermission()) {
            Toast.makeText(getApplicationContext(), "請允許應用顯示懸浮窗", Toast.LENGTH_SHORT).show();
        }
    }

    @Override
    protected void onNewIntent(Intent intent) {
        super.onNewIntent(intent);
        Log.d(TAG, "onNewIntent: 回來了");
    }

    private boolean checkOverlayDisplayPermission() {
        // API23以後需要檢查許可權
        if (Build.VERSION.SDK_INT > Build.VERSION_CODES.M) {
            return Settings.canDrawOverlays(this);
        } else {
            return true;
        }
    }
}

API23以後,需要檢查是否允許顯示懸浮窗。如果不允許則彈一個toast。
跳轉去顯示懸浮窗許可權介面,用Settings.ACTION_MANAGE_OVERLAY_PERMISSION

Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:" + getPackageName()))

程式碼請參考工程 https://gitee.com/rustfisher/AndroidTutorial

執行效果

紅米9A(MIUI 12.5.1 Android 10)

紅米9A

小結

本文實現了一個簡單的懸浮窗功能。有了SYSTEM_ALERT_WINDOW許可權後,懸浮窗能顯示在其他app上方。
新增view到視窗上,主要使用android.view.WindowManager的功能。

參考

相關文章