阅读视图

发现新文章,点击刷新页面。

春节期间独立开发者从 0 到 1:呼吸训练 iOS App 的工程化落地

项目:呼吸视界(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 统一管理业务状态,避免“每个页面自己存一份状态”。

@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 固定在内部,避免散落字符串:

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 的关键是“阶段推进”和“总时长倒计时”分离:

@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()
}

启动时并行两条异步任务:

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)
    }
}

倒计时任务只做一件事:

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:避免每次重新解码 mp3
  • fadeIn/fadeOut:切换音景不突兀
  • updatePlayback:统一播放入口(按 isPlaying/isEnabled 决策)
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
    }
}

停止时先淡出再停引擎:

fade(to: 0, duration: fadeOutDuration) { [weak self] in
    self?.stopNow(resetSession: resetSession)
}

5. 通知提醒:权限、频率、撤销一体化

提醒模块用 UNUserNotificationCenter,重点是“配置即覆盖”而不是“叠加创建”。

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,方便后续精确取消:

let id = "\(weekdayPrefix)\(weekday)"
let request = UNNotificationRequest(identifier: id, content: content, trigger: trigger)
center.add(request, withCompletionHandler: nil)

6. Live Activity:状态去重,避免无效刷新

Live Activity 不是“每帧都更新”,而是先比对状态,只有变化才推送:

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)

@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 课程进度推进

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 直接绑定深链,用户从桌面可一跳进入训练:

private let quickURL = URL(string: "breathing://start?type=quick")!
private let emergencyURL = URL(string: "breathing://start?type=emergency")!

这比“打开 App -> 选模式 -> 开始”少至少 2 步,对高频场景(焦虑急救/会前调整)很关键。


9. 订阅链路:StoreKit 2 的最小闭环

关键流程:拉商品 -> 发起购买 -> 校验交易 -> finish -> 刷新权益。

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
    }
}

恢复购买也单独兜底:

try await StoreKit.AppStore.sync()
await refreshSubscriptionStatus()

10. 工程复盘:最值得复用的 4 个点

  1. 状态收口AppStore 统一管理跨页面状态。
  2. 节奏分治:阶段循环和倒计时分为两条 Task。
  3. 增长内建:提醒/Widget/Live Activity 不是后补功能,而是留存系统。
  4. 数据先行:从第一天就保留训练前后字段,后续分析成本最低。

后记:APP已经上架,某书上反响还不错,赚钱是次要的,主要产品有人用,技术有积累就很开心啦。 体验链接:apps.apple.com/cn/app/%E5%… PS:要兑换码好说,哈哈~

7e175ace-ca50-4f8f-8f0f-fbc0a82eecd7.jpg

cbebea05-dd13-4128-bf5e-06d7dce991d5.jpg

❌