深入解析 Dorso 代码架构TCA 状态管理、纯函数 PostureEngine 与 400 无头单元测试实战【免费下载链接】dorsoA macOS app that blurs your screen when you slouch.项目地址: https://gitcode.com/gh_mirrors/po/dorsoDorso 是一个 macOS 姿势监控工具——当你驼背时它会模糊你的屏幕提醒你坐直。本文带你深入其代码架构如何用 TCASwift Composable Architecture集中管理追踪状态、如何用零副作用的纯函数PostureEngine实现可测试的状态机以及 400 无头单元测试如何在没有图形界面的环境下保证这个 GUI 应用的稳定性。一、整体分层一个包三个目标Dorso 用 SwiftPM 组织代码见 Package.swift整个工程分为三层依赖方向严格单向目标路径职责DorsoCore库Sources/全部核心逻辑与 UI 组件无main入口可被测试导入Dorso可执行文件Sources/App/DorsoMain.swift仅一个启动入口依赖DorsoCoreDorsoTests测试Tests/23 个测试文件、439 个测试函数只依赖DorsoCore// Package.swift 中的依赖TCA 负责状态管理Sparkle 负责自动更新 .package(url: https://github.com/pointfreeco/swift-composable-architecture, from: 1.10.0), .package(url: https://github.com/sparkle-project/Sparkle, from: 2.9.4)这种「核心逻辑编成库、入口只是薄壳」的拆法是后面所有可测试性的前提——测试直接testable import DorsoCore完全不需要启动应用。二、TCA 状态管理TrackingFeature 如何收编所有追踪状态项目的架构铁律写得很直白所有追踪状态只存在于 TCA store 中AppDelegate只提供计算属性读取绝不复制状态。2.1 状态一个 State 装下全局核心 reducer 是 TrackingFeature.swift 中的TrackingFeature: Reducer。它的StateL42-L107聚合了应用生命周期状态、追踪模式手动/自动、当前激活源相机/AirPods、两个数据源的就绪情况、屏幕锁定前的状态快照等struct State: Equatable { var appState: AppState .disabled // 禁用/监控/暂停/校准 var trackingMode: TrackingMode .manual // 手动或自动切换数据源 var activeSource: TrackingSource .camera var cameraReadiness TrackingSourceReadiness() // 权限/连接/已校准 var airPodsReadiness TrackingSourceReadiness() var monitoringState PostureMonitoringState() // 连续坏帧计数等 // ... }2.2 事件一套完整的 Action 词表TrackingFeature.ActionL109-L170枚举了应用可能经历的一切事件appLaunched、postureReadingReceived、airPodsConnectionChanged、cameraConnected/Disconnected、screenLocked/Unlocked、powerSourceChanged、calibrationCompleted……这带来的好处是所有状态变化的入口都收敛为离散、可枚举、可比较Equatable的值。任何来自摄像头热插拔、蓝牙连接、屏幕锁定的 OS 事件进入系统后都必须翻译成某个 Action不存在绕过 store 的「暗门」。2.3 副作用即数据EffectIntentTCA 中 reducer 可以返回 Effect 执行副作用。Dorso 的做法更进一步——reducer 不直接碰任何真实资源而是声明它想要什么// TrackingFeature.swift L24-L40 enum EffectIntent: Equatable { case startMonitoring case beginMonitoringSession case switchCamera(CameraSwitchIntent) case syncUI case updateBlur case stopDetector(TrackingSource) case persistTrackingSource // ... }reducer 通过Dependency(\.trackingRuntime)把意图交给运行时L174-L185而真正的执行点只有一个AppDelegate.performTrackingEffectAppDelegate.swift#L277-L349。新增副作用时规则是「加一个EffectIntent用例 在performTrackingEffect里处理」禁止任何临时回调。这样设计的直接收益测试里只需注入一个「录音式」运行时把发出的意图记下来断言即可完全不需要真实摄像头或蓝牙耳机。三、纯函数 PostureEngine把最难的状态机写成可重放的数学如果说 TCA store 管「宏观」的应用状态那么 PostureEngine.swift 管「微观」的姿势判定而且全文件没有一行副作用代码。3.1 输入输出式 API喂数据拿新状态 想要的副作用最核心的processReadingL187-L246处理每帧姿势读数static func processReading( _ reading: PostureReading, state: PostureMonitoringState, config: PostureConfig, currentTime: Date Date(), frameInterval: TimeInterval 0.1 ) - PostureReadingResult // (newState, effects)它的内部逻辑是一套带防抖的帧计数连续 8 帧坏姿势frameThreshold才判定为驼背连续 5 帧好姿势goodFrameThreshold立即恢复还叠加了warningOnsetDelay警告延迟与强度曲线pow(severity, 1/intensity)。所有对外影响updateUI、updateBlur、recordSlouchEvent、trackAnalytics都只是返回值里的PostureEngineEffect枚举由调用方决定是否执行。3.2 状态机转移查表而非 if 满天飞应用级状态AppStatedisabled / monitoring / paused(reason) / calibrating的合法转移被显式列成一张表canTransition L268-L286。更典型的是一组stateWhenXxx纯函数族每个函数只回答一个业务问题stateWhenScreenLocks—— 锁屏时该从哪个状态切走、要不要记住锁前状态stateWhenScreenUnlocks—— 解锁后恢复到哪注意若解锁时恰好切换成电池供电且开启了「电池时暂停」会落到电池暂停而不是恢复监控stateWhenAirPodsConnectionChanges—— 耳机摘下/戴上的转移stateWhenCameraDisconnects—— 拔出相机后是暂停、换备用相机、还是保持stateWhenDisplayConfigurationChanges—— 拔掉外接显示器触发「外出暂停」每个函数签名都是当前状态 事件参数→ 转移结果结构体不持有任何可变字段。这种写法让「锁屏 → 拔电源 → 解锁」这类多步交互可以直接在测试里逐步重放。四、单一漏斗AppDelegate 如何衔接 store 与副作用AppDelegate.swift 按职责拆分为多个扩展文件Tracking、Calibration、DeviceEvents 等其中与状态机相关的只有三个方法构成一条单向流水线applyTrackingAction/sendTrackingActionL239-L271同步或异步地向 store 发送 Action取回新旧状态调用applyTrackingStoreTransition完成「检测器/UI 与状态同步」——每一次状态转移的同步处理都不可能漏掉reducer 返回的 Effect携带一组EffectIntent经trackingRuntimeClient异步回流performTrackingEffectL277唯一的副作用执行点先通知测试观察者再真正调用startMonitoring()、updateBlur()、saveSettings()等。对外暴露的状态也是「视图」而非「副本」var state: AppState { get { trackingStore.withState { $0.appState } } set { applyTrackingAction(.setAppState(newValue)) } // 写入也必须走 store }五、400 无头单元测试GUI 应用如何不碰窗口服务就跑测试Dorso 的测试有一条硬性规则来自项目内部规范 CLAUDE.mdTests must stay headless: nothing test-reachable inDorsoCoremay require a window server—— 测试能触达的任何代码都不得依赖窗口服务swift test必须在无图形环境下通过。439 个测试函数分布在 23 个文件中Tests/重点大户包括测试文件函数数覆盖内容PostureEngineTransitionTests.swift99PostureEngine 全部纯函数转移TrackingFeatureTests.swift66reducer 的 Action→State 与 EffectIntent 断言ModelsTests.swift58数据模型与设置迁移ObserverTests.swift26摄像头/电源/屏幕锁定等系统观察者PostureEngineTests.swift33逐帧姿势判定、防抖阈值、强度曲线5.1 连菜单栏都能无头测试最巧妙的例子是 MenuBarManagerHeadlessTests.swift它不调用会挂载真实菜单的setup()而是调用makeMenu()在内存中构建菜单然后断言菜单项标题、勾选状态、快捷键等价字符L23-L66let menu manager.makeMenu() // 内存构建不碰窗口服务 manager.updateEnabledState(false) XCTAssertEqual(menu.items[ItemIndex.enabled].state, .off)这正是「逻辑构建与系统挂载分离」带来的红利makeMenu()纯构建可测setup()只做挂载。5.2 场景回放与新旧实现「平行对账」更值得学习的是场景测试夹具TrackingReducerScenarioHarness.swift维护 reducer 状态通过withDependencies注入「录音运行时」捕获每个EffectIntent把事件流压成一条可比较的时间线快照L236-L283TrackingParityReplayTests.swift定义了约 30 个事件序列场景锁屏/解锁、耳机断连重连、相机拔出换备机、显示器热插拔、校准失败……让旧实现 Harness 与新 reducer Harness 逐事件回放同一条事件流最后断言两条时间线完全相等。for event in scenario.events { legacyHarness.send(event) await reducerHarness.send(event) } XCTAssertEqual(reducerHarness.timeline, legacyHarness.timeline) // 新旧实现必须分毫不差这套「parity replay对等回放」是重构老状态机时最稳的保险旧行为是 ground truth新架构任何一处转移不同都会立刻报警。六、给初学者的架构启示状态只有一个家所有可变状态收敛到 storeUI/协调层只做「读取 发送 Action」杜绝双份状态不同步副作用即数据把「我想开始监控」表达为可枚举、可断言的值执行点唯一化测试只需录制和断言纯函数吃掉状态机把转移逻辑写成输入 → (新状态, 想要的副作用)的静态函数天然支持逐帧重放和参数化枚举99 个转移测试就是这么来的;构建与挂载分离把「构建菜单」和「挂到菜单栏」分开让 GUI 代码也能在无头 CI 里被 400 测试覆盖重构靠回放对账新旧实现共享事件语料、断言时间线一致比人工回归可靠得多。结语Dorso 的功能很简单——驼背就模糊屏幕模糊强度还会随驼背程度渐进加深坐直即刻清除。但支撑这个简单体验的是一套教科书级的工程架构TCA store 收编全局状态、纯函数 PostureEngine 承载状态机、EffectIntent把副作用变成可测试的数据、439 个无头测试在无窗口环境下持续护航。无论是做 macOS 小工具还是更大的桌面应用这套「状态集中 纯函数转移 场景回放」的组合都值得直接抄作业。 想动手细读建议路线Sources/Core/TrackingFeature.swift → Sources/Core/PostureEngine.swift → Sources/AppDelegate/AppDelegate.swift → Tests/。【免费下载链接】dorsoA macOS app that blurs your screen when you slouch.项目地址: https://gitcode.com/gh_mirrors/po/dorso创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考