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,因此编译器将为我们提供以下错误:





swift跟swiftUi有什么区别_应用程序


#available


swift跟swiftUi有什么区别_UI_02


#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版本的兼容性之间找到平衡并非易事。我希望本文中的提示能使您的决策更加轻松。