You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Flutter基于Kotlin BroadcastReceiver实现Android悬浮窗方案

Android 端实现类CallKit来电悬浮窗的可行方案

这个场景属于原生端主动触发UI展示,和Flutter调原生方法的正向调用逻辑不一样,不要硬套官方文档里Flutter调Kotlin的实现路径,按以下步骤实现即可:

1. 前置配置

先在android/app/src/main/AndroidManifest.xml中添加必要权限与组件注册:

  • 基础权限声明
<!-- 悬浮窗权限 -->
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<!-- 来电状态读取权限 -->
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<!-- 前台服务权限,Android 9+ 必须 -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
  • 组件注册,注意Android 12+必须显式声明exported属性
<!-- 来电事件广播接收器 -->
<receiver
    android:name=".CallEventReceiver"
    android:exported="true">
    <intent-filter>
        <action android:name="android.intent.action.PHONE_STATE" />
    </intent-filter>
</receiver>

<!-- 悬浮窗承载服务 -->
<service
    android:name=".OverlayWindowService"
    android:exported="false"
    android:foregroundServiceType="phoneCall" />

2. 广播接收器事件处理

不要直接在BroadcastReceiver里弹悬浮窗,Android后台启动限制会直接拦截,Receiver只做事件校验和服务转发:

class CallEventReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        val callState = intent.getStringExtra(TelephonyManager.EXTRA_STATE)
        // 仅处理响铃状态
        if (callState == TelephonyManager.EXTRA_STATE_RINGING) {
            val incomingNum = intent.getStringExtra(TelephonyManager.EXTRA_INCOMING_NUMBER)
            // 先校验悬浮窗权限,未授权则跳权限申请页
            if (!Settings.canDrawOverlays(context)) {
                val permIntent = Intent(
                    Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
                    Uri.parse("package:${context.packageName}")
                ).apply {
                    addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
                }
                context.startActivity(permIntent)
                return
            }
            // 启动悬浮窗服务,传来电信息
            val serviceIntent = Intent(context, OverlayWindowService::class.java).apply {
                putExtra("incoming_number", incomingNum)
                action = "ACTION_SHOW_CALL_UI"
            }
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                context.startForegroundService(serviceIntent)
            } else {
                context.startService(serviceIntent)
            }
        }
    }
}

3. 悬浮窗服务实现

在OverlayWindowService中通过WindowManager加载来电UI,优先用原生XML布局实现类CallKit效果,加载速度比Flutter组件更快,不会出现来电时UI加载延迟的问题:

class OverlayWindowService : Service() {
    private lateinit var windowManager: WindowManager
    private var callView: View? = null

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

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        // 必须在服务启动10秒内挂载前台通知,否则会被系统杀掉
        val notification = NotificationCompat.Builder(this, "call_channel")
            .setContentTitle("来电中")
            .setSmallIcon(R.mipmap.ic_launcher)
            .setPriority(NotificationCompat.PRIORITY_HIGH)
            .build()
        startForeground(1001, notification)

        if (intent?.action == "ACTION_SHOW_CALL_UI") {
            val incomingNum = intent.getStringExtra("incoming_number")
            showCallOverlay(incomingNum)
        }
        return START_STICKY
    }

    private fun showCallOverlay(number: String?) {
        windowManager = getSystemService(WINDOW_SERVICE) as WindowManager
        // 悬浮窗参数配置
        val layoutParams = WindowManager.LayoutParams(
            WindowManager.LayoutParams.MATCH_PARENT,
            WindowManager.LayoutParams.WRAP_CONTENT,
            // 适配不同Android版本的悬浮窗类型
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
                WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
            else
                WindowManager.LayoutParams.TYPE_PHONE,
            WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN,
            PixelFormat.TRANSLUCENT
        ).apply {
            gravity = Gravity.TOP
        }
        // 加载提前写好的类CallKit布局
        callView = LayoutInflater.from(this).inflate(R.layout.layout_incoming_call, null)
        // 给布局里的接听、挂断按钮绑定点击事件
        callView?.findViewById<Button>(R.id.btn_answer)?.setOnClickListener {
            // 1. 移除悬浮窗 2. 通过MethodChannel通知Flutter层接听逻辑
            removeOverlay()
            // 拿全局缓存的FlutterEngine实例发消息即可
        }
        callView?.findViewById<Button>(R.id.btn_reject)?.setOnClickListener {
            // 1. 移除悬浮窗 2. 通知Flutter层挂断逻辑
            removeOverlay()
        }
        windowManager.addView(callView, layoutParams)
    }

    private fun removeOverlay() {
        callView?.let {
            windowManager.removeView(it)
            callView = null
            stopSelf()
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        removeOverlay()
    }
}

4. 与Flutter层通信注意事项

  • App启动时,在MainActivity的configureFlutterEngine方法中,将初始化好的FlutterEngine实例存到原生全局单例中,不要每次事件触发都新建Engine和MethodChannel,否则会有极大的性能损耗
  • 悬浮窗按钮触发事件时,直接从全局单例取Engine对应的MethodChannel发送消息即可,如果此时Flutter进程未存活,先启动主Activity等待Engine初始化完成后再发事件,避免事件丢失
  • 如果一定要用Flutter组件写悬浮窗UI,可以将FlutterView作为子View加到WindowManager的布局中,但不推荐,冷启动时FlutterView初始化耗时较长,会导致来电UI弹出延迟

常见坑点

  • Android 10及以上版本严格限制后台启动Activity,不要试图直接拉起Flutter页面作为来电界面,会被系统拦截,悬浮窗/全屏通知是仅有的两个合规方案
  • 国产ROM普遍需要额外引导用户开启「后台弹出界面」「自启动」权限,否则锁屏状态下悬浮窗可能无法正常展示
  • 悬浮窗权限是特殊权限,不能和普通权限一样动态申请,必须跳系统设置页让用户手动开启
  • 服务启动后必须在10秒内调用startForeground挂载通知,否则会被系统直接判定为无响应并杀死

内容的提问来源于stack exchange,提问作者deok

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.09.09 16:15:44