
简介面向安卓原生开发与 Unity 开发者的集成实践资源解决将 Unity 3D 内容嵌入已有安卓工程时工程导出、依赖导入、场景接入、双向通信等常见难点。压缩包体积约 323MB内容围绕 Unity 构建 Android 库工程并导入安卓工程的主流程展开涵盖构建设置、清单权限、启动接入以及 Java/Kotlin 桥接与回调机制。从生成可复用库工程开始逐步讲解导入安卓工程、关联依赖、配置 Unity 组件再到原生代码与 Unity 场景的消息互调说明如何由安卓页面拉起 3D 模块并回传结果。同时给出内存、渲染、多线程等性能优化方向以及日志定位、崩溃排查、不同安卓版本兼容性检查等实战经验还涉及发布前测试要点与签名打包注意事项。已有 300 余人学习适合需要在原生应用中快速集成 3D 场景、互动内容或游戏模块的安卓工程师作为操作参考。1. Unity嵌入到Android原工程为什么不是简单导出而是工程级改造客户丢给我一个已经在应用市场上线的原生App说要在主界面加一个3D展厅入口点进去直接跑Unity场景。最初我以为把Unity工程Build成APK再装就完了结果整个技术方案直接就被否了——原生App不可能让用户先去装第二个App。真正的方案是把Unity嵌进Android Studio的工程里Unity以Library形式存在原生页面负责入口和业务Unity场景负责3D展示两边还要互传参数。这套流程牵扯到Unity导出配置、Gradle工程整合、JNI桥接和一系列兼容性问题属于典型的“理论一页纸落地满坑坑”的活。这个资源包已经把这些步骤和脚本打包好了适合遇到同样混编需求的Android工程师或Unity开发者直接照着落地。2. 导出Unity Library工程脚本后端、Player Settings与目录结构在把Unity内容塞进Android原工程之前第一步是在Unity侧把「可安装的APK」切换为「可依赖的Android Library工程」。这一步是后续所有改造的根基而且很容易踩坑因为Unity的导出界面本身藏了不少选项。稍有不慎导出的工程缺.so或缺少unityLibrary模块到Android Studio那一步就会让你直接怀疑人生。2.1 为什么脚本后端必须选IL2CPP先讲最基础的一个决策Scripting Backend到底选Mono还是IL2CPP。不少新手直接沿用默认的Mono导出到了Android Studio集成时才发现在Java层根本没法直接调度Unity的C#逻辑只能借助一堆额外的运行库运行效率还拉胯。IL2CPP是Unity把C#代码先翻译成C再编译成各ABI对应的.so文件是AOT的静态编译方式Mono则是JIT运行在虚拟机上。由于Android的ART虚拟机本身就给Java/Kotlin用Mono在Android纯原生混编场景里就是个二等公民。实际项目里我一般固定选IL2CPP配套两个理由一是发布到应用市场时64位架构ARM64必须支持IL2CPP天然把这部分纳入编译产物Mono在部分设备上还是只给ARMv7二是在Unity侧写的业务逻辑IL2CPP编译后运行效率更高对后文要做的双向通信没有额外性能负担。2.2 Player Settings关键参数包名、API Level与目标架构一次配好导出配置在File → Build Settings → Player Settings里看似就几栏实际与Android原生工程有关系的是下面这些。我一般按这个顺序过一遍参数建议值原因Company Name与原生App组织一致影响最终包名前缀与签名逻辑Product Name与App显示名一致Unity场景内运行时也会用到Minimum API Level与原生工程的minSdkVersion一致不一致会在Gradle合并时报警甚至失败Target API Level与原生工程的targetSdkVersion一致避免Unity所用API在对应系统版本上表现异常Scripting BackendIL2CPP见2.1节理由Target ArchitecturesARM64 ARMv7兼容新老设备市场对应64位要求Texture CompressionASTC适配大多数Android设备的纹理格式除了手动配置我一般在Unity工程里写一个Editor脚本把这几个关键项固化成代码避免每次手动点漏。下面是实际在用的导出脚本片段using UnityEditor; using UnityEditor.Build; public static class AndroidExportTool { [MenuItem(Tools/Export/Android Library Project)] public static void ExportAndroidLibrary() { PlayerSettings.companyName 你的组织; PlayerSettings.productName UnityExportLib; PlayerSettings.applicationIdentifier com.example.unityexport; PlayerSettings.Android.minSdkVersion AndroidSdkVersions.AndroidApiLevel21; PlayerSettings.Android.targetSdkVersion AndroidSdkVersions.AndroidApiLevel30; PlayerSettings.SetScriptingBackend(NamedBuildTarget.Android, ScriptingImplementation.IL2CPP); PlayerSettings.Android.targetArchitectures AndroidArchitecture.ARMv7 | AndroidArchitecture.ARM64; var options new BuildPlayerOptions { scenes new[] { Assets/Scenes/Main.unity }, locationPathName build/unityLibrary_project, target BuildTarget.Android, options BuildOptions.AcceptExternalModificationsToPlayer }; BuildPipeline.BuildPlayer(options); } }这段脚本里PlayerSettings.SetScriptingBackend固定了IL2CPP后端targetArchitectures同时启用ARMv7和ARM64保证老设备与新设备都能跑。重点说一下BuildOptions.AcceptExternalModificationsToPlayer这个选项它是导出Android Library Project而不是普通APK的关键开关之一加上之后Unity才会生成带src/main/java与libs/unityLibrary结构的Android工程目录。locationPathName是导出目录通常在Unity工程根目录下建build/子目录防止污染Assets目录。参数方面再解释下minSdkVersion和targetSdkVersion为什么要跟原生工程一致因为Gradle在合并清单和资源时如果发现Unity侧的SDK版本与原生主工程冲突轻则warning重则直接sync失败。applicationIdentifier是Unity侧的包名后面原生工程里Unity Library模块的namespace会沿用这个值如果和原生主包名不一致接入时的启动逻辑容易配错。2.3 导出Library Project后目录结构哪些目录是核心别乱删点了Build之后在build/unityLibrary_project目录下会生成一个完整的Android Library工程。关键是看清里面几个目录的职责因为后面导入Android Studio时很多人被目录结构搞晕。大致结构是这样的unityLibrary_project/ ├── settings.gradle ├── build.gradle ├── unityLibrary/ │ ├── build.gradle │ ├── libs/ │ │ ├── unity-classes.jar │ │ └── 各ABI的 .so 文件 │ └── src/main/ │ ├── AndroidManifest.xml │ ├── java/com/unity3d/player/ │ │ ├── UnityPlayer.java │ │ ├── UnityPlayerActivity.java │ │ └── ... │ └── res/ └── src/main/ └── AndroidManifest.xml这里的unityLibrary是核心模块里面包含了UnityPlayer的Java实现和C#运行时所需要的全部native库。unity-classes.jar提供了UnityPlayer类的Java层API后面写原生与Unity的桥接代码时就会用到。.so文件则分散在libs/下分别对应各CPU架构Android Studio打包时会根据abiFilters来决定打入哪些。这一层是后面一切工作的地基动它之前多看一眼别急着删。3. 把Unity Library工程并进原生工程Gradle导入与双Activity生命周期Unity侧导出完成后进入到Android侧。这一章是混编项目最容易翻车的两步Gradle导入模块和Activity生命周期协作。网上很多教程只讲了“导入工程”四个字真正操作时不是Gradle sync不过就是启动Unity后回不到原生层。这里把两端拉通写清楚每一步。3.1 原生App引入unityLibrary模块Gradle依赖全跟到底Unity导出的Library工程一般包含一个unityLibrary目录常见做法是把整个unityLibrary拷贝到原生工程的根目录下然后在settings.gradle里把它声明成一个模块// settings.gradle include :app include :unityLibrary project(:unityLibrary).projectDir new File(rootDir, unityLibrary)include :unityLibrary把Unity库模块挂到构建体系里projectDir指定它的实际路径。有些情况下Unity导出工程里自带一个unityLibrary_project外壳里面才是真正的unityLibrary参照它自身结构稍作调整就行。这个模块自身依赖Unity的android.jar和unity-classes.jar正常情况下它的build.gradle已经写好了不用大动。接着在应用模块app/build.gradle里加上依赖dependencies { implementation project(:unityLibrary) implementation fileTree(dir: new File(project(:unityLibrary).projectDir, libs), include: [*.jar]) }implementation project(:unityLibrary)表示app模块直接依赖Unity库fileTree把unityLibrary/libs目录下的jar也纳入编译。注意Unity导出工程可能还包含一个src/main/java/com/unity3d/player的源码文件夹这些Java类在模块建立后会自动参与编译不需要手动加依赖。Gradle sync之后重点检查三点一是namespace是否正确Unity模块的build.gradle里namespace一般写死在导出包名如果与主工程冲突需要改掉二是minSdkVersion是否一致Gradle报错往往都从这里来三是看unityLibrary的build.gradle里有没有带过期依赖有的话建议直接在原生工程里对齐版本。3.2 一个原生MainActivity和一个UnityPlayerActivity生命周期协作导入成功后冷启动直接跳转到Unity场景最直接的做法是在原生工程里声明UnityPlayerActivity作为一个普通Activity。看一下Unity自带清单的关键部分activity android:namecom.unity3d.player.UnityPlayerActivity android:launchModesingleInstance android:configChangesmnc|mcc|mobile|touchscreen|density|fontScale|orientation|layoutDirection|screenSize|smallestScreenSize|screenLayout|uiMode|keyboard|keyboardHidden|navigation|locale android:hardwareAcceleratedtrue android:screenOrientationlandscape meta-data android:nameunityplayer.UnityActivity android:valuetrue / /activity这段配置有几个细节直接影响稳定性launchModesingleInstance避免UnityPlayerActivity被多次创建否则Unity引擎会被反复初始化导致资源爆炸configChanges把屏幕方向、键盘弹出、字体缩放、密度改变等全部交给Unity自己处理而不是让系统重启ActivityhardwareAcceleratedtrue强制硬件加速Unity场景没有它帧率惨不忍睹。在主工程的AndroidManifest.xml里只要把这个Activity声明片段贴进去同时补上Unity需要的权限。互动场景一般至少要INTERNET、ACCESS_NETWORK_STATE这两个如果场景里要读写外部文件再考虑READ_EXTERNAL_STORAGE和WRITE_EXTERNAL_STORAGE之类的运行时权限而不是一股脑全加。启动的时候在主Activity里加一个按钮事件Intent intent new Intent(this, com.unity3d.player.UnityPlayerActivity.class); startActivity(intent);因为UnityPlayerActivity是独立组件从主Activity直接startActivity就行。返回时Unity场景内按返回键UnityPlayerActivity自身会关闭并回到主Activity。这里要提一个血泪经验Unity场景内返回键的默认行为是退出UnityPlayerActivity但如果你在原生层又塞了onBackPressed拦截逻辑反而会把UnityActivity的销毁流程打乱导致Unity引擎释放一半、Activity又重建的情况出现。3.3 用UnityPlayer句柄管理启动比裸跳Activity更可控想进一步控制Unity场景的加载与释放最好的做法是不要裸跳UnityPlayerActivity而是自己写一个UnityHostActivity在它的onCreate里直接创建UnityPlayer实例public class UnityHostActivity extends Activity { private UnityPlayer unityPlayer; Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); unityPlayer new UnityPlayer(this); setContentView(unityPlayer); } Override protected void onResume() { super.onResume(); unityPlayer.resume(); } Override protected void onPause() { super.onPause(); unityPlayer.pause(); } Override protected void onDestroy() { unityPlayer.destroy(); finish(); super.onDestroy(); } }UnityPlayer直接作为View设置成内容视图它就嵌入在原生Activity里而不是独立跳转为后续做原生页面与Unity同屏展示留了余地。onResume/onPause对应Unity的暂停与恢复destroy()在Activity销毁时显式释放引擎否则Unity场景占用的显存和线程会留给下一个Activity收拾。这套做法比直接跳UnityPlayerActivity更可控尤其是在需要频繁进出Unity场景时引擎实例不会被反复创建销毁。4. Unity与原生双向通信UnitySendMessage与currentActivity的配对使用Unity嵌进去只是第一步项目需求绝不只是播个3D模型。常见的是用户在原生页面填写参数进Unity后按参数加载场景或者Unity里点了个虚拟按钮结果要回传给原生去做业务请求。这一章专门讲两端的通信通道怎么建立。4.1 原生到UnityUnitySendMessage的三个参数别写错Unity的Java侧早就预留了发送消息的入口就是UnityPlayer.UnitySendMessage签名是public static void UnitySendMessage(String objName, String methodName, String msg)三个参数意义objName是Unity场景里挂脚本的GameObject名称methodName是那个GameObject上某个脚本的public方法名msg是传入的字符串消息。注意两个坑方法必须带一个string参数且返回值必须是voidUnity的消息机制不识别带返回值的回调如果objName在场景里不存在Unity侧只是静默忽略不会给Java任何报错。原生侧实际编码长这样// 原生按钮触发Unity事件 public void sendToUnity(String data) { UnityPlayer.UnitySendMessage(BridgeObject, OnReceiveNativeMessage, data); }调用前要确保Unity场景已经加载完成否则GameObject尚未注册消息直接被丢弃。我一般会在Unity侧的场景初始化脚本里给BridgeObject发一个“UnityReady”的回调原生侧收到之后再放行后续指令。下面这段C#挂在名为BridgeObject的空GameObject上using UnityEngine; public class UnityBridge : MonoBehaviour { void Start() { // 通知原生侧Unity已就绪 AndroidJavaObject activity new AndroidJavaClass(com.unity3d.player.UnityPlayer) .GetStaticAndroidJavaObject(currentActivity); activity.Call(OnUnityReady); } public void OnReceiveNativeMessage(string msg) { Debug.Log(原生侧传来消息 msg); // 在这里解析msg并驱动场景逻辑比如切换3D模型、播放动画 } }这段C#里的Start()方法在场景启动时执行一次通过AndroidJavaClass拿到currentActivity再调用原生侧定义的OnUnityReady()方法实现“Unity侧主动通知原生”。OnReceiveNativeMessage对应Java侧的UnitySendMessage目标方法方法名必须完全一致Unity消息泵会把字符串直接送到这里。4.2 Unity到原生AndroidJavaObject拿Activity再调Java方法反向通道的经典模式是C#侧通过反射拿到当前Activity再在Activity实例上调用其公开方法。上面已经演示了一遍正向的常见写法是把要调的Java方法集中到一个专门的桥接类里C#侧共用同一个入口public static class NativeBridge { public static void CallNative(string methodName, string payload) { using (var unityPlayer new AndroidJavaClass(com.unity3d.player.UnityPlayer)) { var activity unityPlayer.GetStaticAndroidJavaObject(currentActivity); activity.Call(methodName, payload); } } }GetStaticAndroidJavaObject(currentActivity)拿到的是当前UnityPlayer所在的Activity对象。Call(methodName, payload)会把payload字符串传给Activity上名为methodName的public方法。这个方案要求原生侧写对应的方法方法签名严格是public void methodName(String payload)。如果业务上不想在Activity里堆逻辑可以把方法集中到一个小工具类并在Activity的onCreate里把实例挂到一个静态字段上。C#侧改成访问这个静态字段public class NativeBridgeManager { private static NativeBridgeManager instance; public static NativeBridgeManager getInstance() { return instance; } public void showToast(String message) { // 原生侧处理Unity传来的消息 } }C#侧var manager new AndroidJavaClass(com.example.app.NativeBridgeManager) .CallStaticAndroidJavaObject(getInstance); manager.Call(showToast, Unity透传到原生的消息);这样把通信入口收拢到单一类排查问题的时候只需要盯这一个类不需要去Activity里翻一堆回调。4.3 桥接类数据格式JSON比拼字符串实用得多通信通道打通之后下一个问题就是消息格式。只传单值字符串还简单一旦涉及多字段参数——比如品牌、模型ID、是切入展厅模式还是自由浏览模式——字符串拼接会变成灾难。我现在做法的做法是约定统一JSON格式两端各用一个解析工具包。原生侧传JSON数据给UnityJSONObject json new JSONObject(); json.put(action, loadModel); json.put(modelId, T001); json.put(autoRotate, true); UnityPlayer.UnitySendMessage(BridgeObject, OnReceiveNativeMessage, json.toString());Unity侧解析public void OnReceiveNativeMessage(string msg) { var data JsonUtility.FromJsonNativeMessage(msg); if (data.action loadModel) { // 加载模型并设置是否自动旋转 } } [System.Serializable] public class NativeMessage { public string action; public string modelId; public bool autoRotate; }JsonUtility.FromJson是Unity自带的轻量JSON解析器处理小体积、结构不复杂的协议足够。注意JsonUtility对字段名大小写敏感Java侧put的key是什么C#侧类的字段就要叫什么否则拿不到值。Unity 2020以上的版本也可以考虑用Newtonsoft.Json但对于桥接协议这种高频且低体量的数据用JsonUtility无额外依赖序列化开销也低。实战中建议把发送端和接收端做成对称结构所有来自原生侧的指令统一以action区分行为Unity侧所有上报也统一action。调试时用Logcat打全量消息配合这章的代码一套双向通信通道几分钟就能拉通。5. 避坑与排查Unity嵌入Android原工程的五个典型翻车现场这一章记录我在实际项目里踩过的、以及帮同事排查过的高频问题每一条都是「现象→原因→解决」这个套路。这些坑不会因为你Unity和Android两头都熟就自动消失多留点心眼的从不过分。5.1 现象Unity场景启动后黑屏或者卡在启动画面UnityPlayerActivity正常跳转但画面黑屏Logcat里没有明显的崩溃堆栈。最常见的原因是minSdkVersion或targetSdkVersion与Unity支持范围不一致。现在很多Unity版本对Android 12的某些行为调整是有兼容性限制的比如启动画面动画、通知权限相关的处理。可以先看Unity版本对应的Release Notes确认它支持的SDK范围然后把两边SDK版本统一到范围内。另一个常见原因是设备GPU不支持硬件加速。虽然Unity默认要求hardwareAcceleratedtrue但低端设备或模拟器上还是会翻车。解决方法是先在一台主流真机上用Unity原生导出的APK验证一遍场景本身没有问题排除Unity侧的锅后再回到混编工程里查Manifest和SDK版本。我习惯第一步永远先跑Logcat过滤Unity标签看有没有“Graphics device”相关报错它指向的硬件与驱动问题往往非常直接。5.2 现象Unity场景启动时直接闪退这种情况大多发生在SO库层面。Unity的IL2CPP编译产物是多个.so文件它们必须在unityLibrary/libs下存在且Gradle打包时没有被过滤掉。闪退往往在加载.so的时候发生Logcat会打印dlopen failed: library libunity.so not found之类的字样——注意Unity的库不止libunity.so一个还有libil2cpp.so和libmain.so。排查时先去unityLibrary/build.gradle看abiFilters如果只配置了arm64-v8a而测试机是32位的老设备就会直接闪退。解决方法是把abiFilters配成arm64-v8a和armeabi-v7a都保留。但如果你实际包里只需要64位那就在测试机上只装支持64位的设备否则不要怪Gradle。另外APK若做了分包或压缩注意Unity的.so是否被压缩Unity的库不能压缩需要在build.gradle里配置packagingOptions相关的jniLibs逻辑确保useLegacyPackaging配置正确。5.3 现象从Unity场景返回原生页面后又进入内存暴增第一次进Unity场景正常返回原生再进内存蹭蹭涨。原因在于UnityPlayerActivity在onDestroy时没有正确释放引擎的全局状态二次进入时等于又创建了一套OpenGL上下文和资源缓存。Unity的官方推荐做法是自己管理UnityPlayer生命周期而不是反复创建销毁Activity。解决思路是在宿主Activity里保持UnityPlayer实例进出场景时只做pause()和resume()不重复new UnityPlayer。如果是独立跳转的UnityPlayerActivity那就要确保onDestroy里调用了unityPlayer.destroy()并且Activity的finish()不要被延迟。另外一个容易忽略的点是Unity侧的场景用到了Resources.Load加载的资源如果脚本里没做引用释放即使Activity销毁了资源也还挂在内存里这属于C#侧的引用泄漏要在Unity侧用Profiler确认。5.4 现象Android 11以上Unity场景内部读写文件失败Android 11开始分区存储Scoped Storage对应用访问外部存储的限制明显收紧Unity自身封装的File.Exists、Directory.CreateDirectory在读写公共目录时不带权限就直接失败。很多Unity插件写死的路径/storage/emulated/0/在旧版本能跑在Android 11上就崩。解决有几个方向一是让Unity业务数据改写到应用的内部存储目录通过原生侧获取getExternalFilesDir()路径再传给Unity二是如果确实要访问公共目录必须动态申请权限并且用MediaStore或SAF但这套逻辑要在原生侧写Unity侧只传路径三是把场景数据和下载缓存一并放到应用专属目录绕开分区存储的限制。我实际项目里直接统一走getExternalFilesDir省掉权限申请和维护成本副作用是卸载App时数据一并清掉这个对大部分业务可接受。5.5 现象返回键行为错乱——在Unity里返回直接退到了桌面Unity场景内的返回键默认会退出UnityPlayerActivity但很多混编工程在AndroidManifest里给UnityActivity设置了singleInstance又在原生层做了onBackPressed拦截导致返回键事件被Activity优先处理Unity内部的场景栈反而没收到。表现就是用户以为返回的是Unity里的上一层级页面结果Activity直接结束退到桌面。解决方法是先把返回键的归属理清如果Unity场景内部有需要返回的层级逻辑由Unity侧脚本处理而不是交给Activity可以在C#侧监听Escape键输入做场景内返回如果希望返回键直接退出Unity并回原生保持UnityPlayerActivity默认逻辑即可。Android侧的返回事件如果要接管常见做法是在Unity C#脚本里调用UnityPlayer.UnitySendMessage通知原生侧执行finish()而不是在原生层强行拦截。6. 集成后的性能检查与发布前验证App别死在用户手机上工程能跑通只是开始真正让你头疼的是性能和数据包大小。这部分讲每次发版前强制走一遍的检查项照着做能省掉线上用户把App卸掉的可能。6.1 双端Profiler一起看先分清锅在谁Unity场景卡顿先分锅。引擎内部瓶颈看Unity Profiler的CPU/GPU模块引擎之外的瓶颈看Android Studio Profiler。一般是两头同时接LogcatUnity侧先看Main Thread有没有长耗时比如Resources.Load、AssetBundle.LoadFromFileAsync这类加载操作原生侧重看Activity切换时有没有主线程卡顿比如Unity初始化本身会占用大量CPU进场动画要和它错开。三个常用的性能阈值供参考Unity场景首帧加载时间不超过3秒场景内帧率在主力测试机上不低于55fpsUnity Activity启动时原生主线程阻塞不超过500ms。超过这些值先查资源加载策略再查Gradle依赖是否有重复打包。6.2 包体与架构核对别把两个ABI的SO重复打进去Unity的.so体积占整个APK的大头发布前用Gradle打一次release包核对最终产物里lib/arm64-v8a和lib/armeabi-v7a下的.so文件列表避免重复打进同一个分包里导致包体膨胀。资源方面优先保证ASTC纹理压缩生效未压缩的PNG在包体里是最显眼的一块。最后检查applicationIdentifier、签名和版本号Unity导出用的包名如果跟原生主包名不一致还要在双方衔接的配置里留意。6.3 发布前的回归清单照着勾一遍再交市场检查项通过标准启动Unity场景冷启动3秒内进入首帧无黑屏与卡启动画面返回原生页面内存回落二次进入场景内存增量不超过20%双向通信原生→Unity、Unity→原生两条通道消息无丢失权限运行时权限弹窗正常拒绝后不崩溃多设备覆盖Android 8.0、10.0、12.0以上至少各一台包体安装包大小符合预期无重复SO发版后我一般还会在线上环境盯着崩溃日志跑一天重点关注Unity场景的崩溃率与退出率。混编项目里崩在Unity侧的问题常常是在原生工程升级了Gradle或SDK版本后悄悄冒出来的最典型的就是targetSdkVersion一升级Unity的某些API行为跟着变场景就出问题。从那以后我每次升版强制走一遍双向通信和内存回归先把原生工程的Gradle版本锁死再决定要不要动Unity侧。希望帮到你。本文还有配套的精品资源点击获取