Unity 开发 Android 端的朋友,十有八九都被文件读写权限折腾过。尤其是这两年新项目从 Android 10、Android 11 起步,稍微涉及一点文件导出、下载、日志保存、存档备份功能,马上就遇到分区存储这道坎。明明加了权限,外部存储目录还是写不进去;明明能写,一查目录又是 App 私有目录,用户根本找不到文件在哪。这篇文就单纯围绕“Unity 发布 Android 后如何真正实现所有文件可读写”这一点,把权限历史、清单配置、运行时申请、各版本差异一次说透,顺便把我在项目里踩过的坑都摆出来。
先说结论:标题里说的“所有文件可读写”,在 Android 11(API 30)及以上系统里,本质就是申请MANAGE_EXTERNAL_STORAGE权限,也就是我们常说的“所有文件访问权(All files access)”。Android 10 及部分特殊场景可以用requestLegacyExternalStorage走老路子,但新项目、新系统上真正靠得住的方案,就是把这个权限声明进 Unity 导出的 Android 工程里,并在运行时引导用户去系统设置页打开开关。下面从原理讲到实操,全程给可直接复制的代码和配置。
1. 为什么 Android 会把文件访问管得这么严
先从根上讲清楚这事,不然你只会改配置,不知道为啥改,遇到问题也没法排查。
1.1 存储分区限制到底限制了什么
从 Android 10(API 29)开始,系统默认启用了分区存储(Scoped Storage)。这个机制的核心逻辑很简单:App 访问自己专属目录(比如/data/data/包名/、Android/data/包名/下的缓存和文件),或者通过 MediaStore 访问图片、视频、音频这类媒体文件,是畅通无阻的;但如果你想直接拿绝对路径去操作/sdcard/Download/下面某个文件,或者去读其他 App 创建的文件,那就需要额外权限和特殊处理。
到了 Android 11(API 30),连WRITE_EXTERNAL_STORAGE和READ_EXTERNAL_STORAGE这两个老权限,也基本只能管媒体文件了。如果你想访问“所有文件”,比如列出 SD 卡(或者说外部共享存储)根目录的完整文件树、操作非媒体类型的文件(PDF、压缩包、数据库文件),Android 11 明确要求使用MANAGE_EXTERNAL_STORAGE。这个权限就是标题里“所有文件可读写”的真正钥匙。
打个通俗的比方:以前 App 拿到WRITE_EXTERNAL_STORAGE权限,等于拿到了整栋楼的万能钥匙,每间房都能开;分区存储上线后,系统把楼划成了“公共展厅”(媒体目录)、“仓库”(其他类型文件)和“私人房间”(App 私有目录)。App 默认只能用自己的私人房间和公共展厅的一部分,想进仓库翻所有东西,必须有物业特批的手环——这就是MANAGE_EXTERNAL_STORAGE。
1.2 Unity 项目里的存储路径为什么容易搞混
Unity 项目打包后,代码里路径选择是个高频出错点。
Application.persistentDataPath:这对应 App 私有目录下的 files 目录,在 Android 上实际路径类似/storage/emulated/0/Android/data/<包名>/files。不需要任何权限就能读写,卸载 App 时跟着删除,用户直接查看时会发现这个目录很深,不借助文件管理器基本找不到。Application.dataPath:在 Android 上指向 APK 内部的assets目录解压位置,只读,别往这里写。Environment.getExternalStorageDirectory():Java 原生代码拿到的外部存储根目录,通常是/storage/emulated/0/。从 Android 11 起,想在这个根目录下随意建文件夹、读写任意文件,就必须要有MANAGE_EXTERNAL_STORAGE。
很多开发者遇到的问题根源就出在这里:在persistentDataPath里写入文件一切正常,就会误以为权限已经通了,结果用户想要的是“把文件导出发到 Download 目录”,代码里写的又是Environment.getExternalStorageDirectory(),一跑就崩。先分清这些路径,后面的配置才有意义。
2. 方案选型:All files access 比旧方案更靠谱
在真正动手配置之前,我们先确认哪套方案适合当前项目。
2.1 三条路线的对比
针对 Unity Android 外存储读写,业界常用方案有以下三者,我并排列个表方便对比:
| 方案 | 权限声明 | 适用系统 | 是否可访问任意文件 | 上架限制 | 推荐度 |
|---|---|---|---|---|---|
| 传统外部存储权限 | READ_EXTERNAL_STORAGE+WRITE_EXTERNAL_STORAGE | Android 10 及以下(Android 10 需另配requestLegacyExternalStorage) | 否(Android 10+ 分区存储下受限) | 无特别声明,但新系统上作用缩水 | 低 |
| 旧版存储兼容开关 | requestLegacyExternalStorage = true | Android 10 专属(Android 11 无效) | Android 10 上可以访问绝大多数文件 | 无,但治标不治本 | 中 |
| 所有文件访问权限 | MANAGE_EXTERNAL_STORAGE | Android 11+,Android 10 部分厂商 ROM 也支持 | 是,只要用户授权即可 | 需要在应用商店/权限审核中说明用途 | 高 |
2.2 为什么我个人推荐直接上 MANAGE_EXTERNAL_STORAGE
如果你的 App 定位是文件管理器、备份工具、版本更新下载器、数据迁移工具,这类功能天然就需要跨目录操作,那么用MANAGE_EXTERNAL_STORAGE是唯一正解。
有些朋友喜欢偷懒,在 Android 10 设备上把requestLegacyExternalStorage配上就想通用一辈子,这在 Android 11 手机上会直接失效。因为 Android 11 系统直接屏蔽了该开关,除非你的 App targetSdkVersion 还是 29,并且设备系统是 Android 10,否则别指望它。
所以我的建议很干脆:新项目一律直接配MANAGE_EXTERNAL_STORAGE,并做好用户引导;老项目如果必须兼容 Android 10 以下的机型,可以同时保留requestLegacyExternalStorage,但核心逻辑必须按 Android 11+ 的权限模型来写。这样写出来,代码不会出现在 Android 11 设备上整个瘫掉的情况。
3. 实操配置:从 AndroidManifest 到运行时申请
下面进入正题,给 Unity 工程加上“所有文件可读写”能力的完整步骤。
3.1 第一步:定制 AndroidManifest.xml
Unity 默认的清单文件在打包时自动生成,不会保留你手动加的内容。正确的做法是把自定义 Manifest 放到 Unity 项目的Assets/Plugins/Android/AndroidManifest.xml路径下。
版本较新的 Unity 创建项目有时没有这个目录,自己新建即可。用下面的模板替换 Content:
<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" package="com.example.unitydemo"> <!-- 核心:所有文件访问权限 --> <uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage" /> <!-- 兼容 Android 10 及以下的外部存储读写权限 --> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="29" /> <application android:requestLegacyExternalStorage="true" tools:replace="android:requestLegacyExternalStorage"> <!-- 这里保留或合并 Unity 原本的 Activity 配置 --> <activity android:name="com.unity3d.player.UnityPlayerActivity" /> </application> </manifest>说明几点:
tools:ignore="ScopedStorage"是给编译器和 lint 看的,告诉它“我确实知道这个权限敏感,并且有合理用途”,不写会被 IDE 警告,但一般不影响编译。如果你们那边规范要求严格,看是否需要保留。android:requestLegacyExternalStorage="true"写在<application>标签上,仅对 Android 10(API 29)的机型生效,作用是让 App 临时获得非分区存储的模式。Android 11 及以上直接忽略这个属性。android:maxSdkVersion="29"写在WRITE_EXTERNAL_STORAGE上,是因为 Android 10+ 的系统上这个权限已经受分区存储限制,更加没有意义。写上这个属性,可以避免商店审核时被问到“为什么高版本系统还声明这个权限”。
3.2 第二步:通过 Unity 代码请求权限
有了权限声明还不行,MANAGE_EXTERNAL_STORAGE属于特殊权限,不能像普通运行时权限那样直接弹窗申请。用户必须手动进入系统的“特殊应用权限”页面打开开关。
但是我们可以通过 Intent 把用户引导到那个页面。Unity 侧用AndroidJavaObject调 Java 层很方便,我直接给一个封装好的类,放在 C# 脚本里调用即可。
using UnityEngine; using System; public static class AndroidExternalStoragePermission { private const string PermissionName = "android.permission.MANAGE_EXTERNAL_STORAGE"; /// <summary> /// 检查是否已授予所有文件访问权限 /// </summary> public static bool IsGranted() { #if UNITY_ANDROID && !UNITY_EDITOR try { using (var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer")) using (var activity = unityPlayer.GetStatic<AndroidJavaObject>("currentActivity")) using (var packageManager = activity.Call<AndroidJavaObject>("getPackageManager")) using (var packageName = activity.Call<string>("getPackageName")) { int checkResult = packageManager.Call<int>( "checkPermission", PermissionName, packageName); return checkResult == 0; // PackageManager.PERMISSION_GRANTED } } catch (Exception e) { Debug.LogWarning("[ExternalStorage] Check permission failed: " + e.Message); return false; } #else return true; #endif } /// <summary> /// 跳转到系统设置中的“所有文件访问”授权页面 /// </summary> public static void OpenAllFilesAccessSetting() { #if UNITY_ANDROID && !UNITY_EDITOR try { using (var intent = new AndroidJavaObject("android.content.Intent")) using (var settings = new AndroidJavaObject("android.provider.Settings")) { string action = "android.settings.MANAGE_APP_ALL_FILES_ACCESS_PERMISSION"; string packageName = null; using (var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer")) using (var activity = unityPlayer.GetStatic<AndroidJavaObject>("currentActivity")) { packageName = activity.Call<string>("getPackageName"); } intent.Call<AndroidJavaObject>("setAction", action); intent.Call<AndroidJavaObject>("setData", AndroidJavaObject.CallStatic<AndroidJavaObject>( "android.net.Uri", "fromParts", "package", packageName, null)); using (var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer")) using (var activity = unityPlayer.GetStatic<AndroidJavaObject>("currentActivity")) { activity.Call("startActivity", intent); } } } catch (Exception e) { Debug.LogWarning("[ExternalStorage] Open settings failed, will fallback to app details."); OpenAppDetailsSetting(); } #endif } }调用方式很直接:
if (AndroidExternalStoragePermission.IsGranted()) { // 直接开始读写文件 } else { AndroidExternalStoragePermission.OpenAllFilesAccessSetting(); }需要注意的一个小坑:部分厂商 ROM(比如某国产品牌)对Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION支持度差,直接跳转会报错抛异常。所以我上面写了catch分支,跳不过去就退回 App 详情页让用户手动找“所有文件访问”开关。这个备选逻辑别省。
3.3 第三步:在旧系统上兼容处理
如果你的 Android 设备系统是 10 以下,也就是 API 28 及更低,上面那套MANAGE_EXTERNAL_STORAGE是不存在的,系统根本不认。这时候判定逻辑要分层:
private static bool IsAndroid11OrHigher() { using (var version = new AndroidJavaClass("android.os.Build$VERSION")) { int sdkInt = version.GetStatic<int>("SDK_INT"); return sdkInt >= 30; } }然后在写文件前统一走这个入口判断:
if (IsAndroid11OrHigher()) { if (!AndroidExternalStoragePermission.IsGranted()) { AndroidExternalStoragePermission.OpenAllFilesAccessSetting(); return; } } else { // Android 10 及以下的设备,用普通的运行时权限申请即可 AndroidRuntimePermission.RequestExternalStorage(); }Android 10 以下版本的权限申请,用上文的READ_EXTERNAL_STORAGE+WRITE_EXTERNAL_STORAGE组合就能搞定。如果项目已经用了第三方权限插件,比如大家都在用的原生权限申请方案,可以直接把这两个权限塞进插件请求队列里,不用重复造轮子。
4. 常见问题与排查技巧实录
这部分是我实际项目中一点点积累出来的,比原理更值钱。每一个都真实遇到过。
4.1 问题速查表
| 现象 | 根本原因 | 解决办法 |
|---|---|---|
| Android 11 手机上申请权限后跳转设置页,但列表里找不到“所有文件访问”入口 | App 没有声明MANAGE_EXTERNAL_STORAGE权限,或被清单合并阶段移除 | 检查Assets/Plugins/Android/AndroidManifest.xml是否存在,权限是否写在<manifest>下,而不是<application>下 |
| 代码里明明判断已授权,写入 Download 目录还是提示 permission denied | 用的是Environment.getExternalStorageDirectory()直接拼路径,但这个路径在分区存储下不直接可写 | 改用 MediaStore API,或者用context.getExternalFilesDir()获取 App 专属外部目录;一个需要“所有文件读写”,一个不需要,别搞混 |
打包报错:Manifest merger failed with multiple errors | Unity 自动生成的 Manifest 和你自定义的 Manifest 冲突,尤其是requestLegacyExternalStorage和package属性重复声明 | 在<application>标签上加上tools:replace="android:requestLegacyExternalStorage";若还报其他冲突,把 Unity 生成的 Manifest 内容 dump 出来,找冲突点手动对齐 |
| 跳转设置页时闪退,或者直接不能跳 | 部分国产 ROM 不支持MANAGE_APP_ALL_FILES_ACCESS_PERMISSION这个 Action | 捕获异常后改用Settings.ACTION_APPLICATION_DETAILS_SETTINGS,让用户去 App 详情页里自己翻“权限管理” |
| Android 10 设备上依然不能随便读写 | 指定了 targetSdkVersion 29/30,但没加requestLegacyExternalStorage,或该参数在系统设置中被覆盖 | 配合android:requestLegacyExternalStorage="true"并重新打包;确认 Manifest 合并结果确实包含该属性 |
| 上传 OBB/APK 到测试平台后,测试人员拿到的是一个没有权限的版本 | 自定义 Manifest 没有被打进最终包,常见于 Unity 工程多个模块各带一个 Manifest,覆盖关系混乱 | 用APK Analyzer或解压 APK 看AndroidManifest.xml中是否包含权限,不行就使用Assets/Plugins/Android/AndroidManifest.xml统一管理 |
4.2 血泪排错实录:请求一次设置页还是授权失败
这里讲一个比较隐蔽的问题:有些开发者把OpenAllFilesAccessSetting()的 Intent 写错成直接打开应用设置页,用户授权完成后,代码里刷新权限状态发现IsGranted()还是 false。
排查后发现是包名少传了。Settings的跳转链接必须带上package:前缀的 URI,如下:
intent.SetData(AndroidUri.FromParts("package", packageName, null));少了这一步,有些系统会直接把页面当成普通的 App 设置页打开,用户在里面找不到“所有文件访问”入口,自然授权不生效。我见过最坑的写法是用字符串拼接 URL 而不是用Uri.fromParts,差点跪了。
4.3 另一个坑:Unity 生命周期对权限回调的影响
如果你调用ActivityCompat.requestPermissions去申请普通权限,Unity 的 Activity 本身不会自动回调到 C# 侧。需要在 MainActivity 里重写onRequestPermissionsResult,再通过UnitySendMessage转发给场景里的 GameObject。很多封装插件都是在做这件事。
但MANAGE_EXTERNAL_STORAGE本身不走requestPermissions,而是通过设置页开关回调,所以 Unity 侧不会收到原生权限请求回调。最稳妥的游戏内状态刷新策略是在OnApplicationFocus里重新检查:
private void OnApplicationFocus(bool focus) { if (focus && waitForPermissionGrant) { bool granted = AndroidExternalStoragePermission.IsGranted(); if (granted) { // 到这里再开始执行文件读写逻辑 } } }一定要在跳转设置页前设置一个waitForPermissionGrant标志位,否则从 Chrome 或微信切回 App 时也会触发检查,白白多跑一次判断。
4.4 权限申请后的文件访问限制仍然存在
很多朋友配好了MANAGE_EXTERNAL_STORAGE,以为就能为所欲为,但还是忽略了两个限制:
- 即使拿到所有文件访问权限,在 Android 11 上,访问
Android/data和Android/obb目录下的其他 App 专属目录,系统仍然会限制。这是为了隔离 App 缓存数据,官方文档白纸黑字写着的。所以别做“看看别的 App 都在 data 目录里藏了什么文件”这种功能,收了权限也照样访问不了。 MANAGE_EXTERNAL_STORAGE是“特殊权限”,用户可以在设置里随时关掉。代码里不能只启动时检查一次,应该在每次进入需要文件操作的界面时都检查一下授权状态,否则用户把开关关了,App 还在傻傻地写文件,结果全失败。
5. 上架与合规提醒
这部分不属于纯技术,但直接影响你的包能不能正常发出去。
5.1 商店审核的差异化要求
Google Play 对使用MANAGE_EXTERNAL_STORAGE的 App 抓得很严,官方政策要求开发者声明核心用途,并且只有文件管理器、备份恢复工具、设备清理工具等少数类别可以申请。如果你的 App 是普通的休闲游戏,仅仅为了把日志写到 Download 目录,照理不应该申请这个权限,审核基本不会通过。
国内安卓应用商店对这类权限的审核相对宽松,但也不是没跟踪。很多厂商后台会要求你在隐私政策里写明“收集了哪些文件、为什么收集”,最好把功能描述写准确,不要泛泛写“优化用户体验”。
所以我个人的建议是:能用 MediaStore 或者getExternalFilesDir解决的场景,就尽量别碰MANAGE_EXTERNAL_STORAGE。这权限不是万能的,不是插件里加一行声明就万事大吉,它是带着政策责任和审核风险的。等到真的需要操作非媒体类文件、遍历整个外部存储目录、批量管理下载文件的时候,再用它,这样审核申诉也有正当理由。
5.2 支持库版本与 targetSdkVersion 选择
Unity 2019.3 及以上版本都支持自定义 Manifest 合并,低版本使用起来相对麻烦。如果你的 Unity 主版本在 2019.3 之前,建议先升级或使用 Gradle 工程方式自定义,否则配置了也可能不生效。
targetSdkVersion 一旦设为 30 或更高,系统就认定你要适配 Android 11 的分区存储逻辑,代码必须按分区存储模型来写。如果你搞不定MANAGE_EXTERNAL_STORAGE的跳转授权,可以把 targetSdkVersion 暂时压到 29,在 Android 10 设备上用requestLegacyExternalStorage混过去,但这样到新手机上依然会碰上权限拦截,只是把问题延后了。
从我自己几个项目的经验看,Unity 导出时很多东西看着是“能用就行”,但存储权限这个点特别容易在售后阶段爆发“文件导出失败”“备份内容丢失”的反馈。开发阶段多花一个小时把权限链路走通,后面能省下来的沟通成本远远不止一个小时。
最后分享一个做法:我会在每次打包后写一个小工具,直接把 APK 拖进去反编译或解析 Manifest,看权限列表是否完整。自动化虽好,但手动盯一次也不费多少时间,尤其是多人协作项目里,经常出现某人从分支合了一个旧目录进来,把你的自定义 Manifest 覆盖掉的情况。每次发版前扫一眼,确认MANAGE_EXTERNAL_STORAGE真的在,比测试人员装上手机发现权限缺失再来找你要新包靠谱得多。