下載需要集成第三方?Android原生下載服務DownloadManager不行嗎?

語言: CN / TW / HK

theme: juejin highlight: a11y-dark


攜手創作,共同成長!這是我參與「掘金日新計劃 · 8 月更文挑戰」的第20天,點擊查看活動詳情

前言

App 內的下載功能也是我們常用的場景,比如下載最新的 Apk 安裝包,還有些會下載圖片,或者資源,插件等場景。

下載不是很簡單的功能嗎?OkHttp就能下載,基於OkHttp實現的一些框架那更多,比較出名的有FileDownloader okdownload RxDownload 等等。

同時我們 Android 系統服務 DownloadManager 同樣可以使用下載服務,他們之間有什麼區別?

一、DownloadManager的默認使用

DownloadManager 是android2.3以後,系統下載的方法。可以讓 Android 設備請求的 URI 被下載到一個特定的目標文件。客户端將會在後台與http交互進行下載,或者在下載失敗,或者連接改變,重新啟動系統後重新下載。還可以進入系統的下載管理界面查看進度。

內部主要包含 DownloadManager.Query 和 DownloadManager.Request 兩個重要類。一個是封裝一些下載請求的參數,一個是用於查詢下載的信息。Request 是必須的,Query是非必須的。

通常使用 DownloadManager 推薦我們使用通知欄展示真正進行下載,並且我們可以跳轉到下載器頁面查看。

```kotlin private fun startDownLoad() {

    //下載鏈接 這裏下載手機B站為示例
    val downloadUrl = "http://dl.hdslb.com/mobile/latest/iBiliPlayer-html5_app_bili.apk"

    val fileName = downloadUrl.substring(downloadUrl.lastIndexOf('/') + 1)
    //這裏下載到指定的目錄,我們存在公共目錄下的download文件夾下
    val fileUri = Uri.fromFile(
        File(
            Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
            System.currentTimeMillis().toString() + "-" + fileName
        )
    )
    //開始構建 DownloadRequest 對象
    val request = DownloadManager.Request(Uri.parse(downloadUrl))

    //構建通知欄樣式
    request.setTitle("測試下載標題")
    request.setDescription("測試下載的內容文本")

    //下載或下載完成的時候顯示通知欄
    request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE or DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)

    //指定下載的文件類型為APK
    request.setMimeType("application/vnd.android.package-archive")

// request.addRequestHeader() //還能加入請求頭 // request.setAllowedNetworkTypes(DownloadManager.Request.NETWORK_WIFI) //能指定下載的網絡

    //指定下載到本地的路徑(可以指定URI)
    request.setDestinationUri(fileUri)

    //開始構建 DownloadManager 對象
    val downloadManager = commContext().getSystemService(DOWNLOAD_SERVICE) as DownloadManager

    //加入Request到系統下載隊列,在條件滿足時會自動開始下載。返回的為下載任務的唯一ID
    val requestID = downloadManager.enqueue(request)

    //註冊下載任務完成的監聽
    commContext().registerReceiver(object : BroadcastReceiver() {

        override fun onReceive(context: Context, intent: Intent) {

            //已經完成
            if (intent.action.equals(DownloadManager.ACTION_DOWNLOAD_COMPLETE)) {

                //獲取下載ID
                val id = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1)
                val uri = downloadManager.getUriForDownloadedFile(id)
                YYLogUtils.w("下載完成了- uri:$uri")

                installApk(uri)

            } else if (intent.action.equals(DownloadManager.ACTION_NOTIFICATION_CLICKED)) {

                //如果還未完成下載,跳轉到下載中心
                YYLogUtils.w("跳轉到下載中心")
                val viewDownloadIntent = Intent(DownloadManager.ACTION_VIEW_DOWNLOADS)
                viewDownloadIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
                context.startActivity(viewDownloadIntent)

            }

        }
    }, IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE))
}

```

註釋的很詳細,步驟如下: 1. 我們封裝一個 Request 對象設置下載的鏈接Uri,設置下載到的目標文件夾,設置是否需要展示通知等。 2. 構建 DownloadManager 服務,把 Request 任務放入隊列,如果滿足條件即可生效。 3. 一般來説我們都希望下載完成之後能處理一些事情,我們就需要監聽完成的廣播(非必須的)。

