
我最早被Unity 应用内安装 APK这个需求找上门是在做一款游戏的分发与自更新模块时。启动后客户端检查到新版本需要把 APK 下载到本机然后直接拉起系统安装器完成覆盖安装。当时我以为这就是一个 Intent 的事结果真正把这条链路在真机上跑通前后折腾了将近三天。踩坑点密集得超出预期Android 7.0 的 FileUriExposedException、Android 8.0 的未知来源权限、Unity 侧拿不到正确 Context 导致 FileProvider 直接抛异常、APK 下载一半就回调成功结果安装器告诉你解析包时出现问题……每一个坑都足以让功能在部分机型上当场失效。这篇文章就是想把这条链路从头到尾拆开讲清楚从系统机制、选型分析到完整代码、排查思路适合正在做 Unity 安卓端应用内下载并安装 APK功能的开发者也适合想搞明白 Unity 如何调用安卓原生能力的初学者。1. 需求拆解Unity 内安装 APK 到底解决什么问题1.1 这类需求通常落在哪些场景里先别急着写代码想清楚为什么需要这个功能往往能帮你省下大量返工时间。我在项目里归纳了一下Unity 应用内安装 APK 的需求集中在这么几类情况游戏内版本自更新这是最常见的场景。游戏启动后请求更新接口发现强制更新或大版本更新时直接下载新 APK 并引导安装而不是跳转应用市场。适用于没有上架传统应用市场、或者走渠道包分发模式的游戏。插件/扩展包的安装部分应用需要用户额外安装一个配套 APK比如语音包、资源工具从主应用里一键拉起安装。工程调试与灰度测试团队内部工具用 Unity 壳包去分发测试 APK省去手动下载安装的流程。存量用户的渠道迁移老包体需要引导用户安装新渠道包场景和自更新类似但更强调覆盖安装的稳定性。我自己的项目属于第一类服务端会下发一个 APK 下载地址客户端下载完成后调起安装。这类场景有一个共性对下载完整性、版本校验、权限引导的要求都特别高因为用户并不会因为你某个步骤没做好就去开发者模式里翻日志。1.2 为什么不能统一跳转应用市场不少同学会问既然 Android 系统自带浏览器下载和安装能力为什么不直接跳浏览器或者应用市场这个方案确实简单但现实的拦路问题不少很多渠道包根本没有上架任何公开市场市场里搜不到对应包体。跳转市场的体验割裂感很重游戏场景下用户可能正在关键流程中一跳就回不来了。部分定制 ROM尤其是一些国内厂商的系统对市场跳转的拦截策略很多你没法保证用户跳过去之后能顺利找到目标应用。版本更新如果涉及灰度只有特定用户能看到更新入口这种精细控制在市场侧很难做。所以应用内直接装 APK在这类场景下还是最可控的方案。系统层面对这个行为有完整的授权机制只要把权限流程、URI 授权做对体验可以非常顺滑。2. Android 安装 APK 的系统链路ACTION_VIEW、FileProvider 与安装权限2.1 核心思路用 Intent 把 APK 交给系统安装器Android 安装 APK 最正统的方式是构造一个携带 APK 路径的Intentaction 设为ACTION_VIEWMIME 类型设为application/vnd.android.package-archive然后交给系统。PackageInstaller系统安装器会接管后续流程展示安装确认界面。这个方案看起来简单但落到真实设备上有两个版本分水岭是绕不过去的Android 7.0API 24和Android 8.0API 26。7.0 之前Uri.fromFile(file)直接拿file://路径就能用代码极其简单。7.0 开始应用默认无法暴露file://给其他应用系统直接抛FileUriExposedException必须用FileProvider生成content://URI。8.0 开始非系统应用安装未知来源 APK 时必须先获得用户的安装未知应用授权也就是REQUEST_INSTALL_PACKAGES权限的运行时校验。这两条叠加在一起导致Unity 调用安卓安装 APK这个题目的实际工作量有一大半在权限和 URI 处理上而不是真正调用 Intent。2.2 Android 7.0 分水岭file:// 被禁FileProvider 接管Google 在 Android 7.0 上收紧应用间文件共享的策略目的是避免私有文件路径被其他应用直接读取。StrictMode会在检测到file://URI 跨应用传递时直接抛出FileUriExposedException应用就崩了。官方替代方案是FileProvider它是ContentProvider的子类通过注册好的路径映射规则把你的私有目录临时暴露成一个content://URI并且只对这个 URI 授予当前这一条 Intent 的读写权限。关键点在于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对应地res/xml/file_paths.xml里要声明允许暴露的路径根。最常见的三条规则?xml version1.0 encodingutf-8? paths files-path nameinternal_files path. / cache-path nameinternal_cache path. / external-path nameexternal_files path. / /pathsfiles-path对应的是/data/data/包名/files/cache-path对应/data/data/包名/cache/external-path对应/storage/emulated/0/这类外部存储根目录。写错一条getUriForFile就会抛IllegalArgumentException提示你的目标路径不在已配置的根里。这个错误信息我第一次看到时还以为是 Unity 侧的问题排查了半天才发现是 XML 路径规则没写全。2.3 Android 8.0 的未知来源安装开关REQUEST_INSTALL_PACKAGESAndroid 8.0 开始Google 把允许安装未知来源应用从系统设置里的一个全局开关改成了按应用粒度管理。也就是说你的应用要安装其他 APK必须先问用户是否允许 某某应用 安装未知应用 这个权限写在 Manifest 里uses-permission android:nameandroid.permission.REQUEST_INSTALL_PACKAGES /但光声明不够运行时还需要用PackageManager.canRequestPackageInstalls()检查当前应用是否已被用户允许。如果没允许跳转Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES引导用户开权限Intent intent new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES); intent.setData(Uri.parse(package: context.getPackageName())); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); context.startActivity(intent);这里有个特别容易踩的细节只声明 Manifest 权限、不检查运行时授权在部分国产 ROM 上会直接闪退在原生系统上则是点击安装后静默失败日志里只有一条SecurityException: Unknown source。所以正确顺序一定是先检查、再引导、最后才发安装 Intent。3. Unity 侧桥接 Android 的两种方案反射调用与原生插件封装3.1 方案一AndroidJavaObject 动态反射适合快速验证Unity 内置了AndroidJavaObject和AndroidJavaClass可以直接在 C# 侧反射调用 Java 层的类和方法。这种方式的好处是零插件工程不需要装 Android Studio改完 C# 代码就能打包测试。典型的调用路径是这样的public static void InstallApk(string apkPath) { AndroidJavaClass unityPlayer new AndroidJavaClass(com.unity3d.player.UnityPlayer); AndroidJavaObject activity unityPlayer.GetStaticAndroidJavaObject(currentActivity); AndroidJavaObject context activity.CallAndroidJavaObject(getApplicationContext); AndroidJavaObject file new AndroidJavaObject(java.io.File, apkPath); string packageName context.Callstring(getPackageName); string authority packageName .fileprovider; AndroidJavaClass fileProvider new AndroidJavaClass(androidx.core.content.FileProvider); AndroidJavaObject uri fileProvider.CallStaticAndroidJavaObject(getUriForFile, context, authority, file); AndroidJavaObject intent new AndroidJavaObject(android.content.Intent, android.intent.action.VIEW); intent.CallAndroidJavaObject(setDataAndType, uri, application/vnd.android.package-archive); intent.CallAndroidJavaObject(addFlags, 0x00000001); // FLAG_GRANT_READ_URI_PERMISSION intent.CallAndroidJavaObject(addFlags, 0x10000000); // FLAG_ACTIVITY_NEW_TASK activity.Call(startActivity, intent); }这段代码能跑通大部分场景但有一个致命的问题FileProvider 必须在 Manifest 里注册而且res/xml/file_paths.xml必须真实存在于打包产物里。Unity 默认打包出来的 Manifest 是不会自动加这些配置的所以纯反射方案也得配合自定义 Manifest 合并来用。另外反射写法里大量使用int常量比如0x00000001、0x10000000可读性差、容易写错真要上生产环境隐患不小。3.2 方案二导出工程写原生插件适合正式发布更稳妥的做法是走原生插件路线用 Android Studio 写一个 Library 模块把权限判断、URI 获取、Intent 启动全部封装在 Java/Kotlin 层打成 AAR 塞进 Unity 工程。Unity 侧只保留一个非常薄的 C# 接口。原生侧核心代码大致是这个样子public class ApkInstaller { public static void install(Context context, String apkPath) { if (Build.VERSION.SDK_INT Build.VERSION_CODES.O !context.getPackageManager().canRequestPackageInstalls()) { // 引导用户到设置页申请安装未知应用权限 Intent intent new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES); intent.setData(Uri.parse(package: context.getPackageName())); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); context.startActivity(intent); return; } File apkFile new File(apkPath); Uri apkUri; if (Build.VERSION.SDK_INT Build.VERSION_CODES.N) { apkUri FileProvider.getUriForFile( context, context.getPackageName() .fileprovider, apkFile); } else { apkUri Uri.fromFile(apkFile); } Intent intent new Intent(Intent.ACTION_VIEW); intent.setDataAndType(apkUri, application/vnd.android.package-archive); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); try { context.startActivity(intent); } catch (ActivityNotFoundException e) { // 设备上可能没有可用的安装器需要做兜底提示 } } }然后在 Android Studio 里完成两步配置第一步在AndroidManifest.xml里声明权限和 FileProvider第二步在res/xml/file_paths.xml里配好路径规则。这些配置会跟着 AAR 一起打进 Unity 包体Unity 侧完全不用关心 manifest 合并的问题因为 AAR 自带的 manifest 会被 Gradle 自动 merge。3.3 我的选型结论为什么正式项目我选了插件这里给出我的真实结论如果只是验证可行性用反射方案没问题但一旦要上生产环境我强烈建议用 AAR 插件方案。原因有三条。第一逻辑边界清晰。权限判断、版本兼容、错误兜底都放在 Java 层处理C# 侧只暴露一个bool Install(string apkPath)接口出问题的时候定位成本低一大截。第二Android 系统的兼容策略越来越复杂光是 FileProvider 就有 Support 库和 AndroidX 两套坐标体系这些差异用 Java 判断起来比在 C# 里反射字符串拼 String 要直观太多。第三代码可测试性。原生插件可以单独用 Android Studio 跑单元测试和 instrumented testUnity 侧的打包验证成本高能少跑一次就少跑一次。顺带提一个 Unity 侧的小细节用AndroidJavaObject反射调用时方法名建议用Call而不是CallStatic第一个参数传实例对象获取currentActivity时一定要在主线程。这些坑我后面在踩坑章节会详细展开。4. 完整落地步骤Manifest 合并、路径映射、下载、拉起安装4.1 第一步在 Unity 工程里合并自定义 AndroidManifest如果你不打算导出工程手动改 Manifest而是想保留 Unity 的一键打包流程那就需要在 Unity 工程里提供一个自定义的AndroidManifest.xml。Unity 在构建 APK 时会自动合并Assets/Plugins/Android/AndroidManifest.xml里的内容。我建议的最小 Manifest 模板长这样?xml version1.0 encodingutf-8? manifest xmlns:androidhttp://schemas.android.com/apk/res/android packagecom.unity3d.player xmlns:toolshttp://schemas.android.com/tools uses-permission android:nameandroid.permission.INTERNET / uses-permission android:nameandroid.permission.REQUEST_INSTALL_PACKAGES / uses-permission android:nameandroid.permission.WRITE_EXTERNAL_STORAGE / application 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注意几个容易错的地方package属性在 Unity 构建时会由Player Settings里的包名覆盖这里写com.unity3d.player不影响最终结果。${applicationId}这种占位符在 AAR 的 manifest merge 阶段会被替换成实际包名放在 Unity 自定义 Manifest 里也支持但如果你用的是比较老的 Unity 版本可能会遇到替换失败的情况。保守做法是在authorities里直接写你的包名。WRITE_EXTERNAL_STORAGE是否需要取决于你把 APK 下载到哪里。如果统一写到Application.persistentDataPath内部存储的 files 目录这个权限可以不加如果你要写到公共外部目录让文件管理器可见则需要它。Android 10 之后还有分区存储的限制我的建议是能用内部存储就用内部存储别跟公共目录过不去。res/xml/file_paths.xml文件要放在Assets/Plugins/Android/res/xml/目录下这样 Unity 打包时才能把它作为一个 resource 编进去?xml version1.0 encodingutf-8? paths files-path nameapk_files path. / cache-path nameapk_cache path. / external-path nameapk_external path. / /paths4.2 第二步FileProvider 路径映射internal 和 external 的差异这一步我把话说明白。FileProvider 映射的是你允许暴露的根目录getUriForFile会根据文件的实际路径去匹配这些根。匹配失败的唯一结果就是异常崩溃且错误信息里有明确提示Failed to find configured root that contains /data/user/0/com.xxx.xxx/files/...。在 Unity 工程里三个常见存储位置对应关系如下Unity 侧获取方式Android 实际路径file_paths.xml 对应标签Application.persistentDataPath/data/data/包名/filesfiles-pathApplication.temporaryCachePath/data/data/包名/cachecache-pathApplication.persistentDataPath外部存储模式/storage/emulated/0/Android/data/包名/filesexternal-files-path有一个特别隐蔽的坑Unity 的Application.persistentDataPath不是在所有设备上都指向/data/data/包名/files。部分老设备或者特殊配置下它可能指向外部存储下的Android/data/包名/files。这就导致你在 A 设备上测试通过了换一台设备却报Failed to find configured root。我的兜底策略很简单下载 APK 时先取Application.persistentDataPath如果路径前缀包含/files/结尾的 internal 标识就继续用如果发现它指向外部存储就换到Application.temporaryCachePath。这样 FileProvider 永远只暴露我们主动选择的根目录而不会在两个候选根之间摇摆。实际统计下来我这个策略上线后这类的崩溃日志降到了零。4.3 第三步用 UnityWebRequest DownloadHandlerFile 下载大 APK下载 APK 不能直接UnityWebRequestDownloadHandlerBuffer那是把整个文件塞进内存里一个 200MB 的安装包大概率会把低端机直接打 OOM。用DownloadHandlerFile是正确姿势using System.Collections; using System.IO; using UnityEngine; using UnityEngine.Networking; public class ApkDownloader : MonoBehaviour { public IEnumerator DownloadAndInstall(string url, string savePath) { using (UnityWebRequest request UnityWebRequest.Get(url)) { request.downloadHandler new DownloadHandlerFile(savePath); yield return request.SendWebRequest(); if (request.result UnityWebRequest.Result.Success) { Debug.Log(APK 下载完成大小 new FileInfo(savePath).Length); // 校验文件大小或MD5通过后再调用安装接口 } else { Debug.LogError(APK 下载失败 request.error); File.Delete(savePath); // 失败就清理半成品避免下次误用 } } } }DownloadHandlerFile会把数据直接落盘内存占用基本是常数级别。但这里也有个经验点下载完成并不代表文件是完好的。网络断开、服务端传输中断、磁盘写入失败都有可能导致 APK 文件不完整系统安装器会直接报解析包时出现问题。所以下载完成后一定要做一次完整性校验。常规做法是服务端在更新接口里下发 APK 的 MD5 或 SHA256客户端下载完比对不一致就删掉重下string GetMd5Hash(string filePath) { using (var md5 System.Security.Cryptography.MD5.Create()) using (var stream File.OpenRead(filePath)) { byte[] hash md5.ComputeHash(stream); return System.BitConverter.ToString(hash).Replace(-, ).ToLowerInvariant(); } }另外大文件下载还需要考虑断点续传。UnityWebRequest 在 2020 之后的版本里支持SetRequestHeader(Range, bytes...)手动实现断点但要处理的情况比较多服务端是否支持 Range、文件是否被服务端更新过、断点续传后 MD5 比对策略等。我的建议是第一版先不做断点续传把完整性和错误清理做好用户网络差就直接失败重下。断点续传是后续性能优化项不该一开始就背上。4.4 第四步权限检查、拉起安装器、安装结果判定安装前先把 Android 8.0 的权限检查做了。Unity 侧封装一个工具类public static class ApkInstallHelper { private static AndroidJavaObject GetUnityActivity() { AndroidJavaClass unityPlayer new AndroidJavaClass(com.unity3d.player.UnityPlayer); return unityPlayer.GetStaticAndroidJavaObject(currentActivity); } public static bool CanRequestPackageInstalls() { AndroidJavaObject activity GetUnityActivity(); AndroidJavaObject pm activity.CallAndroidJavaObject(getPackageManager); return pm.Callbool(canRequestPackageInstalls); } public static void OpenUnknownAppSourceSetting() { AndroidJavaObject activity GetUnityActivity(); AndroidJavaObject uri new AndroidJavaClass(android.net.Uri) .CallStaticAndroidJavaObject(parse, package: GetPackageName()); AndroidJavaObject intent new AndroidJavaObject(android.content.Intent, android.settings.MANAGE_UNKNOWN_APP_SOURCES); intent.CallAndroidJavaObject(setData, uri); intent.CallAndroidJavaObject(addFlags, 0x10000000); // FLAG_ACTIVITY_NEW_TASK activity.Call(startActivity, intent); } public static string GetPackageName() { AndroidJavaObject activity GetUnityActivity(); return activity.Callstring(getPackageName); } public static void Install(string apkPath) { AndroidJavaObject activity GetUnityActivity(); AndroidJavaObject context activity.CallAndroidJavaObject(getApplicationContext); AndroidJavaObject file new AndroidJavaObject(java.io.File, apkPath); string authority GetPackageName() .fileprovider; AndroidJavaClass fileProvider new AndroidJavaClass(androidx.core.content.FileProvider); AndroidJavaObject uri fileProvider.CallStaticAndroidJavaObject(getUriForFile, context, authority, file); AndroidJavaObject intent new AndroidJavaObject(android.content.Intent, android.intent.action.VIEW); intent.CallAndroidJavaObject(setDataAndType, uri, application/vnd.android.package-archive); intent.CallAndroidJavaObject(addFlags, 0x00000001); // FLAG_GRANT_READ_URI_PERMISSION intent.CallAndroidJavaObject(addFlags, 0x10000000); // FLAG_ACTIVITY_NEW_TASK activity.Call(startActivity, intent); } }调用顺序建议做成这样一个状态机检查是否有新版本。下载 APK 到内部存储校验 MD5。检查CanRequestPackageInstalls()不允许就弹业务层说明弹窗引导用户到设置页开权限。权限通过后调用Install(apkPath)。用户从安装器返回后用PackageManager轮询目标应用版本号确认是否真的装上了。关于第五步一个直观的判定方式是在 C# 侧调用安卓的PackageManager.getPackageInfo。如果新旧版本号一致说明用户确认安装了如果版本没变说明用户取消了安装此时可以考虑是否弹更新失败提示。示例public static int GetInstalledVersionCode(string packageName) { AndroidJavaObject activity GetUnityActivity(); AndroidJavaObject pm activity.CallAndroidJavaObject(getPackageManager); try { AndroidJavaObject packageInfo pm.CallAndroidJavaObject(getPackageInfo, packageName, 0); return packageInfo.Getint(versionCode); } catch (AndroidJavaException) { return -1; // 包不存在 } }这里有个小经验getPackageInfo在目标包未安装时会抛异常所以一定要包一层 try-catch不要让它直接中断 Unity 的当前流程。5. 踩坑排错实录我在这个功能上实际遇到过的 5 个问题5.1 FileUriExposedException7.0 之后的第一个拦路虎我刚实现第一版时在测试机上点击安装Unity 日志里刷出AndroidJavaException: android.os.FileUriExposedException: file:///storage/emulated/0/... exposed beyond app through Intent.getData()这个异常在 Android 7.0 及以上是崩溃级别的系统直接让应用闪退。原因就是我用了Uri.fromFile把file://传给了安装器。有的同学在网上搜到老教程还这么写在模拟器和老版本设备上没问题一上真机就崩。解决方案就是前面说的 FileProvider。我调试时踩的坑反而是FileProvider 导入哪个包——android.support.v4.content.FileProvider和androidx.core.content.FileProvider是两套东西。如果你的 Unity 工程开启了 AndroidXUnity 2020 之后默认开一定要用androidx.core.content.FileProvider而 Unity 自带的某些老插件还在用 support 版本两者共存可能导致依赖冲突。确认方法很简单看Assets/Plugins/Android/mainTemplate.gradle里有没有androidx相关的依赖有就用 AndroidX 版本。5.2 Failed to find configured rootFileProvider 路径没映射对这个错误信息我在踩坑章节里已经预告过这里说一个更隐蔽的变种如果你的file_paths.xml里配置了多个根而 APK 文件落在其中一个根的子目录下理论上是可以匹配的。但getUriForFile的匹配规则是按配置顺序找第一个前缀匹配的根如果前面配了一个范围更大的根比如path.后面配的更精确的根反而可能永远匹配不到。我建议的file_paths.xml最佳实践是根尽量收紧只配你实际会下载 APK 的那一两个目录。比如确定下载到Application.persistentDataPath那就只配files-path nameapk_files path. /不要图省事把所有类型的根都配进去否则一旦多个根都能匹配逻辑会变得很难排查。这个报错在 Unity 侧的典型表现是IllegalArgumentException被打包成AndroidJavaException抛出而且错误信息里会把实际路径打印出来看到这个异常基本不用怀疑别处先去检查file_paths.xml的映射关系。5.3 SecurityException / 一闪而过的未知来源弹窗如果只声明了REQUEST_INSTALL_PACKAGES而没有运行时检查最常见的现象是点击安装按钮后系统安装器没有任何反应回头查看日志会看到一条SecurityException提示缺少安装权限。但在部分国产 ROM 上甚至不是异常而是直接弹一个内容很模糊的提示未知来源应用然后跳走用户根本不知道发生了什么。所以权限引导必须做得主动且明确。我的做法是在下载 APK 之前就先检查权限没有权限就不进入下载流程而是展示一个专门的引导界面用一两句话解释为了完成版本更新需要您允许安装未知来源应用然后点击按钮跳转设置页。这样用户带着明确目的去开权限成功率比装完再弹要高得多。跳转设置页的 action 是Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES注意和Settings.ACTION_MANAGE_APPLICATIONS区分。有些老代码会让你跳ACTION_SECURITY_SETTINGS或ACTION_MANAGE_APPLICATIONS但那个页面在 8.0 上不是按应用授权未知来源的专用入口体验不对。5.4 解析包时出现问题下载完整性必须校验解析包时出现问题是安装 APK 时最常见的通用错误。它可能由文件损坏、APK 签名格式不兼容、CPU 架构不匹配、SDK 版本不兼容等多重原因引起。我最早遇到时一度怀疑是签名问题后来发现是自己拿了一个还没下载完的文件去安装——UnityWebRequest 的result返回Success但文件实际少了尾部数据。为什么会这样问题出在服务端某些 CDN 或者 Web Server 在传输大文件时如果有网络抖动可能在连接异常关闭前返回了 200 头UnityWebRequest 把这当作成功。所以我说下载完成后一定要做 CRC 或 MD5 比对这是硬性要求而不是可选项。另一个容易忽略的点是磁盘空间。安装包写如内部存储如果分区满了DownloadHandlerFile会在中途写盘失败但返回的依然是成功因为 HTTP 层已经收完数据。所以下载完成后用FileInfo.Length和服务端下发的 size 对比一下是最轻量有效的完整性检查。5.5 Unity 线程与 Android 线程交叉调用的崩溃Unity 侧的AndroidJavaObject/AndroidJavaClass调用有一个默认假设需要在 Unity 主线程执行。如果你在Task.Run或 C# 的ThreadPool里调用这些反射接口轻则收到一个不明确的JNI DETACHED FROM THREAD警告重则直接导致 IL2CPP 崩溃。我的经验是所有和 Android Java 层交互的入口都通过 Unity 主线程的队列转发。最简单的做法是写一个MainThreadDispatcher把调用投递到Update里执行private static readonly QueueSystem.Action _actions new QueueSystem.Action(); public static void RunOnMainThread(System.Action action) { lock (_actions) { _actions.Enqueue(action); } } private void Update() { lock (_actions) { while (_actions.Count 0) { _actions.Dequeue()?.Invoke(); } } }安装 APK 的逻辑虽然短但在异步下载完成后去调用时下载协程本身是在 Unity 主线程的通常没问题问题容易出在我用了单独的下载线程比如 C# 的async/awaitTask.Run时。拒绝花哨统一走主线程队列是我在这条链路上最大的经验之一。6. 再往深走一点PackageInstaller 与自更新场景的进阶思路6.1 PackageInstaller API 能做什么、不能做什么除了ACTION_VIEWAndroid 还有一个更底层的PackageInstaller会话式安装 API。它允许应用通过Session分块写入 APK 数据然后调commit执行安装。它和ACTION_VIEW最核心的区别有两个可以拿到安装结果回调commit时会返回一个IntentSender回调安装成功/失败/取消都能精确感知。不能绕过用户确认在非系统权限下commit之后系统同样会弹出安装确认界面用户拒绝就是拒绝。PackageInstaller在我们这个需求中的一个好处是安装完成后能第一时间知道结果不需要像前面那样通过PackageManager轮询版本号。但代价是代码量明显增加还要处理安装会话的创建、写入、提交细节。如果你的产品对更新结果统计有严格要求比如更新成功率、用户取消比例用PackageInstaller是值得的。6.2 企业内部场景的静默安装边界很多做企业工具的同学会问能不能做到无感安装也就是不弹系统安装界面直接装好。这里必须把边界讲清楚普通第三方应用在用户手机上无论如何都做不到真正的静默安装。系统允许的只有两类路径一类是设备所有者Device Owner或配置文件所有者Profile Owner应用可以通过PackageInstaller配合系统权限做静默安装另一类是企业的 MDM 方案由移动设备管理系统下发安装。如果你的项目确实属于企业内部设备统管场景可以做设备所有者授权但这是一条完全不同的链路需要先通过DevicePolicyManager激活、用adb shell dpm set-device-owner或扫码激活然后才能调用静默安装接口。这一步在 Unity 侧没有捷径必须走原生插件并且需要单独的开发板或测试设备来做激活验证。如果是普通上架应用我的建议是不要在这条路上花时间。对用户做清晰的权限引导用ACTION_VIEW走系统安装器是目前覆盖面最广、崩溃率最低的方案。6.3 我最终沉淀下来的实现范式做了两轮迭代之后我沉淀出一套相对固定的实现范式Unity 侧只管下载 校验 状态机所有 Android 特性权限、FileProvider、安装全部收敛到 AAR 插件里C# 侧暴露三个接口CheckInstallPermission()、RequestInstallPermission()、InstallWithPath(string apkPath)。这样无论是换 Unity 版本、升级 Gradle还是适配新的 Android API level要改的地方都局限在 AAR 工程里Unity 主工程基本不动。这套范式在之后迁移到 Unity 2022 的时候帮了大忙——AndroidX 的依赖版本升级、Manifest 合并规则变化我在 AAR 里一次性处理完了Unity 侧连版本号都没变。如果一开始就把所有逻辑都写在 C# 里这种隔三差五的环境升级会让人无比痛苦。回到最初的问题Unity 调用安卓内部安装 APK真的做起来以后你会发现难度从来不在于调用本身而在于你对 Android 系统版本演进的理解对权限、URI、存储这三个维度的掌控。把这些底层机制吃透无论 Unity 怎么变、Android 怎么升级这条链路都能稳得住。