Android 副屏(Virtual Display)创建与悬浮窗画中画显示实战

Android 副屏(Virtual Display)创建与悬浮窗画中画显示实战

前言

在 Android 自动化、多任务场景中,我们经常需要将目标 App 启动到一个独立的虚拟屏幕上运行,不干扰用户主屏操作,同时在主屏以画中画悬浮窗的形式实时预览副屏画面,并支持用户通过触摸悬浮窗来操控副屏中的 App。

本文基于实际项目经验,完整讲解:

  1. 如何创建和管理 VirtualDisplay
  2. 如何通过 ImageReader 实时捕获副屏画面
  3. 如何以悬浮窗形式展示画中画预览
  4. 如何处理悬浮窗上的点击和滑动,并映射注入到副屏

一、整体架构

scss 复制代码
┌──────────────────────────────────────────────────────┐
│                  主屏 (Default Display)                │
│                                                      │
│  ┌────────────────────────────────────────────────┐  │
│  │  悬浮窗 (WindowManager.TYPE_APPLICATION_OVERLAY)│  │
│  │  ┌──────────────────────────────────────────┐  │  │
│  │  │  ImageView (FIT_CENTER 显示副屏截图)      │  │  │
│  │  │  ↕ OnTouchListener                       │  │  │
│  │  │  触摸坐标 → mapToVirtualDisplay() 映射    │  │  │
│  │  └──────────────────────────────────────────┘  │  │
│  └────────────────────────────────────────────────┘  │
│                         ▲ Bitmap                      │
│                         │                            │
│  ┌──────────────────────┴─────────────────────────┐  │
│  │  ImageReader (RGBA_8888, 实时帧捕获)            │  │
│  │         ↑ Surface                              │  │
│  │  ┌─────┴──────────────────────────────────┐    │  │
│  │  │  VirtualDisplay (副屏)                  │    │  │
│  │  │  - 目标 App 运行在此 Display 上          │    │  │
│  │  │  - input -d displayId tap/swipe 注入     │    │  │
│  │  └────────────────────────────────────────┘    │  │
│  └────────────────────────────────────────────────┘  │
└──────────────────────────────────────────────────────┘

核心数据流:

  • 渲染方向:App → VirtualDisplay → ImageReader.Surface → Bitmap → 悬浮窗 ImageView
  • 交互方向 :用户触摸悬浮窗 → 坐标映射 → input -d displayId tap/swipe → 副屏 App 响应

二、VirtualDisplay 管理器

2.1 单例设计

副屏在整个应用生命周期中只需要一个实例,使用 object 单例管理:

kotlin 复制代码
object VirtualDisplayManager {

    private const val TAG = "VirtualDisplayMgr"
    private const val DISPLAY_NAME = "SimpleAOA-Screen"

    // 通过系统属性动态配置副屏参数(方便不同设备适配)
    private const val PROP_VD_WIDTH = "persist.sys.bd.virtual_width"
    private const val PROP_VD_HEIGHT = "persist.sys.bd.virtual_height"
    private const val PROP_VD_DPI = "persist.sys.bd.virtual_dpi"

    private const val DEFAULT_WIDTH = 800
    private const val DEFAULT_HEIGHT = 1280
    private const val DEFAULT_DPI = 180

    val WIDTH: Int get() = getSystemPropertyInt(PROP_VD_WIDTH, DEFAULT_WIDTH)
    val HEIGHT: Int get() = getSystemPropertyInt(PROP_VD_HEIGHT, DEFAULT_HEIGHT)
    private val DENSITY_DPI: Int get() = getSystemPropertyInt(PROP_VD_DPI, DEFAULT_DPI)

    @Volatile var enabled: Boolean = true

    private var virtualDisplay: VirtualDisplay? = null
    private var imageReader: ImageReader? = null
    private var displayId: Int = Display.INVALID_DISPLAY
    private val inputExecutor = Executors.newSingleThreadExecutor()

    /** 记录通过副屏启动过的包名,关闭时统一 force-stop */
    private val launchedPackages: MutableSet<String> = Collections.synchronizedSet(mutableSetOf())
}

2.2 创建副屏

关键点:

  • 使用 ImageReader 作为 Surface 提供者,这样可以逐帧捕获画面用于预览
  • 标志位组合决定了副屏的能力(触摸支持、焦点管理等)
kotlin 复制代码
@Synchronized
fun ensureDisplay(context: Context): Int {
    if (!enabled) return Display.INVALID_DISPLAY
    if (virtualDisplay != null && displayId != Display.INVALID_DISPLAY) {
        return displayId  // 已存在,直接复用
    }
    return createDisplay(context)
}

private fun createDisplay(context: Context): Int {
    val w = WIDTH
    val h = HEIGHT
    val dpi = DENSITY_DPI

    // 1. 创建 ImageReader 作为渲染目标
    imageReader = ImageReader.newInstance(w, h, PixelFormat.RGBA_8888, 2)

    val dm = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager

    // 2. 组合标志位
    val flags = DisplayManager.VIRTUAL_DISPLAY_FLAG_PUBLIC or
        DisplayManager.VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY or
        DisplayManager.VIRTUAL_DISPLAY_FLAG_PRESENTATION or
        getOptionalFlag("VIRTUAL_DISPLAY_FLAG_TRUSTED") or       // 系统签名才有
        getOptionalFlag("VIRTUAL_DISPLAY_FLAG_SUPPORTS_TOUCH") or // 支持触摸
        getOptionalFlag("VIRTUAL_DISPLAY_FLAG_OWN_FOCUS")         // 独立焦点

    // 3. 创建虚拟显示器
    virtualDisplay = dm.createVirtualDisplay(
        DISPLAY_NAME, w, h, dpi,
        imageReader!!.surface, flags
    )

    displayId = virtualDisplay!!.display.displayId
    Log.i(TAG, "副屏创建成功: displayId=$displayId, ${w}x${h}@${dpi}dpi")
    return displayId
}

2.3 标志位详解

标志位 作用 是否需要系统签名
FLAG_PUBLIC 允许其他应用在此 Display 上显示
FLAG_OWN_CONTENT_ONLY 仅显示创建者的内容
FLAG_PRESENTATION 标记为演示用途
FLAG_TRUSTED 系统信任的显示器
FLAG_SUPPORTS_TOUCH 支持触摸输入注入
FLAG_OWN_FOCUS 副屏有独立焦点,不抢主屏焦点

注意:FLAG_TRUSTEDFLAG_SUPPORTS_TOUCHFLAG_OWN_FOCUS 是隐藏 API,需要系统签名权限。通过反射获取,不存在时返回 0 不影响编译。

