cross-platform
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseCross-Platform: macOS ↔ iOS
跨平台开发:macOS ↔ iOS
Patterns for sharing code between macOS and iOS, building iOS-specific extensions, and syncing data across platforms.
本文介绍macOS与iOS之间的代码共享模式、iOS专属扩展的构建方法,以及跨平台的数据同步方案。
Critical Constraints
关键约束
- ❌ Never import or
AppKitin shared code — use SwiftUI types andUIKitfor platform-specific imports#if os() - ❌ Never use /
NSColordirectly in shared views — use SwiftUIUIColorColor - ❌ Never use /
NSFontdirectly — use SwiftUIUIFont.font() - ✅ Abstract platform services behind protocols
- ✅ Use /
#if os(macOS)for platform-specific implementations#if os(iOS) - ✅ Use for adaptive layouts within a platform
@Environment(\.horizontalSizeClass)
- ❌ 永远不要在共享代码中导入或
AppKit——使用SwiftUI类型和UIKit进行平台专属导入#if os() - ❌ 永远不要在共享视图中直接使用/
NSColor——使用SwiftUI的UIColorColor - ❌ 永远不要直接使用/
NSFont——使用SwiftUI的UIFont.font() - ✅ 通过协议抽象平台服务
- ✅ 使用/
#if os(macOS)编写平台专属实现#if os(iOS) - ✅ 使用实现平台内的自适应布局
@Environment(\.horizontalSizeClass)
Project Structure
项目结构
MyApp/
├── Shared/ # 70-80% of code
│ ├── Models/ # SwiftData models
│ ├── ViewModels/ # @Observable view models
│ ├── Services/
│ │ ├── StorageService.swift
│ │ ├── SyncService.swift
│ │ └── ClipboardService.swift # Protocol — platform-abstracted
│ ├── Views/
│ │ ├── Components/ # Shared UI: cards, rows, badges
│ │ └── Screens/ # Platform-adapted via #if os()
│ └── Extensions/
├── macOS/ # 15-20% — Mac-specific
│ ├── App/
│ │ ├── MacApp.swift
│ │ └── AppDelegate.swift
│ ├── Services/
│ │ ├── HotkeyManager.swift
│ │ ├── MenuBarController.swift
│ │ └── MacClipboardService.swift
│ └── Views/
│ ├── FloatingPanel.swift
│ └── QuickAccessView.swift
├── iOS/ # 15-20% — iOS-specific
│ ├── App/
│ │ └── iOSApp.swift
│ ├── Services/
│ │ ├── KeyboardExtension/
│ │ └── iOSClipboardService.swift
│ └── Views/
│ ├── MainTabView.swift
│ └── WidgetView.swift
├── Widgets/ # Shared widget target
└── MyApp.xcodeprojMyApp/
├── Shared/ # 70-80% of code
│ ├── Models/ # SwiftData models
│ ├── ViewModels/ # @Observable view models
│ ├── Services/
│ │ ├── StorageService.swift
│ │ ├── SyncService.swift
│ │ └── ClipboardService.swift # Protocol — platform-abstracted
│ ├── Views/
│ │ ├── Components/ # Shared UI: cards, rows, badges
│ │ └── Screens/ # Platform-adapted via #if os()
│ └── Extensions/
├── macOS/ # 15-20% — Mac-specific
│ ├── App/
│ │ ├── MacApp.swift
│ │ └── AppDelegate.swift
│ ├── Services/
│ │ ├── HotkeyManager.swift
│ │ ├── MenuBarController.swift
│ │ └── MacClipboardService.swift
│ └── Views/
│ ├── FloatingPanel.swift
│ └── QuickAccessView.swift
├── iOS/ # 15-20% — iOS-specific
│ ├── App/
│ │ └── iOSApp.swift
│ ├── Services/
│ │ ├── KeyboardExtension/
│ │ └── iOSClipboardService.swift
│ └── Views/
│ ├── MainTabView.swift
│ └── WidgetView.swift
├── Widgets/ # Shared widget target
└── MyApp.xcodeprojPlatform Abstraction
平台抽象
Protocol-Based Services
基于协议的服务
swift
// Shared/Services/ClipboardServiceProtocol.swift
protocol ClipboardServiceProtocol {
func copy(_ text: String)
func read() -> String?
}
// macOS/Services/MacClipboardService.swift
#if os(macOS)
import AppKit
class MacClipboardService: ClipboardServiceProtocol {
func copy(_ text: String) {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(text, forType: .string)
}
func read() -> String? { NSPasteboard.general.string(forType: .string) }
}
#endif
// iOS/Services/iOSClipboardService.swift
#if os(iOS)
import UIKit
class iOSClipboardService: ClipboardServiceProtocol {
func copy(_ text: String) { UIPasteboard.general.string = text }
func read() -> String? { UIPasteboard.general.string }
}
#endif
// Shared/Services/ClipboardService.swift
class ClipboardService {
static var shared: ClipboardServiceProtocol = {
#if os(macOS)
return MacClipboardService()
#else
return iOSClipboardService()
#endif
}()
}swift
// Shared/Services/ClipboardServiceProtocol.swift
protocol ClipboardServiceProtocol {
func copy(_ text: String)
func read() -> String?
}
// macOS/Services/MacClipboardService.swift
#if os(macOS)
import AppKit
class MacClipboardService: ClipboardServiceProtocol {
func copy(_ text: String) {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(text, forType: .string)
}
func read() -> String? { NSPasteboard.general.string(forType: .string) }
}
#endif
// iOS/Services/iOSClipboardService.swift
#if os(iOS)
import UIKit
class iOSClipboardService: ClipboardServiceProtocol {
func copy(_ text: String) { UIPasteboard.general.string = text }
func read() -> String? { UIPasteboard.general.string }
}
#endif
// Shared/Services/ClipboardService.swift
class ClipboardService {
static var shared: ClipboardServiceProtocol = {
#if os(macOS)
return MacClipboardService()
#else
return iOSClipboardService()
#endif
}()
}Conditional Compilation in Views
视图中的条件编译
swift
struct PromptListView: View {
var body: some View {
#if os(macOS)
NavigationSplitView {
sidebar
} detail: {
detail
}
#else
NavigationStack {
list
}
#endif
}
}swift
struct PromptListView: View {
var body: some View {
#if os(macOS)
NavigationSplitView {
sidebar
} detail: {
detail
}
#else
NavigationStack {
list
}
#endif
}
}Environment-Based Adaptation (iPad vs iPhone)
基于环境的自适应(iPad vs iPhone)
swift
struct AdaptiveView: View {
@Environment(\.horizontalSizeClass) var sizeClass
var body: some View {
if sizeClass == .compact {
VStack { content } // iPhone
} else {
HStack { content } // iPad / Mac
}
}
}swift
struct AdaptiveView: View {
@Environment(\.horizontalSizeClass) var sizeClass
var body: some View {
if sizeClass == .compact {
VStack { content } // iPhone
} else {
HStack { content } // iPad / Mac
}
}
}Shared Components with Platform Styling
带平台样式的共享组件
swift
struct GlassCard<Content: View>: View {
let content: Content
init(@ViewBuilder content: () -> Content) { self.content = content() }
var body: some View {
content
.padding()
.glassEffect(.regular, in: .rect(cornerRadius: 16))
}
}
struct PrimaryButton: View {
let title: String
let action: () -> Void
var body: some View {
Button(title, action: action)
.buttonStyle(.glassProminent)
#if os(macOS)
.controlSize(.large)
#endif
}
}swift
struct GlassCard<Content: View>: View {
let content: Content
init(@ViewBuilder content: () -> Content) { self.content = content() }
var body: some View {
content
.padding()
.glassEffect(.regular, in: .rect(cornerRadius: 16))
}
}
struct PrimaryButton: View {
let title: String
let action: () -> Void
var body: some View {
Button(title, action: action)
.buttonStyle(.glassProminent)
#if os(macOS)
.controlSize(.large)
#endif
}
}iOS Extensions
iOS扩展
Custom Keyboard Extension
自定义键盘扩展
Replaces global hotkey on iOS — users type prompts via custom keyboard.
swift
// iOS/KeyboardExtension/KeyboardViewController.swift
import UIKit
import SwiftUI
class KeyboardViewController: UIInputViewController {
override func viewDidLoad() {
super.viewDidLoad()
let hostingController = UIHostingController(rootView: KeyboardView(
onSelect: { [weak self] prompt in
self?.textDocumentProxy.insertText(prompt.content)
}
))
addChild(hostingController)
view.addSubview(hostingController.view)
hostingController.view.frame = view.bounds
hostingController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
}
}在iOS上替代全局快捷键——用户可通过自定义键盘输入提示内容。
swift
// iOS/KeyboardExtension/KeyboardViewController.swift
import UIKit
import SwiftUI
class KeyboardViewController: UIInputViewController {
override func viewDidLoad() {
super.viewDidLoad()
let hostingController = UIHostingController(rootView: KeyboardView(
onSelect: { [weak self] prompt in
self?.textDocumentProxy.insertText(prompt.content)
}
))
addChild(hostingController)
view.addSubview(hostingController.view)
hostingController.view.frame = view.bounds
hostingController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
}
}Interactive Widgets (Home Screen + Lock Screen)
交互式小组件(主屏幕 + 锁屏)
swift
import WidgetKit
import SwiftUI
import AppIntents
struct PromptWidget: Widget {
let kind = "PromptWidget"
var body: some WidgetConfiguration {
AppIntentConfiguration(
kind: kind,
intent: SelectPromptsIntent.self,
provider: PromptTimelineProvider()
) { entry in
PromptWidgetView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName("Quick Prompts")
.description("Tap to copy your favorite prompts")
.supportedFamilies([.systemSmall, .systemMedium, .accessoryRectangular])
}
}
// Widget buttons trigger App Intents directly
struct PromptWidgetView: View {
let entry: PromptEntry
var body: some View {
VStack(alignment: .leading, spacing: 8) {
ForEach(entry.prompts) { prompt in
Button(intent: CopyPromptIntent(prompt: prompt.entity)) {
HStack {
Image(systemName: prompt.icon).foregroundStyle(.secondary)
Text(prompt.title).lineLimit(1)
Spacer()
Image(systemName: "doc.on.clipboard").font(.caption).foregroundStyle(.tertiary)
}
.padding(.vertical, 4)
}
.buttonStyle(.plain)
}
}
.padding()
}
}swift
import WidgetKit
import SwiftUI
import AppIntents
struct PromptWidget: Widget {
let kind = "PromptWidget"
var body: some WidgetConfiguration {
AppIntentConfiguration(
kind: kind,
intent: SelectPromptsIntent.self,
provider: PromptTimelineProvider()
) { entry in
PromptWidgetView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName("Quick Prompts")
.description("Tap to copy your favorite prompts")
.supportedFamilies([.systemSmall, .systemMedium, .accessoryRectangular])
}
}
// Widget buttons trigger App Intents directly
struct PromptWidgetView: View {
let entry: PromptEntry
var body: some View {
VStack(alignment: .leading, spacing: 8) {
ForEach(entry.prompts) { prompt in
Button(intent: CopyPromptIntent(prompt: prompt.entity)) {
HStack {
Image(systemName: prompt.icon).foregroundStyle(.secondary)
Text(prompt.title).lineLimit(1)
Spacer()
Image(systemName: "doc.on.clipboard").font(.caption).foregroundStyle(.tertiary)
}
.padding(.vertical, 4)
}
.buttonStyle(.plain)
}
}
.padding()
}
}Share Extension (Save text from other apps)
分享扩展(从其他应用保存文本)
swift
import UIKit
import SwiftUI
import UniformTypeIdentifiers
class ShareViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
guard let item = extensionContext?.inputItems.first as? NSExtensionItem,
let provider = item.attachments?.first(where: {
$0.hasItemConformingToTypeIdentifier(UTType.plainText.identifier)
}) else { close(); return }
provider.loadItem(forTypeIdentifier: UTType.plainText.identifier) { [weak self] text, _ in
DispatchQueue.main.async {
if let text = text as? String { self?.showSaveUI(text: text) }
else { self?.close() }
}
}
}
func showSaveUI(text: String) {
let saveView = SavePromptView(initialContent: text,
onSave: { [weak self] title, content, category in
SharedPromptStore.shared.add(SharedPrompt(title: title, content: content, category: category))
self?.close()
},
onCancel: { [weak self] in self?.close() }
)
let hc = UIHostingController(rootView: saveView)
addChild(hc)
view.addSubview(hc.view)
hc.view.frame = view.bounds
hc.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
}
func close() { extensionContext?.completeRequest(returningItems: nil) }
}swift
import UIKit
import SwiftUI
import UniformTypeIdentifiers
class ShareViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
guard let item = extensionContext?.inputItems.first as? NSExtensionItem,
let provider = item.attachments?.first(where: {
$0.hasItemConformingToTypeIdentifier(UTType.plainText.identifier)
}) else { close(); return }
provider.loadItem(forTypeIdentifier: UTType.plainText.identifier) { [weak self] text, _ in
DispatchQueue.main.async {
if let text = text as? String { self?.showSaveUI(text: text) }
else { self?.close() }
}
}
}
func showSaveUI(text: String) {
let saveView = SavePromptView(initialContent: text,
onSave: { [weak self] title, content, category in
SharedPromptStore.shared.add(SharedPrompt(title: title, content: content, category: category))
self?.close()
},
onCancel: { [weak self] in self?.close() }
)
let hc = UIHostingController(rootView: saveView)
addChild(hc)
view.addSubview(hc.view)
hc.view.frame = view.bounds
hc.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
}
func close() { extensionContext?.completeRequest(returningItems: nil) }
}Control Center Widget (iOS 18+)
控制中心小组件(iOS 18+)
swift
import WidgetKit
struct PromptControlWidget: ControlWidget {
var body: some ControlWidgetConfiguration {
AppIntentControlConfiguration(kind: "PromptControl", intent: CopyFavoritePromptIntent.self) { config in
ControlWidgetButton(action: config) {
Label(config.prompt?.title ?? "Prompt", systemImage: "doc.on.clipboard")
}
}
.displayName("Quick Prompt")
}
}swift
import WidgetKit
struct PromptControlWidget: ControlWidget {
var body: some ControlWidgetConfiguration {
AppIntentControlConfiguration(kind: "PromptControl", intent: CopyFavoritePromptIntent.self) { config in
ControlWidgetButton(action: config) {
Label(config.prompt?.title ?? "Prompt", systemImage: "doc.on.clipboard")
}
}
.displayName("Quick Prompt")
}
}URL Scheme Deep Links
URL Scheme深度链接
swift
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.onOpenURL { url in
// myapp://copy/{id}, myapp://edit/{id}, myapp://new?content={encoded}
guard url.scheme == "myapp" else { return }
switch url.host {
case "copy":
if let id = UUID(uuidString: url.lastPathComponent) {
PromptService.shared.copyToClipboard(id: id)
}
case "edit":
if let id = UUID(uuidString: url.lastPathComponent) {
NavigationState.shared.editPrompt(id: id)
}
default: break
}
}
}
}
}swift
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.onOpenURL { url in
// myapp://copy/{id}, myapp://edit/{id}, myapp://new?content={encoded}
guard url.scheme == "myapp" else { return }
switch url.host {
case "copy":
if let id = UUID(uuidString: url.lastPathComponent) {
PromptService.shared.copyToClipboard(id: id)
}
case "edit":
if let id = UUID(uuidString: url.lastPathComponent) {
NavigationState.shared.editPrompt(id: id)
}
default: break
}
}
}
}
}Data Sync & App Groups
数据同步与App Groups
App Groups for Extensions/Widgets
面向扩展/小组件的App Groups
Extensions (widgets, keyboard, share) run in separate processes. Share data via App Groups.
swift
// 1. Add App Groups capability to main app AND all extensions
// 2. Use same group identifier: "group.com.yourapp.shared"
// Shared container for SwiftData
extension ModelContainer {
static var shared: ModelContainer = {
let schema = Schema([Prompt.self, Category.self])
let storeURL = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: "group.com.yourapp.shared")!
.appendingPathComponent("prompts.sqlite")
let config = ModelConfiguration(url: storeURL)
return try! ModelContainer(for: schema, configurations: [config])
}()
}
// Lightweight sharing via UserDefaults
let sharedDefaults = UserDefaults(suiteName: "group.com.yourapp.shared")
sharedDefaults?.set(encodedData, forKey: "prompts")扩展(小组件、键盘、分享)运行在独立进程中,通过App Groups共享数据。
swift
// 1. 为主应用和所有扩展添加App Groups能力
// 2. 使用相同的组标识符:"group.com.yourapp.shared"
// SwiftData的共享容器
extension ModelContainer {
static var shared: ModelContainer = {
let schema = Schema([Prompt.self, Category.self])
let storeURL = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: "group.com.yourapp.shared")!
.appendingPathComponent("prompts.sqlite")
let config = ModelConfiguration(url: storeURL)
return try! ModelContainer(for: schema, configurations: [config])
}()
}
// 通过UserDefaults实现轻量级共享
let sharedDefaults = UserDefaults(suiteName: "group.com.yourapp.shared")
sharedDefaults?.set(encodedData, forKey: "prompts")CloudKit Sync with SwiftData
基于SwiftData的CloudKit同步
swift
let config = ModelConfiguration(
schema: schema,
isStoredInMemoryOnly: false,
cloudKitDatabase: .automatic // Enable iCloud sync
)swift
let config = ModelConfiguration(
schema: schema,
isStoredInMemoryOnly: false,
cloudKitDatabase: .automatic // Enable iCloud sync
)Sync Status Monitoring
同步状态监控
swift
class SyncMonitor: ObservableObject {
@Published var syncStatus: SyncStatus = .unknown
enum SyncStatus { case unknown, syncing, synced, error(String), noAccount }
init() {
CKContainer.default().accountStatus { [weak self] status, _ in
DispatchQueue.main.async {
switch status {
case .available: self?.syncStatus = .synced
case .noAccount: self?.syncStatus = .noAccount
default: self?.syncStatus = .error("iCloud unavailable")
}
}
}
// Observe sync events
NotificationCenter.default.addObserver(
forName: NSPersistentCloudKitContainer.eventChangedNotification,
object: nil, queue: .main
) { [weak self] notification in
guard let event = notification.userInfo?[NSPersistentCloudKitContainer.eventNotificationUserInfoKey]
as? NSPersistentCloudKitContainer.Event else { return }
self?.syncStatus = event.endDate != nil ? .synced : .syncing
}
}
}swift
class SyncMonitor: ObservableObject {
@Published var syncStatus: SyncStatus = .unknown
enum SyncStatus { case unknown, syncing, synced, error(String), noAccount }
init() {
CKContainer.default().accountStatus { [weak self] status, _ in
DispatchQueue.main.async {
switch status {
case .available: self?.syncStatus = .synced
case .noAccount: self?.syncStatus = .noAccount
default: self?.syncStatus = .error("iCloud unavailable")
}
}
}
// Observe sync events
NotificationCenter.default.addObserver(
forName: NSPersistentCloudKitContainer.eventChangedNotification,
object: nil, queue: .main
) { [weak self] notification in
guard let event = notification.userInfo?[NSPersistentCloudKitContainer.eventNotificationUserInfoKey]
as? NSPersistentCloudKitContainer.Event else { return }
self?.syncStatus = event.endDate != nil ? .synced : .syncing
}
}
}JSON Export/Import
JSON导出/导入
swift
struct PromptExport: Codable {
let version: Int
let exportedAt: Date
let prompts: [PromptData]
struct PromptData: Codable {
let id: UUID, title: String, content: String
let categoryName: String?, isFavorite: Bool
}
}
extension PromptService {
func exportToJSON() throws -> Data {
let prompts = try context.fetch(FetchDescriptor<Prompt>())
let export = PromptExport(version: 1, exportedAt: Date(), prompts: prompts.map {
.init(id: $0.id, title: $0.title, content: $0.content,
categoryName: $0.category?.name, isFavorite: $0.isFavorite)
})
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
return try encoder.encode(export)
}
func importFromJSON(_ data: Data) throws -> Int {
let export = try JSONDecoder().decode(PromptExport.self, from: data)
var imported = 0
for p in export.prompts {
let existing = try? context.fetch(
FetchDescriptor<Prompt>(predicate: #Predicate { $0.id == p.id })
).first
if existing == nil {
let prompt = Prompt(title: p.title, content: p.content)
prompt.id = p.id; prompt.isFavorite = p.isFavorite
context.insert(prompt); imported += 1
}
}
try context.save()
return imported
}
}swift
struct PromptExport: Codable {
let version: Int
let exportedAt: Date
let prompts: [PromptData]
struct PromptData: Codable {
let id: UUID, title: String, content: String
let categoryName: String?, isFavorite: Bool
}
}
extension PromptService {
func exportToJSON() throws -> Data {
let prompts = try context.fetch(FetchDescriptor<Prompt>())
let export = PromptExport(version: 1, exportedAt: Date(), prompts: prompts.map {
.init(id: $0.id, title: $0.title, content: $0.content,
categoryName: $0.category?.name, isFavorite: $0.isFavorite)
})
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
return try encoder.encode(export)
}
func importFromJSON(_ data: Data) throws -> Int {
let export = try JSONDecoder().decode(PromptExport.self, from: data)
var imported = 0
for p in export.prompts {
let existing = try? context.fetch(
FetchDescriptor<Prompt>(predicate: #Predicate { $0.id == p.id })
).first
if existing == nil {
let prompt = Prompt(title: p.title, content: p.content)
prompt.id = p.id; prompt.isFavorite = p.isFavorite
context.insert(prompt); imported += 1
}
}
try context.save()
return imported
}
}Schema Versioning
架构版本控制
swift
enum SchemaV1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] { [Prompt.self, Category.self] }
}
enum SchemaV2: VersionedSchema {
static var versionIdentifier = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type] { [Prompt.self, Category.self, PromptVariable.self] }
}
enum MigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] { [SchemaV1.self, SchemaV2.self] }
static var stages: [MigrationStage] {
[MigrationStage.lightweight(fromVersion: SchemaV1.self, toVersion: SchemaV2.self)]
}
}swift
enum SchemaV1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] { [Prompt.self, Category.self] }
}
enum SchemaV2: VersionedSchema {
static var versionIdentifier = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type] { [Prompt.self, Category.self, PromptVariable.self] }
}
enum MigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] { [SchemaV1.self, SchemaV2.self] }
static var stages: [MigrationStage] {
[MigrationStage.lightweight(fromVersion: SchemaV1.self, toVersion: SchemaV2.self)]
}
}Widget Refresh
小组件刷新
swift
// After any prompt change — notify widgets
WidgetCenter.shared.reloadAllTimelines()
// Or specific widget
WidgetCenter.shared.reloadTimelines(ofKind: "PromptWidget")swift
// After any prompt change — notify widgets
WidgetCenter.shared.reloadAllTimelines()
// Or specific widget
WidgetCenter.shared.reloadTimelines(ofKind: "PromptWidget")macOS → iOS Migration Checklist
macOS → iOS迁移清单
Core Functionality
核心功能
- All models work on both platforms (no AppKit/UIKit imports)
- ViewModels have no platform-specific imports
- Services use protocol abstraction
- 所有模型可在两个平台正常运行(无AppKit/UIKit导入)
- ViewModel无平台专属导入
- 服务采用协议抽象
UI Adaptation
UI适配
- Navigation adapted (SplitView → Stack on iPhone)
- Touch targets ≥ 44pt minimum
- No hover-only interactions (add tap alternatives)
- Keyboard shortcuts have touch equivalents
- 导航已适配(SplitView → iPhone用Stack)
- 触控目标最小≥44pt
- 无仅悬停的交互(添加点击替代方案)
- 键盘快捷键有触控等效操作
Platform Features
平台特性
- Hotkey → Keyboard extension or widget on iOS
- Menu bar → App icon or widget on iOS
- Floating panel → Sheet or full-screen modal on iOS
- Right-click → Long press context menu
- 快捷键 → iOS上替换为键盘扩展或小组件
- 菜单栏 → iOS上替换为应用图标或小组件
- 浮动面板 → iOS上替换为弹窗或全屏模态视图
- 右键点击 → 长按上下文菜单
Data
数据
- CloudKit sync enabled
- App Groups configured for extensions
- Shared UserDefaults for lightweight data
- SwiftData shared container for extensions
- 已启用CloudKit同步
- 已为扩展配置App Groups
- 已设置用于轻量级数据的共享UserDefaults
- 已为扩展配置SwiftData共享容器
Testing
测试
- Shared tests pass on both platforms
- UI tests for each platform
- Widget previews work
- Test on real device (not just simulator)
- 共享测试在两个平台均通过
- 每个平台都有对应的UI测试
- 小组件预览正常工作
- 在真实设备上测试(而非仅模拟器)
Common Pitfalls
常见陷阱
- Documents directory differs — abstract file paths, don't hardcode
- Keyboard presence on iOS — handle or
keyboardLayoutGuide.ignoresSafeArea(.keyboard) - Right-click menus — provide (works as right-click on Mac, long-press on iOS)
.contextMenu - Window management — macOS has multiple windows; iOS is single-window (use conditionally)
openWindow - Status bar — macOS ; no equivalent on iOS (use widget instead)
MenuBarExtra
- Documents目录不同——抽象文件路径,不要硬编码
- iOS上的键盘存在性——处理或使用
keyboardLayoutGuide.ignoresSafeArea(.keyboard) - 右键菜单——提供(在Mac上为右键点击,在iOS上为长按)
.contextMenu - 窗口管理——macOS支持多窗口;iOS为单窗口(按需使用)
openWindow - 状态栏——macOS用;iOS无等效功能(改用小组件)
MenuBarExtra