1. 程式人生 > >Android APK 更新之路

Android APK 更新之路

一、前言

提到 APK 更新,大家可能會想到友盟(umeng)更新,市場上已有數萬款應用在使用友盟自動更新的服務。但友盟於 2016 年 10 月 15 日起停止了更新服務。那麼我們需要自己處理 APK 更新的業務。

本篇主要講解以下知識點:

  • 使用 DownloadManager 更新

  • 基於 RxJava 和 retrofit 擴充套件的 Android 執行緒安全 http 請求庫下載 APK 更新

  • 熱更新(AndFix)

我們來啾啾第一個知識點。

DownloadManager 更新

Android 2.3(API level 9)開始 Android 用系統服務(Service)的方式提供了DownloadManager 來優化處理長時間的下載操作。DownloadManager 對後臺下載,下載狀態回撥,斷點續傳,下載環境設定,下載檔案的操作等都有很好的支援。

本篇基於 Android 4.0 ~7.0 (SDK 14~24) 開發,眾所周知 Android 6.0 的 Runtime Permissions (執行時許可權)。

下面具體來看看 DownloadManager 更新的具體流程。

AndroidManifest 清單檔案配置許可權

下載檔案需要使用到網路許可權,檔案讀寫許可權:

    <uses-permission android:name="android.permission.INTERNET"/>
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>

獲取當前的版本號

getPackageManager().getPackageInfo(getPackageName(), 0).versionName;

後臺需要提供查詢最新版本號的介面,獲取介面資料與當前版本號對比,判定是否需要更新。

獲取 DownloadManager 例項

DownloadManager manager = (DownloadManager)
            appContext.getSystemService
(Context.DOWNLOAD_SERVICE);

下面來看看 DownloadManager 提供哪些介面:

  • public long enqueue(Request request) 執行下載,返回 downloadId,downloadId 可用於後面查詢下載資訊。若網路不滿足條件、Sdcard 掛載中、超過最大併發數等異常則會等待下載,正常則直接下載。

  • int remove(long… ids) 刪除下載,若下載中取消下載。會同時刪除下載檔案和記錄。引數 ids 為 enqueue 返回的 downloadId 集合。

  • Cursor query(Query query) 查詢下載資訊。

  • getMaxBytesOverMobile(Context context) 返回行動網路下載的最大值

  • rename(Context context, long id, String displayName) 重新命名已下載項的名字

  • getRecommendedMaxBytesOverMobile(Context context) 獲取建議的行動網路下載的大小

  • 其它:通過檢視程式碼我們可以發現還有個 CursorTranslator 私有靜態內部類。這個類主要對 Query 做了一層代理。將 DownloadProvider 和 DownloadManager之間做了個對映。

接著來看看 DownloadManager.Request 的請求引數。

組裝 DownloadManager.Request 請求引數

//獲取Request的例項物件 
    DownloadManager.Request request = new DownloadManager.Request(Uri.parse(appUrl));

顯示資訊:

//設定一些基本顯示資訊
    request.setTitle(name); //通知欄標題
    request.setDescription(description);//通知欄內容
    request.setMimeType("application/vnd.android.package-archive");//檔案的型別

網路型別:

//NETWORK_MOBILE行動網路
//NETWORK_WIFI  wifi網路
//NETWORK_BLUETOOTH 藍芽
req.setAllowedNetworkTypes(DownloadManager.Request.NETWORK_WIFI);

通知欄顯示型別:

    request.setNotificationVisibility(DownloadManager.Request
            .VISIBILITY_VISIBLE_NOTIFY_COMPLETED);
  • VISIBILITY_HIDDEN 下載UI不會顯示,也不會顯示在通知中,如果設定該值,
    需要宣告android.permission.DOWNLOAD_WITHOUT_NOTIFICATION
  • VISIBILITY_VISIBLE 當處於下載中狀態時,可以在通知欄中顯示;當下載完成後,通知欄中不顯示
  • VISIBILITY_VISIBLE_NOTIFY_COMPLETED 當處於下載中狀態和下載完成時狀態,均在通知欄中顯示
  • VISIBILITY_VISIBLE_NOTIFY_ONLY_COMPLETION 只在下載完成時顯示在通知欄中。

