news 2026/10/26 8:22:12

Unity嵌入Android原生工程完整指南:从导出到通信避坑

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Unity嵌入Android原生工程完整指南:从导出到通信避坑

简介:面向需要在原生App中集成Unity 3D内容的Android开发者和Unity开发者,内容系统梳理了Unity导出、Android Studio工程导入、UnityPlayer桥接、性能优化与发布准备等完整流程。重点覆盖IL2CPP脚本后端配置、Library工程生成、AndroidManifest权限声明、Java或Kotlin与Unity的双向交互等实操细节,并给出错误处理与调试思路,可帮助读者避开混合工程整合中的常见坑点。压缩包整体约323.39MB,资料按功能模块整理,便于按需查阅;目前已有304人学习下载,适合需要游戏化改造或3D交互集成的中高级移动开发者参考。

1. 当Unity变成Android工程里的一个模块:先弄清楚你要的是哪种嵌入

做原生Android开发的团队,很少有纯粹为了一两个小功能就去重写一个Unity项目的。大多数情况是:Unity那边已经做好的3D场景、AR交互或者游戏玩法,现在要装进自己已有的Android App里,作为一个内嵌模块存在。这不是把Android工程塞进Unity,而是反过来——Unity作为Android原工程的一个Library,由原生Activity来调度它。我见过太多人拿到“Unity嵌入到Android原工程.rar”这类资源后,直接去解压找“一个现成的Unity项目”,结果发现里面是Unity导出后的Android Library工程,反而不知道该怎么接。真正要做的,是理解Unity作为Library导出时那套固定产物结构,以及原生侧需要承担的初始化、生命周期转发和双向通信职责。本文会按Unity侧导出、Gradle接入、生命周期桥接、双向通信、避坑排查这条线,带你走完一条能复现的路径。适合已经在用Android Studio做原生开发、需要把Unity模块嵌进现有App的工程师,也适合想在Unity 6时代重新梳理嵌入式发布方案的团队。

2. 从Unity侧导出:构建配置、包名与IL2CPP的取舍

2.1 导出前先定三件事:IL2CPP还是Mono、ARM64还是ARMv7、包名怎么对齐

Unity嵌入Android原工程,本质上是一次“Unity as a Library”的导出。这个导出不是简单打个APK,而是生成一个能被Gradle依赖的Android Library工程。开始之前,有三件事必须先定下来,否则后面返工成本非常高。

第一件事是脚本后端。Unity的Player Settings里,Scripting Backend可以选择Mono或IL2CPP。嵌入原生工程,我强烈建议选IL2CPP。原因很现实:IL2CPP会把C#转换成C++再编译成原生so,性能更好、更难被反编译,而且Unity 6之后对Mono的支持已经边缘化。代价是编译时间变长,首包变大,但作为内嵌模块,这些都能接受。我见过团队图省事用Mono导出,结果在Android 10以上某些机型上出现运行时异常,最后排查到是Mono的JIT在某些系统调用上被限制,换回IL2CPP才稳定。

第二件事是ARM架构。现在新手机几乎都是arm64,旧项目可能有ARMv7需求,但Unity 6已经把ARMv7的支持标记为过时。除非你的App还在卖老平板,否则直接勾选ARM64就够了。如果一定要兼容32位,需要同时勾选,此时APK/AAB体积会明显增加,而且Unity的引擎库会变成两份。

第三件事是包名对齐。Unity导出时,Player Settings里的Package Name(com.company.product)不能乱填,它会被写进生成的UnityLibrary的AndroidManifest里,和原生工程包名没有直接关系,但会影响Unity内部一些路径和Activity的启动方式。常见做法是让Unity的Package Name保持一个独立且有意义的包名,比如com.yourapp.unitymodule,而不是和原生包名一致。这能避免在同一个进程里出现类名或资源名冲突。

在Unity里依次打开Edit > Project Settings > Player,找到Android平台图标,在Other Settings里设置Scripting Backend为IL2CPP,Target Architectures勾上ARM64。这里有一个容易忽略的选项:Target API Level。如果原生工程已经用targetSdk 34或35,Unity侧也必须能支持到对应级别,否则Gradle同步时会报SDK版本冲突。Unity 6的默认值通常能跟上,但2021 LTS这种老版本会默认到30或31,需要手动改高。

