Android 攝像頭預覽懸浮窗,可拖動,可顯示在其他app上方

AnRFDev發表於2022-01-07

市面上常見的攝像頭懸浮窗,如微信、手機QQ的視訊通話功能,有如下特點:

  • 整屏頁面能切換到一個小的懸浮窗
  • 懸浮窗能執行在其他app上方
  • 懸浮窗能跳回整屏頁面,並且懸浮窗消失

我們探討過用CameraX開啟攝像頭預覽,結合可改變大小和浮動的activity,實現了應用內攝像頭預覽懸浮Activity。這個懸浮Activity是在應用內使用的。要讓懸浮窗在其他app上,需要結合懸浮窗 System Alert Window

本文用CameraX實現攝像頭預覽懸浮窗,能顯示在其他app上方,可拖動,可跳回activity。

這個例子的相關程式碼放進了單獨的模組。使用時注意gradle裡的細微差別。

引入依賴

模組gradle的一些配置,使用的Android SDK版本為31,啟用databinding

plugins {
    id 'com.android.library'
    id 'kotlin-android'
    id 'kotlin-android-extensions'
    id 'kotlin-kapt'
}

android {
    compileSdk 31

    defaultConfig {
        minSdk 21
        targetSdk 31
        versionCode 1
        versionName "1.0"

        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
        consumerProguardFiles "consumer-rules.pro"
    }
    dataBinding {
        enabled = true
    }
    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }
}

dependencies {
    implementation 'androidx.appcompat:appcompat:1.4.0'
    implementation 'com.google.android.material:material:1.4.0'
    implementation project(path: ':baselib')
    testImplementation 'junit:junit:4.+'
    androidTestImplementation 'androidx.test.ext:junit:1.1.3'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'

    implementation "androidx.camera:camera-core:1.1.0-alpha11"
    implementation "androidx.camera:camera-camera2:1.1.0-alpha11"
    implementation "androidx.camera:camera-lifecycle:1.1.0-alpha11"
    implementation "androidx.camera:camera-view:1.0.0-alpha31"
    implementation "androidx.camera:camera-extensions:1.0.0-alpha31"
}

引入CameraX依賴(CameraX 核心庫是用camera2實現的),目前主要用1.1.0-alpha11版本

許可權

manifest中申請許可權

<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera.any" />

<!-- 懸浮窗的許可權 -->
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />

動態申請相機許可權 Manifest.permission.CAMERA

private static final int REQ_CAMERA = 2;

    if (PackageManager.PERMISSION_GRANTED != ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)) {
        ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.CAMERA}, REQ_CAMERA);
    }

後面需要MeFloatingCameraXActFloatingCameraXService配合使用。

manifest中註冊Activity和Service

<activity
    android:name=".camera.MeFloatingCameraXAct"
    android:exported="true"
    android:launchMode="singleTop" />

<service android:name=".camera.FloatingCameraXService" />

MeFloatingCameraXAct

這個activity提供一個簡單的攝像頭預覽介面,並且提供入口啟動懸浮窗。

layout me_act_floating_preivew_x.xml

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

    <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
            android:id="@+id/container"
            android:layout_width="match_parent"
            android:layout_height="match_parent">

            <androidx.camera.view.PreviewView
                android:id="@+id/previewView"
                android:layout_width="match_parent"
                android:layout_height="match_parent" />
        </FrameLayout>

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_alignParentBottom="true"
            android:gravity="center"
            android:orientation="vertical"
            android:padding="4dp">

            <LinearLayout
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:orientation="horizontal">

                <Button
                    android:id="@+id/start"
                    style="@style/NormalBtn"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:text="開啟攝像頭" />

                <Button
                    android:id="@+id/end"
                    style="@style/NormalBtn"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_marginStart="4dp"
                    android:text="停止攝像頭" />

                <Button
                    android:id="@+id/go_floating"
                    style="@style/NormalBtn"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_marginStart="4dp"
                    android:text="切換到懸浮窗" />

                <Button
                    android:id="@+id/close_act"
                    style="@style/NormalBtn"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_marginStart="4dp"
                    android:text="關閉" />

            </LinearLayout>

        </LinearLayout>

    </RelativeLayout>
</layout>

完整程式碼如下

// package com.rustfisher.mediasamples.camera;
import android.Manifest;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.os.Bundle;
import android.util.Log;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.camera.core.CameraSelector;
import androidx.camera.core.Preview;
import androidx.camera.lifecycle.ProcessCameraProvider;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
import androidx.databinding.DataBindingUtil;
import com.google.common.util.concurrent.ListenableFuture;
import com.rustfisher.mediasamples.R;
import com.rustfisher.mediasamples.databinding.MeActFloatingPreivewXBinding;

