托管代码和 Objective-C 都支持运行时异常(try/catch/finally 子句)。
但是,它们的实现不同,这意味着运行时库(MonoVM/CoreCLR 运行时和 Objective-C 运行时库)在其他运行时遇到异常时出现问题。
本文介绍可能发生的问题以及可能的解决方案。
它还包括一个示例项目 异常封送处理,可用于测试不同的方案及其解决方案。
问题
当引发异常并在堆栈展开期间遇到与引发的异常类型不匹配的帧时,会出现此问题。
此问题的典型示例是,当本机 API 引发 Objective-C 异常时,当堆栈展开过程到达托管帧时,必须以某种方式处理该 Objective-C 异常。
在过去(.NET 之前),默认操作是不执行任何操作。 对于上面的示例,这意味着让 Objective-C 运行时环境展开托管帧。 此操作有问题,因为 Objective-C 运行时不知道如何展开托管帧;例如,它不会执行任何托管 catch
或 finally
子句,从而导致非常难以发现的问题。
代码中断
请考虑以下代码示例:
var dict = new NSMutableDictionary ();
dict.LowlevelSetObject (IntPtr.Zero, IntPtr.Zero);
此代码将在原生代码中引发 Objective-C NSInvalidArgumentException。
NSInvalidArgumentException *** setObjectForKey: key cannot be nil
堆栈跟踪可能如下所示:
0 CoreFoundation __exceptionPreprocess + 194
1 libobjc.A.dylib objc_exception_throw + 52
2 CoreFoundation -[__NSDictionaryM setObject:forKey:] + 1015
3 libobjc.A.dylib objc_msgSend + 102
4 TestApp ObjCRuntime.Messaging.void_objc_msgSend_IntPtr_IntPtr (intptr,intptr,intptr,intptr)
5 TestApp Foundation.NSMutableDictionary.LowlevelSetObject (intptr,intptr)
6 TestApp ExceptionMarshaling.Exceptions.ThrowObjectiveCException ()
帧 0-3 是本机帧,Objective-C 运行时中的堆栈展开器 可以 展开这些帧。 具体而言,它将执行任何 Objective-C @catch
或 @finally
子句。
但是,Objective-C 堆栈展开器 无法 正确展开托管帧(帧 4-6):Objective-C 堆栈展开器将展开托管帧,但不会执行任何托管异常逻辑(如 catch
或 finally
子句)。
这意味着通常无法通过以下方式捕获这些异常:
try {
var dict = new NSMutableDictionary ();
dict.LowLevelSetObject (IntPtr.Zero, IntPtr.Zero);
} catch (Exception ex) {
Console.WriteLine (ex);
} finally {
Console.WriteLine ("finally");
}
这是因为 Objective-C 堆栈展开器不知道托管 catch
子句, 以及 finally
子句也不会被执行。
当 上述代码示例有效 时,这是因为 Objective-C 有一种方法接收到未经处理的 Objective-C 异常的通知,NSSetUncaughtExceptionHandler
这是 .NET SDK 利用的方法,并且在这种情况下会尝试将任何 Objective-C 异常转换为托管异常。
情境
方案 1 - 使用托管捕获处理程序捕获 Objective-C 异常
在以下情景中,可以使用托管catch
处理程序捕获 Objective-C 异常:
- 引发 Objective-C 异常。
- Objective-C 运行时会遍查堆栈(但不展开它),查找可以处理异常的本机
@catch
处理程序。 - Objective-C 运行时找不到任何
@catch
句柄,然后调用NSGetUncaughtExceptionHandler
,并调用由 .NET SDK 安装的处理程序。 - .NET SDK 的处理程序会将 Objective-C 异常转换为托管异常,并引发该异常。 由于 Objective-C 运行时未展开堆栈(仅对其进行遍历),因此当前帧与抛出 Objective-C 异常的位置相同。
此处会出现另一个问题,因为 Mono 运行时不知道如何正确展开 Objective-C 帧。
当调用 .NET SDK 未捕获的 Objective-C 异常回调时,堆栈如下所示:
0 TestApp exception_handler(exc=name: "NSInvalidArgumentException" - reason: "*** setObjectForKey: key cannot be nil")
1 CoreFoundation __handleUncaughtException + 809
2 libobjc.A.dylib _objc_terminate() + 100
3 libc++abi.dylib std::__terminate(void (*)()) + 14
4 libc++abi.dylib __cxa_throw + 122
5 libobjc.A.dylib objc_exception_throw + 337
6 CoreFoundation -[__NSDictionaryM setObject:forKey:] + 1015
7 TestApp xamarin_dyn_objc_msgSend + 102
8 TestApp ObjCRuntime.Messaging.void_objc_msgSend_IntPtr_IntPtr (intptr,intptr,intptr,intptr)
9 TestApp Foundation.NSMutableDictionary.LowlevelSetObject (intptr,intptr) [0x00000]
10 TestApp ExceptionMarshaling.Exceptions.ThrowObjectiveCException () [0x00013]
此处,唯一托管的帧是帧 8 到 10,但托管异常在帧 0 处被抛出。 这意味着 Mono 运行时必须展开本机帧 0-7,这会导致与上述问题等效的问题:尽管 Mono 运行时将展开本机帧,但它不会执行任何 Objective-C @catch
或 @finally
子句。
代码示例:
-(id) setObject: (id) object forKey: (id) key
{
@try {
if (key == nil)
[NSException raise: @"NSInvalidArgumentException"];
} @finally {
NSLog (@"This won't be executed");
}
}
@finally
子句不会被执行,因为展开此帧的 Mono 运行时不了解它。
其变体是在托管代码中引发托管异常,然后通过本机帧展开以获取第一个托管 catch
子句:
class AppDelegate : UIApplicationDelegate {
public override bool FinishedLaunching (UIApplication application, NSDictionary launchOptions)
{
throw new Exception ("An exception");
}
static void Main (string [] args)
{
try {
UIApplication.Main (args, null, typeof (AppDelegate));
} catch (Exception ex) {
Console.WriteLine ("Managed exception caught.");
}
}
}
托管 UIApplication:Main
方法将调用本机 UIApplicationMain
方法,然后 iOS 会在最终调用托管 AppDelegate:FinishedLaunching
方法之前执行大量本机代码,在引发托管异常时栈中仍存在许多本机帧。
0: TestApp ExceptionMarshaling.IOS.AppDelegate:FinishedLaunching (UIKit.UIApplication,Foundation.NSDictionary)
1: TestApp (wrapper runtime-invoke) <Module>:runtime_invoke_bool__this___object_object (object,intptr,intptr,intptr)
2: TestApp mono_jit_runtime_invoke(method=<unavailable>, obj=<unavailable>, params=<unavailable>, exc=<unavailable>, error=<unavailable>)
3: TestApp do_runtime_invoke(method=<unavailable>, obj=<unavailable>, params=<unavailable>, exc=<unavailable>, error=<unavailable>)
4: TestApp mono_runtime_invoke [inlined] mono_runtime_invoke_checked(method=<unavailable>, obj=<unavailable>, params=<unavailable>, error=0xbff45758)
5: TestApp mono_runtime_invoke(method=<unavailable>, obj=<unavailable>, params=<unavailable>, exc=<unavailable>)
6: TestApp xamarin_invoke_trampoline(type=<unavailable>, self=<unavailable>, sel="application:didFinishLaunchingWithOptions:", iterator=<unavailable>), context=<unavailable>)
7: TestApp xamarin_arch_trampoline(state=0xbff45ad4)
8: TestApp xamarin_i386_common_trampoline
9: UIKit -[UIApplication _handleDelegateCallbacksWithOptions:isSuspended:restoreState:]
10: UIKit -[UIApplication _callInitializationDelegatesForMainScene:transitionContext:]
11: UIKit -[UIApplication _runWithMainScene:transitionContext:completion:]
12: UIKit __84-[UIApplication _handleApplicationActivationWithScene:transitionContext:completion:]_block_invoke.3124
13: UIKit -[UIApplication workspaceDidEndTransaction:]
14: FrontBoardServices __37-[FBSWorkspace clientEndTransaction:]_block_invoke_2
15: FrontBoardServices __40-[FBSWorkspace _performDelegateCallOut:]_block_invoke
16: FrontBoardServices __FBSSERIALQUEUE_IS_CALLING_OUT_TO_A_BLOCK__
17: FrontBoardServices -[FBSSerialQueue _performNext]
18: FrontBoardServices -[FBSSerialQueue _performNextFromRunLoopSource]
19: FrontBoardServices FBSSerialQueueRunLoopSourceHandler
20: CoreFoundation __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__
21: CoreFoundation __CFRunLoopDoSources0
22: CoreFoundation __CFRunLoopRun
23: CoreFoundation CFRunLoopRunSpecific
24: CoreFoundation CFRunLoopRunInMode
25: UIKit -[UIApplication _run]
26: UIKit UIApplicationMain
27: TestApp (wrapper managed-to-native) UIKit.UIApplication:UIApplicationMain (int,string[],intptr,intptr)
28: TestApp UIKit.UIApplication:Main (string[],intptr,intptr)
29: TestApp UIKit.UIApplication:Main (string[],string,string)
30: TestApp ExceptionMarshaling.IOS.Application:Main (string[])
帧 0-1 和 27-30 是受控的,而两者之间的所有帧都是原生的。
如果 Mono 通过这些帧展开,则 Objective-C @catch
或 @finally
子句都不会被执行。
重要
只有 MonoVM 运行时支持在托管异常处理期间展开原生帧。 遇到这种情况时,CoreCLR 运行时只会中止进程(CoreCLR 运行时用于 macOS 应用,以及在任何平台上启用 NativeAOT 时)。
方案 2 - 无法捕获 Objective-C 异常
在以下方案中, 无法使用 托管 catch
处理程序捕获 Objective-C 异常,因为 Objective-C 异常是以另一种方式处理的:
- 引发 Objective-C 异常。
- Objective-C 运行时会遍查堆栈(但不展开它),查找可以处理异常的本机
@catch
处理程序。 - Objective-C 运行时查找处理程序
@catch
、展开堆栈并开始执行@catch
处理程序。
此方案通常在适用于 iOS 应用的 .NET 中找到,因为在主线程上通常有如下所示的代码:
void UIApplicationMain ()
{
@try {
while (true) {
ExecuteRunLoop ();
}
} @catch (NSException *ex) {
NSLog (@"An unhandled exception occured: %@", exc);
abort ();
}
}
这意味着在主线程上,从来没有真正未经处理的 Objective-C 异常,因此,永远不会调用将 Objective-C 异常转换为托管异常的回调。
在早于最新版本的 macOS 上调试 macOS 应用时也很常见,因为检查调试器中的大多数 UI 对象将尝试提取与执行平台上不存在的选择器对应的属性。 调用此类选择器将引发一个 NSInvalidArgumentException
(“无法识别的选择器发送到...”),这最终会导致进程崩溃。
总之,将 Objective-C 运行时或未编程为处理的 Mono 运行时展开帧可能会导致未定义的行为,例如崩溃、内存泄漏和其他类型的不可预知(错误)行为。
小窍门
对于 macOS 和 Mac Catalyst(但不包括 iOS 或 tvOS)应用,可以通过设置应用的属性为NSApplicationCrashOnExceptions
true
,使得用户界面循环不会捕捉所有异常。
var defs = new NSDictionary ((NSString) "NSApplicationCrashOnExceptions", NSValue.FromBoolean (true));
NSUserDefaults.StandardUserDefaults.RegisterDefaults (defs);
但是,请注意,Apple 不会记录此属性,因此将来的行为可能会更改。
解决方案
我们支持在托管与本机边界上捕获托管异常和 Objective-C 异常,并将该异常转换为其他类型的异常。
在伪代码中,它如下所示:
class MyClass {
[DllImport (Constants.ObjectiveCLibrary)]
static extern void objc_msgSend (IntPtr handle, IntPtr selector);
static void DoSomething (NSObject obj)
{
objc_msgSend (obj.Handle, Selector.GetHandle ("doSomething"));
}
}
拦截 P/Invoke 到objc_msgSend
,并调用以下代码:
void
xamarin_dyn_objc_msgSend (id obj, SEL sel)
{
@try {
objc_msgSend (obj, sel);
} @catch (NSException *ex) {
convert_to_and_throw_managed_exception (ex);
}
}
在相反的情况中,也进行了类似的处理(将托管异常封送给 Objective-C 异常)。
在 .NET 中,默认情况下,始终启用将托管异常封送至 Objective-C 异常。
“生成时标志”部分说明如何在默认时禁用拦截。
事件
截获异常后,将引发两个事件: Runtime.MarshalManagedException 和 Runtime.MarshalObjectiveCException。
这两个事件都接收一个 EventArgs
对象,该对象包含引发的原始异常( Exception
属性),以及一个属性 ExceptionMode
,用于定义异常应如何封送。
可以在事件处理程序中更改ExceptionMode
属性,以便根据处理程序中完成的任何自定义处理来改变行为。 一个示例是,如果发生特定异常,则中止进程。
更改 ExceptionMode
属性适用于单个事件,不会影响将来截获的任何异常。
将托管异常封送给本机代码时,可以使用以下模式:
-
Default
:目前,它始终是ThrowObjectiveCException
。 默认值将来可能会更改。 -
UnwindNativeCode
:使用 CoreCLR 时不可用(CoreCLR 不支持展开本机代码,而是会终止进程)。 -
ThrowObjectiveCException
:将托管异常转换为 Objective-C 异常,并抛出 Objective-C 异常。 这是 .NET 中的默认值。 -
Abort
:中止进程。 -
Disable
:禁用异常拦截。 在事件处理程序中设置此值没有意义(一旦引发事件,就太晚了,无法禁用截获异常)。 在任何情况下,如果设置,它将表现为UnwindNativeCode
。
将 Objective-C 异常封送给托管代码时,可以使用以下模式:
-
Default
:在 .NET 中,它始终是ThrowManagedException
。 默认值将来可能会更改。 -
UnwindManagedCode
:这是以前的(未定义)行为。 -
ThrowManagedException
:将 Objective-C 异常转换为托管异常并抛出托管异常。 这是 .NET 中的默认值。 -
Abort
:中止进程。 -
Disable
:禁用异常拦截。 在事件处理程序中设置此值没有意义(一旦引发事件,就太晚了,无法禁用截获异常)。 在任何情况下,如果设置,它将表现为UnwindManagedCode
。
因此,若要观察每次封送异常时,可以执行以下操作:
class MyApp {
static void Main (string args[])
{
Runtime.MarshalManagedException += (object sender, MarshalManagedExceptionEventArgs args) =>
{
Console.WriteLine ("Marshaling managed exception");
Console.WriteLine (" Exception: {0}", args.Exception);
Console.WriteLine (" Mode: {0}", args.ExceptionMode);
};
Runtime.MarshalObjectiveCException += (object sender, MarshalObjectiveCExceptionEventArgs args) =>
{
Console.WriteLine ("Marshaling Objective-C exception");
Console.WriteLine (" Exception: {0}", args.Exception);
Console.WriteLine (" Mode: {0}", args.ExceptionMode);
};
/// ...
}
}
小窍门
理想情况下,Objective-C 异常不应出现在行为良好的应用中(Apple 认为它们比托管异常更 异常 : “避免在寄送给用户的应用中引发 [Objective-C] 异常”)。 实现此目的的一种方法是为 Runtime.MarshalObjectiveCException 事件添加事件处理程序,使用遥测记录所有被封送的 Objective-C 异常(对于调试/本地生成,可以同时将异常模式设置为“中止”),以便检测并修复/避免这些异常。
Build-Time 标志
可以设置以下 MSBuild 属性,这将确定是否启用了异常拦截,并设置应发生的默认作:
- MarshalManagedExceptionMode:“default”、“unwindnativecode”、“throwobjectivecexception”、“abort”、“disable”。
- MarshalObjectiveCExceptionMode:“default”、“unwindmanagedcode”、“throwmanagedexception”、“abort”、“disable”。
示例:
<PropertyGroup>
<MarshalManagedExceptionMode>throwobjectivecexception</MarshalManagedExceptionMode>
<MarshalObjectiveCExceptionMode>throwmanagedexception</MarshalObjectiveCExceptionMode>
</PropertyGroup>
除了disable
之外,这些值与ExceptionMode
传递给MarshalManagedException和MarshalObjectiveCException事件的值相同。
该 disable
选项主要会禁用拦截,除了在不增加执行开销的情况下,我们仍会拦截异常。 这些异常仍会引发编组事件,默认模式是执行平台的预设模式。
局限性
我们仅在尝试捕获 Objective-C 异常时拦截 objc_msgSend
系列函数的 P/Invoke。 这意味着对另一个 C 函数进行 P/Invoke 调用时,如果该函数引发任何 Objective-C 异常,仍会遇到旧的未定义行为(这一问题可能将在未来得到改善)。