☰
Unity调用Android相册的完整跨平台通信方案
2026/10/15 1:31:38 网站建设 项目流程

1. 这不是“调用相册”,而是打通Unity与Android的双向通信链路

很多人看到标题第一反应是:“不就是点个按钮,弹个相册选张图?”——这恰恰是踩坑的起点。我带过几个Unity项目组,几乎每支团队都在这个看似简单的功能上卡过三天以上。问题从来不在“选图”本身,而在于Unity和Android之间那层看不见的通信壁垒:Unity运行在C#虚拟机里,Android原生API跑在Java/Kotlin虚拟机中,两者默认互不可见。你写的Application.OpenURL("content://...")?那是浏览器协议,根本进不了相册界面;你直接AndroidJavaObjectnew一个Intent?没配好Activity生命周期回调,图片路径一返回就成null;更别提64位包、targetSdkVersion升级后权限模型变更、Scoped Storage强制启用这些“温柔一刀”。

关键词里虽然空着,但实际落地必须直面四个硬核模块:Android原生层Intent启动与结果捕获、Unity侧JNI桥接封装、文件URI到本地路径的安全转换、以及Android 10+ Scoped Storage下的媒体访问适配。这不是调用一个API就能搞定的“功能”,而是一条需要亲手铺设的跨语言数据通道。适合谁?不是只写C#逻辑的纯Unity开发者,而是愿意打开AndroidManifest.xml、能看懂onActivityResult回调、敢改build.gradle依赖的全栈型Unity工程师。如果你还在用Asset Store里那些年久失修的“相册插件”,建议先停下手——它们大多没处理MediaStore批量查询、没兼容Storage Access Framework(SAF)的文档树选择,更没做FileProvider配置校验。接下来我会把这条链路从底层协议开始,一节一节拧紧螺丝。

2. Android原生层:Intent不是万能钥匙,得配对“锁芯”才能开门

Unity调用Android相册的本质,是向系统发起一个ACTION_PICK或ACTION_GET_CONTENT类型的Intent请求。但这里有个致命误区:很多人以为只要startActivity(intent)就能弹出相册——错。Android 12+已废弃startActivity隐式调用非系统Activity,且相册界面本身不是标准Activity,而是系统级Chooser或DocumentsUI。真正可靠的方案只有两个:显式指定系统相册Activity(不推荐)或走标准Content Intent协议(强推)。我们选后者,因为它跨厂商、跨版本稳定,且符合Google官方设计规范。

2.1 核心Intent构造:三要素缺一不可

// Java端代码(放在Android Studio工程的UnityPlayerActivity.java中) public void openGallery(int requestCode) { Intent intent = new Intent(Intent.ACTION_GET_CONTENT); intent.addCategory(Intent.CATEGORY_OPENABLE); intent.setType("image/*,video/*"); // 注意:不能用"image/*|video/*",竖线是非法分隔符 // 关键!必须设置FLAG_GRANT_READ_URI_PERMISSION,否则Unity进程无法读取返回的URI intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); startActivityForResult(intent, requestCode); }

