收尾篇:为什么把这几个主题合并

PipeMonitor 的 Flutter 端有三块内容相对薄、单独成篇都会显得水——Material 3 主题配置只有 30 行、Logger 配置只有 15 行、Result 类型也只有 40 行。但它们都是”工程基础设施”性质的东西:主题决定整个 App 的视觉基调,日志决定调试和运维的颗粒度,Result 决定数据层错误如何流向 UI。把它们硬撑成三篇会稀释信息密度,所以这篇作为系列收尾,把三者合并讲透,同时回顾 Archive/Plans 下的开发历程与多版本备份组织方式,给整个 PipeMonitor 博客系列画个句号。

Material 3 种子色主题:一个颜色驱动两套主题

PipeMonitor 的主题配置在 lib/config/theme.dart,全文件不到 40 行,核心是用 ColorScheme.fromSeed 从一个工业蓝种子色派生亮色和暗色两套主题:

/// 全局主题配置。
///
/// 用单一种子色驱动 Material 3 调色板,亮色 / 暗色两套主题
/// 都从同一个 seed 派生,保证整体品牌色一致。
class AppTheme {
  const AppTheme._();

  /// 工业蓝。PipeMonitor 的主品牌色。
  static const Color _seed = Color(0xFF1976D2);

  /// 亮色主题。
  static ThemeData light() {
    return ThemeData(
      colorScheme: ColorScheme.fromSeed(
        seedColor: _seed,
        brightness: Brightness.light,
      ),
      useMaterial3: true,
      // AppBar 标题靠左,符合工业类应用阅读习惯。
      appBarTheme: const AppBarTheme(centerTitle: false),
    );
  }

  /// 暗色主题(系统跟随时使用)。
  static ThemeData dark() {
    return ThemeData(
      colorScheme: ColorScheme.fromSeed(
        seedColor: _seed,
        brightness: Brightness.dark,
      ),
      useMaterial3: true,
      appBarTheme: const AppBarTheme(centerTitle: false),
    );
  }
}

几个设计决策值得展开:

为什么用 fromSeed 而不是手写 ColorScheme。Material 3 的色调系统(tonal palette)从一个种子色能按算法生成 13 个色调档位(tone 0 到 tone 100),每个色调档位再映射到 primaryonPrimaryprimaryContaineronPrimaryContainer 等角色色。手写这些角色色要保证对比度、保证暗色态可读、保证容器色和前景色不冲突,工作量巨大且容易翻车。fromSeed 把这套算法交给 Flutter 内置的 HCT 色彩空间实现,开发者只管选一个种子色,剩下的事 Flutter 全包。

为什么种子色选 0xFF1976D2。这是 Material Design 经典蓝的深一档,工业设备 dashboard 里最常见的”信息蓝”。PipeMonitor 是流量监测应用,主色需要传达”可靠、可读、不刺眼”,避免高饱和的亮蓝在长时间盯屏时疲劳。亮色和暗色两套主题共用同一个种子色,保证品牌色一致——用户在亮暗模式间切换时,primary 色的色相不变,只是明度档位不同。

centerTitle: false 的工业属性。Material 3 默认 AppBar 标题居中,这是消费类 App 的习惯(Instagram、Settings)。工业类应用的数据密度高,左对齐标题能让视线从标题自然滑到下方的数据卡片,减少眼球跳动。这个细节是工业 UI 的默认约定,不是 Flutter 的默认值,所以需要显式覆盖。

AppTheme._() 私有构造。这个类只暴露静态方法,不允许实例化。Dart 里没有静态类的概念,用私有构造器模拟是惯用法,明确表达”这是个工具类,不要 new”。

主题的接入在 app.dart 里:

MaterialApp.router(
  // ...
  theme: AppTheme.light(),
  darkTheme: AppTheme.dark(),
  // 跟随系统暗色模式
)

theme + darkTheme 同时配置后,Flutter 会根据系统设置自动切换。如果未来要支持”跟随系统 / 强制亮色 / 强制暗色”三态切换,加一个 themeMode 参数即可,主题本身不用动。

Flutter Logger:统一日志格式与调用栈控制

日志配置在 lib/utils/app_logger.dart,全项目通过 appLog.d/i/w/e(...) 输出日志,禁用 print

