SwiftUI如何向后兼容性
现在是时候开始发现WWDC 2020带来的所有新SwiftUI功能了。 但是,就像每年一样,几毫秒后,兴奋就消散了,当您记住放弃对较早版本的OS的支持并不是您的选择。
通常,我们求助于#available朋友。 例如,假设您有一个较长的HStack。 您可以决定使用新的LazyHStack,以利用其对长堆栈的性能改进。 但是,如果您的应用程序在iOS13上运行,则可以回退到使用普通的普通HStack:
Group {
Text("A long vertical view is below!")
if #available(iOS 14.0, *) {
LazyHStack {
View1()
View2()
}
} else {
// Fallback on earlier versions
HStack {
View1()
View2()
}
}
}
这很容易替代,但是在更复杂的情况下,您的后备代码将需要更极端的措施,例如由Representable包装的UIKit / AppKit视图。
这种方法对于一个很小的项目来说看起来不错,但是随着视图数量的增加,每次添加#available检查都会使您感到很烦。 而且,代码的可读性将遭受极大的损害。 对于这些情况,我们可以利用Swift可以在不同作用域中处理相同类型名称的事实。 让我用一个例子来说明:
// Now, the compiler will no longer complain about LazyHStack not being available on iOS13.
struct ContentView: View {
var body: some View {
LazyHStack(spacing: 30) {
View1()
View2()
}
}
}
struct LazyHStack<Content> : View where Content : View {
let alignment: VerticalAlignment
let spacing: CGFloat?
let content: () -> Content
var body: some View {
Group {
if #available(OSX 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) {
SwiftUI.LazyHStack(alignment: alignment, spacing: spacing, content: content)
} else {
// Fallback on earlier versions
HStack(alignment: alignment, spacing: spacing, content: content)
}
}
}
init(alignment: VerticalAlignment = .center, spacing: CGFloat? = nil, @ViewBuilder content: @escaping () -> Content) {
self.alignment = alignment
self.spacing = spacing
self.content = content
}
}
通过创建自己的LazyHStack(可在所有OS版本上使用),编译器将不再抱怨。 这是因为现在LazyHStack引用MyApp.LazyHStack而不是SwiftUI.LazyHStack。 然后,在我们自己的实现中,检查版本,然后决定是使用旧的SwiftUI.HStack还是新的SwiftUI.LazyHStack。
到目前为止,这有些琐碎。但是,现在我想将重点转移到新的SwiftUI带来的特定问题上。我正在谈论如何使用新的App和Scene API,以及启动我们的应用程序的“旧方法”。
拥抱变化
假设您有一个应用程序已经在较旧的操作系统版本下运行。现在,在见证了新的SwiftUI改进浪潮之后,您最终决定是时候让您的应用包含新框架了。我不会讨论SwiftUI是否足够成熟。这在很大程度上取决于您正在编写的应用程序的类型,但是就本文而言,我们假设SwiftUI确实适合您的应用程序。
对于此特定示例,我们将探讨是否有可能为运行早期OS版本的用户保留旧的UI,并从头开始重新设计SwiftUI界面,从而使运行iOS14.0 + / macOS11 +的用户受益
从Xcode 12开始,现在可以设计一个完全使用SwiftUI编写的应用程序。在过去(即去年),您仍然需要像往常一样连接场景/窗口的层次结构。不再需要,现在只需编写几行代码即可编写完整的应用程序,如下所示
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
Text("Hello, world!")
}
}
}
这虽然很棒,但在我们尝试同时保留旧UI和新UI时都提出了一个问题。 如果尝试使用“ if #available”来包装@main声明,则会收到编译器错误。
不允许在顶层使用#available,因此编译器将为我们提供以下错误:
#available
#available
幸运的是,仍然有解决方法。让我们来看看…
应用程序入口点(@main)
Swift 5.3具有一个名为@main(SE-0281)的新属性。由于这是该语言的一部分(即不是库),因此只要您使用Swift 5.3+进行编译,它就可以与旧版OS一起使用。我们这里真正的问题是,在较早的OS版本中不存在App协议。那么,考虑到整个应用程序中只能有一种带@main的带注释类型,我们如何解决呢?
还要注意,@main和@ UIApplicationMain / @ NSApplicationMain与main.swift中的顶级代码是互斥的。您只能使用一种类型的入口点。
如果我们查看文档,就会发现@main为您的应用程序提供了入口点。特别是,您的应用程序将从跳转到以@main开头的类型的main()函数开始。查看代码,我们可以推断出App协议必须具有main()函数的默认实现。确实如此,请在此处查看Apple的文档。
这是我们需要知道的全部信息,以便创建一个将新App协议用于新OS版本以及将旧UIApplication / NSApplication类型用于其他应用程序的应用程序。考虑到这一点,我们将需要按以下说明更改代码。
一些注意事项
- 后面的代码仅作为起点。 多年来,有许多方法可以启动应用程序(例如,主故事板,XIB文件,场景清单,手动调用UIApplicationMain等)。 这意味着应以不同的方式处理每种情况。 这里的代码只会为您指明正确的方向(我希望如此)。
- 在尝试进行此操作时,我发现有时该应用有些固执,无法按照我的预期去做。 我发现从模拟器中删除该应用程序并重新部署解决了该问题。 这可能与Info.plist中的更改未正确更新有关……但是我不确定。 只要记住它,就应该在您身上发生。
- 最后一点警告:您不喜欢这个,我很抱歉…但是我们在这里处理了一些未记录的行为,因此,风险自负!
iOS示例
在以下示例中,我们的旧UI使用UIHostingController作为主控制器,因此不会从情节提要中加载主场景。如果您愿意,它将需要更多的工作。我尚未尝试过,但是我为macOS应用做了类似的操作,下面将对此进行介绍。
首先,不要忘记删除旧的@UIApplicationMain批注。在下面的代码中,我将其注释掉,以便您明白我的意思。
确保Info.plist中的“应用程序场景清单”没有UISceneStoryboardFile设置。由于您使用的是基于UIHostingController的应用程序,因此不应该…但是您的代码可能已更新并留在了那里。如果Info.plist文件中包含UISceneStoryboardFile键的值,则该应用可能默认为该值,并忽略您的所有工作。所以要小心另外,如果更改了Info.plist,请记住,您可能需要从模拟器/设备中删除该应用程序,然后重新部署以确保更改生效。
在排除所有这些先决条件之后,让我们开始更新代码。
import SwiftUI
@main
struct MainApp {
static func main() {
if #available(iOS 14.0, *) {
MyNewUI.main()
} else {
UIApplicationMain(
CommandLine.argc,
CommandLine.unsafeArgv,
nil,
NSStringFromClass(AppDelegate.self))
}
}
}
@available(iOS 14.0, *)
struct MyNewUI: App {
var body: some Scene {
WindowGroup {
Text("This is my new UI! Pretty basic, huh?")
}
}
}
//@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate { ... }
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
let contentView = ContentView()
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UIHostingController(rootView: contentView)
self.window = window
window.makeKeyAndVisible()
}
}
func sceneDidDisconnect(_ scene: UIScene) { ... }
func sceneDidBecomeActive(_ scene: UIScene) { ... }
func sceneWillResignActive(_ scene: UIScene) { ... }
func sceneWillEnterForeground(_ scene: UIScene) { ... }
func sceneDidEnterBackground(_ scene: UIScene) { ... }
}
使用@available(iOS 14.0,*)注释App结构非常重要。 这将防止编译器抱怨,因为App在较早的OS版本中不存在。
类似的逻辑适用于macOS,让我们来看另一个示例。
macOS示例(#1)
在第一个macOS示例中,旧的UI使用的是NSHostingView。 第二个示例将使用故事板。
与iOS示例一样,有一些先决条件:
- 从您的Info.plist文件中删除NSMainStoryboardFile条目。
- 从Info.plist文件中删除NSPrincipalClass条目。
- 删除@NSApplicationMain批注。
如果不从Info.plist文件中删除这些条目,则在启动时可能会覆盖您的逻辑。 因此,请勿跳过该部分。
import SwiftUI
var appDelegate = AppDelegate()
@main
struct AppUserInterfaceSelector {
static func main() {
if #available(OSX 11.0, *) {
NewUIApp.main()
} else {
OldUIApp.main()
}
}
}
@available(OSX 11.0, *)
struct NewUIApp: App {
var body: some Scene {
WindowGroup() {
NewContentView()
}
}
}
struct OldUIApp {
static func main() {
NSApplication.shared.setActivationPolicy(.regular)
let nib = NSNib(nibNamed: NSNib.Name("MainMenu"), bundle: Bundle.main)
nib?.instantiate(withOwner: NSApplication.shared, topLevelObjects: nil)
NSApp.delegate = appDelegate
NSApp.activate(ignoringOtherApps: true)
NSApp.run()
}
}
class AppDelegate: NSObject, NSApplicationDelegate {
var window: NSWindow!
func applicationDidFinishLaunching(_ aNotification: Notification) {
// Create the SwiftUI view that provides the window contents.
let contentView = OldContentView()
// Create the window and set the content view.
window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 480, height: 300),
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
backing: .buffered, defer: false)
window.title = "Test Application"
window.isReleasedWhenClosed = false
window.center()
window.setFrameAutosaveName("Main Window")
window.contentView = NSHostingView(rootView: contentView)
window.makeKeyAndOrderFront(nil)
}
func applicationWillTerminate(_ aNotification: Notification) { ... }
}
上面的代码需要注意一些事情。 我们为AppDelegate创建了一个全局变量。 这是因为NSApp.delegate是一个弱属性,因此我们需要保留该对象。 还要注意,应用程序菜单是从xib文件加载的。
macOS示例(#2)
在我们的第二个macOS示例中,旧的UI将使用故事板,而不是NSHostingView。 因此,该部分已从applicationDidFinishLaunching中删除。 其余代码几乎相同,但是我们需要在OldUIApp.main()函数中添加几行:
struct OldUIApp {
static func main() {
NSApplication.shared.setActivationPolicy(.regular)
// Load MainMenu, from MainMenu.xib
let nib = NSNib(nibNamed: NSNib.Name("MainMenu"), bundle: Bundle.main)
nib?.instantiate(withOwner: NSApplication.shared, topLevelObjects: nil)
// Load Main storyboard and show main window
let sb = NSStoryboard(name: NSStoryboard.Name("Main"), bundle: .main)
let windowController = sb.instantiateInitialController() as? NSWindowController
windowController?.window?.makeKeyAndOrderFront(nil)
NSApp.delegate = appDelegate
NSApp.activate(ignoringOtherApps: true)
NSApp.run()
}
}
这个新版本的main()函数,加载情节提要,实例化窗口控制器并显示其窗口。 请注意,菜单仍来自xib文件。 我没有找到从情节提要中引用菜单的方法。 可能有办法,但我没有看。 如果您知道如何,请在下面发表评论。
更进一步
在上面的示例中,我们根据检测到的OS版本确定要运行的UI。 但是,没有什么可以阻止您基于其他事实做出该决定。 例如,在UserDefaults中保存的值,或在应用程序启动时按的键,或两者。 例如:
@main
struct AppUserInterfaceSelector {
static func main() {
if #available(OSX 11.0, *) {
// Use old interface, if SHIFT key is pressed during app launch, or
// if UseOldUI is set to true in UserDefaults.
if NSEvent.modifierFlags.contains(.shift) || UserDefaults.standard.bool(forKey: "UseOldUI") {
OldUIApp.main()
} else {
NewUIApp.main()
}
} else {
OldUIApp.main()
}
}
}
在上面的macOS示例中,如果应用程序默认设置中包含布尔键,或者如果在应用程序启动时按下SHIFT键,则将使用旧的UI,并且与运行该应用程序的OS版本无关。这对于测试非常有用。
如果您包括这种类型的有条件的UI选择,请小心,因为这可能会违反Apple Review Guidelines。请记住,App Store不能包含Beta版软件,因此可以选择其他UI。特别是如果“新”设计不是默认设计。无论如何,在App Store外部分发或进行自己的测试时,它都是完全安全的。
总结
每年,我们都会面临决定何时采用Apple在WWDC期间为我们带来的新技术的挑战。在前进或保持与旧OS版本的兼容性之间找到平衡并非易事。我希望本文中的提示能使您的决策更加轻松。