Flutter+HarmonyOS跨端实战—第07篇:视频播放器的 HarmonyOS 适配
·
实现一个支持三端的视频播放器插件
前言
视频播放是 CleanMark AI 的核心功能之一,用于预览去水印后的视频效果。视频播放器的实现比图片选择器复杂得多,涉及:
- 视频解码和渲染
- 播放控制(播放、暂停、进度)
- 生命周期管理
- 性能优化
这篇文章我会带你实现一个完整的视频播放器插件,重点讲解 HarmonyOS 的适配难点。
一、视频播放器方案对比
1.1 三端原生方案
| 平台 | 原生方案 | 特点 |
|---|---|---|
| Android | ExoPlayer / MediaPlayer | ExoPlayer 功能强大,MediaPlayer 简单易用 |
| iOS | AVPlayer | 功能完善,性能优秀 |
| HarmonyOS | AVPlayer | API 类似 iOS,但有差异 |
1.2 Flutter 官方插件
Flutter 官方提供了 video_player 插件,但不支持 HarmonyOS。我们需要:
- 使用官方插件的接口层
- 自己实现 HarmonyOS 平台代码
二、插件架构设计
2.1 分层架构
video_player/ # 应用层
└─ 依赖 video_player_platform_interface
video_player_platform_interface/ # 接口层
└─ 定义抽象接口
video_player_android/ # Android 实现
video_player_avfoundation/ # iOS 实现
video_player_ohos/ # HarmonyOS 实现
2.2 核心概念
VideoPlayerController:
- 控制视频播放
- 管理播放状态
- 提供播放进度
VideoPlayerValue:
- 视频时长
- 当前播放位置
- 播放状态(播放中、暂停、缓冲)
- 视频尺寸
三、接口层设计
3.1 VideoPlayerPlatform 接口
// packages/video_player_platform_interface/lib/video_player_platform_interface.dart
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
/// 视频播放器平台接口
abstract class VideoPlayerPlatform extends PlatformInterface {
VideoPlayerPlatform() : super(token: _token);
static final Object _token = Object();
static VideoPlayerPlatform _instance = MethodChannelVideoPlayer();
static VideoPlayerPlatform get instance => _instance;
static set instance(VideoPlayerPlatform instance) {
PlatformInterface.verifyToken(instance, _token);
_instance = instance;
}
/// 初始化视频播放器
Future<void> init();
/// 创建播放器实例
Future<int?> create(DataSource dataSource);
/// 销毁播放器实例
Future<void> dispose(int textureId);
/// 设置循环播放
Future<void> setLooping(int textureId, bool looping);
/// 设置音量
Future<void> setVolume(int textureId, double volume);
/// 播放
Future<void> play(int textureId);
/// 暂停
Future<void> pause(int textureId);
/// 跳转到指定位置
Future<void> seekTo(int textureId, Duration position);
/// 获取播放位置
Future<Duration> getPosition(int textureId);
/// 获取视频信息流
Stream<VideoEvent> videoEventsFor(int textureId);
}
/// 数据源
class DataSource {
/// 数据源类型
final DataSourceType sourceType;
/// 视频 URI
final String? uri;
/// 视频文件路径
final String? asset;
/// 视频包名(用于 asset)
final String? package;
/// HTTP 请求头
final Map<String, String>? httpHeaders;
DataSource({
required this.sourceType,
this.uri,
this.asset,
this.package,
this.httpHeaders,
});
}
/// 数据源类型
enum DataSourceType {
/// 网络视频
network,
/// 本地文件
file,
/// Asset 资源
asset,
}
/// 视频事件
abstract class VideoEvent {
const VideoEvent();
}
/// 初始化完成事件
class VideoEventInitialized extends VideoEvent {
final Duration duration;
final Size size;
const VideoEventInitialized({
required this.duration,
required this.size,
});
}
/// 播放完成事件
class VideoEventCompleted extends VideoEvent {
const VideoEventCompleted();
}
/// 缓冲开始事件
class VideoEventBufferingStart extends VideoEvent {
const VideoEventBufferingStart();
}
/// 缓冲结束事件
class VideoEventBufferingEnd extends VideoEvent {
const VideoEventBufferingEnd();
}
/// 错误事件
class VideoEventError extends VideoEvent {
final String errorDescription;
const VideoEventError({required this.errorDescription});
}
3.2 MethodChannel 实现
// packages/video_player_platform_interface/lib/method_channel_video_player.dart
import 'package:flutter/services.dart';
import 'video_player_platform_interface.dart';
class MethodChannelVideoPlayer extends VideoPlayerPlatform {
final MethodChannel channel = const MethodChannel('flutter.io/videoPlayer');
final Map<int, EventChannel> _eventChannels = {};
Future<void> init() {
return channel.invokeMethod<void>('init');
}
Future<int?> create(DataSource dataSource) async {
final Map<String, dynamic> dataSourceDescription = <String, dynamic>{
'sourceType': dataSource.sourceType.toString(),
'uri': dataSource.uri,
'asset': dataSource.asset,
'package': dataSource.package,
'httpHeaders': dataSource.httpHeaders,
};
final int? textureId = await channel.invokeMethod<int>(
'create',
dataSourceDescription,
);
return textureId;
}
Future<void> dispose(int textureId) {
return channel.invokeMethod<void>('dispose', {'textureId': textureId});
}
Future<void> setLooping(int textureId, bool looping) {
return channel.invokeMethod<void>('setLooping', {
'textureId': textureId,
'looping': looping,
});
}
Future<void> setVolume(int textureId, double volume) {
return channel.invokeMethod<void>('setVolume', {
'textureId': textureId,
'volume': volume,
});
}
Future<void> play(int textureId) {
return channel.invokeMethod<void>('play', {'textureId': textureId});
}
Future<void> pause(int textureId) {
return channel.invokeMethod<void>('pause', {'textureId': textureId});
}
Future<void> seekTo(int textureId, Duration position) {
return channel.invokeMethod<void>('seekTo', {
'textureId': textureId,
'location': position.inMilliseconds,
});
}
Future<Duration> getPosition(int textureId) async {
final int? position = await channel.invokeMethod<int>(
'position',
{'textureId': textureId},
);
return Duration(milliseconds: position ?? 0);
}
Stream<VideoEvent> videoEventsFor(int textureId) {
final EventChannel eventChannel = _eventChannels.putIfAbsent(
textureId,
() => EventChannel('flutter.io/videoPlayer/videoEvents$textureId'),
);
return eventChannel.receiveBroadcastStream().map((dynamic event) {
final Map<dynamic, dynamic> map = event as Map<dynamic, dynamic>;
switch (map['event']) {
case 'initialized':
return VideoEventInitialized(
duration: Duration(milliseconds: map['duration'] as int),
size: Size(
(map['width'] as num).toDouble(),
(map['height'] as num).toDouble(),
),
);
case 'completed':
return const VideoEventCompleted();
case 'bufferingStart':
return const VideoEventBufferingStart();
case 'bufferingEnd':
return const VideoEventBufferingEnd();
case 'error':
return VideoEventError(errorDescription: map['errorDescription'] as String);
default:
return const VideoEventError(errorDescription: 'Unknown event');
}
});
}
}
四、应用层封装
4.1 VideoPlayerController
// packages/video_player/lib/video_player.dart
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:video_player_platform_interface/video_player_platform_interface.dart';
export 'package:video_player_platform_interface/video_player_platform_interface.dart'
show DataSource, DataSourceType;
/// 视频播放器控制器
class VideoPlayerController extends ValueNotifier<VideoPlayerValue> {
VideoPlayerController._(this._dataSource)
: super(VideoPlayerValue.uninitialized());
final DataSource _dataSource;
int? _textureId;
StreamSubscription<VideoEvent>? _eventSubscription;
Timer? _timer;
static VideoPlayerPlatform get _platform => VideoPlayerPlatform.instance;
/// 从网络 URL 创建
static VideoPlayerController network(
String url, {
Map<String, String>? httpHeaders,
}) {
return VideoPlayerController._(
DataSource(
sourceType: DataSourceType.network,
uri: url,
httpHeaders: httpHeaders,
),
);
}
/// 从本地文件创建
static VideoPlayerController file(String path) {
return VideoPlayerController._(
DataSource(
sourceType: DataSourceType.file,
uri: path,
),
);
}
/// 从 Asset 创建
static VideoPlayerController asset(
String asset, {
String? package,
}) {
return VideoPlayerController._(
DataSource(
sourceType: DataSourceType.asset,
asset: asset,
package: package,
),
);
}
/// 初始化
Future<void> initialize() async {
await _platform.init();
_textureId = await _platform.create(_dataSource);
if (_textureId == null) {
throw Exception('Failed to create video player');
}
// 监听视频事件
_eventSubscription = _platform.videoEventsFor(_textureId!).listen(
_handleVideoEvent,
onError: (error) {
value = value.copyWith(
hasError: true,
errorDescription: error.toString(),
);
},
);
// 定时更新播放进度
_timer = Timer.periodic(const Duration(milliseconds: 500), (_) {
_updatePosition();
});
}
void _handleVideoEvent(VideoEvent event) {
if (event is VideoEventInitialized) {
value = value.copyWith(
duration: event.duration,
size: event.size,
isInitialized: true,
);
} else if (event is VideoEventCompleted) {
value = value.copyWith(
isPlaying: false,
position: value.duration,
);
} else if (event is VideoEventBufferingStart) {
value = value.copyWith(isBuffering: true);
} else if (event is VideoEventBufferingEnd) {
value = value.copyWith(isBuffering: false);
} else if (event is VideoEventError) {
value = value.copyWith(
hasError: true,
errorDescription: event.errorDescription,
);
}
}
Future<void> _updatePosition() async {
if (_textureId != null && value.isPlaying) {
final position = await _platform.getPosition(_textureId!);
value = value.copyWith(position: position);
}
}
/// 播放
Future<void> play() async {
if (_textureId != null) {
await _platform.play(_textureId!);
value = value.copyWith(isPlaying: true);
}
}
/// 暂停
Future<void> pause() async {
if (_textureId != null) {
await _platform.pause(_textureId!);
value = value.copyWith(isPlaying: false);
}
}
/// 跳转到指定位置
Future<void> seekTo(Duration position) async {
if (_textureId != null) {
await _platform.seekTo(_textureId!, position);
value = value.copyWith(position: position);
}
}
/// 设置循环播放
Future<void> setLooping(bool looping) async {
if (_textureId != null) {
await _platform.setLooping(_textureId!, looping);
value = value.copyWith(isLooping: looping);
}
}
/// 设置音量
Future<void> setVolume(double volume) async {
if (_textureId != null) {
await _platform.setVolume(_textureId!, volume);
value = value.copyWith(volume: volume);
}
}
Future<void> dispose() async {
_timer?.cancel();
_eventSubscription?.cancel();
if (_textureId != null) {
await _platform.dispose(_textureId!);
}
super.dispose();
}
}
/// 视频播放器状态
class VideoPlayerValue {
final Duration duration;
final Duration position;
final Size size;
final bool isInitialized;
final bool isPlaying;
final bool isLooping;
final bool isBuffering;
final double volume;
final bool hasError;
final String? errorDescription;
const VideoPlayerValue({
required this.duration,
required this.position,
required this.size,
required this.isInitialized,
required this.isPlaying,
required this.isLooping,
required this.isBuffering,
required this.volume,
required this.hasError,
this.errorDescription,
});
VideoPlayerValue.uninitialized()
: this(
duration: Duration.zero,
position: Duration.zero,
size: Size.zero,
isInitialized: false,
isPlaying: false,
isLooping: false,
isBuffering: false,
volume: 1.0,
hasError: false,
);
VideoPlayerValue copyWith({
Duration? duration,
Duration? position,
Size? size,
bool? isInitialized,
bool? isPlaying,
bool? isLooping,
bool? isBuffering,
double? volume,
bool? hasError,
String? errorDescription,
}) {
return VideoPlayerValue(
duration: duration ?? this.duration,
position: position ?? this.position,
size: size ?? this.size,
isInitialized: isInitialized ?? this.isInitialized,
isPlaying: isPlaying ?? this.isPlaying,
isLooping: isLooping ?? this.isLooping,
isBuffering: isBuffering ?? this.isBuffering,
volume: volume ?? this.volume,
hasError: hasError ?? this.hasError,
errorDescription: errorDescription ?? this.errorDescription,
);
}
/// 获取播放进度(0.0 - 1.0)
double get progress {
if (duration.inMilliseconds == 0) return 0.0;
return position.inMilliseconds / duration.inMilliseconds;
}
}
本篇小结(第一部分)
这部分我们完成了:
- ✅ 视频播放器方案对比
- ✅ 插件架构设计
- ✅ 接口层设计(VideoPlayerPlatform)
- ✅ MethodChannel 实现
- ✅ 应用层封装(VideoPlayerController)
下一部分我会继续讲解 Android、iOS、HarmonyOS 三端的具体实现。
五、Android 平台实现
5.1 ExoPlayer 集成
Android 平台使用 ExoPlayer 实现视频播放,它比 MediaPlayer 功能更强大:
// packages/video_player_android/android/src/main/kotlin/VideoPlayerPlugin.kt
package io.flutter.plugins.videoplayer
import android.content.Context
import android.view.Surface
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.view.TextureRegistry
import java.util.concurrent.atomic.AtomicInteger
class VideoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
private lateinit var channel: MethodChannel
private lateinit var context: Context
private lateinit var textureRegistry: TextureRegistry
private val videoPlayers = mutableMapOf<Int, VideoPlayer>()
private val textureIdGenerator = AtomicInteger(0)
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
context = binding.applicationContext
textureRegistry = binding.textureRegistry
channel = MethodChannel(binding.binaryMessenger, "flutter.io/videoPlayer")
channel.setMethodCallHandler(this)
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"init" -> result.success(null)
"create" -> {
val textureEntry = textureRegistry.createSurfaceTexture()
val textureId = textureIdGenerator.incrementAndGet()
val dataSource = call.arguments as Map<*, *>
val uri = dataSource["uri"] as? String
val sourceType = dataSource["sourceType"] as? String
if (uri == null) {
result.error("INVALID_ARGS", "URI is required", null)
return
}
val player = VideoPlayer(
context = context,
textureId = textureId,
textureEntry = textureEntry,
uri = uri,
eventChannel = EventChannel(
binding.binaryMessenger,
"flutter.io/videoPlayer/videoEvents$textureId"
)
)
videoPlayers[textureId] = player
result.success(textureId)
}
"dispose" -> {
val textureId = call.argument<Int>("textureId")
videoPlayers[textureId]?.dispose()
videoPlayers.remove(textureId)
result.success(null)
}
"setLooping" -> {
val textureId = call.argument<Int>("textureId")!!
val looping = call.argument<Boolean>("looping")!!
videoPlayers[textureId]?.setLooping(looping)
result.success(null)
}
"setVolume" -> {
val textureId = call.argument<Int>("textureId")!!
val volume = call.argument<Double>("volume")!!
videoPlayers[textureId]?.setVolume(volume.toFloat())
result.success(null)
}
"play" -> {
val textureId = call.argument<Int>("textureId")!!
videoPlayers[textureId]?.play()
result.success(null)
}
"pause" -> {
val textureId = call.argument<Int>("textureId")!!
videoPlayers[textureId]?.pause()
result.success(null)
}
"seekTo" -> {
val textureId = call.argument<Int>("textureId")!!
val location = call.argument<Int>("location")!!
videoPlayers[textureId]?.seekTo(location.toLong())
result.success(null)
}
"position" -> {
val textureId = call.argument<Int>("textureId")!!
val position = videoPlayers[textureId]?.getPosition() ?: 0
result.success(position.toInt())
}
else -> result.notImplemented()
}
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel.setMethodCallHandler(null)
videoPlayers.values.forEach { it.dispose() }
videoPlayers.clear()
}
}
5.2 VideoPlayer 封装
// packages/video_player_android/android/src/main/kotlin/VideoPlayer.kt
class VideoPlayer(
private val context: Context,
private val textureId: Int,
private val textureEntry: TextureRegistry.SurfaceTextureEntry,
private val uri: String,
private val eventChannel: EventChannel
) {
private var exoPlayer: ExoPlayer? = null
private var eventSink: EventChannel.EventSink? = null
private var isInitialized = false
init {
setupPlayer()
setupEventChannel()
}
private fun setupPlayer() {
exoPlayer = ExoPlayer.Builder(context).build().apply {
// 设置视频输出到 Surface
val surface = Surface(textureEntry.surfaceTexture())
setVideoSurface(surface)
// 设置媒体源
val mediaItem = MediaItem.fromUri(uri)
setMediaItem(mediaItem)
// 添加播放器监听
addListener(object : Player.Listener {
override fun onPlaybackStateChanged(state: Int) {
when (state) {
Player.STATE_READY -> {
if (!isInitialized) {
isInitialized = true
sendInitialized()
}
}
Player.STATE_ENDED -> {
sendEvent("completed", emptyMap())
}
Player.STATE_BUFFERING -> {
sendEvent("bufferingStart", emptyMap())
}
}
}
override fun onPlayerError(error: PlaybackException) {
sendEvent("error", mapOf(
"errorDescription" to error.message
))
}
})
// 准备播放器
prepare()
}
}
private fun setupEventChannel() {
eventChannel.setStreamHandler(object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
eventSink = events
}
override fun onCancel(arguments: Any?) {
eventSink = null
}
})
}
private fun sendInitialized() {
exoPlayer?.let { player ->
val videoSize = player.videoSize
sendEvent("initialized", mapOf(
"duration" to player.duration,
"width" to videoSize.width,
"height" to videoSize.height
))
}
}
private fun sendEvent(eventType: String, data: Map<String, Any?>) {
val event = mutableMapOf<String, Any?>("event" to eventType)
event.putAll(data)
eventSink?.success(event)
}
fun play() {
exoPlayer?.play()
}
fun pause() {
exoPlayer?.pause()
}
fun seekTo(position: Long) {
exoPlayer?.seekTo(position)
}
fun setLooping(looping: Boolean) {
exoPlayer?.repeatMode = if (looping) {
Player.REPEAT_MODE_ONE
} else {
Player.REPEAT_MODE_OFF
}
}
fun setVolume(volume: Float) {
exoPlayer?.volume = volume
}
fun getPosition(): Long {
return exoPlayer?.currentPosition ?: 0
}
fun dispose() {
exoPlayer?.release()
exoPlayer = null
textureEntry.release()
}
}
关键点:
- ExoPlayer:使用 Media3 库的 ExoPlayer
- Surface 渲染:通过 TextureRegistry 创建 Surface 渲染视频
- 事件监听:监听播放状态变化,通过 EventChannel 发送事件
- 生命周期管理:正确释放资源
六、iOS 平台实现
6.1 AVPlayer 集成
iOS 平台使用 AVFoundation 框架的 AVPlayer:
// packages/video_player_avfoundation/ios/Classes/VideoPlayerPlugin.swift
import Flutter
import AVFoundation
public class VideoPlayerPlugin: NSObject, FlutterPlugin {
private var registry: FlutterTextureRegistry?
private var messenger: FlutterBinaryMessenger?
private var players: [Int: FLTVideoPlayer] = [:]
private var nextTextureId: Int = 0
public static func register(with registrar: FlutterPluginRegistrar) {
let channel = FlutterMethodChannel(
name: "flutter.io/videoPlayer",
binaryMessenger: registrar.messenger()
)
let instance = VideoPlayerPlugin()
instance.registry = registrar.textures()
instance.messenger = registrar.messenger()
registrar.addMethodCallDelegate(instance, channel: channel)
}
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "init":
result(nil)
case "create":
guard let args = call.arguments as? [String: Any],
let uri = args["uri"] as? String else {
result(FlutterError(code: "INVALID_ARGS", message: "Invalid arguments", details: nil))
return
}
nextTextureId += 1
let textureId = nextTextureId
let player = FLTVideoPlayer(
textureId: textureId,
registry: registry!,
messenger: messenger!,
uri: uri
)
players[textureId] = player
result(textureId)
case "dispose":
guard let args = call.arguments as? [String: Any],
let textureId = args["textureId"] as? Int else {
result(FlutterError(code: "INVALID_ARGS", message: "Invalid arguments", details: nil))
return
}
players[textureId]?.dispose()
players.removeValue(forKey: textureId)
result(nil)
case "setLooping":
guard let args = call.arguments as? [String: Any],
let textureId = args["textureId"] as? Int,
let looping = args["looping"] as? Bool else {
result(FlutterError(code: "INVALID_ARGS", message: "Invalid arguments", details: nil))
return
}
players[textureId]?.setLooping(looping)
result(nil)
case "setVolume":
guard let args = call.arguments as? [String: Any],
let textureId = args["textureId"] as? Int,
let volume = args["volume"] as? Double else {
result(FlutterError(code: "INVALID_ARGS", message: "Invalid arguments", details: nil))
return
}
players[textureId]?.setVolume(Float(volume))
result(nil)
case "play":
guard let args = call.arguments as? [String: Any],
let textureId = args["textureId"] as? Int else {
result(FlutterError(code: "INVALID_ARGS", message: "Invalid arguments", details: nil))
return
}
players[textureId]?.play()
result(nil)
case "pause":
guard let args = call.arguments as? [String: Any],
let textureId = args["textureId"] as? Int else {
result(FlutterError(code: "INVALID_ARGS", message: "Invalid arguments", details: nil))
return
}
players[textureId]?.pause()
result(nil)
case "seekTo":
guard let args = call.arguments as? [String: Any],
let textureId = args["textureId"] as? Int,
let location = args["location"] as? Int else {
result(FlutterError(code: "INVALID_ARGS", message: "Invalid arguments", details: nil))
return
}
players[textureId]?.seekTo(CMTime(value: Int64(location), timescale: 1000))
result(nil)
case "position":
guard let args = call.arguments as? [String: Any],
let textureId = args["textureId"] as? Int else {
result(FlutterError(code: "INVALID_ARGS", message: "Invalid arguments", details: nil))
return
}
let position = players[textureId]?.getPosition() ?? 0
result(position)
default:
result(FlutterMethodNotImplemented)
}
}
}
6.2 FLTVideoPlayer 封装
// packages/video_player_avfoundation/ios/Classes/FLTVideoPlayer.swift
class FLTVideoPlayer: NSObject, FlutterTexture {
private let textureId: Int
private let eventChannel: FlutterEventChannel
private var eventSink: FlutterEventSink?
private var player: AVPlayer?
private var playerItem: AVPlayerItem?
private var videoOutput: AVPlayerItemVideoOutput?
private var displayLink: CADisplayLink?
private var isInitialized = false
private var latestPixelBuffer: CVPixelBuffer?
init(textureId: Int, registry: FlutterTextureRegistry, messenger: FlutterBinaryMessenger, uri: String) {
self.textureId = textureId
self.eventChannel = FlutterEventChannel(
name: "flutter.io/videoPlayer/videoEvents\(textureId)",
binaryMessenger: messenger
)
super.init()
// 注册纹理
registry.register(self)
// 设置事件通道
eventChannel.setStreamHandler(self)
// 初始化播放器
setupPlayer(uri: uri)
}
private func setupPlayer(uri: String) {
guard let url = URL(string: uri) else { return }
// 创建 AVPlayerItem
playerItem = AVPlayerItem(url: url)
// 创建视频输出
let pixelBufferAttributes: [String: Any] = [
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA
]
videoOutput = AVPlayerItemVideoOutput(pixelBufferAttributes: pixelBufferAttributes)
playerItem?.add(videoOutput!)
// 创建播放器
player = AVPlayer(playerItem: playerItem)
// 添加观察者
addObservers()
// 启动显示链接
setupDisplayLink()
}
private func addObservers() {
// 监听播放器状态
playerItem?.addObserver(self, forKeyPath: "status", options: [.new], context: nil)
// 监听播放完成
NotificationCenter.default.addObserver(
self,
selector: #selector(playerDidFinishPlaying),
name: .AVPlayerItemDidPlayToEndTime,
object: playerItem
)
}
override func observeValue(
forKeyPath keyPath: String?,
of object: Any?,
change: [NSKeyValueChangeKey: Any]?,
context: UnsafeMutableRawPointer?
) {
if keyPath == "status" {
if playerItem?.status == .readyToPlay && !isInitialized {
isInitialized = true
sendInitialized()
} else if playerItem?.status == .failed {
sendError("Failed to load video")
}
}
}
@objc private func playerDidFinishPlaying() {
sendEvent("completed", data: [:])
}
private func setupDisplayLink() {
displayLink = CADisplayLink(target: self, selector: #selector(displayLinkCallback))
displayLink?.add(to: .main, forMode: .common)
}
@objc private func displayLinkCallback() {
guard let videoOutput = videoOutput,
let playerItem = playerItem else { return }
let time = playerItem.currentTime()
if videoOutput.hasNewPixelBuffer(forItemTime: time) {
if let pixelBuffer = videoOutput.copyPixelBuffer(forItemTime: time, itemTimeForDisplay: nil) {
latestPixelBuffer = pixelBuffer
}
}
}
// FlutterTexture 协议实现
func copyPixelBuffer() -> Unmanaged<CVPixelBuffer>? {
guard let pixelBuffer = latestPixelBuffer else { return nil }
return Unmanaged.passRetained(pixelBuffer)
}
private func sendInitialized() {
guard let playerItem = playerItem,
let track = playerItem.asset.tracks(withMediaType: .video).first else { return }
let size = track.naturalSize
let duration = CMTimeGetSeconds(playerItem.duration) * 1000
sendEvent("initialized", data: [
"duration": Int(duration),
"width": Int(size.width),
"height": Int(size.height)
])
}
private func sendEvent(_ eventType: String, data: [String: Any]) {
var event: [String: Any] = ["event": eventType]
event.merge(data) { _, new in new }
eventSink?(event)
}
private func sendError(_ message: String) {
sendEvent("error", data: ["errorDescription": message])
}
func play() {
player?.play()
}
func pause() {
player?.pause()
}
func seekTo(_ time: CMTime) {
player?.seek(to: time)
}
func setLooping(_ looping: Bool) {
if looping {
player?.actionAtItemEnd = .none
NotificationCenter.default.addObserver(
self,
selector: #selector(playerItemDidReachEnd),
name: .AVPlayerItemDidPlayToEndTime,
object: playerItem
)
} else {
player?.actionAtItemEnd = .pause
}
}
@objc private func playerItemDidReachEnd() {
player?.seek(to: .zero)
player?.play()
}
func setVolume(_ volume: Float) {
player?.volume = volume
}
func getPosition() -> Int {
guard let player = player else { return 0 }
let time = CMTimeGetSeconds(player.currentTime())
return Int(time * 1000)
}
func dispose() {
displayLink?.invalidate()
player?.pause()
playerItem?.removeObserver(self, forKeyPath: "status")
NotificationCenter.default.removeObserver(self)
player = nil
playerItem = nil
}
}
extension FLTVideoPlayer: FlutterStreamHandler {
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
eventSink = events
return nil
}
func onCancel(withArguments arguments: Any?) -> FlutterError? {
eventSink = nil
return nil
}
}
关键点:
- AVPlayer:iOS 原生视频播放器
- FlutterTexture:实现纹理协议,将视频帧传递给 Flutter
- CADisplayLink:定时获取视频帧
- KVO 观察者:监听播放器状态变化
七、HarmonyOS 平台实现
7.1 AVPlayer 集成
HarmonyOS 使用 @ohos.multimedia.media 模块的 AVPlayer:
// packages/video_player_ohos/ohos/src/main/ets/VideoPlayerPlugin.ets
import { FlutterPlugin, MethodCall, MethodChannel, MethodResult } from '@ohos/flutter_ohos';
import media from '@ohos.multimedia.media';
import { BusinessError } from '@ohos.base';
export class VideoPlayerPlugin implements FlutterPlugin {
private channel: MethodChannel | null = null;
private players: Map<number, VideoPlayer> = new Map();
private nextTextureId: number = 0;
getUniqueClassName(): string {
return "VideoPlayerPlugin";
}
onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding): void {
this.channel = new MethodChannel(
binding.getBinaryMessenger(),
'flutter.io/videoPlayer'
);
this.channel.setMethodCallHandler(this.onMethodCall.bind(this));
}
async onMethodCall(call: MethodCall, result: MethodResult): Promise<void> {
const method = call.method;
try {
if (method === 'init') {
result.success(null);
} else if (method === 'create') {
await this.createPlayer(call, result);
} else if (method === 'dispose') {
await this.disposePlayer(call, result);
} else if (method === 'setLooping') {
await this.setLooping(call, result);
} else if (method === 'setVolume') {
await this.setVolume(call, result);
} else if (method === 'play') {
await this.play(call, result);
} else if (method === 'pause') {
await this.pause(call, result);
} else if (method === 'seekTo') {
await this.seekTo(call, result);
} else if (method === 'position') {
await this.getPosition(call, result);
} else {
result.notImplemented();
}
} catch (err) {
result.error('ERROR', (err as Error).message, null);
}
}
private async createPlayer(call: MethodCall, result: MethodResult): Promise<void> {
const args = call.args as Record<string, any>;
const uri = args['uri'] as string;
if (!uri) {
result.error('INVALID_ARGS', 'URI is required', null);
return;
}
this.nextTextureId++;
const textureId = this.nextTextureId;
const player = new VideoPlayer(
textureId,
uri,
binding.getBinaryMessenger()
);
await player.initialize();
this.players.set(textureId, player);
result.success(textureId);
}
private async disposePlayer(call: MethodCall, result: MethodResult): Promise<void> {
const args = call.args as Record<string, any>;
const textureId = args['textureId'] as number;
const player = this.players.get(textureId);
if (player) {
await player.dispose();
this.players.delete(textureId);
}
result.success(null);
}
private async setLooping(call: MethodCall, result: MethodResult): Promise<void> {
const args = call.args as Record<string, any>;
const textureId = args['textureId'] as number;
const looping = args['looping'] as boolean;
const player = this.players.get(textureId);
if (player) {
await player.setLooping(looping);
}
result.success(null);
}
private async setVolume(call: MethodCall, result: MethodResult): Promise<void> {
const args = call.args as Record<string, any>;
const textureId = args['textureId'] as number;
const volume = args['volume'] as number;
const player = this.players.get(textureId);
if (player) {
await player.setVolume(volume);
}
result.success(null);
}
private async play(call: MethodCall, result: MethodResult): Promise<void> {
const args = call.args as Record<string, any>;
const textureId = args['textureId'] as number;
const player = this.players.get(textureId);
if (player) {
await player.play();
}
result.success(null);
}
private async pause(call: MethodCall, result: MethodResult): Promise<void> {
const args = call.args as Record<string, any>;
const textureId = args['textureId'] as number;
const player = this.players.get(textureId);
if (player) {
await player.pause();
}
result.success(null);
}
private async seekTo(call: MethodCall, result: MethodResult): Promise<void> {
const args = call.args as Record<string, any>;
const textureId = args['textureId'] as number;
const location = args['location'] as number;
const player = this.players.get(textureId);
if (player) {
await player.seekTo(location);
}
result.success(null);
}
private async getPosition(call: MethodCall, result: MethodResult): Promise<void> {
const args = call.args as Record<string, any>;
const textureId = args['textureId'] as number;
const player = this.players.get(textureId);
if (player) {
const position = await player.getPosition();
result.success(position);
} else {
result.success(0);
}
}
onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding): void {
if (this.channel !== null) {
this.channel.setMethodCallHandler(null);
}
// 释放所有播放器
this.players.forEach((player) => {
player.dispose();
});
this.players.clear();
}
}
7.2 VideoPlayer 封装
// packages/video_player_ohos/ohos/src/main/ets/VideoPlayer.ets
import media from '@ohos.multimedia.media';
import { EventChannel } from '@ohos/flutter_ohos';
import { BusinessError } from '@ohos.base';
export class VideoPlayer {
private textureId: number;
private uri: string;
private avPlayer: media.AVPlayer | null = null;
private eventChannel: EventChannel;
private eventSink: any = null;
private isLooping: boolean = false;
private isInitialized: boolean = false;
constructor(textureId: number, uri: string, messenger: any) {
this.textureId = textureId;
this.uri = uri;
// 创建事件通道
this.eventChannel = new EventChannel(
messenger,
`flutter.io/videoPlayer/videoEvents${textureId}`
);
this.setupEventChannel();
}
private setupEventChannel(): void {
this.eventChannel.setStreamHandler({
onListen: (args: any, sink: any) => {
this.eventSink = sink;
},
onCancel: (args: any) => {
this.eventSink = null;
}
});
}
async initialize(): Promise<void> {
try {
// 创建 AVPlayer 实例
this.avPlayer = await media.createAVPlayer();
// 设置状态回调
this.avPlayer.on('stateChange', (state: string) => {
console.info(`[VideoPlayer] State changed: ${state}`);
if (state === 'prepared' && !this.isInitialized) {
this.isInitialized = true;
this.sendInitialized();
} else if (state === 'completed') {
this.sendEvent('completed', {});
// 循环播放
if (this.isLooping) {
this.avPlayer?.seek(0);
this.avPlayer?.play();
}
}
});
// 设置错误回调
this.avPlayer.on('error', (err: BusinessError) => {
console.error(`[VideoPlayer] Error: ${err.message}`);
this.sendEvent('error', {
errorDescription: err.message
});
});
// 设置视频源
this.avPlayer.url = this.uri;
// 准备播放
await this.avPlayer.prepare();
} catch (err) {
console.error(`[VideoPlayer] Initialize failed: ${(err as Error).message}`);
throw err;
}
}
private sendInitialized(): void {
if (this.avPlayer) {
const duration = this.avPlayer.duration;
const width = this.avPlayer.width;
const height = this.avPlayer.height;
this.sendEvent('initialized', {
duration: duration,
width: width,
height: height
});
}
}
private sendEvent(eventType: string, data: Record<string, any>): void {
if (this.eventSink) {
const event: Record<string, any> = { event: eventType };
// 合并数据(ArkTS 不支持对象展开)
for (const key in data) {
event[key] = data[key];
}
this.eventSink.success(event);
}
}
async play(): Promise<void> {
if (this.avPlayer) {
await this.avPlayer.play();
}
}
async pause(): Promise<void> {
if (this.avPlayer) {
await this.avPlayer.pause();
}
}
async seekTo(position: number): Promise<void> {
if (this.avPlayer) {
await this.avPlayer.seek(position);
}
}
async setLooping(looping: boolean): Promise<void> {
this.isLooping = looping;
if (this.avPlayer) {
this.avPlayer.loop = looping;
}
}
async setVolume(volume: number): Promise<void> {
if (this.avPlayer) {
await this.avPlayer.setVolume(volume);
}
}
async getPosition(): Promise<number> {
if (this.avPlayer) {
return this.avPlayer.currentTime;
}
return 0;
}
async dispose(): Promise<void> {
if (this.avPlayer) {
await this.avPlayer.release();
this.avPlayer = null;
}
}
}
关键点:
- AVPlayer API:HarmonyOS 使用
@ohos.multimedia.media模块 - 异步操作:所有操作都是异步的,需要 await
- 状态监听:通过
on('stateChange')监听播放状态 - 循环播放:需要手动实现,监听 completed 事件后重新播放
- ArkTS 限制:不能使用对象展开,需要手动合并属性
八、VideoPlayer Widget
8.1 视频显示组件
// packages/video_player/lib/video_player_widget.dart
import 'package:flutter/material.dart';
import 'video_player.dart';
/// 视频播放器 Widget
class VideoPlayer extends StatefulWidget {
final VideoPlayerController controller;
const VideoPlayer(this.controller, {super.key});
State<VideoPlayer> createState() => _VideoPlayerState();
}
class _VideoPlayerState extends State<VideoPlayer> {
void initState() {
super.initState();
widget.controller.addListener(_onControllerUpdate);
}
void dispose() {
widget.controller.removeListener(_onControllerUpdate);
super.dispose();
}
void _onControllerUpdate() {
setState(() {});
}
Widget build(BuildContext context) {
final controller = widget.controller;
if (!controller.value.isInitialized) {
return const Center(child: CircularProgressIndicator());
}
if (controller.value.hasError) {
return Center(
child: Text(
'播放失败: ${controller.value.errorDescription}',
style: const TextStyle(color: Colors.red),
),
);
}
// 计算视频宽高比
final size = controller.value.size;
final aspectRatio = size.width / size.height;
return AspectRatio(
aspectRatio: aspectRatio,
child: Stack(
fit: StackFit.expand,
children: [
// 视频纹理
Texture(textureId: controller._textureId!),
// 缓冲指示器
if (controller.value.isBuffering)
const Center(
child: CircularProgressIndicator(),
),
],
),
);
}
}
8.2 带控制条的播放器
// lib/shared/widgets/video_player_with_controls.dart
class VideoPlayerWithControls extends StatefulWidget {
final VideoPlayerController controller;
const VideoPlayerWithControls({
required this.controller,
super.key,
});
State<VideoPlayerWithControls> createState() => _VideoPlayerWithControlsState();
}
class _VideoPlayerWithControlsState extends State<VideoPlayerWithControls> {
bool _showControls = true;
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
setState(() {
_showControls = !_showControls;
});
},
child: Stack(
children: [
// 视频播放器
VideoPlayer(widget.controller),
// 控制条
if (_showControls)
Positioned(
bottom: 0,
left: 0,
right: 0,
child: _buildControls(),
),
],
),
);
}
Widget _buildControls() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black.withOpacity(0.7),
],
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 进度条
ValueListenableBuilder<VideoPlayerValue>(
valueListenable: widget.controller,
builder: (context, value, child) {
return Slider(
value: value.progress,
onChanged: (newValue) {
final duration = value.duration;
final position = duration * newValue;
widget.controller.seekTo(position);
},
);
},
),
const SizedBox(height: 8),
// 控制按钮
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 播放/暂停按钮
ValueListenableBuilder<VideoPlayerValue>(
valueListenable: widget.controller,
builder: (context, value, child) {
return IconButton(
icon: Icon(
value.isPlaying ? Icons.pause : Icons.play_arrow,
color: Colors.white,
),
onPressed: () {
if (value.isPlaying) {
widget.controller.pause();
} else {
widget.controller.play();
}
},
);
},
),
const SizedBox(width: 16),
// 时间显示
ValueListenableBuilder<VideoPlayerValue>(
valueListenable: widget.controller,
builder: (context, value, child) {
return Text(
'${_formatDuration(value.position)} / ${_formatDuration(value.duration)}',
style: const TextStyle(color: Colors.white),
);
},
),
],
),
],
),
);
}
String _formatDuration(Duration duration) {
final minutes = duration.inMinutes;
final seconds = duration.inSeconds % 60;
return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
}
}
九、使用示例
9.1 基本使用
// lib/features/video_preview/video_preview_page.dart
class VideoPreviewPage extends StatefulWidget {
final String videoUrl;
const VideoPreviewPage({required this.videoUrl, super.key});
State<VideoPreviewPage> createState() => _VideoPreviewPageState();
}
class _VideoPreviewPageState extends State<VideoPreviewPage> {
late VideoPlayerController _controller;
bool _isInitialized = false;
void initState() {
super.initState();
_initializePlayer();
}
Future<void> _initializePlayer() async {
// 创建控制器
_controller = VideoPlayerController.network(widget.videoUrl);
try {
// 初始化
await _controller.initialize();
// 自动播放
await _controller.play();
setState(() {
_isInitialized = true;
});
} catch (e) {
print('视频初始化失败: $e');
}
}
void dispose() {
_controller.dispose();
super.dispose();
}
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('视频预览'),
),
body: Center(
child: _isInitialized
? VideoPlayerWithControls(controller: _controller)
: const CircularProgressIndicator(),
),
);
}
}
9.2 在 CleanMark AI 中的应用
// lib/features/result/result_page.dart
class ResultPage extends StatefulWidget {
final TaskModel task;
const ResultPage({required this.task, super.key});
State<ResultPage> createState() => _ResultPageState();
}
class _ResultPageState extends State<ResultPage> {
VideoPlayerController? _controller;
void initState() {
super.initState();
if (widget.task.outputFile != null && widget.task.outputFile!.endsWith('.mp4')) {
_initializeVideoPlayer();
}
}
Future<void> _initializeVideoPlayer() async {
_controller = VideoPlayerController.network(widget.task.outputFile!);
await _controller!.initialize();
await _controller!.setLooping(true);
setState(() {});
}
void dispose() {
_controller?.dispose();
super.dispose();
}
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('处理结果'),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// 视频预览
if (_controller != null && _controller!.value.isInitialized)
VideoPlayerWithControls(controller: _controller!),
const SizedBox(height: 24),
// 下载按钮
ElevatedButton.icon(
onPressed: _downloadVideo,
icon: const Icon(Icons.download),
label: const Text('下载视频'),
),
],
),
),
);
}
Future<void> _downloadVideo() async {
// 下载逻辑
}
}
十、性能优化
10.1 内存管理
// 及时释放播放器资源
class VideoPlayerManager {
static final Map<String, VideoPlayerController> _controllers = {};
static VideoPlayerController getOrCreate(String url) {
if (_controllers.containsKey(url)) {
return _controllers[url]!;
}
final controller = VideoPlayerController.network(url);
_controllers[url] = controller;
return controller;
}
static void dispose(String url) {
_controllers[url]?.dispose();
_controllers.remove(url);
}
static void disposeAll() {
_controllers.values.forEach((controller) => controller.dispose());
_controllers.clear();
}
}
10.2 预加载优化
// 提前初始化播放器
class VideoPreloader {
static Future<void> preload(String url) async {
final controller = VideoPlayerController.network(url);
await controller.initialize();
// 不自动播放,只是预加载
}
}
10.3 HarmonyOS 特定优化
// 使用硬件加速
this.avPlayer.videoScaleType = media.VideoScaleType.VIDEO_SCALE_TYPE_FIT;
// 设置缓冲策略
this.avPlayer.on('bufferingUpdate', (infoType: media.BufferingInfoType) => {
console.info(`[VideoPlayer] Buffering: ${infoType}`);
});
十一、常见问题
11.1 HarmonyOS 视频不播放
问题: 视频初始化成功但不播放
原因: 缺少必要的权限或 Surface 未正确设置
解决方案:
// 确保设置了 Surface
if (this.surfaceId) {
this.avPlayer.surfaceId = this.surfaceId;
}
// 检查权限
// module.json5 中添加
"requestPermissions": [
{
"name": "ohos.permission.INTERNET"
}
]
11.2 视频尺寸不正确
问题: 视频显示变形或尺寸错误
解决方案:
// 使用 AspectRatio 保持宽高比
AspectRatio(
aspectRatio: controller.value.size.width / controller.value.size.height,
child: VideoPlayer(controller),
)
11.3 内存泄漏
问题: 播放器未正确释放导致内存泄漏
解决方案:
void dispose() {
// 必须在 dispose 中释放
_controller.dispose();
super.dispose();
}
本篇完整小结
这篇文章我们完成了:
- ✅ 视频播放器方案对比(ExoPlayer、AVPlayer)
- ✅ 插件架构设计(联邦插件模式)
- ✅ 接口层设计(VideoPlayerPlatform)
- ✅ 应用层封装(VideoPlayerController)
- ✅ Android 平台实现(ExoPlayer + Surface)
- ✅ iOS 平台实现(AVPlayer + FlutterTexture)
- ✅ HarmonyOS 平台实现(AVPlayer + 状态监听)
- ✅ VideoPlayer Widget 封装
- ✅ 使用示例和实战应用
- ✅ 性能优化建议
- ✅ 常见问题解决
关键要点:
- 三端使用各自的原生播放器 API
- 通过 Texture 将视频帧传递给 Flutter
- 使用 EventChannel 传递播放状态事件
- HarmonyOS 需要手动实现循环播放
- 注意 ArkTS 的语法限制(不支持对象展开)
- 及时释放播放器资源,避免内存泄漏
HarmonyOS 适配难点:
- 异步操作:所有 API 都是异步的,需要正确处理 Promise
- 状态监听:通过
on('stateChange')监听,状态名称是字符串 - 循环播放:需要手动监听 completed 事件并重新播放
- ArkTS 限制:不能使用对象展开、解构等 ES6+ 特性
思考题
- 为什么要使用 Texture 而不是直接在原生层渲染视频?
- EventChannel 和 MethodChannel 的区别是什么?各自适用什么场景?
- 如何实现视频的后台播放功能?
下一篇预告:第08篇 - 启动页与引导页动画实现
更多推荐


所有评论(0)