PAYATHON 2026

Flutter Stream Video 如何在后台接听来电而不先打开 Flutter UI?

排障赵师傅

结论

使用 Stream Video Flutter 的标准 Android 来电流程,无法仅靠通知参数做到:点击 Answer 后完全不启动 Flutter Activity,由原生端独立完成接听和媒体连接。

官方在应用终止状态下的处理流程如下:

收到 FCM 来电推送
→ 显示原生来电通知
→ 用户点击 Answer
→ 启动应用
→ 初始化 Flutter / Stream 客户端
→ consumeAndAcceptActiveCall() 恢复并接受该来电
→ 应用决定是否导航到 CallScreen

Stream 官方来电教程说明了这一启动顺序。

这里有两个不同的要求:

  • 由应用决定何时显示 CallScreen:可以实现,需要将接听处理与页面导航分开。
  • 接听全过程不启动 Flutter Activity:超出了官方 Flutter 标准接入流程,需要额外设计 Android 后台通话架构。

如果继续使用现有 Flutter 插件,不增加原生通话实现,可行的做法是允许 Answer 唤起应用,尽早处理通话,再延后显示 CallScreen。这种方式仍不能保证应用窗口完全不出现。

consumeAndAcceptActiveCall() 与 Activity 启动是什么关系?

consumeAndAcceptActiveCall() 用于在应用启动后,恢复并接受用户已在来电通知中点击接听的通话。

在官方教程中,Activity 和 Flutter 先启动,Dart 随后才执行该方法。因此,删除它的 onCallAccepted 回调,或取消回调中的导航,都无法阻止此前已经发生的应用启动。

还需要区分以下三个状态:

状态含义
通知中的 Answer 被点击原生通知记录了用户的接听操作
Stream 通话被接受完成 SDK 的接受流程
媒体连接建立已加入 SFU,音频、视频按配置开始工作

call.accept() 和 call.join() 是不同的操作。收到接受回调,并不代表对方已经能听到声音。官方分别提供了接受来电 API和加入通话流程。

先移除代码中主动打开页面的入口

当前代码即使取消了 onCallAccepted 中的导航,仍会通过以下监听打开通话页:

globalIncomingCallSub = client.state.incomingCall.listen((call) {
  if (call != null) {
    openCallScreen(call);
  }
});

incomingCall 只表示有来电,不能据此判断用户已经点击 Answer。如果要由业务决定何时显示页面,这个监听应只更新状态。

可以用以下方式替换现有回调。pendingIncomingCall、acceptedCall 和 showAcceptedCall 都是应用自行定义的状态与方法,不属于 Stream SDK:

import 'package:flutter/foundation.dart';
import 'package:stream_video_flutter/stream_video_flutter.dart'
    as stream;

final pendingIncomingCall = ValueNotifier<stream.Call?>(null);
final acceptedCall = ValueNotifier<stream.Call?>(null);

void rememberAcceptedCall(stream.Call call) {
  pendingIncomingCall.value = null;
  acceptedCall.value = call;

  // 这里只保存通话,不执行页面导航。
}

Future<void> bindCallHandling(stream.StreamVideo client) async {
  globalRingingEventsSub = client.observeCoreRingingEvents(
    onCallAccepted: rememberAcceptedCall,
  );

  globalIncomingCallSub = client.state.incomingCall.listen((call) {
    pendingIncomingCall.value = call;
  });

  if (Platform.isAndroid) {
    await client.consumeAndAcceptActiveCall(
      onCallAccepted: rememberAcceptedCall,
    );
  }
}

void showAcceptedCall() {
  final call = acceptedCall.value;
  if (call == null) return;

  openCallScreen(call);
}

这段代码将接听处理与页面导航分开,但还不是完整的后台通话实现。通话结束时需要清除状态,退出账号时需要取消订阅,同时应避免重复注册监听。

如果不再挂载原来的通话页面,还要检查媒体加入逻辑是否由页面中的组件负责。如果实际使用版本的接受流程尚未执行 join(),就需要由通话控制层主动加入,例如:

// 前提:该通话已经完成接受,且尚未加入。
// 同一通话的 join 应由一个控制层统一管理。
final result = await call.join();

result.fold(
  success: (_) {
    debugPrint('Call joined');
  },
  failure: (failure) {
    debugPrint('Join failed: ${failure.error.message}');
  },
);

不要让页面和控制层同时调用 join(),也不要在上述接受回调中无条件再次调用 accept()。具体处理应以实际解析版本的行为和通话状态为准。

后台监听应保留独立职责

题目中 FCM 后台处理器的基本结构符合官方接入方式:

final subscription =
    streamVideo.observeCoreRingingEventsForBackground();

streamVideo.disposeAfterResolvingRinging(
  disposingCallback: () async {
    await subscription?.cancel();
  },
);

