项目:呼吸视界(iOS 已上架)
技术栈:SwiftUI + SwiftData + StoreKit 2 + WidgetKit + ActivityKit
各位新年快乐,春节期间体验到了人挤人,车挤车,闲来无事撸了个一直想做的APP,分享点技术心得大家共勉。
1. 架构目标:把"训练体验"和"增长闭环"同时做出来
这个项目不是只做一个呼吸动画,而是做一条完整链路:
- 训练引擎:稳定跑节奏(吸气/停顿/呼气)
- 多感官反馈:视觉 + 音频 + 触觉一致
- 习惯闭环:课程进度、训练记录、分享卡片
- 增长入口:提醒、Widget、Live Activity、深链
- 商业化:订阅、恢复购买、权益门控
核心分层:
- 状态中枢:
breathing-iOS/breathing/Domain/AppStore.swift - 页面编排:
breathing-iOS/breathing/UI/RootView.swift - 能力引擎:
breathing-iOS/breathing/Engines/* - 数据模型:
breathing-iOS/breathing/Data/* - 外部触达:
breathing-iOS/breathingWidget/*+BreathingLiveActivityManager
2. 单一状态中枢:AppStore 统一收口
AppStore 用 @MainActor + ObservableObject 统一管理业务状态,避免"每个页面自己存一份状态"。
swift
@MainActor
final class AppStore: ObservableObject {
@Published var activeMode: BreathingMode
@Published var activeDuration: Int
@Published var isPro: Bool
@Published var settings: AppSettings
@Published var soundEnabled: Bool
@Published var soundscapeId: String
let breathingEngine: BreathingEngine
let hapticsEngine: HapticsEngine
private let soundscapePlayer = SoundscapePlayer()
private let liveActivityManager = BreathingLiveActivityManager()
}
同时把订阅商品 ID 固定在内部,避免散落字符串:
swift
private enum ProProductID {
static let monthly = "com.xun.breathing.pro.monthly"
static let yearly = "com.xun.breathing.pro.yearly"
static let all = [monthly, yearly]
}
收益:UI 层只绑定状态,不再承担复杂业务判断;后续加模式/加权益不会牵一发而动全身。
3. 训练引擎:状态机 + 双 Task 保证节奏稳定
BreathingEngine 的关键是"阶段推进"和"总时长倒计时"分离:
swift
@MainActor
final class BreathingEngine: ObservableObject {
@Published private(set) var phase: BreathPhase = .ready
@Published private(set) var isPlaying: Bool = false
@Published private(set) var timeRemaining: Int
private var cycleTask: Task<Void, Never>?
private var countdownTask: Task<Void, Never>?
private var sessionId = UUID()
}
启动时并行两条异步任务:
swift
func start() {
guard !isPlaying else { return }
isPlaying = true
sessionId = UUID()
timeRemaining = duration
runCountdown(sessionId: sessionId)
switch courseType {
case .standard:
runBreathingLoop(sessionId: sessionId)
case .wimHof(let config):
runWimHofSession(sessionId: sessionId, config: config)
}
}
倒计时任务只做一件事:
swift
private func runCountdown(sessionId: UUID) {
countdownTask?.cancel()
countdownTask = Task { [weak self] in
guard let self else { return }
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 1_000_000_000)
guard sessionId == self.sessionId, self.isPlaying else { return }
self.timeRemaining = max(0, self.timeRemaining - 1)
if self.timeRemaining <= 0 {
self.completeSession()
return
}
}
}
}
收益:暂停/恢复/切模式时行为稳定,不会出现"相位跳变"或"倒计时错乱"。
4. 音景引擎:缓存 + 淡入淡出,解决听感跳变
音频引擎里最关键是三点:
bufferCache:避免每次重新解码 mp3fadeIn/fadeOut:切换音景不突兀updatePlayback:统一播放入口(按isPlaying/isEnabled决策)
swift
func updatePlayback(isPlaying: Bool, isEnabled: Bool, soundscapeId: String) {
guard isEnabled, isPlaying else {
stop()
return
}
play(soundscapeId)
}
private func loadBuffer(for soundscape: Soundscape) -> AVAudioPCMBuffer? {
if let cached = bufferCache[soundscape.id] { return cached }
guard let url = Bundle.main.url(forResource: soundscape.fileName, withExtension: "mp3") else { return nil }
do {
let file = try AVAudioFile(forReading: url)
let frameCount = AVAudioFrameCount(file.length)
guard let buffer = AVAudioPCMBuffer(pcmFormat: file.processingFormat, frameCapacity: frameCount) else { return nil }
try file.read(into: buffer)
bufferCache[soundscape.id] = buffer
return buffer
} catch {
return nil
}
}
停止时先淡出再停引擎:
swift
fade(to: 0, duration: fadeOutDuration) { [weak self] in
self?.stopNow(resetSession: resetSession)
}
5. 通知提醒:权限、频率、撤销一体化
提醒模块用 UNUserNotificationCenter,重点是"配置即覆盖"而不是"叠加创建"。
swift
func configure(enabled: Bool, minutes: Int, frequency: ReminderFrequency) async -> Bool {
if !enabled {
cancel()
return true
}
let allowed = await requestAuthorizationIfNeeded()
guard allowed else {
cancel()
return false
}
schedule(minutes: minutes, frequency: frequency)
return true
}
按周频次时生成固定 ID,方便后续精确取消:
swift
let id = "\(weekdayPrefix)\(weekday)"
let request = UNNotificationRequest(identifier: id, content: content, trigger: trigger)
center.add(request, withCompletionHandler: nil)
6. Live Activity:状态去重,避免无效刷新
Live Activity 不是"每帧都更新",而是先比对状态,只有变化才推送:
swift
func update(state: BreathingLiveActivityAttributes.ContentState) {
guard #available(iOS 16.1, *) else { return }
guard let activity else { return }
guard state != lastState else { return }
lastState = state
Task {
let content = ActivityContent(state: state, staleDate: nil)
await activity.update(content)
}
}
收益:减少无意义更新,降低系统开销。
7. 数据闭环:训练记录 + 课程进度
7.1 会话记录模型(SwiftData)
swift
@Model
final class SessionRecord {
var id: UUID
var timestamp: Date
var modeId: String
var courseId: String?
var programId: String?
var programDay: Int?
var duration: Int
var preCheckin: String?
var postCheckin: String?
}
preCheckin/postCheckin 让"训练前后变化"可追踪,这是后续留存和转化分析的基础字段。
7.2 课程进度推进
swift
static func nextDayIndex(program: BreathingProgram, record: ProgramProgressRecord?) -> Int? {
let completed = Set(record?.completedDays ?? [])
for index in program.plan.indices {
if !completed.contains(index) {
return index
}
}
return nil
}
这个实现很朴素,但稳定,且便于后续做"断点继续"。
8. Widget 深链:缩短回流路径
Widget 直接绑定深链,用户从桌面可一跳进入训练:
swift
private let quickURL = URL(string: "breathing://start?type=quick")!
private let emergencyURL = URL(string: "breathing://start?type=emergency")!
这比"打开 App -> 选模式 -> 开始"少至少 2 步,对高频场景(焦虑急救/会前调整)很关键。
9. 订阅链路:StoreKit 2 的最小闭环
关键流程:拉商品 -> 发起购买 -> 校验交易 -> finish -> 刷新权益。
swift
func purchaseSelectedProduct() async {
guard let product = selectedProduct else { return }
let result = try await product.purchase()
switch result {
case .success(let verification):
let transaction = try checkVerified(verification)
await transaction.finish()
await refreshSubscriptionStatus()
case .pending, .userCancelled:
break
@unknown default:
break
}
}
恢复购买也单独兜底:
swift
try await StoreKit.AppStore.sync()
await refreshSubscriptionStatus()
10. 工程复盘:最值得复用的 4 个点
- 状态收口 :
AppStore统一管理跨页面状态。 - 节奏分治:阶段循环和倒计时分为两条 Task。
- 增长内建:提醒/Widget/Live Activity 不是后补功能,而是留存系统。
- 数据先行:从第一天就保留训练前后字段,后续分析成本最低。
后记:APP已经上架,某书上反响还不错,赚钱是次要的,主要产品有人用,技术有积累就很开心啦。 体验链接:apps.apple.com/cn/app/%E5%... PS:要兑换码好说,哈哈~

