現代 Android (API 26+) **必須**使用 **Foreground Service + Notification** 實現後台下載並顯示進度,防止系統殺死進程。`DownloadManager` 系統服務可選,但自定義 UI 需自行實現。
┌─────────────────┐ Intent ┌──────────────────────┐
│ UI Activity │ ──────────────▶ │ DownloadService │
│ (開始/取消按鈕) │ startForeground│ (Foreground Service) │
└─────────────────┘ │ - OkHttp/Retrofit │
│ - 寫入文件流 │
│ - 發送進度廣播/回調 │
└──────────┬───────────┘
│ Broadcast / LiveData
▼
┌──────────────────────┐
│ Notification │
│ (ProgressBar + 文字) │
└──────────────────────┘
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) }
}
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
}
<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>
// 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 |
| UI 實時更新 | Service 發送有序廣播 / 使用 `MediaSession` / `LiveData` 跨進程觀察 |
| 大文件校驗 | 下載完成後計算 MD5/SHA256 與服務端對比 |
| 存儲適配 | API 29+ 使用 `MediaStore` 或 `getExternalFilesDir` 免權限寫入 |
Android 下載進度條標準實現:**Foreground Service (dataSync 類型) + OkHttp 流式寫入 + NotificationCompat.setProgress 更新通知欄**。核心在於**前台服務防殺進程**、**流式寫入邊下載邊更新進度**、**通知欄 ProgressBar 視覺化**。附帶完整 Kotlin 代碼、Manifest 配置、權限適配與進階優化方向。