import java.util.concurrent.ExecutionException;


/**
 * @author an.rustfisher.com
 * @date 2022-1-06 23:53
 */
public class MeFloatingCameraXAct extends AppCompatActivity {
    private static final String TAG = "rfDevX";
    private static final int REQ_CAMERA = 2;
    public static final String K_START_CAMERA = "start_camera"; // 直接啟動攝像頭

    private MeActFloatingPreivewXBinding mBinding;
    private ListenableFuture<ProcessCameraProvider> mCameraProviderFuture;
    private ProcessCameraProvider mCameraProvider;
    private boolean mRunning = false;

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        mBinding = DataBindingUtil.setContentView(this, R.layout.me_act_floating_preivew_x);
        final boolean startNow = getIntent().getBooleanExtra(K_START_CAMERA, false);
        mCameraProviderFuture = ProcessCameraProvider.getInstance(this);
        mCameraProviderFuture.addListener(() -> {
            try {
                mCameraProvider = mCameraProviderFuture.get();
                Log.d(TAG, "獲取到了 cameraProvider");
                if (startNow) {
                    bindPreview(mCameraProvider);
                }
            } catch (ExecutionException | InterruptedException e) {
                // 這裡不用處理
            }
        }, ContextCompat.getMainExecutor(this));
        mBinding.start.setOnClickListener(v -> {
            if (mCameraProvider != null && !mRunning) {
                bindPreview(mCameraProvider);
            }
        });
        mBinding.end.setOnClickListener(v -> {
            mCameraProvider.unbindAll();
            mRunning = false;
        });
        mBinding.goFloating.setOnClickListener(v -> {
            startService(new Intent(getApplicationContext(), FloatingCameraXService.class));
            finish();
        });
        mBinding.closeAct.setOnClickListener(v -> {
            Toast.makeText(getApplicationContext(), "關閉攝像頭示例", Toast.LENGTH_SHORT).show();
            stopService(new Intent(getApplicationContext(), FloatingCameraXService.class));
            finish();
        });

        if (PackageManager.PERMISSION_GRANTED != ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)) {
            ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.CAMERA}, REQ_CAMERA);
        }
    }

    @Override
    public void onBackPressed() {
        Toast.makeText(getApplicationContext(), "請點選關閉按鈕", Toast.LENGTH_SHORT).show();
    }

    @Override
    public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults);
        if (requestCode == REQ_CAMERA) {
            if (grantResults[0] != PackageManager.PERMISSION_GRANTED) {
                Toast.makeText(getApplicationContext(), "請允許相機許可權", Toast.LENGTH_SHORT).show();
            }
        }
    }

    private void bindPreview(ProcessCameraProvider cameraProvider) {
        if (cameraProvider == null) {
            Toast.makeText(getApplicationContext(), "沒獲取到相機", Toast.LENGTH_SHORT).show();
            return;
        }
        Toast.makeText(getApplicationContext(), "相機啟動", Toast.LENGTH_SHORT).show();
        Preview preview = new Preview.Builder().build();

        CameraSelector cameraSelector = new CameraSelector.Builder()
                .requireLensFacing(CameraSelector.LENS_FACING_BACK)
                .build();

        preview.setSurfaceProvider(mBinding.previewView.getSurfaceProvider());

        cameraProvider.bindToLifecycle(this, cameraSelector, preview);
        mRunning = true;
    }
}

CameraX啟動預覽的程式碼可以參考 https://an.rustfisher.com/android/jetpack/camerax/simple-preview/

懸浮窗

layout

先給懸浮窗準備一個layout

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="#000">

    <androidx.camera.view.PreviewView
        android:id="@+id/preview_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

    <ImageView
        android:id="@+id/to_big"
        android:layout_width="40dp"
        android:layout_height="40dp"
        android:padding="4dp"
        android:src="@drawable/me_to_big"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

圖片請自備

FloatingCameraXService

FloatingCameraXService實現LifecycleOwner介面,為了方便CameraX繫結生命週期元件。

// package com.rustfisher.mediasamples.camera;
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 android.widget.Toast;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.camera.core.CameraSelector;
import androidx.camera.core.Preview;
import androidx.camera.lifecycle.ProcessCameraProvider;
import androidx.camera.view.PreviewView;
import androidx.core.content.ContextCompat;
import androidx.lifecycle.Lifecycle;
import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.LifecycleRegistry;

