做过移动游戏项目的人应该都遇过这种需求Unity 游戏里要弹一个系统级对话框、要读取设备电量、要申请相册权限、要接第三方登录和支付 SDK……Unity 自带的能力在移动端始终是有限的一层壳很多系统级功能必须依靠 iOS 原生层才能触达。这篇文章我会把 Unity 与 iOS 之间的交互链路完整拆开来看怎么让 Unity 主动调用 Objective-C 方法怎么让原生层把结果安全回传给 Unity以及真实项目里最容易踩到的那些坑。我不打算只贴两段 DllImport 和 UnitySendMessage 的 demo 就完事这类资料网上太多但普遍太浅。真正放到业务里跑一圈你会遇到线程问题、内存回收问题、静默丢消息问题、iOS 新版本 API 变化问题。这篇文章会尽量按“能直接抄、抄完能跑”的标准来写也是我最近在两个模拟项目里反复验证过的思路。1. 为什么 Unity 和 iOS 之间需要一座桥1.1 什么场景下你会用到原生交互很多刚接触 Unity 移动开发的人会疑惑Unity 自己不是已经能获取设备信息、能弹窗、能读系统时间了吗为什么还要费劲去写原生代码因为 Unity 跨平台引擎的定位决定了它的 API 必须取最大公约数。它不会追着 iOS 每一个系统能力去适配比如设备当前精确电量、系统存储空间、电池充电状态、网络制式、系统语言地区、原生 UIAlertController 弹窗、推送通知注册回调、第三方账号登录结果——这些在 Unity 内置 API 里要么拿不到要么拿到的数据不够细要么回调时机和你需要的不一致。我在模拟项目里遇到过最典型的需求是这样游戏启动时要申请“通知权限”和“相册权限”同时需要读取设备剩余存储空间来决定是否开启高清资源下载。这些能力 Unity 本身没有现成接口只能通过原生层去拿。拿了以后原生告诉 Unity“权限已授权”或者“存储空间只剩 2.3G”Unity 再根据结果去切换后续逻辑。另一个更常见的是接第三方 SDK。绝大多数登录、支付、广告类 SDK 只提供 iOS 原生接口Unity 官方没有封装你不写桥接层功能就上线不了。所以说这不是“会不会用到”的问题而是“迟早要用到”尤其是做中重度游戏、做混合开发内容、做快速迭代上线需求的时候。1.2 双向通信的本质调用与回调Unity 与 iOS 的交互本质上只有两个方向的动作理解清楚这两个方向后面写代码就不会乱。第一个方向是 Unity 主动调原生。C# 通过[DllImport(__Internal)]声明一个外部方法底层由 IL2CPP 把调用转发给原生编译出来的符号。这个形式看起来有点像 C# 调用系统动态库但在 iOS 上它不是动态加载而是直接静态链接到最终二进制。第二个方向是原生主动找 Unity。原生代码在处理完某一件事之后通过UnitySendMessage(GameObjectName, MethodName, message)往 Unity 场景里的某个 GameObject 发送一条消息。Unity 收到后自动调用挂在该对象上的组件方法。你可以把所有复杂交互都拆成这两个基本动作的组合。比如“Unity 请求原生显示一个系统弹窗用户点击确定后Unity 接着执行下一步”这就是 Unity 调用原生弹窗函数然后原生在用户点击按钮之后回调 Unity两个方向各用一次。还有一个容易忽略的第四层原生执行任务可能跨线程。比如网络请求回调、后台队列里的数据处理这些线程不是 Unity 主线程。所以回调时还要考虑线程调度不能直接在随机线程里操作 Unity 的对象。1.3 方案对比Unity 原生接口 vs. 自建桥接层有些场景其实不需要自己写原生代码。Unity 提供了诸如Application.RequestUserAuthorization、SystemInfo等跨平台接口能覆盖到最基础的一部分需求。但判断标准很简单当你要的信息不够、回调不够、或者根本没有对应 API 时就该上自建桥接层。自建桥接层也有两种做法。一种是把所有原生代码写在Assets/Plugins/iOS目录下由 Unity 在生成 Xcode 工程时自动并入原生项目。另一种是做 Frameworks 动态库或静态库再由 Unity 侧配置 Link 和 Embed。我推荐第一种理由很实际快速迭代期你大概率经常需要改原生代码直接放Plugins/iOS里改完回 Unity 重新出包即可少碰 Xcode 工程配置。要注意自建桥接层从来不是“把代码写完就完了”。你还要考虑编辑器模拟、异常日志、原生回调的 GameObejct 是否存在等问题。这些我在后面几节都会展开。2. 开工前的四项准备工程配置与代码骨架2.1 插件目录与脚本后端Unity 工程里有一个约定目录叫Assets/Plugins/iOS。这个目录下的.h、.m、.mm、.c、.cpp文件会在构建 iOS 工程的时候被自动拷贝并参与编译。所以原生代码我习惯直接放在这个目录里。脚本后端必须在 Player Settings 里选择 IL2CPP。早期 Unity 版本还能选 Mono 后端跑 iOS现在已经不现实IL2CPP 几乎是唯一可用方案。IL2CPP 会把 C# 代码转换成 C 再编译成原生机器码这个转换过程也就决定了__Internal这种静态链接的调用方式能正常工作。文件命名也有一点讲究。如果原生代码涉及 Objective-C 和 C 混写用.mm后缀如果只写 Objective-C 就用.m。我一般统一用.mm因为需要写extern C时不需要纠结编译器的链接处理。2.2 条件编译真正的 iOS 与编辑器模拟这一步是整个交互方案里我最看重的一环。你写代码的时候大多数时间在 Unity 编辑器里调试而编辑器跑在 macOS 或 Windows 上不存在__Internal这种 iOS 静态链接符号。如果直接调用编辑器里立刻会报 EntryPointNotFoundException 或者直接崩溃。所以标准的做法是条件编译#if UNITY_IOS !UNITY_EDITOR [DllImport(__Internal)] private static extern void _showAlert(string title, string message); #endif只在真机 iOS 环境里声明和调用原生函数。同时提供一个编辑器模拟分支让同样的业务代码能在编辑器里跑通。模拟分支并不是可有可无的它直接影响开发效率。没有模拟分支每次想验证交互逻辑你都需要打包到真机一个来回至少二十几分钟迭代效率非常低。我通常在编辑器模拟分支里把返回值、回调事件都伪造一遍打上明显日志这样业务逻辑开发完全不用依赖真机。真机上如果发现和模拟结果不一致再回到原生层排查。2.3 extern C 导出规则与命名约定原生这边的方法需要以 C 符号导出的方式暴露给 IL2CPP。Objective-C 方法本身是面向对象的消息机制C# 的 DllImport 无法直接定位所以必须包一层 C 函数。用.mm文件时外面用extern C { }包住即可。这里有个命名约定值得注意。我习惯在导出函数前加一个下划线前缀比如_showAlert、_requestBatteryLevel。这不是硬性要求但它能明显区分“原生导出函数”和“普通 C/ObjC 函数”代码里扫一眼就清晰。C# 侧声明的外部方法名保持和原生完全一致因为 IL2CPP 是按符号名去链接的。命名不一致是构建期最常见的坑之一。你 C# 里写_showAlerts原生里写_showAlertXcode 构建时直接报 Undefined Symbol而且这个报错还会出现在比较晚的链接阶段排查起来有点费时间。2.4 生命周期管理保证 Bridge 永远存在原生回调UnitySendMessage必须指定一个场景中存在的 GameObject 名字。如果这个名字对应的物体不存在Unity 会静默忽略这条消息你什么都看不到就像什么都没发生。我见过很多新项目都栽在这里原生回调发到某个临时场景里的对象上场景一切换对象被销毁后续回调全部丢失。或者更隐蔽的同一个 GameObject 名在多个场景里都存在但挂载的组件方法不一样回调行为诡异。我的解决方式是用一个不依赖任何场景的常驻单例 Bridge。它由RuntimeInitializeOnLoadMethod在游戏启动阶段自动创建并调用DontDestroyOnLoad确保整个生命周期内都存在。场景切换不影响消息接收。C# 侧还要在回调方法里加上防御性日志至少把收到的原始字符串打出来方便排查。[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] private static void Bootstrap() { _ IOSBridge.Instance; }这样 Unity 一启动Bridge 就会准备好原生任何时刻回调过来都有人接。3. Unity 主动调用 iOSDllImport 的实际用法3.1 从一个最简单的弹窗开始先用最简单的系统弹窗把链路打通。C# 侧写这样一个声明public class IOSBridge : MonoBehaviour { public static IOSBridge Instance { get; private set; } #if UNITY_IOS !UNITY_EDITOR [DllImport(__Internal)] private static extern void _showAlert(string title, string message); #endif private void Awake() { if (Instance ! null Instance ! this) { Destroy(gameObject); return; } Instance this; DontDestroyOnLoad(gameObject); } public void ShowAlert(string title, string message) { #if UNITY_IOS !UNITY_EDITOR _showAlert(title, message); #else Debug.Log($[编辑器模拟] 弹窗 title{title} message{message}); #endif } }原生这边放到Assets/Plugins/iOS/IOSBridge.mm#import UIKit/UIKit.h #import Foundation/Foundation.h extern C { void _showAlert(const char* title, const char* message) { NSString *nsTitle title ? [NSString stringWithUTF8String:title] : ; NSString *nsMessage message ? [NSString stringWithUTF8String:message] : ; dispatch_async(dispatch_get_main_queue(), ^{ UIAlertController *alert [UIAlertController alertControllerWithTitle:nsTitle message:nsMessage preferredStyle:UIAlertControllerStyleAlert]; UIAlertAction *ok [UIAlertAction actionWithTitle:确定 style:UIAlertActionStyleDefault handler:nil]; [alert addAction:ok]; UIViewController *rootVC GetRootViewController(); [rootVC presentViewController:alert animated:YES completion:nil]; }); } }这段代码里有三个细节不写清楚很容易出问题。第一C# 传入的字符串是 UTF-8所以原生必须用stringWithUTF8String转成 NSString千万别自己想当然用别的方式转。第二const char*可能为空原生侧要先判空再转换否则遇到参数为空直接崩。第三所有 UIKit 操作必须在主线程执行所以外面包了一层dispatch_async(dispatch_get_main_queue())。即便 C# 调用本身发生在 Unity 主线程原生函数执行时也不保证当前所在线程就是主线程多包这一层最稳妥。3.2 参数传递到底发生了什么很多人第一次看到string和const char*对应会觉得奇怪其实理解 IL2CPP 的编组逻辑就明白了。当 C# 侧声明private static extern void _showAlert(string title, string message)时IL2CPP 在为这次调用生成原生侧转发代码时会把 C# 的字符串转换成一段 UTF-8 编码的临时内存然后把这内存地址当作const char*传给原生函数。原生函数执行完毕返回后这段临时内存由 Unity 负责释放。所以 C# 传字符串进去你不需要在原生侧手动释放这个参数指针。反之如果原生函数需要返回字符串给 C#代码里写的是string返回值那么返回值的内存就完全不一样了这是下一节的重点。除了字符串C# 的int对应原生intfloat对应floatbool对应bool。这些简单类型封送开销很小传参会比字符串更省事。我建议原生接口的参数尽量用简单类型加字符串少用自定义结构体结构体封送在 iOS 上踩过坑的人都知道有多难受。复杂数据统一走 JSON见后文。3.3 返回值可以吗内存问题先想清楚很多读者肯定会问能不能直接让原生返回一个字符串或 JSON 给 Unity比如private static extern string _getDeviceName();。答案是能但要小心内存。IL2CPP 封送返回字符串时会把原生返回的char*拷贝成 C# 的 string然后不负责释放原生返回的这块内存。如果你用strdup、malloc或者返回一个动态创建的 NSString 的 UTF8String内存就泄漏了。每次调用泄漏一点次数多了 App 内存会涨得很难看。我的建议是两种方案二选一。第一种返回复杂或动态数据时不要用返回值改用回调原生执行完通过UnitySendMessage把 JSON 传给 C#。第二种如果确实需要一个同步返回值就在原生侧使用一个静态缓冲区比如static char buffer[1024]把结果写入缓冲区再返回这样不存在每次调用重复分配的问题但要注意缓冲区大小上限。实际业务里同步返回值的需求很少大部分都是异步状态。所以下面的核心模式我都以回调为准。4. iOS 主动回调 UnityUnitySendMessage 的正确打开方式4.1 回调目标与函数签名约定原生回调 Unity 用的接口是UnitySendMessage(const char* gameObjectName, const char* methodName, const char* message)。它做的事能简单理解成在场景里找到gameObjectName对应的 GameObject然后调用它身上所有 MonoBehaviour 里名为methodName的 public 方法并把message当作字符串参数传过去。这里有几个硬性条件容易踩坑。第一个GameObject 必须真实存在于当前场景而且名字完全一致。第二个被调用的方法必须是public。第三个方法的参数必须是一个string即便你本质上想传的是 int 或 float也得先转成字符串。第四个方法名区分大小写拼错了不会报错只会静默丢消息。我在实际项目里把这些要求固化成了一个回调协议GameObject 固定叫IOSBridge方法全部挂在IOSBridge这个组件上命名统一以OnNativeXxxReceived开头。原生代码只认这一套统一命名业务逻辑不要直接暴露给原生层。4.2 为什么一定要回主线程这是原生回调最常见的坑没有之一。很多原生操作完结在非主线程。比如网络请求的回调、某个系统框架的后台处理、GCD 全局队列里跑的任务。如果你在后台线程直接调用UnitySendMessage轻则消息丢失重则崩溃。Unity 自身虽然内部可能有一定保护但跨线程访问 Unity 运行时对象本身就是未定义行为。安全写法非常简单把UnitySendMessage放到主线程队列里执行。dispatch_async(dispatch_get_main_queue(), ^{ UnitySendMessage(IOSBridge, OnDeviceInfoReceived, [jsonString UTF8String]); });dispatch_get_main_queue()返回的就是 iOS 的主线程队列。把 Unity 回调包在这个 block 里能保证 Unity 收到消息时一定在正确线程上也可以放心让原生内部自由使用后台线程做耗时操作。这条规则我建议直接定为项目规范只要遇到回调无脑先套主线程。4.3 完整示例弹窗按钮回传 Unity回到 3.1 的弹窗例子。这次我们把“确定”和“取消”两个按钮的点击结果回传给 Unity。C# 侧增加一个接收方法public void OnNativeAlertDismissed(string message) { Debug.Log($[IOSBridge] 原生弹窗回调: {message}); // message 里可以带按钮索引、业务标识等 }原生侧在按钮 handler 里发送消息UIAlertAction *ok [UIAlertAction actionWithTitle:确定 style:UIAlertActionStyleDefault handler:^(UIAlertAction *action) { dispatch_async(dispatch_get_main_queue(), ^{ UnitySendMessage(IOSBridge, OnNativeAlertDismissed, 0); }); }]; UIAlertAction *cancel [UIAlertAction actionWithTitle:取消 style:UIAlertActionStyleCancel handler:^(UIAlertAction *action) { dispatch_async(dispatch_get_main_queue(), ^{ UnitySendMessage(IOSBridge, OnNativeAlertDismissed, 1); }); }];这里“0”代表确定“1”代表取消。短字符串传输比较直观但如果回调数据复杂就要用 JSON 了。这套链路是非常标准的“Unity 调用原生 → 原生异步等待用户操作 → 用户操作完成 → 原生回调 Unity”。游戏里的权限申请、协议弹窗、外部登录跳转本质上都是这个链路。5. 复杂数据交换JSON 协议设计与请求应答模型5.1 为什么统一用 JSON如果回调只传一个 int 或者一个短字符串UnitySendMessage 直接传字符串就够了。但真实业务往往是“电池电量、充电状态、设备型号、系统版本、剩余内存”一堆字段一起返回这时候就要用到结构化的数据格式。JSON 是最自然的选择。原生端 iOS 的NSJSONSerialization可以直接把 NSDictionary 转成 JSON 字符串Unity 端可以用JsonUtility或第三方库解析。整个链路都是字符串不涉及复杂的内存封送是最稳妥的跨语言数据方案。Unity 自带的JsonUtility有一个限制它不能直接解析顶层的 Dictionary 或者数组它只能解析带[Serializable]标记的类。所以 JSON 结构要和 C# 类一一对应。如果字段特别多或者结构很动态我建议换用 Newtonsoft Json 等第三方库。不过简单场景我直接用 JsonUtility少引入依赖。5.2 自定义协议请求 ID 事件派发原生回调多了以后你会发现一个问题Unity 同时发起多个异步请求原生完成顺序不一定回调回来后你怎么知道这是哪一次请求的结果我习惯在每个原生请求里带上一个自增的 requestId。C# 请求时传 int原生处理完把 requestId 原样放进 JSON 返回。这样解析回调的时候就能对应回发起方。private static int _requestId; public void RequestBatteryInfo() { _requestId; #if UNITY_IOS !UNITY_EDITOR _requestBatteryLevel(_requestId); #else OnBatteryLevelReceived(${{\requestId\:{_requestId},\level\:0.6,\state\:2}}); #endif }原生侧在读取完电量后组装 JSONvoid _requestBatteryLevel(int requestId) { [[UIDevice currentDevice] setBatteryMonitoringEnabled:YES]; float level [[UIDevice currentDevice] batteryLevel]; int state [[UIDevice currentDevice] batteryState]; NSDictionary *info { requestId : (requestId), level : (level), state : (state) }; NSError *error nil; NSData *jsonData [NSJSONSerialization dataWithJSONObject:info options:0 error:error]; if (jsonData) { NSString *jsonString [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding]; dispatch_async(dispatch_get_main_queue(), ^{ UnitySendMessage(IOSBridge, OnBatteryLevelReceived, [jsonString UTF8String]); }); } }C# 侧接收[Serializable] public class BatteryInfo { public int requestId; public float level; public int state; } public void OnBatteryLevelReceived(string json) { BatteryInfo info JsonUtility.FromJsonBatteryInfo(json); Debug.Log($[IOSBridge] 电量 requestId{info.requestId}, level{info.level}, state{info.state}); }这里要特别提一句batteryLevel的取值范围是 0.0 到 1.0而且初次开启batteryMonitoringEnabled后读取可能需要一小段时间才能得到真实值。如果是真机测试发现一直返回 -1检查一下是不是刚开启监控就立刻读系统还没有准备好。5.3 获取设备信息的完整示例把多个字段合并成 JSON 返回非常适合设备信息读取这类需求。原生侧一次性组装好系统、版本、型号、内存等字段Unity 侧一解析就全部拿到。void _getDeviceInfo() { NSMutableDictionary *info [NSMutableDictionary dictionary]; NSString *system [[UIDevice currentDevice] systemName]; NSString *version [[UIDevice currentDevice] systemVersion]; NSString *model [[UIDevice currentDevice] model]; [info setObject:system forKey:system]; [info setObject:version forKey:version]; [info setObject:model forKey:model]; NSProcessInfo *processInfo [NSProcessInfo processInfo]; [info setObject:([processInfo physicalMemory]) forKey:memory]; NSError *error nil; NSData *jsonData [NSJSONSerialization dataWithJSONObject:info options:0 error:error]; if (jsonData) { NSString *jsonString [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding]; dispatch_async(dispatch_get_main_queue(), ^{ UnitySendMessage(IOSBridge, OnDeviceInfoReceived, [jsonString UTF8String]); }); } }C# 侧对应定义[Serializable] public class DeviceInfo { public string system; public string version; public string model; public long memory; } public void OnDeviceInfoReceived(string json) { DeviceInfo info JsonUtility.FromJsonDeviceInfo(json); // 后续逻辑使用 }如果 JSON 里的字段名和 C# 类的字段名不一致JsonUtility默认匹配不上解析出来的值就是默认值。所以设计 JSON 协议时原生字典的 key 要和 C# 字段严格一致。这是很不起眼但经常让人头疼的小问题。5.4 本地回调的 Event 封装Bridge 组件收到原生回调后直接发日志、再把业务事件转发出去是一个比“让业务类直接挂在 Bridge 上”更解耦的做法。我习惯在 C# 侧定义静态事件public static event ActionDeviceInfo DeviceInfoReceived; public static event ActionBatteryInfo BatteryLevelReceived; public void OnDeviceInfoReceived(string json) { Debug.Log($[IOSBridge] 设备信息: {json}); DeviceInfo info JsonUtility.FromJsonDeviceInfo(json); DeviceInfoReceived?.Invoke(info); }游戏里真正关心电池信息的模块在初始化时订阅这个事件。这样 Bridge 不对任何业务模块产生依赖以后新增原生功能只需要扩展 Bridge 本身和事件不污染其他逻辑。对于多人协作的项目这种隔离能减少很多莫名其妙的“改了一个文件整条链路崩了”的问题。6. 常见问题与调试实录6.1 高频问题速查表我在多个项目里把这些交互问题整理成了一张排查表基本每次救急都用得上现象可能原因解决方向编辑器里调用原生方法报 EntryPointNotFoundException#if UNITY_IOS条件没写!UNITY_EDITOR加条件编译编辑器走模拟分支Xcode 链接报 Undefined Symbolextern C 没包住或者 C# 与原生函数名不一致检查符号导出与命名统一加前缀原生回调后 Unity 收不到消息GameObject 名称对不上或方法不是 public或方法参数不是 string在原生代码里给 UnitySendMessage 的三个字符串打印日志回调偶尔在后台线程发出去行为随机甚至崩溃调用了 UnitySendMessage 但没有回主线程统一套dispatch_async(dispatch_get_main_queue())iOS 13 之后取 rootViewController 拿不到窗口keyWindow废弃改用 connectedScenes 查找当前 UIWindowScene回调丢失但同一个 GameObject 在多个场景存在场景切换后对象被销毁或重复创建用 DontDestroyOnLoad 常驻 Bridge返回字符串后内存持续增长原生返回的动态内存没有被释放改回调传 JSON或使用静态缓冲区6.2 后台线程崩溃的排查思路如果 App 在不固定的时机闪退先怀疑是不是后台线程直接操作了 UIKit 和 Unity 对象。这类崩溃特点就是“时好时坏”运气好一直不崩运气差一上线就崩。排查技巧是在原生代码所有可能跨线程的函数入口处打印线程信息NSLog([Bridge] current thread %, [NSThread currentThread]);如果日志里出现number X, main之外的线程标识而后续又直接调用了 UIKit 或 UnitySendMessage基本就能定位问题。修复方式就是套dispatch_async(dispatch_get_main_queue())把真正操作 Unity 和 UIKit 的代码全部强制回主线程。6.3 调试技巧把回调链路可视化原生回调不像是 C# 断点那样直观因为它可能来自完全陌生的线程和调用点。我的习惯是先让链路全程留有日志。C# 侧所有OnNativeXxxReceived方法第一行都打日志打印原始 JSON。原生侧在调用 UnitySendMessage 之前也打日志打印目标 GameObject、方法名和消息。这样一旦出问题两个日志一对照就能看出到底是原生没调还是 Unity 收到但解析失败。真机调试时Unity 侧的Debug.Log不会直接出现在 Xcode 控制台但原生侧的NSLog会。所以排查原生侧问题时直接在 Xcode 控制台观察即可。如果想看 Unity 侧的日志可以打开 Xcode 的 Console 面板并选择设备日志或者在原生侧把所有关键动作都用 NSLog 打印一遍这样两条链路的日志就都会汇聚到 Xcode 控制台排查效率会高很多。6.4 一个隐藏得很深的启动时序问题有些原生初始化需要在 Unity 场景完全加载之后调用。比如原生代码里某个模块依赖当前场景的 UIWindow你却在Awake阶段就触发了原生调用此时 Unity 可能在场景切到一半类时空窗。这种问题不会立刻崩溃而是在后续呈现 UI 时出现诡异的布局错乱。我通常建议原生调用统一从 Bridge 的对外方法进入而且不要在 Awake 里主动调用原生除非确认确实是安全时机。需要启动初始化时用协程或Start延迟到下一帧再调用确认 Unity 生命周期已就绪。这个时序问题在日志层面不一定看得出来需要用“等待后调用是否恢复正常”的方式验证。7. 我在实践中总结的几点经验最后聊几点没写进代码的体会。第一交互方案的关键不是把某一个UnitySendMessage调通而是把整条链路的命名、线程、生命周期、协议固定成团队规范。我把 GameObject 名、回调方法名、JSON 字段命名都写进项目文档后面任何人接需求只需要照着加不会出现每个人都发明一套写法的混乱局面。第二编辑器模拟分支不是敷衍用的它是主开发工具。我经常先写模拟返回值让 Unity 侧业务逻辑先跑通再补原生实现最后真机验证。整个节奏舒服很多。第三能少穿字符串就少穿。复杂数据统一 JSON简单数据短字符串绝不为了省事把多个字段用特定分隔符拼成一串再手动拆。分隔符方案看着轻巧一旦某个字段内部出现同样字符就爆雷排查成本远大于直接用 JSON。第四尽可能不要直接返回动态字符串给 Unity。多走一步回调应用自己管理内存能避免很多和内存相关的隐蔽问题。Unity 与 iOS 交互这个主题说白了就是“调用 回调 统一协议 生命周期管理”四件事。把一和二搞明白把三和四做规范后续就算接再复杂的原生 SDK也只不过是在这套骨架上多挂几个节点而已。