这里三个细节决定成败:

  • ACTION_GET_CONTENT比ACTION_PICK更现代,支持所有可打开的媒体类型,且不依赖特定ContentProvider;
  • addCategory(Intent.CATEGORY_OPENABLE)过滤掉无法直接读取的伪URI(如某些厂商相册返回的content://media/...无读取权限);
  • FLAG_GRANT_READ_URI_PERMISSION是权限通行证——它让目标URI的读取权限临时授予Unity进程,没有它,getContentResolver().openInputStream(uri)必抛SecurityException。

提示:别用Intent.ACTION_PICK搭配MediaStore.Images.Media.EXTERNAL_CONTENT_URI。这是旧方案,Android 10+因Scoped Storage限制,MediaStore查询会返回空列表,除非你声明MANAGE_EXTERNAL_STORAGE(已被Google Play拒收)。

2.2 onActivityResult:不是回调终点,而是数据搬运起点

很多开发者在这里栽跟头:以为onActivityResult里拿到data.getData()就万事大吉。实测发现,华为EMUI、小米MIUI返回的URI形如content://com.android.providers.media.documents/document/image:12345,而三星One UI可能返回content://media/external/images/media/67890。这些URI都不能直接当文件路径用,必须通过ContentResolver转换:

@Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { if (requestCode == GALLERY_REQUEST_CODE && resultCode == RESULT_OK && data != null) { Uri selectedUri = data.getData(); if (selectedUri != null) { String filePath = getRealPathFromUri(selectedUri); // 将filePath传回Unity(通过JNI或BroadcastReceiver) sendToUnity(filePath); } } }

关键在getRealPathFromUri()方法——它不是简单uri.getPath(),而是要根据URI Scheme动态适配:

private String getRealPathFromUri(Uri uri) { String filePath = ""; if (DocumentsContract.isDocumentUri(this, uri)) { // 处理document URI(如content://com.android.providers.media.documents/document/image:12345) String docId = DocumentsContract.getDocumentId(uri); String[] split = docId.split(":"); String type = split[0]; Uri contentUri = null; if ("image".equals(type)) { contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI; } else if ("video".equals(type)) { contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI; } String selection = "_id=?"; String[] selectionArgs = { split[1] }; filePath = getDataColumn(contentUri, selection, selectionArgs); } else if ("content".equalsIgnoreCase(uri.getScheme())) { // 处理普通content URI(如content://media/external/images/media/67890) filePath = getDataColumn(uri, null, null); } else if ("file".equalsIgnoreCase(uri.getScheme())) { // 处理file URI(极少见,但需兼容) filePath = uri.getPath(); } return filePath; } private String getDataColumn(Uri uri, String selection, String[] selectionArgs) { Cursor cursor = null; String column = MediaStore.Images.Media.DATA; String[] projection = { column }; try { cursor = getContentResolver().query(uri, projection, selection, selectionArgs, null); if (cursor != null && cursor.moveToFirst()) { int index = cursor.getColumnIndexOrThrow(column); return cursor.getString(index); } } finally { if (cursor != null) cursor.close(); } return null; }

这段代码覆盖了99%的厂商URI格式。注意getDataColumn中MediaStore.Images.Media.DATA字段在Android 10+已被弃用,但query方法本身仍有效——我们只是用它查_id对应的原始路径,而非依赖DATA字段。

注意:getRealPathFromUri在Android 10+可能返回null(因Scoped Storage限制),此时必须降级使用ContentResolver.openInputStream()直接读取流,再保存到应用私有目录。这点我们放到第4节详述。

3. Unity侧JNI桥接:用C#写Java,比写Java还烧脑

Unity调用Android原生代码,标准路径是JNI(Java Native Interface)。但很多Unity开发者对JNI的理解停留在“调用Java方法”层面,忽略了线程安全和对象生命周期这两个地雷。Unity主线程(Main Thread)和Android主线程(UI Thread)不是同一个线程,AndroidJavaObject构造、方法调用、对象释放都必须在正确线程上下文执行。我见过最典型的错误:在Unity协程里直接new AndroidJavaObject("com.xxx.GalleryHelper"),结果在小米手机上必崩——因为协程可能在子线程执行,而Android Java对象必须在UI线程创建。

3.1 线程安全的JNI调用模式:Handler + Runnable

正确做法是将所有Android Java调用封装进Runnable,通过Handler投递到Android主线程:

// C#侧代码(GalleryManager.cs) public class GalleryManager : MonoBehaviour { private AndroidJavaObject activity; private AndroidJavaObject handler; void Start() { // 获取当前Activity和主线程Handler using (var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer")) { activity = unityPlayer.GetStatic<AndroidJavaObject>("currentActivity"); } using (var looper = new AndroidJavaClass("android.os.Looper")) { var mainLooper = looper.GetStatic<AndroidJavaObject>("mainLooper"); handler = new AndroidJavaObject("android.os.Handler", mainLooper); } } public void OpenGallery() { // 创建Runnable对象(必须在主线程创建) var runnable = new AndroidJavaObject("com.yourcompany.GalleryRunnable", activity); // 投递到主线程执行 handler.Call("post", runnable); } }

对应Java端GalleryRunnable.java:

public class GalleryRunnable implements Runnable { private AndroidJavaObject activityRef; public GalleryRunnable(AndroidJavaObject activity) { this.activityRef = activity; } @Override public void run() { // 此时已在Android主线程 Activity activity = (Activity) activityRef.GetRawObject(); // 调用相册打开逻辑 ((UnityPlayerActivity) activity).openGallery(GALLERY_REQUEST_CODE); } }

这种模式确保所有Android Java操作都在UI线程完成,彻底规避线程冲突。

3.2 回调Unity:BroadcastReceiver比接口回调更可靠

Java层获取到图片路径后,如何通知Unity?常见方案有二:

  • 方案A:定义Java接口,Unity实现并传入Java层
  • 方案B:发送本地Broadcast,Unity注册Receiver监听

我强烈推荐方案B。原因很现实:方案A要求Java层持有Unity传入的接口引用,而Android Activity重建(如横竖屏切换)会导致引用失效,回调丢失;BroadcastReceiver是系统级组件,生命周期独立,且支持粘性广播(Sticky Broadcast)兜底。实测在华为P40、OPPO Reno5上,方案A失败率超30%,方案B接近100%成功。

Java端发送广播:

private void sendToUnity(String filePath) { Intent intent = new Intent("com.yourcompany.GALLERY_RESULT"); intent.putExtra("file_path", filePath); intent.putExtra("request_code", GALLERY_REQUEST_CODE); LocalBroadcastManager.getInstance(this).sendBroadcast(intent); }

Unity侧监听:

// 在Start()中注册 private void Start() { // 注册广播接收器 using (var broadcastManager = new AndroidJavaClass("android.support.v4.content.LocalBroadcastManager")) { var instance = broadcastManager.CallStatic<AndroidJavaObject>("getInstance", activity); var receiver = new AndroidJavaObject("com.yourcompany.GalleryReceiver"); var intentFilter = new AndroidJavaObject("android.content.IntentFilter", "com.yourcompany.GALLERY_RESULT"); instance.Call("registerReceiver", receiver, intentFilter); } } // 自定义Receiver类(需在Android工程中实现) public class GalleryReceiver : BroadcastReceiver { @Override public void onReceive(Context context, Intent intent) { String filePath = intent.getStringExtra("file_path"); int requestCode = intent.getIntExtra("request_code", 0); // 通过UnityPlayer.UnitySendMessage通知C#脚本 UnityPlayer.UnitySendMessage("GalleryManager", "OnGalleryResult", filePath); } }

UnityPlayer.UnitySendMessage是Unity官方提供的跨语言消息机制,比自定义事件总线更轻量、更稳定。

4. Scoped Storage适配:Android 10+的“新大陆”规则

2020年Android 10强制启用Scoped Storage,这是Unity相册功能的分水岭。在此之前,“路径即一切”;在此之后,“路径即幻影”。Environment.getExternalStorageDirectory()返回的路径不再可写,MediaStore查询受限,getRealPathFromUri大概率返回null。很多老项目因此崩溃,根源在于没理解Scoped Storage的核心思想:应用只能直接访问自己创建的文件,访问他人文件必须通过系统中介(MediaStore或SAF)。

4.1 MediaStore插入法:绕过路径,直取媒体ID

当getRealPathFromUri返回null时,不要死磕路径转换。转而用MediaStore的insert方法,将URI内容复制到应用私有目录:

private String copyUriToPrivateDir(Uri uri) { InputStream inputStream = null; OutputStream outputStream = null; try { inputStream = getContentResolver().openInputStream(uri); // 创建应用私有目录文件 File privateDir = getExternalFilesDir(Environment.DIRECTORY_PICTURES); File outputFile = new File(privateDir, "gallery_" + System.currentTimeMillis() + ".jpg"); outputStream = new FileOutputStream(outputFile); // 流拷贝 byte[] buffer = new byte[4096]; int len; while ((len = inputStream.read(buffer)) != -1) { outputStream.write(buffer, 0, len); } return outputFile.getAbsolutePath(); } catch (Exception e) { e.printStackTrace(); return null; } finally { try { if (inputStream != null) inputStream.close(); if (outputStream != null) outputStream.close(); } catch (IOException e) { e.printStackTrace(); } } }

此方法生成的路径绝对可用,且无需任何危险权限。getExternalFilesDir()返回的路径属于应用沙盒,卸载应用时自动清理,符合Google Play政策。

4.2 SAF(Storage Access Framework):用户授权的“文件保险箱”

对于需要访问非媒体类文件(如用户下载的MP4),必须启用SAF。它要求用户手动选择文件夹,但换来的是永久读写权限:

// Java端启动SAF public void openSAF() { Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); intent.addCategory(Intent.CATEGORY_OPENABLE); intent.setType("*/*"); startActivityForResult(intent, SAF_REQUEST_CODE); }

Unity侧收到URI后,用DocumentFile.fromSingleUri()获取访问权:

// 获取DocumentFile对象 DocumentFile documentFile = DocumentFile.fromSingleUri(this, uri); // 检查是否可读 if (documentFile.canRead()) { // 读取流 InputStream is = getContentResolver().openInputStream(uri); // 复制到私有目录(同上) }

SAF流程虽多一步交互,但它是Android 11+访问外部存储的唯一合规路径。

提示:在AndroidManifest.xml中必须声明<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />和<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />(Android 12+),否则MediaStore查询直接失败。

5. 实战避坑指南:那些文档里不会写的血泪教训

写完代码不等于功能可用。我在某教育类App上线前一周,被一个“偶发性崩溃”折磨到凌晨三点——现象是:相册选图后Unity黑屏,Logcat显示JNI ERROR (app bug): local reference table overflow (max=512)。排查三天才发现,是AndroidJavaObject未及时Dispose()导致JNI局部引用表爆满。这类坑,只有亲手填过才刻骨铭心。

5.1 JNI引用泄漏:每个new都要配Dispose

Unity的AndroidJavaObject和AndroidJavaClass都是JNI对象包装器,底层对应Java对象的局部引用。不手动释放,引用数持续累加,直到触发JVM崩溃。正确写法:

// 错误:忘记Dispose var activity = new AndroidJavaClass("com.unity3d.player.UnityPlayer").GetStatic<AndroidJavaObject>("currentActivity"); // 正确:using语句自动Dispose using (var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer")) { using (var activity = unityPlayer.GetStatic<AndroidJavaObject>("currentActivity")) { // 执行操作 activity.Call("openGallery"); } // activity.Dispose()自动调用 } // unityPlayer.Dispose()自动调用

所有AndroidJavaObject实例都必须包裹在using块中,包括Intent、Uri等临时对象。

5.2 权限动态申请:WRITE_EXTERNAL_STORAGE已是历史

Android 6.0+要求运行时申请权限,但很多人还在申请WRITE_EXTERNAL_STORAGE。这是大忌!Android 10+该权限已失效,申请它反而触发Google Play审核拒绝。正确权限清单:

<!-- AndroidManifest.xml --> <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" /> <uses-permission android:name="android.permission.READ_MEDIA_VIDEO" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="28" />

READ_EXTERNAL_STORAGE仅对Android 9及以下生效,maxSdkVersion="28"确保高版本不声明该权限。Unity侧用Permission.RequestUserPermission(Permission.ExternalStorageRead)申请即可。

5.3 构建配置陷阱:Gradle版本与Plugin冲突

Unity 2021.3+默认使用Android Gradle Plugin 4.2,但某些老插件依赖AGP 3.6。构建时报错Could not find method compileOptions()?这是AGP版本不匹配。解决方案:在mainTemplate.gradle中强制指定:

buildscript { dependencies { classpath 'com.android.tools.build:gradle:4.2.2' // 统一为4.2.2 } }

同时关闭Jetifier(Unity 2021.3+默认开启):

android.useAndroidX=true android.enableJetifier=false

Jetifier会重写第三方库的support包引用,但某些相册插件未适配AndroidX,开启后反而编译失败。

5.4 真机测试清单:别信模拟器的“一切正常”

模拟器永远无法复现真机问题。我的测试清单强制包含:

  • 华为EMUI 11:检查FileProviderauthority是否与applicationId完全一致(少一个点都报IllegalArgumentException);
  • 小米MIUI 12.5:验证Intent.FLAG_GRANT_READ_URI_PERMISSION是否生效(MIUI常静默拦截);
  • 三星One UI 4:测试MediaStore批量查询性能(返回1000+图片时是否ANR);
  • Pixel 5(Android 12):确认ACTION_GET_CONTENT是否支持多选(需Intent.EXTRA_ALLOW_MULTIPLE = true)。

每次发版前,必须用这四台设备实测完整流程:打开相册→选择图片→返回Unity→加载纹理→播放视频。漏一台,线上就可能炸。

6. 性能优化与扩展:从“能用”到“好用”的最后一公里

功能跑通只是起点。用户点击相册按钮,到图片显示在UI上,延迟超过800ms就会感知卡顿。我们做了三件事:预加载缩略图、异步解码、内存池复用。

6.1 缩略图预加载:让用户“感觉”秒开

相册界面加载慢,主因是MediaStore查询全尺寸图片耗时。解决方案:只查缩略图URI,用ContentResolver.loadThumbnail()异步加载:

// Java端查询缩略图 Cursor cursor = getContentResolver().query( MediaStore.Images.Thumbnails.EXTERNAL_CONTENT_URI, new String[]{MediaStore.Images.Thumbnails._ID, MediaStore.Images.Thumbnails.DATA}, null, null, null ); while (cursor.moveToNext()) { long id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Thumbnails._ID)); String thumbnailPath = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.Thumbnails.DATA)); // 发送缩略图路径到Unity }

Unity侧用Texture2D.LoadImage()快速解码,比加载原图快5倍以上。

6.2 异步图片解码:阻塞主线程是UX杀手

Unity中Texture2D.LoadImage()是同步阻塞调用。一张5MB图片解码可能耗时300ms,UI直接冻结。我们封装了异步解码器:

public static async Task<Texture2D> LoadTextureAsync(string imagePath) { var bytes = await File.ReadAllBytesAsync(imagePath); // 异步读取 var texture = new Texture2D(2, 2); await Task.Run(() => texture.LoadImage(bytes)); // CPU密集型操作放线程池 texture.Apply(); // Apply必须在主线程 return texture; }

配合async/await,图片加载全程不卡UI。

6.3 内存池管理:避免GC风暴

频繁创建Texture2D会触发GC,造成帧率波动。我们实现了一个简易内存池:

public class TexturePool { private static readonly Queue<Texture2D> pool = new Queue<Texture2D>(); public static Texture2D Get(int width, int height, TextureFormat format) { if (pool.Count > 0) { var texture = pool.Dequeue(); if (texture.width == width && texture.height == height && texture.format == format) return texture; texture.Destroy(); } return new Texture2D(width, height, format, false); } public static void Return(Texture2D texture) { if (texture != null && pool.Count < 10) // 限制池大小 pool.Enqueue(texture); } }

每次加载图片后,不用DestroyImmediate,而是TexturePool.Return(texture)归还,下次Get直接复用。

最后分享个小技巧:在AndroidManifest.xml的<activity>标签里添加android:configChanges="orientation|screenSize|keyboardHidden",能防止横竖屏切换时Activity重建,避免相册回调丢失。这个配置加了,用户旋转手机时相册界面依然稳如泰山——细节,才是专业和业余的分界线。

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

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

立即咨询