import com.google.common.util.concurrent.ListenableFuture;
import com.rustfisher.mediasamples.R;

import java.util.concurrent.ExecutionException;


/**
 * 攝像頭預覽懸浮窗的服務
 *
 * @author an.rustfisher.com
 * @date 2022-01-06 23:53
 */
public class FloatingCameraXService extends Service implements LifecycleOwner {
    private static final String TAG = "rfDevFloatingCameraX";

    private WindowManager mWM;
    private View mFloatView;
    private LifecycleRegistry mLifecycleRegistry;

    private ListenableFuture<ProcessCameraProvider> mCameraProviderFuture;
    private ProcessCameraProvider mCameraProvider;

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

    @Override
    public void onCreate() {
        super.onCreate();
        mLifecycleRegistry = new LifecycleRegistry(this);
        mLifecycleRegistry.setCurrentState(Lifecycle.State.CREATED);
    }

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

    @Override
    public void onDestroy() {
        Log.d(TAG, "onDestroy");
        if (mFloatView != null) {
            mWM.removeView(mFloatView);
        }
        mLifecycleRegistry.setCurrentState(Lifecycle.State.DESTROYED);
        super.onDestroy();
    }

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

        mWM = (WindowManager) getSystemService(WINDOW_SERVICE);
        LayoutInflater inflater = (LayoutInflater) getBaseContext().getSystemService(LAYOUT_INFLATER_SERVICE);
        mFloatView = inflater.inflate(R.layout.me_floating_camerax, 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;

        mFloatView.findViewById(R.id.to_big).setOnClickListener(v -> {
            stopSelf();
            Intent intent = new Intent(getApplicationContext(), MeFloatingCameraXAct.class);
            intent.putExtra(MeFloatingCameraXAct.K_START_CAMERA, true);
            intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
            startActivity(intent);
        });

        mWM.addView(mFloatView, floatLp);

        mFloatView.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);
                        mWM.updateViewLayout(mFloatView, floatWindowLayoutUpdateParam);
                        break;
                }
                return false;
            }
        });

        mCameraProviderFuture = ProcessCameraProvider.getInstance(this);
        mCameraProviderFuture.addListener(() -> {
            try {
                mCameraProvider = mCameraProviderFuture.get();
                Log.d(TAG, "[service]獲取到了cameraProvider");
                bindPreview(mCameraProvider, mFloatView.findViewById(R.id.preview_view));
            } catch (ExecutionException | InterruptedException e) {
                // 這裡不用處理
            }
        }, ContextCompat.getMainExecutor(this));
    }

    @NonNull
    @Override
    public Lifecycle getLifecycle() {
        return mLifecycleRegistry;
    }

    private void bindPreview(ProcessCameraProvider cameraProvider, PreviewView previewView) {
        if (cameraProvider == null) {
            Toast.makeText(getApplicationContext(), "沒獲取到相機", Toast.LENGTH_SHORT).show();
            return;
        }
        Toast.makeText(getApplicationContext(), "相機啟動", Toast.LENGTH_SHORT).show();
        Preview preview = new Preview.Builder().build();
        CameraSelector cameraSelector = new CameraSelector.Builder()
                .requireLensFacing(CameraSelector.LENS_FACING_BACK)
                .build();
        preview.setSurfaceProvider(previewView.getSurfaceProvider());
        cameraProvider.bindToLifecycle(this, cameraSelector, preview);
    }
}

這裡懸浮窗view建立完畢後,再去請求攝像頭和開啟預覽。

LifeCycle

生命週期請參考LifeCycle

WindowManager

建立懸浮窗view,並且新增到視窗中。主要使用WindowManager提供的方法。

建立WindowManager.LayoutParams的時候,指定寬高,根據API版本選擇layoutType

啟動預覽

啟動預覽部分bindPreview程式碼與前面的類似。用bindToLifecycle方法。

執行測試

執行到手機上,開啟這個Activity就可以看到攝像頭預覽。影像寬高比正常,沒有拉伸現象。
縮小成懸浮窗後,可以拖動。可從懸浮窗跳回Activity。

  • 榮耀 EMUI 3.1 Lite,Android 5.1 執行正常
  • Redmi 9A,MIUI 12.5.1穩定版,Android 10 執行正常

小結

結合懸浮窗功能與攝像頭得到的預覽懸浮窗,可以執行在其他app上方。
注意引導使用者開啟懸浮窗許可權和攝像頭許可權。

參考

相關文章