Writing

使用 SwiftUI 開發多個平台的應用

SwiftUI,iOS,macOS,tvOS · 2026-04-26

幾乎共享所有代碼

使用 SwiftUI 可共享大部分的代碼。我在編寫 Nomad Drive App 時候使用了此方案,借這次的開發過程,我總結了這篇文章來記錄一下我使用該方案遇到的問題。

歡迎下載體驗我寫的 Nomad Drive 體驗。

https://qoli.notion.site/Nomad-Drive-16f781c9681a487696bceeb7dd2bffbe

index-1.png

文件結構

index-1.png

以我最近開發的 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 的多欄佈局)

index-1 copy.png
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 的修飾。