☰
Unity原生VideoPlayer实战:手搓一个简易视频播放器
2026/10/18 5:36:21 网站建设 项目流程

1. 为什么要在Unity里手搓一个播放器

很多人第一次听到“在Unity里做播放器”这个需求,第一反应是:Unity不是做游戏的吗,怎么干起播放器的活了?其实这个需求在工业仿真、展厅互动、教育培训、医疗可视化这些领域里非常常见。你接到的任务可能是给某个数字展厅做一个视频展示模块,也可能是给一套虚拟仿真课件加一个能暂停、快进、切换片源的小控件,甚至是在一个三维场景里嵌入一段产品介绍视频,让用户能自己控制播放进度。

Unity自带的VideoPlayer组件功能其实不弱,但它的默认UI交互几乎为零。你把它拖到场景里,它只会老老实实播放,想要进度条、音量条、播放列表、全屏切换这些,全得自己写。而市面上的视频插件要么太重,要么收费,要么跟你的项目架构格格不入。所以“简易播放器”这个需求的核心,不是做一个功能大而全的万能播放器,而是用最小的代码量,搭出一个够用、好维护、能快速集成到现有项目里的播放控制模块。

这篇文章面向的读者,是已经会用Unity基础操作、知道什么是GameObject和Component、写过简单C#脚本的开发者。如果你刚接触Unity也没关系,我会把每一步的操作意图和背后的逻辑讲清楚,你照着做也能跑起来。最终我们要实现的效果是:一个能在Unity场景中播放视频的播放器,支持播放、暂停、停止、进度拖拽、音量调节、播放进度显示,以及一个简单的播放列表切换功能。整个方案不依赖任何第三方插件,全部用Unity原生API完成。

2. 整体方案设计与技术选型思路

2.1 为什么选VideoPlayer而不是其他方案

Unity里播放视频,历史上出现过好几种方案。早期大家用MovieTexture,但这个组件在Unity 5.6之后就被标记为过时,后来直接移除了。再往后,有人用AVPro Video这类商业插件,功能确实强大,支持硬件解码、多平台适配、透明视频等高级特性,但价格不便宜,而且对于“简易播放器”这个需求来说,属于杀鸡用牛刀。

Unity从2017版本开始正式推出VideoPlayer组件,它基于平台的底层视频解码能力,在Windows上走Media Foundation,在macOS上走AVFoundation,在移动端走各自的原生播放器。这意味着它不需要你把视频转成特殊格式,常见的MP4(H.264编码)直接就能播。VideoPlayer的API设计也比较直观,提供了play、pause、stop、frameReady、seekCompleted等事件和属性,足够我们搭建一个完整的播放控制逻辑。

选VideoPlayer的另一个重要理由是:它和Unity的渲染管线是打通的。你可以把视频渲染到Camera的Near Plane上做全屏背景,也可以渲染到RenderTexture上再贴到某个RawImage或Material上。这就意味着视频可以出现在UI层、也可以出现在三维场景中的某个物体表面,灵活性很高。对于展厅项目里常见的“在弧形屏幕上播放视频”或者“在虚拟电视模型上显示画面”这类需求,VideoPlayer都能直接支持。

2.2 播放器架构的模块划分

虽然说是“简易播放器”,但如果不做模块划分,代码写到后面会变成一坨。我的习惯是把播放器拆成三个部分:播放核心、UI控制层、事件分发层。

播放核心负责持有VideoPlayer组件,管理视频源的加载、播放状态的控制、播放进度的读取。这一层不关心UI长什么样,只提供方法接口,比如Play()、Pause()、Seek(float time)、SetVolume(float v)。

UI控制层负责所有跟用户交互相关的元素:播放按钮、暂停按钮、进度条Slider、音量条Slider、时间显示Text、播放列表按钮等。这一层只调用播放核心暴露的方法,不直接操作VideoPlayer。

事件分发层是一个简单的观察者模式实现,当视频播放完成、进度更新、音量变化时,通知所有关心这些事件的UI元素去刷新显示。这样做的好处是,以后你想加一个“播放速度显示”或者“循环模式切换”,只需要订阅事件,不用去改播放核心的代码。

这个架构听起来有点正式,但实际写下来也就两三百行代码。我试过在好几个项目里用这套结构,后期维护和扩展都很顺手。