檔案的儲存位置:

  • 儲存到外部環境的私有目錄:file:///storage/emulated/0/Android/data/your-package/files/Download/app.apk
    request.setDestinationInExternalFilesDir(context, Environment.DIRECTORY_DOWNLOADS, "app.apk");
  • 儲存到外部環境的共有目錄: file:///storage/emulated/0/Download/app.apk
request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, "app.apk");
  • 自定義檔案路徑
setDestinationUri(Uri uri)

新增請求下載的網路連結的http頭,比如User-Agent,gzip壓縮等:

request.addRequestHeader(String header, String value)

漫遊:

//true  允許
//false  不允許
request.setAllowedOverRoaming(false);

其他:

setAllowedOverMetered(boolean allow) //是否允許計量
setRequiresCharging(boolean requiresCharging)//是否在充電環境下
setVisibleInDownloadsUi(boolean isVisible)//是否顯示下載介面
...

下面是本文建立Request的示例程式碼:

    request.setTitle(name);
    request.setDescription(description);
    //在通知欄顯示下載進度
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) {
        request.allowScanningByMediaScanner();
        request.setNotificationVisibility(DownloadManager.Request
                .VISIBILITY_VISIBLE_NOTIFY_COMPLETED);
    }

    request.setAllowedNetworkTypes(DownloadManager.Request.NETWORK_WIFI);
    request.setDestinationInExternalPublicDir(SAVE_APP_LOCATION, SAVE_APP_NAME);

加入下載佇列

DownloadManager manager = (DownloadManager)                appContext.getSystemService(Context.DOWNLOAD_SERVICE);

manager.enqueue(request);

下載資訊查詢

DownloadManager 下載工具並沒有提供相應的回撥介面用於返回實時的下載進度狀態。可以通過 DownloadManager.query 方法進行查詢,該方法返回一個 Cursor 物件,具體看以下程式碼:

    private void queryDownloadManager(long id) {
        DownloadManager mDownloadManager = (DownloadManager)
                this.getSystemService(Context.DOWNLOAD_SERVICE);
        DownloadManager.Query query = new DownloadManager.Query().setFilterById(id);
        //可以對query設定一些過濾條件
        //setFilterById(long… ids)根據下載id進行過濾
        //setFilterByStatus(int flags)根據下載狀態進行過濾
        Cursor cursor = mDownloadManager.query(query);

        if (cursor != null) {

            while (cursor.moveToNext()) {

                String bytesDownload = cursor.getString(cursor.getColumnIndex(DownloadManager
                        .COLUMN_BYTES_DOWNLOADED_SO_FAR));
                String description = cursor.getString(cursor.getColumnIndex(DownloadManager
                        .COLUMN_DESCRIPTION));
                String cid = cursor.getString(cursor.getColumnIndex(DownloadManager.COLUMN_ID));
                String localUri = cursor.getString(cursor.getColumnIndex(DownloadManager
                        .COLUMN_LOCAL_URI));
                String mimeType = cursor.getString(cursor.getColumnIndex(DownloadManager
                        .COLUMN_MEDIA_TYPE));
                String title = cursor.getString(cursor.getColumnIndex(DownloadManager
                        .COLUMN_TITLE));
                String status = cursor.getString(cursor.getColumnIndex(DownloadManager
                        .COLUMN_STATUS));
                String totalSize = cursor.getString(cursor.getColumnIndex(DownloadManager
                        .COLUMN_TOTAL_SIZE_BYTES));

                Log.i("MainActivity", "bytesDownload:" + bytesDownload);
                Log.i("MainActivity", "description:" + description);
                Log.i("MainActivity", "cid:" + cid);
                Log.i("MainActivity", "localUri:" + localUri);
                Log.i("MainActivity", "mimeType:" + mimeType);
                Log.i("MainActivity", "title:" + title);
                Log.i("MainActivity", "status:" + status);
                Log.i("MainActivity", "totalSize:" + totalSize);
            }

        }
    }