kotlin 复制代码
private fun getOptionalFlag(name: String): Int {
    return try {
        DisplayManager::class.java.getField(name).getInt(null)
    } catch (_: Exception) { 0 }
}

2.4 帧捕获

通过 ImageReader 获取副屏当前画面的 JPEG 字节(用于 AI 分析等场景):

kotlin 复制代码
fun captureFrame(): ByteArray? {
    val reader = imageReader ?: return null
    val image = reader.acquireLatestImage() ?: return null
    return try {
        val plane = image.planes[0]
        val buffer = plane.buffer
        val rowStride = plane.rowStride
        val pixelStride = plane.pixelStride
        val rowPadding = rowStride - pixelStride * WIDTH

        val bitmap = Bitmap.createBitmap(
            WIDTH + rowPadding / pixelStride, HEIGHT,
            Bitmap.Config.ARGB_8888
        )
        bitmap.copyPixelsFromBuffer(buffer)

        // 裁掉行填充
        val cropped = if (rowPadding > 0) {
            Bitmap.createBitmap(bitmap, 0, 0, WIDTH, HEIGHT).also { bitmap.recycle() }
        } else bitmap

        val stream = ByteArrayOutputStream()
        cropped.compress(Bitmap.CompressFormat.JPEG, 50, stream)
        cropped.recycle()
        stream.toByteArray()
    } finally {
        image.close()
    }
}

三、向副屏注入点击和滑动

这是副屏交互的核心------通过 input 命令的 -d displayId 参数,将触摸事件注入到指定显示器。

3.1 注入点击

kotlin 复制代码
fun injectTap(x: Int, y: Int) {
    val id = getDisplayId() ?: return
    inputExecutor.execute {
        try {
            Runtime.getRuntime().exec(
                arrayOf("input", "-d", id.toString(), "tap", x.toString(), y.toString())
            ).waitFor(5, TimeUnit.SECONDS)
        } catch (e: Exception) {
            Log.w(TAG, "injectTap 失败: ${e.message}")
        }
    }
}

3.2 注入滑动

kotlin 复制代码
fun injectSwipe(x1: Int, y1: Int, x2: Int, y2: Int, durationMs: Long) {
    val id = getDisplayId() ?: return
    inputExecutor.execute {
        try {
            Runtime.getRuntime().exec(
                arrayOf("input", "-d", id.toString(), "swipe",
                    x1.toString(), y1.toString(),
                    x2.toString(), y2.toString(),
                    durationMs.toString())
            ).waitFor(5, TimeUnit.SECONDS)
        } catch (e: Exception) {
            Log.w(TAG, "injectSwipe 失败: ${e.message}")
        }
    }
}

3.3 input 命令说明

bash 复制代码
# 在指定 displayId 上点击坐标 (400, 640)
input -d 2 tap 400 640

# 在指定 displayId 上从 (400,800) 滑动到 (400,200),耗时 300ms
input -d 2 swipe 400 800 400 200 300

注意:input -d 需要 shell 权限或系统签名。普通第三方 App 无法使用,需要系统应用或 ADB 权限。


四、悬浮窗画中画预览

4.1 预览窗口类设计

kotlin 复制代码
class VirtualDisplayPreview(private val context: Context) {

    companion object {
        private const val TAP_SLOP_DP = 10f
        private const val PROP_PREVIEW_SCALE = "persist.sys.bd.virtual_preview_scale"
        private const val DEFAULT_SCALE = 1.2f
    }

    private val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
    private val mainHandler = Handler(Looper.getMainLooper())
    private var overlayView: View? = null
    private var imageView: ImageView? = null
    private var isShowing = false

    /** 触摸开关,自动化运行时设为 false 避免用户误触 */
    var touchEnabled = true

    // 触摸手势状态
    private var touchDownX = 0f
    private var touchDownY = 0f
    private var touchDownTime = 0L
    private val tapSlop by lazy {
        TAP_SLOP_DP * context.resources.displayMetrics.density
    }
}

4.2 显示悬浮窗

kotlin 复制代码
fun show() {
    if (isShowing) return
    val reader = VirtualDisplayManager.getImageReader() ?: return

    val view = LayoutInflater.from(context)
        .inflate(R.layout.view_preview_overlay, null, false)
    val iv = view.findViewById<ImageView>(R.id.iv_overlay_preview)
    val closeBtn = view.findViewById<ImageButton>(R.id.btn_overlay_close)

    // 清掉残留帧
    try { reader.acquireLatestImage()?.close() } catch (_: Exception) {}

    closeBtn.setOnClickListener {
        onCloseListener?.invoke()
        hide()
    }
    iv.setOnTouchListener { v, event -> handlePreviewTouch(v, event) }

    // 预览窗口尺寸 = 副屏尺寸 × 缩放比例
    val scale = VirtualDisplayManager.getSystemPropertyFloat(PROP_PREVIEW_SCALE, DEFAULT_SCALE)
    val overlayWidth = (VirtualDisplayManager.WIDTH * scale).toInt()
    val overlayHeight = (VirtualDisplayManager.HEIGHT * scale).toInt()

    val params = WindowManager.LayoutParams(
        overlayWidth, overlayHeight,
        WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
        WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
            or WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN,
        PixelFormat.TRANSLUCENT
    ).apply {
        gravity = Gravity.TOP or Gravity.END
        x = dp(22)
        y = dp(25)
    }

    windowManager.addView(view, params)
    overlayView = view
    imageView = iv
    isShowing = true

    // 注册帧监听,实时更新预览画面
    reader.setOnImageAvailableListener(imageListener, mainHandler)
}

4.3 实时帧更新

通过 ImageReader.OnImageAvailableListener 持续获取副屏新帧:

kotlin 复制代码
private val imageListener = ImageReader.OnImageAvailableListener { reader ->
    val image = reader.acquireLatestImage() ?: return@OnImageAvailableListener
    try {
        val plane = image.planes[0]
        val buffer = plane.buffer
        val width = VirtualDisplayManager.WIDTH
        val height = VirtualDisplayManager.HEIGHT
        val rowStride = plane.rowStride
        val pixelStride = plane.pixelStride
        val rowPadding = rowStride - pixelStride * width

        val bitmap = Bitmap.createBitmap(
            width + rowPadding / pixelStride, height, Bitmap.Config.ARGB_8888
        )
        bitmap.copyPixelsFromBuffer(buffer)

        val finalBitmap = if (rowPadding > 0) {
            Bitmap.createBitmap(bitmap, 0, 0, width, height).also { bitmap.recycle() }
        } else bitmap

        mainHandler.post {
            imageView?.setImageBitmap(finalBitmap) ?: finalBitmap.recycle()
        }
    } finally {
        image.close()
    }
}