import 'package:logger/logger.dart';

/// 全局共享的日志实例。
///
/// 全项目应使用 `appLog.d/i/w/e(...)` 而不是 `print`
/// 以便统一格式、便于在生产环境替换为远端日志收集。
final Logger appLog = Logger(
  printer: PrettyPrinter(
    methodCount: 0, // 普通日志不打印调用栈
    errorMethodCount: 5, // 错误日志保留 5 行调用栈,便于定位
    lineLength: 100,
    colors: true,
    printEmojis: false,
    dateTimeFormat: DateTimeFormat.onlyTimeAndSinceStart,
  ),
);

每个参数的选择都有理由:

methodCount: 0PrettyPrinter 默认会打印调用栈,每条日志后面跟一串 #0 foo (file.dart:12) 的栈帧。开发时看着信息量大,实际调试时全是噪音——大部分日志只是确认”执行到这里了”,不需要知道调用链。设为 0 让普通日志干净一行,只保留时间、级别、消息。

errorMethodCount: 5error 级别才需要调用栈定位。e(...) 调用通常对应异常或业务错误,5 行栈帧足以看清”谁调的、从哪条路径过来的”,又不会刷屏。methodCounterrorMethodCount 分开设置是这个配置的核心——普通日志求简洁,错误日志求可定位

printEmojis: falsePrettyPrinter 默认在每行前加 emoji( info warning),终端看着活泼,但复制到 issue tracker、日志文件、IDE 输出框时全是乱码。工业项目日志要能被 grep、能被脚本处理,emoji 是纯负收益。

dateTimeFormat: DateTimeFormat.onlyTimeAndSinceStart。只显示时间和距启动的毫秒数,不显示日期。App 单次运行时间通常在分钟级,日期是冗余信息;“距启动 X 毫秒”对定位”启动后第几秒发生了什么”很有用,比如 WebSocket 重连间隔、首屏数据加载耗时。

colors: true。ANSI 颜色码在支持终端里能快速区分级别,复制到纯文本时退化为无色,不影响可读性。

这个配置的隐含约定是:生产环境替换 printEmojis/colors 为 false、甚至替换 printer 为远端上报实现,都不需要改业务代码appLog 是个 Logger 实例,业务侧只依赖接口不依赖具体实现,未来接 Sentry / Firebase Crashlytics 时只需改这一个文件。

Result 类型:用模式匹配替代 try/catch

lib/utils/result.dart 是个 40 行的 sealed class,却是整个数据层的错误处理基石:

/// 数据层广泛使用的函数式 [Result] 类型。
///
/// Repository 一律返回 `Future<Result<T>>`,让 ViewModel 可以
/// 用模式匹配区分成功 / 失败,不必到处写 try/catch。
///
/// ```dart
/// final res = await repo.getLatest();
/// switch (res) {
///   case Ok(:final value): // 使用 value
///   case Err(:final error): // 显示错误
/// }
/// ```
sealed class Result<T> {
  const Result();

  /// 是否为成功结果。
  bool get isOk => this is Ok<T>;

  /// 取值;若是 [Err] 则返回 `null`,便于链式表达。
  T? get valueOrNull => switch (this) {
        Ok<T>(:final value) => value,
        Err<T>() => null,
      };
}

/// 成功分支:携带类型为 [T] 的实际值。
final class Ok<T> extends Result<T> {
  final T value;
  const Ok(this.value);
}

/// 失败分支:携带异常对象与可选的调用栈,便于上层日志定位。
final class Err<T> extends Result<T> {
  final Object error;
  final StackTrace? stackTrace;
  const Err(this.error, [this.stackTrace]);

  @override
  String toString() => 'Err($error)';
}

为什么不用异常。Dart 的异常机制鼓励”抛掷-捕获”,但在数据层这会导致两个问题:一是每个 Repository 调用点都要写 try/catch,且 catch 块里要猜测可能抛什么异常(网络异常?解析异常?业务错误?),代码膨胀;二是异常会打断控制流,await repo.getLatest() 后面的代码在异常时不会执行,ViewModel 需要靠 try/catch 兜底,逻辑分散。

Result<T> 把错误变成值——Ok<T> 携带成功值,Err<T> 携带错误对象。Repository 永远返回 Future<Result<T>>,ViewModel 用 switch 模式匹配处理两种分支:

