将最低部署版本降至 iOS 16,以 ObservableObject 替换 @Observable,新增导航与 UI 兼容层,并补充登录冒烟 UI 测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
10 KiB
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.swiftsuixinkan/App/State/AccountContext.swiftsuixinkan/App/State/PermissionContext.swiftsuixinkan/App/State/ScenicSpotContext.swiftsuixinkan/App/State/ToastCenter.swiftsuixinkan/App/Navigation/NavigationRouter.swift- 各业务
Features/*/ViewModels/*.swift
保留为 ObservableObject 的对象主要是 UI 状态或运行时状态:
AppSessionAccountContextPermissionContextScenicSpotContextAppRouterRouterPathToastCenterScenicQueueRuntimeForegroundLocationProvider- 各业务 ViewModel
已清回普通 class 的对象:
APIClient- 各业务
*API OSSUploadServiceUploadAPI- token/snapshot/preferences store
AuthSessionCoordinatorSessionBootstrapperPushAPI
3. 环境注入迁移
新增文件:
suixinkan/App/AppServiceEnvironment.swift
状态对象使用:
.environmentObject(appSession)
.environmentObject(accountContext)
.environmentObject(permissionContext)
.environmentObject(scenicSpotContext)
.environmentObject(appRouter)
.environmentObject(toastCenter)
.environmentObject(scenicQueueRuntime)
页面读取:
@EnvironmentObject private var appSession: AppSession
@EnvironmentObject private var router: RouterPath
@EnvironmentObject private var toastCenter: ToastCenter
服务对象使用自定义 EnvironmentKey:
.environment(\.ordersAPI, ordersAPI)
.environment(\.profileAPI, profileAPI)
.environment(\.ossUploadService, ossUploadService)
页面读取:
@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.swiftsuixinkan/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.swiftsuixinkan/App/Navigation/NavigationCompatibility.swift
兼容点:
ContentUnavailableView->AppContentUnavailableViewnavigationDestination(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 后,不能再写:
viewModel = SomeViewModel(...)
已改成 ViewModel 内部原地回填:
ProjectEditorViewModel.apply(_:)StoreProjectEditorViewModel.apply(_:)PunchPointEditorViewModel.apply(_:)
涉及文件:
suixinkan/Features/Projects/ViewModels/ProjectViewModels.swiftsuixinkan/Features/Projects/Views/ProjectViews.swiftsuixinkan/Features/PunchPoint/ViewModels/PunchPointViewModels.swiftsuixinkan/Features/PunchPoint/Views/PunchPointViews.swift
7. 推送迁移保留
本次 iOS 16 兼容迁移没有回退之前完成的 APNs 推送迁移。
相关文件仍保留:
suixinkan/Core/Push/PushAPI.swiftsuixinkan/Core/Push/PushPayload.swiftsuixinkan/Core/Push/PushNotificationManager.swiftsuixinkan/App/AppDelegate.swiftsuixinkan/suixinkan.entitlementssuixinkan/Info.plistsuixinkanTests/PushNotificationTests.swift
验证命令
App build:
/Applications/Xcode.app/Contents/Developer/usr/bin/xcodebuild build \
-quiet \
-workspace suixinkan.xcworkspace \
-scheme suixinkan \
-destination 'platform=iOS Simulator,name=iPhone 17'
全量测试:
/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
先跑:
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)
建议优先迁回这些核心状态:
AppSessionAccountContextPermissionContextScenicSpotContextAppRouterRouterPathToastCenterScenicQueueRuntime
然后再考虑业务 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:
请基于仓库中的 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 残留语法:
rg -n "import Observation|@Observable|@ObservationIgnored|@Environment\\([A-Za-z0-9_]+\\.self\\)|@Bindable|onChange\\(of:.*\\{\\s*_,|navigationDestination\\(item:" suixinkan --glob '*.swift'
检查 target deployment:
rg -n "IPHONEOS_DEPLOYMENT_TARGET" suixinkan.xcodeproj/project.pbxproj
检查 service/API 是否误变成 ObservableObject:
rg -n "class .*ObservableObject" suixinkan/Core suixinkan/App/State suixinkan/Features/*/API
正常情况下,service/API/store 不应出现在最后这个结果里;UI 状态对象和 ViewModel 可以是 ObservableObject。