為什麼 Lossic 每一首都能秒開?
- Lossic 首頁寫「約 0.2 秒出聲」,這是 iPhone 上實測的數字(180–270 ms),不是行銷修辭。但它有前提:那首歌的開頭已經在手機上。
- 兩個我們改不了的固定成本:Google Drive 每個請求約 250 ms 才吐出第一個 byte,AVFoundation 從拿到 bytes 到出聲自己要約 240 ms。加起來就是「從零開始」的地板。
- 所以秒開的核心不是把網路變快,是把延遲搬到你點下去之前:當前曲目播放時,趁無線電和連線都是熱的,先把下一首的前 1.5 MB 拿回來。
- 另外三件事讓它撐得住真實世界:一首歌只開一個請求(多次小塊會慢 12 倍)、記住磁碟上已經有的每一個 byte(重播差 40 倍)、全 app 只有一條連線(瀏覽資料夾就順便暖好播放要用的連線)。
- 誠實地說:真正的「第一首」、或你跳到預抓範圍外的歌,還是要付那 ~0.5–1 秒。這篇會講清楚哪些情況是 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-Fi | 800 ms | 600 ms | ~200 ms |
| LTE | 1700 ms | 1600 ms | ~100 ms |
這張表教了我們一件事:冷啟動幾乎全部是「拿到第一個 byte」的成本。我們的程式碼再怎麼優化,也只剩 100–200 ms 可以擠。再進一步拆,穩定的公式是 冷啟動 = TTFB + 240 ms,那 240 ms 是 AVFoundation 自己建 pipeline 的時間,TTFB 是 Drive 每個檔案的性質——兩者都不是我們能改的。
LTE 上還多一層無法消除的東西:無線電從 idle 醒過來要 100–500 ms。你點下去的那一刻,天線可能正在睡覺。
結論很乾淨:如果 TTFB 和 AVFoundation 都改不了,唯一的槓桿就是在使用者點下去之前,把 TTFB 付掉。
核心:把延遲搬到你看不到的時候
當前曲目一出聲,無線電是醒的、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 搶頻寬,是拿一個真實的卡頓去換一個假設性的卡頓:
- 當前曲目永遠優先。出聲之前,任何其他東西都不碰網路。
- 先等當前曲目有 8 MB 緩衝(約 20 秒的 24/96 ALAC)才開始預抓。這條規則來自弱 LTE(3.5 Mbps)的實測:立刻預抓會讓正在播的那首以 1–2 KB 一次的速度滴進來。
- 只預抓下一首、只抓 1.5 MB,然後停。第三首要等第二首真正開始播、且自己有了 8 MB 緩衝才輪到。Drive 對同帳號的併發存取有節流,每多開一個請求就多付一次 250 ms,都是從正在播的那首身上搶走的。
接到下一首時,播放先從磁碟上那 1.5 MB 起跳(約 0.2 秒出聲),然後從 1.5 MB 的位置開第二個請求補剩下的。這時音訊早就在響了,多付的那次 250 ms 使用者聽不到。
第四個發現:不要忘記已經拿到的東西
有一天測試時網路變得很爛,同一輪 log 裡出現了兩組數字:
| 情況 | 冷啟動 |
|---|---|
| 全新請求(網路已惡化) | 9861 ms(TTFB 8804 ms) |
| 重用磁碟上已有的 2.9–10.6 MB | 205 / 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 秒
- 你今天的第一首歌,如果它從沒被播過、也不是上一首的下一首:要付完整的 TTFB + 240 ms。Wi-Fi 上大約 0.5–0.8 秒,LTE 上可能 1–1.7 秒,天線在睡覺的話更久。這是物理,我們不假裝它不存在。
- 跳到預抓範圍外的歌——例如在專輯裡從第 2 首直接點第 9 首——同上。我們只預抓「下一首」,不預抓整張專輯,因為那會跟你正在聽的搶頻寬、也會在行動網路上燒流量。
- Seek 到還沒下載的位置要再付一次 250 ms。
- 快取有上限(預設 2 GB,可調 1/2/5/10 GB),最久沒聽的先被清。它是快取不是離線收藏——真正的「釘住離線」是還在待辦清單上的功能。
反過來說,什麼時候是 0.2 秒:順著專輯聽(每一首接下來的都被預抓了)、重播最近聽過的任何一首(在磁碟上)、以及 app 重開後回到你昨天聽的專輯。這涵蓋了絕大多數真實的聽音樂方式——人很少隨機在 1 萬首歌裡亂點。
所以,怎麼做到的
沒有魔法,也沒有比別人快的網路。是四件很土的事:
- 一首歌一個請求,不切塊。
- 趁連線熱著,先付下一首的延遲。
- 記住磁碟上已經有的每一個 byte,包括 app 重開之後。
- 全 app 一條連線,瀏覽就是暖身。
以及一件比較不土的事:每一步都在真實裝置、真實 Drive、真實檔案上量過——包括那幾次量錯的。這篇的每個數字都是 log 裡的,不是估的。
數字的出處:2026 年 8 月 23 日起,iPhone 16e、真實 Google Drive、24/96 ALAC 與 16/44.1 FLAC 實檔。Wi-Fi 為家用網路,LTE 為家中訊號。行動網路上的數字會隨訊號差異很大,表中是我們量到的,不是承諾的。
你買下的音樂,值得比檔案管理器更好的家。