final res = await repo.getLatest();
switch (res) {
  case Ok(:final value):
    // 渲染 value
  case Err(:final error):
    // 显示 error.toString()
}

这种写法的好处是编译器强制处理两个分支。Dart 3 的 sealed class + exhaustive switch 会让漏掉 Err 分支的代码编译报错,比 try/catch 漏处理异常更安全。

valueOrNull 的链式便利。有时候 ViewModel 只想要”有值就用,没值就 null”,不需要区分错误类型。valueOrNull 用内联 switch 实现,Err 时返回 null,配合 ?. 操作符可以写出 repo.getLatest().valueOrNull?.flow 这样的链式表达式。

Err 携带 StackTrace。错误对象的 toString 通常只有消息没有栈,定位”哪里抛的”需要栈帧。Err 构造器接受可选的 StackTrace,Repository 在 catch 时把 stackTrace 一起塞进去,ViewModel 或全局错误处理就能 appLog.e(err.error, err.error, err.stackTrace) 打出完整调用链。

这个类型在 PLAN4 的”可复用资产”里被明确列为基础设施,整个 Flutter MVP 阶段的数据层都建立在它之上。

Archive/Plans:开发历程的版本化归档

PipeMonitor 的 Archive/Plans/ 目录下有 12 个计划文件,按编号排列:

Archive/Plans/
├── PLAN0-legacy-draft.md          # 初稿:架构选型与全链路设计
├── PLAN1-legacy-draft.md
├── PLAN2-legacy-plan.md
├── PLAN3-legacy-baseline.md
├── PLAN4-legacy-flutter-mvp.md    # Flutter MVP:只读数据接入
├── PLAN5-legacy-flutter-followup.md
├── PLAN6-legacy-stm32-portal.md
├── PLAN7-legacy-flutter-ui-alarm.md
├── PLAN8-legacy-stm32-watchdog.md
├── PLAN9-legacy-remaining-work.md
├── PLAN10-legacy-remaining-work.md
├── PLAN11-completed.md            # 收尾:剩余待办与验收清单

这种”编号 + 状态后缀”的命名方式有几个特点:

编号单调递增。PLAN0 是最初的整体方案,PLAN11 是最后的收尾清单。每个 PLAN 对应一次明确的开发阶段,编号本身是时间线。

legacy- 前缀标记历史文档。带 legacy- 的文件是”当时写下的计划”,不是当前规范。它们保留的是决策时刻的上下文——为什么选 MQTT 透传、为什么用 JSON 行分隔、为什么单用户单设备。这些上下文在事后看代码时无法还原,必须靠计划文件留存。

-completed.md 标记收尾。PLAN11 是最后一个,文件名直接标 completed,明确表达”这个项目到此为止”。

PLAN0:架构选型的全貌

PLAN0 是整个项目的起点,一份 380 行的整体方案,覆盖了从 STM32 固件、DTU 配置、云端后端到 Flutter App 的完整链路。关键决策都在这里定下:

  • DTU 选 USR DR154,MQTT 透传模式(NOR),不做协议网关。
  • STM32 通过 USART3 + MAX485 接 DTU,固件侧只做 UART 帧收发 + JSON 编解码,不碰 AT 命令。
  • 协议选 Line-Delimited JSON,每帧以 \n 结尾,不依赖 MQTT 包边界。
  • Flutter 架构遵循官方 App Architecture Guide,UI Layer(View + ViewModel)+ Data Layer(Repository + Service)。
  • 分 6 个 Phase 落地,从 STM32 上行通道到 Flutter App 逐层打通。

这份计划后来基本被完整执行,PLAN4 和 PLAN11 是它的具体子阶段。

PLAN4:Flutter MVP 的现实修正

PLAN4 是 Flutter 接入真后端的 MVP 计划,核心是修正 mock 阶段与服务端实际 shape 的多处不匹配。计划开篇就列了 7 个对不上的点:

  1. /api/latest 服务端返回 {device, topic, payload, receivedAt},Flutter 直接把根对象当 Measurement 解析。
  2. /api/history 服务端返回 {data: [...]},Flutter 期望裸数组。
  3. WebSocket 服务端信封是 {event, kind, payload},Flutter 当 {type, data} 解析。
  4. Flutter 调 POST /api/auth/loginPOST /api/cmd/reboot,服务端没有这两个接口。
  5. Flutter 没传 dev=FM001 参数。

