iOS端App退至后台时如何避免video_player暂停播放?
iOS端audio_service与video_player后台播放问题解决
问题现象
使用audio_service集成video_player实现后台音频播放时,Android端正常,但iOS端存在以下问题:
- App退至后台后,视频播放莫名暂停
- 模拟器中暂停后可恢复,物理设备中暂停后进程会被系统直接杀死
问题原因
- iOS对后台音频播放有严格的权限校验,未配置后台音频模式时,系统会在App退后台后暂停音频播放并回收资源
- video_player的
allowBackgroundPlayback仅开启基础后台播放能力,但未与audio_service的音频会话关联,系统无法识别App的后台音频任务 - 音频会话配置不正确,导致系统判定App无活跃音频任务,进而杀死进程
解决方案
1. 添加iOS后台播放权限
在ios/Runner/Info.plist中添加后台音频模式配置:
<key>UIBackgroundModes</key> <array> <string>audio</string> </array>
2. 初始化并配置音频会话
在AudioPlayerHandler中主动配置音频会话,确保audio_service与video_player共享同一会话:
// 引入audio_session包(audio_service已依赖,无需额外添加) import 'package:audio_session/audio_session.dart'; // 修改AudioPlayerHandler构造函数为异步 class AudioPlayerHandler extends BaseAudioHandler with SeekHandler { static final _item = MediaItem(/* 原内容不变 */); final _controller = VideoPlayerController.networkUrl( Uri.parse('https://flutter.github.io/assets-for-api-docs/assets/videos/butterfly.mp4'), videoPlayerOptions: VideoPlayerOptions(allowBackgroundPlayback: true, mixWithOthers: true)); AudioPlayerHandler() async { // 配置音频会话为音乐类型 final session = await AudioSession.instance; await session.configure(const AudioSessionConfiguration.music()); _controller.addListener(_updatePlaybackState); mediaItem.add(_item); // 等待播放器初始化完成 await _controller.initialize(); // 主动更新初始播放状态 _updatePlaybackState(); } // 其他方法不变 }
3. 完善stop方法实现
确保stop方法正确通知audio_service并清理资源:
@override Future<void> stop() async { await _controller.pause(); await _controller.seekTo(Duration.zero); // 调用父类stop方法,通知audio_service停止服务 await super.stop(); }
4. 优化audio_service初始化配置
添加iOS权限描述,确保系统识别后台音频需求:
Future<void> main() async { _audioHandler = await AudioService.init( builder: () => AudioPlayerHandler(), config: const AudioServiceConfig( androidNotificationChannelId: 'com.ryanheise.myapp.channel.audio', androidNotificationChannelName: 'Audio playback', androidNotificationOngoing: true, // 添加iOS权限描述 iosUsageDescription: '需要访问音频以实现后台播放功能', ), ); runApp(const MyApp()); }
修改后的完整代码
// ignore_for_file: public_member_api_docs import 'dart:async'; import 'dart:math'; import 'package:audio_service/audio_service.dart'; import 'package:audio_session/audio_session.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:rxdart/rxdart.dart'; import 'package:video_player/video_player.dart'; late AudioHandler _audioHandler; Future<void> main() async { _audioHandler = await AudioService.init( builder: () => AudioPlayerHandler(), config: const AudioServiceConfig( androidNotificationChannelId: 'com.ryanheise.myapp.channel.audio', androidNotificationChannelName: 'Audio playback', androidNotificationOngoing: true, iosUsageDescription: '需要访问音频以实现后台播放功能', ), ); runApp(const MyApp()); } class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: 'Audio Service Demo', theme: ThemeData(primarySwatch: Colors.blue), home: const MainScreen(), ); } } class SeekBar extends StatefulWidget { final Duration duration; final Duration position; final Duration bufferedPosition; final ValueChanged<Duration>? onChanged; final ValueChanged<Duration>? onChangeEnd; const SeekBar({ super.key, required this.duration, required this.position, this.bufferedPosition = Duration.zero, this.onChanged, this.onChangeEnd, }); @override SeekBarState createState() => SeekBarState(); } class SeekBarState extends State<SeekBar> { double? _dragValue; bool _dragging = false; late SliderThemeData _sliderThemeData; @override void didChangeDependencies() { super.didChangeDependencies(); _sliderThemeData = SliderTheme.of(context).copyWith( trackHeight: 2.0, ); } @override Widget build(BuildContext context) { final value = min( _dragValue ?? widget.position.inMilliseconds.toDouble(), widget.duration.inMilliseconds.toDouble(), ); if (_dragValue != null && !_dragging) { _dragValue = null; } return Stack( children: [ SliderTheme( data: _sliderThemeData.copyWith( thumbShape: HiddenThumbComponentShape(), activeTrackColor: Colors.blue.shade100, inactiveTrackColor: Colors.grey.shade300, ), child: ExcludeSemantics( child: Slider( min: 0.0, max: widget.duration.inMilliseconds.toDouble(), value: min(widget.bufferedPosition.inMilliseconds.toDouble(), widget.duration.inMilliseconds.toDouble()), onChanged: (value) {}, ), ), ), SliderTheme( data: _sliderThemeData.copyWith( inactiveTrackColor: Colors.transparent, ), child: Slider( min: 0.0, max: widget.duration.inMilliseconds.toDouble(), value: value, onChanged: (value) { if (!_dragging) { _dragging = true; } setState(() { _dragValue = value; }); if (widget.onChanged != null) { widget.onChanged!(Duration(milliseconds: value.round())); } }, onChangeEnd: (value) { if (widget.onChangeEnd != null) { widget.onChangeEnd!(Duration(milliseconds: value.round())); } _dragging = false; }, ), ), Positioned( right: 16.0, bottom: 0.0, child: Text( RegExp(r'((^0*[1-9]\d*:)?\d{2}:\d{2})\.\d+$').firstMatch("$_remaining")?.group(1) ?? '$_remaining', style: Theme.of(context).textTheme.bodySmall), ), ], ); } Duration get _remaining => widget.duration - widget.position; } class HiddenThumbComponentShape extends SliderComponentShape { @override Size getPreferredSize(bool isEnabled, bool isDiscrete) => Size.zero; @override void paint( PaintingContext context, Offset center, { required Animation<double> activationAnimation, required Animation<double> enableAnimation, required bool isDiscrete, required TextPainter labelPainter, required RenderBox parentBox, required SliderThemeData sliderTheme, required TextDirection textDirection, required double value, required double textScaleFactor, required Size sizeWithOverflow, }) {} } class MainScreen extends StatelessWidget { const MainScreen({super.key}); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Audio Service Demo'), ), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ StreamBuilder<MediaItem?>( stream: _audioHandler.mediaItem, builder: (context, snapshot) { final mediaItem = snapshot.data; return Text(mediaItem?.title ?? ''); }, ), StreamBuilder<bool>( stream: _audioHandler.playbackState.map((state) => state.playing).distinct(), builder: (context, snapshot) { final playing = snapshot.data ?? false; return Row( mainAxisAlignment: MainAxisAlignment.center, children: [ _button(Icons.fast_rewind, _audioHandler.rewind), if (playing) _button(Icons.pause, _audioHandler.pause) else _button(Icons.play_arrow, _audioHandler.play), _button(Icons.stop, _audioHandler.stop), _button(Icons.fast_forward, _audioHandler.fastForward), ], ); }, ), StreamBuilder<MediaState>( stream: _mediaStateStream, builder: (context, snapshot) { final mediaState = snapshot.data; return SeekBar( duration: mediaState?.mediaItem?.duration ?? Duration.zero, position: mediaState?.position ?? Duration.zero, onChangeEnd: (newPosition) { _audioHandler.seek(newPosition); }, ); }, ), StreamBuilder<AudioProcessingState>( stream: _audioHandler.playbackState.map((state) => state.processingState).distinct(), builder: (context, snapshot) { final processingState = snapshot.data ?? AudioProcessingState.idle; return Text( "Processing state: ${describeEnum(processingState)}"); }, ), ], ), ), ); } Stream<MediaState> get _mediaStateStream => Rx.combineLatest2<MediaItem?, Duration, MediaState>( _audioHandler.mediaItem, AudioService.position, (mediaItem, position) => MediaState(mediaItem, position)); IconButton _button(IconData iconData, VoidCallback onPressed) => IconButton( icon: Icon(iconData), iconSize: 64.0, onPressed: onPressed, ); } class MediaState { final MediaItem? mediaItem; final Duration position; MediaState(this.mediaItem, this.position); } class AudioPlayerHandler extends BaseAudioHandler with SeekHandler { static final _item = MediaItem( id: 'https://flutter.github.io/assets-for-api-docs/assets/videos/butterfly.mp4', album: "Science Friday", title: "A Salute To Head-Scratching Science", artist: "Science Friday and WNYC Studios", duration: const Duration(milliseconds: 7000), artUri: Uri.parse('https://media.wnyc.org/i/1400/1400/l/80/1/ScienceFriday_WNYCStudios_1400.jpg'), ); final _controller = VideoPlayerController.networkUrl( Uri.parse('https://flutter.github.io/assets-for-api-docs/assets/videos/butterfly.mp4'), videoPlayerOptions: VideoPlayerOptions(allowBackgroundPlayback: true, mixWithOthers: true)); AudioPlayerHandler() async { final session = await AudioSession.instance; await session.configure(const AudioSessionConfiguration.music()); _controller.addListener(_updatePlaybackState); mediaItem.add(_item); await _controller.initialize(); _updatePlaybackState(); } @override Future<void> play() => _controller.play(); @override Future<void> pause() => _controller.pause(); @override Future<void> seek(Duration position) => _controller.seekTo(position); @override Future<void> stop() async { await _controller.pause(); await _controller.seekTo(Duration.zero); await super.stop(); } AudioProcessingState resolveAudioProcessingState(VideoPlayerValue value) { if (value.isBuffering) { return AudioProcessingState.buffering; } if (value.isCompleted) { return AudioProcessingState.completed; } return AudioProcessingState.ready; } void _updatePlaybackState() { final isPlaying = _controller.value.isPlaying; final bufferedRanges = _controller.value.buffered; final bufferedPosition = bufferedRanges.isNotEmpty ? bufferedRanges.last.end : Duration.zero; playbackState.add(PlaybackState( processingState: resolveAudioProcessingState(_controller.value), playing: isPlaying, updatePosition: _controller.value.position, bufferedPosition: bufferedPosition, speed: _controller.value.playbackSpeed, controls: [ MediaControl.rewind, if (isPlaying) MediaControl.pause else MediaControl.play, MediaControl.stop, MediaControl.fastForward, ], systemActions: const { MediaAction.seek, MediaAction.seekForward, MediaAction.seekBackward, }, updateTime: DateTime.now(), )); } }
验证说明
完成上述修改后,重新编译iOS项目:
- 运行App并开始播放视频
- 将App退至后台,音频应持续播放
- 物理设备上进程不会被系统杀死,可通过控制中心的音频控件继续控制播放
内容的提问来源于stack exchange,提问作者Qbencjusz
相关产品推荐
相关产品推荐

