:斷點續(xù)傳與內(nèi)存優(yōu)化)
1. 移動端大文件上傳的痛點與整體思路拆解做過移動端文件上傳的朋友應該都有體會一個幾百兆的視頻直接往服務端扔結(jié)果基本只有兩種要么請求超時要么內(nèi)存直接爆掉。尤其是現(xiàn)在短視頻、在線教育、社交分享這類場景用戶隨手拍一段視頻就是幾十上百兆如果還用傳統(tǒng)的“一次性表單上傳”思路用戶體驗會非常糟糕。我最近在一個跨平臺應用項目里就遇到了這個問題需求很明確用戶從相冊選一段視頻前端做分片處理然后逐片上傳到服務端最后在服務端合并成完整文件。這個方案的核心價值在于三個層面。第一是穩(wěn)定性大文件拆成小塊之后單個請求的體積可控網(wǎng)絡抖動導致的失敗只需要重傳某一片而不是整個文件從頭再來。第二是內(nèi)存友好移動設備的內(nèi)存本來就緊張把整個視頻讀進內(nèi)存再上傳很容易觸發(fā)系統(tǒng)殺進程分片之后每次只處理一小塊數(shù)據(jù)內(nèi)存占用曲線會平緩很多。第三是可恢復性分片上傳天然支持斷點續(xù)傳用戶切到后臺再回來或者網(wǎng)絡短暫中斷都能從已上傳的分片繼續(xù)不用重新開始。適合閱讀這篇內(nèi)容的人我大致分三類。一類是剛接觸Flutter跨平臺開發(fā)、正在做文件上傳功能的開發(fā)者你可以直接參考這里的流程和代碼結(jié)構(gòu)。另一類是有一定經(jīng)驗但沒處理過大文件分片的后端或全棧工程師這里會講到分片策略、合并邏輯和校驗機制。還有一類是技術負責人需要評估分片上傳方案的復雜度和落地成本我也會把踩過的坑和取舍講清楚。整體方案我采用的是“前端分片 逐片上傳 服務端合并”的經(jīng)典架構(gòu)。前端負責讀取視頻文件、按固定大小切分、維護上傳狀態(tài)服務端負責接收分片、臨時存儲、校驗完整性、最終合并。這個架構(gòu)不依賴特定的云服務自己搭一套也能跑起來靈活性比較高。下面我會從技術選型、核心細節(jié)、實操流程、問題排查幾個維度展開把每個環(huán)節(jié)的“為什么”講透。2. 技術選型與核心細節(jié)解析2.1 為什么選Flutter做視頻選擇與分片F(xiàn)lutter本身是跨平臺的一套代碼能跑在Android和iOS上這對需要快速覆蓋多端的項目來說很關鍵。視頻選擇這塊社區(qū)里比較成熟的方案是image_picker和file_picker這兩個庫。image_picker對相冊視頻的支持比較直接調(diào)用pickVideo方法就能拿到文件路徑file_picker則更通用能選任意類型文件但視頻預覽體驗稍弱。我最終選了image_picker因為它的API更簡潔而且對相冊權(quán)限的處理比較完善。拿到文件路徑之后分片操作其實是在Dart層做的。這里有個關鍵點不要試圖把整個視頻讀成字節(jié)數(shù)組再切分。我試過用File.readAsBytes()一次性讀取一個200MB的視頻直接讓內(nèi)存飆到幾百兆低端機當場卡死。正確做法是用RandomAccessFile或者File.openRead()配合Stream按偏移量逐段讀取。Dart的File類提供了open方法返回RandomAccessFile可以精確控制讀取位置和長度這樣每次只把一片的數(shù)據(jù)加載到內(nèi)存。分片大小我建議設在1MB到5MB之間。太小會導致請求數(shù)量暴增服務端壓力大太大又失去了分片的意義。我實測下來2MB是一個比較平衡的值一個100MB的視頻大概50片上傳過程比較流暢。當然這個值可以根據(jù)網(wǎng)絡狀況動態(tài)調(diào)整比如WiFi下用5MB移動網(wǎng)絡下用1MB但那是進階優(yōu)化第一版先固定值跑通再說。2.2 分片上傳的通信協(xié)議設計分片上傳不是簡單地把文件切開發(fā)出去就完事了前后端需要約定一套協(xié)議。我設計的接口大致是這樣的前端每次上傳一個分片請求里帶上fileId文件唯一標識、chunkIndex當前分片序號、totalChunks總分片數(shù)、chunkData分片二進制數(shù)據(jù)。服務端收到后把分片存到以fileId命名的臨時目錄下文件名用chunkIndex區(qū)分。這里有個細節(jié)容易被忽略fileId的生成時機。我一開始是在前端生成一個UUID但后來發(fā)現(xiàn)如果用戶重復選擇同一個視頻fileId會變導致服務端無法識別這是同一個文件。更好的做法是用文件的哈希值或者“文件名文件大小修改時間”組合生成一個穩(wěn)定ID。不過計算哈希需要讀取整個文件對大文件來說耗時較長所以我最終用了“文件名大小最后修改時間戳”的MD5既穩(wěn)定又不用全量讀取。另一個關鍵點是分片校驗。每個分片上傳時我會額外傳一個chunkHash服務端收到后校驗分片完整性。如果校驗失敗返回錯誤碼前端重傳這一片。全部上傳完成后前端再發(fā)一個“合并請求”服務端按順序拼接所有分片并計算最終文件的哈希和前端傳來的總哈希比對確保合并結(jié)果正確。2.3 斷點續(xù)傳的狀態(tài)管理斷點續(xù)傳的核心是“知道哪些片已經(jīng)傳成功了”。我的做法是在前端維護一個上傳狀態(tài)對象記錄fileId、已上傳的分片索引列表、總分片數(shù)。每次上傳前先請求服務端的“檢查接口”服務端返回已接收的分片列表前端對比后只上傳缺失的部分。這個狀態(tài)對象需要持久化否則應用被殺掉再打開就丟了。我用的是shared_preferences存一個JSON字符串結(jié)構(gòu)很簡單{fileId: {uploaded: [0,1,2], total: 50}}。每次分片上傳成功后更新這個對象應用啟動時讀取如果有未完成的任務就提示用戶繼續(xù)上傳。注意狀態(tài)持久化不要存分片數(shù)據(jù)本身只存索引和元信息。分片數(shù)據(jù)是臨時的存了反而占空間而且可能過期。2.4 服務端合并策略與存儲設計服務端我用的是Node.js配合Express臨時分片存在本地磁盤的temp/{fileId}/目錄下。合并的時候按chunkIndex排序用fs.createWriteStream逐片追加寫入最終文件。這里有個坑不要用fs.readFileSync讀所有分片再拼接那樣內(nèi)存會爆。正確做法是用流式寫入每次讀一片寫一片內(nèi)存占用恒定。合并完成后我會做三件事一是計算最終文件的MD5和前端傳來的總哈希比對二是刪除臨時分片目錄釋放空間三是把最終文件移到正式存儲目錄返回訪問URL。如果比對失敗說明分片有問題需要清理并讓前端重新上傳。存儲設計上我建議臨時目錄和正式目錄分開臨時目錄可以設置定時清理任務比如超過24小時未合并的分片自動刪除避免磁盤被占滿。正式目錄按日期分文件夾比如uploads/2025/01/15/方便管理和備份。3. 實操過程與核心環(huán)節(jié)實現(xiàn)3.1 視頻選擇與文件信息讀取第一步是讓用戶選視頻。用image_picker的代碼大概長這樣final ImagePicker picker ImagePicker(); final XFile? video await picker.pickVideo(source: ImageSource.gallery); if (video null) return; final File file File(video.path); final int fileSize await file.length(); final String fileName video.name; final DateTime lastModified await file.lastModified();拿到文件后先算fileId。我用的是crypto庫的MD5final String rawId $fileName-$fileSize-${lastModified.millisecondsSinceEpoch}; final String fileId md5.convert(utf8.encode(rawId)).toString();然后算總分片數(shù)const int chunkSize 2 * 1024 * 1024; // 2MB final int totalChunks (fileSize / chunkSize).ceil();這里要注意fileSize是字節(jié)數(shù)chunkSize也是字節(jié)除法之后向上取整。比如一個5MB的文件totalChunks就是3最后一片可能不足2MB這是正常的。3.2 分片讀取與上傳的完整流程分片讀取我用的是RandomAccessFilefinal RandomAccessFile raf await file.open(); for (int i 0; i totalChunks; i) { final int start i * chunkSize; final int end (start chunkSize fileSize) ? fileSize : start chunkSize; final int length end - start; await raf.setPosition(start); final Uint8List chunk await raf.read(length); // 上傳這一片 await uploadChunk(fileId, i, totalChunks, chunk); } await raf.close();uploadChunk方法用http庫的MultipartRequest發(fā)送Futurevoid uploadChunk(String fileId, int index, int total, Uint8List data) async { final uri Uri.parse(https://your-server.com/upload/chunk); final request http.MultipartRequest(POST, uri); request.fields[fileId] fileId; request.fields[chunkIndex] index.toString(); request.fields[totalChunks] total.toString(); request.files.add(http.MultipartFile.fromBytes(chunk, data, filename: chunk_$index)); final response await request.send(); if (response.statusCode ! 200) { throw Exception(分片 $index 上傳失敗); } }這里有個性能優(yōu)化點不要串行上傳所有分片。串行雖然簡單但速度慢。我改成了并發(fā)上傳用Future.wait控制并發(fā)數(shù)比如同時傳3片。但并發(fā)數(shù)不能太高否則移動網(wǎng)絡下容易超時而且服務端壓力也大。我實測3到5是比較合適的值。final ListFuturevoid tasks []; for (int i 0; i totalChunks; i) { if (uploadedChunks.contains(i)) continue; // 跳過已上傳的 tasks.add(uploadChunk(fileId, i, totalChunks, chunkData)); if (tasks.length 3) { await Future.wait(tasks); tasks.clear(); } } if (tasks.isNotEmpty) await Future.wait(tasks);3.3 服務端接收分片與合并的代碼實現(xiàn)服務端用Express接收分片const express require(express); const multer require(multer); const fs require(fs); const path require(path); const app express(); const upload multer({ dest: temp/ }); app.post(/upload/chunk, upload.single(chunk), (req, res) { const { fileId, chunkIndex, totalChunks } req.body; const tempDir path.join(__dirname, temp, fileId); if (!fs.existsSync(tempDir)) { fs.mkdirSync(tempDir, { recursive: true }); } const chunkPath path.join(tempDir, chunkIndex); fs.renameSync(req.file.path, chunkPath); res.json({ code: 0, message: ok }); });合并接口app.post(/upload/merge, async (req, res) { const { fileId, totalChunks, fileName } req.body; const tempDir path.join(__dirname, temp, fileId); const finalDir path.join(__dirname, uploads, new Date().toISOString().slice(0, 10)); if (!fs.existsSync(finalDir)) { fs.mkdirSync(finalDir, { recursive: true }); } const finalPath path.join(finalDir, fileName); const writeStream fs.createWriteStream(finalPath); for (let i 0; i totalChunks; i) { const chunkPath path.join(tempDir, i.toString()); const chunkData fs.readFileSync(chunkPath); writeStream.write(chunkData); } writeStream.end(); writeStream.on(finish, () { // 清理臨時目錄 fs.rmSync(tempDir, { recursive: true, force: true }); res.json({ code: 0, url: /uploads/${fileName} }); }); });這段代碼里fs.readFileSync讀單片是沒問題的因為單片只有2MB內(nèi)存占用可控。但如果你把分片大小設成50MB那就得改成流式讀取了。3.4 上傳進度與用戶反饋的實現(xiàn)用戶需要知道上傳到哪了。我在前端維護一個progress變量每上傳成功一片就更新double progress uploadedCount / totalChunks;然后用StreamBuilder或者setState刷新UI。這里有個細節(jié)進度更新不要太頻繁否則UI會卡。我是在每片上傳成功后更新一次50片就是50次更新完全沒問題。如果你把分片設得很小比如100KB那更新次數(shù)會很多可以考慮每5片更新一次。另外上傳過程中要允許用戶取消。我用了一個bool _isCancelled標志在循環(huán)里檢查如果取消了就跳出循環(huán)并調(diào)用服務端的“取消上傳”接口清理臨時分片。提示取消上傳后記得把持久化的狀態(tài)也清掉否則下次進來還會提示繼續(xù)上傳。4. 常見問題與排查技巧實錄4.1 分片上傳中的典型問題速查表問題現(xiàn)象可能原因排查方法解決方案上傳到一半卡住網(wǎng)絡超時或并發(fā)過高查看日志中哪個分片沒返回降低并發(fā)數(shù)增加超時重試合并后文件損壞分片順序錯亂或缺失比對分片數(shù)量和索引合并前校驗分片完整性內(nèi)存占用飆升一次性讀取整個文件用Profiler看內(nèi)存曲線改用RandomAccessFile逐片讀斷點續(xù)傳失效狀態(tài)未持久化或fileId變化檢查shared_preferences內(nèi)容用穩(wěn)定fileId及時保存狀態(tài)服務端磁盤占滿臨時分片未清理查看temp目錄大小加定時清理任務iOS上傳失敗權(quán)限或后臺限制查看系統(tǒng)日志申請相冊權(quán)限處理后臺任務4.2 我踩過的三個坑與解決方案第一個坑是iOS后臺上傳被系統(tǒng)掛起。iOS對后臺任務限制很嚴應用切到后臺幾秒后網(wǎng)絡請求就會被暫停。我一開始沒處理用戶切出去回個消息上傳就斷了。后來用了background_fetch或者flutter_background_service這類庫把上傳任務放到后臺isolate里跑情況好了很多。但要注意iOS后臺時間有限大文件還是建議提示用戶保持前臺。第二個坑是分片哈希校驗的性能問題。我一開始對每個分片都算MD5結(jié)果發(fā)現(xiàn)CPU占用很高上傳速度反而慢了。后來改成只對分片做簡單的長度校驗總文件才做MD5。這樣既保證了完整性又不會拖慢速度。如果你對安全性要求極高可以保留分片哈希但要做好性能測試。第三個坑是服務端合并時的文件句柄泄漏。我用fs.createWriteStream寫文件但忘記在finish事件里關閉流導致大量文件句柄沒釋放跑一段時間后服務端就報“too many open files”。后來加了writeStream.close()和錯誤處理才解決。這個坑很隱蔽因為短期內(nèi)看不出問題跑久了才暴露。4.3 性能優(yōu)化的幾個實用技巧技巧一動態(tài)分片大小。根據(jù)網(wǎng)絡類型調(diào)整分片大小WiFi下用5MB4G用2MB弱網(wǎng)用512KB。這樣能在不同網(wǎng)絡下都有不錯的表現(xiàn)。實現(xiàn)上可以用connectivity_plus庫檢測網(wǎng)絡類型。技巧二分片壓縮。如果視頻不需要原始畫質(zhì)可以在前端用ffmpeg做壓縮再上傳。不過Flutter里集成ffmpeg比較重我一般建議在服務端做轉(zhuǎn)碼前端只負責上傳原始文件。技巧三秒傳檢測。上傳前先算文件哈希請求服務端檢查是否已存在相同文件。如果存在直接返回URL不用上傳。這個對熱門視頻特別有效能省大量帶寬。但哈希計算需要讀整個文件對大文件來說耗時可以只算前1MB的哈希做粗略判斷。技巧四分片預簽名URL。如果服務端支持對象存儲可以給每個分片生成預簽名URL前端直接傳到存儲服務不經(jīng)過應用服務器。這樣能大幅降低服務器壓力但架構(gòu)會復雜一些適合大規(guī)模場景。注意預簽名URL有有效期生成后要盡快使用否則會過期導致上傳失敗。4.4 安全性與合規(guī)性注意事項分片上傳涉及文件寫入安全上要特別注意幾點。一是文件類型校驗不能只信前端傳來的擴展名服務端要檢查文件頭魔數(shù)確保是真正的視頻文件。二是分片大小限制防止惡意用戶上傳超大分片撐爆內(nèi)存。三是fileId防猜測不要用自增ID用UUID或者哈希避免被遍歷。四是臨時目錄權(quán)限確保只有應用進程能讀寫防止其他用戶訪問。另外如果應用面向多用戶要做好鑒權(quán)。每個上傳請求都要帶token服務端驗證用戶身份后再允許寫入。合并時也要檢查該用戶是否有權(quán)限操作這個fileId防止越權(quán)。4.5 測試與驗證的完整流程上線前我做了幾輪測試。第一輪是功能測試用不同大小的視頻10MB、100MB、500MB跑完整流程確認能選、能分、能傳、能合并。第二輪是異常測試模擬網(wǎng)絡中斷、服務端重啟、磁盤滿等情況看斷點續(xù)傳和錯誤處理是否正常。第三輪是性能測試用JMeter或者wrk壓測服務端看并發(fā)上傳時的吞吐量和響應時間。測試時我建議用真機不要只用模擬器。模擬器的網(wǎng)絡和存儲行為和真機差別很大尤其是iOS模擬器后臺任務行為和真機完全不同。真機上還要測不同網(wǎng)絡環(huán)境比如WiFi切4G、弱網(wǎng)、高延遲這些場景下分片上傳的表現(xiàn)才是真實的。驗證合并結(jié)果時我會用md5sum對比前后端文件哈希。如果一致說明分片和合并都沒問題。如果不一致就逐片比對找出是哪一片出了問題。這個排查過程比較耗時但能精確定位問題。5. 方案擴展與個人經(jīng)驗體會這套分片上傳方案跑通之后我做了幾個擴展。一個是多文件并發(fā)上傳用戶一次選多個視頻每個文件獨立維護狀態(tài)用隊列控制總并發(fā)數(shù)。另一個是上傳優(yōu)先級小文件優(yōu)先傳大文件后臺慢慢傳提升用戶感知速度。還有一個是失敗自動重試分片上傳失敗后自動重試3次超過次數(shù)才提示用戶。我個人在實際操作中的體會是分片上傳的復雜度主要不在分片本身而在狀態(tài)管理和異常處理。分片邏輯寫起來很快但斷點續(xù)傳、并發(fā)控制、錯誤恢復這些才是真正花時間的地方。我建議第一版先把串行上傳跑通確認流程沒問題再逐步加并發(fā)、加斷點續(xù)傳、加動態(tài)分片。不要一上來就追求完美那樣很容易卡在細節(jié)里出不來。最后再分享一個小技巧日志要打全。每個分片的上傳時間、大小、結(jié)果都記下來出問題的時候一看日志就知道是哪一片、什么原因。我一開始日志打得很簡略排查問題時只能靠猜后來加了詳細日志排查效率提升了好幾倍。這個習慣在移動端開發(fā)里特別重要因為真機調(diào)試不像Web那么方便日志往往是你唯一的線索。