4.4 隐藏时防止 Buffer 满

关键细节:隐藏预览后仍需消费 ImageReader 的帧,否则 buffer 满会导致副屏 App 停止渲染:

kotlin 复制代码
/** 隐藏时持续消费帧,防止 buffer 满 */
private val drainListener = ImageReader.OnImageAvailableListener { reader ->
    reader.acquireLatestImage()?.close()
}

fun hide() {
    if (!isShowing) return
    // 切换到 drain 模式,不再更新 UI 但持续消费帧
    VirtualDisplayManager.getImageReader()
        ?.setOnImageAvailableListener(drainListener, mainHandler)
    removeOverlay()
}

五、触摸坐标映射(核心难点)

5.1 问题

悬浮窗中的 ImageView 使用 FIT_CENTER 缩放模式显示副屏画面。用户触摸的是 ImageView 上的坐标,但需要转换为副屏的像素坐标才能正确注入。

需要处理:

  • ImageView 与副屏的宽高比可能不同(letterbox 黑边)
  • 缩放比例计算
  • 边界裁剪

5.2 映射算法

kotlin 复制代码
/**
 * 将预览 View 上的触摸坐标映射到虚拟屏像素坐标。
 * 考虑 FIT_CENTER 的 letterbox 偏移。
 */
private fun mapToVirtualDisplay(v: View, touchX: Float, touchY: Float): Pair<Int, Int>? {
    val vw = v.width.toFloat()   // ImageView 实际宽度
    val vh = v.height.toFloat()  // ImageView 实际高度
    if (vw <= 0 || vh <= 0) return null

    val vdW = VirtualDisplayManager.WIDTH.toFloat()   // 副屏宽度
    val vdH = VirtualDisplayManager.HEIGHT.toFloat()  // 副屏高度

    // FIT_CENTER: 取宽高中较小的缩放比
    val scale = min(vw / vdW, vh / vdH)

    // 实际渲染区域大小
    val renderedW = vdW * scale
    val renderedH = vdH * scale

    // letterbox 偏移(居中对齐产生的边距)
    val offsetX = (vw - renderedW) / 2f
    val offsetY = (vh - renderedH) / 2f

    // 触摸点相对于渲染内容的坐标
    val contentX = touchX - offsetX
    val contentY = touchY - offsetY

    // 超出渲染区域则忽略(点在黑边上)
    if (contentX < 0 || contentY < 0 || contentX > renderedW || contentY > renderedH) return null

    // 映射到副屏像素坐标
    val virtualX = (contentX / renderedW * vdW).toInt().coerceIn(0, vdW.toInt() - 1)
    val virtualY = (contentY / renderedH * vdH).toInt().coerceIn(0, vdH.toInt() - 1)
    return virtualX to virtualY
}

5.3 图解映射过程

scss 复制代码
ImageView (480 x 270)
┌──────────────────────────────────────────────┐
│ offsetX │                          │ offsetX │
│◄───────►│   渲染区域 (renderedW)    │◄───────►│
│         │  ┌────────────────────┐  │         │
│         │  │                    │  │         │
│         │  │  副屏画面内容       │  │         │
│         │  │  (FIT_CENTER)      │  │         │
│         │  │       ● 触摸点      │  │         │
│         │  │                    │  │         │
│         │  └────────────────────┘  │         │
└──────────────────────────────────────────────┘

触摸点 (touchX, touchY)
    ↓ 减去 offset
内容坐标 (contentX, contentY)
    ↓ 除以 rendered 尺寸,乘以副屏尺寸
副屏坐标 (virtualX, virtualY)
    ↓ input -d displayId tap virtualX virtualY
副屏 App 收到点击事件

六、触摸手势识别(点击 vs 滑动)

6.1 完整触摸处理

kotlin 复制代码
private fun handlePreviewTouch(v: View, event: MotionEvent): Boolean {
    if (!touchEnabled) return true  // 自动化期间吞掉事件

    when (event.actionMasked) {
        MotionEvent.ACTION_DOWN -> {
            touchDownX = event.x
            touchDownY = event.y
            touchDownTime = System.currentTimeMillis()
            return true
        }
        MotionEvent.ACTION_UP -> {
            val dx = event.x - touchDownX
            val dy = event.y - touchDownY
            val elapsed = System.currentTimeMillis() - touchDownTime

            // 映射起点和终点
            val startVd = mapToVirtualDisplay(v, touchDownX, touchDownY) ?: return false
            val endVd = mapToVirtualDisplay(v, event.x, event.y) ?: return false

            if (abs(dx) < tapSlop && abs(dy) < tapSlop) {
                // 移动距离小于阈值 → 判定为点击
                VirtualDisplayManager.injectTap(startVd.first, startVd.second)
            } else {
                // 移动距离大于阈值 → 判定为滑动
                val duration = elapsed.coerceIn(100, 2000)
                VirtualDisplayManager.injectSwipe(
                    startVd.first, startVd.second,
                    endVd.first, endVd.second,
                    duration
                )
            }
            return true
        }
        MotionEvent.ACTION_MOVE -> return true  // 消费但不处理
    }
    return false
}

6.2 手势判定逻辑

条件 判定结果 注入动作
移动距离 < 10dp 点击(Tap) input -d {id} tap x y
移动距离 >= 10dp 滑动(Swipe) input -d {id} swipe x1 y1 x2 y2 duration
touchEnabled = false 吞掉事件 无(自动化运行中)
触摸点在黑边区域 忽略

滑动时长取实际手指滑动耗时,限制在 100ms ~ 2000ms 之间,保证动作自然。


七、通过无障碍服务操控副屏(进阶)

除了 input 命令注入,还可以通过 AccessibilityService 的 dispatchGesture() 操控副屏:

7.1 手势注入

kotlin 复制代码
// 在 AccessibilityService 中
private fun dispatchTap(x: Float, y: Float) {
    val path = Path().apply { moveTo(x, y) }
    val stroke = GestureDescription.StrokeDescription(path, 0, 100)
    val gesture = GestureDescription.Builder().addStroke(stroke).build()
    dispatchGesture(gesture, null, null)
}

private fun dispatchSwipe(x1: Float, y1: Float, x2: Float, y2: Float, durationMs: Long) {
    val path = Path().apply {
        moveTo(x1, y1)
        lineTo(x2, y2)
    }
    val stroke = GestureDescription.StrokeDescription(path, 0, durationMs)
    val gesture = GestureDescription.Builder().addStroke(stroke).build()
    dispatchGesture(gesture, null, null)
}

7.2 获取副屏 UI 树(API 33+)

