Flutter 实战:world_clock 世界时钟的城市列表、UTC 偏移与鸿蒙适配解析

前言

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

world_clock 是一个基于 Flutter 实现的世界时钟应用。它内置了多个城市,并支持添加自定义城市和 UTC 偏移。页面会实时显示每个城市的本地时间、日期、星期以及按昼夜变化的图标和颜色,从而构成一个轻量但完整的跨时区展示工具。

本文基于项目真实源码展开,重点分析 城市列表结构UTC 偏移计算本地时间格式化昼夜视觉提示城市新增与删除控制器生命周期鸿蒙适配关注点。文章内容可直接发布到 CSDN,不包含面向作者的检查说明。

世界时钟看起来只是“把时间换算一下”,但真正的关键在于如何把城市、时区、偏移、日期和视觉状态组织成一条稳定的数据流。world_clock 很适合用来拆解这条链路。

在这里插入图片描述

图示说明:本文围绕 Flutter 世界时钟的城市列表、UTC 偏移、昼夜图标和跨端适配展开,适合用于鸿蒙、Android、iOS 等多端应用开发复盘。

一、项目定位与功能概览

1.1 应用主题

world_clock 的定位是一个 世界城市时间管理工具。用户可以查看多个城市的当前时间,也可以新增自定义城市并手动输入 UTC 偏移。

核心功能如下:

功能 页面表现 源码实现
预置城市 New York、London、Tokyo 等 _locations
城市新增 顶部输入城市名和偏移 _addLocation()
城市删除 右侧删除按钮 _removeLocation()
当前时间 HH:mm:ss 显示 _getTime()
当前日期 day/month/year 显示 _getDate()
当前星期 显示 Monday 到 Sunday _getDayName()
昼夜图标 太阳、云、夜晚、深夜图标 _getTimeIcon()
昼夜颜色 不同时间段不同主色 _getTimeColor()

1.2 默认数据

项目启动后已有几个示例城市:

城市 时区 偏移
New York America/New_York -5
London Europe/London 0
Tokyo Asia/Tokyo 9
Sydney Australia/Sydney 10
Dubai Asia/Dubai 4

这些默认数据让页面加载后就能展示多个城市的对比时间,便于验证列表、时间刷新和昼夜样式。

1.3 适合学习的点

这个项目适合学习以下 Flutter 实战能力:

  1. 如何用 List<Map<String, dynamic>> 保存轻量时区列表。
  2. 如何根据 UTC 偏移计算本地时间。
  3. 如何在列表里同时展示时间、日期、星期和图标。
  4. 如何用颜色与图标表达昼夜状态。
  5. 如何新增、删除并维护城市列表。
  6. 如何面向鸿蒙验证输入、列表和布局适配。

二、工程结构与运行方式

2.1 工程结构

项目保持标准 Flutter 工程结构,核心代码集中在 lib/main.dart

文件或目录 作用 说明
lib/main.dart 应用入口与页面实现 包含城市列表、时间换算和 UI
pubspec.yaml 依赖声明 使用 Flutter SDK 与 Material 图标
test/widget_test.dart Widget 测试入口 可扩展为时间列表测试
ohos/ 鸿蒙平台工程目录 用于跨端构建和适配

2.2 依赖声明

项目没有引入复杂第三方依赖:

dependencies:
  flutter:
    sdk: flutter

  cupertino_icons: ^1.0.8

这说明时间计算、列表渲染和城市管理都由 Flutter 与 Dart 完成,不依赖系统时区库或平台通道。

2.3 常用命令

开发和验证时可以使用以下命令:

flutter pub get
flutter analyze
flutter test
flutter run
命令 作用 使用场景
flutter pub get 获取依赖 首次运行或依赖变化
flutter analyze 静态分析 检查语法和 lint
flutter test 执行测试 验证 Widget 行为
flutter run 启动应用 本地调试界面

三、应用入口与主题配置

3.1 main 函数

应用入口保持 Flutter 标准写法:

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

世界时钟不需要启动时初始化网络或数据库,因此入口很简洁。

3.2 MyApp 根组件

根组件负责创建 MaterialApp

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'World Clock',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
      ),
      home: const MyHomePage(title: 'World Clock'),
    );
  }
}

