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 完全不启动,则需要另行设计原生或无界面引擎架构。
备注:内容仅供参考。
