SwiftUI如何向后兼容性
現(xiàn)在是時候開始發(fā)現(xiàn)WWDC 2020帶來的所有新SwiftUI功能了。 但是,就像每年一樣,幾毫秒后,興奮就消散了,當(dāng)您記住放棄對較早版本的OS的支持并不是您的選擇。
通常,我們求助于#available朋友。 例如,假設(shè)您有一個較長的HStack。 您可以決定使用新的LazyHStack,以利用其對長堆棧的性能改進(jìn)。 但是,如果您的應(yīng)用程序在iOS13上運(yùn)行,則可以回退到使用普通的普通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()
}
}
}
這很容易替代,但是在更復(fù)雜的情況下,您的后備代碼將需要更極端的措施,例如由Representable包裝的UIKit / AppKit視圖。
這種方法對于一個很小的項目來說看起來不錯,但是隨著視圖數(shù)量的增加,每次添加#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
}
}
通過創(chuàng)建自己的LazyHStack(可在所有OS版本上使用),編譯器將不再抱怨。 這是因為現(xiàn)在LazyHStack引用MyApp.LazyHStack而不是SwiftUI.LazyHStack。 然后,在我們自己的實現(xiàn)中,檢查版本,然后決定是使用舊的SwiftUI.HStack還是新的SwiftUI.LazyHStack。
@available 參考資料: https://docs.swift.org/swift-book/ReferenceManual/Attributes.html#ID583.
到目前為止,這有些瑣碎。但是,現(xiàn)在我想將重點轉(zhuǎn)移到新的SwiftUI帶來的特定問題上。我正在談?wù)撊绾问褂眯碌腁pp和Scene API,以及啟動我們的應(yīng)用程序的“舊方法”。
擁抱變化
假設(shè)您有一個應(yīng)用程序已經(jīng)在較舊的操作系統(tǒng)版本下運(yùn)行。現(xiàn)在,在見證了新的SwiftUI改進(jìn)浪潮之后,您最終決定是時候讓您的應(yīng)用包含新框架了。我不會討論SwiftUI是否足夠成熟。這在很大程度上取決于您正在編寫的應(yīng)用程序的類型,但是就本文而言,我們假設(shè)SwiftUI確實適合您的應(yīng)用程序。
對于此特定示例,我們將探討是否有可能為運(yùn)行早期OS版本的用戶保留舊的UI,并從頭開始重新設(shè)計SwiftUI界面,從而使運(yùn)行iOS14.0 + / macOS11 +的用戶受益
從Xcode 12開始,現(xiàn)在可以設(shè)計一個完全使用SwiftUI編寫的應(yīng)用程序。在過去(即去年),您仍然需要像往常一樣連接場景/窗口的層次結(jié)構(gòu)。不再需要,現(xiàn)在只需編寫幾行代碼即可編寫完整的應(yīng)用程序,如下所示
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
Text("Hello, world!")
}
}
}
這雖然很棒,但在我們嘗試同時保留舊UI和新UI時都提出了一個問題。 如果嘗試使用“ if #available”來包裝@main聲明,則會收到編譯器錯誤。
不允許在頂層使用#available,因此編譯器將為我們提供以下錯誤:


幸運(yùn)的是,仍然有解決方法。讓我們來看看…
應(yīng)用程序入口點(@main)
Swift 5.3具有一個名為@main(SE-0281)的新屬性。由于這是該語言的一部分(即不是庫),因此只要您使用Swift 5.3+進(jìn)行編譯,它就可以與舊版OS一起使用。我們這里真正的問題是,在較早的OS版本中不存在App協(xié)議。那么,考慮到整個應(yīng)用程序中只能有一種帶@main的帶注釋類型,我們?nèi)绾谓鉀Q呢?
還要注意,@main和@ UIApplicationMain / @ NSApplicationMain與main.swift中的頂級代碼是互斥的。您只能使用一種類型的入口點。
如果我們查看文檔,就會發(fā)現(xiàn)@main為您的應(yīng)用程序提供了入口點。特別是,您的應(yīng)用程序?qū)奶D(zhuǎn)到以@main開頭的類型的main()函數(shù)開始。查看代碼,我們可以推斷出App協(xié)議必須具有main()函數(shù)的默認(rèn)實現(xiàn)。確實如此,請在此處查看Apple的文檔。
這是我們需要知道的全部信息,以便創(chuàng)建一個將新App協(xié)議用于新OS版本以及將舊UIApplication / NSApplication類型用于其他應(yīng)用程序的應(yīng)用程序??紤]到這一點,我們將需要按以下說明更改代碼。
一些注意事項
- 后面的代碼僅作為起點。 多年來,有許多方法可以啟動應(yīng)用程序(例如,主故事板,XIB文件,場景清單,手動調(diào)用UIApplicationMain等)。 這意味著應(yīng)以不同的方式處理每種情況。 這里的代碼只會為您指明正確的方向(我希望如此)。
- 在嘗試進(jìn)行此操作時,我發(fā)現(xiàn)有時該應(yīng)用有些固執(zhí),無法按照我的預(yù)期去做。 我發(fā)現(xiàn)從模擬器中刪除該應(yīng)用程序并重新部署解決了該問題。 這可能與Info.plist中的更改未正確更新有關(guān)……但是我不確定。 只要記住它,就應(yīng)該在您身上發(fā)生。
- 最后一點警告:您不喜歡這個,我很抱歉…但是我們在這里處理了一些未記錄的行為,因此,風(fēng)險自負(fù)!
iOS示例
在以下示例中,我們的舊UI使用UIHostingController作為主控制器,因此不會從情節(jié)提要中加載主場景。如果您愿意,它將需要更多的工作。我尚未嘗試過,但是我為macOS應(yīng)用做了類似的操作,下面將對此進(jìn)行介紹。
首先,不要忘記刪除舊的@UIApplicationMain批注。在下面的代碼中,我將其注釋掉,以便您明白我的意思。
確保Info.plist中的“應(yīng)用程序場景清單”沒有UISceneStoryboardFile設(shè)置。由于您使用的是基于UIHostingController的應(yīng)用程序,因此不應(yīng)該…但是您的代碼可能已更新并留在了那里。如果Info.plist文件中包含UISceneStoryboardFile鍵的值,則該應(yīng)用可能默認(rèn)為該值,并忽略您的所有工作。所以要小心另外,如果更改了Info.plist,請記住,您可能需要從模擬器/設(shè)備中刪除該應(yīng)用程序,然后重新部署以確保更改生效。
在排除所有這些先決條件之后,讓我們開始更新代碼。
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結(jié)構(gòu)非常重要。 這將防止編譯器抱怨,因為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) { ... }
}
上面的代碼需要注意一些事情。 我們?yōu)锳ppDelegate創(chuàng)建了一個全局變量。 這是因為NSApp.delegate是一個弱屬性,因此我們需要保留該對象。 還要注意,應(yīng)用程序菜單是從xib文件加載的。
macOS示例(#2)
在我們的第二個macOS示例中,舊的UI將使用故事板,而不是NSHostingView。 因此,該部分已從applicationDidFinishLaunching中刪除。 其余代碼幾乎相同,但是我們需要在OldUIApp.main()函數(shù)中添加幾行:
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()函數(shù),加載情節(jié)提要,實例化窗口控制器并顯示其窗口。 請注意,菜單仍來自xib文件。 我沒有找到從情節(jié)提要中引用菜單的方法。 可能有辦法,但我沒有看。 如果您知道如何,請在下面發(fā)表評論。
更進(jìn)一步
在上面的示例中,我們根據(jù)檢測到的OS版本確定要運(yùn)行的UI。 但是,沒有什么可以阻止您基于其他事實做出該決定。 例如,在UserDefaults中保存的值,或在應(yīng)用程序啟動時按的鍵,或兩者。 例如:
@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示例中,如果應(yīng)用程序默認(rèn)設(shè)置中包含布爾鍵,或者如果在應(yīng)用程序啟動時按下SHIFT鍵,則將使用舊的UI,并且與運(yùn)行該應(yīng)用程序的OS版本無關(guān)。這對于測試非常有用。
如果您包括這種類型的有條件的UI選擇,請小心,因為這可能會違反Apple Review Guidelines。請記住,App Store不能包含Beta版軟件,因此可以選擇其他UI。特別是如果“新”設(shè)計不是默認(rèn)設(shè)計。無論如何,在App Store外部分發(fā)或進(jìn)行自己的測試時,它都是完全安全的。
總結(jié)
每年,我們都會面臨決定何時采用Apple在WWDC期間為我們帶來的新技術(shù)的挑戰(zhàn)。在前進(jìn)或保持與舊OS版本的兼容性之間找到平衡并非易事。我希望本文中的提示能使您的決策更加輕松。
原文地址
https://swiftui-lab.com/backward-compatibility/
推薦
基礎(chǔ)文章推薦
經(jīng)典教程推薦
技術(shù)源碼推薦
推薦文章
CoreData篇
- SwiftUI數(shù)據(jù)存儲之做個筆記App 新增與查詢(CoreData)
- SwiftUI進(jìn)階之存儲用戶狀態(tài)實現(xiàn)登錄與登出
- SwiftUI 數(shù)據(jù)之List顯示Sqlite數(shù)據(jù)庫內(nèi)容(2020年教程)
Combine篇
TextField篇
- 《SwiftUI 一篇文章全面掌握TextField文本框 (教程和全部源碼)》
- 《SwiftUI實戰(zhàn)之TextField風(fēng)格自定義與formatters》
- 《SwiftUI實戰(zhàn)之TextField如何給鍵盤增加個返回按鈕(隱藏鍵盤)》
- 《SwiftUI 當(dāng)鍵盤出現(xiàn)時避免TextField被遮擋自動向上移動》
- 《SwiftUI實戰(zhàn)之TextField如何給鍵盤增加個返回按鈕(隱藏鍵盤)》
JSON文件篇
一篇文章系列
- SwiftUI一篇文章全面掌握List(教程和源碼)
- 《SwiftUI 一篇文章全面掌握TextField文本框 (教程和全部源碼)》
- SwiftUI一篇文章全面掌握Picker,解決數(shù)據(jù)選擇(教程和源碼)
- SwiftUI一篇文章全面掌握Form(教程和源碼)
- SwiftUI Color 顏色一篇文章全解決
技術(shù)交流
QQ:3365059189
SwiftUI技術(shù)交流QQ群:518696470
- 請關(guān)注我的專欄icloudend, SwiftUI教程與源碼
http://www.itdecent.cn/c/7b3e3b671970