await streamVideo.handleRingingFlowNotifications(data);

后台 isolate 在这里创建临时客户端,处理来电通知和响铃期间的事件,完成后释放。主应用使用 observeCoreRingingEvents(),并在冷启动后补充调用 consumeAndAcceptActiveCall()。Stream Firebase 接入文档采用的就是这种分工。

将后台处理器中的监听换成 observeCoreRingingEvents(),不能把临时客户端变成长期运行的通话实例。现有 disposeAfterResolvingRinging() 的生命周期也不适合维持持续媒体会话。

示例中的调用还需要注意:

_createStreamClient(auth, background: true)

这里传入了 background,但函数内部没有使用它。因此,这个参数目前不会改变 SDK 的执行模式,也不会创建由 Android Service 托管的 FlutterEngine。

FCM 后台 isolate 与主 isolate 不共享普通全局变量。在后台写入 appLaunchedForCall,无法直接控制主 isolate 中的页面状态。

Android 配置能解决哪些问题?

StreamBackgroundService.init() 用于维持通话

应按官方要求初始化:

await StreamBackgroundService.init(client);

该服务让活动通话在 UI 不可见时继续运行,并显示持续通话通知,但不代表原生端可以独立接听所有冷启动来电。keepConnectionsAliveWhenInBackground 和静音选项也不会改变 Answer 的启动目标。官方后台模式文档说明了该服务的用途。

launchMode 用于避免重复 Activity

官方 Android 接入文档建议为 MainActivity 配置:

android:launchMode="singleInstance"

这一配置用于复用 Activity,避免点击通知后反复叠加实例。它仍会把应用带到前台,无法实现无 Activity 接听。

全屏通知权限只影响来电展示

showFullScreenOnLockScreen 和 ensureFullScreenIntentPermission() 控制来电的展示能力,不决定由哪个运行环境接听通话。

调用以下方法时,如果权限未满足,可能会打开系统设置页:

await StreamVideoPushNotificationManager
    .ensureFullScreenIntentPermission();

这类权限引导应安排在用户主动进行的初始化流程中,避免成为冷启动接听的必经步骤。

检查 Manifest 时,应查看最终的 Merged Manifest,并核对当前版本文档要求的权限和组件。仅增加权限、修改 MainActivity.kt 或再声明一个 Service,无法自动将 Dart 通话逻辑转为原生后台通话逻辑。

严格要求不启动 Flutter Activity 时,应采用什么架构?

如果硬性要求 Answer 后不能出现应用窗口,就需要由独立于 Flutter 页面的组件持有持续通话。

可以评估以下架构:

原生来电通知的 Answer 操作
→ Android 后台通话组件
→ 恢复身份并接受、加入通话
→ foreground service 维持通话
→ Flutter UI 按需连接现有通话状态

通话层可以考虑使用 Stream Android SDK,复用 Stream 的信令和媒体能力,无需自行实现 WebRTC 或完整的 VoIP 协议。这仍需要额外的原生集成;现有 Flutter 推送插件的公开接入流程没有提供可直接实现“原生独立接听”的配置。

另一种方案是由 Service 托管无界面的 FlutterEngine,让 Dart 负责通话。这需要验证插件注册、后台引擎兼容性、媒体资源、进程恢复和 UI 接入,不能直接让 FCM 后台处理器长期运行来承担通话。

两种方案都应由一个组件持有媒体会话。Flutter 页面后续接入时,应展示已有通话,避免重新建立第二份媒体连接。

Android 对后台启动 foreground service,以及访问麦克风和摄像头都有限制。通知交互属于部分限制的例外条件,但仍需满足相应的服务类型和权限要求,不能仅凭高优先级 FCM 就假定可以开始录音。Android 官方限制说明列出了这些条件。

版本与“进程被终止”的注意事项

^1.6.0 是版本范围,排查时应以 pubspec.lock 中实际解析的版本为准。在线文档可能已经更新,不能据此认定题目列出的全部构建工具与依赖组合都已验证兼容。

测试时还应区分以下情况:

  • 应用退到后台;
  • 应用进程被系统回收;
  • 从最近任务列表划掉应用;
  • 用户在系统设置中执行“强行停止”。

最后一种情况下,不能保证收到来电推送。Firebase 明确说明,Android 应用被用户从设置中强行停止后,需要手动重新打开才能恢复消息接收。FCM 后台处理器也有执行时间限制,不适合作为持续通话的宿主。Firebase 官方说明

如果继续采用 Flutter 标准流程,建议先移除 incomingCall 中的自动导航,并将身份恢复和 Stream 初始化移到启动早期。通话的接受、加入和结束交给独立通话控制层管理,由业务入口决定何时打开 CallScreen。如果要求 Flutter Activity 完全不启动,则需要另行设计原生或无界面引擎架构。

备注:内容仅供参考。