本文档面向 CalcLab 二次开发者,详细介绍项目架构、模块划分、扩展方法与最佳实践。
CalcLab 采用经典的三层架构:
┌─────────────────────────────────────────────┐
│ UI 层 (lib/ui/) │
│ - Flutter Widget │
│ - 用户交互 │
│ - 页面路由 │
└──────────────┬──────────────────────────────┘
│ 调用
▼
┌─────────────────────────────────────────────┐
│ 业务核心层 (lib/core/) │
│ - 纯 Dart 实现(不依赖 Flutter) │
│ - 可独立抽取为 Dart 包 │
│ - 易于单元测试 │
└──────────────┬──────────────────────────────┘
│ 使用
▼
┌─────────────────────────────────────────────┐
│ 基础设施层(Flutter 平台) │
│ - sqlite、path_provider、shared_preferences│
│ - pdf、image、fl_chart │
│ - flutter_math_fork │
└─────────────────────────────────────────────┘
- UI 层不包含业务逻辑,仅负责渲染和事件分发
- 业务核心层不依赖 Flutter Widget,可在 Dart VM、Node.js、Flutter Web 中复用
- 基础设施层通过
lib/core/storage/、lib/core/export/等适配器模式解耦
ui → core → infrastructure
绝对禁止反向依赖。
| 文件 | 职责 |
|---|---|
expression_engine.dart |
表达式求值入口,封装 math_expressions |
complex.dart |
复数类与运算 |
matrix.dart |
矩阵类与运算 |
equation_solver.dart |
一元/多元方程求解 |
latex_to_expression.dart |
LaTeX → 表达式(反向解析) |
math_renderer.dart |
LaTeX → Flutter Widget(封装层) |
- 8 个独立模块,按功能拆分
- 每个模块导出静态方法,无状态
- 输入:原始数据
List<double> - 输出:标量 / Map / 统计对象
unit_converter.dart:单位换算核心,支持动态注册新单位physical_constants.dart:常量数据库(Map<String, ConstantEntry>)
formula_recognizer.dart:抽象接口 + 本地实现local_rule_engine.dart:基于规则的本地方案(默认)nl_query_engine.dart:自然语言意图识别ai_service.dart:AI 服务总入口(预留远程接入)
share_service.dart:分享码编解码
history_model.dart:历史数据模型(不可变)history_store.dart:SQLite 持久化(CRUD)
csv_exporter.dart:数据 → CSVimage_exporter.dart:Widget → PNG(RepaintBoundary)
report_generator.dart:报告生成(PDF + Markdown)
每个页面单独成目录,结构:
ui/<feature>/
├── <feature>_page.dart # 主页面 Widget
└── widgets/ # 子组件(可选)
└── xxx.dart
主入口 main.dart 通过 IndexedStack 维护 4 个主 Tab:
CalculatorPage:主计算器DataPage:数据清洗ChartPage:图表HistoryPage:历史
高级功能通过主计算器右上角菜单的二级页面进入。
CalcLab 暂未引入第三方状态管理库,遵循以下原则:
- 用
StatefulWidget+setState管理页面级状态 - 不滥用全局变量
- 通过
InheritedWidget暴露(如主题) - 通过构造函数传递(如共享的数据对象)
- 历史数据通过
HistoryStore单例访问
// 写入
await HistoryStore.instance.add(
HistoryEntry(
expression: '1+2',
result: '3',
timestamp: DateTime.now(),
),
);
// 读取
final all = await HistoryStore.instance.all();
// 监听变化
HistoryStore.instance.changes.listen((event) {
// 刷新 UI
});假设新增 cbrt(x)(立方根)函数:
class ExpressionEngine {
static double evaluate(String expression) {
// 包装 math_expressions
final parser = Parser();
final ctx = ContextModel();
// 注册自定义函数
ctx.bindVariableName('cbrt', (num x) => _cbrt(x.toDouble()));
return parser.parse(expression).evaluate(EvaluationType.REAL, ctx);
}
static double _cbrt(double x) => x.isNegative
? -((-x) ** (1/3))
: x ** (1/3);
}编辑 lib/ui/calculator/keypad.dart,在合适位置添加:
CalcButton(
label: '∛',
onTap: () => _onFunction('cbrt('),
),// test/expression_engine_test.dart
test('cbrt(27) = 3', () {
expect(ExpressionEngine.evaluate('cbrt(27)'), closeTo(3.0, 1e-9));
});- 更新 USER_GUIDE.md 的「支持的运算」表格
- 更新 README.md 的特性列表
假设新增 geometricMean(List<double> data):
class CentralTendency {
/// 几何平均:所有值乘积的 n 次方根
/// 注意:数据必须为正
static double geometricMean(List<double> data) {
if (data.isEmpty) throw ArgumentError('Data cannot be empty');
if (data.any((x) => x <= 0)) {
throw ArgumentError('Geometric mean requires all positive values');
}
final product = data.reduce((a, b) => a * b);
return math.pow(product, 1 / data.length).toDouble();
}
}编辑 lib/core/statistics/ 的统一导出文件(如果存在),或直接 import 使用。
// test/statistics_test.dart
test('geometricMean of [1, 2, 4, 8] = 2.828...', () {
expect(
CentralTendency.geometricMean([1, 2, 4, 8]),
closeTo(2.8284271247461903, 1e-9),
);
});在 lib/ui/data/data_page.dart 中添加到统计菜单。
CalcLab 采用"接口预留 + 本地兜底"模式,新增 AI 服务步骤:
// lib/core/ai/remote_formula_recognizer.dart
import 'package:http/http.dart' as http;
import 'dart:convert';
class RemoteFormulaRecognizer implements FormulaRecognizer {
final String apiKey;
final String endpoint;
RemoteFormulaRecognizer({
required this.apiKey,
required this.endpoint,
});
@override
Future<FormulaRecognitionResult> recognize(String latex) async {
final response = await http.post(
Uri.parse(endpoint),
headers: {
'Authorization': 'Bearer $apiKey',
'Content-Type': 'application/json',
},
body: jsonEncode({'latex': latex}),
);
if (response.statusCode != 200) {
throw Exception('AI service error: ${response.statusCode}');
}
final data = jsonDecode(response.body);
return FormulaRecognitionResult(
latex: latex,
expression: data['expression'],
confidence: data['confidence'] ?? 0.0,
explanation: data['explanation'],
);
}
}// lib/core/ai/formula_recognizer.dart
FormulaRecognizer createDefaultRecognizer({String? apiKey}) {
if (apiKey != null && apiKey.isNotEmpty) {
return RemoteFormulaRecognizer(
apiKey: apiKey,
endpoint: 'https://api.example.com/recognize',
);
}
return LocalFormulaRecognizer();
}在设置页面添加 API Key 输入框。
编辑 lib/core/units/unit_converter.dart:
class UnitCategory {
final String name; // 物理量名
final String symbol; // 量符号
final List<UnitEntry> units;
}
class UnitEntry {
final String name; // 单位名
final String symbol; // 缩写
final double factor; // 相对 SI 的换算系数
final double? offset; // 偏移(如温度)
}
// 添加新类别
static final UnitCategory dataStorage = UnitCategory(
name: '数据存储',
symbol: 'S',
units: [
UnitEntry(name: '字节', symbol: 'B', factor: 1),
UnitEntry(name: '千字节', symbol: 'KB', factor: 1024),
UnitEntry(name: '兆字节', symbol: 'MB', factor: 1024 * 1024),
UnitEntry(name: '吉字节', symbol: 'GB', factor: 1024 * 1024 * 1024),
UnitEntry(name: '太字节', symbol: 'TB', factor: 1024 * 1024 * 1024 * 1024),
],
);并在 UnitConverter.categories 中注册。
// lib/ui/custom/my_feature_page.dart
import 'package:flutter/material.dart';
class MyFeaturePage extends StatefulWidget {
const MyFeaturePage({super.key});
@override
State<MyFeaturePage> createState() => _MyFeaturePageState();
}
class _MyFeaturePageState extends State<MyFeaturePage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('我的功能')),
body: const Center(child: Text('Hello CalcLab')),
);
}
}例如添加到主计算器菜单:
// lib/ui/calculator/calculator_page.dart
PopupMenuButton<String>(
itemBuilder: (context) => [
const PopupMenuItem(value: 'my_feature', child: Text('我的功能')),
// ... 其他项
],
onSelected: (value) {
if (value == 'my_feature') {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const MyFeaturePage()),
);
}
},
)// test/widget_test.dart
testWidgets('MyFeaturePage renders title', (tester) async {
await tester.pumpWidget(
const MaterialApp(home: MyFeaturePage()),
);
expect(find.text('我的功能'), findsOneWidget);
});| 类型 | 位置 | 框架 | 用途 |
|---|---|---|---|
| 单元测试 | test/ |
flutter test |
验证业务逻辑 |
| Widget 测试 | test/ |
flutter test |
验证 UI 组件 |
| 集成测试 | integration_test/ |
flutter test integration_test/ |
端到端流程 |
- 文件:
xxx_test.dart - 测试用例:使用描述性英文,
should ... when ...格式
test('should return geometric mean when given positive numbers', () {
// ...
});- 业务核心层:≥ 90%
- UI 层:≥ 60%
- 总覆盖率:≥ 75%
查看覆盖率:
flutter test --coverage
genhtml coverage/lcov.info -o coverage/html
# 打开 coverage/html/index.html- 边界值(0、负数、空列表、极大/极小值)
- 异常输入(null、非法字符)
- 浮点精度(用
closeTo(expected, tolerance)) - 并发安全(历史存储、分享码生成)
# 格式化全部
dart format .
# 检查未格式化
dart format --output=none --set-exit-if-changed .analysis_options.yaml 启用 flutter_lints,提交前必须:
flutter analyze| 类型 | 规范 | 示例 |
|---|---|---|
| 类 | PascalCase | HistoryStore |
| 方法/函数 | camelCase | evaluate() |
| 变量 | camelCase | result |
| 常量 | lowerCamelCase 或 SCREAMING | pi / MAX_SIZE |
| 私有成员 | 前缀下划线 | _internalState |
| 文件 | snake_case | history_store.dart |
- 公共 API 必须有 dartdoc 注释
- 中文注释仅用于业务逻辑说明
- 关键算法附上原理链接或公式
/// 计算皮尔逊相关系数
///
/// 公式:r = Σ((xi - x̄)(yi - ȳ)) / sqrt(Σ(xi - x̄)² × Σ(yi - ȳ)²)
///
/// 范围:[-1, 1],1 表示完全正相关,-1 表示完全负相关
double pearsonCorrelation(List<double> x, List<double> y) {
// ...
}<type>(<scope>): <subject>
<body>
<footer>
| Type | 用途 |
|---|---|
feat |
新功能 |
fix |
修复 bug |
docs |
仅文档 |
style |
格式(不影响代码运行) |
refactor |
重构 |
perf |
性能优化 |
test |
测试相关 |
chore |
构建/工具/依赖 |
git commit -m "feat(calculator): add cbrt() function"
git commit -m "fix(storage): handle concurrent history writes"
git commit -m "docs: update USER_GUIDE with new features"main:稳定主分支feature/*:特性开发fix/*:bug 修复release/*:发布准备hotfix/*:紧急修复
工作流:feature → main(PR + review)
import 'package:flutter/foundation.dart';
// 仅 debug 模式打印
debugPrint('History added: $entry');
// 性能追踪
final timeline = Stopwatch()..start();
// ... 代码
timeline.stop();
debugPrint('Operation took ${timeline.elapsedMilliseconds}ms');# 启动 DevTools
flutter run --debug
# 在输出中找到 DevTools URL 并打开主要功能:
- Inspector:查看 Widget 树
- Memory:内存泄漏检测
- Performance:帧时间分析
- Network:HTTP 请求追踪
VS Code:在代码行号左侧点击 → 设置断点 → F5 启动调试
# 单个文件 + 输出
flutter test test/expression_engine_test.dart --reporter expanded
# 显示 print 输出
flutter test --reporter expanded --concurrency=1编辑 android/gradle.properties:
org.gradle.jvmargs=-Xmx4G
org.gradle.parallel=true
org.gradle.caching=true使用国内镜像:
export PUB_HOSTED_URL=https://pub.flutter-io.cn
export FLUTTER_STORAGE_BASE_URL=https://storage.flutter-io.cn
flutter pub getcd ios
pod install --repo-update位置:lib/core/calculator/latex_to_expression.dart
支持命令:
\frac{a}{b}→a/b\sqrt{x}→sqrt(x)\pi→pix^{n}→x^n\times、\cdot→*\left(\right)→( )\sum_{i=1}^{n}→sum(i=1,n,...)
位置:lib/core/ai/nl_query_engine.dart
三层识别:
- 关键词 + 正则 匹配统计请求
- 运算符检测 识别为表达式求值
- 阶乘模式 提取阶乘运算
位置:lib/core/share/share_service.dart
- 编码:UTF-8 JSON → base64,加
CALCLAB:前缀 - 解码:剥离前缀 → base64 decode → UTF-8 JSON
| 场景 | 涉及文件 | 难度 |
|---|---|---|
| 新增三角函数 | expression_engine.dart、keypad.dart |
⭐ |
| 新增统计方法 | 新建 core/statistics/xxx.dart |
⭐⭐ |
| 新增单位类别 | unit_converter.dart |
⭐ |
| 接入云端 AI | core/ai/remote_xxx.dart |
⭐⭐⭐ |
| 新增图表类型 | fl_chart 文档参考 |
⭐⭐ |
| 多语言支持 | 创建 lib/l10n/、arb 文件 |
⭐⭐ |
| 暗色主题 | ThemeData.dark() |
⭐ |
- Flutter 官方文档
- Dart 官方文档
- math_expressions 包
- flutter_math_fork 包
- fl_chart 包
- Effective Dart
- Conventional Commits
📝 最后更新:2026-07-08 · 基于 CalcLab v1.0.0