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