这里有三个关键信息:

  • 应用标题是 World Clock
  • 主题种子色是 Colors.indigo
  • 首页是 MyHomePage

3.3 主题色选择

靛蓝色适合时间类工具,稳重且清晰。源码中列表头像背景、AppBar 和图标都围绕时间状态变化,整体观感统一。

当前源码没有显式设置 useMaterial3: true,因此文章以真实代码为准。如果后续需要统一 Material 3 表现,可以在 ThemeData 中补充该配置。

四、状态字段设计

4.1 核心状态总览

页面状态集中在 _MyHomePageState 中:

final List<Map<String, dynamic>> _locations = [
  {'name': 'New York', 'timezone': 'America/New_York', 'offset': -5},
  {'name': 'London', 'timezone': 'Europe/London', 'offset': 0},
];
final TextEditingController _cityController = TextEditingController();
final TextEditingController _offsetController = TextEditingController();

4.2 状态字段说明

字段 类型 作用
_locations List<Map<String, dynamic>> 城市、时区和偏移列表
_cityController TextEditingController 城市名称输入
_offsetController TextEditingController UTC 偏移输入

4.3 为什么使用列表而不是单一模型

当前项目使用 Map<String, dynamic> 保存城市信息,优点是写法简单、上手快:

优点 说明
简洁 少量字段直接存 Map
灵活 便于快速增删字段
适合演示 适合教学和小工具样例

4.4 结构扩展建议

如果后续字段增多,可以把城市项抽成模型,例如:

class CityClock {
  const CityClock({
    required this.name,
    required this.timezone,
    required this.offset,
  });

  final String name;
  final String timezone;
  final int offset;
}

五、控制器生命周期管理

5.1 两个输入控制器

页面使用两个控制器管理输入:

final TextEditingController _cityController = TextEditingController();
final TextEditingController _offsetController = TextEditingController();

5.2 dispose 释放资源

页面销毁时释放控制器:


void dispose() {
  _cityController.dispose();
  _offsetController.dispose();
  super.dispose();
}

5.3 生命周期表

阶段 行为 对应代码
页面创建 创建控制器 字段初始化
用户输入 控制器保存文本 顶部输入区
添加城市 读取并清空控制器 _addLocation()
页面销毁 释放控制器 dispose()

六、城市新增逻辑

6.1 addLocation 方法

新增城市的逻辑如下:

void _addLocation() {
  if (_cityController.text.isEmpty) return;
  final offset = int.tryParse(_offsetController.text) ?? 0;

  setState(() {
    _locations.add({
      'name': _cityController.text,
      'timezone': 'Custom',
      'offset': offset,
    });
    _cityController.clear();
    _offsetController.clear();
  });
}

6.2 输入校验

代码只校验城市名不能为空:

if (_cityController.text.isEmpty) return;

6.3 偏移默认值

UTC 偏移使用:

final offset = int.tryParse(_offsetController.text) ?? 0;

如果用户没有输入有效数字,就默认按 0 处理。

6.4 新增后的状态变化

成功新增后会发生三件事:

  1. _locations 增加一项。
  2. 城市输入框清空。
  3. 偏移输入框清空。

七、城市删除逻辑

7.1 removeLocation 方法

删除逻辑非常直接:

void _removeLocation(int index) {
  setState(() {
    _locations.removeAt(index);
  });
}

7.2 删除入口

列表项右侧有删除按钮:

IconButton(
  icon: const Icon(Icons.delete, color: Colors.red),
  onPressed: () => _removeLocation(index),
)

7.3 删除后的界面变化

删除后列表会重新构建,少掉对应城市卡片。

7.4 删除交互边界

当前删除是即时生效,没有确认弹窗。对于演示型应用足够简单;如果做正式产品,可以增加撤销或确认提示。

八、时间计算核心

8.1 getTime 方法

本地时间由 _getTime() 计算:

String _getTime(int offset) {
  final now = DateTime.now().toUtc();
  final localTime = now.add(Duration(hours: offset));
  return '${localTime.hour.toString().padLeft(2, '0')}:${localTime.minute.toString().padLeft(2, '0')}:${localTime.second.toString().padLeft(2, '0')}';
}

