Files
suixinkan_ios_new/iOS16兼容迁移记录.md
汉秋 703078352c 从 iOS 17 Observation 迁移至 iOS 16 兼容的 Combine 架构
将最低部署版本降至 iOS 16,以 ObservableObject 替换 @Observable,新增导航与 UI 兼容层,并补充登录冒烟 UI 测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 10:16:35 +08:00

10 KiB
Raw Permalink Blame History

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例如 ContentUnavailableViewnavigationDestination(item:)

为了让 App 最低支持 iOS 16.0,统一迁移到 iOS 16 可用的 ObservableObject + @Published + EnvironmentObject/EnvironmentKey 体系。没有维护两套 RootView也没有做运行时双架构分支。

本次完成的改动

1. Deployment Target

文件:

  • suixinkan.xcodeproj/project.pbxproj

改动:

  • App target Debug/ReleaseIPHONEOS_DEPLOYMENT_TARGET = 16
  • Test target Debug/ReleaseIPHONEOS_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

状态对象使用:

.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.swift
  • suixinkan/Features/Main/Views/MainTabsView.swift

主要变化:

  • RootView 中全局 UI 状态改为 @StateObject
  • service/API 继续作为稳定引用由 RootView 创建并下发
  • MainTabsViewAppRouter 改为 @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 后,不能再写:

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

/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/ReleaseIPHONEOS_DEPLOYMENT_TARGET = 17
  • Test target Debug/ReleaseIPHONEOS_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 17ObservableObject + @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

请基于仓库中的 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