在上一篇裡,我們把 MVI 單向資料流打造完畢,消滅了所有狀態打架與白屏問題。那時候我們只挑了一條最關鍵的投票主路徑來驗證架構,把核心流程跑得極度絲滑。
按理說,身為一個「怠惰」的工程師,既然架構打通了、技術難點解決了,這時候就可以拍拍屁股收工了。但每當我在手機上打開那個只剩下四個簡潔畫面的新版 App,看著光禿禿的選單,心裡總有一種說不出的失落感。
十年前在 2016 年的 master 分支裡,那個我每天自己下載來玩的 FunnyVote,裡面可不只有冷冰冰的投票列表。那裡封存了滿滿的心血:六頁圖文並茂的啟動導覽教學(TutorialActivity)、四個介紹開發團隊與條款的關於子頁面(AboutActivity)、自訂手勢的底層分享彈窗(ShareBottomSheet),甚至還有當初為了賺幾塊美金串接的 AdMob 廣告橫幅。
業界很多開源專案重構,往往只刻了最簡單的兩三個畫面,遇到難搞的引導頁、自訂彈窗或邊緣條款,就直接當作沒這回事。這種只做核心的「樣品屋架構」,騙得了面試官,騙不過自己的心。
這不是理性的技術決策,這是一個原創者的情感潔癖。於是,在 2026 年的 modern-android 分支上,我決定展開一場硬核的體力活:把十年前的青春與細節,一個不漏地 100% 搬進現代 Material 3!
一、 體力活一:從 48×48 像素泥潭到現代 SVG 向量圖標
當我打開 2016 年的 res/drawable-mdpi 與 drawable-hdpi 目錄時,眼前的景象簡直是一場視覺災難。
各位資深的 Android 開發者應該還記得,十年前為了適應不同螢幕密度,設計師得辛辛苦苦把一張圖切成 mdpi、hdpi、xhdpi、xxhdpi、xxxhdpi 五套不同大小的點陣 PNG。而在當年的 FunnyVote 裡,很多圖標甚至只有 mdpi(48×48 像素)的超低解析度小圖。
在十年前 Nexus 4 或 Galaxy S3 那種螢幕上,這些小圖看起來還算過得去。但在 2026 年的高解析度旗艦機(動輒 400+ ppi)上把這些老舊 PNG 渲染出來時,圖標糊得像打了重度馬賽克,鋸齒邊緣銳利到可以割傷眼睛!
在現代 Compose 開發中,點陣 PNG 早就被時代淘汰了。標準做法是全面使用向量圖形(Vector Drawable / SVG),由 GPU 在渲染時即時計算貝茲曲線,不論放大到幾吋螢幕都銳利無比。
那幾天,我耐著性子把舊專案裡幾十個陳舊的 PNG 挖了出來。有些圖標在開源圖庫裡找得到對應的標準 Material Icon,我就直接替換成 Compose 官方的 ImageVector:
// 用現代 AutoMirrored 與官方向量圖標替換陳舊資源
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "返回上一頁",
tint = MaterialTheme.colorScheme.onSurface
)
而對於那些 FunnyVote 獨創的自訂圖形(例如投票小手、特色徽章),我用向量軟體一個點一個錨點重新勾勒,匯出成乾淨的 SVG,再透過 Android Studio 的 Vector Asset Studio 轉成 VectorDrawable XML。光是這項清理資產的苦工,就花了我整整兩個深夜。但當看到所有圖標在手機上晶瑩剔透地浮現時,那種視覺舒適感真的無可取代。
二、 體力活二:30+ 份 XML 寫死顏色的除魅工程,遷徙至 Material 3
搞定圖標後,接下來要面對的是色彩系統的崩潰。
在十年前的 styles.xml 與各個排版 XML 裡,充斥著滿滿的硬編碼顏色(Hardcoded Colors):
<!-- 2016 年的經典 Material 1 寫死配色 -->
<color name="colorPrimary">#3F51B5</color> <!-- 經典靛藍色 -->
<color name="colorPrimaryDark">#303F9F</color>
<color name="colorAccent">#FF4081</color> <!-- 亮粉紅強調色 -->
<color name="textColorPrimary">#212121</color>
<color name="background_gray">#F5F5F5</color>
那時候的寫法極其野蠻。只要想改個背景色,大家就在 XML 裡隨手寫個 android:background="#F5F5F5"。結果整個專案裡散落著幾十種深淺不一的灰色與藍色,完全沒有任何設計規範可言。而且更致命的是:當年的專案根本不支援深色模式(Dark Mode)!只要使用者在系統切換到夜間模式,App 畫面依然是一片刺眼的亮白色。
在 modern-android 分支中,我們一口氣升級到了 Material 3(M3)設計語言,建立了標準的語義化色盤系統:
// 2026 年的 Material 3 語義化色彩主題
private val LightColorScheme = lightColorScheme(
primary = Color(0xFF1E88E5),
onPrimary = Color.White,
primaryContainer = Color(0xFFD1E4FF),
onPrimaryContainer = Color(0xFF001D36),
secondary = Color(0xFF535F70),
background = Color(0xFFFDFCFF),
surface = Color(0xFFFDFCFF),
onSurface = Color(0xFF1A1C1E)
)
private val DarkColorScheme = darkColorScheme(
primary = Color(0xFF9ECAFF),
onPrimary = Color(0xFF003258),
primaryContainer = Color(0xFF00497D),
onPrimaryContainer = Color(0xFFD1E4FF),
background = Color(0xFF1A1C1E),
surface = Color(0xFF1A1C1E),
onSurface = Color(0xFFE2E2E6)
)
在 Compose 裡面,所有文字、按鈕與卡片的顏色,一律強制引用 MaterialTheme.colorScheme.*:
// 乾淨利落的語義化元件宣告
Text(
text = vote.title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface // 自動根據日夜間模式切換適當色彩
)
這項改造最神奇的地方在於:深色模式的支援直接變成了免費贈品。當手機切換到夜間模式時,系統自動將背景置換為優雅的暗黑色調,文字自動反白,對比度完美符合 WCAG 無障礙標準,再也不會在深夜亮瞎使用者的雙眼。
三、 體力活三:100% 復刻 6 頁新手引導與自訂分享彈窗
基礎設施修整完畢後,我開始一頁一頁地無損復刻舊畫面。
最先動工的是當年的 6 頁啟動教學(TutorialActivity)。十年前我們是用 ViewPager 搭配 6 份獨立的 XML 來輪播圖片與文字說明。在現代 Compose 裡面,我們用 HorizontalPager 重新復刻了這段經典回憶:
// 現代版 6 頁教學輪播導覽
@Composable
fun TutorialScreen(onFinishTutorial: () -> Unit) {
val pagerState = rememberPagerState(pageCount = { 6 })
val coroutineScope = rememberCoroutineScope()
Column(modifier = Modifier.fillMaxSize()) {
HorizontalPager(
state = pagerState,
modifier = Modifier.weight(1f)
) { page ->
TutorialPageContent(pageIndex = page)
}
// 底部圓點指示器與跳過按鈕
TutorialBottomBar(
pagerState = pagerState,
onSkipClick = onFinishTutorial,
onNextClick = {
if (pagerState.currentPage < 5) {
coroutineScope.launch { pagerState.animateScrollToPage(pagerState.currentPage + 1) }
} else {
onFinishTutorial()
}
}
)
}
}
接著是十年前那個客製化的自訂分享選單(ShareBottomSheet)。那時候為了解決「分享到 Facebook、Twitter、LINE、複製連結」的按鈕排版,我們寫了一個專屬的 DialogFragment,光是處理底部彈出動畫就踩了不少坑。現在用 Compose 的 ModalBottomSheet,配合 rememberModalBottomSheetState,幾十行代碼就能做出擁有絲滑阻尼感與邊緣手勢拖曳的現代底部抽屜。
我們連四個「關於」子頁面(版本資訊、贊助名單、隱私條款、常見 Q&A)與原本的自研促銷橫幅(Promotion Banner)都通通搬了回來。看著這個介面,十年前每天在咖啡廳 Debug 的記憶,彷彿穿越時空重新鮮活了起來。
四、 實機驗收:在 Samsung S23 上滑滿 120Hz 的極致爽感
當全部 20 幾個畫面與彈窗復刻完畢後,到了最激動人心的驗收時刻。
我拿出實體測試機 Samsung Galaxy S23,透過 ADB 把 Release 版本安裝進手機。接著,在開發者選項中開啟經典的「GPU 呈現模式分析(Profile HWUI Rendering)」,選擇在螢幕上以長條圖顯示。
在 Android 螢幕上,有一條醒目的綠色水平基準線,代表每幀渲染耗時的及格門檻(120Hz 螢幕對應的是 8.3 毫秒)。
我按住螢幕,對著熱門投票列表、搜尋分頁、引導輪播狂甩手指,用最快的速度上下滑動:
畫面上每一個柱狀圖,穩如磐石地貼在最底部,全部緊貼著綠色基準線之下!每一幀的繪製時間被壓制在 3 到 5 毫秒之間,沒有任何一根長條衝破基準線!
看著 120fps 一條綠線滑到底的極致流暢感,我不禁回想起十年前 Android 5.0 / 6.0 時代。那時候即使在頂級手機上滑 ListView,稍微遇到圖片載入或複雜排版,長條圖就動不動紅藍相間、高聳入雲,伴隨著肉眼可見的頓挫感。十年過去了,硬體的演進與 Compose 的成熟,終於讓 Android 的渲染體驗達到了藝術級的境界。
五、 終極尷尬:跑車刻好了,後端伺服器早就入土八年了
正當我沉浸在「畫面復刻完畢、效能完美登頂」的巨大成就感中,我點開了 App 中的「刷新資料」按鈕。
轉圈圈轉了十秒鐘,畫面彈出了一個熟悉的錯誤訊息:java.net.UnknownHostException: Unable to resolve host "funny-vote.com"。
我當場愣在工位上。
我立刻翻出當年的 api.properties 與 Git 歷史紀錄。原來在 2016 年,我們專案的後端是好朋友 JJJ 用 PHP 寫的,架在 DigitalOcean 每個月五美元的虛擬主機(VPS)上;網址 funny-vote.com 當年在 Godaddy 買的一年要 699 台幣。
然而,這十年來誰也沒去管它。域名早就過期被不知名的網址蟑螂買走了,那台五美元的主機,也早在八年前就因為欠費被主機商整機刪除、灰飛煙滅了!
這簡直是天大的幽默:我們花了好幾個月,把客戶端從 2016 年的馬車進化成了 2026 年的頂級超跑;結果引擎蓋一掀開,裡面的發動機早就化成了灰燼!我們竟然是在拿著 Mock 靜態資料,自嗨了整整一個禮拜!
身為一個有尊嚴的工程師,我絕不允許自己的作品只是一個只能看 Demo 的假 App。但十年前的 PHP 代碼早就遺失在時光機裡,現在的我,也絕不想再花時間去折騰 Linux 指令、Nginx 設定檔與 MySQL 連線池。
如何在一行伺服器代碼都不想維護的前提下,給這款死透了的 App 換上一顆現代雲原生心臟?下一篇,我們聊聊 Firebase Firestore 的換心搶救實戰!

發佈留言