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

338 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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`