快速体验

在开始今天关于 Android悬浮窗实战:从权限适配到微信语音通话效果实现 的探讨之前,我想先分享一个最近让我觉得很有意思的全栈技术挑战。

我们常说 AI 是未来,但作为开发者,如何将大模型(LLM)真正落地为一个低延迟、可交互的实时系统,而不仅仅是调个 API?

这里有一个非常硬核的动手实验:基于火山引擎豆包大模型,从零搭建一个实时语音通话应用。它不是简单的问答,而是需要你亲手打通 ASR(语音识别)→ LLM(大脑思考)→ TTS(语音合成)的完整 WebSocket 链路。对于想要掌握 AI 原生应用架构的同学来说,这是个绝佳的练手项目。

架构图

点击开始动手实验

从0到1构建生产级别应用,脱离Demo,点击打开 从0打造个人豆包实时通话AI动手实验

Android悬浮窗实战:从权限适配到微信语音通话效果实现

背景痛点分析

在开发类似微信语音通话悬浮窗功能时,Android开发者通常会遇到几个典型问题:

  1. 权限管理困境
    SYSTEM_ALERT_WINDOW属于危险权限,需要用户手动授权。不同厂商ROM对权限弹窗的拦截策略不同,小米、华为等设备需要额外跳转特殊设置页面。

  2. 版本兼容性问题
    Android 8.0(Oreo)引入TYPE_APPLICATION_OVERLAY替代TYPE_SYSTEM_ALERT,但部分国产ROM仍保留自定义窗口类型。开发者需要同时处理新旧API。

  3. 交互冲突场景
    悬浮窗可能遮挡输入法、与其他悬浮窗层级冲突,触摸事件可能意外穿透到底层Activity。微信的解决方案是通过智能避让和事件过滤保证通话悬浮窗始终可用。

技术方案选型

窗口类型对比

  • TYPE_SYSTEM_ALERT(已废弃)
    需要SYSTEM_ALERT_WINDOW权限,在Android 8.0+会触发安全异常。优势是能显示在所有应用最上层。

  • TYPE_APPLICATION_OVERLAY(推荐)
    Android 8.0+官方推荐方案,同样需要悬浮窗权限。相比前者:

    • 不会遮挡系统关键UI(如状态栏)
    • 支持设置交互式/非交互式模式
    • 必须指定窗口尺寸(不能使用MATCH_PARENT)
val params = WindowManager.LayoutParams(
    width,
    height,
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
        WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
    } else {
        WindowManager.LayoutParams.TYPE_SYSTEM_ALERT
    },
    WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
    PixelFormat.TRANSLUCENT
)

核心实现步骤

1. 动态权限申请

private fun checkOverlayPermission(): Boolean {
    return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
        Settings.canDrawOverlays(context)
    } else {
        true // 6.0以下默认有权限
    }
}

fun requestOverlayPermission(activity: Activity, reqCode: Int) {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
        val intent = Intent(
            Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
            Uri.parse("package:${activity.packageName}")
        )
        activity.startActivityForResult(intent, reqCode)
    }
}

厂商适配技巧

  • 小米:检查Settings.canDrawOverlays()返回true但实际无权限时,跳转miui.intent.action.APP_PERM_EDITOR
  • 华为:需要额外申请android.permission.SYSTEM_ALERT_WINDOW的权限组

2. 窗口参数配置

关键参数说明:

val params = WindowManager.LayoutParams().apply {
    // 基础参数
    type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
        WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
    } else {
        WindowManager.LayoutParams.TYPE_SYSTEM_ALERT
    }
    format = PixelFormat.TRANSLUCENT
    gravity = Gravity.START or Gravity.TOP
    
    // 交互控制
    flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
            WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS
    
    // 视觉表现
    dimAmount = 0.3f // 背景变暗程度
    alpha = 0.9f     // 窗口透明度
    
    // 初始位置
    x = initialX
    y = initialY
}

