Flutter +OpenHarmony 实战:clock 时钟应用

目录

    • 前言
    • 一、项目背景与功能定位
      • [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 ClockApp](#4.2 ClockApp)
      • [4.3 Material 3 主题](#4.3 Material 3 主题)
    • 五、页面状态设计
      • [5.1 ClockHomePage](#5.1 ClockHomePage)
      • [5.2 _ClockHomePageState](#5.2 _ClockHomePageState)
      • [5.3 状态字段作用表](#5.3 状态字段作用表)
    • 六、每秒刷新机制
      • [6.1 initState 启动刷新](#6.1 initState 启动刷新)
      • [6.2 _updateTime 方法](#6.2 _updateTime 方法)
      • [6.3 Future.doWhile 的作用](#6.3 Future.doWhile 的作用)
      • [6.4 mounted 的保护意义](#6.4 mounted 的保护意义)
    • 七、时间格式化
      • [7.1 _formatTime 方法](#7.1 _formatTime 方法)
      • [7.2 padLeft 补零](#7.2 padLeft 补零)
      • [7.3 时间输出示例](#7.3 时间输出示例)
    • 八、日期格式化
      • [8.1 _formatDate 方法](#8.1 _formatDate 方法)
      • [8.2 星期数组](#8.2 星期数组)
      • [8.3 月份数组](#8.3 月份数组)
      • [8.4 日期输出示例](#8.4 日期输出示例)
    • [九、页面 UI 结构](#九、页面 UI 结构)
      • [9.1 Scaffold 骨架](#9.1 Scaffold 骨架)
      • [9.2 Column 布局](#9.2 Column 布局)
      • [9.3 大号时间文本](#9.3 大号时间文本)
      • [9.4 日期文本](#9.4 日期文本)
    • 十、世界时钟模块
      • [10.1 模块容器](#10.1 模块容器)
      • [10.2 城市配置](#10.2 城市配置)
      • [10.3 模块视觉结构](#10.3 模块视觉结构)
    • 十一、世界时间计算
      • [11.1 _buildWorldClock 方法](#11.1 _buildWorldClock 方法)
      • [11.2 UTC 基准](#11.2 UTC 基准)
      • [11.3 城市时间格式](#11.3 城市时间格式)
      • [11.4 行布局](#11.4 行布局)
    • 十二、生命周期与动态刷新
      • [12.1 为什么需要 mounted](#12.1 为什么需要 mounted)
      • [12.2 刷新频率选择](#12.2 刷新频率选择)
      • [12.3 与秒表项目的差异](#12.3 与秒表项目的差异)
    • [十三、OpenHarmony 适配边界](#十三、OpenHarmony 适配边界)
      • [13.1 Flutter 层职责](#13.1 Flutter 层职责)
      • [13.2 平台侧文件作用](#13.2 平台侧文件作用)
      • [13.3 时钟应用的适配特点](#13.3 时钟应用的适配特点)
    • 十四、测试与验证
      • [14.1 交互验证路径](#14.1 交互验证路径)
      • [14.2 Widget 测试示例](#14.2 Widget 测试示例)
      • [14.3 时间格式测试思路](#14.3 时间格式测试思路)
      • [14.4 世界时间计算测试思路](#14.4 世界时间计算测试思路)
    • 十五、常见问题解析
      • [15.1 为什么使用 DateTime.now()](#15.1 为什么使用 DateTime.now())
      • [15.2 为什么每秒刷新一次](#15.2 为什么每秒刷新一次)
      • [15.3 为什么主时钟显示秒而世界时钟不显示秒](#15.3 为什么主时钟显示秒而世界时钟不显示秒)
      • [15.4 为什么使用 toUtc 再加偏移](#15.4 为什么使用 toUtc 再加偏移)
      • [15.5 为什么使用 monospace](#15.5 为什么使用 monospace)
    • 十六、完整业务链路复盘
      • [16.1 本地时间链路](#16.1 本地时间链路)
      • [16.2 世界时钟链路](#16.2 世界时钟链路)
      • [16.3 UI 渲染链路](#16.3 UI 渲染链路)
    • 十七、核心源码总览
    • 十八、总结
    • 相关资源

前言

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

clock 是一个基于 Flutter 实现的 Clock 时钟应用 。项目核心代码集中在 lib/main.dart,同时保留了 ohos OpenHarmony 平台工程目录,适合用来理解 Flutter 动态时间刷新、日期格式化、世界时钟换算和 OpenHarmony 工程承载。

时钟应用看起来比天气、秒表、笔记这些项目更轻量,但它包含非常典型的工程细节:当前时间获取每秒刷新生命周期保护两位数补零英文日期格式化UTC 时间换算世界时钟列表展示。这些点在日历、提醒、倒计时、运动记录和跨时区工具中都很常见。

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

  1. ClockApp 如何配置应用入口和靛蓝色主题。
  2. _ClockHomePageState 如何维护当前时间。
  3. _updateTime() 如何通过 Future.doWhile 实现每秒刷新。
  4. mounted 如何保护异步刷新中的 setState
  5. _formatTime()_formatDate() 如何格式化时间与日期。
  6. _buildWorldClock() 如何基于 UTC 时间计算世界城市时间。

效果图如下:


一、项目背景与功能定位

1.1 功能概览

clock 是一个轻量数字时钟应用。它在页面中央展示当前本地时间和英文日期,下方展示四个世界城市的时间,包括 New York、London、Tokyo 和 Sydney。

当前项目支持以下功能:

  • 展示当前本地时间。
  • 时间格式为 HH:mm:ss
  • 每秒自动刷新一次。
  • 展示英文日期,如 Monday, June 1, 2026
  • 使用 monospace 等宽字体降低数字跳动感。
  • 展示 World Clocks 世界时钟模块。
  • 支持 New York、London、Tokyo、Sydney 四个城市。
  • 使用 _currentTime.toUtc() 加小时偏移计算城市时间。
  • 使用 mounted 避免页面销毁后继续调用 setState

1.2 技术关键词

关键词 在项目中的作用
DateTime.now() 获取当前本地时间
Future.doWhile 构造每秒刷新循环
Future.delayed 控制刷新间隔
mounted 判断 State 是否仍在组件树中
setState 更新时间并触发 UI 重建
padLeft(2, '0') 补齐两位时间格式
toUtc() 将本地时间转换为 UTC
Duration(hours: offset) 计算世界城市时间
monospace 稳定数字字符宽度

1.3 时间刷新链路

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

页面初始化 initState
_updateTime()
Future.doWhile 异步循环
Future.delayed 1 秒
mounted 是否为 true?
_currentTime = DateTime.now()
setState 触发 UI 重建
_formatTime / _formatDate 重新计算
停止循环

这条链路说明:时钟页面不是靠用户交互刷新,而是在页面生命周期内持续读取当前时间并重建 UI。

关键点:动态时钟必须处理好刷新频率和生命周期边界。mounted 的存在让异步循环在页面销毁后能够自然停止,避免无效的 setState


二、项目目录结构分析

2.1 根目录结构

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

bash 复制代码
clock/
├── 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 基础信息

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

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

environment:
  sdk: ^3.9.2

publish_to: 'none' 表示这是应用工程,不会被误发布到 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 DateTime

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 ClockApp());
}

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

4.2 ClockApp

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

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

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

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

  1. 应用标题是 Clock
  2. 主题色使用 Colors.indigo
  3. 首页是 ClockHomePage,动态时间状态从首页 State 中维护。

4.3 Material 3 主题

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

useMaterial3: true 会影响 AppBar、文本、容器色彩等基础样式。时钟项目的页面元素较少,主题色主要体现在顶部应用栏和世界时钟模块的视觉氛围中。


五、页面状态设计

5.1 ClockHomePage

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

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

  @override
  State<ClockHomePage> createState() => _ClockHomePageState();
}

时间会每秒变化,因此页面使用 StatefulWidget。如果使用 StatelessWidget,页面就无法保存并刷新 _currentTime

5.2 _ClockHomePageState

状态类中只维护一个核心字段。

dart 复制代码
class _ClockHomePageState extends State<ClockHomePage> {
  DateTime _currentTime = DateTime.now();
}

_currentTime 是整个页面的数据源。本地时间、日期文本、世界时钟都基于它计算。

5.3 状态字段作用表

字段 类型 作用
_currentTime DateTime 保存当前本地时间
_currentTime.hour int 本地小时
_currentTime.minute int 本地分钟
_currentTime.second int 本地秒
_currentTime.toUtc() DateTime 世界时钟换算基准

六、每秒刷新机制

6.1 initState 启动刷新

页面初始化时调用 _updateTime()

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

initState 只执行一次,适合启动页面级的异步刷新循环。

6.2 _updateTime 方法

dart 复制代码
void _updateTime() {
  Future.doWhile(() async {
    await Future.delayed(const Duration(seconds: 1));
    if (mounted) {
      setState(() {
        _currentTime = DateTime.now();
      });
      return true;
    }
    return false;
  });
}

这段代码每隔 1 秒更新一次 _currentTime。只要页面仍然挂载在组件树中,循环就会继续。

6.3 Future.doWhile 的作用

Future.doWhile 会反复执行一个异步回调,直到回调返回 false

dart 复制代码
Future.doWhile(() async {
  await Future.delayed(const Duration(seconds: 1));
  return mounted;
});

当前项目在回调中加入 setState,让页面每秒刷新时间。

6.4 mounted 的保护意义

dart 复制代码
if (mounted) {
  setState(() {
    _currentTime = DateTime.now();
  });
  return true;
}
return false;

mounted 表示当前 State 是否仍然在组件树中。页面销毁后,mounted 会变成 false,循环返回 false 并停止。

关键概念:异步任务中调用 setState 前检查 mounted,可以避免页面已经销毁后继续刷新 UI。


七、时间格式化

7.1 _formatTime 方法

当前项目使用 _formatTime() 生成 HH:mm:ss 字符串。

dart 复制代码
String _formatTime() {
  return '${_currentTime.hour.toString().padLeft(2, '0')}:${_currentTime.minute.toString().padLeft(2, '0')}:${_currentTime.second.toString().padLeft(2, '0')}';
}

7.2 padLeft 补零

padLeft(2, '0') 会把一位数字补成两位。

原始值 处理后
3 03
9 09
12 12
23 23

没有补零时,时间可能显示为 8:5:3;补零后可以稳定显示为 08:05:03

7.3 时间输出示例

dart 复制代码
final hour = 8.toString().padLeft(2, '0');
final minute = 5.toString().padLeft(2, '0');
final second = 3.toString().padLeft(2, '0');
final text = '$hour:$minute:$second';

输出结果:

text 复制代码
08:05:03

这种格式非常适合数字时钟。


八、日期格式化

8.1 _formatDate 方法

当前项目通过英文数组拼接日期。

dart 复制代码
String _formatDate() {
  final weekdays = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
  final months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
  return '${weekdays[_currentTime.weekday - 1]}, ${months[_currentTime.month - 1]} ${_currentTime.day}, ${_currentTime.year}';
}

8.2 星期数组

DateTime.weekday 的取值范围是 1 到 7,分别表示 Monday 到 Sunday。因此访问数组时使用 _currentTime.weekday - 1

weekday 数组索引 英文星期
1 0 Monday
2 1 Tuesday
3 2 Wednesday
4 3 Thursday
5 4 Friday
6 5 Saturday
7 6 Sunday

8.3 月份数组

DateTime.month 的取值范围是 1 到 12,因此访问月份数组时使用 _currentTime.month - 1

dart 复制代码
final months = [
  'January',
  'February',
  'March',
  'April',
  'May',
  'June',
  'July',
  'August',
  'September',
  'October',
  'November',
  'December',
];

8.4 日期输出示例

text 复制代码
Monday, June 1, 2026

这种英文日期格式和大号数字时间搭配,适合简洁工具类页面。


九、页面 UI 结构

9.1 Scaffold 骨架

页面主体使用 Scaffold

dart 复制代码
return Scaffold(
  appBar: AppBar(
    title: Text(widget.title),
    backgroundColor: Theme.of(context).colorScheme.inversePrimary,
  ),
  body: Center(
    child: Column(...),
  ),
);

AppBar 展示页面标题,body 使用 Center 让内容居中。

9.2 Column 布局

页面主体使用垂直布局。

dart 复制代码
Column(
  mainAxisAlignment: MainAxisAlignment.center,
  children: [
    Text(_formatTime()),
    const SizedBox(height: 16),
    Text(_formatDate()),
    const SizedBox(height: 48),
    Container(...),
  ],
)

从上到下依次是大号时间、日期文本、间距和世界时钟模块。

9.3 大号时间文本

dart 复制代码
Text(
  _formatTime(),
  style: const TextStyle(
    fontSize: 72,
    fontWeight: FontWeight.w200,
    fontFamily: 'monospace',
  ),
)

时间文本使用 72 号字体和较细字重,视觉上轻盈但清晰。monospace 等宽字体可以减少数字变化时的宽度跳动。

9.4 日期文本

dart 复制代码
Text(
  _formatDate(),
  style: const TextStyle(
    fontSize: 20,
    color: Colors.grey,
  ),
)

日期文本字号较小,并使用灰色作为辅助信息,与主时间形成层级。


十、世界时钟模块

10.1 模块容器

世界时钟使用 Container 构建浅靛蓝色面板。

dart 复制代码
Container(
  padding: const EdgeInsets.all(24),
  decoration: BoxDecoration(
    color: Colors.indigo.shade50,
    borderRadius: BorderRadius.circular(16),
  ),
  child: Column(
    children: [
      const Text(
        'World Clocks',
        style: TextStyle(
          fontSize: 18,
          fontWeight: FontWeight.bold,
        ),
      ),
      const SizedBox(height: 16),
      _buildWorldClock('New York', -4),
      _buildWorldClock('London', 1),
      _buildWorldClock('Tokyo', 9),
      _buildWorldClock('Sydney', 10),
    ],
  ),
)

这个模块包含标题和四个城市时间项。

10.2 城市配置

城市 偏移值
New York -4
London 1
Tokyo 9
Sydney 10

这里的偏移值基于 UTC 小时偏移。当前项目没有处理夏令时数据库,而是用固定偏移完成简单世界时钟展示。

10.3 模块视觉结构

#mermaid-svg-URdfZ3mCSKNEGCzs{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-URdfZ3mCSKNEGCzs .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-URdfZ3mCSKNEGCzs .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-URdfZ3mCSKNEGCzs .error-icon{fill:#552222;}#mermaid-svg-URdfZ3mCSKNEGCzs .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-URdfZ3mCSKNEGCzs .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-URdfZ3mCSKNEGCzs .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-URdfZ3mCSKNEGCzs .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-URdfZ3mCSKNEGCzs .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-URdfZ3mCSKNEGCzs .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-URdfZ3mCSKNEGCzs .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-URdfZ3mCSKNEGCzs .marker{fill:#333333;stroke:#333333;}#mermaid-svg-URdfZ3mCSKNEGCzs .marker.cross{stroke:#333333;}#mermaid-svg-URdfZ3mCSKNEGCzs svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-URdfZ3mCSKNEGCzs p{margin:0;}#mermaid-svg-URdfZ3mCSKNEGCzs .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-URdfZ3mCSKNEGCzs .cluster-label text{fill:#333;}#mermaid-svg-URdfZ3mCSKNEGCzs .cluster-label span{color:#333;}#mermaid-svg-URdfZ3mCSKNEGCzs .cluster-label span p{background-color:transparent;}#mermaid-svg-URdfZ3mCSKNEGCzs .label text,#mermaid-svg-URdfZ3mCSKNEGCzs span{fill:#333;color:#333;}#mermaid-svg-URdfZ3mCSKNEGCzs .node rect,#mermaid-svg-URdfZ3mCSKNEGCzs .node circle,#mermaid-svg-URdfZ3mCSKNEGCzs .node ellipse,#mermaid-svg-URdfZ3mCSKNEGCzs .node polygon,#mermaid-svg-URdfZ3mCSKNEGCzs .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-URdfZ3mCSKNEGCzs .rough-node .label text,#mermaid-svg-URdfZ3mCSKNEGCzs .node .label text,#mermaid-svg-URdfZ3mCSKNEGCzs .image-shape .label,#mermaid-svg-URdfZ3mCSKNEGCzs .icon-shape .label{text-anchor:middle;}#mermaid-svg-URdfZ3mCSKNEGCzs .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-URdfZ3mCSKNEGCzs .rough-node .label,#mermaid-svg-URdfZ3mCSKNEGCzs .node .label,#mermaid-svg-URdfZ3mCSKNEGCzs .image-shape .label,#mermaid-svg-URdfZ3mCSKNEGCzs .icon-shape .label{text-align:center;}#mermaid-svg-URdfZ3mCSKNEGCzs .node.clickable{cursor:pointer;}#mermaid-svg-URdfZ3mCSKNEGCzs .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-URdfZ3mCSKNEGCzs .arrowheadPath{fill:#333333;}#mermaid-svg-URdfZ3mCSKNEGCzs .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-URdfZ3mCSKNEGCzs .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-URdfZ3mCSKNEGCzs .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-URdfZ3mCSKNEGCzs .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-URdfZ3mCSKNEGCzs .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-URdfZ3mCSKNEGCzs .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-URdfZ3mCSKNEGCzs .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-URdfZ3mCSKNEGCzs .cluster text{fill:#333;}#mermaid-svg-URdfZ3mCSKNEGCzs .cluster span{color:#333;}#mermaid-svg-URdfZ3mCSKNEGCzs 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-URdfZ3mCSKNEGCzs .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-URdfZ3mCSKNEGCzs rect.text{fill:none;stroke-width:0;}#mermaid-svg-URdfZ3mCSKNEGCzs .icon-shape,#mermaid-svg-URdfZ3mCSKNEGCzs .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-URdfZ3mCSKNEGCzs .icon-shape p,#mermaid-svg-URdfZ3mCSKNEGCzs .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-URdfZ3mCSKNEGCzs .icon-shape .label rect,#mermaid-svg-URdfZ3mCSKNEGCzs .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-URdfZ3mCSKNEGCzs .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-URdfZ3mCSKNEGCzs .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-URdfZ3mCSKNEGCzs :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} World Clocks Container
Title: World Clocks
New York Row
London Row
Tokyo Row
Sydney Row
City Name + HH:mm

世界时钟模块在主时间下方,不抢占主时钟视觉焦点,同时提供跨时区信息。


十一、世界时间计算

11.1 _buildWorldClock 方法

每个城市时间由 _buildWorldClock() 构建。

dart 复制代码
Widget _buildWorldClock(String city, int offset) {
  final utcTime = _currentTime.toUtc();
  final cityTime = utcTime.add(Duration(hours: offset));
  final cityHour = cityTime.hour.toString().padLeft(2, '0');
  final cityMinute = cityTime.minute.toString().padLeft(2, '0');

  return Padding(
    padding: const EdgeInsets.symmetric(vertical: 4),
    child: Row(
      mainAxisAlignment: MainAxisAlignment.spaceBetween,
      children: [
        Text(city),
        Text(
          '$cityHour:$cityMinute',
          style: const TextStyle(
            fontFamily: 'monospace',
            fontWeight: FontWeight.bold,
          ),
        ),
      ],
    ),
  );
}

这个方法同时完成城市时间计算和 UI 构建。

11.2 UTC 基准

dart 复制代码
final utcTime = _currentTime.toUtc();
final cityTime = utcTime.add(Duration(hours: offset));

先把本地时间转成 UTC,再按城市偏移计算城市时间。这样比直接在本地时间上加减小时更清晰。

11.3 城市时间格式

dart 复制代码
final cityHour = cityTime.hour.toString().padLeft(2, '0');
final cityMinute = cityTime.minute.toString().padLeft(2, '0');

世界时钟只显示小时和分钟,不显示秒。这样视觉更简洁,也能降低列表的动态变化感。

11.4 行布局

dart 复制代码
Row(
  mainAxisAlignment: MainAxisAlignment.spaceBetween,
  children: [
    Text(city),
    Text('$cityHour:$cityMinute'),
  ],
)

城市名在左,时间在右,spaceBetween 让两端对齐,符合列表型信息的阅读习惯。


十二、生命周期与动态刷新

12.1 为什么需要 mounted

异步循环和页面生命周期不是完全同步的。页面可能已经关闭,但 Future.delayed 的回调随后才执行。如果此时继续调用 setState,就可能引发异常。

当前项目通过 mounted 判断状态是否有效。

dart 复制代码
if (mounted) {
  setState(() {
    _currentTime = DateTime.now();
  });
  return true;
}
return false;

12.2 刷新频率选择

当前时钟显示到秒,因此 1 秒刷新一次即可。

显示精度 刷新间隔
分钟级 60 秒
秒级 1 秒
毫秒级 小于 1 秒

clock 显示 HH:mm:ss,所以 Duration(seconds: 1) 与 UI 精度一致。

12.3 与秒表项目的差异

项目 时间来源 刷新频率 核心用途
秒表 Stopwatch.elapsed 10ms 记录经过时间
时钟 DateTime.now() 1s 展示当前时间

秒表关注流逝时间,时钟关注系统当前时间。二者都需要动态刷新,但时间源和刷新精度不同。


十三、OpenHarmony 适配边界

13.1 Flutter 层职责

当前时钟应用的业务逻辑全部在 Flutter 层完成。

text 复制代码
Flutter 层:
ClockApp
ClockHomePage
_ClockHomePageState
DateTime.now
Future.doWhile
_formatTime
_formatDate
_buildWorldClock

OpenHarmony 层:
AppScope
entry
EntryAbility
Index
resources

Flutter 层负责时间获取、格式化、刷新和页面渲染;OpenHarmony 层负责应用启动、模块配置和资源承载。

13.2 平台侧文件作用

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

13.3 时钟应用的适配特点

clock 当前不需要网络、定位、数据库、文件读写或传感器权限。它主要验证 Flutter 页面在 OpenHarmony 容器中的动态刷新和文本布局表现。


十四、测试与验证

14.1 交互验证路径

时钟应用没有复杂用户输入,主要验证展示和自动刷新:

  1. 启动应用,标题显示 Clock
  2. 主时间显示为 HH:mm:ss
  3. 等待 1 秒,确认秒数更新。
  4. 日期显示英文星期、月份、日和年。
  5. World Clocks 面板正常展示。
  6. New York、London、Tokyo、Sydney 四个城市正常显示。
  7. 世界时钟时间为 HH:mm 格式。
  8. 页面在前后台切换后仍能继续刷新。

14.2 Widget 测试示例

下面的测试验证应用启动后能看到标题和世界时钟模块。

dart 复制代码
testWidgets('shows clock page title and world clocks', (WidgetTester tester) async {
  await tester.pumpWidget(const ClockApp());

  expect(find.text('Clock'), findsOneWidget);
  expect(find.text('World Clocks'), findsOneWidget);
  expect(find.text('New York'), findsOneWidget);
  expect(find.text('London'), findsOneWidget);
  expect(find.text('Tokyo'), findsOneWidget);
  expect(find.text('Sydney'), findsOneWidget);
});

14.3 时间格式测试思路

_formatTime() 是私有方法,直接测试时可以把等价格式化逻辑抽出来验证。

dart 复制代码
test('formats time with two digits', () {
  final hour = 8.toString().padLeft(2, '0');
  final minute = 5.toString().padLeft(2, '0');
  final second = 3.toString().padLeft(2, '0');

  expect('$hour:$minute:$second', '08:05:03');
});

14.4 世界时间计算测试思路

dart 复制代码
test('calculates city time from utc offset', () {
  final utc = DateTime.utc(2026, 6, 1, 12, 30);
  final tokyo = utc.add(const Duration(hours: 9));
  final hour = tokyo.hour.toString().padLeft(2, '0');
  final minute = tokyo.minute.toString().padLeft(2, '0');

  expect('$hour:$minute', '21:30');
});

这些测试覆盖了页面基础展示、两位数补零和 UTC 偏移计算。


十五、常见问题解析

15.1 为什么使用 DateTime.now()

时钟应用展示的是系统当前时间。DateTime.now() 能直接获取本地时间,适合数字时钟场景。

dart 复制代码
_currentTime = DateTime.now();

15.2 为什么每秒刷新一次

页面展示精度是秒级,刷新频率与展示精度保持一致即可。如果每 10ms 刷新一次,会造成不必要的重建;如果每分钟刷新一次,秒数就不会准确变化。

15.3 为什么主时钟显示秒而世界时钟不显示秒

主时钟是页面核心,展示到秒可以体现动态刷新。世界时钟是辅助信息,只显示小时和分钟可以降低视觉噪声,让列表更稳定。

15.4 为什么使用 toUtc 再加偏移

世界时间换算以 UTC 为基准更直观。先把本地时间转为 UTC,再加目标城市偏移,可以让计算逻辑保持一致。

dart 复制代码
final utcTime = _currentTime.toUtc();
final cityTime = utcTime.add(Duration(hours: offset));

15.5 为什么使用 monospace

数字时钟每秒变化,如果数字宽度不同,文本会出现轻微跳动。等宽字体能让每个数字占据相同宽度,视觉上更稳定。


十六、完整业务链路复盘

16.1 本地时间链路

  1. 页面创建 _currentTime = DateTime.now()
  2. initState() 调用 _updateTime()
  3. Future.doWhile 启动异步循环。
  4. 每次循环等待 1 秒。
  5. 检查 mounted
  6. 页面仍然有效时调用 setState
  7. _currentTime 更新为新的 DateTime.now()
  8. _formatTime() 生成主时钟文本。
  9. _formatDate() 生成日期文本。
  10. 页面重新渲染。

16.2 世界时钟链路

  1. _buildWorldClock(city, offset) 接收城市和 UTC 偏移。
  2. _currentTime.toUtc() 得到 UTC 时间。
  3. utcTime.add(Duration(hours: offset)) 得到城市时间。
  4. 小时和分钟分别使用 padLeft 补齐。
  5. Row 左侧展示城市名。
  6. Row 右侧展示城市时间。

16.3 UI 渲染链路

数据来源 格式化方法 展示位置
_currentTime.hour/minute/second _formatTime() 主时钟
_currentTime.weekday/month/day/year _formatDate() 日期文本
_currentTime.toUtc() _buildWorldClock() 世界时钟
widget.title 直接展示 AppBar

十七、核心源码总览

下面集中展示当前项目中最关键的时间刷新与格式化代码。

dart 复制代码
class _ClockHomePageState extends State<ClockHomePage> {
  DateTime _currentTime = DateTime.now();

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

  void _updateTime() {
    Future.doWhile(() async {
      await Future.delayed(const Duration(seconds: 1));
      if (mounted) {
        setState(() {
          _currentTime = DateTime.now();
        });
        return true;
      }
      return false;
    });
  }

  String _formatTime() {
    return '${_currentTime.hour.toString().padLeft(2, '0')}:${_currentTime.minute.toString().padLeft(2, '0')}:${_currentTime.second.toString().padLeft(2, '0')}';
  }

  String _formatDate() {
    final weekdays = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
    final months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
    return '${weekdays[_currentTime.weekday - 1]}, ${months[_currentTime.month - 1]} ${_currentTime.day}, ${_currentTime.year}';
  }
}

这段代码体现了时钟应用的主干:以 DateTime.now() 作为时间源,通过异步循环每秒更新,并把结果格式化为可读文本。


十八、总结

clock 是一个结构简洁但知识点清晰的 Flutter 时钟应用。它使用 DateTime.now() 获取系统当前时间,通过 Future.doWhileFuture.delayed 构造每秒刷新循环,再用 setState 驱动页面重建。mounted 的加入让异步刷新与页面生命周期保持安全边界。

从源码结构看,项目的关键点集中在三条线:_currentTime 承载当前时间,_formatTime()_formatDate() 负责本地时间展示,_buildWorldClock() 负责基于 UTC 偏移计算世界城市时间。UI 则通过大号等宽时间文本、灰色日期文本和浅靛蓝世界时钟面板形成清晰层级。

从 OpenHarmony 适配角度看,当前项目业务逻辑保持在 Flutter 层,ohos 目录负责平台工程承载。时钟应用没有复杂权限,但具备持续刷新特征,适合验证 Flutter 页面在 OpenHarmony 容器中的动态文本更新与布局稳定性。

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


相关资源

相关推荐
G_dou_1 小时前
Flutter+OpenHarmony 实战:weather 天气查询应用
flutter·harmonyos
yuegu7772 小时前
HarmonyOS应用<节气通>开发第1篇:启动页开发——留下第一印象的2秒
harmonyos
川石课堂软件测试2 小时前
零基础小白如何学习自动化测试
python·功能测试·学习·测试工具·jmeter·压力测试·harmonyos
韩曙亮2 小时前
【错误记录】flutter pub get 执行报错 ( 打开 Windows 开发者模式 )
windows·flutter
Swift社区2 小时前
OpenHarmony鸿蒙PC平台移植 gifsicle:CC++ 三方库适配实践(Lycium tpc_c_cplusplus)
c语言·c++·harmonyos
川石课堂软件测试2 小时前
作为一名测试工程师如何学习Kubernetes(k8s)技能
学习·测试工具·容器·职场和发展·kubernetes·测试用例·harmonyos
yuegu7773 小时前
HarmonyOS应用<节气通>开发第4篇:TabBar导航实现
华为·harmonyos
阿钱真强道3 小时前
25 鸿蒙LiteOS GPIO轮询模式实战教程:电平读取与上升沿检测
嵌入式·harmonyos·liteos·开源鸿蒙·瑞芯微·rk2206