Writing
使用 SwiftUI 開發多個平台的應用
SwiftUI,iOS,macOS,tvOS · 2026-04-26
幾乎共享所有代碼
使用 SwiftUI 可共享大部分的代碼。我在編寫 Nomad Drive App 時候使用了此方案,借這次的開發過程,我總結了這篇文章來記錄一下我使用該方案遇到的問題。
歡迎下載體驗我寫的 Nomad Drive 體驗。
https://qoli.notion.site/Nomad-Drive-16f781c9681a487696bceeb7dd2bffbe
文件結構
以我最近開發的 Nomad 為例子
我為此項目建立如下的文件結構
- Shared:所有的共享文件
- Pikpak(iOS):iOS 的獨立文件,在建立 Targets 時候會自動建立
- macOS:建立 Targets 會自動建立
- tvOS:Targets 會自動建立
統一的 App 入口設定
//
// AppView.swift
// Pikpak macOS
//
// Created by 黃佁媛 on 2021/12/2.
//
import AVKit
import SwiftUI
struct AppView: View {
let persistenceController = PersistenceController.shared
#if !os(iOS)
@StateObject private var appViewModal = AppViewModal.shared
#endif
init() {
#if os(iOS)
let audioSession = AVAudioSession.sharedInstance()
do {
try audioSession.setCategory(.playback, mode: .moviePlayback)
} catch {
print("Setting category to AVAudioSessionCategoryPlayback failed.")
}
#endif
}
var body: some View {
#if !os(iOS)
let messageView = MessageView()
.environmentObject(appViewModal)
.transition(AnyTransition.asymmetric(insertion: .move(edge: .leading), removal: .opacity))
.opacity(appViewModal.show ? 1 : 0)
.animation(.easeInOut, value: appViewModal.show)
#endif
let sidebar = Sidebar()
.navigationTitle(Bundle.main.appName)
#if os(tvOS)
ZStack(alignment: .topLeading) {
NavigationView {
sidebar
}.navigationViewStyle(.stack)
messageView
}
.environment(\.managedObjectContext, persistenceController.container.viewContext)
.sheet(isPresented: $appViewModal.showSheet, onDismiss: nil) {
appViewModal.showView
}
#endif
#if os(macOS)
ZStack(alignment: .top) {
NavigationView {
Sidebar()
.navigationTitle(Bundle.main.appName)
Text("No Sidebar Selection")
}
messageView
}
.environment(\.managedObjectContext, persistenceController.container.viewContext)
.sheet(isPresented: $appViewModal.showSheet, onDismiss: nil) {
VStack {
HStack {
Text(appViewModal.sheetTitle)
.foregroundColor(.secondary)
Spacer()
Button {
appViewModal.showSheet = false
} label: {
Image(systemName: "xmark.circle.fill")
}.buttonStyle(.plain)
}
appViewModal.showView
.padding([.top, .leading, .trailing])
}
.padding()
.frame(minWidth: 360)
}
#endif
#if os(iOS)
NavigationView {
sidebar
if UIDevice.current.userInterfaceIdiom == .pad {
Text("No Sidebar Selection")
EmptyView()
}
}
.navigationViewStyle(.stack)
.environment(\.managedObjectContext, persistenceController.container.viewContext)
#endif
}
}我在 Shared、App 下建立一個統一的 AppView 入口。
然後,必須把每一個平台的 Targets 都重新指向到這個 AppView 上即可。
平台 Targets 的指向
//
// PikpakApp.swift
// Pikpak
//
// Created by 黃佁媛 on 2021/12/2.
//
import SwiftUI
@main
struct PikpakApp: App {
var body: some Scene {
WindowGroup {
AppView() //把這裡修改為統一 View 入口即可。
}
}
}這裡以 iOS 端的為例子
以上,你就完成了基本的多平台代碼重用的設定了。
issues
很可惜,這篇文章不是教程,只是記錄了一些我在使用此方案的問題
issue 01
只出現在 iPhone(iPad 不會出現),單例 @StateObject AppViewModal.shared 的 @Published 值變更導致了 NavigationView 回到了第一層
上面這個問題我解決了快 1 個小時,我最終的解決方案是:
#if !os(iOS)
@StateObject private var appViewModal = AppViewModal.shared
#endif我把這個 @StateObject 從 App 入口移除了 iOS 端的,並且重新為 iOS 寫了獨立的調用方案。
appViewModal 在我的 App 提供了 App 內的 Toast 樣式設定。
所以,在我的 showToast 就有了如下奇怪的代碼,使用 UIKit 方案載入此 Toast View。
if !show {
#if os(iOS)
let messageView = MessageWrapperView()
let hudView = UIHostingController(rootView: messageView)
hudView.view.frame.size = UIScreen.main.bounds.size
hudView.view.backgroundColor = UIColor.clear
hudView.view.tag = 1009
hudView.view.isUserInteractionEnabled = false
getTopMostViewController()?.view.addSubview(hudView.view)
#endif
}issue 02
不同平台,默認樣式的區別
這個問題是大量 #if os ... #endif 的出現的其中一個原因。
建議的解決方案是把需要的樣式封裝成為獨立的 WidgetView。
import SwiftUI
struct UniversalButton: View {
let title: String
let icon: String
let completionHandler: () -> Void
init(title: String, icon: String, completionHandler: @escaping () -> Void) {
self.title = title
self.icon = icon
self.completionHandler = completionHandler
}
var body: some View {
Button {
completionHandler()
} label: {
#if os(tvOS)
Text(title)
#else
Label(title, systemImage: icon)
.padding()
.background {
Color
.gray.opacity(0.1)
.cornerRadius(8)
}
#endif
}
.modify { button in
#if os(tvOS)
button
#else
button.buttonStyle(.plain)
#endif
}
}
}issue 03
macOS 端的 NavigationLink 運作思維與 iOS 端的思維不一致。
在 macOS 上,NavigationLink 只能在簡單的進行 View 的替換。他並非如同在 iOS 下一樣,會建立具備深度的路徑。而是根據你的 NavigationView 可用的 View 數量來替換(類似 iPad 的多欄佈局)
NavigationView {
Sidebar() // <- 紅色區域
.navigationTitle(Bundle.main.appName)
Text("No Sidebar Selection") // <- 黃色區域
}我們看了 NavigationView 的結構後,就會發現,
在 Sidebar 的 NavigationLink 會替換黃色區域(默認 View 為 Text),
然後在黃色區域的 NavigationLink 會由於沒有更多的可替換 View 從而使用了 Popup Windows 的樣式。
我的 Nomad app 在黃色區域(文件列表)是具備深度的,所以這個訪問深度的功能必須自行編寫,不能按 iOS 的思維一樣進行使用 NavigationLink 進行深度訪問。
issue 04
macOS 的 .toolbar 必須具備佔位 icon
在 macOS 下,toolbar 是不可以使用代碼動態切換 icon 數量的。如果希望 toolbar 一直可見,就必須保持最少 1 個 icon,否則,當沒有 icon 之後,會自動隱藏 toolbar 並且無法使用添加 icon 的方法恢復 toolbar 可見。
分享
Button {
gotoEdit = true
} label: {
Image(systemName: "pencil")
}
.iOS { button in
button.padding(.trailing)
}
Button {
save()
} label: {
Label("Save", systemImage: "externaldrive.badge.plus")
}
.disabled(displayName.isEmpty || username.isEmpty || password.isEmpty)
.modify {
#if !os(tvOS)
$0.keyboardShortcut(.defaultAction)
#else
$0
#endif
}分享一個 modification.swift 文件,方便大家可以優雅的進行 View 的修飾。