developing-ios-apps
Develops iOS applications with XcodeGen, SwiftUI, and SPM. Triggers on XcodeGen project.yml configuration, SPM dependency issues, device deployment problems, code signing errors, camera/AVFoundation debugging, iOS version compatibility, or "Library not loaded @rpath" framework errors. Use when building iOS apps, fixing Xcode build failures, or deploying to real devices.
Packaged view
This page reorganizes the original catalog entry around fit, installability, and workflow context first. The original raw source lives below.
Install command
npx @skill-hub/cli install nguyendinhquocx-code-ai-ios-app-developer
Repository
Skill path: skills/iOS-APP-developer
Develops iOS applications with XcodeGen, SwiftUI, and SPM. Triggers on XcodeGen project.yml configuration, SPM dependency issues, device deployment problems, code signing errors, camera/AVFoundation debugging, iOS version compatibility, or "Library not loaded @rpath" framework errors. Use when building iOS apps, fixing Xcode build failures, or deploying to real devices.
Open repositoryBest for
Primary workflow: Build Mobile.
Technical facets: Full Stack, DevOps, Mobile.
Target audience: everyone.
License: Unknown.
Original source
Catalog source: SkillHub Club.
Repository owner: nguyendinhquocx.
This is still a mirrored public skill entry. Review the repository before installing into production workflows.
What it helps with
- Install developing-ios-apps into Claude Code, Codex CLI, Gemini CLI, or OpenCode workflows
- Review https://github.com/nguyendinhquocx/code-ai before adding developing-ios-apps to shared team environments
- Use developing-ios-apps for development workflows
Works across
Favorites: 0.
Sub-skills: 0.
Aggregator: No.
Original source / Raw SKILL.md
---
name: developing-ios-apps
description: Develops iOS applications with XcodeGen, SwiftUI, and SPM. Triggers on XcodeGen project.yml configuration, SPM dependency issues, device deployment problems, code signing errors, camera/AVFoundation debugging, iOS version compatibility, or "Library not loaded @rpath" framework errors. Use when building iOS apps, fixing Xcode build failures, or deploying to real devices.
---
# iOS App Development
Build, configure, and deploy iOS applications using XcodeGen and Swift Package Manager.
## Critical Warnings
| Issue | Cause | Solution |
|-------|-------|----------|
| "Library not loaded: @rpath/Framework" | XcodeGen doesn't auto-embed SPM dynamic frameworks | **Build in Xcode GUI first** (not xcodebuild). See [Troubleshooting](#spm-dynamic-framework-not-embedded) |
| `xcodegen generate` loses signing | Overwrites project settings | Configure in `project.yml` target settings, not global |
| Command-line signing fails | Free Apple ID limitation | Use Xcode GUI or paid developer account ($99/yr) |
| "Cannot be set when automaticallyAdjustsVideoMirroring is YES" | Setting `isVideoMirrored` without disabling automatic | Set `automaticallyAdjustsVideoMirroring = false` first. See [Camera](#camera--avfoundation) |
## Quick Reference
| Task | Command |
|------|---------|
| Generate project | `xcodegen generate` |
| Build simulator | `xcodebuild -destination 'platform=iOS Simulator,name=iPhone 17' build` |
| Build device (paid account) | `xcodebuild -destination 'platform=iOS,name=DEVICE' -allowProvisioningUpdates build` |
| Clean DerivedData | `rm -rf ~/Library/Developer/Xcode/DerivedData/PROJECT-*` |
| Find device name | `xcrun xctrace list devices` |
## XcodeGen Configuration
### Minimal project.yml
```yaml
name: AppName
options:
bundleIdPrefix: com.company
deploymentTarget:
iOS: "16.0"
settings:
base:
SWIFT_VERSION: "6.0"
packages:
SomePackage:
url: https://github.com/org/repo
from: "1.0.0"
targets:
AppName:
type: application
platform: iOS
sources:
- path: AppName
settings:
base:
INFOPLIST_FILE: AppName/Info.plist
PRODUCT_BUNDLE_IDENTIFIER: com.company.appname
CODE_SIGN_STYLE: Automatic
DEVELOPMENT_TEAM: TEAM_ID_HERE
dependencies:
- package: SomePackage
```
### Code Signing Configuration
**Personal (free) account**: Works in Xcode GUI only. Command-line builds require paid account.
```yaml
# In target settings
settings:
base:
CODE_SIGN_STYLE: Automatic
DEVELOPMENT_TEAM: TEAM_ID # Get from Xcode → Settings → Accounts
```
**Get Team ID**:
```bash
security find-identity -v -p codesigning | head -3
```
## iOS Version Compatibility
### API Changes by Version
| iOS 17+ Only | iOS 16 Compatible |
|--------------|-------------------|
| `.onChange { old, new in }` | `.onChange { new in }` |
| `ContentUnavailableView` | Custom VStack |
| `AVAudioApplication` | `AVAudioSession` |
| `@Observable` macro | `@ObservableObject` |
| SwiftData | CoreData/Realm |
### Lowering Deployment Target
1. Update `project.yml`:
```yaml
deploymentTarget:
iOS: "16.0"
```
2. Fix incompatible APIs:
```swift
// iOS 17
.onChange(of: value) { oldValue, newValue in }
// iOS 16
.onChange(of: value) { newValue in }
// iOS 17
ContentUnavailableView("Title", systemImage: "icon")
// iOS 16
VStack {
Image(systemName: "icon").font(.system(size: 48))
Text("Title").font(.title2.bold())
}
// iOS 17
AVAudioApplication.shared.recordPermission
// iOS 16
AVAudioSession.sharedInstance().recordPermission
```
3. Regenerate: `xcodegen generate`
## Device Deployment
### First-time Setup
1. Connect device via USB
2. Trust computer on device
3. In Xcode: Settings → Accounts → Add Apple ID
4. Select device in scheme dropdown
5. Run (`Cmd + R`)
6. On device: Settings → General → VPN & Device Management → Trust
### Command-line Build (requires paid account)
```bash
xcodebuild \
-project App.xcodeproj \
-scheme App \
-destination 'platform=iOS,name=DeviceName' \
-allowProvisioningUpdates \
build
```
### Common Issues
| Error | Solution |
|-------|----------|
| "Library not loaded: @rpath/Framework" | SPM dynamic framework not embedded. Build in Xcode GUI first, then CLI works |
| "No Account for Team" | Add Apple ID in Xcode Settings → Accounts |
| "Provisioning profile not found" | Free account limitation. Use Xcode GUI or get paid account |
| Device not listed | Reconnect USB, trust computer on device, restart Xcode |
| DerivedData won't delete | Close Xcode first: `pkill -9 Xcode && rm -rf ~/Library/Developer/Xcode/DerivedData/PROJECT-*` |
### Free vs Paid Developer Account
| Feature | Free Apple ID | Paid ($99/year) |
|---------|---------------|-----------------|
| Xcode GUI builds | ✅ | ✅ |
| Command-line builds | ❌ | ✅ |
| App validity | 7 days | 1 year |
| App Store | ❌ | ✅ |
| CI/CD | ❌ | ✅ |
## SPM Dependencies
### SPM Dynamic Framework Not Embedded
**Root Cause**: XcodeGen doesn't generate the "Embed Frameworks" build phase for SPM dynamic frameworks (like RealmSwift, Realm). The app builds successfully but crashes on launch with:
```
dyld: Library not loaded: @rpath/RealmSwift.framework/RealmSwift
Referenced from: /var/containers/Bundle/Application/.../App.app/App
Reason: image not found
```
**Why This Happens**:
- Static frameworks (most SPM packages) are linked into the binary - no embedding needed
- Dynamic frameworks (RealmSwift, etc.) must be copied into the app bundle
- XcodeGen generates link phase but NOT embed phase for SPM packages
- `embed: true` in project.yml causes build errors (XcodeGen limitation)
**The Fix** (Manual, one-time per project):
1. Open project in Xcode GUI
2. Select target → General → Frameworks, Libraries
3. Find the dynamic framework (RealmSwift)
4. Change "Do Not Embed" → "Embed & Sign"
5. Build and run from Xcode GUI first
**After Manual Fix**: Command-line builds (`xcodebuild`) will work because Xcode persists the embed setting in project.pbxproj.
**Identifying Dynamic Frameworks**:
```bash
# Check if a framework is dynamic
file ~/Library/Developer/Xcode/DerivedData/PROJECT-*/Build/Products/Debug-iphoneos/FRAMEWORK.framework/FRAMEWORK
# Dynamic: "Mach-O 64-bit dynamically linked shared library"
# Static: "current ar archive"
```
### Adding Packages
```yaml
packages:
AudioKit:
url: https://github.com/AudioKit/AudioKit
from: "5.6.5"
RealmSwift:
url: https://github.com/realm/realm-swift
from: "10.54.6"
targets:
App:
dependencies:
- package: AudioKit
- package: RealmSwift
product: RealmSwift # Explicit product name when package has multiple
```
### Resolving Dependencies (China proxy)
```bash
git config --global http.proxy http://127.0.0.1:1082
git config --global https.proxy http://127.0.0.1:1082
xcodebuild -scmProvider system -resolvePackageDependencies
```
**Never clear global SPM cache** (`~/Library/Caches/org.swift.swiftpm`). Re-downloading is slow.
## Camera / AVFoundation
Camera preview requires real device (simulator has no camera).
### Quick Debugging Checklist
1. **Permission**: Added `NSCameraUsageDescription` to Info.plist?
2. **Device**: Running on real device, not simulator?
3. **Session running**: `session.startRunning()` called on background thread?
4. **View size**: UIViewRepresentable has non-zero bounds?
5. **Video mirroring**: Disabled `automaticallyAdjustsVideoMirroring` before setting `isVideoMirrored`?
### Video Mirroring (Front Camera)
**CRITICAL**: Must disable automatic adjustment before setting manual mirroring:
```swift
// WRONG - crashes with "Cannot be set when automaticallyAdjustsVideoMirroring is YES"
connection.isVideoMirrored = true
// CORRECT - disable automatic first
connection.automaticallyAdjustsVideoMirroring = false
connection.isVideoMirrored = true
```
### UIViewRepresentable Sizing Issue
UIViewRepresentable in ZStack may have zero bounds. Fix with explicit frame:
```swift
// BAD: UIViewRepresentable may get zero size in ZStack
ZStack {
CameraPreviewView(session: session) // May be invisible!
OtherContent()
}
// GOOD: Explicit sizing
ZStack {
GeometryReader { geo in
CameraPreviewView(session: session)
.frame(width: geo.size.width, height: geo.size.height)
}
.ignoresSafeArea()
OtherContent()
}
```
### Debug Logging Pattern
Add logging to trace camera flow:
```swift
import os
private let logger = Logger(subsystem: "com.app", category: "Camera")
func start() async {
logger.info("start() called, isRunning=\(self.isRunning)")
// ... setup code ...
logger.info("session.startRunning() completed")
}
// For CGRect (doesn't conform to CustomStringConvertible)
logger.info("bounds=\(NSCoder.string(for: self.bounds))")
```
Filter in Console.app by subsystem.
**For detailed camera implementation**: See [references/camera-avfoundation.md](references/camera-avfoundation.md)
## Resources
- [references/xcodegen-full.md](references/xcodegen-full.md) - Complete project.yml options
- [references/swiftui-compatibility.md](references/swiftui-compatibility.md) - iOS version API differences
- [references/camera-avfoundation.md](references/camera-avfoundation.md) - Camera preview debugging
- [references/testing-mainactor.md](references/testing-mainactor.md) - Testing @MainActor classes (state machines, regression tests)
---
## Referenced Files
> The following files are referenced in this skill and included for context.
### references/camera-avfoundation.md
```markdown
# Camera / AVFoundation Reference
## Camera Preview Implementation
### Complete Working Example
```swift
import SwiftUI
import AVFoundation
import os
private let logger = Logger(subsystem: "com.app", category: "Camera")
// MARK: - Session Manager
@MainActor
final class CameraSessionManager: ObservableObject {
@Published private(set) var isRunning = false
@Published private(set) var error: CameraError?
let session = AVCaptureSession()
private var videoInput: AVCaptureDeviceInput?
enum CameraError: LocalizedError {
case noCamera
case setupFailed(String)
case permissionDenied
var errorDescription: String? {
switch self {
case .noCamera: return "No camera available"
case .setupFailed(let reason): return "Setup failed: \(reason)"
case .permissionDenied: return "Camera permission denied"
}
}
}
func start() async {
logger.info("start() called, isRunning=\(self.isRunning)")
guard !isRunning else { return }
// Check permission
guard await requestPermission() else {
error = .permissionDenied
return
}
// Get camera
guard let device = AVCaptureDevice.default(
.builtInWideAngleCamera,
for: .video,
position: .front
) else {
logger.error("No front camera available")
error = .noCamera
return
}
// Configure session
session.beginConfiguration()
session.sessionPreset = .high
do {
let input = try AVCaptureDeviceInput(device: device)
if session.canAddInput(input) {
session.addInput(input)
videoInput = input
}
session.commitConfiguration()
// Start on background thread
await withCheckedContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
self?.session.startRunning()
DispatchQueue.main.async {
self?.isRunning = true
logger.info("Camera session started")
continuation.resume()
}
}
}
} catch {
session.commitConfiguration()
self.error = .setupFailed(error.localizedDescription)
}
}
func stop() {
guard isRunning else { return }
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
self?.session.stopRunning()
DispatchQueue.main.async {
self?.isRunning = false
}
}
}
private func requestPermission() async -> Bool {
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized: return true
case .notDetermined:
return await AVCaptureDevice.requestAccess(for: .video)
default: return false
}
}
}
// MARK: - SwiftUI View
struct CameraPreviewView: UIViewRepresentable {
let session: AVCaptureSession
func makeUIView(context: Context) -> CameraPreviewUIView {
let view = CameraPreviewUIView()
view.backgroundColor = .black
view.session = session
return view
}
func updateUIView(_ uiView: CameraPreviewUIView, context: Context) {}
}
final class CameraPreviewUIView: UIView {
override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
var previewLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer }
var session: AVCaptureSession? {
get { previewLayer.session }
set {
previewLayer.session = newValue
previewLayer.videoGravity = .resizeAspectFill
configureMirroring()
}
}
private func configureMirroring() {
guard let connection = previewLayer.connection,
connection.isVideoMirroringSupported else { return }
// CRITICAL: Must disable automatic adjustment BEFORE setting manual mirroring
// Without this, iOS throws: "Cannot be set when automaticallyAdjustsVideoMirroring is YES"
connection.automaticallyAdjustsVideoMirroring = false
connection.isVideoMirrored = true
}
override func layoutSubviews() {
super.layoutSubviews()
previewLayer.frame = bounds
}
}
// MARK: - Usage in SwiftUI
struct ContentView: View {
@StateObject private var cameraManager = CameraSessionManager()
var body: some View {
ZStack {
// CRITICAL: Use GeometryReader for proper sizing
GeometryReader { geo in
CameraPreviewView(session: cameraManager.session)
.frame(width: geo.size.width, height: geo.size.height)
}
.ignoresSafeArea()
// Overlay content here
}
.onAppear {
Task { await cameraManager.start() }
}
.onDisappear {
cameraManager.stop()
}
}
}
```
## Common Issues and Solutions
### Issue: Camera preview shows nothing
**Debug steps:**
1. Check if running on simulator (camera not available):
```swift
#if targetEnvironment(simulator)
logger.warning("Camera not available on simulator")
#endif
```
2. Add logging to trace execution:
```swift
logger.info("Permission status: \(AVCaptureDevice.authorizationStatus(for: .video).rawValue)")
logger.info("Session running: \(session.isRunning)")
logger.info("Preview layer bounds: \(previewLayer.bounds)")
```
3. Verify Info.plist has camera permission:
```xml
<key>NSCameraUsageDescription</key>
<string>Camera access for preview</string>
```
### Issue: UIViewRepresentable has zero size
**Cause**: In ZStack, UIViewRepresentable doesn't expand like SwiftUI views.
**Solution**: Wrap in GeometryReader with explicit frame:
```swift
GeometryReader { geo in
CameraPreviewView(session: session)
.frame(width: geo.size.width, height: geo.size.height)
}
```
### Issue: Preview layer connection is nil
**Cause**: Connection isn't established until session is running and layer is in view hierarchy.
**Solution**: Configure mirroring in layoutSubviews:
```swift
override func layoutSubviews() {
super.layoutSubviews()
previewLayer.frame = bounds
// Retry mirroring here
configureMirroring()
}
private func configureMirroring() {
guard let conn = previewLayer.connection,
conn.isVideoMirroringSupported else { return }
conn.automaticallyAdjustsVideoMirroring = false
conn.isVideoMirrored = true
}
```
### Issue: Crash on setVideoMirrored
**Error**: `*** -[AVCaptureConnection setVideoMirrored:] Cannot be set when automaticallyAdjustsVideoMirroring is YES`
**Cause**: iOS automatically adjusts mirroring by default. Setting `isVideoMirrored` while automatic adjustment is enabled throws an exception.
**Solution**: Always disable automatic adjustment first:
```swift
// WRONG - crashes on some devices
connection.isVideoMirrored = true
// CORRECT - disable automatic first
connection.automaticallyAdjustsVideoMirroring = false
connection.isVideoMirrored = true
```
**Affected Devices**: Primarily older devices (iPhone X, etc.) but can affect any device.
### Issue: Swift 6 concurrency errors with AVCaptureSession
**Error**: "cannot access property 'session' with non-Sendable type from nonisolated deinit"
**Solution**: Don't access session in deinit. Use explicit stop() call:
```swift
deinit {
// Don't access session here
// Cleanup handled by stop() call from view
}
```
## Debugging with Console.app
1. Open Console.app
2. Select your device
3. Filter by:
- Subsystem: `com.yourapp`
- Category: `Camera`
4. Look for the log sequence:
```
start() called, isRunning=false
Permission granted
Found front camera: Front Camera
Camera session started
```
## Camera + Audio Conflict
If using AudioKit or AVAudioEngine, camera audio input may conflict.
**Solution**: Use video-only input, no audio:
```swift
// Only add video input, skip audio
let videoInput = try AVCaptureDeviceInput(device: videoDevice)
session.addInput(videoInput)
// Do NOT add audio input
```
```
### references/xcodegen-full.md
```markdown
# XcodeGen Complete Reference
## Full project.yml Structure
```yaml
name: ProjectName
options:
bundleIdPrefix: com.company
deploymentTarget:
iOS: "16.0"
macOS: "13.0"
xcodeVersion: "16.0"
generateEmptyDirectories: true
createIntermediateGroups: true
settings:
base:
SWIFT_VERSION: "6.0"
MARKETING_VERSION: "1.0.0"
CURRENT_PROJECT_VERSION: "1"
packages:
# Basic package
PackageName:
url: https://github.com/org/repo
from: "1.0.0"
# Exact version
ExactPackage:
url: https://github.com/org/repo
exactVersion: "2.0.0"
# Branch
BranchPackage:
url: https://github.com/org/repo
branch: main
# Local package
LocalPackage:
path: ../LocalPackage
targets:
MainApp:
type: application
platform: iOS
sources:
- path: Sources
excludes:
- "**/.DS_Store"
- "**/Tests/**"
- path: Resources
type: folder
settings:
base:
INFOPLIST_FILE: Sources/Info.plist
PRODUCT_BUNDLE_IDENTIFIER: com.company.app
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
LD_RUNPATH_SEARCH_PATHS: "$(inherited) @executable_path/Frameworks"
ENABLE_BITCODE: NO
CODE_SIGN_STYLE: Automatic
DEVELOPMENT_TEAM: TEAM_ID
configs:
Debug:
SWIFT_OPTIMIZATION_LEVEL: -Onone
Release:
SWIFT_OPTIMIZATION_LEVEL: -O
dependencies:
# SPM package
- package: PackageName
# SPM package with explicit product
- package: Firebase
product: FirebaseAnalytics
# Another target
- target: Framework
# System framework
- framework: UIKit.framework
# SDK
- sdk: CoreLocation.framework
preBuildScripts:
- name: "Run Script"
script: |
echo "Pre-build script"
runOnlyWhenInstalling: false
postBuildScripts:
- name: "Post Build"
script: |
echo "Post-build script"
Tests:
type: bundle.unit-test
platform: iOS
sources:
- path: Tests
dependencies:
- target: MainApp
settings:
base:
TEST_HOST: "$(BUILT_PRODUCTS_DIR)/MainApp.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/MainApp"
BUNDLE_LOADER: "$(TEST_HOST)"
```
## Target Types
| Type | Description |
|------|-------------|
| `application` | iOS/macOS app |
| `framework` | Dynamic framework |
| `staticFramework` | Static framework |
| `bundle.unit-test` | Unit test bundle |
| `bundle.ui-testing` | UI test bundle |
| `app-extension` | App extension |
| `watch2-app` | watchOS app |
| `widget-extension` | Widget extension |
## Build Settings Reference
### Common Settings
```yaml
settings:
base:
# Versioning
MARKETING_VERSION: "1.0.0"
CURRENT_PROJECT_VERSION: "1"
# Swift
SWIFT_VERSION: "6.0"
SWIFT_STRICT_CONCURRENCY: complete
# Signing
CODE_SIGN_STYLE: Automatic
DEVELOPMENT_TEAM: TEAM_ID
CODE_SIGN_IDENTITY: "Apple Development"
# Deployment
IPHONEOS_DEPLOYMENT_TARGET: "16.0"
TARGETED_DEVICE_FAMILY: "1,2" # 1=iPhone, 2=iPad
# Build
ENABLE_BITCODE: NO
DEBUG_INFORMATION_FORMAT: dwarf-with-dsym
# Paths
LD_RUNPATH_SEARCH_PATHS: "$(inherited) @executable_path/Frameworks"
```
### Per-Configuration Settings
```yaml
settings:
configs:
Debug:
SWIFT_OPTIMIZATION_LEVEL: -Onone
SWIFT_ACTIVE_COMPILATION_CONDITIONS: DEBUG
MTL_ENABLE_DEBUG_INFO: INCLUDE_SOURCE
Release:
SWIFT_OPTIMIZATION_LEVEL: -O
SWIFT_COMPILATION_MODE: wholemodule
VALIDATE_PRODUCT: YES
```
## Info.plist Keys
Common keys to add:
```xml
<key>NSCameraUsageDescription</key>
<string>Camera access description</string>
<key>NSMicrophoneUsageDescription</key>
<string>Microphone access description</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Photo library access description</string>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
<string>location</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
```
```
### references/swiftui-compatibility.md
```markdown
# SwiftUI iOS Version Compatibility
## iOS 17 vs iOS 16 API Differences
### View Modifiers
#### onChange
```swift
// iOS 17+ (dual parameter)
.onChange(of: value) { oldValue, newValue in
// Can compare old and new
}
// iOS 16 (single parameter)
.onChange(of: value) { newValue in
// Only new value available
}
```
#### sensoryFeedback (iOS 17+)
```swift
// iOS 17+
.sensoryFeedback(.impact, trigger: triggerValue)
// iOS 16 fallback
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
```
### Views
#### ContentUnavailableView (iOS 17+)
```swift
// iOS 17+
ContentUnavailableView(
"No Results",
systemImage: "magnifyingglass",
description: Text("Try a different search")
)
// iOS 16 fallback
VStack(spacing: 16) {
Image(systemName: "magnifyingglass")
.font(.system(size: 48))
.foregroundStyle(.secondary)
Text("No Results")
.font(.title2.bold())
Text("Try a different search")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
```
#### Inspector (iOS 17+)
```swift
// iOS 17+
.inspector(isPresented: $showInspector) {
InspectorContent()
}
// iOS 16 fallback: Use sheet or sidebar
.sheet(isPresented: $showInspector) {
InspectorContent()
}
```
### Observation
#### @Observable Macro (iOS 17+)
```swift
// iOS 17+ with @Observable
@Observable
class ViewModel {
var count = 0
}
struct ContentView: View {
var viewModel = ViewModel()
var body: some View {
Text("\(viewModel.count)")
}
}
// iOS 16 with ObservableObject
class ViewModel: ObservableObject {
@Published var count = 0
}
struct ContentView: View {
@StateObject var viewModel = ViewModel()
var body: some View {
Text("\(viewModel.count)")
}
}
```
### Audio
#### AVAudioApplication (iOS 17+)
```swift
// iOS 17+
let permission = AVAudioApplication.shared.recordPermission
AVAudioApplication.requestRecordPermission { granted in }
// iOS 16
let permission = AVAudioSession.sharedInstance().recordPermission
AVAudioSession.sharedInstance().requestRecordPermission { granted in }
```
### Animations
#### Symbol Effects (iOS 17+)
```swift
// iOS 17+
Image(systemName: "heart.fill")
.symbolEffect(.bounce, value: isFavorite)
// iOS 16 fallback
Image(systemName: "heart.fill")
.scaleEffect(isFavorite ? 1.2 : 1.0)
.animation(.spring(), value: isFavorite)
```
### Data
#### SwiftData (iOS 17+)
```swift
// iOS 17+ with SwiftData
@Model
class Item {
var name: String
var timestamp: Date
}
// iOS 16: Use CoreData or third-party (Realm)
// CoreData: NSManagedObject subclass
// Realm: Object subclass with @Persisted properties
```
## Conditional Compilation
For features that must use iOS 17 APIs when available:
```swift
if #available(iOS 17.0, *) {
ContentUnavailableView("Title", systemImage: "icon")
} else {
LegacyEmptyView()
}
```
For view modifiers:
```swift
extension View {
@ViewBuilder
func onChangeCompat<V: Equatable>(of value: V, perform: @escaping (V) -> Void) -> some View {
if #available(iOS 17.0, *) {
self.onChange(of: value) { _, newValue in
perform(newValue)
}
} else {
self.onChange(of: value, perform: perform)
}
}
}
```
## Minimum Deployment Targets by Feature
| Feature | Minimum iOS |
|---------|-------------|
| SwiftUI basics | 13.0 |
| @StateObject | 14.0 |
| AsyncImage | 15.0 |
| .searchable | 15.0 |
| NavigationStack | 16.0 |
| .navigationDestination | 16.0 |
| @Observable | 17.0 |
| ContentUnavailableView | 17.0 |
| SwiftData | 17.0 |
| .onChange (dual param) | 17.0 |
```
### references/testing-mainactor.md
```markdown
# Testing @MainActor Classes
## The Problem
Testing `@MainActor` classes like `ObservableObject` controllers in Swift 6 is challenging because:
1. `setUp()` and `tearDown()` are nonisolated
2. Properties with `private(set)` can't be set from tests
3. Direct property access from tests triggers concurrency errors
## Solution: setStateForTesting Pattern
Add a DEBUG-only method to allow tests to set internal state:
```swift
@MainActor
final class TrainingSessionController: ObservableObject {
@Published private(set) var state: TrainingState = .idle
// ... rest of controller ...
// MARK: - Testing Support
#if DEBUG
/// Set state directly for testing purposes only
func setStateForTesting(_ newState: TrainingState) {
state = newState
}
#endif
}
```
## Test Class Structure
```swift
import XCTest
@testable import YourApp
@MainActor
final class TrainingSessionControllerTests: XCTestCase {
var controller: TrainingSessionController!
override func setUp() {
super.setUp()
controller = TrainingSessionController()
}
override func tearDown() {
controller = nil
super.tearDown()
}
func testConfigureFromFailedStateAutoResets() {
// Arrange: Set to failed state
controller.setStateForTesting(.failed("Recording too short"))
// Act: Configure should recover
controller.configure(with: PhaseConfig.default)
// Assert: Should be back in idle
XCTAssertEqual(controller.state, .idle)
}
}
```
## State Machine Testing Patterns
### Testing State Transitions
```swift
func testStateTransitions() {
// Test each state's behavior
let states: [TrainingState] = [.idle, .completed, .failed("error")]
for state in states {
controller.setStateForTesting(state)
controller.configure(with: PhaseConfig.default)
// Verify expected outcome
XCTAssertTrue(controller.canStart, "\(state) should allow starting")
}
}
```
### Regression Tests
For bugs that have been fixed, add specific regression tests:
```swift
/// Regression test for: State machine dead-lock after recording failure
/// Bug: After error, controller stayed in failed state forever
func testRegressionFailedStateDeadLock() {
// Simulate the bug scenario
controller.configure(with: PhaseConfig.default)
controller.setStateForTesting(.failed("录音太短"))
// The fix: configure() should auto-reset from failed state
controller.configure(with: PhaseConfig.default)
XCTAssertEqual(controller.state, .idle,
"REGRESSION: Failed state should not block configure()")
}
```
### State Machine Invariants
Test invariants that should always hold:
```swift
/// No terminal state should become a "dead end"
func testAllTerminalStatesAreRecoverable() {
let terminalStates: [TrainingState] = [
.idle,
.completed,
.failed("test error")
]
for state in terminalStates {
controller.setStateForTesting(state)
// Action that should recover
controller.configure(with: PhaseConfig.default)
// Verify recovery
XCTAssertTrue(canConfigure(),
"\(state) should be recoverable via configure()")
}
}
```
## Why This Pattern Works
1. **`#if DEBUG`**: Method only exists in test builds, zero production overhead
2. **Explicit method**: Makes test-only state manipulation obvious and searchable
3. **MainActor compatible**: Method is part of the @MainActor class
4. **Swift 6 safe**: Avoids concurrency errors by staying on the main actor
## Alternative: Internal Setter
If you prefer, you can use `internal(set)` instead of `private(set)`:
```swift
@Published internal(set) var state: TrainingState = .idle
```
However, this is redundant since properties are already internal by default. The `setStateForTesting()` pattern is more explicit about test-only intent.
```