這裏需要注意的是: 1. 可能需要申請SD卡權限, 2. 如果下載是公共目錄,在Android12以上只有download等少數文件夾是開放的,其他的文件夾可能無法訪問。 3. 如果下載的是沙盒目錄,你無需申請SD卡權限,但是如果外部應用想要訪問到此文件,需要定義FileProvider提供給對方使用(比如Apk安裝)

完成的效果:

我們下載的是一個Apk,由於我們下載到了公共目錄的download文件夾下面,所以我們可以直接調用安裝方法,(注意Android8.0的兼容)

兼容8.0以上 聲明權限 <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />

直接調用即可 kotlin private fun installApk(uri: Uri) { val intent = Intent(Intent.ACTION_VIEW) intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) intent.setDataAndType(uri, "application/vnd.android.package-archive") startActivity(intent) }

效果:

由於測試機器為Android12,所以需要同意未知的安裝包安裝權限

一系列的操作就安裝成功了。

不行!我不能讓我的Apk就這麼暴露在公共目錄下面!我要隱私,我要下載在沙盒裏面!行不行?

當然行,太行了,我們下載到沙盒的目錄中的話,我們只能自己的應用有訪問權限,其他的應用程序訪問就需要FileProvider,這裏簡單的過一下吧。

```xml

        <meta-data
            android:name="android.support.FILE_PROVIDER_PATHS"
            android:resource="@xml/file_paths" />
    </provider>

```

```xml

<!--下載apk-->
<external-path
    name="download"
    path=""/>

```

那麼我們獲取Uri的時候我們就需要通過FileProvider來獲取Uri對象了 java Uri apkUri = FileProvider.getUriForFile(context, "com.meiyue.smartcity.fileprovider", file);

關於FileProvider感覺已經被開發者玩壞了,有機會會單獨出一期,今天的主題是下載服務的使用,我們迴歸主題。

二、DownloadManager的靜默下載

哇,真的能下載了呢!好簡單哦。但是你這麼好Low啊,用户一看就知道我在幹什麼了,我想下載個資源包或插件那怎麼辦,總不能讓用户看到我在下載吧。

萬一偷偷的下載點東西乾點壞事,不是搞得大家都知道了。啊,你這個通知欄也太醜了,只能設置Title Content,又不能定製UI,放棄!

(下載的時候通知欄的樣式是由廠商或系統決定的)

放心,都可以實現的!DownloadManager 其實可以設置不使用通知欄的。

那我怎麼知道進度和狀態?其實 DownloadManager 內部有 Query 可以查詢這些狀態的。那我們實現一個偷偷的靜默下載邏輯看看。

```kotlin private val scheduledExecutorService: ScheduledExecutorService = Executors.newScheduledThreadPool(3)

private fun startDownLoad() {

    //下載鏈接 這裏下載手機B站為示例
    val downloadUrl = "http://dl.hdslb.com/mobile/latest/iBiliPlayer-html5_app_bili.apk"

    val fileName = downloadUrl.substring(downloadUrl.lastIndexOf('/') + 1)
    //這裏下載到指定的目錄,我們存在公共目錄下的download文件夾下
    val fileUri = Uri.fromFile(
        File(
            Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
            System.currentTimeMillis().toString() + "-" + fileName
        )
    )
    //開始構建 DownloadRequest 對象
    val request = DownloadManager.Request(Uri.parse(downloadUrl))

    //下載時候隱藏通知欄
    request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_HIDDEN)

    //指定下載的文件類型為APK
    request.setMimeType("application/vnd.android.package-archive")

// request.addRequestHeader() //還能加入請求頭 // request.setAllowedNetworkTypes(DownloadManager.Request.NETWORK_WIFI) //能指定下載的網絡

    //指定下載到本地的路徑(可以指定URI)
    request.setDestinationUri(fileUri)

    //開始構建 DownloadManager 對象
    val downloadManager = commContext().getSystemService(DOWNLOAD_SERVICE) as DownloadManager

    //加入Request到系統下載隊列,在條件滿足時會自動開始下載。返回的為下載任務的唯一ID
    val requestID = downloadManager.enqueue(request)

    //註冊獲取進度的監聽
    YYLogUtils.w("開始下載:fileUri:$fileUri requestID:$requestID")
    //每秒定時刷新一次
    val command = Runnable {
        getBytesAndStatus(requestID)
    }
    scheduledExecutorService.scheduleAtFixedRate(command, 0, 1, TimeUnit.SECONDS)

    //註冊下載任務完成的監聽
    commContext().registerReceiver(object : BroadcastReceiver() {

        override fun onReceive(context: Context, intent: Intent) {

            //已經完成
            if (intent.action.equals(DownloadManager.ACTION_DOWNLOAD_COMPLETE)) {

                //解綁進度監聽
                scheduledExecutorService.shutdown()

                //獲取下載ID
                val id = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1)
                val uri = downloadManager.getUriForDownloadedFile(id)
                YYLogUtils.w("下載完成了- uri:$uri")

                installApk(uri)

            } else if (intent.action.equals(DownloadManager.ACTION_NOTIFICATION_CLICKED)) {

                //如果還未完成下載,跳轉到下載中心
                YYLogUtils.w("跳轉到下載中心")
                val viewDownloadIntent = Intent(DownloadManager.ACTION_VIEW_DOWNLOADS)
                viewDownloadIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
                context.startActivity(viewDownloadIntent)

            }

        }
    }, IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE))
}

//獲取當前進度,和總進度
private fun getBytesAndStatus(downloadId: Long) {

    val query = DownloadManager.Query().setFilterById(downloadId)
    var cursor: Cursor? = null

    val downloadManager = commContext().getSystemService(DOWNLOAD_SERVICE) as DownloadManager

    try {
        cursor = downloadManager.query(query)
        if (cursor != null && cursor.moveToFirst()) {

// //Notification 標題 // val title = cursor.getString(cursor.getColumnIndex(DownloadManager.COLUMN_TITLE))

// //描述 // val description = cursor.getString(cursor.getColumnIndex(DownloadManager.COLUMN_DESCRIPTION))

            val downloaded = cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR))
            val total = cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_TOTAL_SIZE_BYTES))
            val progress = downloaded * 100 / total

            YYLogUtils.w("當前下載大小:$downloaded 總共大小:$total")
        }
    } finally {
        cursor?.close()
    }

}

private fun installApk(uri: Uri) {
    val intent = Intent(Intent.ACTION_VIEW)
    intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
    intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
    intent.setDataAndType(uri, "application/vnd.android.package-archive")
    startActivity(intent)
}

```