kotlin 复制代码
fun captureUiTree(targetDisplayId: Int): List<AccessibilityNodeInfo> {
    return if (Build.VERSION.SDK_INT >= 33) {
        // Android 13+ 支持获取所有 Display 的窗口
        getWindowsOnAllDisplays()
            .filterKeys { it == targetDisplayId }
            .values.flatten()
            .mapNotNull { it.root }
    } else {
        // 旧版本通过 getWindows() 过滤
        windows.filter { it.displayId == targetDisplayId }
            .mapNotNull { it.root }
    }
}

八、启动 App 到副屏

kotlin 复制代码
fun launchAppToVirtualDisplay(context: Context, packageName: String) {
    val displayId = VirtualDisplayManager.ensureDisplay(context)
    if (displayId == Display.INVALID_DISPLAY) return

    // 通过 am start 指定 display
    val cmd = "am start -n $packageName/.MainActivity --display $displayId"
    Runtime.getRuntime().exec(arrayOf("sh", "-c", cmd))

    // 记录包名,后续统一清理
    VirtualDisplayManager.trackLaunchedPackage(packageName)
}

或通过 Intent 方式(需要 ActivityOptions):

kotlin 复制代码
val options = ActivityOptions.makeBasic()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
    options.launchDisplayId = displayId
}
context.startActivity(intent, options.toBundle())

九、资源释放与生命周期

9.1 释放副屏

kotlin 复制代码
@Synchronized
fun release() {
    virtualDisplay?.release()
    virtualDisplay = null
    imageReader?.close()
    imageReader = null
    displayId = Display.INVALID_DISPLAY
}

9.2 关闭时清理已启动的 App

kotlin 复制代码
fun forceStopAllLaunched() {
    val packages = launchedPackages.toList()
    launchedPackages.clear()
    inputExecutor.execute {
        packages.forEach { pkg ->
            Runtime.getRuntime().exec(arrayOf("am", "force-stop", pkg))
                .waitFor(5, TimeUnit.SECONDS)
        }
    }
}

9.3 释放顺序

markdown 复制代码
1. 关闭预览悬浮窗 (VirtualDisplayPreview.hide())
2. force-stop 副屏上的 App
3. 释放 VirtualDisplay
4. 关闭 ImageReader

十、常见问题

