本文為《Funny Vote 十年演進系列》第 6 篇(全系列共 8 篇)|查看全系列架構導覽

架構搭好了,狀態管線通了,照理說工程師這時候就可以打卡下班、在履歷上寫「精通 MVI 與 Compose 工業級管線」了。
但當我把上一篇寫好的 App 拿在手上把玩時,心裡那一股工程師的強迫症火苗卻越燒越旺:
「不行,現在這隻 App 根本只是個被閹割的骨架!」
大家去 GitHub 上翻翻那些號稱「十年經典重構」的開源 Repository,十個有九個都犯了一種「架構很潮、功能砍半」的偷懶病:後端換成了最潮的協程和 Flow,UI 換成了 Compose,但原本複雜的功能、細膩的互動彈窗、甚至連導覽教學頁全部被無情閹割,只留下一個空洞簡陋的列表在自嗨。
身為 Funny Vote 的親生父親,看著十年前在 Java 舊石器時代熬夜手刻的那些青春細節——6 頁經典手勢導覽教學、自訂社群分享底層彈窗、四個層級的關於頁面、投票得票數動態排序 FAB——如果把它們丟掉,這趟重構根本毫無靈魂可言。
於是,我開了 modern-android 分支,給自己立下了一個近乎自虐的軍令狀:不只要換上現代 Material 3,原版的所有功能、動態與畫面細節,一個都不能少,全部 100% 像素級復刻!
完整復刻分支原始碼傳送門:
1. 拒絕閹割:十年前的畫面地圖與 Material 3 矩陣
先把十年前原版的畫面盤點出來。很多人以為投票 App 不就一個列表加一個投票按鈕?
錯。翻開老專案的 Activity 清單,整套 App 其實包含了五大業務鏈路:
[Funny Vote 完整功能樹]
├── 啟動與引導
│ ├── WelcomeScreen (Logo 淡入淡出與 1.2 秒無縫過渡)
│ └── TutorialScreen (6 頁經典功能導覽、動態指示點、雙向滑動)
├── 首頁中樞 (HomeScreen)
│ ├── PromotionCarousel (焦點話題自研無廣告輪播橫幅)
│ ├── 3 大分類分頁 (熱門排行 / 最新上架 / 我的收藏)
│ └── ShareBottomSheet (自訂社群分享彈窗:FB/LINE/Twitter/複製)
├── 投票詳情 (VoteDetailScreen)
│ ├── 規則說明彈窗 (起訖時間、投票限制、安全模式)
│ ├── 動態即時排序 (得票數升降冪動態重排)
│ └── 自由新增選項 (合法規則下即時動態擴充選項)
├── 個人中心 (ProfileScreen)
│ ├── 雙 Tab 歷史 (我發起的 / 我收藏的)
│ └── 修改個人暱稱與頭像持久化
└── 系統與條款 (AboutScreen)
├── AboutAppScreen (版本與理念)
├── AuthorInfoScreen (作者經歷與社群)
├── LicenceScreen (第三方開源組件授權)
└── ProblemScreen (常見問題與使用 FAQ)
要在不引入任何一份 XML 的前提下,用純 Compose 把這套龐大的畫面全部刻完,那幾週我的工位螢幕上全都是 Material 3 的元件在飛舞。
2. 核心戰役一:用 HorizontalPager 降伏 6 頁經典導覽
十年前,為了在使用者第一次開啟 App 時展示 6 頁新手導覽,我們在 Java 裡用 ViewPager、自訂 PagerAdapter,還得在 XML 裡手刻六個圓點的 Shape Drawable,手動在 onPageScrolled 裡面計算偏移量去換顏色,代碼又臭又長。
在 Compose 裡,官方提供了 HorizontalPager。看似簡單,但在真實復刻時,魔鬼都在細節裡:
- 動態指示點(Indicator)的流暢過渡:當頁面滑到第 6 頁時,右下角的按鈕必須從「下一頁」平滑過渡為「立即體驗」;
- 頂部的跳過(SKIP)邏輯:隨時點擊都能直接觸發 DataStore 寫入「已讀教學」旗標,並無縫導航至首頁。
在 modern-android 分支裡,核心實作濃縮成了這段乾淨的代碼:
// 6 頁導覽教學的 Compose 現代實作
@Composable
fun TutorialScreen(onFinishTutorial: () -> Unit) {
val pagerState = rememberPagerState(pageCount = { 6 })
val coroutineScope = rememberCoroutineScope()
Scaffold(
topBar = {
TutorialTopBar(onSkip = onFinishTutorial)
},
bottomBar = {
TutorialBottomBar(
pagerState = pagerState,
onNext = {
coroutineScope.launch {
if (pagerState.currentPage < 5) {
pagerState.animateScrollToPage(pagerState.currentPage + 1)
} else {
onFinishTutorial()
}
}
}
)
}
) { padding ->
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize().padding(padding)
) { page ->
TutorialPageContent(pageIndex = page)
}
}
}
沒有任何複雜的 Adapter,沒有脆弱的 Drawable 狀態切換,手勢滑動與硬體加速幀率直接頂滿 120fps。
3. 核心戰役二:丟棄 AdMob 廣告垃圾,換上抗手勢衝突輪播橫幅
十年前身為獨立開發者,總幻想著在 App 裡放幾個廣告條就能賺大錢(笑,後來發現一個月賺不到兩杯咖啡)。那時候首頁頂部塞了一個刺眼的 Google AdMob 橫幅,不僅拉低整個 App 的質感,還動不動拖慢首頁啟動速度。
這次重構,我直接把 AdMob 徹底拔除,改用純 Compose 自研了一個極具現代感的 焦點話題輪播橫幅(PromotionCarousel)。
在刻這個元件時,很多人的公版寫法只是無腦塞個 delay(4500) 然後觸發換頁。結果真機上一測,災難發生了:當使用者手指正按在畫面上左右拖曳卡片時,背景協程突然執行 animateScrollToPage,硬生生把畫面扯走!手勢瞬間撕裂卡死!
在生產代碼中,必須加入手勢監聽防禦:
// 純 Compose 打造的零廣告話題焦點輪播(含手勢防衝突保護)
@Composable
fun PromotionCarousel(
promotions: List<Promotion>,
onPromotionClick: (Promotion) -> Unit
) {
val pagerState = rememberPagerState(pageCount = { promotions.size })
// 4.5 秒自動平滑輪播(使用者手指拖曳時主動暫停)
LaunchedEffect(pagerState) {
while (true) {
delay(4500)
if (promotions.isNotEmpty() && !pagerState.isScrollInProgress) {
val nextPage = (pagerState.currentPage + 1) % promotions.size
pagerState.animateScrollToPage(nextPage)
}
}
}
Column(modifier = Modifier.fillMaxWidth()) {
HorizontalPager(state = pagerState, modifier = Modifier.height(180.dp)) { page ->
PromotionCard(promotion = promotions[page], onClick = { onPromotionClick(promotions[page]) })
}
// Material 3 風格細長條進度指示點
CarouselDotsIndicator(total = promotions.size, current = pagerState.currentPage)
}
}
沒有外部廣告 SDK 的臃腫追蹤,手勢流暢自然,啟動零延遲。
4. 核心戰役三:Room 快取與 MVI SSOT 的無縫串接
畫面復刻得再漂亮,如果網路一斷線就整頁白屏,那就是個花瓶。
在 modern-android 分支裡,我們為所有頁面補齊了完整的 Room Database 本地快取層,並與 MVI 管線形成完美的單一真理來源(Single Source of Truth, SSOT)全鏈路串接:
flowchart LR
RemoteAPI["遠端 API"] -->|背景無感同步| RoomDB["Room 本地資料庫 (SSOT)"]
RoomDB -->|Flow 響應式串流| Repository["VoteRepository"]
Repository -->|不可變領域實體| ViewModel["MVI ViewModel"]
ViewModel -->|UiState 重組| Screen["Compose 畫面"]
當使用者打開 App:
- 0ms 離線秒開:UI 優先從 Room 資料庫載入本地快取資料,使用者手指一劃立刻有反應;
- 背景無感更新:Repository 在背景協程發起網路請求,資料抓回來後悄悄寫入 Room;
- Room 自動驅動 UI 重組:Room 的 Flow 偵測到資料庫變動,自動發射最新清單,UI 平滑更新,使用者完全感受不到載入菊花的存在。
這才是我們要的怠惰境界——讓框架自己去打架,使用者開心滑,我們安心看劇。
5. 完美畫面的死局與收工
把全套 5 大鏈路、十幾張畫面與細節彈窗 100% 復刻進 Material 3 之後,我看著手上這隻運行的 Funny Vote,成就感簡直爆棚。
它擁有頂級的 MVI 單向流、極速的 120Hz Compose 繪製、無廣告的純淨視覺、以及離線可用的 Room 快取。
但就在這時,一個殘酷的現實狠狠給了我一記悶棍:
這隻 App 的所有資料,底層連的依然是 2016 年那台早已停機除役的老伺服器。
換句話說,現在整隻 App 雖然皮囊與骨架天下無敵,但它的心臟是死的!
使用者點擊投票,沒有遠端資料庫可以落庫;想發起一個新投票給朋友,朋友的手機根本看不到!
(這就像你費盡千辛萬苦組裝了一台頂級的法拉利跑車,結果發現油箱裡沒有油,只能推著它在車庫裡拍照。)
一個不能連線與朋友即時互動的投票 App,根本沒有靈魂。
這也直接逼出了我們整個十年演進史上最瘋狂的一場大手術:在下一篇 feature/firebase-backend 分支裡,我們將在不花一毛錢伺服器租金的前提下,用 Firebase Serverless 為 Funny Vote 進行一場終極的「雲端換心」!
以上是本次小弟的感想,我真的懶得貼落落長的全專案 CODE,各位真的想看完整復刻細節,去 GitHub 翻 modern-android 分支吧,通通都放在那,不浪費版面。
感謝觀看,收工。
《Funny Vote 十年演進系列》章節導航

發佈留言