鐘開發(fā)實(shí)戰(zhàn):遙控器焦點(diǎn)、翻頁動(dòng)畫與性能優(yōu)化全記錄)
簡(jiǎn)介WorldClock 是一份基于 Java 開發(fā)的安卓 TV 平臺(tái)世界時(shí)鐘應(yīng)用源碼主要面向 Android TV 開發(fā)者、Java 學(xué)習(xí)者以及想做電視端工具類項(xiàng)目的程序員。資源共 43 個(gè)文件壓縮包約 3.06MB包含 21 個(gè) xml 界面布局、10 個(gè) java 核心邏輯、4 個(gè) png 圖標(biāo)素材還有 gradle 工程腳本、proguard 混淆規(guī)則、README 說明等基礎(chǔ)文件整體按標(biāo)準(zhǔn)工程結(jié)構(gòu)組織可直接導(dǎo)入 Android Studio 查看運(yùn)行。目前已有 217 人學(xué)習(xí)瀏覽。代碼圍繞電視端時(shí)鐘場(chǎng)景展開覆蓋遙控器導(dǎo)航交互、大屏橫屏適配、世界城市時(shí)區(qū)管理、java.time 日期處理與夏令時(shí)換算、ScheduledExecutorService 定時(shí)刷新、異步加載及性能優(yōu)化等關(guān)鍵點(diǎn)。透過這套源碼可以看懂安卓 TV 應(yīng)用從界面搭建、業(yè)務(wù)實(shí)現(xiàn)到構(gòu)建發(fā)布的完整鏈路適合作為課程設(shè)計(jì)、個(gè)人練習(xí)或二次開發(fā)的基礎(chǔ)。 這段時(shí)間一直在折騰安卓TV上的各種應(yīng)用手頭正好有臺(tái)刷了安卓TV系統(tǒng)的斐訊N1盒子平時(shí)就當(dāng)電視盒子用。有天看著待機(jī)畫面突然想讓它變成一塊大屏?xí)r鐘擺在客廳里既能看時(shí)間又有點(diǎn)裝飾感。翻遍了應(yīng)用市場(chǎng)里的時(shí)鐘App要么是手機(jī)版強(qiáng)行拉伸要么花里胡哨帶一堆廣告沒一個(gè)真正按電視交互邏輯設(shè)計(jì)的。干脆自己動(dòng)手寫一個(gè)這就是WorldClock這個(gè)項(xiàng)目的起因。這個(gè)項(xiàng)目說白了就是一臺(tái)運(yùn)行在安卓TV上的世界時(shí)鐘應(yīng)用核心功能是同時(shí)顯示多個(gè)時(shí)區(qū)的當(dāng)前時(shí)間配合翻頁鐘、城市管理、世界地圖這些玩法讓盒子在閑置時(shí)變成一塊漂亮的信息屏。做這塊的過程中踩了不少坑尤其是遙控器焦點(diǎn)、字體渲染、時(shí)區(qū)數(shù)據(jù)這些細(xì)節(jié)遠(yuǎn)比想象中麻煩。這篇文章就把整個(gè)從需求到落地的過程完整記錄下來給同樣在折騰安卓TV開發(fā)的朋友一個(gè)參考。1. 需求與設(shè)計(jì)電視端時(shí)鐘為什么不能照搬手機(jī)1.1 先搞清楚“十英尺界面”到底別扭在哪安卓TV和手機(jī)最大的區(qū)別是用戶操作距離。手機(jī)是眼睛湊到30厘米內(nèi)手指直接觸控電視是坐在沙發(fā)上至少三米開外手里只有一個(gè)遙控器。行業(yè)里把這種場(chǎng)景叫“十英尺界面”它決定了所有設(shè)計(jì)都要圍繞大字號(hào)、高對(duì)比度、焦點(diǎn)清晰這三個(gè)方向走。很多手機(jī)時(shí)鐘App移植到電視上后第一眼看上去能用但實(shí)際用起來全是問題。最典型的是文字太小手機(jī)上一個(gè)時(shí)鐘數(shù)字占屏寬三分之一放到電視上就是一小塊坐遠(yuǎn)了根本看不清其次是交互邏輯手機(jī)可以靠觸摸滑動(dòng)切換城市電視上沒有觸摸如果列表不能靠遙控器上下左右按鍵導(dǎo)航用戶就只能望洋興嘆。再就是界面亮度手機(jī)時(shí)鐘App為了省電喜歡用深色背景但電視屏幕本身亮度偏高純黑背景下深灰文字很容易糊成一團(tuán)。所以我從一開始就定了幾個(gè)硬性指標(biāo)主時(shí)鐘數(shù)字在1080p分辨率下不低于屏幕高度的五分之一所有可交互元素必須支持遙控器焦點(diǎn)導(dǎo)航背景采用深藍(lán)或深灰漸變而不是純黑保證對(duì)比度配色不超過三種避免花哨。1.2 功能清單和交互設(shè)計(jì)明確了方向之后我列了一個(gè)v1.0的功能清單盡量克制不做多余功能主界面顯示大號(hào)翻頁鐘展示當(dāng)前時(shí)間和日期頂部狀態(tài)欄顯示多個(gè)已添加城市的時(shí)間支持左右切換突出顯示城市管理頁面可從預(yù)置城市列表中添加、刪除、排序世界地圖模式用點(diǎn)標(biāo)記標(biāo)注各城市位置旁邊顯示當(dāng)?shù)禺?dāng)前時(shí)間設(shè)置項(xiàng)12/24小時(shí)制切換、主題配色、翻頁動(dòng)畫開關(guān)交互方面用遙控器按鍵做了映射左右方向鍵切換當(dāng)前高亮的城市卡片上下方向鍵在卡片列表和主時(shí)鐘區(qū)之間移動(dòng)焦點(diǎn)OK鍵進(jìn)入城市管理返回鍵退出。這套規(guī)則雖然精簡(jiǎn)但對(duì)電視用戶來說足夠直觀不需要任何學(xué)習(xí)成本。這里要特別強(qiáng)調(diào)一點(diǎn)不要為了“功能完整”而把手機(jī)端的復(fù)雜交互硬塞進(jìn)電視端。電視用戶的操作成本比手機(jī)高得多每多一層菜單實(shí)際被使用的概率就會(huì)斷崖式下降。做減法不是能力問題而是對(duì)使用場(chǎng)景有沒有足夠敬畏。2. 技術(shù)選型與項(xiàng)目骨架2.1 開發(fā)環(huán)境與適配目標(biāo)開發(fā)語言選Kotlin這是當(dāng)前安卓開發(fā)的主流選擇空安全、擴(kuò)展函數(shù)、協(xié)程這些特性在寫界面邏輯時(shí)能省不少事。UI框架用的是安卓官方Leanback庫它是谷歌專門為TV應(yīng)用準(zhǔn)備的組件庫自帶焦點(diǎn)處理邏輯和推薦界面風(fēng)格。目標(biāo)設(shè)備是手頭這臺(tái)N1盒子4K輸出能力沒問題但GPU性能有限所以在渲染上得留神不能像手機(jī)端那樣堆復(fù)雜的陰影和模糊特效。最低支持安卓8.0API 26因?yàn)镹1刷的是安卓9API 28的固件我也沒打算兼容太老的設(shè)備。targetSdkVersion設(shè)為30主要是為了適配分區(qū)存儲(chǔ)和隱私權(quán)限的變化避免后續(xù)上架遇到問題。分辨率適配以1920x1080為基準(zhǔn)同時(shí)兼顧1280x720和3840x2160。這里有個(gè)經(jīng)驗(yàn)電視端的布局盡量用dp和weight權(quán)重不要寫死像素值。因?yàn)椴煌娨暤膁ensity差異很大同樣一個(gè)1080p有的設(shè)備density是240dpimdpi有的是320dpixhdpi寫死像素值在不同設(shè)備上的物理尺寸會(huì)差出兩倍。2.2 世界時(shí)鐘的數(shù)據(jù)結(jié)構(gòu)設(shè)計(jì)世界時(shí)鐘的核心是時(shí)區(qū)數(shù)據(jù)處理。一開始我圖省事想直接用一個(gè)固定表格存儲(chǔ)城市名和UTC偏移量比如“東京 UTC9”后來想了想不對(duì)勁地球上有一堆地區(qū)實(shí)行夏令時(shí)中國雖然不實(shí)行但歐洲、北美、澳大利亞這些地方每年都有兩次時(shí)間切換。如果寫死偏移量到了夏令時(shí)段界面上的時(shí)間就會(huì)差一個(gè)小時(shí)。正確的做法是用Java自帶的TimeZone體系和IANA時(shí)區(qū)數(shù)據(jù)庫。每個(gè)城市對(duì)應(yīng)一個(gè)固定的時(shí)區(qū)ID比如東京是Asia/Tokyo紐約是America/New_York。顯示時(shí)間時(shí)通過TimeZone.getDefault()或指定TimeZone來獲取當(dāng)前時(shí)間和UTC偏移量系統(tǒng)會(huì)自動(dòng)處理夏令時(shí)切換不需要自己寫邏輯。城市列表的數(shù)據(jù)結(jié)構(gòu)是這樣設(shè)計(jì)的data class CityTimeZone( val cityName: String, val cityNameEn: String, val timeZoneId: String, val countryCode: String, val lat: Double, val lng: Double )時(shí)區(qū)ID是核心城市名和坐標(biāo)都是附屬信息。lat和lng字段暫時(shí)沒用后來做世界地圖模式時(shí)直接拍上用場(chǎng)省了一輪數(shù)據(jù)重構(gòu)。夏令時(shí)這塊我額外提醒一句如果你在預(yù)置城市列表里直接寫“偏移量”而不是“時(shí)區(qū)ID”到3月底和10月底這種切換時(shí)間點(diǎn)你就會(huì)被用戶罵死。調(diào)試的時(shí)候務(wù)必用模擬數(shù)據(jù)測(cè)一下跨夏令時(shí)切換的日期不要只測(cè)當(dāng)前時(shí)間。3. 核心功能實(shí)現(xiàn)與踩坑實(shí)錄3.1 翻頁時(shí)鐘大字體渲染的技術(shù)含量翻頁鐘是這個(gè)應(yīng)用的門面也是技術(shù)坑最集中的一個(gè)模塊。它的視覺邏輯是把時(shí)間拆成一個(gè)個(gè)獨(dú)立的數(shù)字卡片切換時(shí)上半部分向下滾動(dòng)下半部分保持形成一個(gè)“卡翻”的動(dòng)畫效果。實(shí)現(xiàn)方式上我一開始考慮用原生View 屬性動(dòng)畫但算下來要處理的東西太多上半部分和下半部分要分別做旋轉(zhuǎn)變換翻轉(zhuǎn)角度超過90度時(shí)要切換顯示數(shù)字還要處理內(nèi)外陰影模擬厚度。后來換了個(gè)思路用四個(gè)TextSwitcher配合自定義動(dòng)畫來實(shí)現(xiàn)每個(gè)TextSwitcher負(fù)責(zé)一個(gè)數(shù)字位時(shí)十位、時(shí)個(gè)位、分十位、分個(gè)位切換到新數(shù)字時(shí)執(zhí)行一個(gè)翻轉(zhuǎn)動(dòng)畫。字體選擇上我用了Google Fonts里的一款等寬數(shù)碼管字體Digital-7 Mono數(shù)字粗細(xì)均勻翻頁鐘的還原度很高。但這里有個(gè)坑這個(gè)字體文件本身不包含中文字形日期行用的中文字符會(huì)變成方塊。我的解決辦法是字體文件只應(yīng)用于數(shù)字和英文部分日期行的字體用系統(tǒng)默認(rèn)字體代碼里通過FontFamily分別設(shè)置互不干擾。大字號(hào)渲染的性能問題比預(yù)想中更多。DirectWrite這類渲染引擎在移動(dòng)端的優(yōu)化很好但電視上如果字號(hào)過大、刷新頻率過高還是會(huì)出現(xiàn)掉幀。我在N1上實(shí)測(cè)發(fā)現(xiàn)用TextSwitcher每次切換時(shí)如果完整重繪整個(gè)TextSwitcher在4K分辨率下掉幀很明顯。優(yōu)化方案是給TextSwitcher設(shè)置固定的inAnimation和outAnimation動(dòng)畫時(shí)長(zhǎng)控制在300毫秒以內(nèi)同時(shí)把背景布局的動(dòng)畫禁用只讓數(shù)字卡片動(dòng)這樣性能基本就穩(wěn)了。3.2 多時(shí)區(qū)卡片與城市管理主頁頂部的多時(shí)區(qū)卡片列表我用了RecyclerView加水平布局實(shí)現(xiàn)。這里不得不提Leanback庫自帶的一個(gè)功能VerticalGridView和HorizontalGridView。這兩個(gè)控件在普通RecyclerView的基礎(chǔ)上封裝了焦點(diǎn)滾動(dòng)、子項(xiàng)對(duì)齊、高亮放大這些TV交互邏輯比手動(dòng)寫焦點(diǎn)監(jiān)聽省力得多。卡片按距離屏幕中心的位置動(dòng)態(tài)調(diào)整大小聚焦的卡片放大到1.2倍背景顏色變成高亮失焦的卡片透明度降到80%。這個(gè)效果初看很酷但要小心動(dòng)畫的觸發(fā)頻率因?yàn)檫b控器左右移動(dòng)時(shí)焦點(diǎn)變化很快如果動(dòng)畫時(shí)間設(shè)得過長(zhǎng)就會(huì)出現(xiàn)卡片還沒放大完焦點(diǎn)又跳到下一張的情況。經(jīng)驗(yàn)值是動(dòng)畫時(shí)長(zhǎng)控制在150毫秒以內(nèi)并且要開啟animateLayoutChanges的硬件加速。城市管理功能相對(duì)簡(jiǎn)單但有一個(gè)交互點(diǎn)值得多說用戶添加城市時(shí)我用的是一個(gè)垂直滾動(dòng)列表按城市英文名排序遙控器上下翻頁瀏覽按OK鍵添加。這個(gè)方案雖然不算智能卻是遙控器場(chǎng)景下最穩(wěn)妥的交互。我也想過做搜索框但電視端彈出軟鍵盤的體驗(yàn)實(shí)在太差輸入一個(gè)名字要按十幾下遙控器用戶根本不會(huì)用。到目前為止我的預(yù)置城市列表里放了一百多個(gè)常用城市覆蓋了每個(gè)時(shí)區(qū)至少兩個(gè)代表城市基本夠日常使用。3.3 世界地圖模式世界地圖模式是我覺得這個(gè)項(xiàng)目里最有“桌面藝術(shù)品”氣質(zhì)的功能。一張深色世界地圖鋪滿屏幕上面用亮點(diǎn)標(biāo)注已添加的城市每個(gè)點(diǎn)旁邊顯示城市名和當(dāng)前的UTC偏移量當(dāng)前時(shí)區(qū)所在的城市用更大的亮色標(biāo)注。地圖素材用的是從網(wǎng)上找的公開領(lǐng)域世界地圖SVG轉(zhuǎn)成PNG后放在res目錄。這里有個(gè)坑要注意圖片資源不要放在drawable目錄里要放在drawable-nodpi目錄避免系統(tǒng)根據(jù)屏幕density強(qiáng)行縮放導(dǎo)致圖片模糊。地圖上標(biāo)注城市位置的實(shí)現(xiàn)方式是用絕對(duì)坐標(biāo)布局把每個(gè)城市的經(jīng)緯度換算成圖片上的像素坐標(biāo)。換算公式是標(biāo)準(zhǔn)的墨卡托投影代碼大概長(zhǎng)這樣fun latLngToPoint(lat: Double, lng: Double, mapWidth: Double, mapHeight: Double): PairDouble, 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) }用這個(gè)公式標(biāo)注出來東京、倫敦、紐約這些城市的位置基本準(zhǔn)確歐洲國家因?yàn)榭拷鼧O點(diǎn)會(huì)有一些變形但作為裝飾性地圖足夠用了。地圖模式的刷新頻率我設(shè)置為每秒一次只更新TextView的文本不重繪整個(gè)View這樣功耗和性能都能接受。3.4 開機(jī)自啟與屏保模式一個(gè)掛在墻上的時(shí)鐘如果每天要手動(dòng)開應(yīng)用體驗(yàn)就毀了。所以WorldClock做了一套“開機(jī)自啟 屏保替代”的邏輯。開機(jī)自啟監(jiān)聽系統(tǒng)開機(jī)廣播RECEIVE_BOOT_COMPLETED。做法是寫一個(gè)BroadcastReceiver在onReceive里啟動(dòng)主Activity同時(shí)把Activity的launchMode設(shè)為singleTask避免應(yīng)用啟動(dòng)多個(gè)實(shí)例導(dǎo)致內(nèi)存重復(fù)。我這里加了一個(gè)功能檢測(cè)Android TV的設(shè)備類型只有是TV設(shè)備時(shí)才自啟手機(jī)和平板上不觸發(fā)避免裝到手機(jī)上時(shí)自動(dòng)彈窗。屏保這塊用得是安卓原生Daydream機(jī)制。在AndroidManifest里給Activity注冊(cè)了DreamService的action并指定了dream的meta-data。這樣在系統(tǒng)進(jìn)入待機(jī)模式或者用戶手動(dòng)觸發(fā)屏保時(shí)系統(tǒng)會(huì)直接拉起WorldClock作為屏保畫面顯示時(shí)間會(huì)自動(dòng)刷新退出屏保也不需要走應(yīng)用內(nèi)的返回邏輯。這個(gè)方案比讓應(yīng)用自己申請(qǐng)“保持喚醒”權(quán)限更優(yōu)雅因?yàn)镈ream Service由系統(tǒng)管理功耗表現(xiàn)更好不會(huì)因?yàn)閼?yīng)用一直亮屏被系統(tǒng)殺掉。4. 常見問題與調(diào)試記錄4.1 字體顯示和文字缺失數(shù)碼管字體文件裝上去之后第一版運(yùn)行起來日期行顯示成了幾個(gè)方塊這是預(yù)料之中的中文字形不在字體文件里。解決方案是給日期行單獨(dú)指定系統(tǒng)字體但有一個(gè)細(xì)節(jié)需要注意在Compose或XML里重新設(shè)置字體后要檢查數(shù)字行和日期行的基線對(duì)齊不然上下兩行會(huì)出現(xiàn)明顯的錯(cuò)位感。另一個(gè)坑是某些字體文件的數(shù)字只有常規(guī)寬度沒有等寬設(shè)計(jì)。翻頁鐘的數(shù)字寬度必須是一致的否則秒鐘從9變成10的時(shí)候整個(gè)卡片會(huì)向左跳動(dòng)一下。選字體的時(shí)候一定要確認(rèn)它是monospace等寬字體不是所有數(shù)字字體都滿足這個(gè)要求。4.2 動(dòng)畫卡頓與掉幀我在N1上測(cè)試的時(shí)候翻頁鐘的動(dòng)畫在1080p下還算流暢切到4K分辨率后掉幀立刻顯現(xiàn)。原因很直白分辨率的提升讓渲染面積變成了原來的四倍而GPU性能并沒有同比提升。排查優(yōu)化下來最終有效的方案有三個(gè)一是把翻頁動(dòng)畫的觸發(fā)頻率從每秒一次降低到只在分鐘變化時(shí)觸發(fā)秒數(shù)變化只更新數(shù)字不播放動(dòng)畫二是關(guān)閉動(dòng)畫圖層的硬件加速防止每幀都觸發(fā)全屏重繪三是把背景漸變色改成純色漸變雖然好看但每幀的GPU著色開銷比純色高不少。這三個(gè)方案疊加后4K下的幀率從38fps提升到了55fps體驗(yàn)肉眼可見地變好了。4.3 遙控器焦點(diǎn)常丟電視應(yīng)用最常見的bug之一就是焦點(diǎn)丟失按方向鍵時(shí)焦點(diǎn)沒有落到預(yù)期的控件上甚至直接消失。這個(gè)問題的根源通常有兩個(gè)。第一個(gè)是布局中有多個(gè)可聚焦控件時(shí)系統(tǒng)優(yōu)先選擇距離當(dāng)前焦點(diǎn)最近的控件但如果兩個(gè)控件距離相同或者父布局的focusability設(shè)置不對(duì)焦點(diǎn)就會(huì)亂跳。解決方法是給每個(gè)可聚焦控件顯式設(shè)置focusable和focusableInTouchMode同時(shí)用nextFocusLeft、nextFocusRight這些屬性手動(dòng)指定焦點(diǎn)跳轉(zhuǎn)邏輯不給系統(tǒng)自由發(fā)揮的空間。第二個(gè)是RecyclerView復(fù)用導(dǎo)致的焦點(diǎn)崩潰。我定位到卡片列表翻頁后焦點(diǎn)消失的問題排查之后發(fā)現(xiàn)是RecyclerView的item在復(fù)用時(shí)不保留焦點(diǎn)狀態(tài)。解決辦法是在Adapter里保存當(dāng)前高亮位置在onBindViewHolder里恢復(fù)焦點(diǎn)請(qǐng)求同時(shí)在RecyclerView的ScrollListener里做一次焦點(diǎn)預(yù)判滾動(dòng)方向上的下一個(gè)可見項(xiàng)提前請(qǐng)求焦點(diǎn)。4.4 時(shí)間同步偏差安卓系統(tǒng)默認(rèn)通過NTP服務(wù)器自動(dòng)同步時(shí)間大部分情況下沒問題但有些盒子刷機(jī)后系統(tǒng)時(shí)間源失效時(shí)鐘會(huì)越走越快。我在應(yīng)用設(shè)置里加了一個(gè)“手動(dòng)同步時(shí)間”的選項(xiàng)實(shí)現(xiàn)方式是直接調(diào)用System.currentTimeMillis()配合NTP客戶端庫從公共NTP服務(wù)器拉取時(shí)間然后把差值緩存下來用于顯示校正。NTP校正邏輯要小心不能直接改系統(tǒng)時(shí)間普通應(yīng)用沒有這個(gè)權(quán)限只能在應(yīng)用內(nèi)部維護(hù)一個(gè)offset變量每次顯示時(shí)間時(shí)計(jì)算為“系統(tǒng)時(shí)間 offset”。這套方案雖然簡(jiǎn)單但實(shí)測(cè)在N1上運(yùn)行一周時(shí)間誤差控制在1秒以內(nèi)比盒子系統(tǒng)自帶的時(shí)間同步還穩(wěn)。調(diào)試時(shí)間同步時(shí)我遇到一個(gè)典型問題NTP服務(wù)器偶爾超時(shí)導(dǎo)致應(yīng)用卡在獲取時(shí)間的流程上。處理方式是使用異步線程加超時(shí)控制超時(shí)時(shí)間設(shè)3秒超過則使用系統(tǒng)時(shí)間不進(jìn)行校正避免影響正常使用。5. 一點(diǎn)經(jīng)驗(yàn)總結(jié)這個(gè)WorldClock項(xiàng)目從立項(xiàng)到完成大概花了兩周業(yè)余時(shí)間。真正花時(shí)間的部分不是寫代碼本身而是反復(fù)在真機(jī)上調(diào)試那些只有電視端才會(huì)出現(xiàn)的問題。遙控器焦點(diǎn)、字體渲染、4K性能、夏令時(shí)切換這些在模擬器上根本測(cè)不出來每一條都是真機(jī)實(shí)測(cè)踩出來的。最后再分享一個(gè)經(jīng)驗(yàn)如果你也在折騰安卓TV應(yīng)用千萬不要只在模擬器上測(cè)試一定要找一臺(tái)真機(jī)最好是非旗艦的入門級(jí)盒子。因?yàn)槟M器性能和交互方式跟真實(shí)電視設(shè)備差太遠(yuǎn)了很多問題只有在性能較差的真機(jī)上才會(huì)暴露出來而這些問題恰恰是影響用戶體驗(yàn)的關(guān)鍵。這臺(tái)N1刷安卓TV的盒子雖然性能不算強(qiáng)但正因?yàn)槿绱宋以陂_發(fā)過程中被迫做的每一輪性能優(yōu)化最后都實(shí)打?qū)嵉靥嵘藨?yīng)用的整體流暢度。如果你也想做類似的東西不要一上來就堆功能先把最核心的時(shí)間顯示和遙控器交互做扎實(shí)再慢慢加城市管理、地圖、屏保這些擴(kuò)展功能。一個(gè)干凈、流暢、穩(wěn)定的小應(yīng)用比一個(gè)什么都做卻處處卡頓的“大而全”要有價(jià)值得多。本文還有配套的精品資源點(diǎn)擊獲取