news 2026/10/3 13:05:48

深入解析 Dorso 代码架构:TCA 状态管理、纯函数 PostureEngine 与 400+ 无头单元测试实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
深入解析 Dorso 代码架构:TCA 状态管理、纯函数 PostureEngine 与 400+ 无头单元测试实战

深入解析 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 等),其中与状态机相关的只有三个方法,构成一条单向流水线:

  1. applyTrackingAction/sendTrackingAction(L239-L271):同步或异步地向 store 发送 Action,取回新旧状态,调用applyTrackingStoreTransition完成「检测器/UI 与状态同步」——每一次状态转移的同步处理都不可能漏掉;
  2. reducer 返回的 Effect携带一组EffectIntent,经trackingRuntimeClient异步回流;
  3. 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 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-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,新架构任何一处转移不同都会立刻报警。

六、给初学者的架构启示

  1. 状态只有一个家:所有可变状态收敛到 store,UI/协调层只做「读取 + 发送 Action」,杜绝双份状态不同步;
  2. 副作用即数据:把「我想开始监控」表达为可枚举、可断言的值,执行点唯一化,测试只需录制和断言;
  3. 纯函数吃掉状态机:把转移逻辑写成输入 → (新状态, 想要的副作用)的静态函数,天然支持逐帧重放和参数化枚举(99 个转移测试就是这么来的);
  4. 构建与挂载分离:把「构建菜单」和「挂到菜单栏」分开,让 GUI 代码也能在无头 CI 里被 400+ 测试覆盖;
  5. 重构靠回放对账:新旧实现共享事件语料、断言时间线一致,比人工回归可靠得多。

结语

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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/3 13:03:42

C++ 入门基础复习:从 C 到 C++ 的关键变化一次搞懂

C 入门基础复习:从 C 到 C 的关键变化一次搞懂 本篇根据你提供的 01.C入门基础.pdf 整理 目标不是重新学习,而是帮助你把以前学过的知识快速重新串起来 按照你的要求,计算机历史相关内容不整理,并且全文去掉中文句号 。 文章目录C…

作者头像 李华
网站建设 2026/10/3 13:01:29

HiSH - Pi Agent 安装与配置指南

一、前置条件在开始安装之前,请确保你已经安装了 Node.js 和 npm。二、安装执行以下命令安装 Pi:curl -fsSL https://pi.dev/install.sh | sh确认界面会输出如下内容:██████ ██ ██ ████ ██ ██ ██Pi Installer There are …

作者头像 李华
网站建设 2026/10/3 13:01:29

C语言学习笔记(十七):文件操作

目录 1. 为什么使用文件2. 什么是文件 2.1 程序文件2.2 数据文件2.3 文件名 3. 二进制文件和文本文件4. 文件的打开和关闭 4.1 流和标准流4.2 文件指针4.3 fopen和fclose 5. 文件的顺序读写 5.1 fputc5.2 fgetc5.3 feof和ferror5.4 fputs5.5 fgets5.6 fprintf5.7 fscanf5.8 fw…

作者头像 李华
网站建设 2026/10/3 13:00:51

AI接管KiCad图形界面:KiCAD MCP Server的11个GUI驱动工具如何工作

AI接管KiCad图形界面:KiCAD MCP Server的11个GUI驱动工具如何工作 【免费下载链接】KiCAD-MCP-Server KiCAD MCP is a Model Context Protocol (MCP) implementation that enables Large Language Models (LLMs) like Claude to directly interact with KiCAD for p…

作者头像 李华
网站建设 2026/10/3 13:00:03

自芯片顶端发光:VCSEL如何成为AI集群光互联的“快而宽”解法

垂直腔面发射激光器(VCSEL):让 AI 集群“自芯片顶端”发光 核心结论 垂直腔面发射激光器(VCSEL)是一种从芯片表面垂直发射激光的半导体光源。与传统的边发射激光器不同,VCSEL的光束垂直于衬底射出,天然具备圆形光斑、低阈值电流、晶圆级可测试性和二维阵列集成能力。在…

作者头像 李华
网站建设 2026/10/3 12:59:47

CDMA仿真原理与MATLAB实现:从扩频解扩到误码率验证

简介:面向3G移动通信学习与MATLAB仿真的CDMA技术资料包,压缩包共9个文件,包含3个MATLAB脚本、2个C源文件与2个DLL动态库、1个数据文件和1个技术文档,总大小仅626KB。文档系统讲解CDMA扩频通信原理、码片序列机制以及抗干扰、容量大…

作者头像 李华