目录
-
- 前言
- 一、项目背景与功能定位
-
- [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 与状态变化之间的关系。
本文围绕当前项目真实源码展开,重点拆解:
StopwatchApp如何配置应用入口和主题。_StopwatchHomePageState如何管理计时状态。- Dart 标准库
Stopwatch如何作为计时源。 Future.doWhile如何驱动毫秒级 UI 刷新。- 圈速列表如何通过
List<Duration>和ListView.builder渲染。 - 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 Stopwatch 和 Duration。
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'),
);
}
}
这段代码包含三个关键信息:
- 应用标题是
Stopwatch。 - 主题色使用
Colors.purple。 - 首页是
StopwatchHomePage,计时状态从首页 State 中维护。
4.3 Material 3 主题
dart
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.purple),
useMaterial3: true,
),
useMaterial3: true 会影响 AppBar、ElevatedButton、ListTile 等组件的默认视觉效果。当前项目通过自定义按钮颜色,让开始、暂停、重置、计圈四类操作具备明确区分。
五、页面状态设计
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;
});
}
重置操作会同时处理四件事:
_stopwatch.reset()清空内部计时。_elapsed = Duration.zero让 UI 时间回到00:00:00.00。_laps.clear()清空计圈记录。_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 方法
当前项目通过 _formatDuration 将 Duration 转成秒表显示字符串。
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 核心交互路径
秒表应用的完整交互路径可以按以下顺序覆盖:
- 启动应用,确认标题显示
Stopwatch。 - 初始时间显示
00:00:00.00。 - 初始状态只显示绿色播放按钮。
- 点击播放按钮,时间开始变化。
- 运行中播放按钮变为橙色暂停按钮。
- 运行中红色重置按钮和蓝色计圈按钮出现。
- 点击计圈按钮,圈速列表出现。
- 连续点击计圈,列表追加
Lap 1、Lap 2等记录。 - 点击暂停按钮,时间停止变化。
- 点击重置按钮,时间和圈速列表清空。
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)
暂停后 _isRunning 为 false,但 _elapsed 不为零,所以 onPressed 为 null。此时页面通过重置按钮回到初始状态。
16.4 为什么第一圈不做相减
第一圈没有上一圈时间点,因此第一圈耗时就是 _laps[0]。
dart
final lapTime = index == 0 ? _laps[0] : _laps[index] - _laps[index - 1];
从第二圈开始,单圈耗时才需要用当前累计时间减去上一圈累计时间。
16.5 为什么时间文本使用 monospace
秒表时间会持续变化,如果使用普通字体,不同数字宽度可能不同,时间文本会产生轻微跳动。monospace 等宽字体能让每个数字占据相同宽度。
十七、完整业务链路复盘
17.1 开始计时链路
- 用户点击播放按钮。
_startStopwatch()被调用。_isRunning设置为true。_stopwatch.start()启动真实计时。_startTimer()启动异步刷新循环。- 每 10ms 读取
_stopwatch.elapsed。 setState更新_elapsed。- 大号时间文本重新渲染。
17.2 计圈链路
- 用户在运行中点击旗帜按钮。
_lap()被调用。- 当前
_elapsed加入_laps。 - 页面展示
Laps标题。 ListView.builder根据_laps.length构建列表。- 每一项根据索引计算单圈耗时。
- 圈速通过
_formatDuration显示。
17.3 重置链路
- 用户点击红色停止按钮。
_resetStopwatch()被调用。_stopwatch.reset()清空计时器。_elapsed回到Duration.zero。_laps.clear()清空圈速列表。_isRunning变为false。- 页面恢复初始状态。
十八、核心源码总览
下面集中展示当前项目中最关键的计时代码。
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.doWhile 和 Future.delayed 形成运行期间的刷新循环,再通过 setState 将时间变化同步到页面。
从源码结构看,项目的关键点集中在四个状态字段:_stopwatch 负责真实计时,_elapsed 负责界面展示,_laps 负责保存计圈记录,_isRunning 负责控制按钮状态。页面 UI 则围绕大号时间文本、圆形控制按钮和圈速列表构成。
从 OpenHarmony 适配角度看,当前项目业务逻辑保持在 Flutter 层,ohos 目录负责平台工程承载。秒表类应用对动态刷新和交互响应更敏感,因此它比静态页面更适合验证 Flutter 页面在 OpenHarmony 容器中的实时表现。
如果这篇文章对你有帮助,欢迎点赞、收藏、关注。你的支持是我持续创作 Flutter 与 OpenHarmony 实战内容的动力!
相关资源
- Flutter 官方文档:https://docs.flutter.dev/
- Flutter 测试文档:https://docs.flutter.dev/testing
- Dart
StopwatchAPI:https://api.dart.dev/stable/dart-core/Stopwatch-class.html - Dart
DurationAPI:https://api.dart.dev/stable/dart-core/Duration-class.html - OpenHarmony 官网:https://www.openharmony.cn/
- 开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net