Q1: 副屏画面黑屏/不更新?

  • 检查 ImageReader buffer 是否满了(maxImages=2 时,必须及时 acquireLatestImage().close()
  • 隐藏预览时要切换到 drainListener 持续消费帧

Q2: 触摸点击位置不准?

  • 确认 ImageView 的 scaleType 是 FIT_CENTER
  • 检查 mapToVirtualDisplay() 中 letterbox offset 计算是否正确
  • 注意副屏分辨率与预览窗口尺寸的比例关系

Q3: 创建 VirtualDisplay 返回 null?

  • 需要系统签名权限(FLAG_PUBLIC + FLAG_TRUSTED
  • 普通应用只能使用 MediaProjection.createVirtualDisplay()

Q4: 副屏 App 抢了主屏焦点?

  • 添加 VIRTUAL_DISPLAY_FLAG_OWN_FOCUS 标志(需系统签名)
  • 悬浮窗使用 FLAG_NOT_FOCUSABLE

Q5: 悬浮窗需要什么权限?

xml 复制代码
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />

运行时检查:

kotlin 复制代码
if (!Settings.canDrawOverlays(context)) {
    // 引导用户开启
}

十一、总结

模块 关键技术
副屏创建 DisplayManager.createVirtualDisplay() + ImageReader
画面捕获 ImageReader.OnImageAvailableListener 逐帧获取 Bitmap
悬浮窗显示 WindowManager + TYPE_APPLICATION_OVERLAY
触摸映射 FIT_CENTER letterbox 偏移计算 + 坐标比例换算
事件注入 input -d displayId tap/swipe(需 shell 权限)
手势识别 移动距离阈值区分 tap vs swipe
无障碍操控 AccessibilityService.dispatchGesture()
生命周期 drain 防黑屏 + force-stop 清理 + 有序释放

整套方案的精髓在于:VirtualDisplay + ImageReader 实现渲染捕获,悬浮窗 ImageView 实现画中画预览,坐标映射 + input 命令实现反向交互控制,形成完整的双向通信闭环。


附录:完整可运行代码

以下是可以直接复制到项目中使用的完整代码,包含所有必要的 import 和配置。

A. AndroidManifest.xml 配置

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

    <!-- 悬浮窗权限 -->
    <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
    <!-- 前台服务 -->
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />

    <application ...>

        <service
            android:name=".service.FloatingPreviewService"
            android:exported="false"
            android:foregroundServiceType="specialUse" />

    </application>
</manifest>

B. VirtualDisplayManager.kt(完整版)

kotlin 复制代码
package com.example.virtualdisplay

import android.content.Context
import android.graphics.Bitmap
import android.graphics.PixelFormat
import android.hardware.display.DisplayManager
import android.hardware.display.VirtualDisplay
import android.media.ImageReader
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.Display
import java.io.ByteArrayOutputStream
import java.util.Collections
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit

/**
 * 副屏(VirtualDisplay)管理单例。
 * 将目标 App 启动到独立虚拟 Display,不干扰主屏。
 *
 * 使用方式:
 *   val displayId = VirtualDisplayManager.ensureDisplay(context)
 *   // 通过 am start --display $displayId 启动 App
 *   // 通过 injectTap / injectSwipe 注入触摸
 *   // 通过 getImageReader() 获取帧用于预览
 */
object VirtualDisplayManager {

    private const val TAG = "VirtualDisplayMgr"
    private const val DISPLAY_NAME = "VD-Preview"

    // ============ 副屏参数(可通过系统属性动态配置) ============
    // 如果不需要系统属性,直接改成 const val 即可
    var WIDTH = 800
        private set
    var HEIGHT = 1280
        private set
    var DENSITY_DPI = 180
        private set

    /** 副屏功能总开关 */
    @Volatile
    var enabled: Boolean = true

    private var virtualDisplay: VirtualDisplay? = null
    private var imageReader: ImageReader? = null
    private var displayId: Int = Display.INVALID_DISPLAY

    private val mainHandler = Handler(Looper.getMainLooper())
    private val inputExecutor = Executors.newSingleThreadExecutor()

    /** 记录通过副屏启动过的包名,关闭时统一 force-stop */
    private val launchedPackages: MutableSet<String> = Collections.synchronizedSet(mutableSetOf())

    // ======================== 公开 API ========================

    /**
     * 配置副屏参数(在 ensureDisplay 之前调用)
     */
    fun configure(width: Int = 800, height: Int = 1280, dpi: Int = 180) {
        WIDTH = width
        HEIGHT = height
        DENSITY_DPI = dpi
    }

    /**
     * 确保副屏存在,返回 displayId。已创建则直接复用。
     * @return displayId,失败返回 Display.INVALID_DISPLAY
     */
    @Synchronized
    fun ensureDisplay(context: Context): Int {
        if (!enabled) {
            Log.d(TAG, "副屏功能已关闭")
            return Display.INVALID_DISPLAY
        }
        if (virtualDisplay != null && displayId != Display.INVALID_DISPLAY) {
            Log.d(TAG, "副屏已存在, displayId=$displayId")
            return displayId
        }
        return createDisplay(context)
    }

    /**
     * 获取当前副屏 displayId,未创建返回 null。
     */
    fun getDisplayId(): Int? =
        if (displayId != Display.INVALID_DISPLAY) displayId else null

    /**
     * 获取 ImageReader,用于画中画预览读取帧。
     */
    fun getImageReader(): ImageReader? = imageReader

    /**
     * 记录一个已启动到副屏的包名(用于后续统一清理)。
     */
    fun trackLaunchedPackage(pkg: String) {
        launchedPackages.add(pkg)
    }

    /**
     * 向副屏注入一次点击。坐标为副屏像素坐标。
     * 需要 shell 权限或系统签名。
     */
    fun injectTap(x: Int, y: Int) {
        val id = getDisplayId() ?: run {
            Log.w(TAG, "injectTap: 副屏未创建")
            return
        }
        inputExecutor.execute {
            try {
                val process = Runtime.getRuntime().exec(
                    arrayOf("input", "-d", id.toString(), "tap", x.toString(), y.toString())
                )
                process.waitFor(5, TimeUnit.SECONDS)
                Log.d(TAG, "injectTap($x, $y) on display $id")
            } catch (e: Exception) {
                Log.w(TAG, "injectTap 失败: ${e.message}")
            }
        }
    }

    /**
     * 向副屏注入一次滑动。坐标为副屏像素坐标。
     * @param durationMs 滑动持续时间(毫秒),影响滑动速度
     */
    fun injectSwipe(x1: Int, y1: Int, x2: Int, y2: Int, durationMs: Long = 300) {
        val id = getDisplayId() ?: run {
            Log.w(TAG, "injectSwipe: 副屏未创建")
            return
        }
        inputExecutor.execute {
            try {
                val process = Runtime.getRuntime().exec(
                    arrayOf(
                        "input", "-d", id.toString(), "swipe",
                        x1.toString(), y1.toString(),
                        x2.toString(), y2.toString(),
                        durationMs.toString()
                    )
                )
                process.waitFor(5, TimeUnit.SECONDS)
                Log.d(TAG, "injectSwipe($x1,$y1 -> $x2,$y2, ${durationMs}ms) on display $id")
            } catch (e: Exception) {
                Log.w(TAG, "injectSwipe 失败: ${e.message}")
            }
        }
    }

    /**
     * 从 ImageReader 抓取当前帧,返回 JPEG 字节数组。
     * 可用于截图保存或发送给 AI 分析。
     */
    fun captureFrame(quality: Int = 50): ByteArray? {
        val reader = imageReader ?: return null
        val image = reader.acquireLatestImage() ?: return null
        return try {
            val plane = image.planes[0]
            val buffer = plane.buffer
            val rowStride = plane.rowStride
            val pixelStride = plane.pixelStride
            val rowPadding = rowStride - pixelStride * WIDTH

            val bitmap = Bitmap.createBitmap(
                WIDTH + rowPadding / pixelStride, HEIGHT,
                Bitmap.Config.ARGB_8888
            )
            bitmap.copyPixelsFromBuffer(buffer)

            val cropped = if (rowPadding > 0) {
                Bitmap.createBitmap(bitmap, 0, 0, WIDTH, HEIGHT).also { bitmap.recycle() }
            } else {
                bitmap
            }

            val stream = ByteArrayOutputStream()
            cropped.compress(Bitmap.CompressFormat.JPEG, quality, stream)
            cropped.recycle()
            stream.toByteArray()
        } catch (e: Exception) {
            Log.e(TAG, "captureFrame 失败", e)
            null
        } finally {
            image.close()
        }
    }

    /**
     * force-stop 所有已记录的副屏包名并清空列表。
     */
    fun forceStopAllLaunched() {
        val packages = launchedPackages.toList()
        launchedPackages.clear()
        if (packages.isEmpty()) return
        inputExecutor.execute {
            packages.forEach { pkg ->
                try {
                    Runtime.getRuntime().exec(arrayOf("am", "force-stop", pkg))
                        .waitFor(5, TimeUnit.SECONDS)
                    Log.i(TAG, "已关闭副屏应用: $pkg")
                } catch (e: Exception) {
                    Log.w(TAG, "forceStopApp 失败 ($pkg): ${e.message}")
                }
            }
        }
    }

    /**
     * 释放副屏所有资源。
     */
    @Synchronized
    fun release() {
        forceStopAllLaunched()
        virtualDisplay?.release()
        virtualDisplay = null
        imageReader?.close()
        imageReader = null
        displayId = Display.INVALID_DISPLAY
        Log.i(TAG, "副屏已释放")
    }

    // ======================== 内部实现 ========================

    private fun createDisplay(context: Context): Int {
        val w = WIDTH
        val h = HEIGHT
        val dpi = DENSITY_DPI
        try {
            // 1. 创建 ImageReader(buffer 数量为 2,够用且不浪费内存)
            imageReader = ImageReader.newInstance(w, h, PixelFormat.RGBA_8888, 2)

            val dm = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager

            // 2. 组合标志位(隐藏 flag 通过反射获取,不存在则为 0)
            val flags = DisplayManager.VIRTUAL_DISPLAY_FLAG_PUBLIC or
                DisplayManager.VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY or
                DisplayManager.VIRTUAL_DISPLAY_FLAG_PRESENTATION or
                getOptionalFlag("VIRTUAL_DISPLAY_FLAG_TRUSTED") or
                getOptionalFlag("VIRTUAL_DISPLAY_FLAG_SUPPORTS_TOUCH") or
                getOptionalFlag("VIRTUAL_DISPLAY_FLAG_OWN_FOCUS")

            // 3. 创建虚拟显示器
            virtualDisplay = dm.createVirtualDisplay(
                DISPLAY_NAME, w, h, dpi,
                imageReader!!.surface, flags
            )

            if (virtualDisplay == null) {
                Log.e(TAG, "createVirtualDisplay 返回 null,可能缺少系统签名权限")
                imageReader?.close()
                imageReader = null
                return Display.INVALID_DISPLAY
            }

            displayId = virtualDisplay!!.display.displayId
            Log.i(TAG, "副屏创建成功: displayId=$displayId, ${w}x${h}@${dpi}dpi, flags=0x${flags.toString(16)}")
            return displayId
        } catch (e: SecurityException) {
            Log.e(TAG, "创建副屏失败,需要系统签名权限: ${e.message}")
            return Display.INVALID_DISPLAY
        } catch (e: Exception) {
            Log.e(TAG, "创建副屏失败: ${e.message}", e)
            return Display.INVALID_DISPLAY
        }
    }

    /**
     * 反射获取 DisplayManager 中的隐藏 flag 常量值。
     * 不存在时返回 0,不影响其他 flag 的 or 运算。
     */
    private fun getOptionalFlag(name: String): Int {
        return try {
            DisplayManager::class.java.getField(name).getInt(null)
        } catch (_: Exception) {
            0
        }
    }
}

C. VirtualDisplayPreview.kt(完整版 --- 悬浮窗画中画 + 触摸交互)

kotlin 复制代码
package com.example.virtualdisplay

import android.content.Context
import android.graphics.Bitmap
import android.graphics.PixelFormat
import android.media.ImageReader
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.Gravity
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.WindowManager
import android.widget.ImageButton
import android.widget.ImageView
import kotlin.math.abs
import kotlin.math.min

/**
 * 副屏画中画预览悬浮窗。
 *
 * 功能:
 * 1. 在主屏右上角显示一个悬浮窗,实时显示副屏画面
 * 2. 支持触摸交互:点击/滑动会自动映射坐标并注入到副屏
 * 3. 支持关闭按钮和触摸开关(自动化运行时禁用触摸)
 *
 * 使用方式:
 *   val preview = VirtualDisplayPreview(context)
 *   preview.onCloseListener = { /* 关闭副屏逻辑 */ }
 *   preview.show()
 *   // ...
 *   preview.hide()
 */
class VirtualDisplayPreview(private val context: Context) {

    companion object {
        private const val TAG = "VDPreview"

        /** 判定点击 vs 滑动的距离阈值(dp) */
        private const val TAP_SLOP_DP = 10f

        /** 预览窗口相对副屏的缩放比例(1.0 = 与副屏同尺寸) */
        private const val DEFAULT_SCALE = 0.3f

        /** 预览窗口距顶部距离(dp) */
        private const val MARGIN_TOP_DP = 25

        /** 预览窗口距右侧距离(dp) */
        private const val MARGIN_RIGHT_DP = 16
    }

    private val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
    private val mainHandler = Handler(Looper.getMainLooper())
    private var overlayView: View? = null
    private var imageView: ImageView? = null
    private var closeButton: ImageButton? = null
    private var isShowing = false

    /** 触摸开关。自动化运行时设为 false,避免用户误触干扰自动化流程 */
    var touchEnabled = true

    /** 关闭按钮点击回调(在 hide 之前调用) */
    var onCloseListener: (() -> Unit)? = null

    // ============ 触摸手势状态 ============
    private var touchDownX = 0f
    private var touchDownY = 0f
    private var touchDownTime = 0L
    private val tapSlop by lazy {
        TAP_SLOP_DP * context.resources.displayMetrics.density
    }

    // ============ ImageReader 帧监听 ============

    /**
     * 正常显示时的帧监听:将每一帧转为 Bitmap 更新到 ImageView
     */
    private val imageListener = ImageReader.OnImageAvailableListener { reader ->
        val image = reader.acquireLatestImage() ?: return@OnImageAvailableListener
        try {
            val plane = image.planes[0]
            val buffer = plane.buffer
            val width = VirtualDisplayManager.WIDTH
            val height = VirtualDisplayManager.HEIGHT
            val rowStride = plane.rowStride
            val pixelStride = plane.pixelStride
            val rowPadding = rowStride - pixelStride * width

            val bitmap = Bitmap.createBitmap(
                width + rowPadding / pixelStride, height,
                Bitmap.Config.ARGB_8888
            )
            bitmap.copyPixelsFromBuffer(buffer)

            val finalBitmap = if (rowPadding > 0) {
                Bitmap.createBitmap(bitmap, 0, 0, width, height).also { bitmap.recycle() }
            } else {
                bitmap
            }

            mainHandler.post {
                val iv = imageView
                if (iv != null) {
                    iv.setImageBitmap(finalBitmap)
                } else {
                    finalBitmap.recycle()
                }
            }
        } catch (e: Exception) {
            Log.w(TAG, "帧处理失败: ${e.message}")
        } finally {
            image.close()
        }
    }

    /**
     * 隐藏时的帧监听:仅消费帧防止 buffer 满。
     * 如果不消费,ImageReader buffer 满后副屏 App 会停止渲染(ANR 或黑屏)。
     */
    private val drainListener = ImageReader.OnImageAvailableListener { reader ->
        reader.acquireLatestImage()?.close()
    }

    // ======================== 公开 API ========================

    /**
     * 显示画中画预览悬浮窗
     */
    fun show() {
        if (isShowing) return
        val reader = VirtualDisplayManager.getImageReader()
        if (reader == null) {
            Log.w(TAG, "副屏未创建,无法显示预览")
            return
        }

        // 1. 加载布局
        val view = LayoutInflater.from(context)
            .inflate(R.layout.view_preview_overlay, null, false)
        val iv = view.findViewById<ImageView>(R.id.iv_overlay_preview)
        val closeBtn = view.findViewById<ImageButton>(R.id.btn_overlay_close)

        // 2. 清掉残留帧(防止显示上次的旧画面)
        try { reader.acquireLatestImage()?.close() } catch (_: Exception) {}

        // 3. 设置交互
        closeBtn.setOnClickListener {
            onCloseListener?.invoke()
            hide()
        }
        iv.setOnTouchListener { v, event -> handlePreviewTouch(v, event) }

        // 4. 计算预览窗口尺寸
        val overlayWidth = (VirtualDisplayManager.WIDTH * DEFAULT_SCALE).toInt()
        val overlayHeight = (VirtualDisplayManager.HEIGHT * DEFAULT_SCALE).toInt()

        // 5. 配置悬浮窗参数
        val windowType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
        } else {
            @Suppress("DEPRECATION")
            WindowManager.LayoutParams.TYPE_PHONE
        }

        val params = WindowManager.LayoutParams(
            overlayWidth, overlayHeight,
            windowType,
            WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
                or WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN,
            PixelFormat.TRANSLUCENT
        ).apply {
            gravity = Gravity.TOP or Gravity.END
            x = dp(MARGIN_RIGHT_DP)
            y = dp(MARGIN_TOP_DP)
        }

        // 6. 添加到窗口
        try {
            windowManager.addView(view, params)
            overlayView = view
            imageView = iv
            closeButton = closeBtn
            isShowing = true

            // 7. 开始接收帧
            reader.setOnImageAvailableListener(imageListener, mainHandler)
            Log.i(TAG, "画中画预览已显示 (${overlayWidth}x${overlayHeight})")
        } catch (e: Exception) {
            Log.e(TAG, "显示预览失败: ${e.message}")
        }
    }

    /**
     * 关闭预览悬浮窗。切换到 drain 模式持续消费帧。
     */
    fun hide() {
        if (!isShowing) return
        VirtualDisplayManager.getImageReader()
            ?.setOnImageAvailableListener(drainListener, mainHandler)
        removeOverlay()
        Log.i(TAG, "画中画预览已关闭")
    }

    /**
     * 控制关闭按钮可见性(自动化运行时可隐藏)
     */
    fun setCloseButtonVisible(visible: Boolean) {
        mainHandler.post {
            closeButton?.visibility = if (visible) View.VISIBLE else View.GONE
        }
    }

    fun isShowing(): Boolean = isShowing

    // ======================== 触摸处理 ========================

    /**
     * 处理预览窗口上的触摸事件。
     * - ACTION_DOWN: 记录起始坐标和时间
     * - ACTION_UP: 根据移动距离判定为点击或滑动,映射坐标后注入副屏
     * - ACTION_MOVE: 消费但不处理(防止事件穿透)
     */
    private fun handlePreviewTouch(v: View, event: MotionEvent): Boolean {
        if (!touchEnabled) return true  // 自动化期间吞掉所有事件

        when (event.actionMasked) {
            MotionEvent.ACTION_DOWN -> {
                touchDownX = event.x
                touchDownY = event.y
                touchDownTime = System.currentTimeMillis()
                return true
            }
            MotionEvent.ACTION_UP -> {
                val dx = event.x - touchDownX
                val dy = event.y - touchDownY
                val elapsed = System.currentTimeMillis() - touchDownTime

                // 将起点和终点映射到副屏坐标
                val startVd = mapToVirtualDisplay(v, touchDownX, touchDownY) ?: return false
                val endVd = mapToVirtualDisplay(v, event.x, event.y) ?: return false

                if (abs(dx) < tapSlop && abs(dy) < tapSlop) {
                    // ===== 点击 =====
                    Log.d(TAG, "点击: 预览(${touchDownX.toInt()},${touchDownY.toInt()}) → 副屏(${startVd.first},${startVd.second})")
                    VirtualDisplayManager.injectTap(startVd.first, startVd.second)
                } else {
                    // ===== 滑动 =====
                    val duration = elapsed.coerceIn(100, 2000)
                    Log.d(TAG, "滑动: 副屏(${startVd.first},${startVd.second}) → (${endVd.first},${endVd.second}), ${duration}ms")
                    VirtualDisplayManager.injectSwipe(
                        startVd.first, startVd.second,
                        endVd.first, endVd.second,
                        duration
                    )
                }
                return true
            }
            MotionEvent.ACTION_MOVE -> return true
        }
        return false
    }

    /**
     * 【核心算法】将预览 ImageView 上的触摸坐标映射到副屏像素坐标。
     *
     * 由于 ImageView 使用 FIT_CENTER 缩放模式,画面可能有 letterbox 黑边,
     * 需要计算偏移后再做比例换算。
     *
     * @param v ImageView
     * @param touchX 触摸点在 View 内的 X 坐标
     * @param touchY 触摸点在 View 内的 Y 坐标
     * @return 副屏像素坐标 (x, y),触摸在黑边区域时返回 null
     */
    private fun mapToVirtualDisplay(v: View, touchX: Float, touchY: Float): Pair<Int, Int>? {
        val vw = v.width.toFloat()   // ImageView 实际宽度(像素)
        val vh = v.height.toFloat()  // ImageView 实际高度(像素)
        if (vw <= 0 || vh <= 0) return null

        val vdW = VirtualDisplayManager.WIDTH.toFloat()   // 副屏宽度
        val vdH = VirtualDisplayManager.HEIGHT.toFloat()  // 副屏高度

        // FIT_CENTER 缩放比 = min(View宽/图片宽, View高/图片高)
        val scale = min(vw / vdW, vh / vdH)

        // 图片在 View 中实际渲染的尺寸
        val renderedW = vdW * scale
        val renderedH = vdH * scale

        // 居中对齐产生的偏移(letterbox 黑边宽度)
        val offsetX = (vw - renderedW) / 2f
        val offsetY = (vh - renderedH) / 2f

        // 触摸点相对于渲染内容左上角的坐标
        val contentX = touchX - offsetX
        val contentY = touchY - offsetY

        // 如果点在黑边区域,忽略
        if (contentX < 0 || contentY < 0 || contentX > renderedW || contentY > renderedH) {
            return null
        }

        // 按比例映射到副屏像素坐标,并 clamp 到有效范围
        val virtualX = (contentX / renderedW * vdW).toInt().coerceIn(0, vdW.toInt() - 1)
        val virtualY = (contentY / renderedH * vdH).toInt().coerceIn(0, vdH.toInt() - 1)
        return virtualX to virtualY
    }

    // ======================== 内部工具 ========================

    private fun removeOverlay() {
        try {
            overlayView?.let { windowManager.removeViewImmediate(it) }
        } catch (e: Exception) {
            Log.w(TAG, "移除预览失败: ${e.message}")
        }
        overlayView = null
        imageView = null
        closeButton = null
        isShowing = false
    }

    private fun dp(value: Int): Int {
        return (value * context.resources.displayMetrics.density).toInt()
    }
}

