Unity安卓相机相册权限全版本适配:从运行时权限到作用域存储实战
1. 项目概述最近在做一个Unity项目需要调用安卓设备的相机拍照和访问相册选取图片。本以为是个简单的API调用结果一上手就发现坑不少。从安卓6.0API 23引入运行时权限模型开始到后续版本对权限组、后台访问、作用域存储Scoped Storage等一系列规则的收紧让这个“基础功能”的适配变得相当棘手。如果你直接使用Unity的Application.HasUserAuthorization或者一些过时的插件在部分新设备上大概率会碰壁轻则功能失效重则应用崩溃。这篇文章我就把自己从安卓6.0到最新安卓14API 34的权限适配实战经验包括核心思路、代码实现、避坑技巧完整地梳理一遍。无论你是Unity独立开发者还是团队里的客户端主程这套方案都能帮你省去大量查文档和踩坑的时间实现一套健壮、高兼容性的相机相册权限管理模块。2. 权限模型演进与核心挑战解析2.1 安卓权限机制的历史变迁要搞定适配首先得明白规则是怎么变的。安卓的权限管理大致可以分为三个阶段安卓5.1及以前安装时授权用户在安装应用时需要一次性同意应用声明的所有权限。一旦安装应用就拥有了这些权限。这个阶段对开发者最“友好”但用户体验和隐私保护很差。安卓6.0到10运行时危险权限这是权限管理的分水岭。谷歌引入了“危险权限”概念如相机、存储、位置等。这些权限必须在应用运行时动态向用户申请用户可以在使用中随时在系统设置里关闭某个权限。对于存储权限虽然仍需申请READ_EXTERNAL_STORAGE和WRITE_EXTERNAL_STORAGE但访问方式相对宽松。安卓11及以上强化隐私与作用域存储权限管控更加严格。主要体现在两点权限自动重置如果用户几个月未使用应用系统会自动重置其已授予的运行时权限。应用再次启动时需要重新申请。作用域存储Scoped Storage这是对存储权限的颠覆性改变。应用无法再通过简单的存储权限直接访问设备上的任何文件。访问媒体文件图片、视频、音频必须通过系统的“文件选择器”如ACTION_OPEN_DOCUMENT,ACTION_PICK或使用MediaStoreAPI并且只能访问自己创建的文件或用户明确授权访问的文件。MANAGE_EXTERNAL_STORAGE这个“所有文件访问”权限的申请门槛极高普通应用几乎不可能上架。对于Unity开发者而言我们主要需要应对的就是阶段2和阶段3带来的挑战。Unity引擎本身提供的权限APIUnityEngine.Android.Permission在基础运行时权限申请上还能用但在处理安卓11的作用域存储、以及更精细化的权限管理和用户体验上就显得力不从心了。2.2 Unity项目中的具体挑战结合实战我总结了Unity安卓权限适配的几个核心痛点Unity API的局限性UnityEngine.Android.Permission类主要针对标准的危险权限申请和检查。但对于“相机”和“相册”这个具体场景相册访问在安卓11上单纯拥有READ_EXTERNAL_STORAGE权限已经不够。你需要使用Intent启动系统的文件选择器或相册应用这超出了Unity引擎API的直接范围需要编写Android Java插件Android Java Plugin, AJP。权限解释申请权限时向用户展示“为什么需要这个权限”的说明弹窗RationaleUnity原生API支持较弱需要自己封装。永久拒绝处理用户勾选“不再询问”并拒绝后如何优雅地引导用户去系统设置页手动开启Unity没有提供直接方案。版本碎片化你的应用可能同时安装在安卓8、安卓10、安卓13的设备上。代码必须能智能判断当前系统版本并执行不同的逻辑分支。例如在安卓10上你可能还需要申请存储权限来保存拍照图片但在安卓11上你应该使用MediaStore来保存到公共目录或者保存到应用的私有目录。用户体验割裂直接弹出一个系统权限对话框很生硬。更好的做法是在真正需要权限的操作前比如用户点击“上传头像”先展示一个自定义的引导页解释权限用途用户同意后再触发系统弹窗。这套前置流程需要自己设计。存储路径的混乱在安卓11上Application.persistentDataPath对应/data/data/包名/files是绝对安全的私有目录但其他应用无法访问。如果你拍摄的照片希望出现在系统相册里就必须通过MediaStoreAPI插入到公共媒体库中这又是一套新的操作。3. 兼容性方案整体架构设计面对上述挑战我设计了一套分层、可插拔的兼容性方案。核心思想是用C#编写一套统一的、面向业务的权限服务接口在底层根据不同的安卓API级别动态选择和执行不同的原生Android Java实现。3.1 方案分层与职责整个方案分为四层业务层Unity C#这是游戏逻辑所在层。它只关心“我要拍照”或“我要选图”这个业务指令不关心底层如何实现。它调用一个统一的NativeCamera或NativeGallery服务类。适配层Unity C#这是核心桥梁。它定义接口并包含一个AndroidPermissionHelper类。这个类的职责是使用SystemInfo.operatingSystem或AndroidJNI判断当前安卓版本。根据版本号通过AndroidJavaClass和AndroidJavaObject动态调用对应的Java实现类。封装权限检查、申请、Rationale提示、跳转设置等通用逻辑。原生实现层Android Java这里包含两个主要的Java类LegacyCameraGalleryHelper用于API 30即安卓11之前使用Intent.ACTION_PICK或Intent.ACTION_GET_CONTENT访问相册通过MediaStore.ACTION_IMAGE_CAPTURE调用相机并将图片保存到Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES)等公共路径然后申请存储权限来读取它。ModernCameraGalleryHelper用于API 30即安卓11及以上使用Intent.ACTION_OPEN_DOCUMENT或MediaStore.ACTION_PICK_IMAGESAPI 33访问媒体文件。调用相机后通过MediaStore.Images.Media.insertImage或ContentResolver将图片插入到公共媒体库从而无需申请存储权限即可让图片出现在相册。配置层AndroidManifest.xml声明必要的权限和特性。注意在安卓11上某些旧权限的声明方式需要调整。!-- AndroidManifest.xml 关键配置 -- uses-permission android:nameandroid.permission.CAMERA / !-- 安卓10及以下需要声明的存储权限 -- uses-permission android:nameandroid.permission.READ_EXTERNAL_STORAGE android:maxSdkVersion29 / uses-permission android:nameandroid.permission.WRITE_EXTERNAL_STORAGE android:maxSdkVersion29 / !-- 安卓11及以上如果希望保存到公共目录可能需要这个但Google Play政策严格 -- uses-permission android:nameandroid.permission.MANAGE_EXTERNAL_STORAGE android:minSdkVersion30 tools:ignoreScopedStorage / !-- 声明相机特性非权限但某些商店需要 -- uses-feature android:nameandroid.hardware.camera android:requiredfalse /注意MANAGE_EXTERNAL_STORAGE权限在Google Play上受到严格限制仅限文件管理器、备份恢复等少数类型应用可以使用。对于大多数应用强烈建议不要声明和使用此权限而是遵循作用域存储规范使用MediaStore或文件选择器。3.2 核心工作流程权限预检与引导用户触发拍照/选图操作时适配层首先检查所需权限状态。如果未授权先展示自定义的Rationale UI说明权限用途用户点击“同意”后再调用系统申请。动态路由根据Build.VERSION.SDK_INT值适配层决定是实例化LegacyCameraGalleryHelper还是ModernCameraGalleryHelper。执行原生操作调用对应Java类的方法启动相应的Intent相机或文件选择器。结果回调与处理Unity通过AndroidJavaProxy或UnityPlayer.UnitySendMessage接收Java端返回的图片路径或URI。对于安卓11返回的content://格式的URI需要使用ContentResolver打开流并读取到Unity可访问的私有目录再进行纹理加载等后续操作。4. 关键代码实现与详解4.1 Unity C# 适配层核心代码以下是AndroidPermissionHelper类的简化核心它负责版本判断和动态调用。// AndroidPermissionHelper.cs using UnityEngine; using System; using System.Collections; public class AndroidPermissionHelper : MonoBehaviour { private static AndroidJavaObject _currentHelper; // 初始化获取当前系统对应的Java助手对象 private static void InitializeHelper() { if (_currentHelper ! null) return; int apiLevel GetAndroidAPILevel(); string className; if (apiLevel 30) // Android 11 (API 30) and above { className com.yourcompany.modernhelper.ModernCameraGalleryHelper; } else { className com.yourcompany.legacyhelper.LegacyCameraGalleryHelper; } try { AndroidJavaClass helperClass new AndroidJavaClass(className); AndroidJavaObject activity GetUnityActivity(); _currentHelper helperClass.CallStaticAndroidJavaObject(getInstance, activity); } catch (Exception e) { Debug.LogError($Failed to initialize Android helper: {e.Message}); _currentHelper null; } } private static int GetAndroidAPILevel() { // 方法1通过SystemInfo可能不精确 // string os SystemInfo.operatingSystem; // 方法2通过Android Java API更可靠 using (AndroidJavaClass version new AndroidJavaClass(android.os.Build$VERSION)) { return version.GetStaticint(SDK_INT); } } private static AndroidJavaObject GetUnityActivity() { using (AndroidJavaClass unityPlayer new AndroidJavaClass(com.unity3d.player.UnityPlayer)) { return unityPlayer.GetStaticAndroidJavaObject(currentActivity); } } // 统一的拍照接口 public static void TakePhoto(string callbackGameObject, string callbackMethod) { InitializeHelper(); if (_currentHelper ! null) { // 先检查相机权限 if (!CheckPermission(android.permission.CAMERA)) { RequestPermission(android.permission.CAMERA, (granted) { if (granted) { _currentHelper.Call(takePhoto, callbackGameObject, callbackMethod); } else { // 处理权限被拒绝 HandlePermissionDenied(CAMERA); } }); return; } _currentHelper.Call(takePhoto, callbackGameObject, callbackMethod); } } // 统一的选图接口 public static void PickImage(string callbackGameObject, string callbackMethod) { InitializeHelper(); if (_currentHelper ! null) { // 对于安卓10及以下需要检查存储权限 if (GetAndroidAPILevel() 29) { if (!CheckPermission(android.permission.READ_EXTERNAL_STORAGE)) { RequestPermission(android.permission.READ_EXTERNAL_STORAGE, (granted) { if (granted) { _currentHelper.Call(pickImage, callbackGameObject, callbackMethod); } else { HandlePermissionDenied(STORAGE); } }); return; } } // 安卓11 不需要检查READ_EXTERNAL_STORAGE权限 _currentHelper.Call(pickImage, callbackGameObject, callbackMethod); } } // 权限检查使用UnityEngine.Android.Permission private static bool CheckPermission(string permission) { return UnityEngine.Android.Permission.HasUserAuthorizedPermission(permission); } // 权限申请封装Unity的API支持回调 private static void RequestPermission(string permission, Actionbool callback) { var callbacks new UnityEngine.Android.PermissionCallbacks(); callbacks.PermissionGranted (grantedPerm) { if (grantedPerm permission) callback?.Invoke(true); }; callbacks.PermissionDenied (deniedPerm) { if (deniedPerm permission) callback?.Invoke(false); }; callbacks.PermissionDeniedAndDontAskAgain (deniedPerm) { if (deniedPerm permission) callback?.Invoke(false); }; UnityEngine.Android.Permission.RequestUserPermission(permission, callbacks); } private static void HandlePermissionDenied(string permissionType) { // 这里可以弹出自定义弹窗引导用户去设置页开启 Debug.LogWarning(${permissionType} permission denied. Guide user to settings.); // 例如ShowRationaleDialog(需要相机权限才能拍照, () { OpenAppSettings(); }); } // 跳转到应用设置页面 public static void OpenAppSettings() { using (AndroidJavaObject unityActivity GetUnityActivity()) using (AndroidJavaClass intentClass new AndroidJavaClass(android.content.Intent)) using (AndroidJavaClass settingsClass new AndroidJavaClass(android.provider.Settings)) { string packageName unityActivity.Callstring(getPackageName); AndroidJavaObject intent new AndroidJavaObject(android.content.Intent); intent.CallAndroidJavaObject(setAction, settingsClass.GetStaticstring(ACTION_APPLICATION_DETAILS_SETTINGS)); AndroidJavaObject uri AndroidJNIHelper.GetConstructorID(settingsClass, android.net.Uri).FromParts(package, packageName, null); intent.CallAndroidJavaObject(setData, uri); intent.CallAndroidJavaObject(addCategory, intentClass.GetStaticstring(CATEGORY_DEFAULT)); unityActivity.Call(startActivity, intent); } } }4.2 Android Java 原生层实现要点这里以ModernCameraGalleryHelper安卓11为例展示关键方法。// ModernCameraGalleryHelper.java package com.yourcompany.modernhelper; import android.app.Activity; import android.content.Intent; import android.net.Uri; import android.os.Build; import android.provider.MediaStore; import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts; import androidx.core.content.FileProvider; import java.io.File; public class ModernCameraGalleryHelper { private static ModernCameraGalleryHelper instance; private Activity unityActivity; private ActivityResultLauncherIntent takePhotoLauncher; private ActivityResultLauncherIntent pickImageLauncher; private Uri currentPhotoUri; // 用于保存拍照临时文件的Uri private ModernCameraGalleryHelper(Activity activity) { this.unityActivity activity; registerLaunchers(); } public static ModernCameraGalleryHelper getInstance(Activity activity) { if (instance null) { instance new ModernCameraGalleryHelper(activity); } return instance; } private void registerLaunchers() { // 注册拍照Activity结果启动器 takePhotoLauncher unityActivity.registerForActivityResult( new ActivityResultContracts.StartActivityForResult(), result - { if (result.getResultCode() Activity.RESULT_OK) { // 拍照成功图片已保存在currentPhotoUri指向的位置 // 现在需要将其插入到MediaStore以便在相册中可见 Uri publicUri insertImageToMediaStore(currentPhotoUri); if (publicUri ! null) { // 回调给Unity传递可访问的Uri或文件路径 // 注意直接传递content:// Uri给UnityUnity的WWW或UnityWebRequest可能无法直接读取 // 通常需要将文件复制到Unity的持久化数据路径 String copiedPath copyUriToUnityPersistentPath(publicUri); UnityPlayer.UnitySendMessage(YourCallbackGameObject, OnPhotoTaken, copiedPath); } } }); // 注册选图Activity结果启动器 (使用ACTION_OPEN_DOCUMENT) pickImageLauncher unityActivity.registerForActivityResult( new ActivityResultContracts.StartActivityForResult(), result - { if (result.getResultCode() Activity.RESULT_OK result.getData() ! null) { Uri selectedUri result.getData().getData(); if (selectedUri ! null) { // 同样需要将选取的图片复制到Unity可读的路径 String copiedPath copyUriToUnityPersistentPath(selectedUri); UnityPlayer.UnitySendMessage(YourCallbackGameObject, OnImagePicked, copiedPath); } } }); } public void takePhoto(final String callbackGameObject, final String callbackMethod) { // 创建临时文件用于保存拍照结果保存在应用私有目录避免权限问题 File photoFile createImageFileInPrivateDir(); if (photoFile null) { // 处理错误 return; } // 获取FileProvider的Uri适配Android 7.0 Uri photoUri FileProvider.getUriForFile(unityActivity, unityActivity.getPackageName() .fileprovider, photoFile); currentPhotoUri photoUri; Intent takePictureIntent new Intent(MediaStore.ACTION_IMAGE_CAPTURE); takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, photoUri); // 授予相机应用写入此Uri的临时权限 takePictureIntent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION); takePhotoLauncher.launch(takePictureIntent); } public void pickImage(final String callbackGameObject, final String callbackMethod) { Intent pickIntent; if (Build.VERSION.SDK_INT Build.VERSION_CODES.TIRAMISU) { // Android 13 (API 33) 引入了专门的MediaStore.ACTION_PICK_IMAGES pickIntent new Intent(MediaStore.ACTION_PICK_IMAGES); pickIntent.setType(image/*); } else { // Android 11-12 使用ACTION_OPEN_DOCUMENT pickIntent new Intent(Intent.ACTION_OPEN_DOCUMENT); pickIntent.addCategory(Intent.CATEGORY_OPENABLE); pickIntent.setType(image/*); } pickImageLauncher.launch(pickIntent); } private Uri insertImageToMediaStore(Uri privateUri) { // 使用ContentResolver将私有文件插入到公共的MediaStore中 // 这里是一个简化示例实际需要处理InputStream和OutputStream // 插入成功后会返回一个新的content:// Uri // 具体实现涉及MediaStore.Images.Media.insertImage或ContentResolver.insert // 篇幅原因此处省略详细代码 return privateUri; // 假设操作成功返回原Uri或新Uri } private String copyUriToUnityPersistentPath(Uri uri) { // 将ContentResolver打开的Uri对应的文件流复制到 // Unity的Application.persistentDataPath目录下 // 返回复制后的完整文件路径供Unity加载Texture2D // 具体实现涉及ContentResolver.openInputStream和文件IO操作 // 返回示例/storage/emulated/0/Android/data/your.package.name/files/photo_123.jpg return CopiedFilePath; } private File createImageFileInPrivateDir() { // 在应用私有目录创建临时图片文件 File storageDir unityActivity.getExternalFilesDir(Environment.DIRECTORY_PICTURES); if (storageDir null) { storageDir unityActivity.getFilesDir(); } String timeStamp new SimpleDateFormat(yyyyMMdd_HHmmss).format(new Date()); String imageFileName JPEG_ timeStamp _; try { File imageFile File.createTempFile(imageFileName, .jpg, storageDir); return imageFile; } catch (IOException e) { e.printStackTrace(); return null; } } }实操心得在Java层使用ActivityResultLauncher属于AndroidX Activity Result API来启动Intent并处理结果比传统的startActivityForResult和onActivityResult更清晰、更安全避免了类型转换和请求码管理的混乱。这是现代Android开发推荐的方式。4.3 Unity中处理返回的图片路径在Unity C#端接收到Java层回调回来的文件路径后你需要加载它。注意从安卓11返回的路径可能是content://开头的URIUnity的WWW或UnityWebRequest可能无法直接加载。因此我们在Java层已经将其复制到了应用私有目录或可访问的路径。// 在Unity中用于接收回调的脚本 public class CameraGalleryHandler : MonoBehaviour { // 由AndroidPermissionHelper调用的方法 public void OnPhotoTaken(string imagePath) { Debug.Log($Photo saved to: {imagePath}); StartCoroutine(LoadImageCoroutine(imagePath)); } public void OnImagePicked(string imagePath) { Debug.Log($Image picked from: {imagePath}); StartCoroutine(LoadImageCoroutine(imagePath)); } private IEnumerator LoadImageCoroutine(string filePath) { // 使用UnityWebRequest加载图片 string url file:// filePath; // 注意添加 file:// 协议头 using (UnityWebRequest request UnityWebRequestTexture.GetTexture(url)) { yield return request.SendWebRequest(); if (request.result UnityWebRequest.Result.Success) { Texture2D texture DownloadHandlerTexture.GetContent(request); // 将texture应用到你的UI RawImage或Material上 // rawImage.texture texture; Debug.Log(Image loaded successfully.); } else { Debug.LogError($Failed to load image: {request.error}); } } } }5. 实战避坑指南与高级技巧5.1 常见问题与解决方案速查表问题现象可能原因解决方案在安卓11设备上拍照后图片不在系统相册中显示。图片被保存到了应用的私有目录getExternalFilesDir其他应用包括系统相册无法访问。使用MediaStore.Images.Media.insertImage或ContentResolver.insert将图片插入到公共媒体库。插入时需要提供图片的DisplayName、MimeType等元数据。调用系统相册选择图片后返回的路径是content://开头Unity无法加载。安卓11的ACTION_OPEN_DOCUMENT返回的是ContentProvider的URI不是直接文件路径。在Java插件中使用ContentResolver.openInputStream读取URI对应的数据流并将其写入到Unity可访问的目录如Application.persistentDataPath对应的路径然后将这个新路径返回给Unity。用户拒绝了权限并勾选“不再询问”后续无法再次弹出系统授权框。Unity的Permission.RequestUserPermission在用户选择“不再询问”后会直接回调PermissionDeniedAndDontAskAgain不会再弹窗。在PermissionDeniedAndDontAskAgain的回调中弹出自己的提示框友好地说明权限的必要性并提供按钮引导用户跳转到应用系统设置页面使用AndroidPermissionHelper.OpenAppSettings方法。在部分国产定制系统如MIUI、EMUI上权限弹窗不出现或功能异常。国产系统有更激进的权限管理或后台限制可能修改了默认行为。1.检查自启动权限引导用户手动在系统设置中为应用开启“自启动”或“关联启动”权限。2.检查悬浮窗权限如果你的Rationale提示是自定义悬浮窗可能需要申请悬浮窗权限。3.使用系统默认弹窗尽量使用系统原生的权限申请对话框避免完全自定义的弹窗以提高兼容性。在Unity Editor中测试正常打包到安卓后崩溃。可能缺少必要的AndroidManifest.xml配置或Java插件类名、方法名调用错误。1. 检查AndroidManifest.xml中的权限和provider路径配置是否正确。2. 检查Unity中Plugins/Android目录下的.jar或.aar文件是否包含正确的Java类。3. 使用adb logcat查看崩溃日志定位错误行。调用相机时预览画面旋转90度。相机传感器的自然方向与屏幕方向不一致且未正确处理方向信息。在AndroidManifest中为Activity设置screenOrientation属性如portrait或landscape进行锁定。或者在Java代码中从Intent的Extras中获取“data”返回的Bitmap时检查其Exif信息中的旋转标签并在Unity端或Java端进行旋转校正。5.2 高级技巧权限申请的优雅引导直接弹系统权限框很生硬。我推荐一个“三步走”的引导策略场景化提示在用户点击“上传头像”按钮时先弹出一个自定义的、非阻塞的UI浮层用文案和图标解释“需要访问相机和相册来拍摄或选择照片”并给出“去设置”和“取消”按钮。这一步只是告知不申请权限。触发系统申请用户点击“去设置”后再调用RequestPermission。这样用户有了心理预期授权率会更高。处理拒绝如果用户拒绝特别是“永久拒绝”则在下次进入相关功能模块时直接弹出引导去系统设置的弹窗不再重复申请因为系统不会弹窗了。// 伪代码示例三步引导策略 public class PermissionUIManager : MonoBehaviour { public GameObject rationalePanel; // 自定义解释面板 public GameObject goToSettingsPanel; // 引导去设置面板 public void OnUploadAvatarClick() { if (!AndroidPermissionHelper.CheckCameraPermission()) { // 步骤1展示自定义解释 rationalePanel.SetActive(true); } else { // 已有权限直接打开相机 OpenCamera(); } } // 自定义解释面板的“去设置”按钮 public void OnRationaleConfirm() { rationalePanel.SetActive(false); // 步骤2触发系统申请 AndroidPermissionHelper.RequestPermission(android.permission.CAMERA, OnCameraPermissionResult); } private void OnCameraPermissionResult(bool granted) { if (granted) { OpenCamera(); } else { // 步骤3判断是否被永久拒绝若是则引导去系统设置 // 注意Unity API无法直接判断“不再询问”通常结合一次拒绝记录来推断 if (PlayerPrefs.GetInt(CameraPermissionDeniedOnce, 0) 1) { goToSettingsPanel.SetActive(true); } else { PlayerPrefs.SetInt(CameraPermissionDeniedOnce, 1); // 可以稍后再提示或直接认为用户不想用此功能 } } } public void OpenAppSettings() { AndroidPermissionHelper.OpenAppSettings(); } }5.3 关于FileProvider的配置从安卓7.0Nougat开始向应用外部公开file://URI是不安全的必须使用FileProvider。这在调用相机并指定图片输出路径时是必须的。在AndroidManifest.xml中声明Providerapplication ... 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${applicationId}会自动替换为你的包名。创建file_paths.xml资源文件 在Unity项目的Assets/Plugins/Android/res/xml/目录下如果没有则创建新建file_paths.xml文件。?xml version1.0 encodingutf-8? paths xmlns:androidhttp://schemas.android.com/apk/res/android !-- 对应 Context.getExternalFilesDir(Environment.DIRECTORY_PICTURES) -- external-files-path namemy_images pathPictures/ / !-- 对应 Context.getFilesDir() -- files-path namemy_internal_images pathimages/ / !-- 对应 Environment.getExternalStorageDirectory() (谨慎使用安卓10可能失效) -- external-path nameexternal_storage_root path. / /paths这个文件定义了哪些私有目录可以通过FileProvider安全地共享给其他应用如相机App。6. 测试与真机调试建议权限适配的代码强烈建议在真机上进行全面测试模拟器有时无法完全模拟所有厂商的定制行为。准备多版本测试机至少准备三台设备安卓6.0-9.0一台安卓10-12一台安卓13一台。覆盖主要的权限模型变更节点。测试各种用户操作路径首次安装直接点击拍照/选图。授予权限后使用。拒绝权限再尝试使用。拒绝并勾选“不再询问”然后通过应用内的引导去设置页开启。在设置页关闭权限返回应用后的状态。使用ADB命令快速重置权限在测试时可以通过ADB命令快速重置应用权限避免反复卸载安装。adb shell pm reset-permissions 你的包名 adb shell pm clear 你的包名查看Logcat日志当功能异常或崩溃时使用adb logcat | grep Unity或adb logcat -s “YourTag”来过滤查看你的插件打印的日志是定位问题的关键。这套从安卓6.0到高版本的相机相册权限兼容方案核心在于理解不同版本系统的规则差异并用一个抽象的中间层来屏蔽这些差异。实现上虽然需要接触Android原生开发但一旦封装好对于Unity上层逻辑来说就是几个简单的API调用。最重要的是它提供了良好的用户体验和极高的稳定性能够应对国内外各种复杂的安卓设备环境。在实际项目中我将这套逻辑封装成了一个独立的模块在不同的项目间复用再也没有被权限问题困扰过。