2.2 在Unity里把工程导出为Android Library:关键步骤与Gradle坐标

确认上面几项后,就可以导出。菜单路径是File > Build Settings,选择Android平台,点击Export。在导出窗口里,有两个关键选项:Export Project和Export as Android Library,必须两者都选中。很多人只勾了Export Project,导出一个普通工程,那样拿不到Library产物。

正确操作是:在Build Settings里点击Export,弹窗中勾选“Export Project”后,下方会多出一个“Export as Android Library”的选项,同时Show Dependency Management会变成可选。如果要用Gradle接入,建议勾选Show Dependency Management,这样Unity会生成settings.gradle和build.gradle等一套完整的Gradle配置文件,而不是只给一个裸工程。勾选后导出到一个空目录,比如unity-exported/。

导出完成后,你会看到目录里有一个unityLibrary子目录,里面才是真正要被引入的东西。这个unityLibrary包含src/main/java(UnityPlayer等Java源码)、src/main/res(资源文件,比如Unity的启动主题和图标)、libs/classes.jar(Unity的Java类库)、src/main/jniLibs(ARM64下的libunity.so等一堆so文件)、还有一个unityLibrary/build.gradle。这个build.gradle可以直接被Android Studio引用,但通常需要改几个值。

如果不想勾选Show Dependency Management,Unity会在导出时同时生成一个unityLibrary和一个launcher目录。launcher是一个完整的壳App,方便先跑通Unity自身,但嵌入原工程时我们要的是unityLibrary。记住:只引入unityLibrary,不引入launcher。

2.3 导出的产物里到底有什么:classes.jar、so库、res与AndroidManifest的对应关系

了解产物结构不是为了看热闹,而是为了在接入出错时能快速判断缺了什么。unityLibrary下的核心文件可以拆成四类。

第一类是Java层。src/main/java里最关键的是com.unity3d.player.UnityPlayerActivity、UnityPlayer和UnityPlayerNativeWrapper这些类。UnityPlayerActivity是一个Activity,负责承载Unity渲染Surface并转发生命周期;UnityPlayer是内部核心,你会在原生代码里直接操作它。classes.jar里打包了UnityPlayer的编译产物和一些隐藏API,Gradle会把classes.jar当成依赖编译进来,但运行时真正执行的是jniLibs下的so库,两者版本必须严格一致。

第二类是native层。src/main/jniLibs/<abi>/下面有libunity.so(引擎主库)、libmain.so(Unity主线程入口)、libil2cpp.so(IL2CPP生成的运行时)等。如果用的是Mono,还会看到libmono.so。这些so库会直接被打进最终APK,所以原生工程里如果启用了extractNativeLibs=false或者做了压缩,要确保Unity的so库能正确加载,后面避坑章会提到。

第三类是资源层。src/main/res里通常有values/strings.xml(带Unity的App名称)、values/styles.xml(Unity的主题,比如UnityTheme)、drawable图标等。这些资源会和原生资源合并,如果原生工程里已经有同名资源,会发生合并冲突。解决方式是在Unity侧把这些资源名改掉,或者在原生侧添加命名空间前缀。

第四类是清单文件。unityLibrary的src/main/AndroidManifest.xml会声明UnityPlayerActivity、配置必要的权限(比如INTERNET、WAKE_LOCK、VIBRATE等),还会定义Unity的主题。接入时你需要仔细看这个清单里申请的权限,原生App如果本身没有申请,合并后Unity会自动带上,这对隐私合规有影响,需要评估。

导出后的东西不是一成不变的。Unity 6的exportGradle默认会生成Kotlin DSL风格的构建脚本,而不是老的Groovy。如果你的Android Studio工程还是Groovy配置,建议让Unity导出时保持与工程一致,或者干脆在接入时手动改写unityLibrary/build.gradle,这在前几年是常规操作。

3. 把Unity产物接进Android原工程:Gradle依赖与生命周期桥接