2.3 视频源的选择与路径管理

VideoPlayer支持三种视频源:VideoClip(直接拖入视频文件)、URL(本地文件路径或网络地址)、VideoPlayer.source属性对应的VideoSource枚举。对于简易播放器,我建议用URL方式,把视频文件放在StreamingAssets文件夹里,通过Application.streamingAssetsPath拼接路径来加载。

为什么不用VideoClip直接拖?因为VideoClip会把视频文件打包进AssetBundle或者Resources里,视频稍微大一点,包体就爆炸了。而且VideoClip在切换视频时不如URL灵活。用StreamingAssets的好处是,视频文件以原始文件形式存在于发布包中,你可以随时替换视频而不需要重新打包。在Windows平台,StreamingAssets就是发布目录下的一个文件夹,运维人员直接丢视频进去就能用,非常方便。

需要注意的是,不同平台的StreamingAssets路径访问方式不一样。Windows和macOS可以直接用File.ReadAllBytes或者VideoPlayer.url直接读取,但Android平台需要用UnityWebRequest来加载。为了简化,这篇文章以Windows平台为主,Android的适配我会在后面的章节提一下思路。

3. 核心细节解析与实操要点

3.1 VideoPlayer组件的关键参数解读

在Unity编辑器里选中挂有VideoPlayer的物体,Inspector面板里有一堆参数。很多人第一次看会懵,我挑几个最关键的讲。

Source:选URL还是VideoClip。前面说了,用URL。

Play On Awake:这个勾选后,场景一运行视频就自动播放。对于播放器来说,通常不勾,因为你要等用户点播放按钮。但如果你做的是背景视频,可以勾上。

Wait For First Frame:勾上后,VideoPlayer会等第一帧准备好再显示,避免出现黑屏闪烁。建议勾上。

Loop:循环播放。简易播放器可以做成可切换的,默认不循环。

Playback Speed:播放速度。正常是1,可以做0.5倍慢放或者2倍快进。

Render Mode:这个非常重要。有Render Texture、Material Override、API Only、Camera Near Plane几个选项。如果你要把视频显示在UI上,选Render Texture,然后创建一个RenderTexture资产,再把它赋给一个RawImage的Texture。如果你要把视频贴在某个3D物体表面,选Material Override,然后指定那个物体的Renderer和材质属性名(通常是_MainTex)。Camera Near Plane是把视频铺满整个摄像机视野,适合做全屏背景。

Audio Output Mode:音频输出模式。默认是Audio Source,需要你指定一个AudioSource组件来播放声音。也可以选Direct,直接输出到系统音频设备。我一般用Audio Source,因为这样可以通过AudioSource控制音量,和Unity的音频系统统一管理。

Track Enabled:如果视频有多条音轨或字幕轨,可以在这里勾选启用哪一条。

这些参数里,最容易踩坑的是Render Mode和Audio Output Mode的配合。如果你选了Render Texture但忘了创建RenderTexture资产,视频就渲染不出来。如果你选了Audio Source但场景里没有AudioSource组件,视频就没声音。

3.2 进度条拖拽的数学原理与实现

进度条是播放器里交互最复杂的部分。Unity的Slider组件本身提供了onValueChanged事件,但直接拿它来拖拽会有问题:当视频播放时,你每帧更新Slider.value,会触发onValueChanged,如果你在这个事件里调用Seek,就会造成死循环或者拖拽冲突。

正确的做法是区分“用户拖拽”和“程序更新”两种状态。我通常给Slider加一个bool变量isDragging,在Slider的EventTrigger里监听PointerDown和PointerUp事件,PointerDown时把isDragging设为true,PointerUp时设为false。然后在Update里,只有当isDragging为false时,才用videoPlayer.time / videoPlayer.clip.length去更新Slider.value。当isDragging为true时,读取Slider.value乘以视频总时长,调用videoPlayer.time = targetTime来跳转。

这里有个细节:videoPlayer.time的单位是秒,而Slider的minValue和maxValue默认是0和1。你可以把Slider的maxValue设为视频总时长,这样Slider.value就直接是秒数,省去换算。但视频总时长在视频加载完成前是未知的,所以需要在videoPlayer.prepareCompleted事件里动态设置Slider.maxValue。

