1. 项目概述:为什么要在Unity里调用Android原生能力播放视频?
如果你做过Unity移动端开发,尤其是需要播放高清、复杂格式视频或者有特殊播放器UI需求的项目,大概率遇到过Unity内置VideoPlayer组件“力不从心”的情况。比如,播放某些编码的MP4文件直接黑屏,或者遇到音画不同步、内存占用飙升、甚至在某些低端Android设备上直接崩溃。这时候,一个自然的想法就是:能不能绕过Unity,直接调用Android系统原生的媒体播放能力?答案是肯定的,而且这在很多商业项目中已经是成熟方案。
这个项目的核心,就是在Unity应用中,通过C#脚本与Android Java代码进行交互,将Android原生的MediaPlayer或ExoPlayer等强大播放器的渲染画面,“喂”给Unity的Texture2D,最终在Unity的RawImage或材质球上显示出来。这相当于在Unity的“壳”里,嵌入了一个原生的Android视频播放引擎。这么做的优势非常明显:首先,你能获得与系统自带播放器一致的格式兼容性和解码性能,特别是对HEVC(H.265)、VP9等现代编码的支持;其次,原生播放器在硬解优化、功耗控制、DRM(数字版权管理)支持上通常比Unity内置方案更成熟稳定;最后,你还能利用Android MediaPlayer/ExoPlayer丰富的API,实现精准的播放控制、音轨字幕切换等高级功能。
简单来说,这不是一个简单的插件调用,而是一次深度的“跨界合作”。你需要理解Unity的渲染管线、Android的SurfaceView/TextureView机制,以及如何在两者之间搭建一座高效、稳定的数据桥梁。整个过程涉及Unity C#、Android Java、JNI交互、纹理内存管理等多个技术栈的交汇。接下来,我会以一个实际可运行的案例,带你一步步拆解其中的关键技术与实现细节。
2. 核心方案设计与技术选型
在动手写代码之前,我们必须先确定技术路线。核心问题有两个:一是在Android端用什么来播放视频,二是如何把播放的画面传递给Unity。
2.1 Android端播放器选型:MediaPlayer vs. ExoPlayer
在Android生态中,主要有两个选择:系统自带的MediaPlayer和Google开源的ExoPlayer。
MediaPlayer是Android框架的核心组件,它的优点是集成度高、API简单、系统兼容性好(几乎覆盖所有Android版本)。对于播放本地文件或简单网络流,它是一个轻量级的选择。但是,它的缺点也很突出:功能相对基础,定制化能力弱(比如难以自定义渲染器),在不同厂商设备上的解码行为可能存在差异,而且对现代流媒体协议(如DASH、HLS)的支持需要较高系统版本。
ExoPlayer则是一个功能强大的开源播放器库。它被广泛应用于YouTube、Netflix等主流应用中。其优势在于高度模块化、可扩展性强,支持丰富的媒体格式、自适应流(DASH, HLS, SmoothStreaming)、先进的DRM方案,并且拥有活跃的社区和持续的更新。对于项目要求高、需要处理复杂流媒体或深度定制播放逻辑的场景,ExoPlayer几乎是首选。
选择建议:如果你的项目只需要播放本地或简单网络MP4文件,且对包体大小敏感,可以优先考虑
MediaPlayer。但如果涉及流媒体、需要更好的兼容性控制、或未来有功能扩展需求,强烈推荐使用ExoPlayer。考虑到项目的通用性和前瞻性,下文我们将以功能更强大的ExoPlayer作为实现基础。
2.2 Unity与Android的渲染桥接方案
这是整个项目的技术核心。如何把Android播放器解码后的视频帧,变成Unity里的一张纹理(Texture2D)?主要有三种主流思路:
- 通过Android的
SurfaceTexture共享纹理:这是最高效、最主流的方法。在Android端,我们可以创建一个SurfaceTexture,并将其关联到一个OpenGL ES纹理ID上。然后,让ExoPlayer的输出渲染到这个SurfaceTexture上。在Unity端(C#),我们可以通过Android JNI获取到这个纹理ID,并使用Texture2D.CreateExternalTexture方法,在Unity内部创建一个“外部纹理”,该纹理直接指向Android端的那块GPU显存。这样,视频帧数据就在GPU内存中共享,避免了CPU内存的来回拷贝,性能损耗极低。 - 截图回传:在Android端定期(如每帧)对播放视图进行截图,生成Bitmap,然后通过JNI将像素数据(byte[])传回Unity,Unity再将其更新到
Texture2D中。这种方法实现简单,但数据需要在CPU内存间频繁拷贝和传输,性能开销巨大,仅适用于极低帧率或静态画面展示,不适用于实时视频播放。 - 使用本地插件封装:有些第三方插件(如AVPro Video)采用更底层的C/C++代码,直接处理平台相关的解码和渲染。这通常能获得最佳性能,但开发复杂度最高,需要深厚的跨平台图形编程经验。
毫无疑问,方案一(SurfaceTexture共享纹理)是平衡性能、复杂度和可控性的最佳选择,也是我们本次实现的重点。
2.3 项目整体架构图(逻辑描述)
整个数据流可以这样理解:
- Unity (C#) 发起:在Unity场景中,一个C#脚本(如
AndroidVideoPlayer.cs)初始化时,通过JNI调用Android侧的一个Java类(如UnityVideoPlayer)。 - Android (Java) 创建播放环境:Java类收到调用后,执行以下操作:
- 初始化ExoPlayer实例。
- 创建一个
SurfaceTexture对象,并获取其对应的OpenGL ES纹理ID(这是一个int型句柄)。 - 将这个
SurfaceTexture设置给ExoPlayer作为渲染目标。 - 将这个纹理ID通过JNI回传给Unity的C#脚本。
- Unity (C#) 创建外部纹理:C#脚本拿到纹理ID后,调用
Texture2D.CreateExternalTexture,创建一个Unity的Texture2D对象,但其底层数据直接关联到Android端那个纹理ID所代表的GPU内存。 - 渲染循环:
- ExoPlayer开始播放,将解码后的视频帧渲染到
SurfaceTexture。 SurfaceTexture更新其关联的OpenGL纹理内容。- 在Unity的每帧更新(如
Update)中,因为Texture2D是外部纹理,Unity的渲染引擎会自动检测到底层纹理内容已变化,从而更新在RawImage或材质球上显示的画面。
- ExoPlayer开始播放,将解码后的视频帧渲染到
这个架构的关键在于,视频帧数据始终停留在GPU显存中,从解码到显示,没有经过CPU内存的搬运,因此效率非常高。
3. 开发环境准备与工程配置
工欲善其事,必先利其器。在开始编码前,确保你的开发环境配置正确,能避免很多后续的诡异问题。
3.1 Unity项目设置
- 创建新项目或使用现有项目:建议使用Unity LTS版本(如2022.3.x),稳定性更好。
- 切换构建平台:在
File -> Build Settings中,选择Android平台,点击Switch Platform。如果未安装Android支持模块,Unity会提示你下载。 - Player Settings关键配置:
- Other Settings部分:
Minimum API Level:根据你的目标设备设置,建议至少API Level 24 (Android 7.0),以确保较好的ExoPlayer兼容性。Target API Level:设置为你要测试的设备对应的最新API级别。Scripting Backend:必须选择IL2CPP。因为我们要与原生代码交互,IL2CPP的兼容性和性能比Mono更好。Target Architectures:勾选ARM64。现代Android设备基本都是64位,勾选此项能获得更好的性能,并且ExoPlayer的一些扩展功能可能需要64位支持。
- Publishing Settings部分:
- 确保
Minify选项(如ProGuard)根据你的需求配置。如果对生成的APK大小有要求,可以启用,但初期调试建议先关闭,避免混淆代码导致JNI调用失败。
- 确保
- Other Settings部分:
3.2 Android Studio与ExoPlayer库集成
我们不需要在Android Studio里开发整个App,但需要它来管理依赖和编译我们的Android库(AAR或JAR)。
- 创建Android Library Module:
- 打开Android Studio,新建一个
Empty Views Activity项目(项目类型不重要)。 - 在项目中,
File -> New -> New Module,选择Android Library。给它起个名字,比如:unityvideoplayer。这个Module将包含我们所有的Java播放器代码。
- 打开Android Studio,新建一个
- 添加ExoPlayer依赖:打开刚创建的Library Module下的
build.gradle文件(通常是unityvideoplayer/build.gradle)。
将版本号dependencies { // ExoPlayer 核心库 implementation 'com.google.android.exoplayer:exoplayer-core:2.19.1' // 如果需要播放DASH流 implementation 'com.google.android.exoplayer:exoplayer-dash:2.19.1' // 如果需要播放HLS流 implementation 'com.google.android.exoplayer:exoplayer-hls:2.19.1' // 如果需要播放平滑流 implementation 'com.google.android.exoplayer:exoplayer-smoothstreaming:2.19.1' // UI组件(可选,如果我们自己绘制控件就不需要) // implementation 'com.google.android.exoplayer:exoplayer-ui:2.19.1' // 其他可能需要的支持库 implementation 'androidx.appcompat:appcompat:1.6.1' }2.19.1替换为当时最新的稳定版。通常只添加exoplayer-core就够了,其他按需添加。 - 编译生成AAR文件:
- 在Android Studio右侧的
Gradle面板中,找到你的Library Module (:unityvideoplayer) ->Tasks->build->assemble或bundle。 - 双击运行,成功后会在
unityvideoplayer/build/outputs/aar/目录下生成unityvideoplayer-release.aar文件。这个文件就是我们最终要放到Unity项目中的原生插件。
- 在Android Studio右侧的
3.3 Unity项目中的插件部署
- 在Unity项目的
Assets文件夹下,创建如下目录结构:Assets/Plugins/Android/。这是Unity规定的存放Android平台原生插件的标准路径。 - 将上一步生成的
unityvideoplayer-release.aar文件,复制到Assets/Plugins/Android/目录下。 - 同时,我们还需要一个关键的配置文件:
AndroidManifest.xml。虽然AAR中可能包含一个,但为了添加我们需要的权限(如网络权限),最好在Unity项目中也放置一个。在Assets/Plugins/Android/目录下创建或复制一个AndroidManifest.xml文件,并确保其包含以下内容(特别是网络权限):
Unity在打包时,会将这个<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.yourcompany.unityvideo"> <uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <!-- 如果播放本地文件,可能需要读外部存储权限 --> <!-- <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> --> <application android:allowBackup="true" android:supportsRtl="true"> <!-- 你的Activity等配置,Unity会自动合并 --> </application> </manifest>AndroidManifest.xml与它自己生成的基础清单文件进行合并。
4. Android原生层代码实现
现在,我们来编写核心的Android Java代码。在Android Studio的Library Module (:unityvideoplayer) 中,我们主要创建两个类。
4.1 创建UnityPlayerActivity的辅助类
首先,我们需要一个类来帮助Unity的Activity进行一些初始化和上下文获取。在src/main/java/com/yourcompany/unityvideo/包路径下,创建UnityPlayerActivityHelper.java。
package com.yourcompany.unityvideo; import android.app.Activity; import android.content.Context; import android.content.pm.ActivityInfo; import android.content.pm.PackageManager; import android.os.Build; import android.util.Log; import android.view.Surface; import com.unity3d.player.UnityPlayer; public class UnityPlayerActivityHelper { private static final String TAG = "UnityVideoPlugin"; // 获取当前Unity的Activity上下文 public static Activity getActivity() { return UnityPlayer.currentActivity; } // 一个简单的Log工具方法,方便在Logcat中筛选我们的日志 public static void log(String message) { Log.d(TAG, message); } // 检查权限(示例:网络状态权限) public static boolean hasPermission(Context context, String permission) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { return context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED; } return true; } }这个类很简单,主要提供了获取当前Unity Activity的静态方法,这是后续我们创建播放器所必需的上下文(Context)。
4.2 核心播放器封装类
接下来是重头戏,创建AndroidVideoPlayer.java。这个类将封装ExoPlayer,并处理与SurfaceTexture相关的逻辑。
package com.yourcompany.unityvideo; import android.content.Context; import android.graphics.SurfaceTexture; import android.opengl.GLES20; import android.os.Handler; import android.os.Looper; import android.view.Surface; import com.google.android.exoplayer2.ExoPlayer; import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.source.DefaultMediaSourceFactory; import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.ProgressiveMediaSource; import com.google.android.exoplayer2.upstream.DefaultDataSource; import com.google.android.exoplayer2.util.Util; public class AndroidVideoPlayer { private static final String TAG = "AndroidVideoPlayer"; private ExoPlayer player; private SurfaceTexture surfaceTexture; private Surface surface; private int glTextureId = -1; // 关联SurfaceTexture的OpenGL纹理ID private Context context; private Handler mainHandler; // Unity侧回调的接口名称(C#脚本会挂载在某个GameObject上) private String unityCallbackObjectName = "AndroidVideoCallbackReceiver"; private String onPreparedMethodName = "OnNativePlayerPrepared"; private String onErrorMethodName = "OnNativePlayerError"; public AndroidVideoPlayer(Context context) { this.context = context; this.mainHandler = new Handler(Looper.getMainLooper()); UnityPlayerActivityHelper.log("AndroidVideoPlayer constructed."); } // 初始化播放器并创建SurfaceTexture public int initialize() { UnityPlayerActivityHelper.log("Initializing player and SurfaceTexture..."); try { // 1. 生成一个OpenGL纹理ID int[] textures = new int[1]; GLES20.glGenTextures(1, textures, 0); glTextureId = textures[0]; if (glTextureId <= 0) { throw new RuntimeException("Failed to generate OpenGL texture."); } // 2. 绑定纹理并设置参数(必须,否则可能显示异常) GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, glTextureId); GLES20.glTexParameterf(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR); GLES20.glTexParameterf(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR); GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE); GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE); GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, 0); // 3. 用这个纹理ID创建SurfaceTexture surfaceTexture = new SurfaceTexture(glTextureId); surfaceTexture.setOnFrameAvailableListener(new SurfaceTexture.OnFrameAvailableListener() { @Override public void onFrameAvailable(SurfaceTexture surfaceTexture) { // 当有新视频帧时,通知Unity更新纹理。 // 这个回调运行在非UI线程,我们需要通知Unity主线程。 // 通常,Unity端会轮询或通过其他机制更新,这里我们先标记。 UnityPlayerActivityHelper.log("New frame available."); } }); surface = new Surface(surfaceTexture); // 4. 创建并配置ExoPlayer mainHandler.post(new Runnable() { @Override public void run() { // 在主线程初始化ExoPlayer player = new ExoPlayer.Builder(context).build(); player.setVideoSurface(surface); player.addListener(new Player.Listener() { @Override public void onPlaybackStateChanged(int playbackState) { if (playbackState == Player.STATE_READY) { // 播放器准备就绪,通知Unity UnityPlayer.UnitySendMessage(unityCallbackObjectName, onPreparedMethodName, ""); } } @Override public void onPlayerError(com.google.android.exoplayer2.PlaybackException error) { UnityPlayer.UnitySendMessage(unityCallbackObjectName, onErrorMethodName, error.getMessage()); } }); UnityPlayerActivityHelper.log("ExoPlayer initialized on main thread."); } }); return glTextureId; // 将纹理ID返回给Unity } catch (Exception e) { UnityPlayerActivityHelper.log("Initialize failed: " + e.getMessage()); e.printStackTrace(); return -1; } } // 设置媒体源(支持本地路径和网络URL) public void setDataSource(final String path) { if (player == null) { UnityPlayerActivityHelper.log("Player is not initialized. Call initialize() first."); return; } mainHandler.post(new Runnable() { @Override public void run() { try { MediaItem mediaItem = MediaItem.fromUri(path); player.setMediaItem(mediaItem); player.prepare(); UnityPlayerActivityHelper.log("Media source set: " + path); } catch (Exception e) { UnityPlayerActivityHelper.log("setDataSource error: " + e.getMessage()); } } }); } // 播放控制方法 public void play() { if (player != null) { mainHandler.post(() -> player.setPlayWhenReady(true)); } } public void pause() { if (player != null) { mainHandler.post(() -> player.setPlayWhenReady(false)); } } public void stop() { if (player != null) { mainHandler.post(() -> { player.stop(); player.clearVideoSurface(); }); } } public void seekTo(final long positionMs) { if (player != null) { mainHandler.post(() -> player.seekTo(positionMs)); } } public long getCurrentPosition() { return player != null ? player.getCurrentPosition() : 0; } public long getDuration() { return player != null ? player.getDuration() : 0; } public boolean isPlaying() { return player != null && player.isPlaying(); } // 释放资源(非常重要!) public void release() { UnityPlayerActivityHelper.log("Releasing player resources..."); if (player != null) { mainHandler.post(() -> { player.release(); player = null; }); } if (surface != null) { surface.release(); surface = null; } if (surfaceTexture != null) { surfaceTexture.release(); surfaceTexture = null; } if (glTextureId != -1) { int[] textures = new int[]{glTextureId}; GLES20.glDeleteTextures(1, textures, 0); glTextureId = -1; } } // 获取当前纹理ID(供Unity查询) public int getGlTextureId() { return glTextureId; } // 更新SurfaceTexture(需要在渲染线程调用,通常由Unity每帧驱动) public void updateTexImage() { if (surfaceTexture != null) { try { surfaceTexture.updateTexImage(); } catch (Exception e) { UnityPlayerActivityHelper.log("updateTexImage failed: " + e.getMessage()); } } } }关键点解析:
- 纹理类型
GL_TEXTURE_EXTERNAL_OES:注意,与SurfaceTexture关联的OpenGL纹理是一种特殊类型GL_TEXTURE_EXTERNAL_OES,而不是标准的GL_TEXTURE_2D。这在Unity端创建外部纹理时需要对应。 - 主线程操作:ExoPlayer的很多方法(如构造、设置媒体源、播放控制)需要在主线程(UI线程)调用。我们通过
Handler和mainHandler.post来确保这一点。Unity的JNI调用通常发生在渲染线程,所以这个包装至关重要。 - 回调机制:我们使用
UnityPlayer.UnitySendMessage来向Unity发送事件通知(如准备完成、播放错误)。这要求Unity场景中存在一个指定名称的GameObject和对应的C#脚本方法。 - 资源释放:
release()方法必须被正确调用,以释放播放器、Surface、SurfaceTexture和OpenGL纹理。否则会导致内存泄漏和GPU资源泄露,这在移动设备上是致命的。
5. Unity C#层代码实现
Android端准备就绪后,我们需要在Unity中编写C#脚本来驱动整个流程。这个脚本负责JNI通信、纹理管理、播放控制和UI更新。
5.1 创建Android视频播放器管理器
在Unity中创建一个C#脚本,命名为AndroidVideoPlayerManager.cs。
using UnityEngine; using UnityEngine.UI; using System; using System.Runtime.InteropServices; using System.Collections; public class AndroidVideoPlayerManager : MonoBehaviour { // 指向Android原生对象的IntPtr private IntPtr nativePlayerPtr = IntPtr.Zero; // Unity中用于显示视频的Texture2D private Texture2D externalTexture; // 用于显示视频的UI RawImage组件 public RawImage videoDisplayRawImage; // Android Java类和方法签名 private const string JAVA_CLASS_NAME = "com.yourcompany.unityvideo.AndroidVideoPlayer"; private AndroidJavaObject nativePlayerObject = null; private AndroidJavaClass playerClass = null; // 从Android原生层获取的OpenGL纹理ID private int glTextureId = -1; // 视频状态 public enum VideoState { Idle, Preparing, Playing, Paused, Stopped, Error } private VideoState currentState = VideoState.Idle; public System.Action<VideoState> OnStateChanged; void Start() { // 确保此GameObject不会被意外销毁 DontDestroyOnLoad(this.gameObject); InitializeNativePlayer(); } void InitializeNativePlayer() { try { // 获取当前Unity的Activity上下文 AndroidJavaClass unityPlayerClass = new AndroidJavaClass("com.unity3d.player.UnityPlayer"); AndroidJavaObject currentActivity = unityPlayerClass.GetStatic<AndroidJavaObject>("currentActivity"); // 实例化Android端的播放器对象 playerClass = new AndroidJavaClass(JAVA_CLASS_NAME); if (playerClass != null) { // 调用构造函数,传入Activity上下文 nativePlayerObject = playerClass.CallStatic<AndroidJavaObject>("getInstance", currentActivity); // 或者直接new: nativePlayerObject = new AndroidJavaObject(JAVA_CLASS_NAME, currentActivity); if (nativePlayerObject != null) { // 调用初始化方法,获取纹理ID glTextureId = nativePlayerObject.Call<int>("initialize"); Debug.Log($"AndroidVideoPlayer initialized. GL Texture ID: {glTextureId}"); if (glTextureId > 0) { CreateExternalTexture(glTextureId); currentState = VideoState.Idle; OnStateChanged?.Invoke(currentState); } else { Debug.LogError("Failed to initialize native player or get texture ID."); currentState = VideoState.Error; OnStateChanged?.Invoke(currentState); } } else { Debug.LogError("Failed to create instance of AndroidVideoPlayer."); } } else { Debug.LogError($"Could not find Java class: {JAVA_CLASS_NAME}. Please check AAR plugin."); } } catch (System.Exception e) { Debug.LogError($"Exception during native player initialization: {e.Message}\n{e.StackTrace}"); currentState = VideoState.Error; OnStateChanged?.Invoke(currentState); } } void CreateExternalTexture(int texId) { // 释放旧的纹理 if (externalTexture != null) { Destroy(externalTexture); externalTexture = null; } // 关键步骤:创建外部纹理,关联到Android端的OpenGL纹理 // 注意纹理类型是 TextureFormat.External,对应 Android 的 GL_TEXTURE_EXTERNAL_OES externalTexture = Texture2D.CreateExternalTexture( 1, // 临时宽度,会被SurfaceTexture的实际尺寸覆盖 1, // 临时高度 TextureFormat.External, // 必须使用External格式 false, // 是否开启mipmap false, // 是否是线性颜色空间 new IntPtr(texId) // 传入从Android获取的纹理ID ); // 将纹理赋值给UI RawImage进行显示 if (videoDisplayRawImage != null) { videoDisplayRawImage.texture = externalTexture; videoDisplayRawImage.color = Color.white; } else { Debug.LogWarning("VideoDisplayRawImage is not assigned. Video will not be visible."); } Debug.Log("External Texture created and assigned."); } void Update() { // 每帧更新SurfaceTexture,驱动视频帧刷新 if (nativePlayerObject != null && glTextureId > 0) { try { nativePlayerObject.Call("updateTexImage"); } catch (System.Exception e) { // 可能播放器已释放,忽略 } } // 可以在这里更新播放进度UI等 // UpdatePlaybackProgress(); } // 公共方法:供Unity其他脚本调用 public void LoadVideo(string videoPath) { if (nativePlayerObject != null && currentState != VideoState.Error) { Debug.Log($"Loading video: {videoPath}"); currentState = VideoState.Preparing; OnStateChanged?.Invoke(currentState); nativePlayerObject.Call("setDataSource", videoPath); } } public void Play() { if (nativePlayerObject != null && (currentState == VideoState.Paused || currentState == VideoState.Preparing)) { nativePlayerObject.Call("play"); currentState = VideoState.Playing; OnStateChanged?.Invoke(currentState); } } public void Pause() { if (nativePlayerObject != null && currentState == VideoState.Playing) { nativePlayerObject.Call("pause"); currentState = VideoState.Paused; OnStateChanged?.Invoke(currentState); } } public void Stop() { if (nativePlayerObject != null) { nativePlayerObject.Call("stop"); currentState = VideoState.Stopped; OnStateChanged?.Invoke(currentState); } } public void SeekTo(float timeInSeconds) { if (nativePlayerObject != null) { long positionMs = (long)(timeInSeconds * 1000); nativePlayerObject.Call("seekTo", positionMs); } } public float GetCurrentTime() { if (nativePlayerObject != null) { long ms = nativePlayerObject.Call<long>("getCurrentPosition"); return ms / 1000.0f; } return 0f; } public float GetDuration() { if (nativePlayerObject != null) { long ms = nativePlayerObject.Call<long>("getDuration"); return ms / 1000.0f; } return 0f; } // 从Android原生层回调的方法(方法名必须与Java端UnitySendMessage调用的一致) public void OnNativePlayerPrepared(string message) { Debug.Log("Native player prepared."); // 可以在这里自动开始播放 // Play(); currentState = VideoState.Paused; // 准备就绪但未播放 OnStateChanged?.Invoke(currentState); } public void OnNativePlayerError(string errorMessage) { Debug.LogError($"Native player error: {errorMessage}"); currentState = VideoState.Error; OnStateChanged?.Invoke(currentState); } void OnDestroy() { Release(); } void OnApplicationQuit() { Release(); } void Release() { Debug.Log("Releasing AndroidVideoPlayerManager resources."); if (nativePlayerObject != null) { nativePlayerObject.Call("release"); nativePlayerObject.Dispose(); nativePlayerObject = null; } if (externalTexture != null) { Destroy(externalTexture); externalTexture = null; } playerClass?.Dispose(); currentState = VideoState.Idle; OnStateChanged?.Invoke(currentState); } }5.2 场景搭建与测试
- 在Unity场景中创建一个UI Canvas。
- 在Canvas下创建一个
RawImage对象,将其锚点拉伸至全屏或你想要的尺寸。将其命名为VideoDisplay。 - 创建一个空的
GameObject,命名为VideoPlayerManager。 - 将
AndroidVideoPlayerManager脚本挂载到VideoPlayerManager上。 - 在Inspector面板中,将场景中的
VideoDisplayRawImage对象拖拽到脚本的Video Display Raw Image字段上。 - (可选)创建一个简单的UI控制面板(几个按钮:Play, Pause, Stop),并编写脚本调用
AndroidVideoPlayerManager的相应公共方法。 - 在
VideoPlayerManager的Start方法后或通过UI按钮,调用LoadVideo方法。参数可以是:- 本地文件:
"file:///storage/emulated/0/Download/test.mp4"(需要读写权限) - 网络URL:
"https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4"(需要网络权限)
- 本地文件:
- 连接Android真机,确保USB调试已开启,然后Build & Run。
6. 关键问题排查与性能优化
在实际集成过程中,你几乎一定会遇到各种问题。下面是一些最常见的问题及其解决方案。
6.1 常见问题速查表
| 问题现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
| 黑屏,无画面,但可能有声音 | 1. 纹理ID传递或创建失败。 2. SurfaceTexture未正确关联到播放器。3. Unity的 RawImage材质不支持外部纹理。 | 1. 检查Logcat,确认glTextureId是否大于0。2. 确认Android端 player.setVideoSurface(surface)被调用。3. 确保Unity中 RawImage的Texture字段确实被赋值为我们创建的externalTexture。可以尝试创建一个支持GL_TEXTURE_EXTERNAL_OES的Shader给RawImage。 |
| 播放立即崩溃 | 1. JNI调用错误,方法签名不匹配。 2. ExoPlayer依赖冲突或缺失。 3. 未在主线程初始化ExoPlayer。 | 1. 检查Android Studio的Logcat崩溃堆栈,定位到具体Java行。 2. 确认AAR文件已正确放入 Plugins/Android,且ExoPlayer依赖已成功打包。3. 确保所有ExoPlayer操作(如 new ExoPlayer.Builder())都在mainHandler.post中执行。 |
| 画面闪烁、撕裂或错位 | 1.SurfaceTexture.updateTexImage()调用时机不对。2. OpenGL纹理参数设置错误。 | 1. 确保在Unity的Update()中每帧调用updateTexImage。2. 检查Android端 glTexParameter设置是否正确(特别是GL_CLAMP_TO_EDGE)。 |
| 音画不同步 | 1. Unity的更新帧率与视频帧率不匹配。 2. 解码性能不足。 | 1. 尝试将Unity的Application.targetFrameRate设置为60或与视频帧率匹配。2. 考虑在Android端使用更低的分辨率或编码格式(如H.264代替HEVC)。确保使用硬件解码。 |
| 内存泄漏 | 1. 未正确释放ExoPlayer、Surface、SurfaceTexture和OpenGL纹理。 | 1. 务必在Unity的OnDestroy或OnApplicationQuit中调用Release()方法。2. 在Android Studio Profiler中监控内存,确保Activity销毁后相关对象被GC。 |
| 无法播放网络视频 | 1. 未添加网络权限。 2. 视频URL格式错误或服务器问题。 3. Android 9+ 明文传输限制。 | 1. 确认AndroidManifest.xml已添加INTERNET权限。2. 用系统浏览器或VLC测试URL是否有效。 3. 针对Android 9+,在 AndroidManifest的application标签内添加android:usesCleartextTraffic="true"(仅用于测试,生产环境应使用HTTPS)。 |
6.2 性能优化要点
- 纹理尺寸匹配:在
CreateExternalTexture时传入的宽高是临时的。SurfaceTexture的尺寸由第一帧视频决定。如果Unity中RawImage的RectTransform尺寸与视频宽高比不匹配,会导致拉伸。最好在Android端onVideoSizeChanged回调中,将视频宽高通知给Unity,然后动态调整RawImage的显示比例。 - 单例模式管理:一个应用中最好只存在一个
AndroidVideoPlayer实例。可以在Java端实现为单例,避免多个播放器竞争GPU资源。 - 后台播放处理:当应用切换到后台时,应暂停播放并释放
Surface。可以在Unity的OnApplicationPause中处理。回到前台时,需要重新创建Surface并设置给播放器。 - 使用ExoPlayer的扩展功能:利用ExoPlayer的
LoadControl可以预加载视频数据,利用RenderersFactory可以定制解码器选择(强制硬解/软解),这些都能提升体验。 - Shader适配:默认的UI Shader可能对
TextureFormat.External支持不佳。如果遇到黑屏,可以创建一个简单的Unlit Shader,在片元着色器中采样时使用samplerExternalOES而不是sampler2D,并将这个Shader赋给RawImage的Material。
6.3 一个实用的Shader示例
创建一个新的Shader文件,例如UnlitExternalTexture.shader:
Shader "Unlit/ExternalTexture" { Properties { _MainTex ("Texture", 2D) = "white" {} } SubShader { Tags { "RenderType"="Opaque" } LOD 100 Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; // 声明 samplerExternalOES sampler2D _MainTex; float4 _MainTex_ST; v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = TRANSFORM_TEX(v.uv, _MainTex); return o; } fixed4 frag (v2f i) : SV_Target { // 直接采样,对于 GL_TEXTURE_EXTERNAL_OES,在移动平台上Unity会处理 fixed4 col = tex2D(_MainTex, i.uv); return col; } ENDCG } } // 重要:指定回退,并声明需要 GL_OES_EGL_image_external 扩展 FallBack "Unlit/Texture" CustomEditor "ExternalTextureShaderGUI" }然后创建一个对应的Material,赋给场景中的RawImage。这个Shader能更好地兼容外部OES纹理。
7. 进阶扩展与替代方案
掌握了基础实现后,你可以根据项目需求进行深度定制。
7.1 实现精准进度同步与状态回调
目前的进度获取是C#端主动查询。对于需要高精度进度条的场景,可以在Android端使用Handler定时(如每100ms)通过UnitySendMessage将当前播放位置回调给Unity,实现更平滑的UI更新。
7.2 处理复杂流媒体与DRM
ExoPlayer的强大之处在于对流媒体的支持。要播放HLS或DASH流,只需在Android端添加对应的扩展依赖(如exoplayer-hls),并在创建MediaSource时使用HlsMediaSource.Factory或DashMediaSource.Factory。对于DRM保护的内容(如Widevine),需要在MediaItem中配置DrmConfiguration。
7.3 多实例播放与画中画
理论上,可以创建多个AndroidVideoPlayer实例和多个SurfaceTexture,对应Unity中的多个Texture2D,从而实现多视频同时播放。但需要密切关注GPU内存和性能开销。画中画(PiP)功能则需要更复杂的Activity生命周期和窗口管理,通常需要单独处理。
7.4 考虑使用现成的Asset Store插件
如果你觉得从头搭建这套体系过于复杂,或者项目周期紧张,Unity Asset Store上有一些成熟的视频播放插件,例如AVPro Video。这类插件通常提供了更完善的跨平台支持(iOS/Android/Windows等)、更丰富的功能(如360度视频、Alpha通道视频)和更稳定的商业支持。它们的底层原理与本文所述类似,但封装得更好,省去了大量底层开发工作。选择自行开发还是购买插件,需要权衡开发成本、功能需求、定制化程度和预算。
整个流程走下来,你会发现打通Unity与Android原生视频播放,核心在于理解图形API的跨语言共享机制(SurfaceTexture/OpenGL纹理)和正确的线程间通信。虽然步骤繁多,但每一步都有其明确的目的。一旦跑通,你就获得了一个远超Unity内置VideoPlayer能力的高性能、高兼容性视频播放解决方案,足以应对大多数移动端视频播放的复杂场景。