架构走查报告:长辈计算器

2026-09-21 · ElderCalculator(SwiftUI / macOS)· 范围:全部 Swift 源码与测试

实线框 = 模块(module) 虚线 = 接缝(seam) 红色 = 泄漏 / 隐式耦合 深色厚框 = 深模块(deep module)

总体结论:计算引擎与中文朗读转换两侧已是深模块范本,测试只打接口。唯一的结构性短板在语音播报—— 产品的核心卖点,却是唯一没有测试接缝的行为规则集中地。

1 · 播报词决策与语音合成器分层

Strong ports & adapters
ElderCalculator/Speech/VoiceAnnouncer.swift · ElderCalculator/ContentView.swift:52-57

Before — 决策与硬件焊死

ContentView.press
唯一的播报触发点
↓
VoiceAnnouncer
一个模块,两层职责
播报词规则
"点" / "正负号" / "已清除" / 错误优先
AVSpeechSynthesizer
硬编码私有属性,一调就真出声
↓
测试接缝够不到 announce —— 产品核心规则零测试覆盖

After — 纯决策 + 可替换适配器

ContentView.press
编排不变
↓
播报词决策模块(深)
(key, DisplayState) → 播报词?
纯函数,像引擎一样 TDD
↓
接缝:语音合成器接口
适配器 · 生产
AVSpeechSynthesizer
适配器 · 测试
记录播报词的假合成器

问题

错误优先、"="念结果、每个功能键的中文播报词,这些适老化核心规则锁在与 AVSpeechSynthesizer 硬耦合的模块里,现有测试接缝完全够不到;非法数字键的防御判断还在引擎与播报侧各写了一份。

方案

把"按键 + 状态 → 播报词"的纯决策从 VoiceAnnouncer 中提出为独立模块,合成器退到接口之后成为可注入的适配器。

收益

  • 局部性:播报规则集中一处,可 TDD
  • 两个适配器 = 真实接缝
  • 删除测试:决策模块删掉,规则散进 View
  • 接口即测试面:断言播报词字符串

2 · 数值规范口径收敛为一处

Worth exploring in-process
ElderCalculator/Engine/CalculatorEngine.swift:334-343 · ElderCalculator/Speech/ChineseSpeechFormatter.swift:27-32

Before — 同一口径两处实现

flowchart TB
  E["CalculatorEngine.formatted
%.10g"] S["ChineseSpeechFormatter.numericText
%.10g"] E <-.->|"隐式约定:靠注释对齐"| S classDef leak stroke:#dc2626,stroke-width:2px,color:#dc2626; linkStyle 0 stroke:#dc2626,stroke-width:2px,stroke-dasharray:5 5;

After — 一个数值规范模块

flowchart TB
  N["数值规范文本(深模块)
10 位有效数字 · 去尾零"] E["CalculatorEngine.formatted
屏幕显示"] S["ChineseSpeechFormatter.numericText
语音转换"] N --> E N --> S classDef deep fill:#0f172a,color:#f8fafc,stroke:#0f172a; class N deep;

问题

"结果最多 10 位有效数字"的口径在两个模块各实现一遍,一致性靠注释维持;改口径漏一处,屏幕显示与语音播报就会对不上。

方案

提取共享的"数值规范文本"概念,屏幕格式化与语音朗读转换都从同一口径出发。

收益

  • 局部性:口径改一次,两处同时生效
  • 隐式耦合变显式依赖
  • 口径本身获得独立测试面

3 · 运算符的全部表述住进一个文件

Worth exploring in-process
ElderCalculator/Engine/CalculatorEngine.swift:366-395 · ElderCalculator/Speech/VoiceAnnouncer.swift:67-77

Before — 一个概念被切开

Engine/ · ArithmeticOperation
symbol 屏幕符号 apply 运算语义 percentValue 百分比
Speech/ · 扩展
spokenWord 播报用词

新增第五种运算 / 改"除"的念法 → 跨目录找两个 switch

After — 表述集中

ArithmeticOperation(一处)
symbol apply percentValue spokenWord

或:候选 1 落地后,播报用词并入播报词决策层,同样集中

Speech/ · 扩展
spokenWord(搬走)

问题

运算符的屏幕符号、运算语义、百分比规则在引擎文件里,播报用词却在 Speech 目录的扩展里——"一个运算的所有表述"被切到两个文件。

方案

把播报用词并回运算符定义所在处(或并入候选 1 的播报词决策层),一个概念住一个文件。

收益

  • 局部性:改运算只动一处
  • 体量小:4 行代码搬家级别
  • 可与候选 1 合并落地

4 · 错误态改为 DisplayState 的显式 case

Speculative in-process
ElderCalculator/Engine/CalculatorEngine.swift:15-16, 162-170 · 消费方:ContentView.swift:103 / VoiceAnnouncer.swift:27

Before — 两个字段说同一事实

DisplayState
mainText"不能除以零"
errorMessage"不能除以零"

enterDivideByZeroError 分别写两个字段,靠人肉保持同步

After — 错误是显式状态

DisplayState
case normal(mainText, …)
case error(message) — 单一事实来源

红色样式与错误优先播报都从 case 派生

问题

错误态下 mainText 与 errorMessage 同值,两个并行字段表达同一事实,同步靠写入方自觉。

方案

让错误态成为 DisplayState 的显式 case,而不是两个并行字段。收益小,不动也成立。

收益

  • 单一事实来源
  • 现有消费方用法都合理
  • 冗余真实但代价低

已验证足够深,不建议动

最高推荐

候选 1 · 播报词决策与语音合成器分层

语音播报是 CONTEXT.md 钦定的适老化核心功能,却是代码库中唯一"有产品规则、无测试路径"的模块。 分层后播报词决策变成 (key, DisplayState) → String 的纯函数,可以像 CalculatorEngine 一样 TDD; 合成器退到接缝之后,生产用 AVSpeechSynthesizer、测试用记录型假适配器——两个适配器,真实接缝。 成本低(一个文件的重构),收益直接落在产品卖点上。回到候选 1