简介:WorldClock 是一份基于 Java 开发的安卓 TV 平台世界时钟应用源码,主要面向 Android TV 开发者、Java 学习者,以及想做电视端工具类项目的程序员。资源共 43 个文件,压缩包约 3.06MB,包含 21 个 xml 界面布局、10 个 java 核心逻辑、4 个 png 图标素材,还有 gradle 工程脚本、proguard 混淆规则、README 说明等基础文件,整体按标准工程结构组织,可直接导入 Android Studio 查看运行。目前已有 217 人学习浏览。代码围绕电视端时钟场景展开,覆盖遥控器导航交互、大屏横屏适配、世界城市时区管理、java.time 日期处理与夏令时换算、ScheduledExecutorService 定时刷新、异步加载及性能优化等关键点。透过这套源码,可以看懂安卓 TV 应用从界面搭建、业务实现到构建发布的完整链路,适合作为课程设计、个人练习或二次开发的基础。 这段时间一直在折腾安卓TV上的各种应用,手头正好有台刷了安卓TV系统的斐讯N1盒子,平时就当电视盒子用。有天看着待机画面,突然想让它变成一块大屏时钟,摆在客厅里既能看时间又有点装饰感。翻遍了应用市场里的时钟App,要么是手机版强行拉伸,要么花里胡哨带一堆广告,没一个真正按电视交互逻辑设计的。干脆自己动手写一个,这就是WorldClock这个项目的起因。
这个项目说白了就是一台运行在安卓TV上的世界时钟应用,核心功能是同时显示多个时区的当前时间,配合翻页钟、城市管理、世界地图这些玩法,让盒子在闲置时变成一块漂亮的信息屏。做这块的过程中踩了不少坑,尤其是遥控器焦点、字体渲染、时区数据这些细节,远比想象中麻烦。这篇文章就把整个从需求到落地的过程完整记录下来,给同样在折腾安卓TV开发的朋友一个参考。
1. 需求与设计:电视端时钟为什么不能照搬手机
1.1 先搞清楚“十英尺界面”到底别扭在哪
安卓TV和手机最大的区别,是用户操作距离。手机是眼睛凑到30厘米内,手指直接触控;电视是坐在沙发上,至少三米开外,手里只有一个遥控器。行业里把这种场景叫“十英尺界面”,它决定了所有设计都要围绕大字号、高对比度、焦点清晰这三个方向走。
很多手机时钟App移植到电视上后,第一眼看上去能用,但实际用起来全是问题。最典型的是文字太小,手机上一个时钟数字占屏宽三分之一,放到电视上就是一小块,坐远了根本看不清;其次是交互逻辑,手机可以靠触摸滑动切换城市,电视上没有触摸,如果列表不能靠遥控器上下左右按键导航,用户就只能望洋兴叹。再就是界面亮度,手机时钟App为了省电喜欢用深色背景,但电视屏幕本身亮度偏高,纯黑背景下深灰文字很容易糊成一团。
所以我从一开始就定了几个硬性指标:主时钟数字在1080p分辨率下不低于屏幕高度的五分之一;所有可交互元素必须支持遥控器焦点导航;背景采用深蓝或深灰渐变而不是纯黑,保证对比度;配色不超过三种,避免花哨。
1.2 功能清单和交互设计
明确了方向之后,我列了一个v1.0的功能清单,尽量克制,不做多余功能:
- 主界面显示大号翻页钟,展示当前时间和日期
- 顶部状态栏显示多个已添加城市的时间,支持左右切换突出显示
- 城市管理页面,可从预置城市列表中添加、删除、排序
- 世界地图模式,用点标记标注各城市位置,旁边显示当地当前时间
- 设置项:12/24小时制切换、主题配色、翻页动画开关
交互方面用遥控器按键做了映射:左右方向键切换当前高亮的城市卡片,上下方向键在卡片列表和主时钟区之间移动焦点,OK键进入城市管理,返回键退出。这套规则虽然精简,但对电视用户来说足够直观,不需要任何学习成本。
这里要特别强调一点:不要为了“功能完整”而把手机端的复杂交互硬塞进电视端。电视用户的操作成本比手机高得多,每多一层菜单,实际被使用的概率就会断崖式下降。做减法不是能力问题,而是对使用场景有没有足够敬畏。
2. 技术选型与项目骨架
2.1 开发环境与适配目标
开发语言选Kotlin,这是当前安卓开发的主流选择,空安全、扩展函数、协程这些特性在写界面逻辑时能省不少事。UI框架用的是安卓官方Leanback库,它是谷歌专门为TV应用准备的组件库,自带焦点处理逻辑和推荐界面风格。
目标设备是手头这台N1盒子,4K输出能力没问题,但GPU性能有限,所以在渲染上得留神,不能像手机端那样堆复杂的阴影和模糊特效。最低支持安卓8.0(API 26),因为N1刷的是安卓9(API 28)的固件,我也没打算兼容太老的设备。targetSdkVersion设为30,主要是为了适配分区存储和隐私权限的变化,避免后续上架遇到问题。
分辨率适配以1920x1080为基准,同时兼顾1280x720和3840x2160。这里有个经验:电视端的布局尽量用dp和weight权重,不要写死像素值。因为不同电视的density差异很大,同样一个1080p,有的设备density是240dpi(mdpi),有的是320dpi(xhdpi),写死像素值在不同设备上的物理尺寸会差出两倍。
2.2 世界时钟的数据结构设计
世界时钟的核心是时区数据处理。一开始我图省事,想直接用一个固定表格存储城市名和UTC偏移量,比如“东京 UTC+9”,后来想了想不对劲:地球上有一堆地区实行夏令时,中国虽然不实行,但欧洲、北美、澳大利亚这些地方每年都有两次时间切换。如果写死偏移量,到了夏令时段界面上的时间就会差一个小时。
正确的做法是用Java自带的TimeZone体系和IANA时区数据库。每个城市对应一个固定的时区ID,比如东京是Asia/Tokyo,纽约是America/New_York。显示时间时通过TimeZone.getDefault()或指定TimeZone来获取当前时间和UTC偏移量,系统会自动处理夏令时切换,不需要自己写逻辑。
城市列表的数据结构是这样设计的:
data class CityTimeZone( val cityName: String, val cityNameEn: String, val timeZoneId: String, val countryCode: String, val lat: Double, val lng: Double )时区ID是核心,城市名和坐标都是附属信息。lat和lng字段暂时没用,后来做世界地图模式时直接拍上用场,省了一轮数据重构。
夏令时这块我额外提醒一句:如果你在预置城市列表里直接写“偏移量”而不是“时区ID”,到3月底和10月底这种切换时间点,你就会被用户骂死。调试的时候务必用模拟数据测一下跨夏令时切换的日期,不要只测当前时间。
3. 核心功能实现与踩坑实录
3.1 翻页时钟:大字体渲染的技术含量
翻页钟是这个应用的门面,也是技术坑最集中的一个模块。它的视觉逻辑是:把时间拆成一个个独立的数字卡片,切换时上半部分向下滚动,下半部分保持,形成一个“卡翻”的动画效果。
实现方式上,我一开始考虑用原生View + 属性动画,但算下来要处理的东西太多:上半部分和下半部分要分别做旋转变换,翻转角度超过90度时要切换显示数字,还要处理内外阴影模拟厚度。后来换了个思路,用四个TextSwitcher配合自定义动画来实现,每个TextSwitcher负责一个数字位(时十位、时个位、分十位、分个位),切换到新数字时执行一个翻转动画。
字体选择上,我用了Google Fonts里的一款等宽数码管字体Digital-7 Mono,数字粗细均匀,翻页钟的还原度很高。但这里有个坑:这个字体文件本身不包含中文字形,日期行用的中文字符会变成方块。我的解决办法是字体文件只应用于数字和英文部分,日期行的字体用系统默认字体,代码里通过FontFamily分别设置,互不干扰。
大字号渲染的性能问题比预想中更多。DirectWrite这类渲染引擎在移动端的优化很好,但电视上如果字号过大、刷新频率过高,还是会出现掉帧。我在N1上实测发现,用TextSwitcher每次切换时如果完整重绘整个TextSwitcher,在4K分辨率下掉帧很明显。优化方案是给TextSwitcher设置固定的inAnimation和outAnimation,动画时长控制在300毫秒以内,同时把背景布局的动画禁用,只让数字卡片动,这样性能基本就稳了。
3.2 多时区卡片与城市管理
主页顶部的多时区卡片列表,我用了RecyclerView加水平布局实现。这里不得不提Leanback库自带的一个功能:VerticalGridView和HorizontalGridView。这两个控件在普通RecyclerView的基础上封装了焦点滚动、子项对齐、高亮放大这些TV交互逻辑,比手动写焦点监听省力得多。
卡片按距离屏幕中心的位置动态调整大小:聚焦的卡片放大到1.2倍,背景颜色变成高亮;失焦的卡片透明度降到80%。这个效果初看很酷,但要小心动画的触发频率,因为遥控器左右移动时焦点变化很快,如果动画时间设得过长,就会出现卡片还没放大完焦点又跳到下一张的情况。经验值是动画时长控制在150毫秒以内,并且要开启animateLayoutChanges的硬件加速。
城市管理功能相对简单,但有一个交互点值得多说:用户添加城市时,我用的是一个垂直滚动列表,按城市英文名排序,遥控器上下翻页浏览,按OK键添加。这个方案虽然不算智能,却是遥控器场景下最稳妥的交互。我也想过做搜索框,但电视端弹出软键盘的体验实在太差,输入一个名字要按十几下遥控器,用户根本不会用。到目前为止,我的预置城市列表里放了一百多个常用城市,覆盖了每个时区至少两个代表城市,基本够日常使用。
3.3 世界地图模式
世界地图模式是我觉得这个项目里最有“桌面艺术品”气质的功能。一张深色世界地图铺满屏幕,上面用亮点标注已添加的城市,每个点旁边显示城市名和当前的UTC偏移量,当前时区所在的城市用更大的亮色标注。
地图素材用的是从网上找的公开领域世界地图SVG,转成PNG后放在res目录。这里有个坑要注意:图片资源不要放在drawable目录里,要放在drawable-nodpi目录,避免系统根据屏幕density强行缩放导致图片模糊。
地图上标注城市位置的实现方式是用绝对坐标布局,把每个城市的经纬度换算成图片上的像素坐标。换算公式是标准的墨卡托投影,代码大概长这样:
fun latLngToPoint(lat: Double, lng: Double, mapWidth: Double, mapHeight: Double): Pair<Double, Double> { val x = (lng + 180.0) / 360.0 * mapWidth val latRad = lat * Math.PI / 180.0 val mercY = Math.log(Math.tan(Math.PI / 4.0 + latRad / 2.0)) val y = (mapHeight / 2.0) - (mapWidth * mercY / (2.0 * Math.PI)) return Pair(x, y) }用这个公式标注出来,东京、伦敦、纽约这些城市的位置基本准确,欧洲国家因为靠近极点会有一些变形,但作为装饰性地图足够用了。
地图模式的刷新频率我设置为每秒一次,只更新TextView的文本,不重绘整个View,这样功耗和性能都能接受。
3.4 开机自启与屏保模式
一个挂在墙上的时钟,如果每天要手动开应用,体验就毁了。所以WorldClock做了一套“开机自启 + 屏保替代”的逻辑。
开机自启监听系统开机广播RECEIVE_BOOT_COMPLETED。做法是写一个BroadcastReceiver,在onReceive里启动主Activity,同时把Activity的launchMode设为singleTask,避免应用启动多个实例导致内存重复。我这里加了一个功能,检测Android TV的设备类型,只有是TV设备时才自启,手机和平板上不触发,避免装到手机上时自动弹窗。
屏保这块用得是安卓原生Daydream机制。在AndroidManifest里给Activity注册了DreamService的action,并指定了dream的meta-data。这样在系统进入待机模式或者用户手动触发屏保时,系统会直接拉起WorldClock作为屏保画面显示,时间会自动刷新,退出屏保也不需要走应用内的返回逻辑。这个方案比让应用自己申请“保持唤醒”权限更优雅,因为Dream Service由系统管理,功耗表现更好,不会因为应用一直亮屏被系统杀掉。
4. 常见问题与调试记录
4.1 字体显示和文字缺失
数码管字体文件装上去之后,第一版运行起来日期行显示成了几个方块,这是预料之中的,中文字形不在字体文件里。解决方案是给日期行单独指定系统字体,但有一个细节需要注意:在Compose或XML里重新设置字体后,要检查数字行和日期行的基线对齐,不然上下两行会出现明显的错位感。
另一个坑是某些字体文件的数字只有常规宽度,没有等宽设计。翻页钟的数字宽度必须是一致的,否则秒钟从9变成10的时候,整个卡片会向左跳动一下。选字体的时候一定要确认它是monospace等宽字体,不是所有数字字体都满足这个要求。
4.2 动画卡顿与掉帧
我在N1上测试的时候,翻页钟的动画在1080p下还算流畅,切到4K分辨率后掉帧立刻显现。原因很直白:分辨率的提升让渲染面积变成了原来的四倍,而GPU性能并没有同比提升。
排查优化下来,最终有效的方案有三个:一是把翻页动画的触发频率从每秒一次降低到只在分钟变化时触发,秒数变化只更新数字不播放动画;二是关闭动画图层的硬件加速,防止每帧都触发全屏重绘;三是把背景渐变色改成纯色,渐变虽然好看,但每帧的GPU着色开销比纯色高不少。这三个方案叠加后,4K下的帧率从38fps提升到了55fps,体验肉眼可见地变好了。
4.3 遥控器焦点常丢
电视应用最常见的bug之一就是焦点丢失:按方向键时焦点没有落到预期的控件上,甚至直接消失。这个问题的根源通常有两个。
第一个是布局中有多个可聚焦控件时,系统优先选择距离当前焦点最近的控件,但如果两个控件距离相同或者父布局的focusability设置不对,焦点就会乱跳。解决方法是给每个可聚焦控件显式设置focusable和focusableInTouchMode,同时用nextFocusLeft、nextFocusRight这些属性手动指定焦点跳转逻辑,不给系统自由发挥的空间。
第二个是RecyclerView复用导致的焦点崩溃。我定位到卡片列表翻页后焦点消失的问题,排查之后发现是RecyclerView的item在复用时不保留焦点状态。解决办法是在Adapter里保存当前高亮位置,在onBindViewHolder里恢复焦点请求,同时在RecyclerView的ScrollListener里做一次焦点预判,滚动方向上的下一个可见项提前请求焦点。
4.4 时间同步偏差
安卓系统默认通过NTP服务器自动同步时间,大部分情况下没问题,但有些盒子刷机后系统时间源失效,时钟会越走越快。我在应用设置里加了一个“手动同步时间”的选项,实现方式是直接调用System.currentTimeMillis(),配合NTP客户端库从公共NTP服务器拉取时间,然后把差值缓存下来用于显示校正。
NTP校正逻辑要小心,不能直接改系统时间(普通应用没有这个权限),只能在应用内部维护一个offset变量,每次显示时间时计算为“系统时间 + offset”。这套方案虽然简单,但实测在N1上运行一周,时间误差控制在1秒以内,比盒子系统自带的时间同步还稳。
调试时间同步时我遇到一个典型问题:NTP服务器偶尔超时,导致应用卡在获取时间的流程上。处理方式是使用异步线程加超时控制,超时时间设3秒,超过则使用系统时间不进行校正,避免影响正常使用。
5. 一点经验总结
这个WorldClock项目从立项到完成,大概花了两周业余时间。真正花时间的部分不是写代码本身,而是反复在真机上调试那些只有电视端才会出现的问题。遥控器焦点、字体渲染、4K性能、夏令时切换,这些在模拟器上根本测不出来,每一条都是真机实测踩出来的。
最后再分享一个经验:如果你也在折腾安卓TV应用,千万不要只在模拟器上测试,一定要找一台真机,最好是非旗舰的入门级盒子。因为模拟器性能和交互方式跟真实电视设备差太远了,很多问题只有在性能较差的真机上才会暴露出来,而这些问题恰恰是影响用户体验的关键。这台N1刷安卓TV的盒子虽然性能不算强,但正因为如此,我在开发过程中被迫做的每一轮性能优化,最后都实打实地提升了应用的整体流畅度。
如果你也想做类似的东西,不要一上来就堆功能,先把最核心的时间显示和遥控器交互做扎实,再慢慢加城市管理、地图、屏保这些扩展功能。一个干净、流畅、稳定的小应用,比一个什么都做却处处卡顿的“大而全”要有价值得多。
本文还有配套的精品资源,点击获取