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[]传回UnityUnity再将其更新到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或材质球上显示的画面。这个架构的关键在于视频帧数据始终停留在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调用失败。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播放器代码。添加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项目中的原生插件。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文件并确保其包含以下内容特别是网络权限?xml version1.0 encodingutf-8? manifest xmlns:androidhttp://schemas.android.com/apk/res/android packagecom.yourcompany.unityvideo uses-permission android:nameandroid.permission.INTERNET / uses-permission android:nameandroid.permission.ACCESS_NETWORK_STATE / !-- 如果播放本地文件可能需要读外部存储权限 -- !-- uses-permission android:nameandroid.permission.READ_EXTERNAL_STORAGE / -- application android:allowBackuptrue android:supportsRtltrue !-- 你的Activity等配置Unity会自动合并 -- /application /manifestUnity在打包时会将这个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.ActionVideoState OnStateChanged; void Start() { // 确保此GameObject不会被意外销毁 DontDestroyOnLoad(this.gameObject); InitializeNativePlayer(); } void InitializeNativePlayer() { try { // 获取当前Unity的Activity上下文 AndroidJavaClass unityPlayerClass new AndroidJavaClass(com.unity3d.player.UnityPlayer); AndroidJavaObject currentActivity unityPlayerClass.GetStaticAndroidJavaObject(currentActivity); // 实例化Android端的播放器对象 playerClass new AndroidJavaClass(JAVA_CLASS_NAME); if (playerClass ! null) { // 调用构造函数传入Activity上下文 nativePlayerObject playerClass.CallStaticAndroidJavaObject(getInstance, currentActivity); // 或者直接new: nativePlayerObject new AndroidJavaObject(JAVA_CLASS_NAME, currentActivity); if (nativePlayerObject ! null) { // 调用初始化方法获取纹理ID glTextureId nativePlayerObject.Callint(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.Calllong(getCurrentPosition); return ms / 1000.0f; } return 0f; } public float GetDuration() { if (nativePlayerObject ! null) { long ms nativePlayerObject.Calllong(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(需要读写权限)网络URLhttps://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:usesCleartextTraffictrue仅用于测试生产环境应使用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.shaderShader Unlit/ExternalTexture { Properties { _MainTex (Texture, 2D) white {} } SubShader { Tags { RenderTypeOpaque } 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 处理复杂流媒体与DRMExoPlayer的强大之处在于对流媒体的支持。要播放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能力的高性能、高兼容性视频播放解决方案足以应对大多数移动端视频播放的复杂场景。
Unity调用Android原生ExoPlayer实现高性能视频播放方案
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[]传回UnityUnity再将其更新到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或材质球上显示的画面。这个架构的关键在于视频帧数据始终停留在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调用失败。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播放器代码。添加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项目中的原生插件。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文件并确保其包含以下内容特别是网络权限?xml version1.0 encodingutf-8? manifest xmlns:androidhttp://schemas.android.com/apk/res/android packagecom.yourcompany.unityvideo uses-permission android:nameandroid.permission.INTERNET / uses-permission android:nameandroid.permission.ACCESS_NETWORK_STATE / !-- 如果播放本地文件可能需要读外部存储权限 -- !-- uses-permission android:nameandroid.permission.READ_EXTERNAL_STORAGE / -- application android:allowBackuptrue android:supportsRtltrue !-- 你的Activity等配置Unity会自动合并 -- /application /manifestUnity在打包时会将这个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.ActionVideoState OnStateChanged; void Start() { // 确保此GameObject不会被意外销毁 DontDestroyOnLoad(this.gameObject); InitializeNativePlayer(); } void InitializeNativePlayer() { try { // 获取当前Unity的Activity上下文 AndroidJavaClass unityPlayerClass new AndroidJavaClass(com.unity3d.player.UnityPlayer); AndroidJavaObject currentActivity unityPlayerClass.GetStaticAndroidJavaObject(currentActivity); // 实例化Android端的播放器对象 playerClass new AndroidJavaClass(JAVA_CLASS_NAME); if (playerClass ! null) { // 调用构造函数传入Activity上下文 nativePlayerObject playerClass.CallStaticAndroidJavaObject(getInstance, currentActivity); // 或者直接new: nativePlayerObject new AndroidJavaObject(JAVA_CLASS_NAME, currentActivity); if (nativePlayerObject ! null) { // 调用初始化方法获取纹理ID glTextureId nativePlayerObject.Callint(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.Calllong(getCurrentPosition); return ms / 1000.0f; } return 0f; } public float GetDuration() { if (nativePlayerObject ! null) { long ms nativePlayerObject.Calllong(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(需要读写权限)网络URLhttps://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:usesCleartextTraffictrue仅用于测试生产环境应使用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.shaderShader Unlit/ExternalTexture { Properties { _MainTex (Texture, 2D) white {} } SubShader { Tags { RenderTypeOpaque } 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 处理复杂流媒体与DRMExoPlayer的强大之处在于对流媒体的支持。要播放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能力的高性能、高兼容性视频播放解决方案足以应对大多数移动端视频播放的复杂场景。