实现一个支持三端的视频播放器插件

前言

视频播放是 CleanMark AI 的核心功能之一,用于预览去水印后的视频效果。视频播放器的实现比图片选择器复杂得多,涉及:

  • 视频解码和渲染
  • 播放控制(播放、暂停、进度)
  • 生命周期管理
  • 性能优化

这篇文章我会带你实现一个完整的视频播放器插件,重点讲解 HarmonyOS 的适配难点。


一、视频播放器方案对比

1.1 三端原生方案

平台 原生方案 特点
Android ExoPlayer / MediaPlayer ExoPlayer 功能强大,MediaPlayer 简单易用
iOS AVPlayer 功能完善,性能优秀
HarmonyOS AVPlayer API 类似 iOS,但有差异

1.2 Flutter 官方插件

Flutter 官方提供了 video_player 插件,但不支持 HarmonyOS。我们需要:

  1. 使用官方插件的接口层
  2. 自己实现 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;
  }
}

本篇小结(第一部分)

这部分我们完成了:

  1. ✅ 视频播放器方案对比
  2. ✅ 插件架构设计
  3. ✅ 接口层设计(VideoPlayerPlatform)
  4. ✅ MethodChannel 实现
  5. ✅ 应用层封装(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()
    }
}

关键点:

  1. ExoPlayer:使用 Media3 库的 ExoPlayer
  2. Surface 渲染:通过 TextureRegistry 创建 Surface 渲染视频
  3. 事件监听:监听播放状态变化,通过 EventChannel 发送事件
  4. 生命周期管理:正确释放资源

六、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
    }
}

关键点:

  1. AVPlayer:iOS 原生视频播放器
  2. FlutterTexture:实现纹理协议,将视频帧传递给 Flutter
  3. CADisplayLink:定时获取视频帧
  4. 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;
    }
  }
}

关键点:

  1. AVPlayer API:HarmonyOS 使用 @ohos.multimedia.media 模块
  2. 异步操作:所有操作都是异步的,需要 await
  3. 状态监听:通过 on('stateChange') 监听播放状态
  4. 循环播放:需要手动实现,监听 completed 事件后重新播放
  5. 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();
}

本篇完整小结

这篇文章我们完成了:

  1. ✅ 视频播放器方案对比(ExoPlayer、AVPlayer)
  2. ✅ 插件架构设计(联邦插件模式)
  3. ✅ 接口层设计(VideoPlayerPlatform)
  4. ✅ 应用层封装(VideoPlayerController)
  5. ✅ Android 平台实现(ExoPlayer + Surface)
  6. ✅ iOS 平台实现(AVPlayer + FlutterTexture)
  7. ✅ HarmonyOS 平台实现(AVPlayer + 状态监听)
  8. ✅ VideoPlayer Widget 封装
  9. ✅ 使用示例和实战应用
  10. ✅ 性能优化建议
  11. ✅ 常见问题解决

关键要点:

  • 三端使用各自的原生播放器 API
  • 通过 Texture 将视频帧传递给 Flutter
  • 使用 EventChannel 传递播放状态事件
  • HarmonyOS 需要手动实现循环播放
  • 注意 ArkTS 的语法限制(不支持对象展开)
  • 及时释放播放器资源,避免内存泄漏

HarmonyOS 适配难点:

  1. 异步操作:所有 API 都是异步的,需要正确处理 Promise
  2. 状态监听:通过 on('stateChange') 监听,状态名称是字符串
  3. 循环播放:需要手动监听 completed 事件并重新播放
  4. ArkTS 限制:不能使用对象展开、解构等 ES6+ 特性

思考题

  1. 为什么要使用 Texture 而不是直接在原生层渲染视频?
  2. EventChannel 和 MethodChannel 的区别是什么?各自适用什么场景?
  3. 如何实现视频的后台播放功能?

下一篇预告:第08篇 - 启动页与引导页动画实现

更多推荐