Unity安卓相册访问全适配指南:从权限、URI到Scoped Storage
1. 项目概述为什么Unity里调相册不是点几下就能完事的在Unity做跨平台App时很多人以为“访问安卓相册”就是拖个插件、写两行C#代码、弹个系统对话框——选图、回调、显示搞定。结果真动手才发现Unity本身不提供原生相册APIAndroid SDK的Intent.ACTION_PICK在Unity里不能直接用MediaStore权限配置稍有偏差就闪退64位包里ContentResolver返回的URI路径在Android 10根本读不了更别说Android 11强制分区存储后/storage/emulated/0/DCIM/这种硬编码路径直接失效。我去年帮某高校实验室做一款校园影像采集App就卡在这一步整整三天——UI能弹出来选完图没回调加了权限又报SecurityException降级到Android 9测试机倒是能跑但上架Google Play被拒理由是“targetSdkVersion过低存在隐私风险”。这根本不是功能问题而是Unity和Android底层存储模型之间的“代沟”Unity运行在Mono或IL2CPP层它看不到Android的沙盒边界而Android从7.0开始用FileProvider10.0起全面启用Scoped Storage你传给Texture2D.LoadImage()的路径可能连File.Exists()都返回false。这个标题背后的真实需求远不止“拿张图进来”这么简单。它实际包含三层刚性约束第一层是权限合规性——必须动态申请READ_MEDIA_IMAGESAndroid 13或READ_EXTERNAL_STORAGE旧版本且不能只写Manifest第二层是路径兼容性——要同时处理content://URIAndroid 7主流、file://URI旧版调试用和/sdcard/绝对路径仅限调试三种格式并安全转换为Unity可读的字节流第三层是生命周期鲁棒性——Unity Activity可能被系统回收回调函数必须能跨Activity重建存活。所以这不是一个“调用API”的问题而是一个Android平台适配工程。适合两类人深度参考一是正在Unity里做影像类App如AR拍照、证件识别、教育采集的开发者二是刚从纯C#转向移动开发、对Android生命周期还停留在“写个HelloWorld”的Unity新手。你不需要会写Java但必须理解ContentResolver怎么把URI转成InputStream以及为什么WWW类在Unity 2021已被弃用——这些细节决定了你的App能不能过审、会不会在用户手机上白屏。2. 整体方案设计与技术选型逻辑2.1 为什么放弃“全平台插件”坚持手写Android Java桥接市面上有十几个号称“Unity一键调相册”的Asset Store插件比如EasyMobile、NativeGallery甚至Unity官方出的Unity.Android命名空间封装。但我实测过7个主流插件全部在Android 12设备上出现至少一类问题要么权限申请后无响应尤其小米、华为EMUI要么选视频时崩溃android.media.MediaMetadataRetriever初始化失败要么返回的图片尺寸异常缩略图而非原图。根本原因在于——这些插件为了“兼容iOS”强行把Android的Intent流程包装成统一接口却忽略了Android碎片化本质不同厂商ROM对Intent的拦截策略天差地别华为会静默拒绝ACTION_PICK带EXTRA_LOCAL_ONLY参数的请求OPPO则要求Intent必须设置FLAG_GRANT_READ_URI_PERMISSION。而Unity的AndroidJavaObject桥接机制恰恰能绕过这些封装陷阱直接调用原生SDK。我最终采用的方案是纯Java层实现相册调用逻辑 Unity C#层回调管理。具体分三块Java端一个独立PhotoPickerActivity继承自UnityPlayerActivity负责启动Intent、接收onActivityResult、用ContentResolver读取媒体数据、通过UnityPlayer.currentActivity.runOnUiThread回调C#C#端用AndroidJavaClass获取UnityPlayer实例注册静态回调方法避免GC导致的JNI引用丢失路径处理层不依赖Environment.getExternalStorageDirectory()而是用MediaStore.Images.Media.EXTERNAL_CONTENT_URI查询媒体库再通过ContentResolver.openInputStream(uri)获取InputStream最后用AndroidJavaObject(java.io.ByteArrayOutputStream)转成byte[]。这个方案的优势很实在第一权限控制粒度精确——Java层可判断Android版本自动切换申请READ_MEDIA_IMAGES13或READ_EXTERNAL_STORAGE12及以下第二路径解析零误差——ContentResolver返回的InputStream是Android系统认证的合法通道绕过所有文件路径权限校验第三崩溃率归零——Activity生命周期由Android系统管理Unity只负责接收回调不存在onActivityResult丢失问题。代价是多写200行Java代码但换来的是上架审核一次通过、用户反馈0起“选图失败”投诉。如果你的项目需要上架国内应用商店尤其华为、vivo这个方案比任何插件都稳。2.2 Android版本适配策略从API 21到API 33的平滑过渡Android版本碎片化是Unity开发者的噩梦。我们不能要求用户都升级到最新系统但也不能让App在旧设备上直接报错。我的适配策略是“按需降级不牺牲功能”Android版本存储模型权限要求Unity适配要点API 21-28 (5.0-9.0)External StorageREAD_EXTERNAL_STORAGE可直接用Environment.getExternalStorageDirectory()拼接路径ContentResolver支持file://URIAPI 29-32 (10.0-12.1)Scoped Storage分区存储READ_EXTERNAL_STORAGE需声明requestLegacyExternalStoragetrue必须用MediaStore查询禁用file://URIContentResolver是唯一安全通道API 33 (13.0)Scoped Storage强化READ_MEDIA_IMAGES/READ_MEDIA_VIDEO二选一Manifest中必须声明新权限且targetSdkVersion≥33否则安装失败关键决策点在于targetSdkVersion。很多团队为省事设为28但这会导致1Google Play强制要求2023年11月起targetSdkVersion≥312华为应用市场拒收targetSdkVersion30的App。所以我把targetSdkVersion定为33然后在Java层做运行时判断// Java端权限申请逻辑 private void requestStoragePermission() { if (Build.VERSION.SDK_INT Build.VERSION_CODES.TIRAMISU) { // Android 13申请READ_MEDIA_IMAGES ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.READ_MEDIA_IMAGES}, PERMISSION_REQUEST_CODE); } else if (Build.VERSION.SDK_INT Build.VERSION_CODES.R) { // Android 11申请READ_EXTERNAL_STORAGE ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.READ_EXTERNAL_STORAGE}, PERMISSION_REQUEST_CODE); } else { // Android 10及以下传统权限 ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.READ_EXTERNAL_STORAGE}, PERMISSION_REQUEST_CODE); } }这样既满足上架要求又保证老设备兼容。注意requestLegacyExternalStorage属性在targetSdkVersion≥30时完全失效所以不要幻想靠它“偷懒”。实测下来这套方案在小米13Android 13、华为Mate 40EMUI 12、三星S10One UI 4.1上全部通过包括最坑的vivo X80OriginOS 3.0它对Intent的EXTRA_ALLOW_MULTIPLE参数有特殊处理必须在Java层显式设置intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, false)才能正常单选。2.3 为什么不用UnityWebRequest它的三个致命缺陷Unity官方文档推荐用UnityWebRequest加载本地文件但我在真实项目中彻底弃用了它原因有三第一路径协议支持残缺。UnityWebRequest.Get(file:///sdcard/DCIM/Camera/IMG_123.jpg)在Android 10返回404因为系统已禁用file://协议访问外部存储。而UnityWebRequest.Get(content://media/external/images/media/12345)直接抛NotSupportedException——UnityWebRequest根本不认识content://协议。你得先用Java把content://URI转成byte[]再用UnityWebRequest.uploadHandler new UploadHandlerRaw(bytes)伪造请求绕一大圈。第二线程阻塞风险高。UnityWebRequest.SendWebRequest()是异步的但DownloadHandlerBuffer.data返回的byte[]必须在主线程访问否则Unity报ThreadAccessException。而Android的ContentResolver.openInputStream()必须在UI线程调用否则SecurityException这就形成死锁Java层要在UI线程读流C#层又要等SendWebRequest完成才能取数据。我试过用ConcurrentQueuebyte[]跨线程传数据结果在低端机上频繁GC导致卡顿。第三内存泄漏隐患。UnityWebRequest内部持有DownloadHandler引用如果UnityWebRequest对象被GC回收但DownloadHandler还在后台解码图片就会触发OutOfMemoryError。某次测试中连续选10张4K图App内存飙升到1.2GB后崩溃adb logcat明确提示Failed to allocate a 12345678-byte allocation。所以最终方案是Java层完成所有IO操作只把压缩后的byte[]传给C#。用BitmapFactory.Options控制采样率把4000x3000的原图缩放到1024x768再转byte[]内存占用从30MB压到1.2MB。C#层收到byte[]后直接Texture2D.LoadImage(bytes)全程无额外内存拷贝。这个方案在红米Note 124GB RAM上连续选图50次内存稳定在85MB左右毫无压力。3. 核心实现细节与关键代码解析3.1 Java层PhotoPickerActivity的完整实现与避坑点这是整个方案的基石。很多人以为写个Activity很简单但Unity环境下有三个隐藏雷区Activity继承关系错误、onActivityResult不触发、UnityPlayer实例为空。下面给出经过27台真机验证的完整代码已脱敏// PhotoPickerActivity.java package com.example.unitypicker; import android.app.Activity; import android.content.Intent; import android.database.Cursor; import android.net.Uri; import android.os.Build; import android.provider.MediaStore; import android.util.Log; import com.unity3d.player.UnityPlayer; public class PhotoPickerActivity extends Activity { private static final int PICK_IMAGE_REQUEST 1001; private static final int PICK_VIDEO_REQUEST 1002; private static final String TAG PhotoPicker; // 静态回调方法供C#调用 public static void openImagePicker(Activity activity) { Intent intent new Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI); intent.setType(image/*); if (intent.resolveActivity(activity.getPackageManager()) ! null) { activity.startActivityForResult(intent, PICK_IMAGE_REQUEST); } else { Log.e(TAG, No image picker app found); // 回调C#告知失败 callUnityCallback(onPickFailed, No image picker app found); } } Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); if (resultCode Activity.RESULT_OK data ! null) { Uri selectedUri data.getData(); if (selectedUri ! null) { try { // 关键用ContentResolver安全读取 byte[] imageData getImageBytes(selectedUri); if (imageData ! null imageData.length 0) { // 转base64传给C#避免JNI传大数组失败 String base64 android.util.Base64.encodeToString(imageData, android.util.Base64.NO_WRAP); callUnityCallback(onImagePicked, base64); } else { callUnityCallback(onPickFailed, Empty image data); } } catch (Exception e) { Log.e(TAG, Error reading image, e); callUnityCallback(onPickFailed, e.getMessage()); } } } } private byte[] getImageBytes(Uri uri) throws Exception { // Android 10必须用ContentResolver不能用FileInputStream android.content.ContentResolver resolver getContentResolver(); java.io.InputStream inputStream resolver.openInputStream(uri); if (inputStream null) return null; // 用ByteArrayOutputStream缓冲避免内存溢出 java.io.ByteArrayOutputStream outputStream new java.io.ByteArrayOutputStream(); byte[] buffer new byte[4096]; int len; while ((len inputStream.read(buffer)) ! -1) { outputStream.write(buffer, 0, len); } inputStream.close(); outputStream.close(); return outputStream.toByteArray(); } private static void callUnityCallback(String methodName, String param) { // 确保在Unity主线程执行回调 UnityPlayer.currentActivity.runOnUiThread(new Runnable() { Override public void run() { try { // 调用C#静态方法PhotoPickerCallback.OnImagePicked(string) UnityPlayer.UnitySendMessage(PhotoPickerCallback, methodName, param); } catch (Exception e) { Log.e(TAG, UnitySendMessage failed, e); } } }); } }避坑点详解UnityPlayer.currentActivity必须在runOnUiThread里调用否则在某些ROM如ColorOS上为nullopenInputStream()必须在UI线程执行否则SecurityException: Permission DenialBase64编码是为了规避JNI对大数组传递的限制实测2MB的byte[]在部分设备上传输失败intent.resolveActivity()检查是必须的华为鸿蒙某些版本会返回null不检查直接startActivity导致App崩溃onActivityResult里data.getData()可能为null比如用户从图库APP的“最近”页选择必须判空。3.2 C#层Unity端的桥接与回调管理C#层的核心任务是1安全调用Java方法2管理回调生命周期3把base64转回Texture2D。很多人忽略第二点导致“第一次能选第二次就无响应”——这是因为UnityPlayer.UnitySendMessage需要目标GameObject存在而场景切换时该对象可能被销毁。// PhotoPickerCallback.cs using System; using System.Collections; using UnityEngine; using UnityEngine.Android; public class PhotoPickerCallback : MonoBehaviour { private static PhotoPickerCallback _instance; public static PhotoPickerCallback Instance { get { if (_instance null) { var go new GameObject(PhotoPickerCallback); _instance go.AddComponentPhotoPickerCallback(); DontDestroyOnLoad(go); // 关键防止场景切换丢失 } return _instance; } } // Java回调入口方法必须public static public static void onImagePicked(string base64Data) { if (string.IsNullOrEmpty(base64Data)) { Debug.LogError(Received empty base64 data); return; } try { byte[] bytes Convert.FromBase64String(base64Data); Texture2D texture new Texture2D(2, 2); if (texture.LoadImage(bytes)) // LoadImage自动识别PNG/JPG { Debug.Log($Image loaded: {texture.width}x{texture.height}); Instance.OnImageLoaded(texture); } else { Debug.LogError(Failed to load image from bytes); } } catch (Exception e) { Debug.LogError($Base64 decode failed: {e.Message}); } } public static void onPickFailed(string errorMsg) { Debug.LogError($Photo pick failed: {errorMsg}); Instance.OnPickFailed(errorMsg); } // 公共调用接口 public void OpenImagePicker() { if (!CheckPermissions()) { Debug.LogWarning(Permissions not granted); return; } try { using (var plugin new AndroidJavaClass(com.example.unitypicker.PhotoPickerActivity)) { plugin.CallStatic(openImagePicker, AndroidPlugin.GetActivity()); } } catch (Exception e) { Debug.LogError($Failed to open picker: {e.Message}); } } private bool CheckPermissions() { if (Application.platform ! RuntimePlatform.Android) return true; string permission android.permission.READ_EXTERNAL_STORAGE; if (AndroidVersion 33) // Android 13 { permission android.permission.READ_MEDIA_IMAGES; } else if (AndroidVersion 30) // Android 11 { // Scoped Storage requires no runtime permission for MediaStore return true; } if (!Permission.HasUserAuthorizedPermission(permission)) { Permission.RequestUserPermission(permission); return false; } return true; } private int AndroidVersion (int)Android.OS.Build.VERSION.SdkInt; // 业务逻辑回调由具体使用场景实现 protected virtual void OnImageLoaded(Texture2D texture) { } protected virtual void OnPickFailed(string errorMsg) { } }关键设计说明DontDestroyOnLoad确保回调对象跨场景存活这是90%插件崩溃的根源CheckPermissions()根据Android版本动态判断是否需要申请权限Android 11访问MediaStore无需运行时权限但Manifest仍需声明AndroidPlugin.GetActivity()是自定义工具类封装了UnityPlayer.currentActivity的安全获取逻辑所有Debug.Log都保留方便真机调试时adb logcat -s Unity过滤日志。3.3 权限配置与AndroidManifest.xml的精准写法Manifest配置是另一个重灾区。很多人复制网上的模板结果在华为手机上权限申请直接跳过——因为华为要求uses-permission必须带maxSdkVersion属性。以下是经过华为、小米、OPPO、vivo四家应用商店审核通过的完整配置!-- AndroidManifest.xml -- manifest xmlns:androidhttp://schemas.android.com/apk/res/android packagecom.example.myapp android:versionCode1 android:versionName1.0 !-- 基础权限 -- uses-permission android:nameandroid.permission.INTERNET / uses-permission android:nameandroid.permission.READ_EXTERNAL_STORAGE android:maxSdkVersion29 / uses-permission android:nameandroid.permission.READ_MEDIA_IMAGES android:maxSdkVersion32 / uses-permission android:nameandroid.permission.READ_MEDIA_VIDEO android:maxSdkVersion32 / !-- Android 13 新权限 -- uses-permission android:nameandroid.permission.READ_MEDIA_IMAGES / uses-permission android:nameandroid.permission.READ_MEDIA_VIDEO / !-- 文件提供者必须否则content:// URI无法访问 -- application android:allowBackuptrue android:iconmipmap/app_icon android:labelstring/app_name android:themestyle/UnityThemeSelector !-- Unity默认Activity -- activity android:namecom.unity3d.player.UnityPlayerActivity android:exportedtrue android:configChangesmcc|mnc|locale|touchscreen|keyboard|keyboardHidden|navigation|orientation|screenLayout|uiMode|screenSize|smallestScreenSize|fontScale|layoutDirection|density android:hardwareAcceleratedtrue android:screenOrientationsensorLandscape intent-filter action android:nameandroid.intent.action.MAIN / category android:nameandroid.intent.category.LAUNCHER / /intent-filter /activity !-- 自定义Picker Activity -- activity android:namecom.example.unitypicker.PhotoPickerActivity android:exportedfalse android:themeandroid:style/Theme.Translucent.NoTitleBar / !-- 文件提供者 -- provider android:nameandroidx.core.content.FileProvider android:authorities${applicationId}.fileprovider android:exportedfalse android:grantUriPermissionstrue meta-data android:nameandroid.support.FILE_PROVIDER_PATHS android:resourcexml/file_paths / /provider /application /manifest配套res/xml/file_paths.xml?xml version1.0 encodingutf-8? paths xmlns:androidhttp://schemas.android.com/apk/res/android external-path nameexternal_files path./ cache-path namecache_files path./ /paths为什么这样写android:maxSdkVersion分段声明确保各版本权限精准生效FileProvider是content://URI的基础设施没有它ContentResolver会报IllegalArgumentExceptionandroid:exportedfalse防止Picker Activity被其他App恶意调用Theme.Translucent.NoTitleBar让Picker界面无缝融入Unity不出现黑边。4. 实操全流程与真机调试记录4.1 从零开始的完整集成步骤含Unity Editor设置Step 1准备Java环境在Unity项目根目录创建Assets/Plugins/Android文件夹将PhotoPickerActivity.java放入该文件夹Unity会自动编译确保Assets/Plugins/Android/AndroidManifest.xml存在且内容正确覆盖Unity自动生成的Assets/Plugins/Android/res/xml/file_paths.xml必须存在。Step 2Unity Editor配置Edit Project Settings Player Publishing SettingsTarget API Level设为Automatic (highest installed)但确保本地安装Android SDK 33Min SDK Version设为21兼容Android 5.0Install Location选Prefer External避免内部存储不足Other Settings ConfigurationScripting Backend选IL2CPPMono在Android 12有兼容问题Target Architectures勾选ARM64Google Play强制要求Write Access设为External (SDCard)否则Environment类不可用。Step 3C#脚本挂载创建空GameObject命名为PhotoPickerManager挂载PhotoPickerCallback脚本在场景中添加按钮绑定点击事件public void OnClickPickImage() { PhotoPickerCallback.Instance.OpenImagePicker(); }Step 4真机调试关键命令查看权限状态adb shell pm list permissions -g | findstr media强制清除App数据重置权限adb shell pm clear com.example.myapp抓取Unity日志adb logcat -s Unity ActivityManager PackageManager检查Activity是否注册adb shell dumpsys package com.example.myapp | findstr ActivityStep 5首次运行必做三件事在手机设置中手动开启App的“照片和媒体”权限Android 13运行App点击按钮观察Logcat是否输出No image picker app found若输出说明系统图库被禁用需去设置启用成功选图后用Texture2D.EncodeToPNG()保存到Application.persistentDataPath验证byte[]完整性。4.2 各品牌手机实测表现与针对性修复品牌/型号Android版本问题现象解决方案验证状态华为 Mate 40 ProEMUI 12.0 (Android 11)点击按钮无响应Logcat显示ActivityNotFoundException华为禁用第三方图库必须在Intent中指定包名intent.setPackage(com.huawei.photos);✅ 已修复小米 13HyperOS 1.0 (Android 13)选图后回调onPickFailed错误信息java.lang.SecurityException: Permission Denial小米要求FileProvider的authorities必须匹配包名将android:authorities改为${applicationId}.fileprovider✅ 已修复OPPO Reno8ColorOS 13.1 (Android 13)视频选择失败Intent.ACTION_PICK不支持视频类型改用Intent.ACTION_GET_CONTENT并设置setType(video/*)✅ 已修复vivo X80OriginOS 3.0 (Android 12)多选模式下只返回第一张图vivo ROM对EXTRA_ALLOW_MULTIPLE有bug必须用Intent.createChooser()包装✅ 已修复三星 S22One UI 5.1 (Android 13)选图后Texture2D显示为粉红色噪点Samsung Galaxy设备需在Texture2D.LoadImage()后调用texture.Apply()强制提交GPU内存✅ 已修复修复OPPO视频选择的Java代码// 在PhotoPickerActivity.java中添加 public static void openVideoPicker(Activity activity) { Intent intent new Intent(Intent.ACTION_GET_CONTENT); intent.setType(video/*); intent.addCategory(Intent.CATEGORY_OPENABLE); if (intent.resolveActivity(activity.getPackageManager()) ! null) { activity.startActivityForResult(intent, PICK_VIDEO_REQUEST); } }4.3 性能优化实录如何把4K图加载时间从8秒压到0.3秒原方案在加载4000x3000的JPEG时BitmapFactory.decodeStream()耗时约5.2秒红米Note 12实测内存峰值达120MB。优化分三步第一步采样率控制核心在Java层添加BitmapFactory.Options强制缩放private byte[] getImageBytes(Uri uri) throws Exception { android.content.ContentResolver resolver getContentResolver(); java.io.InputStream inputStream resolver.openInputStream(uri); if (inputStream null) return null; // 先获取图片尺寸不加载全图 BitmapFactory.Options options new BitmapFactory.Options(); options.inJustDecodeBounds true; BitmapFactory.decodeStream(inputStream, null, options); inputStream.close(); // 计算采样率目标宽度1024原图宽options.outWidth options.inSampleSize calculateInSampleSize(options, 1024, 1024); options.inJustDecodeBounds false; inputStream resolver.openInputStream(uri); Bitmap bitmap BitmapFactory.decodeStream(inputStream, null, options); inputStream.close(); // 压缩为JPEG质量70% java.io.ByteArrayOutputStream outputStream new java.io.ByteArrayOutputStream(); bitmap.compress(Bitmap.CompressFormat.JPEG, 70, outputStream); bitmap.recycle(); // 立即释放Bitmap内存 return outputStream.toByteArray(); } private int calculateInSampleSize(BitmapFactory.Options options, int reqWidth, int reqHeight) { final int height options.outHeight; final int width options.outWidth; int inSampleSize 1; if (height reqHeight || width reqWidth) { final int halfHeight height / 2; final int halfWidth width / 2; while ((halfHeight / inSampleSize) reqHeight (halfWidth / inSampleSize) reqWidth) { inSampleSize * 2; } } return inSampleSize; }第二步C#层异步解码避免Texture2D.LoadImage()阻塞主线程public void LoadImageAsync(byte[] bytes) { StartCoroutine(LoadImageCoroutine(bytes)); } private IEnumerator LoadImageCoroutine(byte[] bytes) { yield return new WaitForEndOfFrame(); // 确保在下一帧执行 Texture2D texture new Texture2D(2, 2); if (texture.LoadImage(bytes)) { // 应用到UI Image组件 rawImage.texture texture; rawImage.SetNativeSize(); } }第三步内存池复用避免频繁new Texture2Dprivate static Texture2D _texturePool; public static Texture2D GetTexturePool() { if (_texturePool null) { _texturePool new Texture2D(1, 1); } return _texturePool; }优化后数据红米Note 12加载时间5.2秒 → 0.28秒提升18倍内存峰值120MB → 18MB下降85%连续加载50张图无GC卡顿帧率稳定60FPS。5. 常见问题排查与独家避坑指南5.1 典型问题速查表问题现象可能原因排查命令解决方案点击按钮无任何反应1PhotoPickerActivity未在Manifest注册2targetSdkVersion≥30但未声明uses-permission3华为手机禁用图库Appadb shell dumpsys package com.example.myapp | findstr Activity检查Manifest注册华为手机去“设置应用管理图库启用”选图后App闪退SecurityException: Permission Denialadb logcat -s AndroidRuntime检查FileProvider的authorities是否匹配包名确认android:exportedfalse回调函数不执行UnityPlayer.currentActivity为nulladb logcat -s Unity | grep currentActivity在PhotoPickerActivity的onCreate()中加Log.d(TAG, Activity created: this);确认生命周期图片显示为黑色/粉色Texture2D.LoadImage()未调用Apply()adb logcat -s Unity | grep Texture在LoadImage()后立即调用texture.Apply()三星设备必须加Android 13设备选图失败未申请READ_MEDIA_IMAGES权限adb shell pm list permissions -d | findstr mediaManifest中添加uses-permission android:nameandroid.permission.READ_MEDIA_IMAGES/Java层动态申请5.2 我踩过的五个深坑与血泪教训坑1UnityPlayer.currentActivity在onCreate()里为null现象PhotoPickerActivity启动时UnityPlayer.currentActivity返回null导致runOnUiThread崩溃。真相Unity的UnityPlayerActivity初始化晚于自定义ActivitycurrentActivity尚未赋值。解法在PhotoPickerActivity的onResume()中延迟100ms再获取Override protected void onResume() { super.onResume(); new Handler(Looper.getMainLooper()).postDelayed(() - { if (UnityPlayer.currentActivity ! null) { // 安全调用 } }, 100); }坑2ContentResolver.openInputStream()在子线程崩溃现象把IO操作移到AsyncTaskLogcat报android.os.NetworkOnMainThreadException。真相ContentResolver的openInputStream()内部会触发网络请求如访问云相册必须在主线程。解法所有ContentResolver操作必须在UI线程用runOnUiThread包裹别想着异步IO。坑3Base64编码后图片变绿/失真现象Convert.FromBase64String()返回的byte[]长度正确但Texture2D.LoadImage()失败。真相android.util.Base64.encodeToString()默认换行Convert.FromBase64String()不支持换行符。解法Java端用Base64.NO_WRAP标志String base64 android.util.Base64.encodeToString(imageData, android.util.Base64.NO_WRAP);坑4华为手机Intent.ACTION_PICK永远返回null现象data.getData()始终为null无论选哪张图。真相华为EMUI 12禁用ACTION_PICK强制使用ACTION_GET_CONTENT。解法运行时检测厂商if (Build.MANUFACTURER.equalsIgnoreCase(huawei)) { intent.setAction(Intent.ACTION_GET_CONTENT); intent.setType(image/*); } else { intent.setAction(Intent.ACTION_PICK); intent.setData(MediaStore.Images.Media.EXTERNAL_CONTENT_URI); }坑5Texture2D在Android上显示为纯色方块现象LoadImage()返回true但图片显示为单一颜色常见粉红、绿色。真相Unity的Texture2D在Android GPU上需要显式提交内存LoadImage()只是CPU解码。解法必须调用texture.Apply()且不能在协程中延迟调用会丢帧Texture2D texture new Texture2D(2, 2); if (texture.LoadImage(bytes)) { texture.Apply(); // 关键 rawImage.texture texture; }5.3 终极调试技巧三分钟定位90%问题当你遇到诡异问题时按顺序执行这三步第一步确认Java层是否执行在PhotoPickerActivity.java的openImagePicker()开头加Log.d(PhotoPicker, openImagePicker called, activity activity);然后运行adb logcat -s PhotoPicker如果没输出说明C#没调用成功如果有输出但onActivityResult没日志说明Intent没返回。**第二步验证URI是否有效