还有一个坑:VideoPlayer的seek操作不是瞬间完成的,它有一个seekCompleted事件。如果你在拖拽过程中频繁调用seek,可能会造成卡顿。我的做法是,拖拽过程中只更新UI上的时间显示,不实际seek,等PointerUp的时候再执行一次seek。这样用户体验更流畅,性能也更好。

3.3 播放列表的数据结构设计

播放列表听起来简单,但设计不好后期很难扩展。我用的是一个List ,VideoItem是一个自定义类,包含三个字段:string videoName(显示名称)、string videoPath(文件路径)、Texture thumbnail(缩略图,可选)。

为什么不用简单的string数组?因为实际项目里,你很可能需要给每个视频加一个封面图、一段描述文字、甚至一个分类标签。用类来封装,以后加字段很方便。缩略图可以在视频加载完成后,用VideoPlayer的frameReady事件抓取一帧,转成Texture2D存起来。不过这个操作比较耗时,简易播放器可以先不做,用默认图标代替。

播放列表的UI可以用VerticalLayoutGroup加ScrollRect来做,每个列表项是一个Button,点击时调用播放核心的LoadVideo(videoPath)方法。当前播放的列表项可以高亮显示,用一个Image组件的color变化来实现。

3.4 音量控制的平滑过渡技巧

音量控制看起来就是设置AudioSource.volume,但直接设置会有个问题:从0突然跳到1,声音会“砰”的一下爆出来,体验很差。我的做法是加一个平滑过渡,用Mathf.Lerp在几帧内把音量从当前值过渡到目标值。

具体实现是,在Update里检测目标音量和当前音量的差值,如果大于0.01,就用Mathf.Lerp(currentVolume, targetVolume, Time.deltaTime * 5f)来逼近。这样音量变化就是渐进的,听感舒服很多。这个技巧在VR项目里尤其重要,因为VR里突然的声音变化会让人不适。

另外,AudioSource的volume范围是0到1,但Slider的value也是0到1,所以可以直接映射。如果你想让音量感知更符合人耳特性,可以用Mathf.Log或者Mathf.Pow做非线性映射,但简易播放器用线性就够了。

4. 完整实操流程与核心代码实现

4.1 场景搭建与UI布局

先在Unity里新建一个场景,命名为VideoPlayerScene。在Hierarchy里右键,UI -> Canvas,创建一个画布。Canvas的Render Mode设为Screen Space - Overlay,Canvas Scaler设为Scale With Screen Size,参考分辨率设为1920x1080,Match设为0.5。这样UI在不同分辨率下都能正常显示。

在Canvas下创建以下UI元素:

  • 一个RawImage,命名为VideoDisplay,锚点设为stretch-stretch,左右上下边距都设为0,作为视频显示区域。
  • 一个Panel,命名为ControlPanel,锚点设为bottom-stretch,高度设为120,作为底部控制栏。
  • 在ControlPanel里创建:一个Button叫PlayButton,一个Button叫PauseButton,一个Button叫StopButton,一个Slider叫ProgressSlider,一个Slider叫VolumeSlider,一个Text叫TimeText,一个Text叫VolumeText。
  • 再创建一个Panel叫PlaylistPanel,放在右侧,里面放一个ScrollRect,Content里用VerticalLayoutGroup排列播放列表项。

布局这东西没有标准答案,你觉得顺眼就行。但有个原则:控制栏的按钮要足够大,至少60x60像素,方便触屏操作。进度条要足够宽,至少占屏幕宽度的60%。

4.2 播放核心脚本的编写

新建一个C#脚本,命名为VideoPlayerCore.cs。这个脚本挂在一个空物体上,比如叫VideoManager。代码如下:

using UnityEngine; using UnityEngine.Video; using System; using System.Collections.Generic; public class VideoPlayerCore : MonoBehaviour { public static VideoPlayerCore Instance; private VideoPlayer videoPlayer; private AudioSource audioSource; public event Action OnVideoPrepared; public event Action OnVideoStarted; public event Action OnVideoPaused; public event Action OnVideoStopped; public event Action<float> OnProgressUpdated; public event Action OnVideoCompleted; private float targetVolume = 1f; private bool isPrepared = false; void Awake() { if (Instance == null) Instance = this; else Destroy(gameObject); videoPlayer = gameObject.AddComponent<VideoPlayer>(); audioSource = gameObject.AddComponent<AudioSource>(); videoPlayer.playOnAwake = false; videoPlayer.waitForFirstFrame = true; videoPlayer.source = VideoSource.Url; videoPlayer.audioOutputMode = VideoAudioOutputMode.AudioSource; videoPlayer.SetTargetAudioSource(0, audioSource); videoPlayer.renderMode = VideoRenderMode.RenderTexture; videoPlayer.prepareCompleted += OnPrepareCompleted; videoPlayer.started += OnStarted; videoPlayer.seekCompleted += OnSeekCompleted; videoPlayer.loopPointReached += OnLoopPointReached; } void Update() { if (videoPlayer.isPlaying && isPrepared) { float progress = (float)(videoPlayer.time / videoPlayer.length); OnProgressUpdated?.Invoke(progress); } if (Mathf.Abs(audioSource.volume - targetVolume) > 0.01f) { audioSource.volume = Mathf.Lerp(audioSource.volume, targetVolume, Time.deltaTime * 5f); } } public void LoadVideo(string url, RenderTexture renderTexture) { isPrepared = false; videoPlayer.targetTexture = renderTexture; videoPlayer.url = url; videoPlayer.Prepare(); } public void Play() { if (isPrepared) { videoPlayer.Play(); } } public void Pause() { if (videoPlayer.isPlaying) { videoPlayer.Pause(); OnVideoPaused?.Invoke(); } } public void Stop() { videoPlayer.Stop(); OnVideoStopped?.Invoke(); } public void Seek(float normalizedTime) { if (isPrepared) { videoPlayer.time = normalizedTime * videoPlayer.length; } } public void SetVolume(float volume) { targetVolume = Mathf.Clamp01(volume); } public float GetCurrentTime() { return (float)videoPlayer.time; } public float GetTotalTime() { return (float)videoPlayer.length; } public bool IsPlaying() { return videoPlayer.isPlaying; } private void OnPrepareCompleted(VideoPlayer vp) { isPrepared = true; OnVideoPrepared?.Invoke(); } private void OnStarted(VideoPlayer vp) { OnVideoStarted?.Invoke(); } private void OnSeekCompleted(VideoPlayer vp) { // 可以在这里做一些seek后的同步操作 } private void OnLoopPointReached(VideoPlayer vp) { OnVideoCompleted?.Invoke(); } }

这段代码的核心逻辑是:Awake里初始化VideoPlayer和AudioSource,绑定事件。LoadVideo方法接收视频路径和RenderTexture,调用Prepare开始准备。Prepare完成后触发OnPrepareCompleted,外部UI可以在这个事件里更新进度条的最大值。Update里每帧计算播放进度并广播出去,同时处理音量的平滑过渡。

4.3 UI控制脚本的编写

再新建一个脚本,叫VideoPlayerUI.cs,挂在Canvas上。这个脚本负责把UI元素和播放核心连接起来。

using UnityEngine; using UnityEngine.UI; using UnityEngine.EventSystems; public class VideoPlayerUI : MonoBehaviour { public RawImage videoDisplay; public Button playButton; public Button pauseButton; public Button stopButton; public Slider progressSlider; public Slider volumeSlider; public Text timeText; public Text volumeText; public RenderTexture renderTexture; private bool isDraggingProgress = false; private VideoPlayerCore core; void Start() { core = VideoPlayerCore.Instance; videoDisplay.texture = renderTexture; playButton.onClick.AddListener(OnPlayClicked); pauseButton.onClick.AddListener(OnPauseClicked); stopButton.onClick.AddListener(OnStopClicked); volumeSlider.onValueChanged.AddListener(OnVolumeChanged); AddSliderEvents(progressSlider); core.OnVideoPrepared += OnVideoPrepared; core.OnProgressUpdated += OnProgressUpdated; core.OnVideoCompleted += OnVideoCompleted; volumeSlider.value = 1f; } void Update() { if (!isDraggingProgress && core.IsPlaying()) { float progress = core.GetCurrentTime() / core.GetTotalTime(); progressSlider.value = progress; UpdateTimeText(core.GetCurrentTime(), core.GetTotalTime()); } } private void AddSliderEvents(Slider slider) { EventTrigger trigger = slider.gameObject.AddComponent<EventTrigger>(); EventTrigger.Entry downEntry = new EventTrigger.Entry(); downEntry.eventID = EventTriggerType.PointerDown; downEntry.callback.AddListener((data) => { isDraggingProgress = true; }); trigger.triggers.Add(downEntry); EventTrigger.Entry upEntry = new EventTrigger.Entry(); upEntry.eventID = EventTriggerType.PointerUp; upEntry.callback.AddListener((data) => { isDraggingProgress = false; core.Seek(progressSlider.value); }); trigger.triggers.Add(upEntry); } private void OnPlayClicked() { core.Play(); } private void OnPauseClicked() { core.Pause(); } private void OnStopClicked() { core.Stop(); progressSlider.value = 0f; UpdateTimeText(0, core.GetTotalTime()); } private void OnVolumeChanged(float value) { core.SetVolume(value); volumeText.text = Mathf.RoundToInt(value * 100) + "%"; } private void OnVideoPrepared() { progressSlider.value = 0f; UpdateTimeText(0, core.GetTotalTime()); } private void OnProgressUpdated(float progress) { if (!isDraggingProgress) { progressSlider.value = progress; } } private void OnVideoCompleted() { progressSlider.value = 1f; } private void UpdateTimeText(float current, float total) { int currentMin = Mathf.FloorToInt(current / 60); int currentSec = Mathf.FloorToInt(current % 60); int totalMin = Mathf.FloorToInt(total / 60); int totalSec = Mathf.FloorToInt(total % 60); timeText.text = string.Format("{0:00}:{1:00} / {2:00}:{3:00}", currentMin, currentSec, totalMin, totalSec); } }

这个脚本里,AddSliderEvents方法给进度条添加了PointerDown和PointerUp的事件监听,用来区分用户拖拽和程序更新。Update里只在非拖拽状态下更新进度条,避免冲突。时间显示的格式化用了string.Format,把秒数转成“分:秒”的形式。

4.4 播放列表的加载与切换

播放列表的脚本叫VideoPlaylist.cs,挂在PlaylistPanel上。

using UnityEngine; using UnityEngine.UI; using System.Collections.Generic; using System.IO; [System.Serializable] public class VideoItem { public string videoName; public string videoFileName; } public class VideoPlaylist : MonoBehaviour { public GameObject itemPrefab; public Transform contentParent; public RenderTexture renderTexture; private List<VideoItem> videoItems = new List<VideoItem>(); private string streamingAssetsPath; void Start() { streamingAssetsPath = Application.streamingAssetsPath; // 这里可以改成从配置文件读取,简易版直接扫描文件夹 string[] files = Directory.GetFiles(streamingAssetsPath, "*.mp4"); foreach (string file in files) { VideoItem item = new VideoItem(); item.videoName = Path.GetFileNameWithoutExtension(file); item.videoFileName = Path.GetFileName(file); videoItems.Add(item); } GeneratePlaylistUI(); } private void GeneratePlaylistUI() { foreach (VideoItem item in videoItems) { GameObject go = Instantiate(itemPrefab, contentParent); Text btnText = go.GetComponentInChildren<Text>(); btnText.text = item.videoName; Button btn = go.GetComponent<Button>(); string fileName = item.videoFileName; btn.onClick.AddListener(() => OnVideoItemClicked(fileName)); } } private void OnVideoItemClicked(string fileName) { string fullPath = Path.Combine(streamingAssetsPath, fileName); VideoPlayerCore.Instance.LoadVideo(fullPath, renderTexture); } }

这个脚本在Start里扫描StreamingAssets文件夹下所有mp4文件,生成播放列表项。每个列表项是一个Button,点击时调用LoadVideo加载对应的视频。itemPrefab是一个简单的Button预制体,里面包含一个Text组件。

4.5 视频文件的准备与路径配置

在Assets目录下新建一个文件夹叫StreamingAssets(Unity会自动识别这个特殊文件夹)。把你要播放的mp4文件放进去。注意视频编码要用H.264,这是兼容性最好的编码格式。音频编码用AAC。

在Windows平台,Application.streamingAssetsPath返回的是“发布目录/项目名_Data/StreamingAssets”。在编辑器里运行时,返回的是“Assets/StreamingAssets”。所以你在编辑器里测试时,视频文件放在Assets/StreamingAssets下就能直接读取。

如果你要发布到Android平台,StreamingAssets的路径是“jar:file:///data/app/包名.apk!/assets”,这个路径不能用File.ReadAllBytes直接读,需要用UnityWebRequest来加载。简易播放器可以先不考虑Android,等Windows版本跑通了再适配。

5. 常见问题与排查技巧实录

5.1 视频播放没声音怎么办

这是最常见的问题。排查顺序如下:

第一,检查VideoPlayer的Audio Output Mode是不是设成了Audio Source。如果设成了None,那肯定没声音。如果设成了Direct,声音会直接输出到系统,不经过AudioSource,这时候你调AudioSource.volume是没用的。

第二,检查场景里有没有AudioSource组件。VideoPlayer需要你手动指定一个AudioSource来播放音频。如果忘了加,就没声音。

第三,检查AudioSource的Volume是不是被设成了0。有时候你在代码里初始化音量时不小心设成了0,后面又没恢复。

第四,检查视频文件本身有没有音轨。有些视频文件是纯视频无音频的,这种你用播放器打开也没声音。

第五,检查Unity的Audio Listener在不在场景里。如果场景里没有Audio Listener,所有声音都听不到。

5.2 进度条拖拽后视频卡住不动

这个问题通常是因为seek操作太频繁导致的。VideoPlayer的seek是一个异步操作,你连续调用多次seek,它可能处理不过来,就卡住了。

解决方法就是我前面说的,拖拽过程中不seek,只在PointerUp的时候seek一次。另外,seek的目标时间要clamp在0到videoPlayer.length之间,超出范围可能导致异常。

还有一个可能的原因是,你在seek之后没有重新Play。有些情况下,seek完成后视频会暂停,你需要在seekCompleted事件里检查一下,如果之前是播放状态,就重新调用Play。

5.3 视频显示黑屏或花屏

黑屏的原因有很多。首先检查RenderTexture有没有创建,分辨率是不是设得太小。RenderTexture的分辨率建议设成1920x1080或者跟视频源分辨率一致。如果设成0x0,那就什么都渲染不出来。

其次检查RawImage的Texture有没有赋上RenderTexture。有时候你在代码里动态创建RenderTexture,但忘了赋给RawImage。

花屏通常是视频编码问题。Unity的VideoPlayer在Windows上依赖系统的解码器,如果你的视频用了比较冷门的编码格式(比如H.265/HEVC),系统可能没有对应的解码器,就会花屏或者无法播放。解决办法是用H.264重新编码。

5.4 播放列表切换时视频加载慢

每次切换视频都要重新Prepare,这个过程需要几百毫秒到几秒不等,取决于视频文件的大小和硬盘速度。优化思路有两个:一是预加载,在播放当前视频的时候,后台Prepare下一个视频;二是用对象池,不要每次切换都销毁重建VideoPlayer,而是复用同一个VideoPlayer,只换url。

预加载的实现稍微复杂一点,需要两个VideoPlayer实例交替使用。简易播放器可以先不做,等有性能需求了再优化。

5.5 常见问题速查表

问题现象可能原因排查方法解决方案
视频无画面RenderTexture未赋值检查RawImage.texture创建RenderTexture并赋值
视频无声音AudioOutputMode错误检查VideoPlayer设置改为AudioSource并指定AudioSource
进度条拖拽无效事件被覆盖检查EventTrigger确保PointerDown/Up事件正确绑定
视频卡顿视频码率过高查看视频文件信息转码为较低码率的H.264
切换视频黑屏上一个视频未停止检查Stop调用切换前先Stop再Load
时间显示不对总时长未更新检查prepareCompleted在事件里更新Slider.maxValue

5.6 几个我踩过的坑

第一个坑:VideoPlayer的time属性在视频未Prepare完成时读取会返回0,如果你在Prepare完成前就用time去计算进度,会得到错误的结果。所以一定要在prepareCompleted事件之后才开始更新进度条。

第二个坑:Slider的onValueChanged事件在代码里设置value时也会触发。如果你在OnProgressUpdated里设置progressSlider.value,而onValueChanged里又调用了Seek,就会造成循环。我的做法是,onValueChanged里不直接Seek,而是通过isDragging标志来判断。

第三个坑:StreamingAssets里的视频文件在发布后是只读的,你不能在运行时往里面写文件。如果你需要录制视频或者缓存视频,要用Application.persistentDataPath。

第四个坑:在编辑器里运行时,修改了StreamingAssets里的视频文件,Unity不会自动刷新。你需要重新导入或者重启编辑器才能看到变化。

6. 功能扩展与性能优化建议

6.1 加一个全屏切换按钮

全屏切换的思路很简单:把VideoDisplay的RectTransform从stretch-stretch改成覆盖整个屏幕,同时隐藏ControlPanel和PlaylistPanel。再点一次恢复原状。代码就是一个bool变量控制。

但要注意,全屏后控制栏隐藏了,用户就没法退出全屏了。所以全屏状态下要保留一个小的退出按钮,或者支持按ESC键退出。ESC键的检测在Update里用Input.GetKeyDown(KeyCode.Escape)就行。

6.2 加一个播放速度控制

VideoPlayer的playbackSpeed属性可以直接设置播放速度。你可以加三个按钮:0.5x、1x、2x。点击时调用videoPlayer.playbackSpeed = 0.5f等。注意,改变播放速度后,进度条的计算要跟着调整,因为time的增长速度变了。

6.3 加一个循环播放开关

VideoPlayer的isLooping属性控制是否循环。你可以加一个Toggle,onValueChanged时设置videoPlayer.isLooping = value。循环播放时,loopPointReached事件不会触发,所以OnVideoCompleted不会调用,这是正常的。

6.4 性能优化:减少每帧的UI更新

Update里每帧更新进度条和时间显示,虽然开销不大,但在低端设备上还是能省则省。优化方法是把更新频率从每帧改成每0.1秒一次,用Time.time做累加计时。这样UI更新频率降到10Hz,肉眼几乎看不出差别,但CPU占用会降低。

6.5 性能优化:视频预加载策略

前面提到过,切换视频时的Prepare耗时是体验瓶颈。一个实用的优化是,在播放列表里,当用户鼠标悬停在某个列表项上时,就开始后台Prepare那个视频。这样用户点击时,视频已经准备好了,切换几乎是瞬间的。实现方式是给列表项加一个IPointerEnterHandler接口,在OnPointerEnter里调用一个预加载方法。

预加载需要第二个VideoPlayer实例,因为一个VideoPlayer同时只能Prepare一个视频。你可以创建两个VideoPlayer,交替使用。当前播放的用A,预加载的用B。切换时,把B设为当前,A去预加载下一个。这个逻辑稍微复杂一点,但效果很明显。

6.6 适配移动端的注意事项

如果要把这个播放器发布到Android或iOS,有几个地方要改。第一,StreamingAssets的路径要用UnityWebRequest来加载,不能直接用File。第二,移动端的视频解码能力有限,建议视频分辨率不要超过1080p,码率不要超过5Mbps。第三,移动端的内存比较紧张,RenderTexture的分辨率要适当降低,比如720p就够了。第四,触屏操作没有鼠标悬停,所以预加载策略要改成基于滚动位置的预加载。

7. 个人实操体会与最后的小技巧

这套简易播放器的方案,我在好几个展厅项目和仿真课件里用过,整体稳定性不错。VideoPlayer组件虽然有一些小毛病,比如seek偶尔不精准、Prepare耗时较长,但对于非专业视频播放场景来说,完全够用。

我个人在实际操作中的体会是,进度条的拖拽体验是播放器好坏的关键。用户对播放器的容忍度很低,如果拖拽不跟手、跳转有延迟,就会觉得这个播放器很“劣质”。所以我在进度条上花的时间最多,反复调整了拖拽的灵敏度和seek的时机。最后发现,拖拽时不seek、松手时才seek,是最流畅的方案。

最后再分享一个小技巧:如果你想让视频在播放结束后自动切换到下一个,可以在OnVideoCompleted事件里,读取播放列表的当前索引,加一,然后加载下一个视频。记得处理索引越界的情况,如果已经是最后一个,就回到第一个,实现循环播放列表。这个功能在展厅场景里很实用,可以无人值守循环播放。

另外,视频文件的命名建议用英文或拼音,不要用中文。虽然Unity在Windows上支持中文路径,但在某些平台或者打包后可能会出现乱码问题。用英文命名最稳妥,显示名称可以在播放列表的VideoItem里单独设置中文名。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询