D. 布局文件 view_preview_overlay.xml

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<!-- res/layout/view_preview_overlay.xml -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@android:color/black">

    <!-- 副屏画面显示区域 -->
    <ImageView
        android:id="@+id/iv_overlay_preview"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:scaleType="fitCenter"
        android:contentDescription="副屏预览" />

    <!-- 关闭按钮(右上角) -->
    <ImageButton
        android:id="@+id/btn_overlay_close"
        android:layout_width="28dp"
        android:layout_height="28dp"
        android:layout_gravity="top|end"
        android:layout_margin="4dp"
        android:background="@android:color/transparent"
        android:src="@android:drawable/ic_menu_close_clear_cancel"
        android:contentDescription="关闭预览"
        android:padding="4dp" />

</FrameLayout>

E. FloatingPreviewService.kt(前台服务,管理生命周期)

kotlin 复制代码
package com.example.virtualdisplay

import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import android.provider.Settings
import android.util.Log
import androidx.core.app.NotificationCompat

/**
 * 前台服务,负责管理副屏和画中画预览的生命周期。
 *
 * 启动:
 *   val intent = Intent(context, FloatingPreviewService::class.java)
 *   intent.putExtra("package_name", "com.target.app")
 *   context.startForegroundService(intent)
 *
 * 停止:
 *   context.stopService(Intent(context, FloatingPreviewService::class.java))
 */
