Flutter+OpenHarmony 实战:stopwatch 秒表应用

目录

    • 前言
    • 一、项目背景与功能定位
      • [1.1 项目功能概览](#1.1 项目功能概览)
      • [1.2 技术关键词](#1.2 技术关键词)
      • [1.3 秒表业务链路](#1.3 秒表业务链路)
    • 二、项目目录结构分析
      • [2.1 根目录结构](#2.1 根目录结构)
      • [2.2 文件作用表](#2.2 文件作用表)
      • [2.3 OpenHarmony 工程结构](#2.3 OpenHarmony 工程结构)
    • 三、环境与依赖配置
      • [3.1 pubspec 基础信息](#3.1 pubspec 基础信息)
      • [3.2 依赖配置](#3.2 依赖配置)
      • [3.3 常用命令](#3.3 常用命令)
    • 四、应用入口源码拆解
      • [4.1 main 函数](#4.1 main 函数)
      • [4.2 StopwatchApp](#4.2 StopwatchApp)
      • [4.3 Material 3 主题](#4.3 Material 3 主题)
    • 五、页面状态设计
      • [5.1 StopwatchHomePage](#5.1 StopwatchHomePage)
      • [5.2 状态字段](#5.2 状态字段)
      • [5.3 initState](#5.3 initState)
    • 六、计时刷新机制
      • [6.1 _startTimer 方法](#6.1 _startTimer 方法)
      • [6.2 Future.doWhile 的角色](#6.2 Future.doWhile 的角色)
      • [6.3 时间源和展示状态分离](#6.3 时间源和展示状态分离)
    • 七、开始与暂停逻辑
      • [7.1 开始计时](#7.1 开始计时)
      • [7.2 暂停计时](#7.2 暂停计时)
      • [7.3 按钮状态表达](#7.3 按钮状态表达)
    • 八、重置逻辑
      • [8.1 _resetStopwatch 方法](#8.1 _resetStopwatch 方法)
      • [8.2 重置按钮展示条件](#8.2 重置按钮展示条件)
    • 九、计圈逻辑
      • [9.1 _lap 方法](#9.1 _lap 方法)
      • [9.2 计圈按钮展示条件](#9.2 计圈按钮展示条件)
      • [9.3 圈速计算](#9.3 圈速计算)
    • 十、时间格式化
      • [10.1 _formatDuration 方法](#10.1 _formatDuration 方法)
      • [10.2 字段拆解](#10.2 字段拆解)
      • [10.3 格式化示例](#10.3 格式化示例)
    • [十一、页面 UI 结构](#十一、页面 UI 结构)
      • [11.1 Scaffold 页面骨架](#11.1 Scaffold 页面骨架)
      • [11.2 大号时间文本](#11.2 大号时间文本)
      • [11.3 控制按钮布局](#11.3 控制按钮布局)
    • 十二、按钮视觉与交互状态
      • [12.1 开始与暂停按钮](#12.1 开始与暂停按钮)
      • [12.2 重置按钮](#12.2 重置按钮)
      • [12.3 计圈按钮](#12.3 计圈按钮)
    • 十三、圈速列表渲染
      • [13.1 列表显示条件](#13.1 列表显示条件)
      • [13.2 ListView.builder 渲染圈速](#13.2 ListView.builder 渲染圈速)
      • [13.3 圈速数据结构](#13.3 圈速数据结构)
    • [十四、OpenHarmony 适配边界](#十四、OpenHarmony 适配边界)
      • [14.1 Flutter 层职责](#14.1 Flutter 层职责)
      • [14.2 平台侧文件作用](#14.2 平台侧文件作用)
      • [14.3 秒表类应用的适配特点](#14.3 秒表类应用的适配特点)
    • 十五、测试与验证
      • [15.1 核心交互路径](#15.1 核心交互路径)
      • [15.2 时间格式化测试](#15.2 时间格式化测试)
      • [15.3 Widget 测试示例](#15.3 Widget 测试示例)
    • 十六、常见问题解析
      • [16.1 为什么不手动累加毫秒](#16.1 为什么不手动累加毫秒)
      • [16.2 为什么刷新间隔是 10ms](#16.2 为什么刷新间隔是 10ms)
      • [16.3 为什么暂停后播放按钮禁用](#16.3 为什么暂停后播放按钮禁用)
      • [16.4 为什么第一圈不做相减](#16.4 为什么第一圈不做相减)
      • [16.5 为什么时间文本使用 monospace](#16.5 为什么时间文本使用 monospace)
    • 十七、完整业务链路复盘
      • [17.1 开始计时链路](#17.1 开始计时链路)
      • [17.2 计圈链路](#17.2 计圈链路)
      • [17.3 重置链路](#17.3 重置链路)
    • 十八、核心源码总览
    • 十九、总结
    • 相关资源

前言

欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

stopwatch 是一个基于 Flutter 实现的 Stopwatch 秒表应用 。项目核心代码位于 lib/main.dart,同时保留了 ohos OpenHarmony 平台工程目录,适合用来理解 Flutter 动态计时 UI 在 OpenHarmony 工程中的组织方式。

秒表应用看起来简单,但它覆盖了移动端工具类应用中非常典型的一组能力:实时刷新状态切换时间格式化计圈记录条件渲染列表展示。相比静态页面,秒表更能体现 Flutter 声明式 UI 与状态变化之间的关系。

本文围绕当前项目真实源码展开,重点拆解:

  1. StopwatchApp 如何配置应用入口和主题。
  2. _StopwatchHomePageState 如何管理计时状态。
  3. Dart 标准库 Stopwatch 如何作为计时源。
  4. Future.doWhile 如何驱动毫秒级 UI 刷新。
  5. 圈速列表如何通过 List<Duration>ListView.builder 渲染。
  6. Flutter 页面如何在 OpenHarmony 工程中被承载。
    效果图如下:

一、项目背景与功能定位

1.1 项目功能概览

stopwatch 是一个轻量秒表工具应用。页面顶部显示秒表标题,中间展示大号时间文本,下方提供开始、暂停、重置和计圈按钮。运行中点击计圈按钮后,页面会在下方展示每一圈的圈号和耗时。

当前源码支持以下功能:

  • 显示 HH:mm:ss.xx 格式的计时时间。
  • 点击绿色播放按钮开始计时。
  • 运行中点击橙色暂停按钮暂停计时。
  • 计时开始后显示红色停止/重置按钮。
  • 运行中显示蓝色计圈按钮。
  • 点击计圈后将当前时间加入 _laps
  • 列表展示每一圈的圈号和单圈耗时。
  • 重置时清空时间、圈数和运行状态。

1.2 技术关键词

关键词 在项目中的作用
Stopwatch Dart 标准库计时对象,记录真实流逝时间
Duration 表示已流逝时间和圈速时间
Future.doWhile 构造异步循环,驱动运行中的刷新
Future.delayed 控制每次刷新间隔
setState 将时间变化同步到界面
List<Duration> 保存计圈记录
ListView.builder 动态渲染圈速列表
CircleBorder 构建圆形控制按钮
TextStyle(fontFamily: 'monospace') 让时间数字宽度更稳定

1.3 秒表业务链路

#mermaid-svg-DkVei1C0VlalDEAy{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-DkVei1C0VlalDEAy .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-DkVei1C0VlalDEAy .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-DkVei1C0VlalDEAy .error-icon{fill:#552222;}#mermaid-svg-DkVei1C0VlalDEAy .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-DkVei1C0VlalDEAy .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-DkVei1C0VlalDEAy .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-DkVei1C0VlalDEAy .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-DkVei1C0VlalDEAy .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-DkVei1C0VlalDEAy .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-DkVei1C0VlalDEAy .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-DkVei1C0VlalDEAy .marker{fill:#333333;stroke:#333333;}#mermaid-svg-DkVei1C0VlalDEAy .marker.cross{stroke:#333333;}#mermaid-svg-DkVei1C0VlalDEAy svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-DkVei1C0VlalDEAy p{margin:0;}#mermaid-svg-DkVei1C0VlalDEAy .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-DkVei1C0VlalDEAy .cluster-label text{fill:#333;}#mermaid-svg-DkVei1C0VlalDEAy .cluster-label span{color:#333;}#mermaid-svg-DkVei1C0VlalDEAy .cluster-label span p{background-color:transparent;}#mermaid-svg-DkVei1C0VlalDEAy .label text,#mermaid-svg-DkVei1C0VlalDEAy span{fill:#333;color:#333;}#mermaid-svg-DkVei1C0VlalDEAy .node rect,#mermaid-svg-DkVei1C0VlalDEAy .node circle,#mermaid-svg-DkVei1C0VlalDEAy .node ellipse,#mermaid-svg-DkVei1C0VlalDEAy .node polygon,#mermaid-svg-DkVei1C0VlalDEAy .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-DkVei1C0VlalDEAy .rough-node .label text,#mermaid-svg-DkVei1C0VlalDEAy .node .label text,#mermaid-svg-DkVei1C0VlalDEAy .image-shape .label,#mermaid-svg-DkVei1C0VlalDEAy .icon-shape .label{text-anchor:middle;}#mermaid-svg-DkVei1C0VlalDEAy .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-DkVei1C0VlalDEAy .rough-node .label,#mermaid-svg-DkVei1C0VlalDEAy .node .label,#mermaid-svg-DkVei1C0VlalDEAy .image-shape .label,#mermaid-svg-DkVei1C0VlalDEAy .icon-shape .label{text-align:center;}#mermaid-svg-DkVei1C0VlalDEAy .node.clickable{cursor:pointer;}#mermaid-svg-DkVei1C0VlalDEAy .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-DkVei1C0VlalDEAy .arrowheadPath{fill:#333333;}#mermaid-svg-DkVei1C0VlalDEAy .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-DkVei1C0VlalDEAy .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-DkVei1C0VlalDEAy .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-DkVei1C0VlalDEAy .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-DkVei1C0VlalDEAy .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-DkVei1C0VlalDEAy .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-DkVei1C0VlalDEAy .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-DkVei1C0VlalDEAy .cluster text{fill:#333;}#mermaid-svg-DkVei1C0VlalDEAy .cluster span{color:#333;}#mermaid-svg-DkVei1C0VlalDEAy div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-DkVei1C0VlalDEAy .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-DkVei1C0VlalDEAy rect.text{fill:none;stroke-width:0;}#mermaid-svg-DkVei1C0VlalDEAy .icon-shape,#mermaid-svg-DkVei1C0VlalDEAy .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-DkVei1C0VlalDEAy .icon-shape p,#mermaid-svg-DkVei1C0VlalDEAy .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-DkVei1C0VlalDEAy .icon-shape .label rect,#mermaid-svg-DkVei1C0VlalDEAy .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-DkVei1C0VlalDEAy .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-DkVei1C0VlalDEAy .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-DkVei1C0VlalDEAy :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是

用户点击播放按钮
_startStopwatch()
_stopwatch.start()
_isRunning = true
_startTimer()
Future.delayed 10ms
_stopwatch.isRunning?
_elapsed = _stopwatch.elapsed
setState 刷新时间文本
结束本轮异步刷新循环

这条流程说明:真实时间由 Stopwatch 记录,UI 时间由 _elapsed 承载,刷新由 _startTimer() 的异步循环驱动。

关键点:秒表应用的核心不是简单累加数字,而是用可靠的时间源记录流逝时间,再让 UI 按固定节奏读取并展示。


二、项目目录结构分析

2.1 根目录结构

项目采用 Flutter 标准目录,同时包含 OpenHarmony 平台工程。

bash 复制代码
stopwatch/
├── lib/
│   └── main.dart
├── ohos/
│   ├── AppScope/
│   └── entry/
├── test/
│   └── widget_test.dart
├── pubspec.yaml
├── analysis_options.yaml
└── README.md

2.2 文件作用表

文件或目录 作用 本文重点
lib/main.dart 应用入口、计时状态、按钮逻辑和 UI 核心源码
pubspec.yaml 项目名称、版本、依赖和 Flutter 配置 环境依赖
analysis_options.yaml Dart 静态分析规则 代码规范
test/ Flutter 测试目录 时间格式化和交互验证
ohos/ OpenHarmony 平台工程 平台承载

2.3 OpenHarmony 工程结构

ohos 目录中包含应用级配置、模块级配置、Ability 入口、页面入口和资源文件。当前业务逻辑仍然由 Flutter 层完成,OpenHarmony 层负责承载和运行。

OpenHarmony 文件 作用
AppScope/app.json5 应用级配置
entry/src/main/module.json5 模块级配置
EntryAbility.ets Ability 生命周期入口
Index.ets 页面承载入口
GeneratedPluginRegistrant.ets 插件注册入口
resources/base/element/string.json 字符串资源
resources/base/media/icon.png 模块图标资源

三、环境与依赖配置

3.1 pubspec 基础信息

当前项目名称是 stopwatch,版本是 1.0.0+1,Dart SDK 约束是 ^3.9.2

yaml 复制代码
name: stopwatch
description: "A new Flutter project."
publish_to: 'none'
version: 1.0.0+1

environment:
  sdk: ^3.9.2

publish_to: 'none' 表明这是应用工程,不会作为 Dart package 发布到 pub.dev

3.2 依赖配置

项目依赖保持轻量。

yaml 复制代码
dependencies:
  flutter:
    sdk: flutter

  cupertino_icons: ^1.0.8

dev_dependencies:
  flutter_test:
    sdk: flutter

  flutter_lints: ^5.0.0

秒表功能不依赖网络、数据库、定位、相机或传感器。核心能力来自 Flutter UI、Dart StopwatchDuration

3.3 常用命令

bash 复制代码
flutter pub get
flutter analyze
flutter test
flutter run
命令 作用
flutter pub get 获取项目依赖
flutter analyze 执行静态分析
flutter test 运行测试
flutter run 启动调试运行
flutter build hap 构建 OpenHarmony HAP 包

四、应用入口源码拆解

4.1 main 函数

Flutter 应用从 main() 函数启动。

dart 复制代码
import 'package:flutter/material.dart';

void main() {
  runApp(const StopwatchApp());
}

runApp(const StopwatchApp())StopwatchApp 放入 Flutter 渲染树根节点。

4.2 StopwatchApp

StopwatchApp 是应用根组件,负责配置标题、主题和首页。

dart 复制代码
class StopwatchApp extends StatelessWidget {
  const StopwatchApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Stopwatch',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.purple),
        useMaterial3: true,
      ),
      home: const StopwatchHomePage(title: 'Stopwatch'),
    );
  }
}

这段代码包含三个关键信息:

  1. 应用标题是 Stopwatch
  2. 主题色使用 Colors.purple
  3. 首页是 StopwatchHomePage,计时状态从首页 State 中维护。

4.3 Material 3 主题

dart 复制代码
theme: ThemeData(
  colorScheme: ColorScheme.fromSeed(seedColor: Colors.purple),
  useMaterial3: true,
),

useMaterial3: true 会影响 AppBarElevatedButtonListTile 等组件的默认视觉效果。当前项目通过自定义按钮颜色,让开始、暂停、重置、计圈四类操作具备明确区分。


五、页面状态设计

5.1 StopwatchHomePage

首页组件接收标题,并创建状态对象。

dart 复制代码
class StopwatchHomePage extends StatefulWidget {
  const StopwatchHomePage({super.key, required this.title});
  final String title;

  @override
  State<StopwatchHomePage> createState() => _StopwatchHomePageState();
}

秒表页面的时间、运行状态和计圈记录都会变化,因此这里使用 StatefulWidget

5.2 状态字段

dart 复制代码
class _StopwatchHomePageState extends State<StopwatchHomePage> {
  final Stopwatch _stopwatch = Stopwatch();
  Duration _elapsed = Duration.zero;
  final List<Duration> _laps = [];
  bool _isRunning = false;
}
字段 类型 作用
_stopwatch Stopwatch 记录真实流逝时间
_elapsed Duration 当前展示在 UI 上的时间
_laps List<Duration> 保存每次计圈的时间点
_isRunning bool 控制按钮状态和计圈按钮展示

5.3 initState

页面初始化时会调用 _startTimer()

dart 复制代码
@override
void initState() {
  super.initState();
  _startTimer();
}

由于此时 _stopwatch 尚未运行,_startTimer() 内部循环会在判断到未运行后结束。真正开始计时时,_startStopwatch() 会再次调用 _startTimer()


六、计时刷新机制

6.1 _startTimer 方法

秒表的刷新逻辑由 _startTimer() 完成。

dart 复制代码
void _startTimer() {
  Future.doWhile(() async {
    await Future.delayed(const Duration(milliseconds: 10));
    if (_stopwatch.isRunning) {
      setState(() {
        _elapsed = _stopwatch.elapsed;
      });
      return true;
    }
    return false;
  });
}

这段代码每隔 10ms 等待一次,然后检查 _stopwatch.isRunning。如果秒表仍在运行,就把 _stopwatch.elapsed 同步到 _elapsed,并返回 true 继续循环。

6.2 Future.doWhile 的角色

Future.doWhile 可以把一个异步函数重复执行,直到回调返回 false

dart 复制代码
Future.doWhile(() async {
  await Future.delayed(const Duration(milliseconds: 10));
  return _stopwatch.isRunning;
});

当前项目在循环中加入 setState,使页面能持续展示最新时间。

6.3 时间源和展示状态分离

角色 对应变量 说明
真实时间源 _stopwatch.elapsed 由 Dart Stopwatch 计算
UI 展示状态 _elapsed 通过 setState 更新
刷新节奏 Future.delayed(10ms) 控制界面读取频率
循环开关 _stopwatch.isRunning 决定是否继续刷新

这种分离让计时更可靠:时间不是靠每 10ms 手动加一次,而是从 Stopwatch 读取真实流逝时间。


七、开始与暂停逻辑

7.1 开始计时

开始按钮调用 _startStopwatch()

dart 复制代码
void _startStopwatch() {
  setState(() {
    _isRunning = true;
    _stopwatch.start();
  });
  _startTimer();
}

这里先更新运行状态并启动 Stopwatch,再调用 _startTimer() 开始异步刷新。

7.2 暂停计时

暂停按钮调用 _pauseStopwatch()

dart 复制代码
void _pauseStopwatch() {
  setState(() {
    _isRunning = false;
    _stopwatch.stop();
  });
}

暂停时 _stopwatch.stop() 会停止真实计时,_isRunning 变为 false 后,界面按钮也会随之变化。

7.3 按钮状态表达

dart 复制代码
onPressed: _isRunning
    ? _pauseStopwatch
    : (_elapsed == Duration.zero ? _startStopwatch : null),

这行代码决定了左侧主按钮的行为:

状态 按钮行为
_isRunning == true 点击后暂停
_isRunning == false && _elapsed == Duration.zero 点击后开始
_isRunning == false && _elapsed != Duration.zero 按钮禁用

当前实现中,暂停后需要通过红色重置按钮回到初始状态,然后才能再次开始。


八、重置逻辑

8.1 _resetStopwatch 方法

重置按钮调用 _resetStopwatch()

dart 复制代码
void _resetStopwatch() {
  setState(() {
    _stopwatch.reset();
    _elapsed = Duration.zero;
    _laps.clear();
    _isRunning = false;
  });
}

重置操作会同时处理四件事:

  1. _stopwatch.reset() 清空内部计时。
  2. _elapsed = Duration.zero 让 UI 时间回到 00:00:00.00
  3. _laps.clear() 清空计圈记录。
  4. _isRunning = false 恢复非运行状态。

8.2 重置按钮展示条件

dart 复制代码
if (_elapsed != Duration.zero || _isRunning) ...[
  const SizedBox(width: 24),
  ElevatedButton(
    onPressed: _resetStopwatch,
    style: ElevatedButton.styleFrom(
      padding: const EdgeInsets.all(20),
      backgroundColor: Colors.red,
      shape: const CircleBorder(),
    ),
    child: const Icon(Icons.stop, size: 48, color: Colors.white),
  ),
]

只有当秒表已经有时间或正在运行时,重置按钮才会出现。初始状态下页面只显示开始按钮。


九、计圈逻辑

9.1 _lap 方法

运行中点击蓝色旗帜按钮,会调用 _lap()

dart 复制代码
void _lap() {
  setState(() {
    _laps.add(_elapsed);
  });
}

_laps 保存的是每次点击计圈时的累计时间点,而不是直接保存单圈耗时。

9.2 计圈按钮展示条件

dart 复制代码
if (_isRunning) ...[
  const SizedBox(width: 24),
  ElevatedButton(
    onPressed: _lap,
    style: ElevatedButton.styleFrom(
      padding: const EdgeInsets.all(20),
      backgroundColor: Colors.blue,
      shape: const CircleBorder(),
    ),
    child: const Icon(Icons.flag, size: 48, color: Colors.white),
  ),
],

计圈按钮只在运行中展示。暂停或初始状态下不会出现计圈入口。

9.3 圈速计算

圈速列表中使用相邻时间点相减,计算每一圈的实际耗时。

dart 复制代码
final lapTime = index == 0 ? _laps[0] : _laps[index] - _laps[index - 1];

如果是第一圈,单圈耗时等于 _laps[0];如果不是第一圈,单圈耗时等于当前累计时间减去上一圈累计时间。

圈数 累计时间 单圈耗时
Lap 1 00:00:05.20 00:00:05.20
Lap 2 00:00:12.70 00:00:07.50
Lap 3 00:00:20.10 00:00:07.40

十、时间格式化

10.1 _formatDuration 方法

当前项目通过 _formatDurationDuration 转成秒表显示字符串。

dart 复制代码
String _formatDuration(Duration duration) {
  final hours = duration.inHours.toString().padLeft(2, '0');
  final minutes = (duration.inMinutes % 60).toString().padLeft(2, '0');
  final seconds = (duration.inSeconds % 60).toString().padLeft(2, '0');
  final millis = (duration.inMilliseconds % 1000 ~/ 10).toString().padLeft(2, '0');
  return '$hours:$minutes:$seconds.$millis';
}

最终格式是 HH:mm:ss.xx。其中 xx 表示百分之一秒。

10.2 字段拆解

字段 计算方式 示例
小时 duration.inHours 01
分钟 duration.inMinutes % 60 08
duration.inSeconds % 60 35
百分之一秒 duration.inMilliseconds % 1000 ~/ 10 42

10.3 格式化示例

dart 复制代码
final value = Duration(hours: 1, minutes: 8, seconds: 35, milliseconds: 420);
final text = _formatDuration(value);

输出结果:

text 复制代码
01:08:35.42

这种格式比只显示秒数更接近真实秒表,也更利于展示圈速。


十一、页面 UI 结构

11.1 Scaffold 页面骨架

页面主体使用 Scaffold

dart 复制代码
return Scaffold(
  appBar: AppBar(
    title: Text(widget.title),
    backgroundColor: Theme.of(context).colorScheme.inversePrimary,
  ),
  body: Column(
    children: [
      const SizedBox(height: 48),
      Text(_formatDuration(_elapsed)),
      const SizedBox(height: 48),
      Row(...),
      const SizedBox(height: 32),
      if (_laps.isNotEmpty) ...[
        const Text('Laps'),
        Expanded(child: ListView.builder(...)),
      ],
    ],
  ),
);

页面从上到下依次是应用栏、大号时间文本、控制按钮区域和圈速列表。

11.2 大号时间文本

dart 复制代码
Text(
  _formatDuration(_elapsed),
  style: const TextStyle(
    fontSize: 56,
    fontWeight: FontWeight.bold,
    fontFamily: 'monospace',
  ),
),

这里使用 monospace 字体,使数字变化时宽度更稳定。对于实时刷新的时间文本来说,等宽字体能减少视觉抖动。

11.3 控制按钮布局

dart 复制代码
Row(
  mainAxisAlignment: MainAxisAlignment.center,
  children: [
    ElevatedButton(...),
    if (_elapsed != Duration.zero || _isRunning) ...[
      const SizedBox(width: 24),
      ElevatedButton(...),
      if (_isRunning) ...[
        const SizedBox(width: 24),
        ElevatedButton(...),
      ],
    ],
  ],
)

按钮区域通过条件渲染展示不同操作。初始状态只有开始按钮;运行中会显示暂停、重置和计圈;暂停后保留重置按钮。


十二、按钮视觉与交互状态

12.1 开始与暂停按钮

dart 复制代码
ElevatedButton(
  onPressed: _isRunning ? _pauseStopwatch : (_elapsed == Duration.zero ? _startStopwatch : null),
  style: ElevatedButton.styleFrom(
    padding: const EdgeInsets.all(20),
    backgroundColor: _isRunning ? Colors.orange : Colors.green,
    shape: const CircleBorder(),
  ),
  child: Icon(
    _isRunning ? Icons.pause : Icons.play_arrow,
    size: 48,
    color: Colors.white,
  ),
)

运行时按钮为橙色暂停图标,初始时为绿色播放图标。暂停后由于 _elapsed != Duration.zero_isRunning == false,按钮进入禁用状态。

12.2 重置按钮

dart 复制代码
ElevatedButton(
  onPressed: _resetStopwatch,
  style: ElevatedButton.styleFrom(
    padding: const EdgeInsets.all(20),
    backgroundColor: Colors.red,
    shape: const CircleBorder(),
  ),
  child: const Icon(Icons.stop, size: 48, color: Colors.white),
)

红色按钮表示重置/停止类操作。点击后秒表回到初始状态。

12.3 计圈按钮

dart 复制代码
ElevatedButton(
  onPressed: _lap,
  style: ElevatedButton.styleFrom(
    padding: const EdgeInsets.all(20),
    backgroundColor: Colors.blue,
    shape: const CircleBorder(),
  ),
  child: const Icon(Icons.flag, size: 48, color: Colors.white),
)

蓝色旗帜按钮表示记录当前圈速。它只在运行中出现。


十三、圈速列表渲染

13.1 列表显示条件

只有 _laps 非空时,页面才展示圈速区域。

dart 复制代码
if (_laps.isNotEmpty) ...[
  const Text('Laps', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
  const SizedBox(height: 8),
  Expanded(
    child: ListView.builder(...),
  ),
],

这样初始页面不会出现空列表区域,界面更简洁。

13.2 ListView.builder 渲染圈速

dart 复制代码
ListView.builder(
  itemCount: _laps.length,
  itemBuilder: (context, index) {
    final lapTime = index == 0 ? _laps[0] : _laps[index] - _laps[index - 1];
    return ListTile(
      leading: CircleAvatar(child: Text('${index + 1}')),
      title: Text('Lap ${index + 1}'),
      trailing: Text(
        _formatDuration(lapTime),
        style: const TextStyle(fontFamily: 'monospace'),
      ),
    );
  },
)

每个列表项包含圈号、标题和单圈耗时。右侧时间同样使用等宽字体,保证数字展示稳定。

13.3 圈速数据结构

数据结构 保存内容
_laps[0] 第一圈累计时间
_laps[1] 第二次点击计圈时的累计时间
_laps[index] - _laps[index - 1] index + 1 圈单圈耗时

这种数据结构既能保留累计时间点,也能在展示时计算单圈耗时。


十四、OpenHarmony 适配边界

14.1 Flutter 层职责

当前项目的秒表业务全部在 Flutter 层完成。

text 复制代码
Flutter 层:
StopwatchApp
StopwatchHomePage
_StopwatchHomePageState
Stopwatch
Duration
ListView.builder

OpenHarmony 层:
AppScope
entry
EntryAbility
Index
resources

Flutter 层负责时间状态、按钮交互和页面渲染;OpenHarmony 层负责应用启动、模块配置和资源承载。

14.2 平台侧文件作用

层级 文件 作用
应用级 AppScope/app.json5 描述应用整体信息
模块级 entry/src/main/module.json5 描述模块入口和页面
Ability EntryAbility.ets 应用启动入口
页面 Index.ets 承载 Flutter 页面
资源 resources/base 图标、字符串和配置资源
插件 GeneratedPluginRegistrant.ets 插件注册入口

14.3 秒表类应用的适配特点

stopwatch 不需要网络、相机、定位、文件读写或数据库权限。它主要考察 Flutter 页面在 OpenHarmony 容器中的动态刷新能力:时间文本能否持续变化,按钮状态能否正确切换,计圈列表能否顺畅追加。


十五、测试与验证

15.1 核心交互路径

秒表应用的完整交互路径可以按以下顺序覆盖:

  1. 启动应用,确认标题显示 Stopwatch
  2. 初始时间显示 00:00:00.00
  3. 初始状态只显示绿色播放按钮。
  4. 点击播放按钮,时间开始变化。
  5. 运行中播放按钮变为橙色暂停按钮。
  6. 运行中红色重置按钮和蓝色计圈按钮出现。
  7. 点击计圈按钮,圈速列表出现。
  8. 连续点击计圈,列表追加 Lap 1Lap 2 等记录。
  9. 点击暂停按钮,时间停止变化。
  10. 点击重置按钮,时间和圈速列表清空。

15.2 时间格式化测试

_formatDuration 是纯格式化逻辑,非常适合单独测试。下面是等价测试思路:

dart 复制代码
test('formats duration as HH:mm:ss.xx', () {
  final duration = Duration(
    hours: 1,
    minutes: 2,
    seconds: 3,
    milliseconds: 450,
  );

  final hours = duration.inHours.toString().padLeft(2, '0');
  final minutes = (duration.inMinutes % 60).toString().padLeft(2, '0');
  final seconds = (duration.inSeconds % 60).toString().padLeft(2, '0');
  final millis = (duration.inMilliseconds % 1000 ~/ 10).toString().padLeft(2, '0');

  expect('$hours:$minutes:$seconds.$millis', '01:02:03.45');
});

15.3 Widget 测试示例

下面的测试验证应用启动后存在初始时间和播放按钮。

dart 复制代码
testWidgets('shows initial stopwatch state', (WidgetTester tester) async {
  await tester.pumpWidget(const StopwatchApp());

  expect(find.text('00:00:00.00'), findsOneWidget);
  expect(find.byIcon(Icons.play_arrow), findsOneWidget);
});

下面的测试可以覆盖点击开始后的按钮变化。

dart 复制代码
testWidgets('starts stopwatch and shows pause button', (WidgetTester tester) async {
  await tester.pumpWidget(const StopwatchApp());

  await tester.tap(find.byIcon(Icons.play_arrow));
  await tester.pump(const Duration(milliseconds: 20));

  expect(find.byIcon(Icons.pause), findsOneWidget);
  expect(find.byIcon(Icons.stop), findsOneWidget);
  expect(find.byIcon(Icons.flag), findsOneWidget);
});

十六、常见问题解析

16.1 为什么不手动累加毫秒

当前项目没有写 _elapsed += Duration(milliseconds: 10),而是使用 _stopwatch.elapsed。这样可以避免异步延迟、调度抖动导致时间不准。

dart 复制代码
_elapsed = _stopwatch.elapsed;

这表示 UI 只是读取真实时间源,而不是自己模拟时间源。

16.2 为什么刷新间隔是 10ms

显示格式是 HH:mm:ss.xx,最后两位表示百分之一秒。10ms 正好对应百分之一秒,因此页面每 10ms 刷新一次,与展示精度一致。

16.3 为什么暂停后播放按钮禁用

按钮逻辑写成了:

dart 复制代码
onPressed: _isRunning ? _pauseStopwatch : (_elapsed == Duration.zero ? _startStopwatch : null)

暂停后 _isRunningfalse,但 _elapsed 不为零,所以 onPressednull。此时页面通过重置按钮回到初始状态。

16.4 为什么第一圈不做相减

第一圈没有上一圈时间点,因此第一圈耗时就是 _laps[0]

dart 复制代码
final lapTime = index == 0 ? _laps[0] : _laps[index] - _laps[index - 1];

从第二圈开始,单圈耗时才需要用当前累计时间减去上一圈累计时间。

16.5 为什么时间文本使用 monospace

秒表时间会持续变化,如果使用普通字体,不同数字宽度可能不同,时间文本会产生轻微跳动。monospace 等宽字体能让每个数字占据相同宽度。


十七、完整业务链路复盘

17.1 开始计时链路

  1. 用户点击播放按钮。
  2. _startStopwatch() 被调用。
  3. _isRunning 设置为 true
  4. _stopwatch.start() 启动真实计时。
  5. _startTimer() 启动异步刷新循环。
  6. 每 10ms 读取 _stopwatch.elapsed
  7. setState 更新 _elapsed
  8. 大号时间文本重新渲染。

17.2 计圈链路

  1. 用户在运行中点击旗帜按钮。
  2. _lap() 被调用。
  3. 当前 _elapsed 加入 _laps
  4. 页面展示 Laps 标题。
  5. ListView.builder 根据 _laps.length 构建列表。
  6. 每一项根据索引计算单圈耗时。
  7. 圈速通过 _formatDuration 显示。

17.3 重置链路

  1. 用户点击红色停止按钮。
  2. _resetStopwatch() 被调用。
  3. _stopwatch.reset() 清空计时器。
  4. _elapsed 回到 Duration.zero
  5. _laps.clear() 清空圈速列表。
  6. _isRunning 变为 false
  7. 页面恢复初始状态。

十八、核心源码总览

下面集中展示当前项目中最关键的计时代码。

dart 复制代码
class _StopwatchHomePageState extends State<StopwatchHomePage> {
  final Stopwatch _stopwatch = Stopwatch();
  Duration _elapsed = Duration.zero;
  final List<Duration> _laps = [];
  bool _isRunning = false;

  @override
  void initState() {
    super.initState();
    _startTimer();
  }

  void _startTimer() {
    Future.doWhile(() async {
      await Future.delayed(const Duration(milliseconds: 10));
      if (_stopwatch.isRunning) {
        setState(() {
          _elapsed = _stopwatch.elapsed;
        });
        return true;
      }
      return false;
    });
  }

  void _startStopwatch() {
    setState(() {
      _isRunning = true;
      _stopwatch.start();
    });
    _startTimer();
  }

  void _pauseStopwatch() {
    setState(() {
      _isRunning = false;
      _stopwatch.stop();
    });
  }

  void _resetStopwatch() {
    setState(() {
      _stopwatch.reset();
      _elapsed = Duration.zero;
      _laps.clear();
      _isRunning = false;
    });
  }

  void _lap() {
    setState(() {
      _laps.add(_elapsed);
    });
  }
}

这段代码就是秒表业务的核心:启动、暂停、重置、计圈和刷新全部围绕 _stopwatch_elapsed_laps_isRunning 四个状态展开。


十九、总结

stopwatch 是一个非常适合学习 Flutter 动态 UI 的秒表项目。它使用 Dart 标准库 Stopwatch 作为真实时间源,用 Duration 表示已流逝时间,用 Future.doWhileFuture.delayed 形成运行期间的刷新循环,再通过 setState 将时间变化同步到页面。

从源码结构看,项目的关键点集中在四个状态字段:_stopwatch 负责真实计时,_elapsed 负责界面展示,_laps 负责保存计圈记录,_isRunning 负责控制按钮状态。页面 UI 则围绕大号时间文本、圆形控制按钮和圈速列表构成。

从 OpenHarmony 适配角度看,当前项目业务逻辑保持在 Flutter 层,ohos 目录负责平台工程承载。秒表类应用对动态刷新和交互响应更敏感,因此它比静态页面更适合验证 Flutter 页面在 OpenHarmony 容器中的实时表现。

如果这篇文章对你有帮助,欢迎点赞、收藏、关注。你的支持是我持续创作 Flutter 与 OpenHarmony 实战内容的动力!


相关资源

相关推荐
亚信安全官方账号1 小时前
AISTrustOne鸿蒙版安全方案 让终端防护“内生”力量觉醒
安全·华为·harmonyos
hxy06012 小时前
Flutter 定时器相关
flutter
G_dou_2 小时前
Flutter三方库适配OpenHarmony【compass】罗盘 UI 项目完整实战
flutter·ui
夜勤月2 小时前
HarmonyOS 6.0 ArkWeb实战:PDF背景色自定义功能全解析(附完整代码+避坑指南)
华为·pdf·harmonyos
想你依然心痛3 小时前
HarmonyOS 6(API 23)实战:基于悬浮导航、沉浸光感与HMAF的“药界智脑“——PC端AI智能体沉浸式药物研发与分子模拟工作台
人工智能·华为·ar·harmonyos·智能体
G_dou_4 小时前
Flutter +OpenHarmony 实战:clock 时钟应用
flutter·harmonyos
G_dou_4 小时前
Flutter+OpenHarmony 实战:weather 天气查询应用
flutter·harmonyos
yuegu7774 小时前
HarmonyOS应用<节气通>开发第1篇:启动页开发——留下第一印象的2秒
harmonyos