說說Android版本更新

關於本文DownloadManager版本更新的源碼連接詳見個人開源項目 AppUpdate

前言

版本升級對於app來說已是很是常見的功能了,每次項目的版本迭代、新功能的開發都須要下載更新新版本,經過安裝新版原本實現咱們的迭代。固然除了這種方式,實際上也有熱更新與熱修復的存在,無需安裝的狀況下實現版本的迭代,並且不少大型的項目在有了大量用戶的積累後也大都採起了灰度發佈的功能,先小範圍升級試用,在正式推向市場。今天我只想單純來說講基於系統自帶的DownloadManager來實現的下載更新。android

萬能流程圖

畫圖不易,這張流程圖幾乎包含了app檢查更新的全部涉及到的流程,像流程圖中進度框、下載失敗的彈框,MD5校驗我的以爲能夠不須要,通常像DownloadManager來實現下載更新只須要在後臺下載,下載完成用系統的Notification進行通知便可,而後自動彈出安裝界面,這是個標準的流程。 git

涉及知識概括

  • DownloadManager系統下載服務的相關api及使用。
  • Android M 運行時權限的動態申請,主要涉及讀寫存儲卡權限。
  • Android N 關於文件的訪問權限,不能以file://xxx格式的Uri來訪問文件,須要使用FileProvider,Uri格式爲content://xxx
  • Android O 關於未知來源應用的權限申請。
  • Android Q 增長沙箱並改變了應用程序訪問設備外部存儲上文件的方式,並且不能夠在內部存儲肆意的構建本身的目錄
  • 文件MD5校驗,防止apk下載被攔截篡改及驗證apk文件的完整性。

DownloadManager介紹及使用

介紹

DownloadManager下載管理器是一種處理長時間運行的HTTP下載的系統服務。客戶端能夠請求將URI下載到特定目標文件。下載管理器將在後臺進行下載,負責HTTP交互並在發生故障或跨鏈接更改和系統從新啓動後重試下載。翻譯過來的始終感受很差,如下是官方的原話 (官方傳送門github

The download manager is a system service that handles long-running HTTP downloads. Clients may request that a URI be downloaded to a particular destination file. The download manager will conduct the download in the background, taking care of HTTP interactions and retrying downloads after failures or across connectivity changes and system reboots.shell

Apps that request downloads through this API should register a broadcast receiver for ACTION_NOTIFICATION_CLICKED to appropriately handle when the user clicks on a running download in a notification or from the downloads UI.api

Note that the application must have the Manifest.permission.INTERNET permission to use this class.瀏覽器

從概念上都已經明確說明了DownloadManager系統下載服務的優越性:
1.能夠長時間在後臺運行下載
2.能夠指定任意的下載路徑,也能夠支持Android Q
3.下載過程當中碰見問題或者更改網絡會重試下載,斷點續傳
4.原生系統下載服務,不依賴第三方,兼容性和穩定性無疑最好
5.默認已經幫你封裝好了系統欄通知、wifi/移動網絡/漫遊等等下載限制緩存

下載核心的API

類/常量/方法 介紹
DownloadManager.Query 主要用來在下載的過程當中查詢過濾,好比下載狀態、進度等
DownloadManager.Request 下載服務一些配置、下載地址、下載路徑、通知欄配置、網絡限制、媒體類型等
ACTION_DOWNLOAD_COMPLETE 下載完成後,由下載管理器發送的廣播意圖操做
ACTION_NOTIFICATION_CLICKED 當用戶從系統通知或下載UI單擊正在運行的下載時,下載管理器發送廣播意圖操做
ACTION_VIEW_DOWNLOADS 啓動活動以顯示全部下載的意圖操做,說白了手機系統的下載管理界面
COLUMN_BYTES_DOWNLOADED_SO_FAR 目前下載的字節數,須要下載進度條的用獲得
COLUMN_TOTAL_SIZE_BYTES 下載文件的總大小,單位爲字節,須要下載進度條的用獲得
COLUMN_LOCAL_URI 下載的文件將存儲在Uri中,注意:N以前是file://xxx,N以後是content://xxx
EXTRA_DOWNLOAD_ID 在廣播ACTION_DOWNLOAD_COMPLETE中,可拿到download_id
COLUMN_REASON 提供有關下載狀態的更多詳細信息
COLUMN_STATUS 當前的下載狀態,經過DownloadManager.Query來查詢
STATUS_PENDING 下載開始
STATUS_RUNNING 下載進行中
STATUS_PAUSED 下載暫停,這裏會等待重試,注意這是斷點續傳,暫停緣由能夠經過COLUMN_REASON去查
STATUS_SUCCESSFUL 下載成功
STATUS_FAILED 下載失敗,這裏的失敗不會重試的,緣由能夠經過COLUMN_REASON去查
enqueue(DownloadManager.Request request) 開啓一個下載服務
getMaxBytesOverMobile(Context context) 返回手機移動網絡限定下載的最大值
getMimeTypeForDownloadedFile(long id) 經過download_id查詢下載文件的媒體類型,也就是格式
getRecommendedMaxBytesOverMobile(Context context) 獲取建議的移動網絡下載的大小
getUriForDownloadedFile(long id) 若是文件下載成功,返回文件的Uri
openDownloadedFile(long id) 打開下載的文件,讀文件
query(DownloadManager.Query query) 下載查詢
remove(long... ids) 取消下載並從下載管理器中刪除文件

以上即是DownloadManager下載使用到的核心api了,基本上知足一個正常的下載了,固然並無所有羅列出來,像下載暫停和下載失敗關於COLUMN_REASON的描述 還有不少,就不羅列出來了,下面看看下載更新的代碼片斷:安全

  • 下載核心代碼
// 獲取下載管理器
downloadManager = (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE);
clearCurrentTask();
// 下載地址若是爲null,拋出異常
String downloadUrl = Objects.requireNonNull(appUpdate.getNewVersionUrl());
Uri uri = Uri.parse(downloadUrl);
DownloadManager.Request request = new DownloadManager.Request(uri);
// 下載中和下載完成顯示通知欄
request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED);
if (TextUtils.isEmpty(appUpdate.getSavePath())) {
//使用系統默認的下載路徑 此處爲應用內 /android/data/packages ,因此兼容7.0
request.setDestinationInExternalFilesDir(context, Environment.DIRECTORY_DOWNLOADS, context.getPackageName() + ".apk");
deleteApkFile(Objects.requireNonNull(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS + File.separator + context.getPackageName() + ".apk")));
} else {
// 自定義的下載目錄,注意這是涉及到android Q的存儲權限,建議不要用getExternalStorageDirectory()
request.setDestinationInExternalFilesDir(context, appUpdate.getSavePath(), context.getPackageName() + ".apk");
deleteApkFile(Objects.requireNonNull(context.getExternalFilesDir(appUpdate.getSavePath() + File.separator + context.getPackageName() + ".apk")));
}