8.2 UTC 加小时偏移

核心公式是:

localTime = DateTime.now().toUtc().add(Duration(hours: offset));

这意味着:

偏移 结果
-5 比 UTC 早 5 小时
0 与 UTC 相同
9 比 UTC 晚 9 小时

8.3 补零显示

输出格式使用 padLeft(2, '0')

hour.toString().padLeft(2, '0')

这保证小时、分钟和秒都以两位显示。

8.4 时间刷新策略

源码没有定时器,时间是每次构建时重新计算。因此只要界面刷新,显示时间就是最新的 UTC 偏移结果。

九、日期与星期计算

9.1 getDate 方法

日期由 _getDate() 计算:

String _getDate(int offset) {
  final now = DateTime.now().toUtc();
  final localTime = now.add(Duration(hours: offset));
  return '${localTime.day}/${localTime.month}/${localTime.year}';
}

9.2 getDayName 方法

星期由 _getDayName() 计算:

String _getDayName(int offset) {
  final now = DateTime.now().toUtc();
  final localTime = now.add(Duration(hours: offset));
  const days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
  return days[localTime.weekday - 1];
}

9.3 星期名称映射

weekday 名称
1 Monday
2 Tuesday
3 Wednesday
4 Thursday
5 Friday
6 Saturday
7 Sunday

9.4 日期和星期的作用

日历类展示不只要显示时间,还要显示日期和星期,帮助用户快速判断某个城市现在处于哪一天、哪一个时段。

十、昼夜视觉提示

10.1 颜色判断

时间颜色由 _getTimeColor() 决定:

Color _getTimeColor(int offset) {
  final now = DateTime.now().toUtc();
  final hour = now.add(Duration(hours: offset)).hour;
  if (hour >= 6 && hour < 12) return Colors.orange;
  if (hour >= 12 && hour < 18) return Colors.blue;
  if (hour >= 18 && hour < 22) return Colors.deepOrange;
  return Colors.indigo;
}

10.2 图标判断

图标由 _getTimeIcon() 决定:

IconData _getTimeIcon(int offset) {
  final now = DateTime.now().toUtc();
  final hour = now.add(Duration(hours: offset)).hour;
  if (hour >= 6 && hour < 12) return Icons.wb_sunny;
  if (hour >= 12 && hour < 18) return Icons.wb_cloudy;
  if (hour >= 18 && hour < 22) return Icons.nights_stay;
  return Icons.dark_mode;
}

10.3 昼夜规则表

时间段 颜色 图标
6:00 - 11:59 orange wb_sunny
12:00 - 17:59 blue wb_cloudy
18:00 - 21:59 deepOrange nights_stay
其他时间 indigo dark_mode

10.4 视觉提示价值

用户不必精确读时分秒,也能通过颜色和图标快速感知城市是白天还是夜晚,这对世界时钟类应用很有帮助。

十一、城市卡片布局

11.1 列表结构

城市列表用 ListView.builder

Expanded(
  child: ListView.builder(
    padding: const EdgeInsets.symmetric(horizontal: 16),
    itemCount: _locations.length,
    itemBuilder: (context, index) { ... },
  ),
)

11.2 城市头像

每个城市卡片左侧有一个圆形图标容器:

Container(
  width: 60,
  height: 60,
  decoration: BoxDecoration(
    color: _getTimeColor(location['offset']).withAlpha(50),
    borderRadius: BorderRadius.circular(30),
  ),
  child: Icon(
    _getTimeIcon(location['offset']),
    size: 32,
    color: _getTimeColor(location['offset']),
  ),
)

11.3 卡片中间信息

中间区域展示城市名、星期和 UTC 偏移:

Text(location['name'])
Text(_getDayName(location['offset']))
Text('UTC+9')

11.4 卡片右侧时间

右侧展示当前时间和日期:

Text(_getTime(location['offset']))
Text(_getDate(location['offset']))

11.5 信息层级

区域 内容
左侧 昼夜图标
中间 城市、星期、UTC 偏移
右侧 当前时间和日期

十二、顶部新增城市表单

12.1 Add City 卡片

顶部输入区是一张 Card:

