受管理的程式碼和 Objective-C 都支援執行階段例外狀況(try/catch/finally 子句)。
不過,由於它們的實作方式不同,這表示當執行階段庫(MonoVM/CoreCLR 執行階段和 Objective-C 執行階段庫)遭遇其他執行階段的異常時會出現問題。
本文說明可能發生的問題,以及可能的解決方案。
它也包含範例專案 例外狀況封送處理,可用來測試不同的案例及其解決方案。
問題
擲回例外狀況時發生問題,而且在堆疊展開期間,遇到的堆疊框架不符合擲回的例外狀況類型。
此問題的一個典型範例是,當原生 API 擲回 Objective-C 例外狀況時,當堆疊回溯進程到達受控框架時,必須以某種方式處理該 Objective-C 例外狀況。
過去(pre-.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 堆棧回溯器 無法 正確回溯 Managed 框架(畫面 4-6):Objective-C 堆棧回溯器會回溯 Managed 框架,但不會執行任何 Managed 例外狀況邏輯(例如 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 例外狀況轉換成 Managed 例外狀況。
情境
案例 1 - 使用 Managed catch 處理程式攔截 Objective-C 例外狀況
在下列案例中,您可以使用 Managed catch 處理程序攔截 Objective-C 例外狀況:
- 拋出 Objective-C 例外狀況。
- Objective-C 運行時間會逐步遍歷堆疊(但不會展開堆疊),尋找可處理例外狀況的原生
@catch處理程式。 - 執行階段 Objective-C 找不到任何
@catch處理程式,呼叫NSGetUncaughtExceptionHandler,並叫用由 .NET SDK 安裝的處理程式。 - .NET SDKs 的處理程式將會把 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運行時間並不知道。
其中一種變化是拋出 Managed 程式碼中的 Managed 例外狀況,然後透過原生框架回溯,以抵達第一個 Managed 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.");
}
}
}
Managed UIApplication:Main 方法會呼叫原生 UIApplicationMain 方法,然後 iOS 會在最終呼叫 Managed 方法之前執行許多原生程式代碼,並在擲回 Managed 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 運行時間支援在 Managed 例外狀況處理期間回溯原生畫面。 CoreCLR 執行時間只會在遇到這種情況時中止進程(CoreCLR 運行時間用於 macOS 應用程式,以及在任何平臺上啟用 NativeAOT 時)。
場景 2 - 無法捕捉 Objective-C 例外狀況
在下列案例中, 無法使用 Managed 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,讓 UI 循環無法攔截所有例外狀況:
var defs = new NSDictionary ((NSString) "NSApplicationCrashOnExceptions", NSNumber.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"));
}
}
對 objc_msgSend 的 P/Invoke 呼叫已被攔截,並改為呼叫此段程式碼。
void
xamarin_dyn_objc_msgSend (id obj, SEL sel)
{
@try {
objc_msgSend (obj, sel);
} @catch (NSException *ex) {
convert_to_and_throw_managed_exception (ex);
}
}
反向情況也採取了類似的措施(將 managed 例外狀況轉換為 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 例外狀況時才會攔截 P/Invokes 至 objc_msgSend 函式系列。 這表示 P/Invoke 到另一個 C 函式,然後擲回任何 Objective-C 例外狀況,仍然會遇到舊和未定義的行為(未來可能會改善)。