← 開發筆記

為什麼 Lossic 每一首都能秒開?

2026 年 9 月 19 日 · 一個從 Google Drive 串流 24/96 ALAC 的播放器,怎麼做到點下去約 0.2 秒出聲

先說清楚「秒開」在量什麼

我們量的是從手指離開螢幕,到第一個音訊樣本從喇叭出來的時間。不是「開始下載」、不是「播放器顯示 loading」,是出聲。程式裡的做法是監聽播放器的時間軸第一次走過 0——那是我們能觀察到最接近「聲音出來了」的訊號。

這個定義很嚴格,也很誠實。它把播放器建 pipeline、解析檔頭、啟動 audio unit 這些「我們的程式碼之外的時間」全算進去。MVP 訂的驗收線是 1 秒,對象是一首從來沒快取過的 ALAC。

第一個發現:問題不在頻寬,在每一次請求

Google Drive 的檔案下載走 HTTP Range,理論上你可以想切多細就切多細。我們的第一版直覺是「小塊多次」——先抓 1 MB 出聲,再一塊一塊往後補。實測下來是災難:

讀取方式吞吐為什麼
32 次 × 1 MB Range 請求3.3 MB/s每一次請求都要等 Drive ~250 ms 才吐第一個 byte,32 次就是 8 秒純等待
1 次 open-ended Range,持續讀39–42 MB/s延遲只付一次,之後跑滿頻寬

關鍵是那 250 ms 是 Drive 伺服器端的固定成本——不是連線建立(TCP + TLS 只要 51 ms),keep-alive 也降不下來。所以要最小化的是請求的次數,不是每次的大小。Lossic 對每首歌只開一個請求,從 offset 0 一路讀到檔尾,邊收邊寫進快取檔、邊餵給解碼器。

第二個發現:出聲只需要 64 KB

ALAC 裝在 m4a 容器裡,播放器要先讀到 moov atom(檔案的目錄)才知道音訊在哪、怎麼解。如果 moov 在檔尾,就得先跳到檔尾再跳回來——多付一次 250 ms。

我們拿一個 167.9 MB 的 24/96 ALAC 實檔量:moov 從 offset 28 開始、長 45 KB,完整落在前 64 KB 內,而且 tag(曲名、演出者、封面)就在它尾端。所以同一次請求的前 64 KB 到手,播放能力和 Now Playing 的資訊同時拿到,零額外請求。FLAC 更單純,所有 metadata 在前 9 KB。

至於「出聲之前 AVFoundation 實際吃掉多少」——從 log 看是約 600 KB 才到 readyToPlay。這個數字後面會再出現。

第三個發現:我們的程式只佔 100–200 ms

在 iPhone 上實測第一版,誠實的端到端數字是:

網路冷啟動第一個 byte我們的 pipeline(差額)
Wi-Fi800 ms600 ms~200 ms
LTE1700 ms1600 ms~100 ms

這張表教了我們一件事:冷啟動幾乎全部是「拿到第一個 byte」的成本。我們的程式碼再怎麼優化,也只剩 100–200 ms 可以擠。再進一步拆,穩定的公式是 冷啟動 = TTFB + 240 ms,那 240 ms 是 AVFoundation 自己建 pipeline 的時間,TTFB 是 Drive 每個檔案的性質——兩者都不是我們能改的。

LTE 上還多一層無法消除的東西:無線電從 idle 醒過來要 100–500 ms。你點下去的那一刻,天線可能正在睡覺。

結論很乾淨:如果 TTFB 和 AVFoundation 都改不了,唯一的槓桿就是在使用者點下去之前,把 TTFB 付掉。

從點下去到出聲 — 三種情況,依實測數字繪製,5 倍慢速
0250 ms500 ms750 ms1 s
LTE,未預抓實測 1700 ms
無線電喚醒 + 等 Drive 第一個 byte…
→ 1700 ms 才出聲
Wi-Fi,未預抓實測 800 ms
等 Drive 第一個 byte(TTFB 600 ms)
64 KB 到手
AVFoundation
🔊800 ms
已預抓實測 180–270 ms
🔊190 ms — 開頭已在磁碟,零等待
斜紋 = 等網路 綠 = AVFoundation 建 pipeline(約 240 ms,改不了) 深綠 = 從磁碟讀

