深入解析 Dorso 代码架构:TCA 状态管理、纯函数 PostureEngine 与 400+ 无头单元测试实战
【免费下载链接】dorsoA macOS app that blurs your screen when you slouch.项目地址: https://gitcode.com/gh_mirrors/po/dorso
Dorso 是一个 macOS 姿势监控工具——当你驼背时它会模糊你的屏幕提醒你坐直。本文带你深入其代码架构:如何用 TCA(Swift Composable Architecture)集中管理追踪状态、如何用零副作用的纯函数PostureEngine实现可测试的状态机,以及 400+ 无头单元测试如何在没有图形界面的环境下保证这个 GUI 应用的稳定性。
一、整体分层:一个包,三个目标
Dorso 用 SwiftPM 组织代码(见 Package.swift),整个工程分为三层,依赖方向严格单向:
| 目标 | 路径 | 职责 |
|---|---|---|
DorsoCore(库) | Sources/ | 全部核心逻辑与 UI 组件,无main入口,可被测试导入 |
Dorso(可执行文件) | Sources/App/DorsoMain.swift | 仅一个启动入口,依赖DorsoCore |
DorsoTests(测试) | 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。它的State(L42-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.Action(L109-L170)枚举了应用可能经历的一切事件:appLaunched、postureReadingReceived、airPodsConnectionChanged、cameraConnected/Disconnected、screenLocked/Unlocked、powerSourceChanged、calibrationCompleted……
这带来的好处是:所有状态变化的入口都收敛为离散、可枚举、可比较(Equatable)的值。任何来自摄像头热插拔、蓝牙连接、屏幕锁定的 OS 事件,进入系统后都必须翻译成某个 Action,不存在绕过 store 的「暗门」。
2.3 副作用即数据:EffectIntent
TCA 中 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.performTrackingEffect(AppDelegate.swift#L277-L349)。新增副作用时,规则是「加一个EffectIntent用例 + 在performTrackingEffect里处理」,禁止任何临时回调。
这样设计的直接收益:测试里只需注入一个「录音式」运行时,把发出的意图记下来断言即可,完全不需要真实摄像头或蓝牙耳机。
三、纯函数 PostureEngine:把最难的状态机写成可重放的数学
如果说 TCA store 管「宏观」的应用状态,那么 PostureEngine.swift 管「微观」的姿势判定,而且全文件没有一行副作用代码。
3.1 输入输出式 API:喂数据,拿新状态 + 想要的副作用
最核心的processReading(L187-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 满天飞
应用级状态(AppState:disabled / monitoring / paused(reason) / calibrating)的合法转移,被显式列成一张表(canTransition L268-L286)。
更典型的是一组stateWhenXxx纯函数族,每个函数只回答一个业务问题:
stateWhenScreenLocks—— 锁屏时该从哪个状态切走、要不要记住锁前状态stateWhenScreenUnlocks—— 解锁后恢复到哪(注意:若解锁时恰好切换成电池供电且开启了「电池时暂停」,会落到电池暂停而不是恢复监控)stateWhenAirPodsConnectionChanges—— 耳机摘下/戴上的转移stateWhenCameraDisconnects—— 拔出相机后是暂停、换备用相机、还是保持stateWhenDisplayConfigurationChanges—— 拔掉外接显示器触发「外出暂停」
每个函数签名都是(当前状态 + 事件参数)→ 转移结果结构体,不持有任何可变字段。这种写法让「锁屏 → 拔电源 → 解锁」这类多步交互可以直接在测试里逐步重放。
四、单一漏斗:AppDelegate 如何衔接 store 与副作用
AppDelegate.swift 按职责拆分为多个扩展文件(+Tracking、+Calibration、+DeviceEvents 等),其中与状态机相关的只有三个方法,构成一条单向流水线:
applyTrackingAction/sendTrackingAction(L239-L271):同步或异步地向 store 发送 Action,取回新旧状态,调用applyTrackingStoreTransition完成「检测器/UI 与状态同步」——每一次状态转移的同步处理都不可能漏掉;- reducer 返回的 Effect携带一组
EffectIntent,经trackingRuntimeClient异步回流; performTrackingEffect(L277):唯一的副作用执行点,先通知测试观察者,再真正调用startMonitoring()、updateBlur()、saveSettings()等。
对外暴露的状态也是「视图」而非「副本」:
var state: AppState { get { trackingStore.withState { $0.appState } } set { applyTrackingAction(.setAppState(newValue)) } // 写入也必须走 store }五、400+ 无头单元测试:GUI 应用如何不碰窗口服务就跑测试
Dorso 的测试有一条硬性规则(来自项目内部规范 CLAUDE.md):
Tests must stay headless: nothing test-reachable in
DorsoCoremay require a window server—— 测试能触达的任何代码都不得依赖窗口服务,swift test必须在无图形环境下通过。
439 个测试函数分布在 23 个文件中(Tests/),重点大户包括:
| 测试文件 | 函数数 | 覆盖内容 |
|---|---|---|
| PostureEngineTransitionTests.swift | 99 | PostureEngine 全部纯函数转移 |
| TrackingFeatureTests.swift | 66 | reducer 的 Action→State 与 EffectIntent 断言 |
| ModelsTests.swift | 58 | 数据模型与设置迁移 |
| ObserverTests.swift | 26 | 摄像头/电源/屏幕锁定等系统观察者 |
| PostureEngineTests.swift | 33 | 逐帧姿势判定、防抖阈值、强度曲线 |
5.1 连菜单栏都能无头测试
最巧妙的例子是 MenuBarManagerHeadlessTests.swift:它不调用会挂载真实菜单的setup(),而是调用makeMenu()在内存中构建菜单,然后断言菜单项标题、勾选状态、快捷键等价字符(L23-L66):
let menu = manager.makeMenu() // 内存构建,不碰窗口服务 manager.updateEnabledState(false) XCTAssertEqual(menu.items[ItemIndex.enabled].state, .off)这正是「逻辑构建与系统挂载分离」带来的红利:makeMenu()纯构建可测,setup()只做挂载。
5.2 场景回放与新旧实现「平行对账」
更值得学习的是场景测试夹具:
- TrackingReducerScenarioHarness.swift:维护 reducer 状态,通过
withDependencies注入「录音运行时」捕获每个EffectIntent,把事件流压成一条可比较的时间线快照(L236-L283); - TrackingParityReplayTests.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,新架构任何一处转移不同都会立刻报警。
六、给初学者的架构启示
- 状态只有一个家:所有可变状态收敛到 store,UI/协调层只做「读取 + 发送 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),仅供参考