# iOS 16 兼容迁移记录 记录日期:2026-06-26 工程:`suixinkan_ios_new` 目标:将新 iOS 工程从 iOS 17 Observation 架构迁到 iOS 16 可运行的 Combine/SwiftUI 架构,同时保留 iOS 17+ 新 API 的局部增强能力。 ## 背景 本次迁移前,工程大量使用 iOS 17 的 Observation 体系: - `@Observable` - `@Bindable` - `@Environment(Type.self)` - `.environment(object)` - 双参数 `.onChange(of:) { oldValue, newValue in ... }` - iOS 17-only SwiftUI API,例如 `ContentUnavailableView`、`navigationDestination(item:)` 为了让 App 最低支持 iOS 16.0,统一迁移到 iOS 16 可用的 `ObservableObject + @Published + EnvironmentObject/EnvironmentKey` 体系。没有维护两套 RootView,也没有做运行时双架构分支。 ## 本次完成的改动 ### 1. Deployment Target 文件: - `suixinkan.xcodeproj/project.pbxproj` 改动: - App target Debug/Release:`IPHONEOS_DEPLOYMENT_TARGET = 16` - Test target Debug/Release:`IPHONEOS_DEPLOYMENT_TARGET = 16` 说明: - 项目级 SDK 行里仍可看到 `26.5`,这是当前 Xcode/SDK 生成配置,不等于 app/test target 的最低系统版本。 ### 2. 状态体系:Observation -> Combine 迁移规则: - `@Observable class Xxx` -> `final class Xxx: ObservableObject` - 会驱动 UI 的字段 -> `@Published var` - 不驱动 UI 的依赖、client、常量、配置 -> 普通属性 - View 持有 ViewModel: - `@State private var viewModel = XxxViewModel()` -> `@StateObject private var viewModel = XxxViewModel()` - 子视图接收 ViewModel -> `@ObservedObject` - 移除 `@Bindable` 典型文件: - `suixinkan/App/State/AppSession.swift` - `suixinkan/App/State/AccountContext.swift` - `suixinkan/App/State/PermissionContext.swift` - `suixinkan/App/State/ScenicSpotContext.swift` - `suixinkan/App/State/ToastCenter.swift` - `suixinkan/App/Navigation/NavigationRouter.swift` - 各业务 `Features/*/ViewModels/*.swift` 保留为 `ObservableObject` 的对象主要是 UI 状态或运行时状态: - `AppSession` - `AccountContext` - `PermissionContext` - `ScenicSpotContext` - `AppRouter` - `RouterPath` - `ToastCenter` - `ScenicQueueRuntime` - `ForegroundLocationProvider` - 各业务 ViewModel 已清回普通 class 的对象: - `APIClient` - 各业务 `*API` - `OSSUploadService` - `UploadAPI` - token/snapshot/preferences store - `AuthSessionCoordinator` - `SessionBootstrapper` - `PushAPI` ### 3. 环境注入迁移 新增文件: - `suixinkan/App/AppServiceEnvironment.swift` 状态对象使用: ```swift .environmentObject(appSession) .environmentObject(accountContext) .environmentObject(permissionContext) .environmentObject(scenicSpotContext) .environmentObject(appRouter) .environmentObject(toastCenter) .environmentObject(scenicQueueRuntime) ``` 页面读取: ```swift @EnvironmentObject private var appSession: AppSession @EnvironmentObject private var router: RouterPath @EnvironmentObject private var toastCenter: ToastCenter ``` 服务对象使用自定义 `EnvironmentKey`: ```swift .environment(\.ordersAPI, ordersAPI) .environment(\.profileAPI, profileAPI) .environment(\.ossUploadService, ossUploadService) ``` 页面读取: ```swift @Environment(\.ordersAPI) private var ordersAPI @Environment(\.ossUploadService) private var uploadService ``` 注意: - `AppServiceEnvironment.swift` 里的 key 提供默认实例,避免 SwiftUI/test runner 在环境装配阶段读取 key 时崩溃。 - RootView 仍会注入真实共享实例,默认实例主要用于 preview/test fallback。 ### 4. RootView 和 MainTabsView 核心文件: - `suixinkan/App/RootView.swift` - `suixinkan/Features/Main/Views/MainTabsView.swift` 主要变化: - `RootView` 中全局 UI 状态改为 `@StateObject` - service/API 继续作为稳定引用由 RootView 创建并下发 - `MainTabsView` 中 `AppRouter` 改为 `@EnvironmentObject` - 每个 tab 的 `RouterPath` 用 `.environmentObject(appRouter.router(for: tab))` 注入 - `TabView(selection:)` 和自定义 tab 绑定改用 `$appRouter.selectedTab` ### 5. iOS 16 SwiftUI 兼容 新增文件: - `suixinkan/Core/Design/AppContentUnavailableView.swift` - `suixinkan/App/Navigation/NavigationCompatibility.swift` 兼容点: - `ContentUnavailableView` -> `AppContentUnavailableView` - `navigationDestination(item:)` -> `appNavigationDestination(item:)` - 双参数 `.onChange(of:)` -> iOS 16 可用的单参数形式 `AppContentUnavailableView` 行为: - iOS 17+:内部走系统 `ContentUnavailableView` - iOS 16:降级为项目风格的 VStack 空状态 UI `appNavigationDestination(item:)` 行为: - 使用 iOS 16 可用的 `navigationDestination(isPresented:)` 包一层 optional item - 保留原来 optional item push 的页面行为 ### 6. StateObject 不能直接替换实例的修复 迁移到 `@StateObject` 后,不能再写: ```swift viewModel = SomeViewModel(...) ``` 已改成 ViewModel 内部原地回填: - `ProjectEditorViewModel.apply(_:)` - `StoreProjectEditorViewModel.apply(_:)` - `PunchPointEditorViewModel.apply(_:)` 涉及文件: - `suixinkan/Features/Projects/ViewModels/ProjectViewModels.swift` - `suixinkan/Features/Projects/Views/ProjectViews.swift` - `suixinkan/Features/PunchPoint/ViewModels/PunchPointViewModels.swift` - `suixinkan/Features/PunchPoint/Views/PunchPointViews.swift` ### 7. 推送迁移保留 本次 iOS 16 兼容迁移没有回退之前完成的 APNs 推送迁移。 相关文件仍保留: - `suixinkan/Core/Push/PushAPI.swift` - `suixinkan/Core/Push/PushPayload.swift` - `suixinkan/Core/Push/PushNotificationManager.swift` - `suixinkan/App/AppDelegate.swift` - `suixinkan/suixinkan.entitlements` - `suixinkan/Info.plist` - `suixinkanTests/PushNotificationTests.swift` ## 验证命令 App build: ```bash /Applications/Xcode.app/Contents/Developer/usr/bin/xcodebuild build \ -quiet \ -workspace suixinkan.xcworkspace \ -scheme suixinkan \ -destination 'platform=iOS Simulator,name=iPhone 17' ``` 全量测试: ```bash /Applications/Xcode.app/Contents/Developer/usr/bin/xcodebuild test \ -quiet \ -workspace suixinkan.xcworkspace \ -scheme suixinkan \ -destination 'platform=iOS Simulator,name=iPhone 17' ``` 验证结果: - App build:通过 - 全量 tests:通过 已知 warning: - Pods 里部分 AMap target 的 deployment target 为 9.0,当前 Xcode 提示支持范围为 12.0 到 26.5.99。 - Pods dummy object 有 “built for newer iOS-simulator version 17.0 than being linked 16.0” 警告。 - 若干 Swift 6 未来并发检查 warning,目前不影响 Swift 5 build/test。 ## 以后从 iOS 16 迁回 iOS 17 的建议 如果未来确定最低版本重新升回 iOS 17,可以考虑反向迁移,但建议分阶段做。 ### 阶段 1:先改工程配置 - App target Debug/Release:`IPHONEOS_DEPLOYMENT_TARGET = 17` - Test target Debug/Release:`IPHONEOS_DEPLOYMENT_TARGET = 17` 先跑: ```bash xcodebuild build -workspace suixinkan.xcworkspace -scheme suixinkan -destination 'platform=iOS Simulator,name=iPhone 17' xcodebuild test -workspace suixinkan.xcworkspace -scheme suixinkan -destination 'platform=iOS Simulator,name=iPhone 17' ``` ### 阶段 2:是否迁回 Observation 不一定必须迁回。即使最低版本是 iOS 17,`ObservableObject + @Published` 仍然可以继续使用。 只有在明确想利用 Observation 的简化语法时,再迁回: - `ObservableObject` 状态/ViewModel -> `@Observable` - `@Published` -> 普通 `var` - `@StateObject` -> `@State` - `@ObservedObject` -> 普通属性或 `@Bindable` - `@EnvironmentObject` -> `@Environment(Type.self)` - `.environmentObject(object)` -> `.environment(object)` 建议优先迁回这些核心状态: - `AppSession` - `AccountContext` - `PermissionContext` - `ScenicSpotContext` - `AppRouter` - `RouterPath` - `ToastCenter` - `ScenicQueueRuntime` 然后再考虑业务 ViewModel。 ### 阶段 3:服务注入可以不迁回 即使回到 iOS 17,也建议保留当前 `AppServiceEnvironment.swift` 的 service key 注入方式。 原因: - service/API 本质不是 UI 状态 - 自定义 EnvironmentKey 更清晰地区分 service 与 observable state - 对测试和 preview 更稳定 ### 阶段 4:恢复 iOS 17 SwiftUI API 可选恢复: - `AppContentUnavailableView(...)` -> `ContentUnavailableView(...)` - `.appNavigationDestination(item:)` -> `.navigationDestination(item:)` - 单参数 `.onChange(of:)` 可继续保留;iOS 17 兼容单参数版本 建议: - `AppContentUnavailableView` 可以继续保留,因为它内部 iOS 17+ 已经走系统 `ContentUnavailableView`。 - `appNavigationDestination(item:)` 也可以继续保留,除非想彻底减少兼容层。 ## 给未来 Codex 的提示词 如果未来要迁回 iOS 17,可以把下面这段发给 Codex: ```text 请基于仓库中的 iOS16兼容迁移记录.md,帮我评估并执行从 iOS 16 兼容架构迁回 iOS 17 的改造。 要求: 1. 先检查当前代码和 git diff,不要假设记录完全最新。 2. 先把 app/test target 的 IPHONEOS_DEPLOYMENT_TARGET 改到 17。 3. 优先判断是否真的需要迁回 Observation;如果迁回,先迁核心状态对象,再迁业务 ViewModel。 4. service/API/store 继续保持普通 class + EnvironmentKey 注入,除非有明确收益。 5. 可以把 AppContentUnavailableView 和 appNavigationDestination 视情况恢复为 iOS 17 原生 API。 6. 迁移完成后跑 xcodebuild build 和 xcodebuild test。 7. 不要删除现有 APNs 推送迁移代码。 ``` ## 快速检查命令 检查是否还有 iOS 17 Observation 残留语法: ```bash rg -n "import Observation|@Observable|@ObservationIgnored|@Environment\\([A-Za-z0-9_]+\\.self\\)|@Bindable|onChange\\(of:.*\\{\\s*_,|navigationDestination\\(item:" suixinkan --glob '*.swift' ``` 检查 target deployment: ```bash rg -n "IPHONEOS_DEPLOYMENT_TARGET" suixinkan.xcodeproj/project.pbxproj ``` 检查 service/API 是否误变成 ObservableObject: ```bash rg -n "class .*ObservableObject" suixinkan/Core suixinkan/App/State suixinkan/Features/*/API ``` 正常情况下,service/API/store 不应出现在最后这个结果里;UI 状态对象和 ViewModel 可以是 `ObservableObject`。