核心:把延遲搬到你看不到的時候

當前曲目一出聲,無線電是醒的、TLS 連線是熱的——這是最便宜的時刻。Lossic 在這時候開始預抓「資料夾裡的下一首」的前 1.5 MB。1.5 MB 不是拍腦袋:AVFoundation 出聲前約吃 600 KB,1.5 MB 給它兩倍餘裕,同時小到在行動網路上說得過去(約 4 秒的 24/96 ALAC)。

結果:

情境冷啟動(iPhone 16e,Wi-Fi)
已預抓179 / 183 / 185 / 189 / 211 / 213 / 265 ms
未預抓434–1274 ms(完全由該檔案的 TTFB 決定)

連 moov 都在本機,所以比原先估的 240 ms 還低——這就是首頁那個「約 0.2 秒」的來源。

但預抓有嚴格的優先順序,因為跟使用者正在聽的 bytes 搶頻寬,是拿一個真實的卡頓去換一個假設性的卡頓:

  1. 當前曲目永遠優先。出聲之前,任何其他東西都不碰網路。
  2. 先等當前曲目有 8 MB 緩衝(約 20 秒的 24/96 ALAC)才開始預抓。這條規則來自弱 LTE(3.5 Mbps)的實測:立刻預抓會讓正在播的那首以 1–2 KB 一次的速度滴進來。
  3. 只預抓下一首、只抓 1.5 MB,然後停。第三首要等第二首真正開始播、且自己有了 8 MB 緩衝才輪到。Drive 對同帳號的併發存取有節流,每多開一個請求就多付一次 250 ms,都是從正在播的那首身上搶走的。

接到下一首時,播放先從磁碟上那 1.5 MB 起跳(約 0.2 秒出聲),然後從 1.5 MB 的位置開第二個請求補剩下的。這時音訊早就在響了,多付的那次 250 ms 使用者聽不到。

緩存策略 — 三首歌的快取檔如何被填滿(示意,長度非等比)
正在播第 1 首
8 MB 緩衝
請求 1:一路跑到檔尾
整檔落地 → 可離線重播
下一首第 2 首
等第 1 首有 8 MB 緩衝再動
1.5 MB 到手 → cancel 請求 ✂
🔊接手:0.2 秒出聲
請求 2 從 1.5 MB 起補剩下的
再下一首第 3 首
還沒輪到——不跟正在播的搶頻寬
第 2 首有緩衝了 → 才預抓 1.5 MB
① 點播:一首歌一個請求 ② 有 8 MB 緩衝 → 預抓下一首 1.5 MB,然後 cancel ③ 當前曲目整檔落地(Wi-Fi) ④ 接手:從磁碟出聲,再補剩下的 ⑤ 一次只往前看一首

第四個發現:不要忘記已經拿到的東西

有一天測試時網路變得很爛,同一輪 log 裡出現了兩組數字:

情況冷啟動
全新請求(網路已惡化)9861 ms(TTFB 8804 ms)
重用磁碟上已有的 2.9–10.6 MB205 / 208 / 223 / 256 ms

差 40 倍。原因很傻:快取檔本來就在磁碟上,但「哪些位元組已經有了」的索引只活在記憶體裡,每次點播建新物件就等於宣告「什麼都沒有」,從頭重下。更糟的是,使用者實測抓到這一幕:

15:41:57  TAP s1t04 → reusing stream: 36718989 bytes already cached   ← 整首都在
15:42:21  TAP s1t04 → stream: new request at 0                        ← 24 秒後從頭重下

中間只播了三首別的,記憶體裡的串流物件就被淘汰了。檔案還在磁碟上,只是沒人知道。