3.1 用工程内嵌Module方式引入UnityLibrary:build.gradle怎么写

拿到unityLibrary之后,下一步是把它放进Android Studio工程里。常见做法是把它作为一个Module,而不是直接把jar和so复制到自己的模块里。因为Unity Library本身有一堆资源和编译配置,作为Module管理最干净。

具体操作是:在Android工程根目录下建一个unityLibrary文件夹,把导出的unityLibrary整个复制进去(注意保留里面的build.gradle和src)。然后在根目录的settings.gradle里加入:

include ':unityLibrary' project(':unityLibrary').projectDir = new File('unityLibrary')

如果你的settings.gradle是include ':app'这种形式,直接在后面加两行就行。老式Android Studio工程可能用的是settings.gradle中的include ':unityLibrary',两者等效。接着在app/build.gradle的dependencies里加上:

implementation project(':unityLibrary')

这时同步Gradle。注意,Unity导出的unityLibrary/build.gradle里通常会自带一些依赖配置,比如compileOnly files('libs/classes.jar')或者implementation fileTree(include: ['*.jar'], dir: 'libs'),这些不要动。但需要检查一下它声明的compileSdkVersion和minSdkVersion是否和主工程兼容。Unity 6默认要求minSdkVersion在22或以上,如果你的App还在支持Android 5.0(API 21),就必须把Unity侧的minSdk调低,或者反过来提高主工程的minSdk。通常我会把UnityLibrary的minSdk调到和主工程一致,并且确保compileSdk不低于主工程。

还有一个关键点:Unity Library的build.gradle可能会生成android.defaultConfig.ndk配置,包含abiFilters。如果主工程没启用NDK,也没关系,UnityLibrary会自己把ABI合并进来。但如果你在主工程的Gradle里开启了packagingOptions排除某些so,特别是排除了lib/arm64-v8a下的文件,Unity就会被卡死,后面避坑章细讲。

3.2 UnityPlayerActivity与UnityPlayer.currentActivity:两套生命周期的握手方式

接入后,启动Unity的方式有两种。一种是直接启动Unity的UnityPlayerActivity,它本身就是一个独立Activity,和普通Activity一样用Intent拉起。另一种是把Unity的Surface嵌进自己的Activity里,这种方式更复杂,但更符合“嵌入原工程”的语义,因为你可以保留自己的标题栏、菜单栏,甚至是原生Fragment。

第一种方式最简单。在原生代码里:

Intent intent = new Intent(this, com.unity3d.player.UnityPlayerActivity.class); startActivity(intent);

这样Unity就会以全屏Activity的形式出现,返回后回到原生界面。UnityPlayerActivity内部会创建UnityPlayer,并在onResume、onPause时调用UnityPlayer的对应方法。如果你的App只需要“看不跳转地进入Unity场景”,这个方式够用。

第二种方式需要你手动去控UnityPlayer。常见做法是在自己的Activity里创建UnityPlayer实例,然后把它添加到ContentView中。核心代码如下:

public class HybridActivity extends Activity { private UnityPlayer unityPlayer; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); unityPlayer = new UnityPlayer(this); setContentView(unityPlayer); unityPlayer.requestFocus(); } @Override protected void onResume() { super.onResume(); unityPlayer.resume(); } @Override protected void onPause() { super.onPause(); unityPlayer.pause(); } @Override protected void onDestroy() { super.onDestroy(); unityPlayer.quit(); } }

这段代码里,UnityPlayer的构造函数会完成Unity引擎初始化,包括加载libunity.so、解析Unity的AndroidManifest配置、创建渲染Surface。之后setContentView(unityPlayer)会把这个Surface铺满屏幕。注意,unityPlayer.quit()必须在onDestroy里调用,否则Unity线程不会被释放,会出现“退出Activity后声音还在播”的诡异问题。

UnityPlayer.currentActivity是Unity侧访问原生Activity的静态入口。在C#里可以用AndroidJavaClass拿到com.unity3d.player.UnityPlayer的currentActivity字段,进而调用原生Activity的方法。但反过来,原生Java要操作Unity,得持有UnityPlayer实例或通过UnityPlayer.currentActivity判断Unity是否已初始化。这里有一个时序坑:在UnityPlayer构造之前,currentActivity是null,如果你在原生启动流程早期就去调Unity的方法,会撞上空指针。

