世界时钟App开发手记:从能用长成想用(SwiftUI独立开发)
世界时钟App开发手记:从能用长成想用(SwiftUI独立开发) · Jason · 2026-06-23
我最初做这个 App 的原因很简单: 我的网站大部分用户是海外用户,我想有一个随时可以看到的时钟,能看到用户所在国家的当前时间; 作为独立开发者,现在一天大部分时间坐在电脑前,我需要一个番茄时钟,到点提醒我起来站立、运动。 就这么简单的需求,但真正动手之后,它一路长出了国旗、八种语言、六套主题、实时天气、番茄钟,最后还多出一只要喂养的电子宠物,并且从我的 Mac 搬进了一台 2018 年的 iPad Pro。 这篇文章是我做这个APP应用的开发手记: 每一步 为什么这么做 ,以及 具体是怎么做的 。 我会尽量把决策背后的工程权衡也写出来,这些不起眼的选择,也许才是一个工具从「能用」走到「想用」的真正分水岭。 整个项目用 SwiftUI 写成,开发工具是CodeX和Claude Code,一套代码同时跑在 macOS 13+ 和 iPadOS 16+ 上,约 6400 行 Swift,没有引入任何第三方依赖。 起点:一张城市时间表,和它定下的承重墙 最初的设想非常朴素:左边一列国家,点中国,右边就亮起北京、上海、广州、成都、乌鲁木齐五张卡片,每张写着城市名、时区、此刻的时间与日期。 功能虽然简单,但这一版相当于定下了整座房子的承重墙,而且有几处选择后来证明很要紧。 第一,时间用城市自己的时区算,绝不用本地时间硬凑。 数据模型 CityClock 里,每座城市都认领一个 IANA 时区标识符( Asia/Shanghai 、 America/New_York ……),时间由这个时区推导,而不是「本地时间 ± 偏移」。这看似多此一举,但它是诚实与偷懒的分界:偏移量会被夏令时打乱,纽约一年里有半年是 -5、半年是 -4,硬算偏移迟早出错;交给系统的时区数据库,则永远是对的。 第二,每秒刷新,但只用一个时钟源。 整个 App 只有一个 Timer.publish(every: 1) ,由顶层 ContentView 持有。它每秒推进一次「当前时间」,同时驱动番茄钟、宠物状态、整点报时——所有需要「随时间走动」的模块共用这一个心跳,而不是各自起一个定时器。这让状态始终同步,也省去了多定时器之间的对齐麻烦。 第三,选择与展示分离。 左侧 SidebarView 只负责「选哪个国家、哪种语言、哪套主题」,右侧 CityTimesView 只负责「把选中的东西展示好」。这套用 NavigationSplitView 实现的左右分栏,是后来能顺利搬上 iPad 的关键——大屏幕天生适合这种布局。 第1版其实已经可以用了,最初设定的功能已经完整实现,但作为一个自用+练手的项目,我还是想把它做的完整和精美一些。于是有了后面的持续开发。 第一次打磨:界面不该只是一张表 最早的样子工具气太重。国家是一列名字,时间是一行数字,规整,却无趣——一个通篇只有表格和数字的世界时钟,会无聊到让人想立刻关掉。 于是我开始往里注入层次。把顶部做成一张大卡片,让当前国家的时间占据视觉中心;给城市卡片加上渐变与阴影,让它们从平面里浮起来;放进一只指针会走动的模拟时钟( AnalogClockView ),让时间不再只是冷硬的数字。 更重要的是,背景不再是死板的纯色,而是 跟着当地的「时段」呼吸 。我写了一个 ClockInsights ,把一天按小时切成清晨(5–10 点)、白天(10–17 点)、傍晚(17–21 点)、夜间四个阶段;再由 SkyPalette 为每个阶段给出一组渐变色——清晨偏暖橙,白天是明快的天蓝,傍晚转紫红,夜间沉成深蓝。于是你选中一座城市,背景就大致是那里此刻天空的颜色。这套配色刻意调过对比度,保证任何时段下白色文字都读得清楚。 这一步看着是「美化」,其实是转身。它让 App 从一台数据显示器,变成了一个可以被「浏览」的空间。 国旗:一个比缩写更稳的视觉锚点 一位用户提了个看似微小的建议:国家图标,应该以国旗为主。 这是个不起眼却很要紧的改良。国家的名字会被语言折弯——「中国 / China / 中國」在不同语境下面目各异;而国旗是一个更稳、更直接的锚点,不需要翻译,扫一眼就认得。 实现上我用了一个小技巧,省去了打包十几张图片:国旗是从国家的两位代码 实时拼出来的 。Unicode 有一套「区域指示符号」,把两个字母对应的指示符拼在一起,系统就渲染成国旗。代码里就是把 CN 、 US 、 JP 这样的代码,逐字母加上 127397 的偏移量转成区域指示符。这样做有个额外的好处: 用户自己添加的国家,也能自动获得国旗 ,不用我为每个国家预先准备素材。(唯一的特例是英国——通用代码是 UK ,但国旗要用 GB ,单独处理一下即可。) 这类改动从不增加功能,只削减负担。好的界面常常如此:少让人「读」,多让人一眼「看懂」。 多语言:让「世界时钟」真的走向世界 接着我加上了语言切换,一口气支持八种:中文、English、Français、Deutsch、Español、日本語、한국어、Português。 这一步远比表面复杂。它不是把几个按钮翻译了事,而是要让界面文案、国家名、城市名、功能标签,全都随语言一起流转。我没有用一堆零散的字符串文件,而是写了一个 AppLanguage 枚举,核心是一个 select(zh:en:fr:de:es:ja:ko:pt:) 方法——每一处需要翻译的文案,都在调用点把八种语言一次性列齐。这样做的好处是: 漏翻一种语言,编译器当场就会报错 ,因为这八个参数全是必填的。「世界时钟」「主题色」「番茄钟」「宠物」「添加城市」「天气加载中」——每一句,都得在八种语言里各自找到自然的说法。 多语言还逼我重新审视空间。德语、法语、西班牙语的词常常比中文长出一截(「Sonnenuntergang」就比「日落」长得多),按钮和卡片如果排得太紧,一切换语言立刻局促。这反倒成了好事:它逼我把布局做得更有弹性,能容下最长的那种语言。 主题色:六套配色,不只是换张背景 世界时钟这类 App 往往长时间亮在屏幕一角,只有一种颜色,眼睛迟早会累。于是我做了六套主题:国家色、海岸蓝、日落橙、森林绿、极光紫、石墨灰。 这里我刻意没把「主题」做成「换张背景图」那么草率。每套主题在 ThemePalette 里都是一组 结构化的三色 ——主色、辅色、强调色,外加一个 SF Symbol 图标。换主题时,顶部主卡片的气质、城市卡片的渐变、选中态的颜色、标签与状态提示的视觉分量,都跟着一起变,而不是只动背景。 其中「国家色」这套尤其特别:它不是固定配色,而是 根据当前选中的国家动态生成 ——选中日本是一种气质,选中巴西又是另一种。其余五套则是稳定的固定配色,照顾不同口味和不同光线环境。做主题时我最在意的一点是:别让界面沦为单一颜色的堆叠。真正耐看的配色要有主色、辅色、背景的层次,和足够的文字对比——这些细节在 iPad 真机上会被屏幕亮度和环境光统统放大,藏不住。 天气:把时间接回现实世界 只显示时间,终究不够。我们查异地时间,往往不为时间本身,而为一个判断:现在打扰对方合不合适?那边是白昼还是深夜?所以城市卡片里长出了天气——温度、天气图标、风速。 天气数据来自 [Open-Meteo](https://open-meteo.com) 的免费接口,不需要 API key。 WeatherService 这个模块里藏了几处我比较得意的工程考量: 它 缓存 15 分钟 ——一条天气数据拿到后,15 分钟内不会重复请求,过期了才标记为「陈旧」并刷新;它用一个 inFlight 集合做 并发去重 ,同一座城市的请求绝不会同时发两次;接口返回的是 WMO 标准天气代码(一个表示「晴 / 多云 / 小雨 / 雷暴」的整数),我再把它映射成对应的 SF Symbol 图标和文案。请求超时设了 8 秒,失败会单独记录,UI 上能区分「加载中 / 已就绪 / 陈旧 / 失败 / 该城市无坐标」五种状态,而不是笼统地转圈。 这样一来,App 不再只回答「现在几点」,还顺口说出「那座城市此刻大致什么模样」。对一个世界时钟而言,这再自然不过。 番茄钟:从「看时间」到「管时间」 世界时钟解决的是「外部的时间」:别人的城市现在几点。番茄钟解决的是「我的时间」:我此刻该专注多久。于是我加了番茄钟模块——专注、短休、长休、跳过、重置,还会替你数着已经完成几轮专注,每四轮专注自动安排一次长休息。 PomodoroEngine 里有个容易被忽略、但很关键的设计: 它按真实流逝的墙上时间走,而不是「定时器触发了几次」 。每次 tick 我都记下上一次的时间戳,用两次的时间差来扣减剩余秒数。为什么要这么麻烦?因为如果 App 被切到后台、定时器被系统挂起,回到前台时按「触发次数」算就会少扣时间、计时不准;而按时间差算,哪怕中间漏了几拍,回来也能一次性补齐。专注配置(25/5/15 分钟、四轮一长休)都持久化在 UserDefaults ,下次打开还在;而且我特意让「修改设置」不会打断一个正在进行中的专注——改了时长只对下一段生效。 这个功能悄悄改变了 App 的身份:它不再只是被动报时,而开始主动帮人安排时间。一个看世界的时间,一个守自己的时间,二者并肩,意外地般配。 宠物:把工具养成一个小小的伙伴 后来我又把宠物做成了一个轻量的养成小游戏,藏在左下角一隅。 这只小家伙有四项随时间衰减的状态:饱腹、开心、精力、洁净;外加等级、金币、奖励零食和一个由前四项加权算出的「心情」。 PetCareStore 用一套 离线衰减 模型来维持它的「生命感」:每次打开 App,它会按上次记录到现在过了多少分钟,相应地把各项状态往下调一点——也就是说,你不管它,它真的会慢慢饿、慢慢累、慢慢脏。你可以喂食、陪玩、清洁、让它休息,每个动作对各项状态的影响都不一样(陪玩涨开心但耗精力、也消耗一点饱腹,喂食回饱腹还顺带消费一枚金币)。 最让我满意的是它和番茄钟的咬合: 每完成一轮专注,宠物就得到奖赏 ——26 点经验、3 枚金币、一份零食,外加一点开心值。这背后藏着一点心思:别让番茄钟只是冰冷的倒计时。倒计时容易催生焦虑,而一只等着被奖励的宠物,让「专注」这件事多了一丝陪伴的温度。它不是主角,却让整个 App 有了脾气和性格。这套养成场景( PomodoroPetSceneView )也是全项目里最重的一块,光它一个文件就有一千多行。 从 Mac 到 iPad:真正的考验才开始 Mac 版稳了之后,我起了念头:让它也跑在 iPad Pro 上。我手上这台是 2018 款,系统停在 iPadOS 16.7.16。这串数字很要紧——新机器、新系统跑得动,从不等于老设备也跑得舒服,这两者之间的距离,正是开发者最该谦卑的地方。 得益于一开始就用 SwiftUI 和分栏布局,主体代码几乎可以原样复用,但「跨设备」绝不是「换个运行目标」那么轻巧。我做了这么几件事: 为 iPad 单独起了一个 Xcode 工程( RealtimeWorldClock-iPad.xcodeproj ),最低系统压到 iPadOS 16.0、稳稳兜住手上的 16.7.16,设备族设为 iPad( TARGETED_DEVICE_FAMILY = 2 )。然后逐一调校 Mac 与 iPad 的分歧——这些差异都藏在不起眼的角落:音效在两个平台的播放方式不同(macOS 用 AppKit,iOS 走 AudioToolbox,我用 #if os(macOS) 把它们分开);系统背景色的取法不同,所以专门抽了一个 PlatformColors ;窗口行为、整点报时时请求用户注意的方式( NSApp.requestUserAttention 只在 Mac 上有意义)也都得分别处理。同时还要确认那两个时钟音效的资源文件 clock_tick.wav / clock_tock.wav 真的被打进了 iPad 的 App 包里,并分别在模拟器和真机两条构建路上各验一遍——而且死死守住 Mac 版的测试,不让任何一次「为 iPad」的改动反手弄坏原来的它。 魔鬼,从来住在这些细节里。 装进真机:最磨人的不是代码,是签名 iPad 和 Mac 脾性不同。Mac 上你能直接生成一个 .app 拖进去就装好;可 iPad 必须过 Apple 签名与授权这道关。我起初也试着导出一个 .ipa ,但很快认清现实:没有签名,这文件压根没法直接拷进 iPad。 真正把它装上 iPad Pro 的路是这样走的:在 Xcode 里登录 Apple 账号 → 选中那台真实的 iPad(而非模拟器)→ 在 iPad 上开启开发者模式 → 同意 Apple 开发者协议 → 让 Xcode 自动生成安装授权 → 点运行,把 App 送进设备。 途中撞上三个很典型、也很容易劝退人的坎: 一是 Xcode 默认跑去了模拟器 ,而那模拟器偏偏显示成「iPad Pro M5、iOS 26.4」。这极易误导人,让你以为自己那台 2018 老 iPad 根本不受支持——可那不过是个幻影,是模拟器,不是真机。把运行目标手动切到插着线的真实设备就好。 二是开发者模式 。iPad 须在「设置 → 隐私与安全性」里把它打开,重启后再确认一次,Xcode 才肯把 App 放进来。 三是那纸协议 。Xcode 弹出 PLA Update available ——开发者账号得点头同意最新条款,不解决它,设备就进不了安装授权的名单。 把这些一一抚平之后,App 终于稳稳落在了那台 2018 款 iPad Pro 上。 真机亮起的第一眼 跑起来那一刻,最直接的感受是:iPad 生来就适合这类 App。 左侧国家列表可以一直钉在那儿,右侧从容展开当前国家的城市时间。卡片够宽,信息密度恰到好处。比起手机,它不必把界面挤成窄窄一列;比起 Mac,它又更像一块可以随手搁在桌角的信息屏。 真机截图里一切各安其位:中国被选中,北京、上海、广州、成都、乌鲁木齐悉数亮起;每张卡片都备齐了时间、日期、天气与昼夜状态;左侧的语言、主题、国家列表、添加城市、宠物入口无一缺席;时间持续跳动,天气也如约而至。成就感是实打实的——它不再只是开发机上一扇虚拟的窗,而是真真切切活在一台老 iPad Pro 上,可以像任何一件日常工具那样被人随手拿起。 这趟旅程替我重新确认的几件事 其一,功能不在多,而在彼此有关系。 世界时钟、天气、番茄钟、宠物看似各自为政,其实都绕着「时间」打转:世界时钟是城市的时间,天气是城市的状态,番茄钟是个人的时间,宠物是专注的奖赏。圆心够清楚,半径再长也不会散。 其二,UI 不是最后才补的妆。 界面太素时,功能其实早已能用,可它就是不招人喜欢。等卡片、主题、国旗、随时段呼吸的背景一一就位,整个 App 的气质便脱胎换骨。界面从不是一层皮肤——它直接决定了用户愿不愿意留下来。 其三,看不见的工程,决定了用着顺不顺。 时区而非偏移、墙上时间而非定时器触发、天气的缓存与并发去重、宠物的离线衰减——这些用户永远不会注意到的选择,恰恰是「它怎么用都不出错」的底气。诚实的实现不会被看见,但偷懒的实现一定会被撞见。 其四,真机验证无可替代,安装流程也是产品的一部分。 模拟器能跑只说明一半真相;唯有放进那台 2018 款 iPad Pro,才知道字号、触控、卡片密度、边栏宽窄合不合适——设备从不说谎。而签名、协议、开发者模式这些对开发者熟门熟路的步骤,对普通人全是横在门口的障碍;若有朝一日想让更多人用上它,还是得走 TestFlight 或 App Store,让「安装」回归它本该有的轻盈。 接下来还能往哪里打磨 这一版已能在 iPad Pro 上稳稳运转,但可琢磨的地方仍多:为老款 iPad Pro 再细调字号与卡片间距;给宠物添上更多成长阶段与互动动画;为城市加入排序、收藏与常用分组;把天气的刷新状态做得更分明;做一枚更正式的图标与启动页;再借 TestFlight 分发,把安装门槛踏平一些。 如今的它,已从一只朴素的世界时钟,长成了一个有时间、有天气、有专注、亦有陪伴的小工具。 我喜欢这个变化。因为它印证了一件事:一个产品未必非要从一个宏大的念头出发。很多时候只需揣着一个微小的需求,持之以恒地追问一句——「它能不能再好用一点、再好看一点、再有一点生命力?」——它便会在你手里,慢慢长成一件真正值得被使用的东西。