修法是把「已有區間」的索引持久化成一個 sidecar 檔(JSON,記錄不連續的位元組區間——因為 1.5 MB 的預抓加上後續 seek 會產生 [0, 1.5MB) 和 [20MB, 24MB) 這種不連續的區段)。載入時用磁碟上的實際檔案大小 clamp:iOS 可以隨時清空或截斷快取目錄,索引若超額宣告,播放器會把 0 當音訊供出去——無聲的損毀。寧可少算。

做完之後,app 完全終止再重開:

15:49:44  cache index: recovered 106667847 bytes
15:49:45  ── FIRST AUDIO at +273ms (0 request(s))

0 request(s) 是重點——這次播放沒有發出任何網路請求。之後連續 8 次點播 172–273 ms。這也是為什麼聽完一張專輯,這張專輯就暫時可以完全離線播:每一首在播放時都被完整寫進快取了。

一條連線

還有一個藏在數字裡的 bug。同一個家用 Wi-Fi,筆電量到 TTFB 250 ms,手機卻是 600 ms。差額來自:第一版每首歌建一個新的 URLSession,每次換歌都重做 TCP + TLS handshake;而瀏覽資料夾用的是另一個 session,連暖身都暖不到播放要用的連線。

修法是全 app 一個 URLSession,API 呼叫和音訊串流共用。副作用是好的:你在列資料夾、看專輯的時候,就順便把播放要用的連線暖起來了。

兩個差點毀掉一切的坑

坑一:預抓把整首歌都下載了。我們的串流是「一個 open-ended 請求」,所以「預抓 1.5 MB」的第一版實際上在請求檔案的全部——log 抓到交接時已快取 116 MB。它把正在播那首的頻寬吃光,預抓自己的 TTFB 因此飆到 1264 ms。修法是設 budget:收到 1.5 MB 且沒人在等,就把請求 cancel 掉。

坑二:cancel 用錯成 suspend,整個 app 的網路死掉。suspend 看起來更好——保留連線、不用再付 TTFB。結果幾首歌之後每次點播都開了請求,但第一個 byte 永遠不來。原因:googleapis.com 是 HTTP/2,一個沒人讀取的 stream 會把連線層級的 flow-control window 耗盡,同一條連線上所有其他請求一起餓死。一個被遺忘的 suspended 預抓,鎖住了整條連線。改回 cancel,代價是接手時要重開請求再付一次 250 ms——但那發生在音訊已經在播的時候,1.5 MB ≈ 4 秒緩衝,來得及。

還有一個小的、但我們覺得重要的:量測本身曾經說謊。預抓交接後畫面曾顯示 ttfb 0ms,因為那個數字描述的是最新那個請求,而它還沒收到第一個 byte。從磁碟播放時也曾印 ttfb 0ms——那是宣稱一個從未進行的量測。現在 UI 會說 prefetched 或 served from disk, no network,不印假的 0。一個不誠實的數字比沒有數字更糟,它會把下一輪優化指向錯的方向。

誠實地說:什麼時候不是 0.2 秒

反過來說,什麼時候是 0.2 秒:順著專輯聽(每一首接下來的都被預抓了)、重播最近聽過的任何一首(在磁碟上)、以及 app 重開後回到你昨天聽的專輯。這涵蓋了絕大多數真實的聽音樂方式——人很少隨機在 1 萬首歌裡亂點。

所以,怎麼做到的

沒有魔法,也沒有比別人快的網路。是四件很土的事:

以及一件比較不土的事:每一步都在真實裝置、真實 Drive、真實檔案上量過——包括那幾次量錯的。這篇的每個數字都是 log 裡的,不是估的。


數字的出處:2026 年 8 月 23 日起,iPhone 16e、真實 Google Drive、24/96 ALAC 與 16/44.1 FLAC 實檔。Wi-Fi 為家用網路,LTE 為家中訊號。行動網路上的數字會隨訊號差異很大,表中是我們量到的,不是承諾的。

你買下的音樂,值得比檔案管理器更好的家。