做Unity开发的人,迟早会遇到一个绕不开的需求:在iOS上让用户从系统相册挑一张图,或者直接调起系统相机拍一张。我最早碰到这个需求是做一款生活记录类App,Unity里所有UI都是自己做好的,唯独相册和相机这两个系统能力必须借助原生层弹出的UIImagePickerController才能完成。这篇文章我把从C#到Objective-C、从Unity工程到Xcode工程的完整链路拆开来讲,包含可以直接用的源码和我踩过的坑。
1. 为什么Unity项目绕不开"原生弹窗"这一关
1.1 典型业务场景
先说说在什么情况下会用到这个能力。Unity开发的App里,凡是涉及"用户头像""照片分享""社区发帖""证件照拍摄"这些功能,都需要和系统相册、相机打交道。比如我做的那款App,用户在个人中心点击头像,弹出一个ActionSheet,选"拍照"或"从相册选择",然后进入系统相机拍照或从相册挑图,最后把图片回传到Unity场景里显示。
这类需求在iOS上无法完全用Unity自身功能完成。Unity的C#运行在IL2CPP或Mono之上,虽然能通过Texture2D加载图片,但没有权限遍历用户的相册列表,更没有能力弹出系统相机界面。系统UI必须通过UIKit访问,而Unity引擎没有把UIImagePickerController暴露给C#。
1.2 Unity与iOS的能力边界
我刚做这个需求时,也想过是不是有现成的Unity插件。Asset Store上确实有一些付费插件能搞定,但问题很现实:插件是一个黑盒,出了问题不好排查,而且只为了一个选照片的功能引入整套插件,包体和复杂度都不划算。
更核心的障碍在技术边界上。iOS的沙盒机制把App的数据隔离在一个独立目录里,C#代码无法跨进程访问系统相册框架Photos.framework里的UIImagePickerController。系统相机和相册本质上是一个独立于Unity渲染管线的原生UI页面,必须在Objective-C(或Swift)层创建和present。
所以标准做法是:写一个Objective-C桥接类,在原生层弹出UIImagePickerController,拿到图片后压缩成临时文件,再把文件路径通过UnitySendMessage回传给C#。整体通信闭环是"Unity C#调OC,OC回调C#"的双向通信。
1.3 整体通信闭环预览
先给一张全局图,方便后面代码对上号:
- Unity场景里挂一个名为"NativeBridge"的GameObject,组件脚本里用
[DllImport("__Internal")]声明原生函数。 - 玩家点击按钮,C#调用
_OpenPhotoLibrary()或_OpenCamera()。 - Objective-C桥接类收到调用,弹出系统相册或相机。
- 用户选择或拍照完成后,原生层把UIImage做方向校正、等比压缩,写入NSTemporaryDirectory临时目录。
- 原生层调用
UnitySendMessage("NativeBridge", "OnImageSelected", "/tmp/xxx.jpg")把路径回传给C#。 - C#方法
OnImageSelected(string path)读取文件、加载为Texture2D、显示到RawImage上。
这个流程里,最关键也最容易出问题的点是第5步的UnitySendMessage机制,以及第4步的图片处理时序。下面逐层展开。
2. 双向桥接:UnitySendMessage与C#调OC的闭环是怎么跑通的
2.1 C#调用Objective-C:extern "C"导出函数
Unity调iOS原生方法,标准做法是使用[DllImport("__Internal")]声明外部函数。__Internal告诉Unity运行时,这个符号不在独立的动态库里,而是链接在当前可执行文件里,也就是Unity生成的Xcode工程中编译进App的原生代码。
在Objective-C++(.mm)文件里,用extern "C"把C++名字修饰去掉,导出三个函数:
extern "C" { void _InitCameraGallery(const char *objectName); void _OpenPhotoLibrary(); void _OpenCamera(); }这样C#端就能用对应的函数签名直接调用:
[DllImport("__Internal")] private static extern void _InitCameraGallery(string gameObjectName); [DllImport("__Internal")] private static extern void _OpenPhotoLibrary(); [DllImport("__Internal")] private static extern void _OpenCamera();注意几个关键点:
第一,[DllImport("__Internal")]只有在真机或模拟器运行时才会去找原生符号,在Unity编辑器里点击运行不会执行,所以必须用#if UNITY_IOS && !UNITY_EDITOR把声明和调用包起来,否则编辑器会报EntryPointNotFoundException或直接忽略。
第二,原生函数必须放在.mm文件里而不是.m文件。extern "C"是C++语法,纯Objective-C编译器的.m文件不认识。我一开始偷懒放在.m里,编译直接报错。
第三,Unity传递字符串给原生时是UTF-8的const char*,原生侧接收后要转成NSString再存储,不要直接保存const char*指针,因为C#字符串生命周期结束后指针就悬垂了。
2.2 OC调用C#:UnitySendMessage的三个关键坑
原生层回传数据给C#,用的就是Unity官方提供的UnitySendMessage接口:
UnitySendMessage("NativeBridge", "OnImageSelected", "/tmp/xxx.jpg");第一个参数是场景中GameObject的名字,第二个参数是挂在那个GameObject上的MonoBehaviour方法名,第三个参数是方法接收的字符串参数。
这里有几个坑是必踩的:
GameObject必须真实存在且名字完全一致。脚本里如果用gameObject.name初始化,那场景里这个物体叫什么名字,原生侧就缓存什么名字。如果场景切换时这个物体被销毁,UnitySendMessage会静默失败,控制台没有任何日志。标准做法是Awake里DontDestroyOnLoad(gameObject)并且整个App生命周期内不销毁。
方法名大小写必须完全一致,参数必须是string。如果C#方法定义成void OnImageSelected(string path),原生传的方法名写成"onImageSelected",回调就不会触发。参数类型目前只支持string、int、float这些简单类型,最实用的是string,复杂的结构体就把它JSON序列化成一个字符串传过去。
Message参数不能传null,要传空字符串。我刚开始在取消选择时会传null,iOS端UnitySendMessage的第三参数传nil后,C#端有时收到的是空字符串,有时直接不触发,行为很迷。后来统一传""空字符串,C#侧判空处理,稳定多了。
2.3 完整时序与主线程问题
整个调用过程中的线程问题也需要注意。UnitySendMessage本身可以在任意线程调用,Unity内部会把它投递到主线程的Update循环中执行,所以原生层处理完图片、写临时文件后,无论耗时多久,回调到C#都是在主线程,可以直接操作Unity API比如创建Texture2D、给RawImage赋值。
但反过来,C#调用_OpenPhotoLibrary()之后,图片选择是异步的,原生层不会阻塞等用户操作完,用户选完图才会走回调。所以桥接层务必持有回调目标的名字,而不能期望用返回值把UIImage传回C#。UIImagePickerController是异步回调,原生侧至少要持有这些信息才能正确回调。
3. iOS端原生实现:相册选择器与拍照流程的完整代码
3.1 为什么用UIImagePickerController
iOS 14之后系统提供了PHPickerViewController,它运行在独立进程中,App拿不到相册权限也能选择照片,隐私性和体验都更好。但PHPicker有一个限制:它不支持直接拍照,调起相机的需求还是得靠UIImagePickerController。
我这个项目需要同时支持相册选择和拍照两个入口,权衡代码量和兼容性,决定先用一个UIImagePickerController同时覆盖两个场景。这样代码量小,iOS 13及以下的老系统也兼容。后续如果要做纯相册选择的升级版,再把相册入口替换成PHPicker也不迟。
还有一个考虑:UIImagePickerController在真机上弹出的就是系统原生界面,体验最贴近系统,玩家不觉得突兀。自己用AVFoundation写自定义相机弹窗,效果虽然也能做,但工程量大很多,而且后续系统升级还要不断适配。
3.2 头文件定义
创建YLCameraBridge.h:
#import <UIKit/UIKit.h> @interface YLCameraBridge : NSObject <UIImagePickerControllerDelegate, UINavigationControllerDelegate> // 缓存Unity侧接收回调的GameObject名字 @property (nonatomic, copy) NSString *unityObjectName; // 防止快速点击导致弹出多个系统弹窗 @property (nonatomic, assign) BOOL isProcessing; + (instancetype)sharedInstance; - (void)openPhotoLibrary; - (void)openCamera; @end用NSObject而不是继承UIViewController,是因为这个桥接类只需要做picker的delegate和数据转发,它自己不需要出现在视图层级里。弹出picker用的承载视图控制器直接用Unity的GLViewController。
3.3 完整实现代码
创建YLCameraBridge.mm:
#import "YLCameraBridge.h" #import <UnityFramework/UnityFramework.h> @implementation YLCameraBridge + (instancetype)sharedInstance { static YLCameraBridge *instance = nil; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ instance = [[YLCameraBridge alloc] init]; }); return instance; } - (UIViewController *)rootViewController { return UnityGetGLViewController(); } - (void)openPhotoLibrary { if (self.isProcessing) return; self.isProcessing = YES; UIImagePickerController *picker = [[UIImagePickerController alloc] init]; picker.sourceType = UIImagePickerControllerSourceTypePhotoLibrary; picker.delegate = self; picker.modalPresentationStyle = UIModalPresentationFullScreen; [[self rootViewController] presentViewController:picker animated:YES completion:nil]; } - (void)openCamera { if (self.isProcessing) return; if (![UIImagePickerController isSourceTypeAvailable:UIImagePickerControllerSourceTypeCamera]) { NSLog(@"YLCameraBridge: 当前设备不支持相机"); self.isProcessing = NO; [self sendEmptyResult]; return; } self.isProcessing = YES; UIImagePickerController *picker = [[UIImagePickerController alloc] init]; picker.sourceType = UIImagePickerControllerSourceTypeCamera; picker.delegate = self; picker.modalPresentationStyle = UIModalPresentationFullScreen; [[self rootViewController] presentViewController:picker animated:YES completion:nil]; } #pragma mark - UIImagePickerControllerDelegate - (void)imagePickerController:(UIImagePickerController *)picker didFinishPickingMediaWithInfo:(NSDictionary<UIImagePickerControllerInfoKey,id> *)info { UIImage *image = info[UIImagePickerControllerOriginalImage]; if (!image) { self.isProcessing = NO; [self sendEmptyResult]; [picker dismissViewControllerAnimated:YES completion:nil]; return; } image = [self normalizedImage:image]; NSString *path = [self saveImageToTemp:image]; [picker dismissViewControllerAnimated:YES completion:^{ self.isProcessing = NO; if (path) { [self sendResult:path]; } else { [self sendEmptyResult]; } }]; } - (void)imagePickerControllerDidCancel:(UIImagePickerController *)picker { [picker dismissViewControllerAnimated:YES completion:^{ self.isProcessing = NO; [self sendEmptyResult]; }]; } #pragma mark - 图片处理 // 把EXIF方向信息转成实际像素方向,避免横图 - (UIImage *)normalizedImage:(UIImage *)image { if (image.imageOrientation == UIImageOrientationUp) { return image; } UIGraphicsBeginImageContextWithOptions(image.size, NO, image.scale); [image drawInRect:CGRectMake(0, 0, image.size.width, image.size.height)]; UIImage *normalized = UIGraphicsGetImageFromCurrentImageContext(); UIGraphicsEndImageContext(); return normalized; } // 等比压缩到1280以内,写入临时文件 - (NSString *)saveImageToTemp:(UIImage *)image { CGFloat maxSide = 1280.0f; CGFloat maxDimension = MAX(image.size.width, image.size.height); CGFloat scale = MIN(1.0f, maxSide / maxDimension); CGSize targetSize = CGSizeMake(image.size.width * scale, image.size.height * scale); UIGraphicsBeginImageContextWithOptions(targetSize, YES, 1.0); [image drawInRect:CGRectMake(0, 0, targetSize.width, targetSize.height)]; UIImage *scaledImage = UIGraphicsGetImageFromCurrentImageContext(); UIGraphicsEndImageContext(); NSData *jpgData = UIImageJPEGRepresentation(scaledImage, 0.8); NSString *fileName = [NSString stringWithFormat:@"yl_%@.jpg", [[NSUUID UUID] UUIDString]]; NSString *tempPath = [NSTemporaryDirectory() stringByAppendingPathComponent:fileName]; BOOL ok = [jpgData writeToFile:tempPath atomically:YES]; return ok ? tempPath : nil; } #pragma mark - Unity 回调 - (void)sendResult:(NSString *)path { if (self.unityObjectName.length == 0) { NSLog(@"YLCameraBridge: unityObjectName 未设置,回调丢失"); return; } const char *objectName = [self.unityObjectName UTF8String]; const char *methodName = "OnImageSelected"; const char *arg = [path UTF8String]; UnitySendMessage(objectName, methodName, arg); } - (void)sendEmptyResult { if (self.unityObjectName.length == 0) return; UnitySendMessage([self.unityObjectName UTF8String], "OnImageSelected", ""); } @end #pragma mark - 供C#调用的C接口 extern "C" { void _InitCameraGallery(const char *objectName) { NSString *name = [NSString stringWithUTF8String:objectName]; [YLCameraBridge sharedInstance].unityObjectName = name; } void _OpenPhotoLibrary() { [[YLCameraBridge sharedInstance] openPhotoLibrary]; } void _OpenCamera() { [[YLCameraBridge sharedInstance] openCamera]; } }三个函数和C#的声明严格对应。_InitCameraGallery接收场景里挂载脚本的GameObject名字,缓存到属性里,后续回调都发给这个物体。
图片处理部分有两个细节值得多解释一下。normalizedImage必须放在压缩之前执行。因为UIImage的imageOrientation只是元数据标记,如果不重绘,直接压缩会得到一张旋转了90度或180度的图。saveImageToTemp里的maxSide = 1280是基于Unity侧显示需要的经验值,如果原图是1200万像素,直接解码到内存约为48MB,加载到Unity再做一次解码又会翻倍,很容易触发iOS的内存警告。先压缩再传路径,内存峰值能控制得很低。
压缩参数里UIGraphicsBeginImageContextWithOptions(targetSize, YES, 1.0)的最后一个参数是scale,传1.0可以避免Retina屏上生成2x或3x的大尺寸图片,对头像和列表缩略图场景足够。
3.4 Info.plist必须配置的权限声明
少了权限描述,App在真机上直接崩溃,这是最容易被忽略的。在Xcode导出工程后,Info.plist里补上:
<key>NSCameraUsageDescription</key> <string>需要使用相机拍摄图片用于头像和内容发布</string> <key>NSPhotoLibraryUsageDescription</key> <string>需要访问相册以选择图片</string>文案要写得具体,说明用途。App Store审核会看这段文字,如果只写"需要访问相册"这类含糊描述,大概率被拒。iOS系统弹窗显示的也是这段文案,要认真写。
还有一点,如果之后打算把处理后的图片保存回相册,还需要加NSPhotoLibraryAddUsageDescription。本次需求只是读图,不需要加。加上但没用到也不会被拒,只是隐私清单里多一项。
3.5 升级方案:iOS 14+换成PHPicker
如果你的App最低版本支持iOS 14以上,强烈建议把相册入口换成PHPickerViewController。PHPicker有几个明显优势:
| 对比项 | UIImagePickerController | PHPickerViewController |
|---|---|---|
| 相册权限 | 需要NSPhotoLibraryUsageDescription | 不需要相册权限,独立进程运行 |
| 支持Limited模式 | 不支持 | 支持,用户可只授权部分照片 |
| 搜索和筛选 | 弱 | 支持搜索、多选、类型过滤 |
| 拍照能力 | 支持 | 不支持,只用做选图 |
实现差异不大,PHPicker用PHPickerConfiguration设置过滤条件,然后用PHPickerViewController呈现,在delegate里通过loadObjectOfClass拿UIImage。我把这部分留作进阶优化,当前方案先用UIImagePickerController跑通闭环。
4. Unity侧C#封装:从按钮点击到回传Image的完整链路
4.1 封装类完整代码
Unity侧创建NativeCameraGallery.cs,挂在一个持久化的GameObject上,这个GameObject的名字就作为回调目标:
using System; using System.IO; using System.Runtime.InteropServices; using UnityEngine; using UnityEngine.UI; public class NativeCameraGallery : MonoBehaviour { public static NativeCameraGallery Instance { get; private set; } [SerializeField] private RawImage previewImage; public event Action<string> OnImagePathReceived; #if UNITY_IOS && !UNITY_EDITOR [DllImport("__Internal")] private static extern void _InitCameraGallery(string gameObjectName); [DllImport("__Internal")] private static extern void _OpenPhotoLibrary(); [DllImport("__Internal")] private static extern void _OpenCamera(); #endif private void Awake() { if (Instance != null && Instance != this) { Destroy(gameObject); return; } Instance = this; DontDestroyOnLoad(gameObject); #if UNITY_IOS && !UNITY_EDITOR _InitCameraGallery(gameObject.name); #endif } public void PickFromGallery() { #if UNITY_IOS && !UNITY_EDITOR _OpenPhotoLibrary(); #else Debug.Log("编辑器或非iOS环境:模拟选择"); OnImagePathReceived?.Invoke(""); #endif } public void TakePhoto() { #if UNITY_IOS && !UNITY_EDITOR _OpenCamera(); #else Debug.Log("编辑器或非iOS环境:模拟拍照"); OnImagePathReceived?.Invoke(""); #endif } // 由UnitySendMessage回调触发 public void OnImageSelected(string path) { if (string.IsNullOrEmpty(path)) { Debug.LogWarning("用户取消或没有拿到图片"); return; } if (previewImage != null) { StartCoroutine(LoadImageToTexture(path)); } else { OnImagePathReceived?.Invoke(path); } } private System.Collections.IEnumerator LoadImageToTexture(string path) { byte[] bytes = File.ReadAllBytes(path); Texture2D texture = new Texture2D(2, 2, TextureFormat.RGBA32, false); if (!texture.LoadImage(bytes)) { Debug.LogError("图片解码失败: " + path); yield break; } if (previewImage != null) { Texture2D old = previewImage.texture as Texture2D; previewImage.texture = texture; if (old != null) Destroy(old); } OnImagePathReceived?.Invoke(path); yield return null; } private void OnDestroy() { if (Instance == this) Instance = null; } }这段代码在Awake里把gameObject.name传给原生层,再通过DontDestroyOnLoad保证切换场景后这个物体还在。UnitySendMessage的回调是同步派发到主线程的,所以OnImageSelected里可以直接创建Texture2D和操作UI。
4.2 图片回传方式:base64 vs 临时文件路径
图片从原生回传Unity,常见的方式有两种:base64字符串和临时文件路径。我强烈建议用临时文件路径,原因很实际:
| 对比维度 | base64字符串 | 临时文件路径 |
|---|---|---|
| 内存占用 | 高,图片转base64和C#解码都吃内存 | 低,文件IO可以流式处理 |
| 传递速度 | 慢,数据量大时卡顿明显 | 快,只传一个路径字符串 |
| 生命周期 | 字符串生命周期短,需立即解析 | 文件在临时目录,随时可读 |
| Unity侧加载 | 需要额外解码 | File.ReadAllBytes + LoadImage即可 |
实测下来,选一张1200万像素照片,如果直接转base64传回,Unity侧接收和Convert.FromBase64String的内存峰值能到200MB以上,低配iPhone会闪退。临时文件方式原生侧已经压缩到1280以内,最终Unity侧读取的jpg文件大概只有200KB,内存压力完全可控。
4.3 Texture加载时的内存控制
C#侧用Texture2D.LoadImage解码jpg,这个方法支持自动识别jpg和png,不需要手工设置格式。注意创建Texture2D时传TextureFormat.RGBA32,LoadImage会覆盖掉纹理尺寸和格式。
替换贴图时,我先把旧纹理存下来,赋值新纹理后再Destroy(old),避免旧纹理内存泄漏。这个细节在Unity里很容易漏,尤其是反复选图时,旧Texture2D不销毁,内存持续上涨,最终触发iOS的"清内存"机制,表现为App直接被杀。
4.4 编辑器调试分支
编辑器里没有iOS原生代码,直接运行按钮会报找不到DllImport。所以封装类里Package了UNITY_EDITOR分支,在编辑器里模拟选择,输出日志并触发回调。开发联调阶段,UI展示可以先接假数据,等真机再验证完整链路。
5. 构建配置与Xcode联调:最容易忽略的权限项
5.1 Unity Build Settings里的关键开关
导出Xcode工程前,有几个Build Settings必须检查,缺一个都会在真机上翻车:
| 配置项 | 推荐值 | 原因 |
|---|---|---|
| Target Device | iPhone + iPad | 只用iPhone会限制iPad安装,审核也可能要求适配 |
| Scripting Backend | IL2CPP | iOS平台标准配置,Mono在iOS上性能差且提交审核易被拒 |
| Architecture | ARM64 | 当前iOS真机都要求ARM64 |
| Strip Engine Code | 建议开启 | 减小包体,但注意不要Strip掉原生回调相关代码 |
这些配置在Unity 2021.3 LTS和6000.0系列上都适用。版本太老的Unity,比如2018,生成的Xcode工程结构差异比较大,UnityFramework.h的导入路径要相应调整。
5.2 Xcode里必须手工完成的步骤
Unity生成的Xcode工程默认没有权限描述,也没有签名Team,这些必须在Xcode里处理:
- 在Signing & Capabilities里选择你的Team和Bundle Identifier。
- Info.plist里加NSCameraUsageDescription和NSPhotoLibraryUsageDescription。
- 真机联调时,在Build Settings里把Code Signing Identity设为Apple Development。
- 如果使用PHPicker,需要把iOS Deployment Target设为14.0以上,否则编译报错。
很多新人在这一步卡住,主要原因是权限描述没加。App安装到真机上,点击相册或相机按钮立刻崩溃,查看Xcode控制台会看到"Attempting to access camera without a usage description"之类的信息,就是这个原因。
5.3 iOS 14+的相册权限模式变化
iOS 14开始,系统相册权限除了"允许访问所有照片",还多了"选择照片"(Limited)模式。用户选择Limited后,App拿到的相册列表只有用户勾选的部分。
UIImagePickerController在选择照片时,系统会直接弹UIImagePickerController自己的界面,它在Limited模式下体验尚可,但如果你的App后续自己枚举相册资源,就会出现"为什么看不到全部照片"的问题。用PHPicker则可以完全绕开这个限制。我做这个项目时最低支持iOS 13,所以选择了UIImagePickerController,新版项目建议直接上PHPicker。
5.4 联调日志怎么看
真机联调时,C#的Debug.Log在Xcode控制台会输出带"Unity"前缀的日志,原生层的NSLog也会混在里面,可以用关键词过滤:
po [YLCameraBridge sharedInstance].isProcessing如果你在原生代码里加了断点,Xcode会停在真机调试,这时候Unity画面会卡住,这是正常现象。回调链路是否走通,重点看三点:原生API有没有被调用、UnitySendMessage的objectName有没有值、C#方法有没有触发。
6. 真机踩坑清单:内存、方向、弹窗冲突这些老问题
6.1 大图OOM:一张照片直接闪退
第一次联调时,我用iPhone 13拍了一张1200万像素的照片,原生回调后Unity侧直接崩溃。Xcode控制台显示的崩溃日志指向ImageIO解码,典型的内存峰值过高。
原因是我最初没有在原生侧压缩,而是把UIImage直接转base64给Unity。UIImage从相机出来时,底层图像数据已经解码成ARGB位图,1200万像素约48MB,转base64又要把它编码成JPG原始数据,再加一次base64膨胀,Unity侧解码又一次,三层叠加触发JetSam。
解决思路就是原生侧一次性做到位:方向校正、等比压缩、JPEG压缩到0.8质量、写入临时文件。压缩后的图片长边控制在1280,文件大小200KB左右,后续所有环节的内存开销都很小。顺带提一句,临时文件写完后C#侧用File.ReadAllBytes读取,用完不删除也没关系,系统会清理,但长时间高频使用建议用完就删。
6.2 照片方向错乱:竖拍变横图
第二个坑是方向。用相机拍照后会拿到的UIImage,如果检查imageOrientation,很可能不是Up,而是Right或Left。直接把这个UIImage写进JPEG,得到的图片是横着的。
我在最早版本里没做方向校正,Unity侧显示的图片旋转了90度,测试人员第一时间就提了bug。修法就是normalizedImage方法,用UIGraphicsBeginImageContextWithOptions重绘一遍,把EXIF方向信息在像素层面落地。注意这个方法在压缩之前调用,因为重绘后image.size才是真实尺寸,压缩比例才计算得准。
6.3 横屏游戏下的相机弹窗冲突
如果Unity工程是横屏游戏,直接present UIImagePickerController,相机的画面可能被拉伸或黑边。UIImagePickerController有自己支持的屏幕方向,有些版本在横屏下预览是旋转的。
我当时的处理是设置picker.modalPresentationStyle = UIModalPresentationFullScreen,大部分机型能正常显示。如果你做的是横屏游戏,而且相机预览方向错乱,可以考虑在present之前设置preferredInterfaceOrientationForPresentation,或者干脆用AVFoundation自定义相机页面。自定义相机虽然工作量大,但方向处理完全可控,Unity端甚至可以直接用RenderTexture把摄像头画面渲染进游戏场景,这是后话了。
6.4 连续点击弹出多个系统弹窗
玩家手指快的时候,一个按钮可能被连续点击多次,C#侧调用了多次_OpenPhotoLibrary(),原生层就有多个UIImagePickerController同时present,表现为"上一个相册还没关,下一个又弹出来",界面卡死。
我在原生侧加了isProcessing锁,每次弹窗前判断,如果已经在处理中就直接忽略。这个锁在picker关闭的时候复位。同样的问题在C#侧也可以做一层防护,按钮点击后暂时置灰,但最根本的还是原生侧锁住,因为C#侧的网络延迟和输入延迟都不可控。
6.5 UnitySendMessage回调不触发
这个坑出现得最隐蔽,排查了很久。症状是:原生日志打印了,UnitySendMessage也调用了,但C#方法没反应。
原因有三类:
第一,初始化时unityObjectName传了空值或错误名字。我在Awake里用gameObject.name初始化,但这个脚本所在物体在某个场景切换时被销毁重建,名字变了,导致原生侧缓存的名字失效。解决:脚本挂在单例化的持久对象上,且用DontDestroyOnLoad。
第二,方法名拼写不一致。C#侧是OnImageSelected,原生侧写成了OnImageSelectd,这种手滑比想象中更容易发生,而且不会报错,只会静默失败。
第三,回调时机在场景卸载期间。如果用户在选图过程中快速切场景(比如点了Home键),Unity的Application生命周期可能不活跃,UnitySendMessage投递时物体还没创建。解决办法是初始化时机尽量提前,最好是App启动后第一个场景就完成注册。
6.6 调试工具与思路
原生和Unity联调,日志是最重要的线索。我习惯在原生代码里打NSLog标记每个关键节点,比如进入openPhotoLibrary、拿到图片、压缩完成、UnitySendMessage调用。Xcode控制台用"YLCameraBridge"关键词过滤,能快速定位问题出在哪个环节。
如果是崩溃问题,Xcode的崩溃堆栈里如果出现NativeCall、il2cpp字样,说明Unity侧代码被原生调用时出了问题,优先排查DllImport函数签名和参数类型是否一致。如果是内存问题,Xcode的Memory Report工具能实时看到内存曲线,大图加载时的飙升一眼就能看出来。
这套方案我在两个项目里反复用过,最后一次跑通后,基本上一两个小时就能完整接入:原生桥接代码写一次,C#封装写一次,后面再做其他原生能力,比如推送、支付、扫码,思路完全一致——init注册回调GameObject、暴露C接口给C#、原生操作完成后UnitySendMessage回传数据。这套"三步走"的桥接模式,才是比选相册本身更值得沉淀的东西。