class FloatingPreviewService : Service() {

    companion object {
        private const val TAG = "FloatingPreviewSvc"
        private const val CHANNEL_ID = "virtual_display_preview"
        private const val NOTIFICATION_ID = 1001

        fun hasOverlayPermission(context: Context): Boolean {
            return Settings.canDrawOverlays(context)
        }
    }

    private var preview: VirtualDisplayPreview? = null

    override fun onBind(intent: Intent?): IBinder? = null

    override fun onCreate() {
        super.onCreate()
        startForegroundNotification()
    }

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        val packageName = intent?.getStringExtra("package_name")

        if (!hasOverlayPermission(this)) {
            Log.e(TAG, "缺少悬浮窗权限")
            stopSelf()
            return START_NOT_STICKY
        }

        // 1. 确保副屏存在
        val displayId = VirtualDisplayManager.ensureDisplay(this)
        if (displayId == android.view.Display.INVALID_DISPLAY) {
            Log.e(TAG, "副屏创建失败")
            stopSelf()
            return START_NOT_STICKY
        }

        // 2. 启动目标 App 到副屏
        if (!packageName.isNullOrEmpty()) {
            launchToDisplay(packageName, displayId)
        }

        // 3. 显示画中画预览
        if (preview == null) {
            preview = VirtualDisplayPreview(this).apply {
                onCloseListener = {
                    VirtualDisplayManager.forceStopAllLaunched()
                    VirtualDisplayManager.release()
                    stopSelf()
                }
            }
        }
        preview?.show()