注意點: 1. 一定要設置 VISIBILITY_HIDDEN 才能不顯示通知欄 2. 如果高版本設置 VISIBILITY_HIDDEN 報錯,需要設置權限

<uses-permission android:name="android.permission.DOWNLOAD_WITHOUT_NOTIFICATION" />

  1. 我們使用 Query 來查詢下載的狀態,如果要監聽下載進度,我們使用定時任務即可,比如每一秒查詢一次。(這裏的定時任務可以以任意方式來實現)

這樣我們就可以實現和應用內部OkHttp來下載一樣的效果啦。

通知欄不能自定義UI?現在我們是靜默下載了,你想彈窗展示進度,佈局展示進度,通知欄展示進度,自定義通知欄什麼的,只要拿到下載的進度,那不是任你揉搓了!屬實是想怎麼玩就怎麼玩了。

總結

DownloadManager 同樣很靈活 ,其實他提供了很多 Api 。我們可以使用它實現各種定製化的下載需求。(比如斷點續傳,重新下載等),如有有需求,大家可以基於 DownloadManager 實現一個下載的框架。

我覺得 DownloadManager 對比其他的類似OkHttp這樣的下載框架,最大的一個優點是系統服務,由於它是系統服務,只要我們的App開啟了一個下載任務,那麼退出App,這個下載任務一樣能繼續下載,而使用OkHttp下載就算放在前台Service中,也是有機率掛掉的,而 DownloadManager 則不會。

當然兩種方案都是可以用的,看不同的使用場景了,讓我選的話,如果我做的應用是多媒體類型的,有很多的隊列併發下載,並查看媒體文件之類的,我可能會使用 okdownload ,但是如果我做的就是很普通的應用,大量併發下載的場景不多,我可能就會使用DownloadManager實現了。

同時我們可以基於系統服務進行一些聯動,比如我們之前講到的 WorkManager 。每12小時檢查一下遠程的資源與版本,我們就可以搭配 DownloadManager 在後台偷偷的下載資源與插件。並且他們都支持指定Wifi環境下的下載。簡直完美。

想測試的同學可以看看代碼,運行一下,源碼在此

最後吐槽一句,DownloadManager 可比 坑爹的 LocationManager 好用多了。

好了,我如有講解不到位或錯漏的地方,希望同學們可以指出交流。

如果感覺本文對你有一點點點的啟發,還望你能點贊支持一下,你的支持是我最大的動力。

Ok,這一期就此完結。

「其他文章」