恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
Unity原生VideoPlayer实战:手搓一个简易视频播放器
首页
资讯中心
/
Unity原生VideoPlayer实战:手搓一个简易视频播放器
Unity原生VideoPlayer实战:手搓一个简易视频播放器
发布时间:2026/10/18 5:36:26
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在移动端走各自的原生播放器。这意味着它不需要你把视频转成特殊格式常见的MP4H.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设为truePointerUp时设为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 - OverlayCanvas Scaler设为Scale With Screen Size参考分辨率设为1920x1080Match设为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放在右侧里面放一个ScrollRectContent里用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 Actionfloat 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.AddComponentVideoPlayer(); audioSource gameObject.AddComponentAudioSource(); 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.AddComponentEventTrigger(); 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 ListVideoItem videoItems new ListVideoItem(); 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.GetComponentInChildrenText(); btnText.text item.videoName; Button btn go.GetComponentButton(); 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目录下新建一个文件夹叫StreamingAssetsUnity会自动识别这个特殊文件夹。把你要播放的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.maxValue5.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属性控制是否循环。你可以加一个ToggleonValueChanged时设置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里单独设置中文名。