        return START_NOT_STICKY
    }

    override fun onDestroy() {
        super.onDestroy()
        preview?.hide()
        preview = null
        VirtualDisplayManager.forceStopAllLaunched()
        VirtualDisplayManager.release()
        Log.i(TAG, "服务已销毁,副屏已释放")
    }

    private fun launchToDisplay(packageName: String, displayId: Int) {
        try {
            val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
            if (launchIntent != null) {
                val cmd = "am start -n ${launchIntent.component?.flattenToString()} --display $displayId"
                Runtime.getRuntime().exec(arrayOf("sh", "-c", cmd))
                VirtualDisplayManager.trackLaunchedPackage(packageName)
                Log.i(TAG, "已启动 $packageName 到 display $displayId")
            }
        } catch (e: Exception) {
            Log.e(TAG, "启动应用失败: ${e.message}")
        }
    }

    private fun startForegroundNotification() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            val channel = NotificationChannel(
                CHANNEL_ID, "副屏预览",
                NotificationManager.IMPORTANCE_LOW
            ).apply {
                description = "副屏画中画预览运行中"
            }
            getSystemService(NotificationManager::class.java)
                .createNotificationChannel(channel)
        }

        val notification = NotificationCompat.Builder(this, CHANNEL_ID)
            .setContentTitle("副屏预览运行中")
            .setContentText("正在显示副屏画中画")
            .setSmallIcon(android.R.drawable.ic_menu_view)
            .setOngoing(true)
            .build()

        startForeground(NOTIFICATION_ID, notification)
    }
}

F. 调用示例(Activity 中一键启动)

kotlin 复制代码
class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        findViewById<Button>(R.id.btn_start_preview).setOnClickListener {
            startVirtualDisplayPreview("com.target.app")
        }

        findViewById<Button>(R.id.btn_stop_preview).setOnClickListener {
            stopService(Intent(this, FloatingPreviewService::class.java))
        }
    }

    private fun startVirtualDisplayPreview(targetPackage: String) {
        // 1. 检查悬浮窗权限
        if (!FloatingPreviewService.hasOverlayPermission(this)) {
            val intent = Intent(
                Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
                Uri.parse("package:$packageName")
            )
            startActivity(intent)
            return
        }

        // 2. 可选:配置副屏参数
        VirtualDisplayManager.configure(width = 720, height = 1280, dpi = 160)

        // 3. 启动服务
        val intent = Intent(this, FloatingPreviewService::class.java).apply {
            putExtra("package_name", targetPackage)
        }
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            startForegroundService(intent)
        } else {
            startService(intent)
        }
    }
}

G. build.gradle 依赖

groovy 复制代码
// 无需额外第三方依赖,仅需 AndroidX
dependencies {
    implementation 'androidx.core:core-ktx:1.12.0'
    implementation 'androidx.appcompat:appcompat:1.6.1'
}

使用前提与限制

条件 说明
系统签名 FLAG_PUBLIC + FLAG_TRUSTED 等需要系统签名
Shell 权限 input -d 命令需要 shell 权限(系统应用或 ADB)
悬浮窗权限 SYSTEM_ALERT_WINDOW,需运行时引导用户开启
Android 版本 建议 Android 8.0+(TYPE_APPLICATION_OVERLAY)
非系统应用替代方案 使用 MediaProjection + AccessibilityService.dispatchGesture()

如果你的应用不是系统签名应用,可以用 MediaProjection.createVirtualDisplay() 替代直接创建,用 AccessibilityService.dispatchGesture() 替代 input -d 命令注入触摸。

相关推荐
程序员老邢1 小时前
【产品底稿 12】工程架构最终定型:完整模块拆分、分包规范、层级依赖与开发规约全清单
微服务·架构·springboot·多模块·技术债务
Hello-Mr.Wang1 小时前
【保姆级教程】MasterGo MCP + Cursor 一键实现 UI 设计稿还原
前端·javascript·vue.js·ai编程
Dabei1 小时前
Android 无障碍服务实现美团/微信自动化:客户端开发实践
前端·设计模式
华超磊2 小时前
关于手动实现滚动的尝试
前端
宁雨桥2 小时前
前端修行日记之JS 原型与 AI基础常识
前端·javascript·原型模式
程序员陆通2 小时前
月烧 400 刀到不到 20 刀:我是怎么把 OpenClaw 的 Token 账单砍掉 95% 的
java·前端·数据库
水云桐程序员2 小时前
前端教程官方文档|HTML、CSS、JavaScript教程官方文档
前端·javascript·css·html·学习方法
万事大吉CC2 小时前
【1】Django 基础:MTV 架构与核心组件
数据库·架构·django
SsunmdayKT2 小时前
前后端项目部署与运行机制全流程详解
前端·后端