Card(
  margin: const EdgeInsets.all(16),
  color: Colors.indigo.shade50,
  child: Padding(
    padding: const EdgeInsets.all(16),
    child: Column(...),
  ),
)

12.2 城市输入框

城市名使用普通文本输入:

TextField(
  controller: _cityController,
  decoration: InputDecoration(
    labelText: 'City',
    border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
  ),
)

12.3 UTC 偏移输入框

偏移输入框使用数字键盘:

TextField(
  controller: _offsetController,
  keyboardType: TextInputType.number,
  decoration: InputDecoration(
    labelText: 'UTC+',
    border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
  ),
)

12.4 添加按钮

添加按钮使用图标按钮:

IconButton(
  onPressed: _addLocation,
  icon: const Icon(Icons.add_circle, color: Colors.indigo),
)

十三、日期时间展示的同步性

13.1 每个城市都独立计算

页面列表中每个城市都会单独调用时间、日期、星期和图标计算方法,因此不会共享缓存状态。

13.2 计算入口统一

所有时间展示都以 DateTime.now().toUtc() 为入口:

final now = DateTime.now().toUtc();

13.3 统一入口的好处

这样可以保证同一帧内各城市都基于同一个 UTC 参考点计算,逻辑清楚。

13.4 城市展示表

展示项 计算方式
时间 UTC + offset
日期 localTime.day/month/year
星期 localTime.weekday 映射
图标 hour 区间判断

十四、鸿蒙适配关注点

14.1 为什么适配风险较低

world_clock 主要由 Flutter 标准组件和 Dart 逻辑构成,不依赖数据库、网络、定位、系统时钟插件或平台通道,因此基础适配风险较低。

模块 是否依赖平台能力 适配关注度
时间计算 Dart 标准库
列表展示 Flutter 标准组件
文本输入 Flutter 标准输入
滚动布局 Flutter 标准组件
本地持久化 当前未实现

14.2 输入法与软键盘

鸿蒙设备上需要重点验证:

  • 城市名输入是否顺畅。
  • 数字键盘是否正常弹出。
  • 输入框在软键盘弹出后是否仍可见。
  • 添加后输入框清空是否正常。

14.3 列表适配

每个城市卡片包含三列信息:图标、城市信息、时间信息。小屏设备上要重点观察文字是否换行、是否出现溢出。

14.4 数据边界

当前城市列表只保存在内存中,应用重启后新增城市不会保留。若要做正式世界时钟工具,应增加本地存储或同步能力。

当前项目适合作为 Flutter 世界时钟样例,不应理解为具备长期数据保存和系统时区管理能力的完整产品。

十五、测试设计与默认测试改造

15.1 当前测试入口

项目中的测试文件仍是默认计数器测试。对于世界时钟应用,更有价值的是验证初始城市列表、新增城市、删除城市和时间展示。

15.2 初始页面测试

