如果只是做纯 Unity 手游,Unity 自己出包就行,根本不涉及原生集成这件事。但 2020 年之后,我接到的咨询越来越集中于同一个问题:“如何把 Unity 集成到原生 iOS 应用里”。典型场景是:团队手里已经有一个成熟的原生 App,几百个页面、完整的用户体系、推送和支付链路,不可能为了一个 3D 功能整体改用 Unity 重写;但业务方又需要高质量的实时 3D 渲染,比如商品预览、家具摆放、AR 试戴或者某个特效模块。于是把 Unity 当作一个渲染组件嵌入原生工程,成了最现实的选择。这篇文章我会把 Unity 2020+ 官方主流的 UnityFramework 集成思路完整拆解一遍,包括工程怎么导、框架怎么引、生命周期怎么对齐、双向消息怎么设计,以及很多常规文档里不会写的实际操作经验。想省时间的原生工程师可以直接按第三、四章的步骤操作;想弄懂原理再动手的,建议从头看。1. UnityFramework 解决了什么问题:先理解 2020 年前后的路径差异1.1 老路子为什么撑不住先说一段历史。早期把 Unity 集成到原生 iOS 应用,基本只能从 Unity 导出的 Xcode 工程下手:你先把 Unity 项目构建成一个完整 iOS App,然后把自己已有的原生代码往这个工程里“逆向嫁接”。听起来可行,实际上非常难受——Unity 生成的工程结构每次版本升级都可能变化,你的原生代码和 Unity 的 AppDelegate 纠缠在一起,两个团队改同一份 Xcode 工程,冲突记录能刷满半个屏幕。一旦 Unity 小版本更新,AppDelegate 里的生命周期方法又变了,整个集成代码都要跟着返工。这种方式最核心的问题在于:宿主关系反了。理论上应该是原生 App 作为宿主,Unity 作为被嵌入的引擎;老路子里却是 Unity 的 App 工程当壳,原生代码变成“寄居蟹”。团队边界不清晰,版本升级像在火堆上跳舞。1.2 UnityFramework 的产物结构2020 年前后,Unity 把官方推荐路径收敛成“Unity as Library”思路,落到 iOS 上就是 UnityFramework。你从 Unity 构建 iOS 工程时,导出的 Xcode 工程里会同时存在几个 target:一个是真正打包成 App 的工程,另一个就是 UnityFramework.framework。UnityFramework 这个框架把引擎运行时、IL2CPP 编译出来的 C# 代码、托管程序集、资源 bundle,甚至场景数据,全部打成一个可独立加载的动态/静态框架。宿主 App 不需要继承 Unity 的 APP 生命周期入口,而是通过一个公开头文件去实例化、启动、暂停和退出 Unity 运行时。关键部分作用UnityFramework.framework引擎本体与脚本编译产物,宿主工程的依赖核心UnityFramework.h公开接口,负责加载、启动、数据目录设置appController 对象管理 Unity 的 App 生命周期和视图,宿主可以拿它的 view 来展示Data.bundle / 场景资源Unity 的资源包,框架加载时按 bundleId 找到对应数据1.3 一句话概括集成模式现在的思路完全可以压缩成一句话:原生 App 是老大,Unity 是被拉起来的渲染引擎。页面跳转、用户授权、支付、推送这些走原生;Unity 只负责进入某个场景、渲染某些内容、把渲染结果通过视图交给原生展示,再通过消息接口完成数据交换。这个模式带来的团队分工非常清晰:Unity 工程专注做 3D 场景和表现层,原生工程专注做 App 骨架和业务逻辑。两边只通过一套消息协议沟通,不用再互相侵入工程结构。2. 动手前先用三个问题做选型,比到处找集成脚本更重要很多团队拿到集成文档就开始拖 framework,结果业务形态根本不匹配,后期又要改架构。我在实际项目里总结了三个前置问题,建议开工前先回答清楚。2.1 问题一:Unity 场景要长期驻留,还是按需拉起有些需求是 Unity 场景作为 App 的一个“长期模块”,比如整个首页都是 Unity 渲染;有些需求则是“用户点开商品详情才临时加载 3D 预览,退出后释放”。这两种情况的技术方案完全不同。长期驻留意味着 Unity 运行时全程活着,内存占用是持续的,原生和 Unity 的渲染视图会长期叠加;按需拉起则要考虑场景加载时间、资源释放、以及多次启动退出的稳定性。如果只是局部功能,我很建议按需初始化,不要让 Unity 常驻。2.2 问题二:渲染区域是“全屏替换”还是“嵌视图”全屏替换的情况比较接近 Unity 自己出包的体验,旋转、刘海屏适配都可以交给 Unity 处理;嵌视图则更像把一个 UnityView 塞进原生 UIViewController 的某个 region,此时你不仅要处理渲染视图的 frame,还要处理事件分发和不同屏幕方向下的布局变化,复杂度上升一个台阶。我见过一个项目把 Unity 渲染结果放在一个列表页的 cell 里,初衷是“滚动时能看到实时 3D 预览”,实际操作中发现问题非常多:Metal 渲染层和列表滚动时的 frame 变化冲突、cell 复用后的通知错乱、内存暴涨。最后不得不改成“点击后进入全屏 Unity 场景”的方式才稳定下来。2.3 问题三:原生团队和 Unity 团队的协作边界这个最容易被忽略。Unity 集成不是纯技术问题,而是跨团队协作问题。两边需要共同商定:消息协议谁来定、Unity 场景的进出时机谁负责触发、资源由谁发布、崩溃日志怎么统一归口。如果没有清晰边界,最后很容易变成“Unity 崩溃怪原生,原生卡顿怪 Unity”。技术方向全 Unity App原生壳 + UnityFramework原生重写所有功能3D 渲染能力高高低原生页面体验依赖 Unity 实现原生完整保留原生完整保留团队改动成本Unity 团队接手所有两边并行开发重新开发包体/内存控制较难可控制最可控推荐场景游戏/重度 3D原生 App 加 3D 模块纯 2D/轻业务这里没有绝对最优,只有适合不适合。如果你的 App 本质是内容型产品,3D 只是其中一个特色功能,原生壳加 UnityFramework 通常是最平衡的方案。3. 落地步骤:从 Unity 导出一个干净 Framework,再接入现有工程3.1 Unity 导出前的构建参数设置在 Unity Editor 里打开 Build Settings,把目标平台切换到 iOS。导出前有几项设置直接决定集成成败。第一是 Bundle Identifier,尽量和宿主 App 保持一致或使用同一个团队 ID,否则加载资源时会遇到数据目录定位错误。第二是 Scripting Backend 选择 IL2CPP;Unity 2020 之后这基本是 iOS 的默认选项,它能提供更完整的 AOT 支持,也方便最终打包时做裁剪。第三是 Graphics API,保持 Metal;Unity 2019 之后 iOS 上 Metal 是默认渲染 API,旧项目可能遗留 OpenGL ES,嵌入现代宿主时应该清理掉。还有几个容易被忽略的选项。建议在 Player Settings 中清理掉不需要的模块,比如完全没有使用的增强现实组件、物理引擎模块,如果有裁剪入口,导出框架时能省不少体积。Unity 2020 之后构建出来的 Xcode 工程可以保留原始工程结构,不要勾选自动打开 Xcode,避免开发机上的环境干扰。3.2 把 UnityFramework 放进宿主 Xcode 工程Unity 构建完成之后,会在导出目录生成一个 Xcode 工程。打开这个工程,Products 目录下能找到 UnityFramework.framework,直接把它拷贝到你的宿主工程目录下。然后在宿主的 Xcode 工程中做三件事:把 UnityFramework.framework 拖入 TARGETS 的 Frameworks 分组下,在 Embed 属性里选择 “Embed & Sign”,而不是 “Do Not Embed”。很多崩溃就是这里选错导致的:只在编译时链接了框架,运行时不把框架打进 App,启动时直接找不到动态库。在 Build Settings 里找到 Other Linker Flags,需要时添加-ObjC,保证 Unity 的原生符号能正确解析。检查宿主 App 的签名设置,UnityFramework 的签名要和你主 App 的证书一致。真机调试时最容易遇到签名不一致导致加载失败。需要说明的是,Unity 导出的 UnityFramework 在不同版本里包含的依赖稍有差异,以你自己导出的工程为准。你甚至可以打开导出的 Xcode 工程,直接把要引用的文件拖到宿主工程,但这种方式可维护性较差,我建议还是用 framework 形式。3.3 在 AppDelegate 里拉起 Unity 运行时宿主 App 启动时,通过 UnityFramework 的公开接口加载引擎。参考代码如下:#import <UnityFramework/UnityFramework.h> @interface AppDelegate () <UnityFrameworkListener> @property (nonatomic, strong) UnityFramework *unityFramework; @end @implementation AppDelegate - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { self.unityFramework = [UnityFramework load]; [self.unityFramework setDataBundleId:@"com.example.yourhostapp"]; [self.unityFramework runUIApplicationWithOptions:launchOptions]; return YES; }如果你的场景不是以“整个 App 变成 Unity 工程”为目标,而是要把 Unity 的内容嵌到某个页面,那么不要调用runUIApplicationWithOptions里让它接管全部界面的逻辑,改为用unityFramework.appController.unityView取到 Unity 的视图,再添加到你的原生视图层级里。这里的核心要点是:Unity 有自己的窗口和应用生命周期入口,宿主集成时要明确“谁持有主窗口”。如果两个窗口同时都要显示,就会出现旋转不一致、键盘通知错乱、视图层级互相遮挡的问题。3.4 切换和加载 Unity 场景Unity 侧建议做一个专门的入口场景或启动场景,用来接收原生传来的指令,再切换到业务场景。原生侧通过消息机制通知 Unity 加载场景,而不是每次都重启运行时。C# 侧加载场景的代码本身不复杂:using UnityEngine; using UnityEngine.SceneManagement; public class UnityBridgeBehaviour : MonoBehaviour { public void SwitchScene(string sceneName) { SceneManager.LoadScene(sceneName, LoadSceneMode.Single); } }但工程上要注意:Unity 首次启动加载引擎需要时间,如果你在宿主刚启动时立刻切场景,会有明显的黑屏或卡顿。更稳妥的做法是先把 Unity 初始化完成,再根据业务触发加载具体场景,首次加载最好放在一个“正在准备”的过渡动画后。4. 原生与 Unity 双向通信:把消息层做成网关,而不是到处写直连4.1 原生调 Unity:UnitySendMessage原生侧调用 Unity 最直接的方式是UnitySendMessage,Unity 官方导出后在原生代码里可以直接调用:UnitySendMessage("UnityBridge", "OnNativeMessage", "{\"command\":\"openScene\",\"scene\":\"product_3d\"}");这段代码的意思是:往 Unity 场景中名为UnityBridge的游戏对象发送一个字符串消息,C# 端对应方法会收到这个字符串。C# 侧接收方法必须挂在同一个 GameObject 上:public class UnityBridge : MonoBehaviour { public void OnNativeMessage(string payload) { // 解析 payload 并分发 Debug.Log("recevied: " + payload); } }这里有几个很实际的坑:UnitySendMessage的第一个参数对应的 GameObject 必须已经存在于场景中,否则消息会被丢掉;方法名必须是公开方法,且参数类型必须是 string。建议把UnityBridge这个对象放在一个常驻场景里,不要放在会被销毁的临时场景中。4.2 Unity 调原生:C# 定义 extern 方法从 Unity 再调用回原生侧,标准做法是借助 iOS 的静态链接符号,在 C# 里声明 extern 方法:using System.Runtime.InteropServices; public class UnityBridge : MonoBehaviour { [DllImport("__Internal")] private static extern void unityBridgeSendMessage(string payload); public void SendToNative(string payload) { unityBridgeSendMessage(payload); } }原生侧提供一个 C 函数来接收:extern "C" void unityBridgeSendMessage(const char *payload) { NSString *message = [NSString stringWithUTF8String:payload]; // 分发到当前原生控制器 }这里的__Internal是 iOS 静态链接库的特殊名字,表示从 Unity 可执行文件自身找符号。如果集成后报符号找不到,多半是因为宿主工程没有链接 UnityFramework,或者编译选项里缺失相关符号导出。线程问题也要重视。Unity 的渲染和 MonoBehaviour 回调默认在 Unity 主线程,原生侧回调时不一定在同一线程,跨线程处理 UI 更新容易出问题。我建议在 C# 侧接回调后,通过UnityMainThreadDispatcher这类工具投递到主线程,再做实际业务操作。4.3 为什么我建议统一用 JSON 字符串协议很多初学者会在原生和 Unity 之间拆一大堆各不相同的调用:一会儿传字符串,一会儿传数字,一会儿直接调各自的方法。业务一多,两边接口满天飞,调试时根本不知道消息从哪里来。我更建议把它们收拢成一套“命令消息”:全部走透传字符串,字符串内部用 JSON 表达命令名、参数、请求序列号。原生到 Unity 统一走UnitySendMessage,Unity 到原生统一走unityBridgeSendMessage,两边各自维护一个 router,把 JSON 里的 command 映射到内部方法。这样有几个明显好处:新增业务不用增加新的桥接方法;两边可以各自 mock 消息;日志也好打——只要在 router 入口打印 payload,整个通信链路就一目了然。通信时机是比消息格式更隐蔽的问题。Unity 初始化完成之前发消息,消息很可能直接丢失。原生侧最好在收到 Unity 返回的“ready”回调后再开放业务入口。C# 侧可以在Start里主动向原生上报一次初始化完成通知。5. 生命周期协同:iOS 宿主和 Unity 各自有一套闹钟5.1 AppDelegate 事件必须转发给 UnityAppControllerUnity 运行时本身是一个完整的 App 生命周期对象,它也有自己的applicationDidBecomeActive、applicationDidEnterBackground等回调。宿主 App 如果不把这些事件转发给 Unity,就会出现“App 退到后台再回来,Unity 场景卡死”或“来电之后渲染线程崩溃”的诡异问题。常规做法是在宿主 AppDelegate 里补充转发:- (void)applicationDidBecomeActive:(UIApplication *)application { [self.unityFramework.appController applicationDidBecomeActive:application]; } - (void)applicationDidEnterBackground:(UIApplication *)application { [self.unityFramework.appController applicationDidEnterBackground:application]; } - (void)applicationWillResignActive:(UIApplication *)application { [self.unityFramework.appController applicationWillResignActive:application]; }这一套如果不做,你在模拟器上可能永远测不出来,因为模拟器的前后台切换触发方式太温和;真机下一接电话、一锁屏就崩,问题排查半天才定位到是生命周期没有转发。5.2 Unity 视图放哪里:现有 Window 还是独立 Window我见过的三种接入姿势:直接用 Unity 自己的 window 做展示,宿主只负责在合适时机唤起它。把unityView加到宿主的一个 UIViewController 上,适合嵌入局部区域。创建一个独立 UIWindow 用于承载 Unity,方便控制层级,但要处理好 window 的 key 状态。最稳妥的是第二种:把 Unity 视图当成一个普通 UIView 来管理,经过 frame 和布局约束,放在你想放的位置。这样不会和宿主的 tab 栏、导航栏冲突,事件路线也和原生页面一致。需要注意,unityView在 Unity 初始化完成前是空的,你要等待UnityFrameworkListener的回调,拿到确认后再把它加入视图。5.3 旋转、内存警告、键盘事件的协同Unity 场景是实时渲染的,屏幕旋转时原生和 Unity 可能各转各的,最后对不上。最省事的办法是:如果你要嵌入局部视图,锁死 Unity 内部的方向,方向切换由原生层去调整渲染视图的 transform;如果你要让整个 Unity 场景全屏跟随旋转,那就把 Unity 控制器的旋转方法纳入原生响应链。内存警告也是一个容易被忽视的点。iOS 在内存紧张时会广播didReceiveMemoryWarning,Unity 有自己的资源回收机制。宿主应该在原生侧接收到内存警告后,让 Unity 的相应方法也执行一次资源释放,否则低端机上很容易在切换页面时被系统杀掉。6. 接完之后真实会遇到的问题:包体、启动时间、渲染冲突6.1 包体膨胀是物理学,不要指望奇迹Unity 引擎本身加 IL2CPP 编译产物,随便一个最小的空场景打出来也有几十 MB,这还不算你的业务资源和纹理。把一个 Unity Framework 塞进原生 App,包体增长在 30MB 到 100MB 之间都很常见,取决于你用了几套架构、贴图质量、音频压缩、以及是否做了引擎模块裁剪。因素典型影响IL2CPP + 引擎基础库20MB - 40MB架构支持数每多一种架构,体积近乎线性增加场景和资源 Bundle完全取决于业务资源模块裁剪可裁剪掉不用的物理/动画/UI 模块,省出数 MB 到数十 MB如果你对包体敏感,建议从 Unity 构建配置阶段就把不需要的架构去掉,同时压缩音频和纹理。等到接完了才发现包体超标,再回头裁资源会非常痛苦。6.2 启动时间:首帧和可用场景是两回事Unity 初始化到首帧渲染通常需要几百毫秒到一两秒,低端机更久。但“首帧渲染”不意味着场景已经可交互,脚本初始化、资源加载、shader 编译都可能在这个阶段继续卡顿。我会建议在业务场景里分批加载资源,而不是在启动时一次性把所有资源全部加载出来。Unity 场景里的模型和纹理尽量用 Addressable 或 AssetBundle 按需加载,这样能明显减少“点开 3D 页面就白屏三秒”的体验问题。启动时间优化的另外一个思路是预加载:在宿主 App 进入某个关键页面之前,提前把 Unity 初始化和场景切换完成,让用户真正点击时只是“展示已经准备好的画面”。但预加载会占用内存,你需要权衡常驻成本。6.3 实际排查的几类典型问题症状可能原因解决方向启动即 crash,报 dyld 找不到库UnityFramework 没有 Embed & Sign改为 Embed & Sign 并重新签名UnitySendMessage 调用无反应GameObject 不存在或方法签名不符检查场景是否存在常驻 Bridge 对象切后台回来渲染黑屏生命周期事件未转发给 Unity在 AppDelegate 转发前后台切换回调嵌入局部视图时布局错乱未在旋转时同步更新 unityView frame用 Auto Layout 管理 unityView 的 frameMetal 渲染和原生闪烁Unity 视图加入窗口的时机不对等 Unity 初始化完毕后再 addSubview下载包后出现隐私权限弹窗异常缺少对应系统用途声明在 plist 补全相机、相册、麦克风等说明我的建议是:集成完成之前先做一套最小闭环测试,只验证“原生启动 Unity - Unity 渲染一个简单场景 - 原生发送一条消息 - Unity 回调一条消息 - 原生销毁 Unity”。闭环通了,再逐个叠加业务功能,而不是一上来就把重度 3D 场景接入,那样出了问题根本分不清是引擎问题还是业务问题。7. 我最后想强调的一件事如果让我给正准备做 Unity 和原生 iOS 集成的团队一句建议,我会说:不要把 Unity 当成一个“图形库”,而要当成一个独立运行时来敬畏。很多崩溃和卡顿都源于对它生命周期和渲染周期的轻视。你越早把消息协议、生命周期转发、资源加载时机这些机制定清楚,后面越省心。集成这项工作没有太多魔法,核心就是“宿主心态”:原生 App 始终是主人,Unity 是被邀请来的贵宾。贵宾要有自己的房间,要遵守主人的作息,要和主人沟通清楚什么时候上台、什么时候退场。把这层关系理顺,2020+ 的 Unity 集成其实是件很干净的事。
如果只是做纯 Unity 手游,Unity 自己出包就行,根本不涉及原生集成这件事。但 2020 年之后,我接到的咨询越来越集中于同一个问题:“如何把 Unity 集成到原生 iOS 应用里”。典型场景是:团队手里已经有一个成熟的原生 App,几百个页面、完整的用户体系、推送和支付链路,不可能为了一个 3D 功能整体改用 Unity 重写;但业务方又需要高质量的实时 3D 渲染,比如商品预览、家具摆放、AR 试戴或者某个特效模块。于是把 Unity 当作一个渲染组件嵌入原生工程,成了最现实的选择。这篇文章我会把 Unity 2020+ 官方主流的 UnityFramework 集成思路完整拆解一遍,包括工程怎么导、框架怎么引、生命周期怎么对齐、双向消息怎么设计,以及很多常规文档里不会写的实际操作经验。想省时间的原生工程师可以直接按第三、四章的步骤操作;想弄懂原理再动手的,建议从头看。1. UnityFramework 解决了什么问题:先理解 2020 年前后的路径差异1.1 老路子为什么撑不住先说一段历史。早期把 Unity 集成到原生 iOS 应用,基本只能从 Unity 导出的 Xcode 工程下手:你先把 Unity 项目构建成一个完整 iOS App,然后把自己已有的原生代码往这个工程里“逆向嫁接”。听起来可行,实际上非常难受——Unity 生成的工程结构每次版本升级都可能变化,你的原生代码和 Unity 的 AppDelegate 纠缠在一起,两个团队改同一份 Xcode 工程,冲突记录能刷满半个屏幕。一旦 Unity 小版本更新,AppDelegate 里的生命周期方法又变了,整个集成代码都要跟着返工。这种方式最核心的问题在于:宿主关系反了。理论上应该是原生 App 作为宿主,Unity 作为被嵌入的引擎;老路子里却是 Unity 的 App 工程当壳,原生代码变成“寄居蟹”。团队边界不清晰,版本升级像在火堆上跳舞。1.2 UnityFramework 的产物结构2020 年前后,Unity 把官方推荐路径收敛成“Unity as Library”思路,落到 iOS 上就是 UnityFramework。你从 Unity 构建 iOS 工程时,导出的 Xcode 工程里会同时存在几个 target:一个是真正打包成 App 的工程,另一个就是 UnityFramework.framework。UnityFramework 这个框架把引擎运行时、IL2CPP 编译出来的 C# 代码、托管程序集、资源 bundle,甚至场景数据,全部打成一个可独立加载的动态/静态框架。宿主 App 不需要继承 Unity 的 APP 生命周期入口,而是通过一个公开头文件去实例化、启动、暂停和退出 Unity 运行时。关键部分作用UnityFramework.framework引擎本体与脚本编译产物,宿主工程的依赖核心UnityFramework.h公开接口,负责加载、启动、数据目录设置appController 对象管理 Unity 的 App 生命周期和视图,宿主可以拿它的 view 来展示Data.bundle / 场景资源Unity 的资源包,框架加载时按 bundleId 找到对应数据1.3 一句话概括集成模式现在的思路完全可以压缩成一句话:原生 App 是老大,Unity 是被拉起来的渲染引擎。页面跳转、用户授权、支付、推送这些走原生;Unity 只负责进入某个场景、渲染某些内容、把渲染结果通过视图交给原生展示,再通过消息接口完成数据交换。这个模式带来的团队分工非常清晰:Unity 工程专注做 3D 场景和表现层,原生工程专注做 App 骨架和业务逻辑。两边只通过一套消息协议沟通,不用再互相侵入工程结构。2. 动手前先用三个问题做选型,比到处找集成脚本更重要很多团队拿到集成文档就开始拖 framework,结果业务形态根本不匹配,后期又要改架构。我在实际项目里总结了三个前置问题,建议开工前先回答清楚。2.1 问题一:Unity 场景要长期驻留,还是按需拉起有些需求是 Unity 场景作为 App 的一个“长期模块”,比如整个首页都是 Unity 渲染;有些需求则是“用户点开商品详情才临时加载 3D 预览,退出后释放”。这两种情况的技术方案完全不同。长期驻留意味着 Unity 运行时全程活着,内存占用是持续的,原生和 Unity 的渲染视图会长期叠加;按需拉起则要考虑场景加载时间、资源释放、以及多次启动退出的稳定性。如果只是局部功能,我很建议按需初始化,不要让 Unity 常驻。2.2 问题二:渲染区域是“全屏替换”还是“嵌视图”全屏替换的情况比较接近 Unity 自己出包的体验,旋转、刘海屏适配都可以交给 Unity 处理;嵌视图则更像把一个 UnityView 塞进原生 UIViewController 的某个 region,此时你不仅要处理渲染视图的 frame,还要处理事件分发和不同屏幕方向下的布局变化,复杂度上升一个台阶。我见过一个项目把 Unity 渲染结果放在一个列表页的 cell 里,初衷是“滚动时能看到实时 3D 预览”,实际操作中发现问题非常多:Metal 渲染层和列表滚动时的 frame 变化冲突、cell 复用后的通知错乱、内存暴涨。最后不得不改成“点击后进入全屏 Unity 场景”的方式才稳定下来。2.3 问题三:原生团队和 Unity 团队的协作边界这个最容易被忽略。Unity 集成不是纯技术问题,而是跨团队协作问题。两边需要共同商定:消息协议谁来定、Unity 场景的进出时机谁负责触发、资源由谁发布、崩溃日志怎么统一归口。如果没有清晰边界,最后很容易变成“Unity 崩溃怪原生,原生卡顿怪 Unity”。技术方向全 Unity App原生壳 + UnityFramework原生重写所有功能3D 渲染能力高高低原生页面体验依赖 Unity 实现原生完整保留原生完整保留团队改动成本Unity 团队接手所有两边并行开发重新开发包体/内存控制较难可控制最可控推荐场景游戏/重度 3D原生 App 加 3D 模块纯 2D/轻业务这里没有绝对最优,只有适合不适合。如果你的 App 本质是内容型产品,3D 只是其中一个特色功能,原生壳加 UnityFramework 通常是最平衡的方案。3. 落地步骤:从 Unity 导出一个干净 Framework,再接入现有工程3.1 Unity 导出前的构建参数设置在 Unity Editor 里打开 Build Settings,把目标平台切换到 iOS。导出前有几项设置直接决定集成成败。第一是 Bundle Identifier,尽量和宿主 App 保持一致或使用同一个团队 ID,否则加载资源时会遇到数据目录定位错误。第二是 Scripting Backend 选择 IL2CPP;Unity 2020 之后这基本是 iOS 的默认选项,它能提供更完整的 AOT 支持,也方便最终打包时做裁剪。第三是 Graphics API,保持 Metal;Unity 2019 之后 iOS 上 Metal 是默认渲染 API,旧项目可能遗留 OpenGL ES,嵌入现代宿主时应该清理掉。还有几个容易被忽略的选项。建议在 Player Settings 中清理掉不需要的模块,比如完全没有使用的增强现实组件、物理引擎模块,如果有裁剪入口,导出框架时能省不少体积。Unity 2020 之后构建出来的 Xcode 工程可以保留原始工程结构,不要勾选自动打开 Xcode,避免开发机上的环境干扰。3.2 把 UnityFramework 放进宿主 Xcode 工程Unity 构建完成之后,会在导出目录生成一个 Xcode 工程。打开这个工程,Products 目录下能找到 UnityFramework.framework,直接把它拷贝到你的宿主工程目录下。然后在宿主的 Xcode 工程中做三件事:把 UnityFramework.framework 拖入 TARGETS 的 Frameworks 分组下,在 Embed 属性里选择 “Embed & Sign”,而不是 “Do Not Embed”。很多崩溃就是这里选错导致的:只在编译时链接了框架,运行时不把框架打进 App,启动时直接找不到动态库。在 Build Settings 里找到 Other Linker Flags,需要时添加-ObjC,保证 Unity 的原生符号能正确解析。检查宿主 App 的签名设置,UnityFramework 的签名要和你主 App 的证书一致。真机调试时最容易遇到签名不一致导致加载失败。需要说明的是,Unity 导出的 UnityFramework 在不同版本里包含的依赖稍有差异,以你自己导出的工程为准。你甚至可以打开导出的 Xcode 工程,直接把要引用的文件拖到宿主工程,但这种方式可维护性较差,我建议还是用 framework 形式。3.3 在 AppDelegate 里拉起 Unity 运行时宿主 App 启动时,通过 UnityFramework 的公开接口加载引擎。参考代码如下:#import <UnityFramework/UnityFramework.h> @interface AppDelegate () <UnityFrameworkListener> @property (nonatomic, strong) UnityFramework *unityFramework; @end @implementation AppDelegate - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { self.unityFramework = [UnityFramework load]; [self.unityFramework setDataBundleId:@"com.example.yourhostapp"]; [self.unityFramework runUIApplicationWithOptions:launchOptions]; return YES; }如果你的场景不是以“整个 App 变成 Unity 工程”为目标,而是要把 Unity 的内容嵌到某个页面,那么不要调用runUIApplicationWithOptions里让它接管全部界面的逻辑,改为用unityFramework.appController.unityView取到 Unity 的视图,再添加到你的原生视图层级里。这里的核心要点是:Unity 有自己的窗口和应用生命周期入口,宿主集成时要明确“谁持有主窗口”。如果两个窗口同时都要显示,就会出现旋转不一致、键盘通知错乱、视图层级互相遮挡的问题。3.4 切换和加载 Unity 场景Unity 侧建议做一个专门的入口场景或启动场景,用来接收原生传来的指令,再切换到业务场景。原生侧通过消息机制通知 Unity 加载场景,而不是每次都重启运行时。C# 侧加载场景的代码本身不复杂:using UnityEngine; using UnityEngine.SceneManagement; public class UnityBridgeBehaviour : MonoBehaviour { public void SwitchScene(string sceneName) { SceneManager.LoadScene(sceneName, LoadSceneMode.Single); } }但工程上要注意:Unity 首次启动加载引擎需要时间,如果你在宿主刚启动时立刻切场景,会有明显的黑屏或卡顿。更稳妥的做法是先把 Unity 初始化完成,再根据业务触发加载具体场景,首次加载最好放在一个“正在准备”的过渡动画后。4. 原生与 Unity 双向通信:把消息层做成网关,而不是到处写直连4.1 原生调 Unity:UnitySendMessage原生侧调用 Unity 最直接的方式是UnitySendMessage,Unity 官方导出后在原生代码里可以直接调用:UnitySendMessage("UnityBridge", "OnNativeMessage", "{\"command\":\"openScene\",\"scene\":\"product_3d\"}");这段代码的意思是:往 Unity 场景中名为UnityBridge的游戏对象发送一个字符串消息,C# 端对应方法会收到这个字符串。C# 侧接收方法必须挂在同一个 GameObject 上:public class UnityBridge : MonoBehaviour { public void OnNativeMessage(string payload) { // 解析 payload 并分发 Debug.Log("recevied: " + payload); } }这里有几个很实际的坑:UnitySendMessage的第一个参数对应的 GameObject 必须已经存在于场景中,否则消息会被丢掉;方法名必须是公开方法,且参数类型必须是 string。建议把UnityBridge这个对象放在一个常驻场景里,不要放在会被销毁的临时场景中。4.2 Unity 调原生:C# 定义 extern 方法从 Unity 再调用回原生侧,标准做法是借助 iOS 的静态链接符号,在 C# 里声明 extern 方法:using System.Runtime.InteropServices; public class UnityBridge : MonoBehaviour { [DllImport("__Internal")] private static extern void unityBridgeSendMessage(string payload); public void SendToNative(string payload) { unityBridgeSendMessage(payload); } }原生侧提供一个 C 函数来接收:extern "C" void unityBridgeSendMessage(const char *payload) { NSString *message = [NSString stringWithUTF8String:payload]; // 分发到当前原生控制器 }这里的__Internal是 iOS 静态链接库的特殊名字,表示从 Unity 可执行文件自身找符号。如果集成后报符号找不到,多半是因为宿主工程没有链接 UnityFramework,或者编译选项里缺失相关符号导出。线程问题也要重视。Unity 的渲染和 MonoBehaviour 回调默认在 Unity 主线程,原生侧回调时不一定在同一线程,跨线程处理 UI 更新容易出问题。我建议在 C# 侧接回调后,通过UnityMainThreadDispatcher这类工具投递到主线程,再做实际业务操作。4.3 为什么我建议统一用 JSON 字符串协议很多初学者会在原生和 Unity 之间拆一大堆各不相同的调用:一会儿传字符串,一会儿传数字,一会儿直接调各自的方法。业务一多,两边接口满天飞,调试时根本不知道消息从哪里来。我更建议把它们收拢成一套“命令消息”:全部走透传字符串,字符串内部用 JSON 表达命令名、参数、请求序列号。原生到 Unity 统一走UnitySendMessage,Unity 到原生统一走unityBridgeSendMessage,两边各自维护一个 router,把 JSON 里的 command 映射到内部方法。这样有几个明显好处:新增业务不用增加新的桥接方法;两边可以各自 mock 消息;日志也好打——只要在 router 入口打印 payload,整个通信链路就一目了然。通信时机是比消息格式更隐蔽的问题。Unity 初始化完成之前发消息,消息很可能直接丢失。原生侧最好在收到 Unity 返回的“ready”回调后再开放业务入口。C# 侧可以在Start里主动向原生上报一次初始化完成通知。5. 生命周期协同:iOS 宿主和 Unity 各自有一套闹钟5.1 AppDelegate 事件必须转发给 UnityAppControllerUnity 运行时本身是一个完整的 App 生命周期对象,它也有自己的applicationDidBecomeActive、applicationDidEnterBackground等回调。宿主 App 如果不把这些事件转发给 Unity,就会出现“App 退到后台再回来,Unity 场景卡死”或“来电之后渲染线程崩溃”的诡异问题。常规做法是在宿主 AppDelegate 里补充转发:- (void)applicationDidBecomeActive:(UIApplication *)application { [self.unityFramework.appController applicationDidBecomeActive:application]; } - (void)applicationDidEnterBackground:(UIApplication *)application { [self.unityFramework.appController applicationDidEnterBackground:application]; } - (void)applicationWillResignActive:(UIApplication *)application { [self.unityFramework.appController applicationWillResignActive:application]; }这一套如果不做,你在模拟器上可能永远测不出来,因为模拟器的前后台切换触发方式太温和;真机下一接电话、一锁屏就崩,问题排查半天才定位到是生命周期没有转发。5.2 Unity 视图放哪里:现有 Window 还是独立 Window我见过的三种接入姿势:直接用 Unity 自己的 window 做展示,宿主只负责在合适时机唤起它。把unityView加到宿主的一个 UIViewController 上,适合嵌入局部区域。创建一个独立 UIWindow 用于承载 Unity,方便控制层级,但要处理好 window 的 key 状态。最稳妥的是第二种:把 Unity 视图当成一个普通 UIView 来管理,经过 frame 和布局约束,放在你想放的位置。这样不会和宿主的 tab 栏、导航栏冲突,事件路线也和原生页面一致。需要注意,unityView在 Unity 初始化完成前是空的,你要等待UnityFrameworkListener的回调,拿到确认后再把它加入视图。5.3 旋转、内存警告、键盘事件的协同Unity 场景是实时渲染的,屏幕旋转时原生和 Unity 可能各转各的,最后对不上。最省事的办法是:如果你要嵌入局部视图,锁死 Unity 内部的方向,方向切换由原生层去调整渲染视图的 transform;如果你要让整个 Unity 场景全屏跟随旋转,那就把 Unity 控制器的旋转方法纳入原生响应链。内存警告也是一个容易被忽视的点。iOS 在内存紧张时会广播didReceiveMemoryWarning,Unity 有自己的资源回收机制。宿主应该在原生侧接收到内存警告后,让 Unity 的相应方法也执行一次资源释放,否则低端机上很容易在切换页面时被系统杀掉。6. 接完之后真实会遇到的问题:包体、启动时间、渲染冲突6.1 包体膨胀是物理学,不要指望奇迹Unity 引擎本身加 IL2CPP 编译产物,随便一个最小的空场景打出来也有几十 MB,这还不算你的业务资源和纹理。把一个 Unity Framework 塞进原生 App,包体增长在 30MB 到 100MB 之间都很常见,取决于你用了几套架构、贴图质量、音频压缩、以及是否做了引擎模块裁剪。因素典型影响IL2CPP + 引擎基础库20MB - 40MB架构支持数每多一种架构,体积近乎线性增加场景和资源 Bundle完全取决于业务资源模块裁剪可裁剪掉不用的物理/动画/UI 模块,省出数 MB 到数十 MB如果你对包体敏感,建议从 Unity 构建配置阶段就把不需要的架构去掉,同时压缩音频和纹理。等到接完了才发现包体超标,再回头裁资源会非常痛苦。6.2 启动时间:首帧和可用场景是两回事Unity 初始化到首帧渲染通常需要几百毫秒到一两秒,低端机更久。但“首帧渲染”不意味着场景已经可交互,脚本初始化、资源加载、shader 编译都可能在这个阶段继续卡顿。我会建议在业务场景里分批加载资源,而不是在启动时一次性把所有资源全部加载出来。Unity 场景里的模型和纹理尽量用 Addressable 或 AssetBundle 按需加载,这样能明显减少“点开 3D 页面就白屏三秒”的体验问题。启动时间优化的另外一个思路是预加载:在宿主 App 进入某个关键页面之前,提前把 Unity 初始化和场景切换完成,让用户真正点击时只是“展示已经准备好的画面”。但预加载会占用内存,你需要权衡常驻成本。6.3 实际排查的几类典型问题症状可能原因解决方向启动即 crash,报 dyld 找不到库UnityFramework 没有 Embed & Sign改为 Embed & Sign 并重新签名UnitySendMessage 调用无反应GameObject 不存在或方法签名不符检查场景是否存在常驻 Bridge 对象切后台回来渲染黑屏生命周期事件未转发给 Unity在 AppDelegate 转发前后台切换回调嵌入局部视图时布局错乱未在旋转时同步更新 unityView frame用 Auto Layout 管理 unityView 的 frameMetal 渲染和原生闪烁Unity 视图加入窗口的时机不对等 Unity 初始化完毕后再 addSubview下载包后出现隐私权限弹窗异常缺少对应系统用途声明在 plist 补全相机、相册、麦克风等说明我的建议是:集成完成之前先做一套最小闭环测试,只验证“原生启动 Unity - Unity 渲染一个简单场景 - 原生发送一条消息 - Unity 回调一条消息 - 原生销毁 Unity”。闭环通了,再逐个叠加业务功能,而不是一上来就把重度 3D 场景接入,那样出了问题根本分不清是引擎问题还是业务问题。7. 我最后想强调的一件事如果让我给正准备做 Unity 和原生 iOS 集成的团队一句建议,我会说:不要把 Unity 当成一个“图形库”,而要当成一个独立运行时来敬畏。很多崩溃和卡顿都源于对它生命周期和渲染周期的轻视。你越早把消息协议、生命周期转发、资源加载时机这些机制定清楚,后面越省心。集成这项工作没有太多魔法,核心就是“宿主心态”:原生 App 始终是主人,Unity 是被邀请来的贵宾。贵宾要有自己的房间,要遵守主人的作息,要和主人沟通清楚什么时候上台、什么时候退场。把这层关系理顺,2020+ 的 Unity 集成其实是件很干净的事。