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 秒更新一次 _currentTimemounted 用来判断当前 State 是否仍在组件树中,避免页面销毁后继续调用 setState()

6.3 刷新链路

  1. 等待 1 秒。
  2. 判断页面是否仍然挂载。
  3. 更新 _currentTime
  4. 触发页面重新构建。
  5. 所有城市时间重新计算。
  6. 继续下一轮循环。

定时刷新类页面一定要关注生命周期,否则页面退出后继续刷新,容易引发无效更新或异常。

七、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 运行验证要点

  1. 应用能正常启动到世界时钟页面。
  2. 本地时间每秒刷新。
  3. 城市卡片中的秒数同步变化。
  4. UTC+5:30 能正确展示。
  5. 网格在小屏下不明显溢出。
  6. 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 这类半小时偏移是否正确展示。

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


相关资源:

Logo

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

更多推荐