// 設置通知欄的標題
request.setTitle(getAppName());
// 設置通知欄的描述
request.setDescription("正在下載中...");
// 設置媒體類型爲apk文件
request.setMimeType("application/vnd.android.package-archive");
// 開啓下載,返回下載id
lastDownloadId = downloadManager.enqueue(request);
// 如須要進度及下載狀態,增長下載監聽
if (!appUpdate.getIsSlentMode()) {
DownloadHandler downloadHandler = new DownloadHandler(this);
downloadObserver = new DownloadObserver(downloadHandler, downloadManager, lastDownloadId);
context.getContentResolver().registerContentObserver(Uri.parse("content://downloads/my_downloads"), true, downloadObserver);
}
複製代碼
  • 下載進度的監聽
    默認採起的是系統的ContentObserver對於本地下載的文件變化監聽進度,也能夠經過開啓定時器每隔必定的時間去查詢當前的下載進度。
Cursor cursor = downloadManager.query(query);
        if (cursor != null && cursor.moveToNext()) {
            int status = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_STATUS));
            int totalSize = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES));
            int currentSize = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR));
            // 當前進度
            int mProgress;
            if (totalSize != 0) {
                mProgress = (currentSize * 100) / totalSize;
            } else {
                mProgress = 0;
            }
            Log.d(TAG,String.valueOf(mProgress));
            switch (status) {
                case DownloadManager.STATUS_PAUSED:
                    // 下載暫停
                    handler.sendEmptyMessage(DownloadManager.STATUS_PAUSED);
                    Log.d(TAG,"STATUS_PAUSED");
                    break;
                case DownloadManager.STATUS_PENDING:
                    // 開始下載
                    handler.sendEmptyMessage(DownloadManager.STATUS_PENDING);
                    Log.d(TAG,"STATUS_PENDING");
                    break;
                case DownloadManager.STATUS_RUNNING:
                    // 正在下載,不作任何事情
                    Message message = new Message();
                    message.what = DownloadManager.STATUS_RUNNING;
                    message.arg1 = mProgress;
                    handler.sendMessage(message);
                    Log.d(TAG,"STATUS_RUNNING");
                    break;
                case DownloadManager.STATUS_SUCCESSFUL:
                    if(!isEnd){
                        // 完成
                        handler.sendEmptyMessage(DownloadManager.STATUS_SUCCESSFUL);
                        Log.d(TAG,"STATUS_SUCCESSFUL");
                    }
                    isEnd = true;
                    break;
                case DownloadManager.STATUS_FAILED:
                    if(!isEnd){
                        handler.sendEmptyMessage(DownloadManager.STATUS_FAILED);
                        Log.d(TAG,"STATUS_FAILED");
                    }
                    isEnd = true;
                    break;
                default:
                    Log.d(TAG,"default");
                    break;
            }
            cursor.close();
        } else {
            Log.d(TAG,"cursor======null");
        }
