如何使用 Xamarin.Forms Shell 在 iOS App 内加载页面?
结论
FormsApplicationDelegate 是 Xamarin.Forms 对 UIApplicationDelegate 的封装,iOS 仍然有 root view controller。问题通常出在以下几个方面:
- 在
base.FinishedLaunching(...)完成前读取了RootViewController; - 使用
UIApplication.SharedApplication.KeyWindow,但当前没有可用的KeyWindow; - 额外创建了
UIWindow,覆盖或绕过 Xamarin.Forms 创建的窗口; - 直接从根控制器展示页面,没有使用当前最上层的控制器;
- 支付 SDK 要求跳转浏览器或外部支付 App,无法通过导航代码强制改成内嵌页面。
不要在 AppDelegate 中创建第二个空白 UIWindow。直接使用 Xamarin.Forms 已经创建的 Window.RootViewController,找到当前可见的 UIViewController,然后在主线程调用 PresentViewController。
如果支付 SDK 明确要求传入 UINavigationController,可以从当前控制器层级中查找。找不到时,也可以用新的 UINavigationController 包装支付控制器,再以 modal 方式展示。
为什么 KeyWindow.RootViewController 可能是 null
下面的继承关系没有问题:
public partial class AppDelegate :
Xamarin.Forms.Platform.iOS.FormsApplicationDelegate
FormsApplicationDelegate 仍然参与标准的 iOS application delegate 生命周期。Xamarin.Forms 主要在执行 base.FinishedLaunching(...) 时初始化原生窗口和根控制器。
例如:
LoadApplication(new App());
return base.FinishedLaunching(app, options);
如果在 return base.FinishedLaunching(...) 之前查询 root view controller,它可能还没有准备好。
另外,在多窗口生命周期下,UIApplication.SharedApplication.KeyWindow 并不可靠。即使项目没有主动使用 Scene,也不应把它作为唯一入口。
保留 Xamarin.Forms 创建的窗口
AppDelegate 可以写成:
[Register("AppDelegate")]
public partial class AppDelegate :
global::Xamarin.Forms.Platform.iOS.FormsApplicationDelegate
{
public override bool FinishedLaunching(
UIApplication app,
NSDictionary options)
{
global::Xamarin.Forms.Forms.SetFlags("CollectionView_Experimental");
global::Xamarin.Forms.Forms.Init();
LoadApplication(new App());
return base.FinishedLaunching(app, options);
}
}
不要再执行以下代码:
window = new UIWindow(UIScreen.MainScreen.Bounds);
window.RootViewController = new UIViewController();
window.MakeKeyAndVisible();
这段代码会创建一个与 Xamarin.Forms Shell 无关的新窗口。它的空控制器不包含 Shell,也没有 Shell 当前的导航状态,还可能取代原窗口成为 key window。
获取 Xamarin.Forms 的原生根控制器
在 iOS 项目中,可以优先通过 AppDelegate.Window 获取 Xamarin.Forms 使用的窗口,同时为其他生命周期状态提供后备查找:
using System.Linq;
using UIKit;
public static class ViewControllerLocator
{
public static UIViewController GetRootViewController()
{
var appDelegate =
UIApplication.SharedApplication.Delegate as AppDelegate;
var root = appDelegate?.Window?.RootViewController;
if (root != null)
return root;
var window = UIApplication.SharedApplication.Windows
.FirstOrDefault(x => x.IsKeyWindow)
?? UIApplication.SharedApplication.Windows
.FirstOrDefault(x => !x.Hidden);
return window?.RootViewController;
}
}
如果支付请求可能在应用刚启动、恢复前台或 Shell 页面尚未显示时触发,root view controller 仍可能暂时为空。遇到这种情况,应延后支付操作,不要创建新窗口来顶替它。
找到当前可用于展示页面的控制器
Shell 的 root view controller 通常是容器控制器。直接对根控制器调用 PresentViewController,可能出现“控制器不在 window hierarchy 中”的错误,或者新页面被其他 modal 覆盖。
可以递归查找当前最上层的控制器:
using UIKit;
public static class ViewControllerLocator
{
public static UIViewController GetRootViewController()
{
var appDelegate =
UIApplication.SharedApplication.Delegate as AppDelegate;
var root = appDelegate?.Window?.RootViewController;
if (root != null)
return root;
foreach (var window in UIApplication.SharedApplication.Windows)
{
if (window.IsKeyWindow && window.RootViewController != null)
return window.RootViewController;
}
return null;
}
public static UIViewController GetVisibleViewController(
UIViewController controller = null)
{
controller ??= GetRootViewController();
if (controller == null)
return null;
if (controller.PresentedViewController != null)
{
return GetVisibleViewController(
controller.PresentedViewController);
}
if (controller is UINavigationController navigationController)
{
return GetVisibleViewController(
navigationController.VisibleViewController
?? navigationController.TopViewController);
}
if (controller is UITabBarController tabBarController)
{
return GetVisibleViewController(
tabBarController.SelectedViewController);
}
foreach (var child in controller.ChildViewControllers)
{
if (child.ViewIfLoaded?.Window != null)
return GetVisibleViewController(child);
}
return controller;
}
public static UINavigationController FindNavigationController(
UIViewController controller)
{
if (controller == null)
return null;
if (controller is UINavigationController navigationController)
return navigationController;
if (controller.NavigationController != null)
return controller.NavigationController;
if (controller.PresentedViewController != null)
{
var presentedResult = FindNavigationController(
controller.PresentedViewController);
if (presentedResult != null)
return presentedResult;
}
foreach (var child in controller.ChildViewControllers)
{
var childResult = FindNavigationController(child);
if (childResult != null)
return childResult;
}
return null;
}
}
ChildViewControllers 的具体结构属于 Xamarin.Forms Shell 的内部实现,不要依赖固定下标或某个私有类型。上面的代码只按照 UIKit 的标准容器关系查找控制器。
在 App 内展示支付页面
如果 payObj.Checkout(payment) 返回可嵌入的 UIView,可以把它加入 UIViewController,再通过当前可见的控制器展示:
using System;
using CoreGraphics;
using Foundation;
using UIKit;
public void PaymentRequest(PaymentData paymentData)
{
UIApplication.SharedApplication.BeginInvokeOnMainThread(() =>
{
var payment = new Payment
{
RefNo = paymentData.ReferenceNumber
// 设置其他支付参数
};
var payObject = new PayObject();
// 设置 payObject 的其他参数
UIView paymentView;
try
{
paymentView = payObject.Checkout(payment);
}
catch (Exception ex)
{
// 按项目的日志和错误处理规范处理
System.Diagnostics.Debug.WriteLine(ex);
return;
}
if (paymentView == null)
return;
var presenter =
ViewControllerLocator.GetVisibleViewController();
if (presenter == null || presenter.View?.Window == null)
{
// 当前原生页面尚未进入 window hierarchy,应稍后重试或提示用户。
return;
}
var paymentController = new UIViewController();
paymentView.Frame = paymentController.View.Bounds;
paymentView.AutoresizingMask =
UIViewAutoresizing.FlexibleWidth |
UIViewAutoresizing.FlexibleHeight;
paymentController.View.AddSubview(paymentView);
paymentController.ModalPresentationStyle =
UIModalPresentationStyle.FullScreen;
presenter.PresentViewController(
paymentController,
true,
null);
});
}
不要先给一个 UIView 设置 Frame,再用 SDK 的返回值覆盖该变量:
UIView responseView = new UIView();
responseView.Frame = UIScreen.MainScreen.Bounds;
responseView = payObj.Checkout(payment);
这里的 Frame 设置在第一个临时对象上,对 SDK 返回的 responseView 没有作用。应该先取得返回值,再设置布局。
SDK 要求 UINavigationController 时
不能通过下面的 Xamarin.Forms API,把原生控制器直接推入 Shell:
Shell.Current.Navigation.PushAsync(...)
PushAsync 接收 Xamarin.Forms Page,不接受 UIKit 的 UIViewController。
如果当前控制器位于原生 UINavigationController 中,可以这样处理:
UIApplication.SharedApplication.BeginInvokeOnMainThread(() =>
{
var visibleController =
ViewControllerLocator.GetVisibleViewController();
var navigationController =
ViewControllerLocator.FindNavigationController(
visibleController);
if (navigationController != null)
{
navigationController.PushViewController(
paymentController,
true);
return;
}
var paymentNavigationController =
new UINavigationController(paymentController);
paymentNavigationController.ModalPresentationStyle =
UIModalPresentationStyle.FullScreen;
visibleController?.PresentViewController(
paymentNavigationController,
true,
null);
});
在 Shell 项目中,不能假定顶层一定有可直接操作的 UINavigationController。对于一次性支付流程,用 full-screen modal 展示包装后的 UINavigationController,通常比直接修改 Shell 内部导航栈更稳妥。
还需要确认支付 SDK 的展示模式
如果按上述方式处理后,支付流程仍然会“跳出 App”,需要检查支付 SDK 的接口约定。下面这些情况与 root view controller 无关:
- SDK 内部调用了
UIApplication.OpenUrl; - SDK 使用外部浏览器完成支付;
- SDK 跳转到银行、钱包或支付平台 App;
- SDK 使用
ASWebAuthenticationSession,由系统管理认证窗口; - SDK 要求传入 presenting view controller,但当前调用的重载没有传入;
- SDK 返回的
UIView只是启动入口,后续支付步骤仍通过外部 URL 完成。
如果 SDK 提供类似下面的接口,应优先传入当前可见的控制器:
var presenter =
ViewControllerLocator.GetVisibleViewController();
payObject.Checkout(payment, presenter);
这只是接口形式示意。实际方法名和参数必须以该支付 SDK 的 iOS 文档为准,不能自行假定这个重载存在。
如果支付平台出于安全、认证或 App Store 合规要求,规定支付流程必须跳转外部页面,就无法通过修改 Xamarin.Forms 或 UIKit 导航代码强制内嵌。此时应配置 URL Scheme、Universal Link 或 SDK 回调,让用户在支付完成后返回应用。
注意事项
所有 UIKit 操作都必须在 iOS 主线程执行。支付按钮也应等到 Shell 页面完全显示后再允许触发,以免控制器尚未进入 window hierarchy。
关闭 modal 支付页面时,应调用:
paymentController.DismissViewController(true, null);
如果使用原生 push,则调用:
navigationController.PopViewController(true);
展示方式和关闭方式需要配对。不要用 PresentViewController 打开页面,再用 Shell 的 PopAsync 关闭。Xamarin.Forms Shell 负责 Forms 页面导航,UIKit 负责第三方原生控制器。两套导航可以同时存在,但同一个页面的打开和关闭流程不能混用。
备注:内容仅供参考。
