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

現實總是會在工程師最得意的時候,狠狠賞你一記耳光。
上一篇我們聊到了強迫症發作,在 modern-android 分支裡把十年前所有的畫面、導覽教學、關於子頁面 100% 復刻進了 Material 3。
但正如上一篇結尾說的,當我開開心心地把 App 打包安裝到手機上,準備在朋友面前炫耀一番時,當場被冷酷的現實潑了一盆冰水:
「幹,後端伺服器十年前早就欠費關機了啊!」
當年為了讓 Funny Vote 上線,我每個月自掏腰包花 5 美元租了一台便宜的海外 VPS,上面跑著 MySQL、PHP 跟陽春的 REST API。後來 App 下架、生活變忙,信用卡過期沒補,主機商早就把虛擬機給格式化回收了,連一塊硬碟磁區都沒留下。
身為一個信奉「怠惰是開發的源頭、能白嫖雲端免費額度就絕不掏信用卡」的資深工程師,重新租一台 Linux 伺服器、從頭配置 Nginx、設定 SSL 憑證再寫一套後端 CRUD,光用想的就讓人頭皮發麻。
但不能連網、不能跨裝置即時投票的 App,就只是一具沒有靈魂的塑膠人偶。
於是,我開了 feature/firebase-backend 分支,決定展開一場徹底推倒重來的**「Serverless 雲端換心大手術」**:不架任何一台實體伺服器,利用 Google Firebase 的生態圈,讓十年前的老專案在現代雲端架構上滿血復活!
完整分支原始碼傳送門:
1. 殺死自己的架構:為什麼我大膽砍掉了 800 行 Room 代碼?
在決定全面導入 Firebase Cloud Firestore 之後,擺在眼前第一個重大的架構抉擇是:要不要保留上一篇辛苦刻完的 Room 資料庫做本地快取?
很多架構狂熱者最喜歡畫這種「看起來很健全」的架構圖:
Firestore (遠端) ➔ Network DTO ➔ Repository ➔ Room Entity ➔ Room DAO ➔ Domain Model ➔ UiState。
在工位上盯著這條落落長的依賴鏈看十分鐘,我直接罵了句髒話。
大家仔細想一想:
Firestore 官方的 Android SDK 本身就自帶極其恐怖的原生離線持久化引擎(persistentCacheSettings)!當你在裝置斷網時,Firestore SDK 會自動把你的查詢與寫入快取在本地 SQLite 中;一旦網路恢復,SDK 會自動在背景完成 Transaction 同步與衝突消解。
如果你在外面硬套一層 Room:
- 你得為幾十個表多寫 800 多行 DAO、Entity 以及雙向轉換的 Mapper 樣板代碼;
- 你得親手寫一套機制去監聽 Firestore Snapshot、手動把資料寫入 Room,再從 Room 讀出來暴露給 UI;
- 最致命的是,你會親手創造出兩個「本地資料庫」,一旦網路抖動引發快取不一致,你根本不知道畫面上的資料到底是以 Room 為準還是以 Firestore 本地快取為準!
這種脫褲子放屁的過度設計,完全違背了軟體工程的奧坎剃刀原則。
於是,我直接手起刀落,把 Room 相關的模組全部拔除,直接以 Firestore 的本地持久化串流作為單一真理來源(SSOT):
// 2026 年在 FunnyVoteApplication.kt 裡的真實配置:解鎖離線持久化
val firestoreSettings = firestoreSettings {
setLocalCacheSettings(persistentCacheSettings {
// 設定 100MB 本地快取上限,支援離線秒開與背景無感同步
setSizeBytes(100L * 1024 * 1024)
})
}
Firebase.firestore.firestoreSettings = firestoreSettings
代碼量瞬間砍掉 40%,架構乾淨到像吹過秋風的高原。
2. 雲端摳門學:反正規化快取,讀取成本暴降 90%
搞定架構之後,第二個現實考驗立刻殺到眼前:雲端計費模型。
Firestore 的計價邏輯非常單純,甚至可以說是殘酷:它是按照 「Document 讀取次數」 來算錢的(雖然免費額度每天有 50,000 次讀取,但身為白嫖黨,任何浪費都是可恥的)。
在十年前的老關聯式資料庫思維裡,我們很習慣把資料正規化:
- 一個
polls集合(儲存投票標題、作者、時間); - 底下掛一個
options子集合(儲存投票選項與個別得票數)。
假設首頁要展示 20 筆熱門投票,而每筆投票平均有 4 個選項。
如果按照傳統作法:
- 查詢 20 筆
polls➔ 消耗 20 次讀取; - 為了在卡片上顯示前兩大領先選項,得對 20 個投票分別去查子集合 ➔ 消耗 20 次讀取;
- 使用者在首頁隨手下拉刷新一次,80~100 次讀取就這樣蒸發了!
只要有幾個熱心使用者在首頁多刷幾下,免費額度半天就被幹穿,接著就是帳單伺候。
為了省錢,我在 Firestore 的文件設計上祭出了一招經典的 「反正規化快取(Denormalized Cache)」:
// 投票主文件:直接內嵌得票最高的前兩名選項摘要
data class FirestorePoll(
val pollId: String = "",
val title: String = "",
val authorName: String = "",
val totalVotes: Int = 0,
// 關鍵摳門設計:首頁渲染所需資料一步到位,絕不發起二次查詢
val topOptions: List<OptionSummary> = emptyList(),
val searchKeywords: List<String> = emptyList()
)
當有使用者投票時,雲端事務(Transaction)在增加選項票數的同時,順手計算並刷新主文件裡的 topOptions。
這樣一來,首頁加載 20 筆卡片,讀取次數嚴格鎖死在 20 次,讀取成本直接暴降將近 90%!省下的算力與額度,足夠讓這隻 App 免費再跑十年。
3. 雲端全文搜尋救贖:中英混排 Bi-gram 分詞檢索
另一個差點讓人抓狂的坑是:搜尋功能。
十年前在 MySQL 裡,我們隨手寫個 WHERE title LIKE %關鍵字% 就能搞定模糊搜尋。但在分散式 NoSQL 資料庫 Firestore 裡,為了保證百億級別資料的查詢速度,它完全不支援雙向模糊查詢,只支援字首匹配(Prefix match)。
如果你想在搜尋框輸入「AI」或者「投票」,要找到「2026 最新 AI 投票」,原生的 Firestore 直接舉白旗投降。
官方文件的建議是去整合第三方付費服務(例如 Algolia 或 ElasticSearch)——開什麼玩笑?身為免費開源專案,怎麼可能為了搜尋去接付費 SaaS?
最後我採取的解法,是在使用者端建立投票時,自動執行 「Bi-gram 雙字元切詞索引」。注意,遇到英數混排時不能暴力切雙字元,必須先用正則抽離單詞,中文部分再滑動窗口切分:
// 建立投票時自動切分 Bi-gram 索引關鍵字(支援中英混排)
fun generateSearchKeywords(title: String): List<String> {
val clean = title.lowercase().trim()
val set = mutableSetOf<String>()
// 1. 抽取完整英數單詞
val words = Regex("[a-z0-9]+").findAll(clean).map { it.value }
words.forEach { word ->
set.add(word)
for (i in 1..word.length) set.add(word.substring(0, i)) // 前綴索引
}
// 2. 中文字元雙字元滑動窗口切分
val cjk = clean.replace(Regex("[a-z0-9\s]"), "")
for (i in 0 until cjk.length) {
set.add(cjk[i].toString())
if (i + 1 < cjk.length) set.add(cjk.substring(i, i + 2))
}
return set.toList()
}
把切分出來的關鍵字陣列存進主文件的 searchKeywords 欄位。當使用者搜尋「AI」時,查詢語法直接呼叫:
// 毫秒級複合索引查詢,免花一毛錢
firestore.collection("polls")
.whereEqualTo("security", "00")
.whereArrayContains("searchKeywords", query.lowercase())
.limit(20)
不需要依賴任何外部付費服務,零成本實現毫秒級的中英文模糊搜尋。
4. 零信任密碼防禦:拒絕把密碼當白菜傳輸
在原版 Funny Vote 中,有一種「加密投票」功能:發起人可以設定密碼,只有輸入正確密碼的人才能進入投票。
十年前的傻白甜寫法,是把密碼明文跟著 API 一起回傳到手機端,在手機上寫 if (input == poll.password) 做比對。現在回頭看,這簡直是把內褲穿在外面到處跑。
在雲原生架構裡,我們實作了零信任(Zero-Trust)安全體系:
- 主文件
polls/{pollId}絕不包含任何密碼欄位; - 密碼在發起時,經過
SHA-256(pollId:password)加鹽雜湊,寫入獨立的密鑰集合secure_polls; firestore.rules雲端安全規則嚴格禁止任何人遍歷(list)該集合,只允許精確文件命中(get)。
使用者如果輸入錯誤密碼,他在網路層連一條資料都碰不到,徹底杜絕撞庫與逆向破解。
5. 雲端復甦與下一站:AI 接管
這場雲端換心手術歷時兩週,結算戰果:
- 架構全面 Serverless:零維護伺服器成本,零運維負擔;
- 真機跨裝置驗證通過:拿著 Samsung S23 與測試機,在不同網路下點擊投票,得票率長條圖在兩隻手機上即時同步增長;
- 離線與持久化固若金湯:拔掉 Wi-Fi 依然能秒開 App、瀏覽快取並發起離線投票,重連後自動落庫。
看著這款誕生於十年前的 Side Project,在經歷了 Java ➔ MVP ➔ Kotlin ➔ Compose ➔ MVI ➔ Firebase 的六重蛻變後,終於以最完整的姿態平穩運行在 2026 年的 Android 16 真機上,心裡真的有一種難以言喻的感慨。
但這段旅程還沒有結束。
在最後的終局篇裡,我想跳脫純架構的技術細節,聊聊一個更衝擊、也更具時代意義的議題:
看我怎麼在工位上跟 Codex 互掐,抓包它那些看似完美實則致命的逆天資安地雷。
我們下一篇,終局見。
以上是本次小弟的感想,我真的懶得貼落落長的全專案 CODE,各位真的想看完整 Firebase 串接細節,去 GitHub 翻 feature/firebase-backend 分支吧,通通都放在那,不浪費版面。
感謝觀看,收工。
《Funny Vote 十年演進系列》章節導航

發佈留言