複製代碼

Android M 運行時權限

android 6.0 版本引入了一種新的權限模式,現在,用戶可直接在運行時管理應用權限。這種模式讓用戶可以更好地瞭解和控制權限,同時爲應用開發者精簡了安裝和自動更新過程。用戶可爲所安裝的各個應用分別授予或撤銷權限。性能優化

對於以 Android 6.0(API級別23)或更高版本爲目標平臺的應用,請務必在運行時檢查和請求權限。要肯定您的應用是否已被授予權限,請調用新增的checkSelfPermission()方法。要請求權限,請調用新增的requestPermissions()方法。即便您的應用並不以Android6.0(API級別23)爲目標平臺,您也應該在新權限模式下測試您的應用.官方傳送門bash

因爲下載須要讀寫文件,Android M 須要動態申請運行時權限,關於如何查看運行時權限,能夠經過AndroidStudio的Terminal終端執行以下命令:

  • 按組列出權限和狀態:

$ adb shell pm list permissions -d -g

  • 授予或撤銷一項或多項權限:

$ adb shell pm [grant|revoke] ...

  • 列出全部權限:

$ adb shell pm list permissions -s

M運行時權限請求代碼片斷:

/**
     * 判斷存儲卡權限
     */
    private void requestPermission() {
        //權限判斷是否有訪問外部存儲空間權限
        int flag = ActivityCompat.checkSelfPermission(getActivity(), Manifest.permission.WRITE_EXTERNAL_STORAGE);
        if (flag != PackageManager.PERMISSION_GRANTED) {
            if (ActivityCompat.shouldShowRequestPermissionRationale(getActivity(), Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
                // 用戶拒絕過這個權限了,應該提示用戶,爲何須要這個權限。
                Toast.makeText(getActivity(), getResources().getString(R.string.update_permission), Toast.LENGTH_LONG).show();
            }
            // 申請受權
            requestPermissions(new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, 1);
        } else {
            // 擁有權限,執行下載相關邏輯
        }
    }

    @Override
    public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults);
        if (requestCode == 1) {
            if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
                 // 授予權限,執行下載相關邏輯
            } else {
                //拒絕權限,給出提示
                Toast.makeText(getActivity(), getResources().getString(R.string.update_permission), Toast.LENGTH_LONG).show();
                dismiss();
            }
        }
    }
}

複製代碼

Android N 文件的訪問權限

爲了提升私有文件的安全性,面向Android7.0或更高版本的應用私有目錄被限制訪問(0700)。此設置可防止私有文件的元數據泄漏,如它們的大小或存在性。此權限更改有多重反作用:

  • 私有文件的文件權限不該再由全部者放寬,爲使用 MODE_WORLD_READABLE 和/或 MODE_WORLD_WRITEABLE 而進行的此類嘗試將觸發 SecurityException

注:迄今爲止,這種限制尚不能徹底執行。應用仍可能使用原生 API 或 File API 來修改它們的私有目錄權限。可是,咱們強烈反對放寬私有目錄的權限

  • 傳遞軟件包網域外的file://URI可能給接收器留下沒法訪問的路徑。所以,嘗試傳遞 file:// URI 會觸發FileUriExposedException。分享私有文件內容的推薦方法是使用 FileProvider。
  • DownloadManager 再也不按文件名分享私人存儲的文件。舊版應用在訪問COLUMN_LOCAL_FILENAME 時可能出現沒法訪問的路徑。面向Android7.0或更高版本的應用在嘗試訪問COLUMN_LOCAL_FILENAME時會觸發SecurityException。經過使用DownloadManager.Request.setDestinationInExternalFilesDir()DownloadManager.Request.setDestinationInExternalPublicDir()將下載位置設置爲公共位置的舊版應用仍能夠訪問COLUMN_LOCAL_FILENAME中的路徑,可是咱們強烈反對使用這種方法。對於由DownloadManager公開的文件,首選的訪問方式是使用ContentResolver.openFileDescriptor()
    下面看一下代碼片斷:

清單文件

<provider
   android:name=".DownloadFileProvider"
   android:authorities="${applicationId}.fileProvider"
   android:exported="false"
   android:grantUriPermissions="true">
      <meta-data
        android:name="android.support.FILE_PROVIDER_PATHS"
        android:resource="@xml/update_file_path" />
    </provider>
複製代碼

文件存儲配置

<paths>
    <external-path
        name="external_storage_root"
        path="." />
    <files-path
        name="files-path"
        path="." />
    <cache-path
        name="cache-path"
        path="." />
    <!--/storage/emulated/0/Android/data/...-->
    <external-files-path
        name="external_file_path"
        path="." />
    <!--表明app 外部存儲區域根目錄下的文件 Context.getExternalCacheDir目錄下的目錄-->
    <external-cache-path
        name="external_cache_path"
        path="." />
    <!--配置root-path。這樣子能夠讀取到sd卡和一些應用分身的目錄,聽說應用分身有bug-->
    <root-path
        name="root-path"
        path="" />
/paths>
複製代碼

app安裝

File downloadFile = getDownloadFile();
    Intent intent = new Intent(Intent.ACTION_VIEW);
    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
        intent.setDataAndType(Uri.fromFile(downloadFile), "application/vnd.android.package-archive");
    } else {
        if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
            boolean allowInstall = context.getPackageManager().canRequestPackageInstalls();
            if (!allowInstall) {
                //不容許安裝未知來源應用,請求安裝未知應用來源的權限
                if (mainPageExtraListener != null) {
                    mainPageExtraListener.applyAndroidOInstall();
                }
                return;
            }
        }
        //Android7.0以後獲取uri要用contentProvider
        Uri apkUri = FileProvider.getUriForFile(context.getApplicationContext(), context.getPackageName() + ".fileProvider", downloadFile);
        //Granting Temporary Permissions to a URI
        intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
        intent.setDataAndType(apkUri, "application/vnd.android.package-archive");
    }
        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        context.startActivity(intent);
複製代碼

Android O 關於未知來源應用

針對 8.0 的應用須要在 AndroidManifest.xml 中聲明REQUEST_INSTALL_PACKAGES 權限,不然將沒法進行應用內升級

清單文件

<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
複製代碼

權限檢測

/**
     * 檢測到無權限安裝未知來源應用,回調接口中須要從新請求安裝未知應用來源的權限
     */
    @RequiresApi(api = Build.VERSION_CODES.O)
    @Override
    public void applyAndroidOInstall() {
        //請求安裝未知應用來源的權限
        ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.REQUEST_INSTALL_PACKAGES}, INSTALL_PACKAGES_REQUESTCODE);
    }

    @Override
    public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults);
        // 8.0的權限請求結果回調
        if (requestCode == INSTALL_PACKAGES_REQUESTCODE) {
            // 受權成功
            if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
               // 執行安裝apk的邏輯...
            } else {
                // 受權失敗,引導用戶去未知應用安裝的界面
                if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
                    //注意這個是8.0新API
                    Uri packageUri = Uri.parse("package:" + getPackageName());
                    Intent intent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, packageUri);
                    startActivityForResult(intent, GET_UNKNOWN_APP_SOURCES);
                }
            }
        }
    }

    @Override
    protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
        super.onActivityResult(requestCode, resultCode, data);
        //8.0應用設置界面未知安裝開源返回時候
        if (requestCode == GET_UNKNOWN_APP_SOURCES) {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                boolean allowInstall = getPackageManager().canRequestPackageInstalls();
                if (allowInstall) {
                   // 執行安裝app的邏輯...
                } else {
                   // 拒絕權限邏輯...
                   Toast.makeText(MainActivity.this,"您拒絕了安裝未知來源應用,應用暫時沒法更新!",Toast.LENGTH_SHORT).show();
                }
            }
        }
    }
複製代碼

Android Q 存儲變動

目前Android Q官網上仍是處於Beta版,Android Q最大的變化 無非是對用戶隱 私權的進一步保護,爲每一個應用程序在外部存儲設備提供了一個獨立的存儲沙箱,應用經過路徑建立的文件都保存在應用的沙箱目錄。
關於下載,文件確定須要保存到本地了,可是因爲AndroidQ採起分區存儲,導致:getExternalStorageDirectory()與getExternalStoragePublicDirectory()讀寫權限變化,用戶在擁有讀寫權限的同時,不能夠在內部存儲肆意的構建本身的目錄,這樣也更容易管理,卸載應用的時候也能夠將這塊數據與文件徹底刪除。

