首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
您现在的位置: 程式碼之家 >> 編程語言 >  >> 更多程式語言 >> 程式設計疑難解答

Android 文件下載進度條實現:Notification + Foreground Service + ProgressBar 完整最佳實踐

     編輯:程式設計疑難解答

核心方案選型

現代 Android (API 26+) **必須**使用 **Foreground Service + Notification** 實現後台下載並顯示進度,防止系統殺死進程。`DownloadManager` 系統服務可選,但自定義 UI 需自行實現。

完整實現架構

┌─────────────────┐     Intent      ┌──────────────────────┐
│   UI Activity   │ ──────────────▶ │ DownloadService      │
│ (開始/取消按鈕)  │  startForeground│ (Foreground Service) │
└─────────────────┘                 │ - OkHttp/Retrofit    │
                                    │ - 寫入文件流         │
                                    │ - 發送進度廣播/回調  │
                                    └──────────┬───────────┘
                                               │ Broadcast / LiveData
                                               ▼
                                    ┌──────────────────────┐
                                    │   Notification       │
                                    │ (ProgressBar + 文字) │
                                    └──────────────────────┘

核心代碼實現 (Kotlin + OkHttp + Coroutines)

1. 前台服務通知構建器

class DownloadNotificationManager(private val context: Context) {
    private val channelId = "download_channel"
    private val notificationId = 1001
    private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
    
    init { createChannel() }
    
    private fun createChannel() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            val channel = NotificationChannel(channelId, "文件下載", NotificationManager.IMPORTANCE_LOW)
            channel.setShowBadge(false)
            notificationManager.createNotificationChannel(channel)
        }
    }
    
    fun showStart(filename: String) {
        val builder = NotificationCompat.Builder(context, channelId)
            .setContentTitle("正在下載: $filename")
            .setContentText("0%")
            .setSmallIcon(R.drawable.ic_download)
            .setProgress(100, 0, false)
            .setOngoing(true)
            .setOnlyAlertOnce(true)
        notificationManager.notify(notificationId, builder.build())
    }
    
    fun updateProgress(progress: Int, currentFile: String = "") {
        val builder = NotificationCompat.Builder(context, channelId)
            .setContentTitle(if (currentFile.isEmpty()) "下載中..." else "下載: $currentFile")
            .setContentText("$progress%")
            .setSmallIcon(R.drawable.ic_download)
            .setProgress(100, progress, false)
            .setOngoing(true)
        notificationManager.notify(notificationId, builder.build())
    }
    
    fun showComplete(filename: String) {
        val builder = NotificationCompat.Builder(context, channelId)
            .setContentTitle("下載完成")
            .setContentText(filename)
            .setSmallIcon(R.drawable.ic_done)
            .setProgress(0, 0, false)
            .setOngoing(false)
            .setAutoCancel(true)
        notificationManager.notify(notificationId, builder.build())
    }
    
    fun showError(msg: String) { /* 類似 */ }
    fun cancel() { notificationManager.cancel(notificationId) }
}

2. 下載前台服務

class DownloadService : Service() {
    private lateinit var notificationManager: DownloadNotificationManager
    private var downloadJob: Job? = null
    private val client = OkHttpClient()
    
    override fun onCreate() {
        super.onCreate()
        notificationManager = DownloadNotificationManager(this)
    }
    
    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        val url = intent?.getStringExtra("url") ?: return START_NOT_STICKY
        val filename = intent?.getStringExtra("filename") ?: "download.bin"
        val saveDir = getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)!!
        val destination = File(saveDir, filename)
        
        notificationManager.showStart(filename)
        startForeground(1001, buildNotification("0%")) // 必須先 startForeground
        
        downloadJob = CoroutineScope(Dispatchers.IO).launch {
            downloadFile(url, destination, filename)
        }
        return START_NOT_STICKY
    }
    
    private suspend fun downloadFile(url: String, file: File, filename: String) {
        val request = Request.Builder().url(url).build()
        client.newCall(request).execute().use { response ->
            if (!response.isSuccessful) throw IOException("HTTP ${response.code}")
            
            val totalLength = response.body?.contentLength() ?: 0
            var downloaded = 0L
            val sink = file.outputStream().buffer()
            
            response.body?.source()?.use { source ->
                val buffer = Buffer()
                while (true) {
                    val read = source.read(buffer, 8192)
                    if (read == -1L) break
                    sink.write(buffer, read)
                    downloaded += read
                    val progress = if (totalLength > 0) (downloaded * 100 / totalLength).toInt() else 0
                    
                    // 更新通知 (主線程)
                    withContext(Dispatchers.Main) {
                        notificationManager.updateProgress(progress, filename)
                    }
                    // 可選:發送廣播/更新 LiveData 給 UI
                }
            }
            sink.close()
        }
        
        withContext(Dispatchers.Main) {
            notificationManager.showComplete(filename)
            stopSelf()
        }
    }.invokeOnCompletion { cause ->
        if (cause != null) {
            withContext(Dispatchers.Main) {
                notificationManager.showError(cause.message ?: "下載失敗")
                stopSelf()
            }
        }
    }
    
    private fun buildNotification(text: String): Notification {
        return NotificationCompat.Builder(this, "download_channel")
            .setContentTitle("下載中...").setContentText(text)
            .setSmallIcon(R.drawable.ic_download).setProgress(100, 0, false)
            .setOngoing(true).build()
    }
    
    override fun onDestroy() {
        downloadJob?.cancel()
        notificationManager.cancel()
        super.onDestroy()
    }
    
    override fun onBind(intent: Intent?): IBinder? = null
}

3. AndroidManifest.xml 註冊 (關鍵權限)

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" /> <!-- API 34+ -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" 
    android:maxSdkVersion="28" /> <!-- API 29+ 使用分區存儲無需 -->

<application ...>
    <service
        android:name=".DownloadService"
        android:foregroundServiceType="dataSync"
        android:exported="false" />
</application>

4. UI 啟動下載

// Activity 中
val intent = Intent(this, DownloadService::class.java).apply {
    putExtra("url", "https://example.com/largefile.zip")
    putExtra("filename", "largefile.zip")
}
ContextCompat.startForegroundService(this, intent)

// 取消下載
stopService(Intent(this, DownloadService::class.java))

進階優化建議

優化點方案
斷點續傳HTTP Range 請求 (`Range: bytes=downloaded-`),記錄已下載長度斷點恢復
多任務隊列服務內維護 `Queue`,順序執行或並發控制 (Semaphore)
UI 實時更新Service 發送有序廣播 / 使用 `MediaSession` / `LiveData` 跨進程觀察
大文件校驗下載完成後計算 MD5/SHA256 與服務端對比
存儲適配API 29+ 使用 `MediaStore` 或 `getExternalFilesDir` 免權限寫入

參考資源

總結

Android 下載進度條標準實現:**Foreground Service (dataSync 類型) + OkHttp 流式寫入 + NotificationCompat.setProgress 更新通知欄**。核心在於**前台服務防殺進程**、**流式寫入邊下載邊更新進度**、**通知欄 ProgressBar 視覺化**。附帶完整 Kotlin 代碼、Manifest 配置、權限適配與進階優化方向。

  1. 上一篇文章:
  2. 下一篇文章:
Copyright © 程式碼之家 All Rights Reserved