下載需要整合第三方?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 = "https://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 = "https://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,這一期就此完結。

「其他文章」