if (TextUtils.isEmpty(appUpdate.getSavePath())) {
        //使用系統默認的下載路徑 此處爲應用內 /android/data/packages ,因此兼容7.0
        request.setDestinationInExternalFilesDir(context, Environment.DIRECTORY_DOWNLOADS, context.getPackageName() + ".apk");
    } else {
        // 自定義的下載目錄,注意這是涉及到android Q的存儲權限,建議不要用getExternalStorageDirectory()
        request.setDestinationInExternalFilesDir(context, appUpdate.getSavePath(), context.getPackageName() + ".apk");
        // 清除本地緩存的文件
        deleteApkFile(Objects.requireNonNull(context.getExternalFilesDir(appUpdate.getSavePath())));
    }
複製代碼

經過setDestinationInExternalFilesDir()存儲文件與getExternalFilesDir()獲取文件,徹底能夠避免Android Q對於存儲作出的限制。

文件MD5校驗

若是採起系統的DownloadManager來實現更新的話,我的以爲能夠不用進行校驗,固然若是懼怕下載的文件被篡改或者不完整的話建議能夠加上MD5校驗。關於MD5做用有如下幾點:

  • 用於校驗apk文件簽名是否一致,防止下載被攔截與篡改
  • 用於校驗文件大小的完整性

下面查看一下代碼片斷:

/**
     * 檢查文件的MD5的合法性,若不一致,則沒法安裝
     *
     * @param md5  服務器返回的文件md5值
     * @param file 下載的apk文件
     * @return true 則md5校驗經過 false 則失敗
     */
    public static boolean checkFileMd5(String md5, File file) {
        if (TextUtils.isEmpty(md5)) {
            return false;
        }
        String md5OfFile = getFileMd5ToString(file);
        if (TextUtils.isEmpty(md5OfFile)) {
            return false;
        }
        return md5.equalsIgnoreCase(md5OfFile);
    }

    /**
     * Return the MD5 of file.
     *
     * @param file The file.
     * @return the md5 of file
     */
    private static String getFileMd5ToString(final File file) {
        return bytes2HexString(getFileMd5(file));
    }

    private static final char[] HEX_DIGITS =
            {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F'};

    private static String bytes2HexString(final byte[] bytes) {
        if (bytes == null) {
            return "";
        }
        int len = bytes.length;
        if (len <= 0) {
            return "";
        }
        char[] ret = new char[len << 1];
        for (int i = 0, j = 0; i < len; i++) {
            ret[j++] = HEX_DIGITS[bytes[i] >> 4 & 0x0f];
            ret[j++] = HEX_DIGITS[bytes[i] & 0x0f];
        }
        return new String(ret);
    }

    /**
     * Return the MD5 of file.
     *
     * @param file The file.
     * @return the md5 of file
     */
    private static byte[] getFileMd5(final File file) {
        if (file == null) {
            return null;
        }
        DigestInputStream dis = null;
        try {
            FileInputStream fis = new FileInputStream(file);
            MessageDigest md = MessageDigest.getInstance("MD5");
            dis = new DigestInputStream(fis, md);
            byte[] buffer = new byte[1024 * 256];
            while (true) {
                if (dis.read(buffer) <= 0) {
                    break;
                }
            }
            md = dis.getMessageDigest();
            return md.digest();
        } catch (NoSuchAlgorithmException | IOException e) {
            e.printStackTrace();
        } finally {
            try {
                if (dis != null) {
                    dis.close();
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        return null;
    }
複製代碼

最後

關於版本更新大概就這麼多知識點了,比較簡單,可是很零碎,若是想要了解詳細的內容,卿能夠下載源碼進行查看哦,源碼詳見個人開源地址AppUpdate,本庫通過長期的驗證,穩定性很OK的啦,若是有好的想法,直接提issues。

本庫目前的功能

  • 兼容AndroidX,項目已經遷移到Androidx
  • 適配Android M,處理關於存儲文件的運行時權限
  • 適配Android N,安卓加強了文件訪問的安全性,利用FileProvider來訪問文件
  • 適配Android O,增長未知來源應用的安裝提示
  • 適配Android Q,關於Q增長沙箱,改變了應用程序訪問設備外部存儲上文件的方式如SD卡
  • 支持靜默下載,下載完畢自動彈出安裝
  • 支持下載進度監聽與下載失敗提示
  • 支持強制更新,未更新沒法使用應用
  • 支持MD5文件防篡改及完整性校驗
  • 支持自定義更新提示界面
  • 下載失敗支持經過系統瀏覽器下載

客官觀賞一下其餘文章

相關文章
相關標籤/搜索