本篇示例的列印結果如下:

man

註冊廣播監聽通知欄點選事件和下載完成事件

當用戶點選通知欄中的下載列表時,系統會發出 ACTION_NOTIFICATION_CLICKED 事件廣播;下載完成時會發出 ACTION_DOWNLOAD_COMPLETE 事件廣播,那麼我們就可以實現一個廣播接收器處理點選和完成時的狀態。請看下面程式碼:

    public void onReceive(Context context, Intent intent) {
        if (intent.getAction().equals(DownloadManager.ACTION_DOWNLOAD_COMPLETE)) {

            installApk(context);

        } else if (intent.getAction().equals(DownloadManager.ACTION_NOTIFICATION_CLICKED)) {
            //Toast.makeText(context, "Clicked", Toast.LENGTH_SHORT).show();

        }
    }

如文字下載 apk 檔案,下載完成時就自動安裝,使用意圖進行 apk 安裝:

    // 安裝Apk
    private void installApk(Context context) {
        try {
            Intent i = new Intent(Intent.ACTION_VIEW);
            String filePath = DownloadManagerUtils.APP_FILE_NAME;
            i.setDataAndType(Uri.parse("file://" + filePath), "application/vnd.android" +
                    ".package-archive");
            i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
            context.startActivity(i);
        } catch (Exception e) {
            Log.e(TAG, "安裝失敗");
            e.printStackTrace();
        }
    }

DownloadManager 更新就講到這裡了,原始碼在文章的後面會附上。

基於 RxJava 和 retrofit 擴充套件的 Android 執行緒安全 http 請求庫下載 APK 更新

針對 DownloadManager 更新,我們還可以通過 http 請求庫下載 apk 檔案進行更新。

提到 http 請求庫,就不得不提到 Novate 庫,功能非常強大,使用便利,看看它有哪些功能:

  • 加入基礎API,減少Api冗餘
  • 支援離線快取
  • 支援多種方式訪問網路(get,put,post ,delete)
  • 支援Json字串,表單提交
  • 支援檔案下載和上傳
  • 支援請求頭統一加入
  • 支援對返回結果的統一處理
  • 支援自定義的擴充套件API
  • 支援統一請求訪問網路的流程控制

我下載了原始碼,並修改了進度條的介面。下載檔案相信大家都比較熟悉了,我這裡就不再細講了。如果有什麼疑問請連結上面地址檢視。

新建通知

以下給出本篇用到的訊息程式碼:

    private NotificationCompat.Builder buildNotification() {
        final Resources res = mContext.getResources();

        // This image is used as the notification's large icon (thumbnail).
        // TODO: Remove this if your notification has no relevant thumbnail.
        final Bitmap picture = BitmapFactory.decodeResource(res, R.mipmap.ic_launcher);

        return new NotificationCompat.Builder(mContext).
                setContentTitle("更新包下載中...")
                .setTicker("準備下載...")
                .setProgress(100, 0, false)
                .setContentText(String.format(mContext.getResources()
                        .getString(R.string.apk_progress), 0) + "%")
                .setLargeIcon(picture)
                .setPriority(NotificationCompat.PRIORITY_DEFAULT)
                .setWhen(System.currentTimeMillis())
                .setSmallIcon(R.mipmap.ic_launcher)
                .setAutoCancel(false);
    }

    //更新訊息進度
    public void showProgressNotification(int progress) {
        if (mBuilder == null) {
            mBuilder = buildNotification();
        }
        Notification notification = mBuilder.setProgress(100, progress, false)
                .setContentText(String.format(mContext.getResources().getString(R.string
                        .apk_progress), progress) + "%")
                .build();
        notify(mContext, notification);
    }