3. 窗口生命周期管理

class FloatingWindow(private val context: Context) {
    private val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
    private lateinit var floatingView: View
    private lateinit var params: WindowManager.LayoutParams

    fun show() {
        floatingView = LayoutInflater.from(context).inflate(R.layout.float_window, null)
        initParams()
        windowManager.addView(floatingView, params)
        initDragListener()
    }

    fun dismiss() {
        if (::floatingView.isInitialized) {
            windowManager.removeView(floatingView)
        }
    }
}

高级特性实现

可拖动悬浮窗实现

private fun initDragListener() {
    var initialX = 0
    var initialY = 0
    var initialTouchX = 0f
    var initialTouchY = 0f
    
    floatingView.setOnTouchListener { v, event ->
        when (event.action) {
            MotionEvent.ACTION_DOWN -> {
                initialX = params.x
                initialY = params.y
                initialTouchX = event.rawX
                initialTouchY = event.rawY
                true
            }
            MotionEvent.ACTION_MOVE -> {
                params.x = initialX + (event.rawX - initialTouchX).toInt()
                params.y = initialY + (event.rawY - initialTouchY).toInt()
                windowManager.updateViewLayout(floatingView, params)
                true
            }
            else -> false
        }
    }
}

性能优化:使用VelocityTracker计算滑动速度,实现惯性滑动效果(时间复杂度O(1))

输入法适配方案

当输入法弹出时,通过监听ViewTreeObserver调整悬浮窗位置:

floatingView.viewTreeObserver.addOnGlobalLayoutListener {
    val rect = Rect().apply { 
        floatingView.getWindowVisibleDisplayFrame(this) 
    }
    val screenHeight = Resources.getSystem().displayMetrics.heightPixels
    val keyboardHeight = screenHeight - rect.bottom
    
    if (keyboardHeight > screenHeight * 0.15) {
        // 输入法弹出,上移悬浮窗
        params.y -= keyboardHeight
        windowManager.updateViewLayout(floatingView, params)
    }
}

避坑指南

  1. 国产ROM白名单
    在小米设备上需要额外添加:

    <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
    
  2. Android 11点击穿透
    设置FLAG_NOT_TOUCH_MODAL时,需明确指定可点击区域:

    params.flags = params.flags or WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL
    params.width = dipToPx(100) // 必须明确尺寸
    params.height = dipToPx(100)
    
  3. 内存泄漏预防
    在Activity/Fragment销毁时务必移除View:

    override fun onDestroy() {
        super.onDestroy()
        floatingWindow?.dismiss()
    }
    

性能验证

通过ADB命令监控悬浮窗性能:

# 查看窗口层级
adb shell dumpsys window windows | grep -E 'Window #|mBounds'

# 测量帧率
adb shell dumpsys gfxinfo <package_name>

# 内存占用
adb shell dumpsys meminfo <package_name>

典型优化指标:

  • 内存占用:单个悬浮窗应<5MB
  • 帧率:拖动时保持60fps
  • 层级深度:避免超过3层嵌套View

通过合理使用ViewStub、减少布局层级、避免频繁requestLayout,可以显著提升悬浮窗性能。

实验介绍

这里有一个非常硬核的动手实验:基于火山引擎豆包大模型,从零搭建一个实时语音通话应用。它不是简单的问答,而是需要你亲手打通 ASR(语音识别)→ LLM(大脑思考)→ TTS(语音合成)的完整 WebSocket 链路。对于想要掌握 AI 原生应用架构的同学来说,这是个绝佳的练手项目。

你将收获:

  • 架构理解:掌握实时语音应用的完整技术链路(ASR→LLM→TTS)
  • 技能提升:学会申请、配置与调用火山引擎AI服务
  • 定制能力:通过代码修改自定义角色性格与音色,实现“从使用到创造”

点击开始动手实验

从0到1构建生产级别应用,脱离Demo,点击打开 从0打造个人豆包实时通话AI动手实验

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