Flutter 实战:time_zone 世界时钟的 UTC 偏移计算、秒级刷新与鸿蒙适配解析
Flutter 实战:time_zone 世界时钟的 UTC 偏移计算、秒级刷新与鸿蒙适配解析
前言
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
世界时钟类应用看起来只是展示多个城市的当前时间,但它背后包含了不少 Flutter 实战能力:秒级状态刷新、UTC 偏移换算、半小时时区处理、网格布局、卡片化展示、固定宽度时间字体 和 跨端显示验证。
time_zone 是一个轻量世界时钟项目。它以当前本地时间为源,先转换成 UTC,再根据城市配置中的 offset 计算目标城市时间,最终用网格卡片展示城市名称、当前时间、日期和 UTC 偏移。整个项目没有复杂插件,核心逻辑集中在 Dart 和 Flutter Widget 层,适合作为 Flutter 工具类应用和鸿蒙适配文章的分析案例。
时区应用的难点不只是“加几个小时”,还包括半小时偏移、跨日期显示、秒级刷新、网格布局和不同平台字体渲染的一致性。
图示说明:上图展示 Flutter 页面在移动端的布局组织方式。time_zone 的实际界面由本地时间卡片和多个城市时区卡片组成。
一、项目定位与功能边界
1.1 应用定位
time_zone 是一个世界时钟展示工具,用于在同一个页面中查看多个城市的当前时间。它不是复杂的会议排期系统,也不包含搜索、收藏、夏令时数据库等高级能力,而是聚焦在固定城市列表的时间展示。
项目当前支持:
- 展示本地当前时间。
- 每秒刷新一次时间。
- 展示 15 个城市的时区时间。
- 支持整数 UTC 偏移。
- 支持 Mumbai 的
UTC+5:30半小时偏移。 - 展示目标城市日期。
- 使用
GridView.builder构建城市卡片。
1.2 城市数据范围
| 城市 | 缩写 | UTC 偏移 |
|---|---|---|
| New York | NYC | UTC-4 |
| Los Angeles | LA | UTC-7 |
| London | LON | UTC+1 |
| Paris | PAR | UTC+2 |
| Berlin | BER | UTC+2 |
| Moscow | MSC | UTC+3 |
| Dubai | DXB | UTC+4 |
| Mumbai | BOM | UTC+5:30 |
| Singapore | SIN | UTC+8 |
| Tokyo | TYO | UTC+9 |
| Seoul | SEL | UTC+9 |
| Sydney | SYD | UTC+10 |
| Auckland | AKL | UTC+12 |
| Honolulu | HNL | UTC-10 |
| Mexico City | MEX | UTC-6 |
1.3 技术栈
| 技术点 | 使用位置 | 价值 |
|---|---|---|
| Flutter | 页面、卡片、网格、图标 | 快速构建跨端 UI |
| Dart | 时间换算、字符串格式化 | 业务逻辑集中清晰 |
| Material 3 | 主题与组件风格 | useMaterial3: true |
| StatefulWidget | 当前时间动态刷新 | 每秒更新页面 |
| GridView.builder | 城市时钟网格 | 适合固定数据列表展示 |
二、工程结构与运行环境
2.1 工程结构
time_zone 是标准 Flutter 工程,主逻辑集中在 lib/main.dart。
| 文件或目录 | 作用 |
|---|---|
lib/main.dart |
应用入口、时区数据、时间换算和 UI 构建 |
pubspec.yaml |
Flutter SDK 与测试依赖声明 |
test/widget_test.dart |
Widget 测试入口 |
ohos/ |
鸿蒙平台工程目录 |
analysis_options.yaml |
Dart 静态分析规则 |
2.2 运行命令
flutter doctor
flutter pub get
flutter run
当前项目没有引入复杂三方插件,主要依赖 Flutter SDK 自带组件和 Dart DateTime 能力。
2.3 依赖声明
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.8
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
这种依赖结构很适合做跨端验证:核心逻辑在 Dart 层,平台侧主要关注渲染、字体、网格布局和定时刷新表现。
三、应用入口与主题配置
3.1 main 函数
Flutter 应用从 main() 进入:
import 'package:flutter/material.dart';
void main() {
runApp(const TimeZoneApp());
}
入口函数只负责启动根组件,不处理具体时区逻辑。
3.2 根组件
class TimeZoneApp extends StatelessWidget {
const TimeZoneApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
title: 'Time Zone',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
),
home: const TimeZoneHomePage(title: 'Time Zone'),
);
}
}
根组件使用 StatelessWidget,负责配置应用标题、主题和首页。每秒变化的时间状态由首页 State 维护。
3.3 主题色
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue)
蓝色主题用于本地时间卡片、城市卡片渐变和偏移标签,整体观感接近工具类仪表盘。
四、StatefulWidget 与核心状态
4.1 首页组件
class TimeZoneHomePage extends StatefulWidget {
const TimeZoneHomePage({super.key, required this.title});
final String title;
State<TimeZoneHomePage> createState() => _TimeZoneHomePageState();
}
首页使用 StatefulWidget,因为当前时间需要持续变化,页面必须跟随状态刷新。
4.2 当前时间字段
DateTime _currentTime = DateTime.now();
_currentTime 是所有城市时区计算的源数据。页面每秒更新它一次,然后所有城市卡片都会基于这个状态重新计算展示。
4.3 时区数据列表
final List<Map<String, dynamic>> _timeZones = [
{'name': 'New York', 'city': 'NYC', 'offset': -4, 'emoji': '🗽'},
{'name': 'Singapore', 'city': 'SIN', 'offset': 8, 'emoji': '🦁'},
{'name': 'Mumbai', 'city': 'BOM', 'offset': 5.5, 'emoji': '🕌'},
];
每个城市用一条 Map 表示,包含城市名称、缩写、UTC 偏移和展示图标。
五、时区数据模型设计
5.1 字段说明
| 字段 | 类型 | 作用 |
|---|---|---|
name |
String |
城市显示名称 |
city |
String |
城市缩写 |
offset |
num |
相对 UTC 的小时偏移 |
emoji |
String |
城市图标展示 |
5.2 为什么 offset 使用小数
绝大多数时区可以用整数小时表示,但有些时区不是整点偏移。例如 Mumbai 使用 5.5,代表 UTC+5:30。因此源码用数字偏移并在计算时拆成小时和分钟。
{'name': 'Mumbai', 'city': 'BOM', 'offset': 5.5, 'emoji': '🕌'}
5.3 数据驱动 UI
时区列表和城市卡片是一一对应关系:
读取 _timeZones
GridView.builder 遍历城市
根据 offset 计算时间
根据 offset 计算日期
格式化 UTC 偏移
渲染城市卡片
这种结构让新增城市非常直接,只需要增加一条数据。
六、秒级刷新实现
6.1 initState 启动刷新
void initState() {
super.initState();
_updateTime();
}
页面创建后调用 _updateTime(),开启持续刷新流程。
6.2 Future.doWhile 循环
void _updateTime() {
Future.doWhile(() async {
await Future.delayed(const Duration(seconds: 1));
if (mounted) {
setState(() {
_currentTime = DateTime.now();
});
return true;
}
return false;
});
}
这段代码每隔 1 秒更新一次 _currentTime。mounted 用来判断当前 State 是否仍在组件树中,避免页面销毁后继续调用 setState()。
6.3 刷新链路
- 等待 1 秒。
- 判断页面是否仍然挂载。
- 更新
_currentTime。 - 触发页面重新构建。
- 所有城市时间重新计算。
- 继续下一轮循环。
定时刷新类页面一定要关注生命周期,否则页面退出后继续刷新,容易引发无效更新或异常。
七、UTC 时间换算逻辑
7.1 获取 UTC 时间
final utc = _currentTime.toUtc();
不同城市的时间都基于 UTC 进行换算,这比直接在本地时间上加减偏移更稳定。
7.2 偏移转 Duration
final localTime = utc.add(Duration(
hours: offset.truncate(),
minutes: ((offset - offset.truncate()) * 60).round(),
));
这里把偏移拆成小时和分钟两部分。5.5 会被拆成 5 小时和 30 分钟。
7.3 时间格式化
return '${localTime.hour.toString().padLeft(2, '0')}:'
'${localTime.minute.toString().padLeft(2, '0')}:'
'${localTime.second.toString().padLeft(2, '0')}';
padLeft(2, '0') 保证小时、分钟、秒始终显示两位,例如 09:05:03。
八、日期与偏移字符串格式化
8.1 目标时区日期
String _getTimeZoneDate(double offset) {
final utc = _currentTime.toUtc();
final localTime = utc.add(Duration(
hours: offset.truncate(),
minutes: ((offset - offset.truncate()) * 60).round(),
));
final months = [
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
];
return '${months[localTime.month - 1]} ${localTime.day}';
}
由于时区换算可能跨日期,城市卡片需要同时展示日期。
8.2 UTC 偏移格式化
String _getOffsetString(double offset) {
final hours = offset.truncate();
final minutes = ((offset - offset.truncate()) * 60).round();
final sign = offset >= 0 ? '+' : '';
return 'UTC$sign$hours'
'${minutes > 0 ? ':${minutes.toString().padLeft(2, '0')}' : ''}';
}
这个方法会把 8 格式化为 UTC+8,把 5.5 格式化为 UTC+5:30。
8.3 格式化结果表
| offset | 输出 |
|---|---|
-4 |
UTC-4 |
1 |
UTC+1 |
5.5 |
UTC+5:30 |
8 |
UTC+8 |
12 |
UTC+12 |
九、本地时间卡片
9.1 顶部 Card
Card(
margin: const EdgeInsets.all(16),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.access_time, color: Colors.blue),
const SizedBox(width: 8),
Text('Local: ...'),
],
),
),
)
本地时间卡片放在页面顶部,作为所有时区计算的参照。
9.2 本地时间格式
'Local: ${_currentTime.hour.toString().padLeft(2, '0')}:'
'${_currentTime.minute.toString().padLeft(2, '0')}:'
'${_currentTime.second.toString().padLeft(2, '0')}'
本地时间也使用两位补零格式,和城市卡片中的时间格式保持一致。
9.3 monospace 字体
fontFamily: 'monospace'
固定宽度字体可以减少秒数变化时的视觉跳动,让时钟看起来更稳定。
十、城市网格布局
10.1 Expanded 包裹 GridView
Expanded(
child: GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
childAspectRatio: 0.9,
),
itemCount: _timeZones.length,
itemBuilder: (context, index) {
final zone = _timeZones[index];
return Card(child: Container());
},
),
)
Expanded 让网格占据剩余空间,顶部本地时间卡片保持固定高度。
10.2 网格参数
| 参数 | 当前值 | 说明 |
|---|---|---|
crossAxisCount |
3 | 每行 3 个城市 |
crossAxisSpacing |
8 | 横向间距 |
mainAxisSpacing |
8 | 纵向间距 |
childAspectRatio |
0.9 | 卡片宽高比例 |
10.3 网格适配思路
当前实现适合常见手机宽度。如果面向平板、桌面或折叠屏,可以根据屏幕宽度动态调整列数。
final width = MediaQuery.of(context).size.width;
final columns = width > 700 ? 5 : 3;
这类调整能让鸿蒙多设备场景下的布局更自然。
十一、城市卡片渲染
11.1 卡片数据读取
final zone = _timeZones[index];
final time = _getTimeZoneTime(zone['offset']);
final date = _getTimeZoneDate(zone['offset']);
每个卡片都会根据自己的 offset 计算时间和日期。
11.2 卡片内容
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(zone['emoji'] as String, style: const TextStyle(fontSize: 28)),
const SizedBox(height: 4),
Text(zone['name'] as String, textAlign: TextAlign.center),
const SizedBox(height: 4),
Text(time),
Text(date),
Text(_getOffsetString(zone['offset'])),
],
)
卡片从上到下展示图标、城市名、时间、日期和 UTC 偏移,信息层级清晰。
11.3 时间文本样式
Text(
time,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
fontFamily: 'monospace',
),
)
时间是卡片的核心内容,因此使用更大的字号和加粗样式。
十二、半小时时区处理
12.1 Mumbai 的 offset
{'name': 'Mumbai', 'city': 'BOM', 'offset': 5.5, 'emoji': '🕌'}
Mumbai 使用 UTC+5:30,这也是项目支持小数偏移的主要体现。
12.2 小数偏移拆分
final hours = offset.truncate();
final minutes = ((offset - offset.truncate()) * 60).round();
truncate() 取整数小时,小数部分乘以 60 得到分钟。
12.3 注意负数半小时偏移
当前城市列表没有负数半小时偏移。对于 -3.5 这类偏移,分钟符号处理会更复杂。如果后续扩展到更多时区,建议抽离并单独测试 offset 转换函数。
十三、边界场景与真实限制
13.1 夏令时限制
当前项目使用固定 UTC 偏移,不接入 IANA 时区数据库,因此不会自动处理夏令时变化。比如纽约、伦敦等城市在不同时期的实际偏移可能变化。
13.2 城市列表限制
城市列表是本地静态数据,不支持搜索、添加或删除城市。它适合演示和固定展示,不等同于完整世界时钟产品。
13.3 日期跨天
由于时间从 UTC 加偏移得到,跨天时 DateTime 会自动处理日期变化。项目展示 Jan 1 这类短日期,能帮助用户理解目标城市是否已经进入前一天或后一天。
13.4 秒级刷新成本
每秒 setState() 会刷新整个页面。当前城市数量只有 15 个,成本很低。如果城市数量变多,可以考虑更细粒度的刷新策略。
十四、Widget 测试设计
14.1 基础渲染测试
import 'package:flutter_test/flutter_test.dart';
import '../lib/main.dart';
void main() {
testWidgets('time zone renders home page', (tester) async {
await tester.pumpWidget(const TimeZoneApp());
expect(find.text('Time Zone'), findsWidgets);
expect(find.textContaining('Local:'), findsOneWidget);
expect(find.text('New York'), findsOneWidget);
});
}
这个测试验证根组件、本地时间卡片和默认城市是否渲染。
14.2 城市网格测试
testWidgets('time zone renders city cards', (tester) async {
await tester.pumpWidget(const TimeZoneApp());
expect(find.text('London'), findsOneWidget);
expect(find.text('Tokyo'), findsOneWidget);
expect(find.text('Singapore'), findsOneWidget);
});
固定城市列表非常适合做基础 UI 存在性测试。
14.3 偏移文本测试
testWidgets('time zone renders utc offset labels', (tester) async {
await tester.pumpWidget(const TimeZoneApp());
expect(find.text('UTC+8'), findsOneWidget);
expect(find.text('UTC+5:30'), findsOneWidget);
});
这个测试可以覆盖整数偏移和半小时偏移。
14.4 测试命令
flutter test
保持测试中的根组件名称与实际源码一致,能避免默认模板测试残留造成编译失败。
十五、鸿蒙适配观察
15.1 适配优势
time_zone 的核心逻辑由 Dart DateTime 和 Flutter Widget 完成,没有复杂原生插件,因此鸿蒙侧适配重点主要在 UI 和刷新表现。
| 维度 | 当前项目情况 | 鸿蒙侧关注点 |
|---|---|---|
| 时间计算 | Dart DateTime |
多端计算一致性 |
| 秒级刷新 | Future.doWhile |
页面生命周期与刷新稳定性 |
| 网格布局 | GridView.builder |
不同屏幕列数和卡片高度 |
| 图标展示 | emoji 字符串 | 字体支持和显示差异 |
| 时间文本 | monospace 字体 | 数字宽度和跳动控制 |
15.2 构建命令参考
flutter clean
flutter pub get
flutter build hap
具体命令取决于所使用的鸿蒙 Flutter 适配环境。对这个项目来说,重点是验证启动、刷新、网格、字体和时间显示。
15.3 运行验证要点
- 应用能正常启动到世界时钟页面。
- 本地时间每秒刷新。
- 城市卡片中的秒数同步变化。
UTC+5:30能正确展示。- 网格在小屏下不明显溢出。
- emoji 图标在目标设备上能正常显示。
鸿蒙适配时,时钟逻辑本身通常不是最大风险,字体、emoji、固定宽度数字、网格尺寸和页面生命周期更值得仔细验证。
十六、性能与可维护性
16.1 性能特征
当前页面每秒刷新一次,城市数量为 15 个,整体计算量很小。
| 维度 | 当前表现 |
|---|---|
| 刷新频率 | 每秒 1 次 |
| 城市数量 | 15 |
| 计算复杂度 | 线性遍历城市 |
| UI 结构 | 顶部卡片 + 网格 |
| 数据来源 | 本地静态列表 |
16.2 当前结构优点
- 时区数据集中在
_timeZones。 - 时间计算方法独立,便于阅读。
- UTC 偏移格式化单独封装。
- 网格卡片由数据驱动生成。
mounted判断避免页面销毁后继续刷新。
16.3 可演进方向
如果项目继续扩展,可以把城市数据定义成模型类。
class TimeZoneCity {
const TimeZoneCity({
required this.name,
required this.code,
required this.offset,
required this.icon,
});
final String name;
final String code;
final double offset;
final String icon;
}
模型类能替代 Map<String, dynamic>,减少类型转换,也让字段语义更清楚。
十七、扩展功能思路
17.1 动态列数
可以根据屏幕宽度调整网格列数,让手机、平板和桌面窗口都有合适布局。
int resolveColumns(double width) {
if (width >= 900) return 5;
if (width >= 600) return 4;
return 3;
}
17.2 城市搜索
如果城市列表继续增加,可以添加搜索框,按城市名或缩写过滤。
final filtered = cities.where((city) {
return city.name.toLowerCase().contains(keyword.toLowerCase());
}).toList();
17.3 时区数据库
面向真实产品时,可以接入更完整的时区数据库,以处理夏令时和历史偏移。当前项目更适合作为固定 offset 的轻量示例。
十八、常见问题与优化建议
18.1 为什么先转 UTC 再加偏移
UTC 是统一基准。先把本地时间转成 UTC,再对每个城市加 offset,可以让不同城市的计算逻辑保持一致。
18.2 为什么使用 Future.doWhile
它可以持续执行异步循环,每秒更新一次时间。源码中配合 mounted 判断,可以在页面销毁后结束循环。
18.3 为什么要显示日期
跨时区计算经常会跨天。只显示时间容易误导用户,显示日期可以让用户知道目标城市是当天、前一天还是后一天。
18.4 为什么使用 GridView.builder
城市数量固定但不少,网格比纵向列表更节省空间,也更符合世界时钟仪表盘的浏览方式。
18.5 为什么 offset 不等于真实全年时区
源码使用固定 UTC 偏移,没有处理夏令时。对于演示和轻量工具足够,但真实时区产品需要更完整的时区规则。
18.6 为什么适合鸿蒙适配示例
它同时覆盖定时刷新、网格布局、emoji、固定宽度时间文本和本地时间计算,能帮助验证 Flutter 工具页面在鸿蒙设备上的基础表现。
总结
time_zone 用一个 Flutter 页面完成了世界时钟的基础闭环:页面保存当前时间,每秒刷新一次;城市数据用本地列表描述;时间换算以 UTC 为基准,按 offset 加上小时和分钟;最终通过网格卡片展示城市、时间、日期和偏移。
从工程角度看,这个项目结构清晰、依赖简单、数据驱动明显。它没有引入复杂时区库,也没有处理夏令时,因此更适合作为 Flutter 世界时钟入门实现和跨端适配观察案例。
从鸿蒙适配角度看,核心关注点包括秒级刷新是否稳定、emoji 是否正常显示、monospace 时间文本是否抖动、网格布局是否适配小屏,以及 UTC+5:30 这类半小时偏移是否正确展示。
如果这篇文章对你有帮助,欢迎点赞、收藏、关注,你的支持是我持续创作的动力!
相关资源:
更多推荐



所有评论(0)