apk下載

    private void downloadApk() {

        RetrofitClient.getInstance(this).createBaseApi()
                .download(DOWN_URL, new CallBack() {
                    @Override
                    public void onError(Throwable e) {
                        Log.e("HttpActivity", "onError--------2222" + e.getMessage());
                        mHttpNotification.removeProgressNotification();
                    }

                    @Override
                    public void onStart() {
                        super.onStart();
                        mHttpNotification.showProgressNotification(0);
                    }

                    @Override
                    public void onSucess(String path, String name, long fileSize) {
                        mHttpNotification.removeProgressNotification();
                        installApk(HttpActivity.this);
                    }

                    @Override
                    public void onProgress(int progress) {
                        super.onProgress(progress);
                        mCircleProgressView.setProgress(progress);
                        mHttpNotification.showProgressNotification(progress);
                    }
                });

    }

如果你還有疑問,在文章結尾處下載原始碼進行檢視。

更新全過程效果圖:

app

熱更新(AndFix)

熱更新技術近段時間非常火爆,各個大公司都相繼開發自己的熱更新框架。由於公司主要專案基於電商商城,所以我選擇了阿里巴巴的 AndFix 熱更新的實現,使用起來也比較簡單。至少在我的測試下修改一些小的 BUG 是沒有問題的。

我的開發工具是 Android Studio ,第一步導包:

app 的 dependencies 的節點下:

    compile 'com.alipay.euler:andfix:0.3.1@aar'

第二步配置 MyApplication 類:

    @TargetApi(Build.VERSION_CODES.KITKAT)
    @Override
    public void onCreate() {
        super.onCreate();

        String version = "";
        try {
            version = getPackageManager().getPackageInfo(getPackageName(), 0).versionName;
        } catch (PackageManager.NameNotFoundException e) {
            e.printStackTrace();
        }

        mPatchManager = new PatchManager(getApplicationContext());
        mPatchManager.init(version);
        mPatchManager.loadPatch();
        try {
            String patchFileString = "/sdcard" + APATCH_PATH;
            mPatchManager.addPatch(patchFileString);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

首先獲取到版本號,系統會判斷版本號,只有相同的版本號的時候會執行熱更新。其中 String patchFileString = "/sdcard" + APATCH_PATH; 是我測試的補丁存放路徑。你需要替換成你自己的存放路徑。

注意:檔案的許可權。

然後在 MainActivity 中寫一個列印吐司的方法:

    private void showToast() {
        Toast.makeText(this, "你好啊", Toast.LENGTH_LONG).show();
    }

然後打包,重新命名為 old.apk

接著修改吐司的內容:

    private void showToast() {
        Toast.makeText(this, "你好啊,世界", Toast.LENGTH_LONG).show();
    }

重新打包,命名為 new.apk

下載apkpatch工具

下載路徑

下面是我的目錄結構:

app

用紅線框框住的是簽名檔案,補丁包,舊包。

開啟 cmd ->cd 到 apkpatch 的目錄,如我 F:\AndroidTools\apkpatch 目錄下,下圖我已用紅框圈住:

app

然後輸入:

apkpatch.bat -f new.apk -t old.apk -o output -k demo.jks -p 123456 -a boby -e 123456 

其中:

  • -f 是新apk的名字

  • -t 是舊apk的名字

  • -o 是輸出補丁的資料夾位置

  • -k 是 keystore(jks)檔案的名稱

  • -p 是keystore檔案的密碼

  • -a 是專案的別名

  • -e 別名的密碼

回車,不出現錯誤,補丁打包成功。

開啟 output 目錄,則可以看到 out.apatch 檔案。

app

補丁檔案上傳到後臺,然後通過介面下載到 /sdcard/out.apatch 目錄下。

注意 /sdcard/out.apatch 路徑,跟 MyApplication 中的一致。

看看效果:

安裝 old.apk 包:

app

安裝補丁,接著執行:

app

若你有什麼疑問請留言,如果對你有所幫助,請關注一下。

原始碼地址