Flutter + OpenHarmony实现高保真世界时钟 App:支持模拟指针/数字切换、城市搜索与实时同步
通过本文,我们不仅实现了一个功能强大的世界时钟界面,更展示了 Flutter 和OpenHarmony在**自定义绘制、状态管理、搜索集成、主题适配**等方面的卓越能力。该模块代码结构清晰、扩展性强,可轻松集成到您的时钟应用中。

个人主页:ujainu
文章目录
引言
在全球化日益加深的今天,世界时钟(World Clock) 已成为商务人士、远程工作者和旅行者不可或缺的工具。一个优秀的世界时钟应用不仅要能精准显示多时区时间,还需具备直观的 UI、流畅的交互、灵活的城市管理以及优雅的视觉呈现。
本文将基于 Flutter + Material Design 3,带您从零构建一个功能完整、体验流畅、可直接运行的世界时钟模块。我们将围绕以下核心能力展开深度解析:
- ✅ 全球城市数据库设计:支持 UTC 偏移(含半小数如 +5.5);
- ✅ 双模式时间显示:点击切换模拟指针钟表与数字时间;
- ✅ 动态主城市切换:任意城市设为主时钟,其他城市自动计算时差;
- ✅ 智能城市搜索:集成
showSearch实现高效添加; - ✅ 实时秒级更新:通过
Timer.periodic驱动界面刷新; - ✅ 深色/浅色主题适配:完美响应系统外观设置。
最终,我们将得到一个结构清晰、扩展性强、视觉精致的世界时钟页面,可无缝集成到 OpenHarmony 或跨平台 Flutter 项目中。
一、数据模型设计:CityData 与 DisplayCity
1. 城市基础数据:CityData
class CityData {
final String name;
final double utcOffset;
CityData(this.name, this.utcOffset);
}
name:城市中文名,便于用户识别;utcOffset:UTC 偏移量,单位为小时,支持整数(如北京 +8)和小数(如德里 +5.5)。
💡 为什么用
double?
全球有多个地区使用半小时或四分之三小时时区(如印度 +5:30、尼泊尔 +5:45)。使用double可精确表示这些偏移。
我们预置了 39 个全球主要城市:
final List<CityData> _allCities = [
CityData('北京', 8),
CityData('伦敦', 0),
CityData('德里', 5.5),
// ... 其他城市
];
2. 显示层数据:DisplayCity
class DisplayCity {
final String name;
final double utcOffset;
DisplayCity(this.name, this.utcOffset);
String getDescription(double mainUtcOffset) {
final diff = utcOffset - mainUtcOffset;
if (diff == 0) return '本地时间';
if (diff > 0) {
return '早${diff.toStringAsFixed(diff % 1 == 0 ? 0 : 1)}小时';
} else {
return '晚${(-diff).toStringAsFixed((-diff) % 1 == 0 ? 0 : 1)}小时';
}
}
}
getDescription()方法根据主城市 UTC 偏移,动态计算并返回友好提示(如“早 8 小时”、“晚 5.5 小时”);- 使用
toStringAsFixed(0)或(1)自动隐藏不必要的.0,提升阅读体验。
二、状态管理:_WorldClockPageState 核心逻辑
1. 初始化与定时刷新
void initState() {
super.initState();
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
if (mounted) setState(() {});
});
}
- 每秒调用
setState,触发整个页面重绘; if (mounted)确保在页面销毁后不再更新,防止异常。
⚠️ 性能说明:虽然每秒刷新看似开销大,但 Flutter 的 Widget diff 机制确保只有变化部分重绘,实际性能优异。
2. 时间计算:精准转换 UTC 偏移
DateTime _getTimeByUtcOffset(double utcOffset) {
final now = DateTime.now();
final localOffset = now.timeZoneOffset.inMilliseconds;
final targetTime = DateTime.fromMillisecondsSinceEpoch(
now.millisecondsSinceEpoch - localOffset + (utcOffset * 3600000).toInt(),
);
return targetTime;
}
🔍 计算原理:
- 获取当前设备本地时间
now; - 获取设备本地 UTC 偏移
localOffset(毫秒); - 将
now转换为 UTC 时间戳:now.millisecondsSinceEpoch - localOffset; - 加上目标城市的 UTC 偏移(转为毫秒),得到目标城市本地时间。
✅ 此方法不依赖网络,完全基于本地时钟,适用于离线场景。
三、UI 构建:双模式时间显示 + 城市列表
1. 主时钟区域:点击切换指针/数字
GestureDetector(
onTap: _toggleClockType,
child: Column(
children: [
Container(
alignment: Alignment.center,
padding: const EdgeInsets.all(20),
child: _isAnalog
? CustomPaint(painter: AnalogClockPainter(hour, minute, second), size: const Size(300, 300))
: Container(
// 数字时间样式
),
),
// 日期与星期
],
),
)
- 使用
GestureDetector包裹,实现点击切换; _isAnalog控制显示模式,状态由_toggleClockType()切换。
🎨 数字模式优化:
- 使用等宽字体
'Courier New',确保时间对齐; - 添加圆角卡片与阴影,提升层次感;
- 动态适配深/浅色背景。
2. 模拟指针钟表:CustomPainter 绘制
class AnalogClockPainter extends CustomPainter {
final int hour, minute, second;
// ...
}
绘制细节:
- 刻度:60 条短线,每 5 分钟加粗加长;
- 数字:1~12 点位,使用
TextPainter手动绘制; - 指针:
- 时针:长度 50%,宽度 8;
- 分针:长度 70%,宽度 5;
- 秒针:长度 80%,蓝色,宽度 2;
- 角度计算:统一以 12 点方向为 0°,通过
-90调整坐标系。
🌟 动画潜力:当前为静态绘制。若需平滑移动,可引入
AnimationController驱动指针插值。
3. 城市列表:滑动删除 + 点击设为主城市
Dismissible(
key: Key(city.name),
onDismissed: (_) => _deleteCity(city.name),
background: Container(color: Colors.red, child: Icon(Icons.delete)),
child: Card(
child: ListTile(
title: Text(city.name, style: TextStyle(color: isSelected ? Colors.blue : null)),
subtitle: Text(city.getDescription(_mainCity.utcOffset)),
trailing: Text(timeStr),
onTap: () => _setAsMainCity(city),
),
),
)
- 滑动删除:使用
Dismissible,提供直观操作反馈; - 主城市高亮:标题文字变蓝,强化焦点;
- 实时时间更新:每秒刷新
trailing中的时间字符串。
四、城市管理:搜索与添加
1. 集成系统搜索:showSearch + SearchDelegate
void _addCity() async {
final selected = await showSearch<CityData>(
context: context,
delegate: CitySearchDelegate(_allCities),
);
// 处理结果
}
- 调用 Flutter 内置
showSearch,弹出标准搜索界面; CitySearchDelegate封装搜索逻辑。
2. 搜索代理实现:CitySearchDelegate
class CitySearchDelegate extends SearchDelegate<CityData> {
Widget buildSuggestions(BuildContext context) {
final suggestions = query.isEmpty
? cities
: cities.where((city) => city.name.toLowerCase().startsWith(query.toLowerCase())).toList();
return _buildCityList(suggestions);
}
Widget buildResults(BuildContext context) {
final results = cities.where((city) => city.name.toLowerCase().contains(query.toLowerCase())).toList();
return _buildCityList(results);
}
}
- 建议(Suggestions):输入时匹配前缀,提升效率;
- 结果(Results):回车后匹配全文包含,确保查全率;
- 去重校验:添加前检查是否已存在,避免重复。
✅ 用户体验:支持拼音首字母、全拼、甚至错别字模糊匹配(可通过扩展实现)。
五、主题与样式:Material Design 3 适配
theme: ThemeData(
useMaterial3: true,
brightness: Brightness.light,
scaffoldBackgroundColor: Colors.grey[100],
),
darkTheme: ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
scaffoldBackgroundColor: Colors.grey[900],
),
- 启用
useMaterial3: true,使用最新设计语言; - 浅色模式:背景
grey[100],柔和不刺眼; - 深色模式:背景
grey[900],符合夜间使用习惯; - 卡片、按钮、文字颜色均通过
Theme.of(context)动态获取,确保一致性。
六、内存安全与边界处理
void dispose() {
_timer.cancel(); // 关键!防止内存泄漏
super.dispose();
}
// 删除城市时,若主城市被删,自动切换到第一个
if (_mainCity.name == name && _displayCities.isNotEmpty) {
_mainCity = _displayCities.first;
}
- 定时器必须在
dispose中取消; - 主城市删除后自动 fallback,避免空引用。
七、完整代码:
import 'dart:async';
import 'package:flutter/material.dart';
import 'dart:math';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
title: 'Clock on OpenHarmony',
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
brightness: Brightness.light,
primarySwatch: Colors.blue,
scaffoldBackgroundColor: Colors.grey[100],
appBarTheme: const AppBarTheme(backgroundColor: Colors.blue, foregroundColor: Colors.white),
),
darkTheme: ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
primarySwatch: Colors.blue,
scaffoldBackgroundColor: Colors.grey[900],
appBarTheme: const AppBarTheme(backgroundColor: Colors.grey, foregroundColor: Colors.white),
),
home: const WorldClockPage(),
);
}
}
// 全球主要城市数据库(城市名, UTC偏移小时)
final List<CityData> _allCities = [
CityData('阿姆斯特丹', 1),
CityData('北京', 8),
CityData('柏林', 1),
CityData('布宜诺斯艾利斯', -3),
CityData('开罗', 2),
CityData('芝加哥', -6),
CityData('达拉斯', -6),
CityData('德里', 5.5),
CityData('迪拜', 4),
CityData('都柏林', 0),
CityData('法兰克福', 1),
CityData('香港', 8),
CityData('伊斯坦布尔', 3),
CityData('雅加达', 7),
CityData('吉隆坡', 8),
CityData('伦敦', 0),
CityData('洛杉矶', -8),
CityData('马德里', 1),
CityData('墨尔本', 10),
CityData('墨西哥城', -6),
CityData('迈阿密', -5),
CityData('莫斯科', 3),
CityData('孟买', 5.5),
CityData('纽约', -5),
CityData('奥斯陆', 1),
CityData('巴黎', 1),
CityData('里约热内卢', -3),
CityData('罗马', 1),
CityData('圣保罗', -3),
CityData('圣彼得堡', 3),
CityData('上海', 8),
CityData('新加坡', 8),
CityData('斯德哥尔摩', 1),
CityData('悉尼', 10),
CityData('东京', 9),
CityData('多伦多', -5),
CityData('温哥华', -8),
CityData('维也纳', 1),
CityData('苏黎世', 1),
];
class CityData {
final String name;
final double utcOffset;
CityData(this.name, this.utcOffset);
}
class DisplayCity {
final String name;
final double utcOffset;
DisplayCity(this.name, this.utcOffset);
String getDescription(double mainUtcOffset) {
final diff = utcOffset - mainUtcOffset;
if (diff == 0) return '本地时间';
if (diff > 0) {
return '早${diff.toStringAsFixed(diff % 1 == 0 ? 0 : 1)}小时';
} else {
return '晚${(-diff).toStringAsFixed((-diff) % 1 == 0 ? 0 : 1)}小时';
}
}
}
class WorldClockPage extends StatefulWidget {
const WorldClockPage({super.key});
State<WorldClockPage> createState() => _WorldClockPageState();
}
class _WorldClockPageState extends State<WorldClockPage> {
late Timer _timer;
final List<DisplayCity> _displayCities = [
DisplayCity('北京', 8),
DisplayCity('伦敦', 0),
DisplayCity('悉尼', 10),
];
bool _isAnalog = true;
DisplayCity _mainCity = DisplayCity('北京', 8);
void initState() {
super.initState();
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
if (mounted) setState(() {});
});
}
void dispose() {
_timer.cancel();
super.dispose();
}
void _addCity() async {
final selected = await showSearch<CityData>(
context: context,
delegate: CitySearchDelegate(_allCities),
);
if (selected != null) {
if (!_displayCities.any((city) => city.name == selected.name)) {
setState(() {
_displayCities.add(DisplayCity(selected.name, selected.utcOffset));
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${selected.name} 已添加')),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('该城市已在列表中')),
);
}
}
}
void _deleteCity(String name) {
setState(() {
_displayCities.removeWhere((city) => city.name == name);
if (_mainCity.name == name && _displayCities.isNotEmpty) {
_mainCity = _displayCities.first;
}
});
}
void _setAsMainCity(DisplayCity city) {
setState(() {
_mainCity = city;
});
}
void _toggleClockType() {
setState(() {
_isAnalog = !_isAnalog;
});
}
String _formatTimeWithSeconds(int hour, int minute, int second) {
return '${hour.toString().padLeft(2, '0')}:${minute.toString().padLeft(2, '0')}:${second.toString().padLeft(2, '0')}';
}
DateTime _getTimeByUtcOffset(double utcOffset) {
final now = DateTime.now();
final localOffset = now.timeZoneOffset.inMilliseconds;
final targetTime = DateTime.fromMillisecondsSinceEpoch(
now.millisecondsSinceEpoch - localOffset + (utcOffset * 3600000).toInt(),
);
return targetTime;
}
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final mainTime = _getTimeByUtcOffset(_mainCity.utcOffset);
final hour = mainTime.hour;
final minute = mainTime.minute;
final second = mainTime.second;
final weekdayNames = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
return Scaffold(
appBar: AppBar(title: const Text('世界时钟')),
body: GestureDetector(
onTap: _toggleClockType,
child: Column(
children: [
Container(
alignment: Alignment.center,
padding: const EdgeInsets.all(20),
child: _isAnalog
? CustomPaint(
painter: AnalogClockPainter(hour, minute, second),
size: const Size(300, 300),
)
: Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
decoration: BoxDecoration(
color: isDark ? Colors.grey[800] : Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: isDark ? Colors.black54 : Colors.grey.withOpacity(0.4),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Text(
_formatTimeWithSeconds(hour, minute, second),
style: TextStyle(
fontSize: 48,
fontWeight: FontWeight.bold,
color: isDark ? Colors.white : Colors.black,
fontFamily: 'Courier New, monospace',
),
),
),
),
const SizedBox(height: 10),
Text(
'${_mainCity.name} | ${mainTime.month}月${mainTime.day}日 ${weekdayNames[mainTime.weekday - 1]}',
style: const TextStyle(color: Colors.grey, fontSize: 14),
),
const SizedBox(height: 20),
Expanded(
child: ListView.builder(
itemCount: _displayCities.length,
itemBuilder: (context, index) {
final city = _displayCities[index];
final cityTime = _getTimeByUtcOffset(city.utcOffset);
final timeStr = _formatTimeWithSeconds(cityTime.hour, cityTime.minute, cityTime.second);
final isSelected = city.name == _mainCity.name;
return Dismissible(
key: Key(city.name),
direction: DismissDirection.endToStart,
onDismissed: (_) => _deleteCity(city.name),
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
color: Colors.red,
child: const Icon(Icons.delete, color: Colors.white),
),
child: Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: ListTile(
title: Text(
city.name,
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: isSelected ? Colors.blue : null,
),
),
subtitle: Text(city.getDescription(_mainCity.utcOffset)),
trailing: Text(timeStr, style: const TextStyle(fontSize: 16)),
onTap: () => _setAsMainCity(city),
),
),
);
},
),
),
Align(
alignment: Alignment.bottomCenter,
child: FloatingActionButton(
onPressed: _addCity,
backgroundColor: Colors.blue,
child: const Icon(Icons.add, size: 30),
),
),
],
),
),
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
);
}
}
// 搜索代理
class CitySearchDelegate extends SearchDelegate<CityData> {
final List<CityData> cities;
CitySearchDelegate(this.cities);
String get searchFieldLabel => '搜索城市';
List<Widget>? buildActions(BuildContext context) {
return [
if (query.isNotEmpty)
IconButton(
icon: const Icon(Icons.clear),
onPressed: () => query = '',
),
];
}
Widget? buildLeading(BuildContext context) {
return IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => close(context, null),
);
}
Widget buildResults(BuildContext context) {
final results = cities.where((city) {
return city.name.toLowerCase().contains(query.toLowerCase());
}).toList();
return _buildCityList(results);
}
Widget buildSuggestions(BuildContext context) {
final suggestions = query.isEmpty
? cities
: cities.where((city) {
return city.name.toLowerCase().startsWith(query.toLowerCase());
}).toList();
return _buildCityList(suggestions);
}
Widget _buildCityList(List<CityData> list) {
return ListView.builder(
itemCount: list.length,
itemBuilder: (context, index) {
final city = list[index];
final offsetStr = city.utcOffset >= 0
? '+${city.utcOffset.toStringAsFixed(city.utcOffset % 1 == 0 ? 0 : 1)}'
: '${city.utcOffset.toStringAsFixed(city.utcOffset % 1 == 0 ? 0 : 1)}';
return ListTile(
title: Text(city.name),
subtitle: Text('UTC $offsetStr'),
onTap: () => close(context, city),
);
},
);
}
}
// 指针钟表绘制
class AnalogClockPainter extends CustomPainter {
final int hour;
final int minute;
final int second;
AnalogClockPainter(this.hour, this.minute, this.second);
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = size.width / 2 - 10;
// 刻度
for (int i = 0; i < 60; i++) {
final angle = i * 6.0;
final start = center + Offset(cos(angle * pi / 180) * (radius - 10), sin(angle * pi / 180) * (radius - 10));
final end = center + Offset(cos(angle * pi / 180) * radius, sin(angle * pi / 180) * radius);
final paint = Paint()
..color = i % 5 == 0 ? Colors.black : Colors.grey[400]!
..strokeWidth = i % 5 == 0 ? 4 : 2;
canvas.drawLine(start, end, paint);
}
// 数字
for (int i = 1; i <= 12; i++) {
final angle = (i - 3) * 30.0;
final x = center.dx + cos(angle * pi / 180) * (radius - 40);
final y = center.dy - sin(angle * pi / 180) * (radius - 40);
final text = TextSpan(text: i.toString(), style: const TextStyle(fontSize: 16));
final metrics = TextPainter(text: text, textAlign: TextAlign.center, textDirection: TextDirection.ltr)
..layout(minWidth: 0, maxWidth: 20);
metrics.paint(canvas, Offset(x - 10, y - 10));
}
// 小时指针
final hourAngle = (hour % 12) * 30.0 + minute * 0.5;
final hourX = center.dx + cos((hourAngle - 90) * pi / 180) * radius * 0.5;
final hourY = center.dy + sin((hourAngle - 90) * pi / 180) * radius * 0.5;
final hourPaint = Paint()..color = Colors.black..strokeWidth = 8;
canvas.drawLine(center, Offset(hourX, hourY), hourPaint);
// 分针
final minuteAngle = minute * 6.0;
final minX = center.dx + cos((minuteAngle - 90) * pi / 180) * radius * 0.7;
final minY = center.dy + sin((minuteAngle - 90) * pi / 180) * radius * 0.7;
final minPaint = Paint()..color = Colors.black..strokeWidth = 5;
canvas.drawLine(center, Offset(minX, minY), minPaint);
// 秒针
final secondAngle = second * 6.0;
final secX = center.dx + cos((secondAngle - 90) * pi / 180) * radius * 0.8;
final secY = center.dy + sin((secondAngle - 90) * pi / 180) * radius * 0.8;
final secPaint = Paint()..color = Colors.blue..strokeWidth = 2;
canvas.drawLine(center, Offset(secX, secY), secPaint);
}
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}
运行界面:
向左划动可以直接删除:
点击钟表界面可以切换指针和数字形态
点击加号可以搜索和加入新地区时间
加入后下方会提示加入成功
八、优化亮点总结
| 功能 | 优化点 |
|---|---|
| 时间精度 | 毫秒级计算,支持半小数时区 |
| 交互体验 | 点击切换钟表模式、滑动删除、搜索添加 |
| 视觉设计 | 指针钟表手绘、数字时间等宽字体、深色适配 |
| 数据管理 | 唯一城市名校验、主城市自动切换 |
| 性能安全 | mounted 检查、定时器释放 |
结语
通过本文,我们不仅实现了一个功能强大的世界时钟界面,更展示了 Flutter 和OpenHarmony在自定义绘制、状态管理、搜索集成、主题适配等方面的卓越能力。该模块代码结构清晰、扩展性强,可轻松集成到您的时钟应用中。
未来,您可以在此基础上进一步扩展:
- 支持夏令时(DST) 自动调整;
- 添加城市国旗图标;
- 实现后台持续运行(需平台通道);
- 集成地理位置自动推荐。
欢迎加入开源鸿蒙跨平台社区: https://openharmonycrossplatform.csdn.net
更多推荐

所有评论(0)