testWidgets('world clock renders initial cities', (WidgetTester tester) async {
  await tester.pumpWidget(const MyApp());

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

15.3 添加城市测试

testWidgets('can add a city', (WidgetTester tester) async {
  await tester.pumpWidget(const MyApp());

  await tester.enterText(find.byType(TextField).first, 'Paris');
  await tester.enterText(find.byType(TextField).last, '1');
  await tester.tap(find.byIcon(Icons.add_circle));
  await tester.pump();

  expect(find.text('Paris'), findsOneWidget);
});

15.4 删除城市测试

testWidgets('can delete a city', (WidgetTester tester) async {
  await tester.pumpWidget(const MyApp());

  await tester.tap(find.byIcon(Icons.delete).first);
  await tester.pump();

  expect(find.text('New York'), findsNothing);
});

15.5 时间格式测试

如果把 _getTime() 抽成纯函数,可以直接测试 HH:mm:ss 格式是否正确。

十六、可维护性优化方向

16.1 抽离城市模型

当前使用 Map 保存城市,简单但类型不够明确。可以抽成模型:

class WorldCity {
  const WorldCity({
    required this.name,
    required this.timezone,
    required this.offset,
  });

  final String name;
  final String timezone;
  final int offset;
}

16.2 增加本地持久化

如果希望新增城市在重启后保留,可以考虑:

方案 适合场景
SharedPreferences 少量偏好设置
SQLite 结构化城市列表
文件存储 简单导入导出
云同步 多设备同步

16.3 统一时间计算方法

当前 _getTime()_getDate()_getDayName() 都重复计算 localTime。可以抽出公共方法,减少重复。

16.4 增加搜索与排序

若城市数量增多,可以支持按城市名搜索、按时差排序或按当前时间排序。

十七、功能扩展方向

17.1 增加真实时区支持

现在项目只使用 UTC 偏移,不处理夏令时和复杂时区规则。后续可以引入更完整的时区库。

17.2 增加世界地图或城市分组

可以按洲、国家或区域分组展示城市,提高城市多时的可读性。

17.3 增加收藏城市

可以把常看的城市置顶或收藏,减少滚动查找。

17.4 增加自动刷新

当前界面是构建时计算时间,如果要让秒数持续跳动,可以增加定时刷新机制。

十八、常见问题与优化建议

18.1 为什么城市名不能为空

源码在 _addLocation() 中判断:

if (_cityController.text.isEmpty) return;

城市名为空时不会新增记录。

18.2 为什么偏移为空时默认为 0

使用:

int.tryParse(_offsetController.text) ?? 0;

这样可以避免非法输入导致异常。

18.3 为什么列表里没有定时自动刷新

当前项目是手动构建刷新时间显示,没有引入定时器。它适合作为时间换算演示,但不是严格的实时时钟产品。

18.4 为什么图标和颜色根据小时变化

这样能帮助用户快速判断城市处于白天还是夜晚,提高可读性。

18.5 鸿蒙适配最应该关注什么

重点关注数字输入、列表滚动、卡片布局、软键盘遮挡和新增删除交互。当前项目没有持久化,新增城市不会在重启后保留。

十九、完整流程复盘

19.1 页面启动流程

main()
  -> runApp(MyApp)
  -> MaterialApp
  -> MyHomePage
  -> initState 设置默认城市
  -> build 渲染列表

19.2 新增城市流程

输入城市名
  -> 输入 UTC 偏移
  -> 点击 add
  -> 校验城市名
  -> 解析偏移
  -> 添加到 _locations
  -> 清空输入框

19.3 删除城市流程

点击删除按钮
  -> _removeLocation(index)
  -> removeAt 删除记录
  -> setState 刷新列表

19.4 时间展示流程

读取 UTC 时间
  -> 加上偏移
  -> 格式化为时间、日期、星期
  -> 根据小时计算颜色和图标
  -> 刷新城市卡片

二十、相关资源与继续学习

20.1 Flutter 学习资源

世界时钟涉及列表、输入、时间计算和测试,可以结合以下资源学习:

资源 内容
Flutter Docs Flutter 官方开发文档
Dart 官方文档 Dart 语言与核心库
Widget catalog Flutter 常用组件
Flutter testing Widget 测试与交互模拟

20.2 世界时钟扩展方向

后续可以继续增强:

  • 更真实的时区库。
  • 自动刷新秒数。
  • 城市收藏。
  • 城市分组。
  • 搜索和排序。
  • 本地持久化。
  • 深色模式优化。

20.3 跨端实践价值

world_clock 很适合作为 Flutter 适配鸿蒙的小型时间工具样例。它依赖很轻,但覆盖了输入、列表、时间换算、昼夜视觉和滚动布局,能帮助开发者验证很多跨端基础能力。

总结

world_clock 用简洁的 Flutter 代码实现了一个世界时钟列表工具。它通过 _locations 保存城市信息,通过 UTC 偏移换算本地时间,通过 _getDate()_getDayName() 展示日期与星期,通过 _getTimeColor()_getTimeIcon() 提供昼夜视觉提示,并支持新增和删除城市。

从工程角度看,这个项目最值得学习的是“城市列表 + UTC 偏移 + 昼夜视觉反馈”的组合方式。面向鸿蒙适配时,项目依赖较轻,主要需要验证数字输入、列表滚动、时间展示和触摸反馈。对于想学习 Flutter 时间类应用的开发者来说,它是一个清晰、实用且容易扩展的案例。

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


相关资源:

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