这份计划的价值在于诚实地记录了 mock 优先开发的代价:mock 数据形状是开发者脑补的,真后端 shape 是另一个开发者按数据库表设计的,两者不对齐是常态。PLAN4 的全部工作就是把 Flutter 端的解析对齐到服务端实际 shape,并做出”跳过登录、隐藏 Control”的产品决策。

PLAN11:收尾与遗留待办

PLAN11 是最后一篇,标题就叫 completed.md,但内容里仍然列了 5 类待办:

  • Flutter 控制占位收口(ControlViewModelsendReboot 去留)
  • 手机端真实数据验收(adb devices、真机各页面验证)
  • 旧 GitHub 仓库归档
  • MQTT 1883/8883 路线确认
  • stm32.varka.cn 多应用入口站

这份文件的特殊性在于它承认项目不会真正”完成”——核心功能跑通后,仍有一堆”需要现场验收”或”和现有文档冲突需要重新决策”的事项。把它们单独列出而非塞进 PLAN10,是为了让收尾边界清晰:核心开发到此为止,剩下的是运维和现场的事。

多版本备份组织方式

Archive/ 目录除了 Plans/,还有按硬件版本分的子目录:

Archive/
├── Plans/              # 计划文档归档
├── KiCad/              # PCB 工程归档
│   └── Old/            # 更早的版本
├── STM32F101VC/        # STM32F103VC 主板的旧固件(F101 兼容)
└── STM32H562/          # STM32H562 主板的固件

按硬件版本分目录STM32F101VCSTM32H562 是两代主板,前者是老方案(F103 + 并口 LCD),后者是新方案(H562 + RGB LCD + 更强外设)。两套固件各自完整保留,包括 RT-Thread 源码、HAL 驱动、LVGL 包、applications 和 services。这样做的理由是:工业设备的固件生命周期长,老主板可能在现场运行多年,需要随时能重新编译旧版本固件做补丁。

KiCad/Old/ 套娃式归档。PCB 工程也分新旧版本,Old/ 里是更早的原理图和封装。KiCad 工程文件不像代码能靠 git diff 看清改动,按目录分版本比按分支管理更直观。

Plans/ 不按版本分。计划文档是线性的时间线,新计划取代旧计划,不需要按硬件版本区分。legacy- 前缀已经表达了”历史参考”的语义。

这种组织方式的核心原则是:代码和固件按硬件版本分目录保留全套,计划文档按编号线性归档。工业项目硬件迭代慢但每次迭代都要能回溯,软件计划迭代快但旧计划只作参考不复活,两者的归档策略不同。

小结

  • Material 3 主题用 ColorScheme.fromSeed 单种子色驱动,工业蓝 0xFF1976D2 派生亮暗两套主题,centerTitle: false 符合工业类应用左对齐习惯,AppTheme._() 私有构造明确工具类语义
  • Logger 配置的核心是分级控制调用栈methodCount: 0 让普通日志干净,errorMethodCount: 5 让错误日志可定位,printEmojis: false 保证日志可被 grep 和脚本处理
  • Result<T> sealed class 把错误变成值,Repository 统一返回 Future<Result<T>>,ViewModel 用 exhaustive switch 编译期强制处理两个分支,比 try/catch 更安全
  • Archive/Plans/ 用编号 + 状态后缀归档计划legacy- 标记历史文档保留决策上下文,PLAN0 是架构选型起点、PLAN4 是 Flutter MVP 现实修正、PLAN11 是收尾清单
  • Archive/ 按硬件版本分目录保留全套固件STM32F101VCSTM32H562 各自完整,工业设备固件生命周期长需要能随时回溯旧版本
  • 整个 PipeMonitor 博客系列从 2026-04-14 的项目总览开始,历经 Modbus 主机、LCD 显示、线程架构、上行服务、看门狗、报警系统、云端部署、Flutter 数据模型、MySQL 存储、远程命令、fl_chart 曲线、跨端时间戳等主题,到这篇收尾共 40 余篇,覆盖了从 STM32 固件到 Flutter UI 的全链路工程实践

后续阅读