3.3 从原生按钮启动Unity场景:最小可运行代码与Intent传参

假设你已经把UnityPlayerActivity作为一个子模块接进来了,现在要做一个最朴素的功能:原生界面上有个按钮,点击后进入Unity加载指定场景。除了上面的Intent方式,还可以在启动时传参。因为UnityPlayerActivity在收到Intent后,会把Intent里的data或extra转发给Unity场景中的C#对象。

Unity侧,在C#脚本的Start方法里读取参数:

using UnityEngine; using System; public class EntryArgumentReader : MonoBehaviour { void Start() { // 获取Unity当前的Activity实例 var activity = new AndroidJavaObject("com.unity3d.player.UnityPlayer"); var context = activity.CallStatic<AndroidJavaObject>("currentActivity"); string args = context.Call<string>("getIntent").Call<string>("getStringExtra", "nav_target"); if (!string.IsNullOrEmpty(args)) { Debug.Log("收到原生传来的参数: " + args); } } }

原生侧启动Intent时:

Intent intent = new Intent(this, com.unity3d.player.UnityPlayerActivity.class); intent.putExtra("nav_target", "MainScene"); startActivity(intent);

这段交互看起来简单,但有几个细节。getIntent().getStringExtra必须在Unity场景加载完成且C#脚本对象实例化后才能读到,如果原生在Unity尚未就绪时就把Intent发过去,Unity会丢失参数。稳定的做法是:原生侧等待Unity初始化完成后再发Intent,或者C#那边轮询等待参数。我一般在Unity侧用一个静态缓存,在Awake时读取并保存,避免因为场景切换而丢失。

另一种更稳妥的传参方式是写完Intent后,通过原生代码直接调用Unity的C#方法,这在下一章讲。总之,Intent传参适合一次性跳转,而持续双向通信必须走UnitySendMessage。

4. 双引擎通信:UnitySendMessage与原生回调的两种姿势

4.1 C#调Java:UnitySendMessage的变通与UnityPlayer.UnitySendMessage

Unity和原生App互调的正式API是UnitySendMessage,但它的方向是“原生Java往Unity的C#发消息”。C#主动往Java发消息,要绕过UnitySendMessage,直接使用AndroidJavaClass和AndroidJavaObject。这两个类是Unity引擎内置的Android JNI封装,相当于Java的反射调用。

C#调Java静态方法:

using UnityEngine; public class CallNative : MonoBehaviour { public void CallNativeMethod(string param) { // 获取UnityPlayer类并调用静态方法 using (var unityPlayerClass = new AndroidJavaClass("com.unity3d.player.UnityPlayer")) { // 拿到当前Activity对象 var activity = unityPlayerClass.GetStatic<AndroidJavaObject>("currentActivity"); // 调用原生Activity里的原生方法 activity.Call("onUnityEvent", param); } } }

对应的原生Activity中:

public void onUnityEvent(String param) { Log.d("Hybrid", "Unity 调用了原生方法: " + param); runOnUiThread(() -> { // 在这里安全地更新UI }); }

这段C#代码里的using是AndroidJavaObject实现了IDisposable,用完释放JNI引用,防止局部引用表溢出。currentActivity是UnityPlayer持有的静态字段,类型是Activity,所以你拿到它之后,可以调它的任何public方法,甚至runOnUiThread进行主线程切换。

注意,很多教程会教你把原生方法放到一个自定义类里,然后在C#里用new AndroidJavaClass("com.example.MyBridge")去调用。这完全可行,前提是那个类所在的jar包被主工程依赖,并且方法是public static。我更喜欢用currentActivity这种方式,因为它天然持有上下文,避免在C#里自己去反射Context。

4.2 Java调C#:UnitySendMessage的签名时序与主线程调度

Java往Unity发消息是UnityPlayer.UnitySendMessage的静态重载。签名是:

UnityPlayer.UnitySendMessage(gameObjectName, methodName, message)

第一个参数是Unity场景里挂脚本的GameObject名称,第二个是脚本上公开的方法名,第三个是字符串参数。需要注意,Unity的GameObject.Find和SendMessage机制要求目标GameObject是激活状态,并且方法必须为public,C#侧建议加[UnityEngine.RuntimeInitializeOnLoadMethod]或确保对象已加载。

原生的调用代码:

// 在主工程任意Java类里调用 UnityPlayer.UnitySendMessage("BridgeObject", "OnNativeMessage", "hello from android");

C#侧的接收:

public class BridgeBehaviour : MonoBehaviour { private void Awake() { // 注册BridgeObject,确保GameObject名为BridgeObject gameObject.name = "BridgeObject"; } public void OnNativeMessage(string message) { Debug.Log("收到原生消息: " + message); } }

这里有三个坑。第一,UnitySendMessage没有返回值,方法返回void,否则会静默失败。第二,消息是异步的吗?其实UnitySendMessage在原生线程里调用,Unity会把消息投递到主线程的消息队列,但如果你在一个非UI线程连续调用很多次,可能触发Unity的线程安全检查。第三,UnitySendMessage必须在Unity引擎已经初始化后调用,否则内部会因UnityPlayer.currentActivity为空而抛异常。我在项目里习惯封装一个NativeBridge单例,内部持有一个ConcurrentLinkedQueue<String>来缓存原生发来的消息,等Unity侧每帧去Update里拉取,这能彻底避开时序问题。

另一种常见姿势是让UnityPlayerActivity直接继承你的自定义Activity,然后由这个Activity来实现消息回调。但那样会破坏Unity导出的完整解耦性,升级Unity引擎时要重新改这个Activity,很麻烦。我一般用独立的Bridge类,不改Unity库。

4.3 把Unity视图嵌入到Fragment或布局容器:边界与限制

如果只是全屏启动Unity,不需要关心视图层级。但很多业务要把Unity场景嵌在原生界面的一个局部区域里,比如一个3D模型展示区或者AR区域。这时就必须把UnityPlayer作为View加入到布局中。

有一个官方不推荐的偏门做法:把UnityPlayer直接作为一个View添加到FrameLayout里,设置宽高和位置。但UnityPlayer内部会创建SurfaceView,SurfaceView的特点是它在Window层独立于View层级,不能正常参与Android的View动画、平移和层级遮挡。如果你用SurfaceView,那么在它上面盖原生控件时,原生控件可能被盖在下面,这是SurfaceView的经典坑。

Unity 6里的UnityPlayer加了一个用于混合渲染的UnityPlayer.initialize接口,但仍建议:如果需要局部嵌入,优先使用TextureView模式。在Unity导出Library时,有个隐藏配置allowBackbuffer? 不,实际是从Unity 2020开始,UnityPlayer支持在构造函数中开启android.view.TextureView。要开启这个模式,需要改UnityPlayer源码或者使用反射调用内部设置。

更稳的路线是:原生层用一个FrameLayout作为容器,把UnityPlayerActivity的渲染Surface通过addContentView塞进去。但这需要Unity侧允许Activity透出,方案复杂度不止一个数量级。我接过的几个生产项目里,80%的“局部嵌入”需求其实用“全屏转场”就能满足交互预期,局部嵌入多半是为了省内存或保持UI连续性,而不是真有多个视图同屏交互。如果确实要同屏,可以去搜Unity官方示例里的UnityFragmentActivity,但那个demo已经很久没有更新,适配Unity 6需要自己改。

我的建议:不要强行把UnitySurface做成Fragment。坦白说,Unity的渲染管线是为独占Surface设计的,和原生View共享屏幕会造成overdraw和焦点混乱。如果产品经理要求“地图部分用Unity,上面盖原生按钮”,请让他们明白按钮要么走Unity的UI系统,要么接受SurfaceView遮挡限制。

5. 避坑:嵌入Android原工程最常见的6个翻车现场与排查路径

5.1 崩溃:Unity引擎初始化失败,libunity.so加载不到

现象:点击进入UnityActivity后,立刻闪退,Logcat里出现java.lang.UnsatisfiedLinkError: dalvik.system.PathClassLoader ... couldn't find "libunity.so"。

原因:主工程Gradle的packagingOptions或jniLibs配置把它排除或覆盖了。常见的是主工程里自己写了ndk { abiFilters 'armeabi-v7a' },导致构建产物里没有arm64下的libunity.so,而Unity导出的unityLibrary只有arm64-v8a。

解决:在app/build.gradle里,检查两个地方。一是defaultConfig.ndk.abiFilters,确保和Unity一致;二是packagingOptions里如果有exclude 'lib/arm64-v8a/**',删掉。然后重新构建,用unzip -l your.apk | grep libunity确认so库已经打进APK。

注意:Unity Library自己的build.gradle里有时候会声明doNotStrip,不要乱改。如果你开了应用压缩,还要确保Unity的so库没有被删减,一般Unity导出时会自动处理,但混淆主工程代码时别把Unity的Java类漏掉。

5.2 黑屏但Logcat正常:Unity渲染Surface与原生的SurfaceView冲突

现象:UnityActivity能启动,不崩溃,但屏幕是黑的,Logcat里能看到Unity的日志,也有UnityGfxDevice初始化成功的信息。

原因:最常见的是Unity的Surface被原生布局中的另一个SurfaceView盖住了,或者是Unity渲染线程初始化后没有及时接到Surface。另一个冷门原因是Unity Player Setting里选了Graphics API为Vulkan,而原生工程里的SurfaceFLinger不兼容。

解决:先临时改成OpenGL ES 3.0试试,排除API兼容问题。如果是SurfaceView遮挡,检查你的Activity是否用了requestWindowFeature或者设置了不透明的主题背景,把根布局的背景色改成transparent。还有,在onCreate里需要调用setContentView(unityPlayer)之后再requestFocus(),而且要确保没有在onWindowFocusChanged里做多余操作。

5.3 返回键和Home键:UnityEngine.Application / Activity的onBackPressed接管问题

现象:在UnityActivity里按返回键,Unity场景没有正常退出,而是跳到了另一个Unity场景或者直接回到桌面,原生App的栈状态被打乱。

原因:UnityPlayerActivity内部重写了onBackPressed并交由Unity引擎处理,如果Unity场景里没有消费返回键,最终会调用Activity的finish()。但如果你在原生代码里用addContentView方式引入UnityPlayer,返回键事件在原生View先分发,导致Unity根本收不到。

解决:如果你直接使用UnityPlayerActivity,不需要管。如果你用了自定义容器,需要在你的Activity里手动把返回事件转给Unity:

@Override public void onBackPressed() { if (unityPlayer != null && unityPlayer.onBackPressed()) { return; } super.onBackPressed(); }

UnityPlayer.onBackPressed()返回true表示Unity消费了事件,则该不该结束Activity由C#侧决定;返回false才交给原生。注意如果你用UnityPlayerActivity,它已经做了这件事,别重复处理。

5.4 内存抖动:加载场景后原生内存被Unity吃掉一大块

现象:进入Unity后,Android Studio自带的Profiler显示原生堆内存很正常,但系统总体内存快速上涨,甚至低端机型被杀后台。

原因:Unity的Application.targetFrameRate默认不限制,高端机跑满60帧/90帧,Shader和纹理占用可能到500MB以上。如果你的Unity场景加载了高分辨率贴图且没有开纹理压缩,内存会爆炸。另外,如果Unity侧没有在离开时调用Resources.UnloadUnusedAssets()和System.GC.Collect(),引擎内存不会释放。

解决:在Unity侧写一个方法,在原生离开Unity时或从场景退出时调用。原生侧可以这样:

@Override protected void onDestroy() { super.onDestroy(); UnityPlayer.UnitySendMessage("MemoryManager", "OnReturnNative", ""); unityPlayer.quit(); }

C#侧:

public void OnReturnNative(string _) { Resources.UnloadUnusedAssets(); System.GC.Collect(); }

另外检查Unity的Quality Settings里的Texture Quality和Anti Aliasing,在嵌入场景时把画质调到中档,比在原生层做内存压缩更有效。

5.5 升级Unity 6后原有Gradle配置失效:exportGradle的默认行为变化

现象:团队原来的Unity 2019/2020导出配置一直用得好好的,换到Unity 6后导出的unityLibrary在Android Studio里同步失败,报错信息是Could not find method implementation() for arguments或Unresolved reference: kotlinx之类。

原因:Unity 6默认生成的build.gradle已经变成Kotlin DSL(即build.gradle.kts),而老工程的settings.gradle还在用Groovy。两者混用会冲突。

解决:有两个方向。一个是Unity导出时,在Player Settings里找到Manage Gradle dependencies或者Export Gradle相关的选项,尝试关闭Kotlin DSL,如果Unity 6已移除该开关,就只能让主工程也跟着用Kotlin DSL。另一个是干脆不依赖UnityLibrary上的Gradle配置文件,自己在主工程里为unityLibrary写一个干净的传统build.gradle,手动依赖classes.jar和jniLibs。

我遇到这种情况时,更推荐自己写unityLibrary/build.gradle,如下:

apply plugin: 'com.android.library' android { compileSdk 34 namespace 'com.unity3d.player' defaultConfig { minSdk 24 targetSdk 34 consumerProguardFiles 'proguard-unity.txt' } sourceSets { main { jniLibs.srcDirs = ['src/main/jniLibs'] java.srcDirs = ['src/main/java'] res.srcDirs = ['src/main/res'] manifest.srcFile 'src/main/AndroidManifest.xml' } } compileOptions { sourceCompatibility JavaVersion.VERSION_11 targetCompatibility JavaVersion.VERSION_11 } } dependencies { implementation fileTree(include: ['*.jar'], dir: 'libs') }

这份配置把Unity库的Java层、so库和资源都编译成一个Android Library,不依赖Unity自动生成的那些冗余脚本。namespace按Unity导出的实际包名来,如果原命名空间是com.unity3d.player,保持它。

5.6 冷启动变慢:Unity引擎初始化不是免费的

现象:接入Unity后,App冷启动时间增加1~2秒,尤其第一次启动。

原因:Unity初始化要加载libunity.so、libil2cpp.so,还要创建新的线程池和图形上下文。即便你的原生界面还没进入Unity,只要你把UnityPlayerActivity所在模块的类加载了,引擎初始化就可能被执行。

解决:把Unity相关代码丢到so层懒加载。最直接的方式是原生端不要在主工程启动时预加载UnityPlayer类,而是等用户第一次点击“进入3D场景”时才触发类加载。Java层可以用Class.forName("com.unity3d.player.UnityPlayer")做延迟初始化。另外,Unity侧在Player Settings里把Run In Background关闭,并在C#侧挂一个DontDestroyOnLoad的空场景入口,避免启动时加载重场景。Unity引擎的初始化时间在高端机上能压到500ms以内,但低端机不要抱期望。

6. 进阶:把Unity做成Fragment级组件并收好三种“后悔药”

先泼一盆冷水:把UnityView嵌到Fragment里,让用户滑开关卡时还能看到原生列表,这在Android上能做到,但代价很高。Unity 6的官方路径是通过UnityPlayer的init方法创建UnityPlayer视图,再由UnityPlayer.getFrameLayout()获取内部容器。但getFrameLayout这个接口在Unity 6里已经不再暴露给Java,需要用反射调用getUnityLayout之类,非常脆弱。我实际用过且稳定的是这套折中方案:先开一个轻量原生Activity,这个Activity的背景和原生页视觉一致,进入时提前初始化UnityPlayer,渲染完成后把UnityPlayer的SurfaceView做成全屏覆盖,相当于伪Fragment。如果要真Fragment,请保持Unity场景内渲染,原生UI作为最上层叠加并处理穿透事件。

再说验证方法。接入完成后,不要只看Unity能打开就完事。至少要验证五件事:进程存活和返回栈、Unity私有目录写入权限、多模块热更兼容、OOM稳定性、以及Android 12以上SplashScreen规范。具体做法是:在原生层写一个测试页面,连续进出Unity 50次,用adb shell dumpsys meminfo观察内存是否回落;用adb shell am start带不同Intent参数验证参数读取一致性。

三种“后悔药”要提前准备。第一,代码版本后悔药:UnityLibrary导出的代码不要直接改动Java源码去适配业务,业务逻辑全放C#侧,否则Unity升级时要重新处理Java冲突。第二,Gradle配置后悔药:把unityLibrary/build.gradle独立成一份版本管理文件,不随Unity导出覆盖,升级Unity后手工合并差异。第三,通信协议后悔药:C#和Java之间不要直接传复杂对象,只传JSON字符串,原生的Gson和Unity的JsonUtility各解析一份,接口签名只用字符串,这样任何一方重构都能兼容。

最后说个我的习惯。每次拿到Unity导出的unityLibrary,我会先把AndroidManifest.xml完整读一遍,看Unity默认为自己申请了什么权限、注册了什么Activity。这个习惯救过我几次,因为Unity某些版本会自动申请READ_PHONE_STATE,而App的隐私合规检测不通过,问题就出在那个我从来没看过的清单文件里。把这个步骤固化到每次接入的checklist里,能省掉很多上线的麻烦。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/26 8:21:50

Agent场景下基于SSE与Redis的实时流式推送架构实践

1. 先说结论&#xff1a;为什么 Agent 场景必须上“推”而不是“拉”做 Agent 应用做久了&#xff0c;你会发现一个很尴尬的现实&#xff1a;Agent 根本不是一个“请求-响应”模型&#xff0c;而是一个“思考-调用-再思考-再调用-最终回答”的连续过程。一次用户提问&#xff0…

作者头像 李华
网站建设 2026/10/26 8:20:56

t3code:面向前端开发者的轻量级代码生成CLI工具

我无法根据当前输入生成符合要求的博文。原因如下&#xff1a;输入中仅提供了项目标题"t3code"&#xff0c;但未提供任何实际的【项目正文】、【关键词】列表&#xff08;当前给出的是网络热搜词混杂列表&#xff0c;并非结构化关键词&#xff09;、也未提供【摘要描…

作者头像 李华
网站建设 2026/10/26 8:20:22

五语言麻将胡牌算法包:check_eye_table查表与跨语言移植实战

简介&#xff1a;这是一份面向游戏开发与算法学习者的多语言麻将胡牌算法合集&#xff0c;覆盖C、C#、Lua、Go、JavaScript五种实现&#xff0c;适合想对比不同语言在相同逻辑下设计差异的中高级开发者&#xff0c;也可作为棋牌类项目判断胡牌、计算番数的参考方案。压缩包共33…

作者头像 李华
网站建设 2026/10/26 8:20:20

技术原理与行业实践:破解通用工程问题的关键路径

简介&#xff1a;面向自动驾驶、地理信息系统与物流规划中的路径跟踪和预测任务&#xff0c;这份代码完整、数据齐全的LSTM项目&#xff0c;提供了基于MATLAB的完整实现流程。资源包共14个文件&#xff0c;包括8个m脚本、2个mat数据文件、2个csv结果以及2个asv备份&#xff0c;…

作者头像 李华
网站建设 2026/10/26 8:19:54

基于视频识别的学生坐姿监测系统设计与实现

1. 项目背景与核心价值去年给某中学做信息化改造时&#xff0c;班主任们反映最头疼的就是学生坐姿问题。传统的人工巡查效率低下&#xff0c;而长期不良坐姿导致的脊柱侧弯、近视等问题越来越普遍。这套基于视频识别的坐姿监测系统&#xff0c;正是为了解决这个痛点而生。系统通…

作者头像 李华
网站建设 2026/10/26 8:18:58

华为杯B题数学建模全流程:从赛题拆解到论文写作的实战指南

简介&#xff1a;这份资源面向备战中国研究生数学建模竞赛的研究生&#xff0c;聚焦2023年华为杯B题&#xff0c;提供从思路梳理到代码实现再到论文写作的完整参考方案。压缩包共19个文件&#xff0c;约24.01MB&#xff0c;包含9个PDF论文与解析文档、8个MATLAB脚本&#xff08…

作者头像 李华