Android中的全量更新、增量更新以及热更新

在客户端开发过程中,我们可能会遇到这样一种需求:点击某个按钮弹出一个弹窗,提示我们可以更新到apk的某个版本,或者我们可以通过服务端接口进行强制更新。在这种需求中,我们是不需要通过应用商店来更新我们的apk的,而是直接在apk内部进行版本更新。这次我们就来看看实现这种应用内更新的几种方式。当然,这种玩法只能在国内玩,海外的话会被Googleplay据审的。如果是海外的应用要更新apk,只能在GooglePlay上上传新版本的包。

全量更新

什么是全量更新呢?举个例子,假设现在用户手机上的apk是1.0版本,如果想要升级到2.0版本,全量更新的处理方式则是把2.0版本的apk全部下载下来进行覆盖安装。那么,我们该如果设计一个合理的全量更新方案呢?

  • 服务端
    需要提供一个接口,这个接口返回来的body中包含新版本的包的下载地址以及该包的md5值用于下载完成之后进行校验用
  • 客户端
    访问该服务端接口,下载新版本的包(其实就是字节流的读写),然后进行覆盖安装

做完上面这2点其实就可以实现一个较为完整的全量更新功能。

客户端核心代码如下:

package com.mvp.myapplication.update;

import android.app.Service;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.ServiceConnection;
import android.content.pm.PackageManager;
import android.content.pm.ProviderInfo;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Binder;
import android.os.Build;
import android.os.Environment;
import android.os.IBinder;
import android.text.TextUtils;
import android.util.Log;

import androidx.core.content.FileProvider;

import com.mvp.myapplication.utils.MD5Util;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;

public class UpdateService extends Service {

    public static final String KEY_MD5 = "MD5";
    public static final String URL = "downloadUrl";


    private boolean startDownload;//开始下载
    public static final String TAG = "UpdateService";
    private DownloadApk downloadApkTask;
    private String downloadUrl;
    private String mMd5;
    private UpdateProgressListener updateProgressListener;
    private LocalBinder localBinder = new LocalBinder();

    public class LocalBinder extends Binder {
        public void setUpdateProgressListener(UpdateProgressListener listener) {
            UpdateService.this.setUpdateProgressListener(listener);
        }
    }

    private void setUpdateProgressListener(UpdateProgressListener listener) {
        this.updateProgressListener = listener;
    }

    /**
     * 获取FileProvider的auth
     */
    private static String getFileProviderAuthority(Context context) {
        try {
            for (ProviderInfo provider : context.getPackageManager().getPackageInfo(context.getPackageName(), PackageManager.GET_PROVIDERS).providers) {
                if (FileProvider.class.getName().equals(provider.name) && provider.authority.endsWith(".update_app.file_provider")) {
                    return provider.authority;
                }
            }
        } catch (PackageManager.NameNotFoundException ignore) {
        }
        return null;
    }

    private static Intent installIntent(Context context, String path) {
        Intent intent = new Intent(Intent.ACTION_VIEW);
        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        intent.addCategory(Intent.CATEGORY_DEFAULT);
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
            Uri fileUri = FileProvider.getUriForFile(context, getFileProviderAuthority(context), new File(path));
            intent.setDataAndType(fileUri, "application/vnd.android.package-archive");
            intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
        } else {
            intent.setDataAndType(Uri.fromFile(new File(path)), "application/vnd.android.package-archive");
        }
        return intent;
    }

    public UpdateService() {
    }

    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        if (!startDownload && intent != null) {
            startDownload = true;
            mMd5 = intent.getStringExtra(KEY_MD5);
            downloadUrl = intent.getStringExtra(URL);

            downloadApkTask = new DownloadApk(this, mMd5);
            downloadApkTask.execute(downloadUrl);

        }
        return super.onStartCommand(intent, flags, startId);
    }

    @Override
    public IBinder onBind(Intent intent) {
        return localBinder;
    }

    @Override
    public boolean onUnbind(Intent intent) {
        return true;
    }

    @Override
    public void onDestroy() {
        if (downloadApkTask != null) {
            downloadApkTask.cancel(true);
        }
        if (updateProgressListener != null) {
            updateProgressListener = null;
        }
        super.onDestroy();
    }

    private static String getSaveFileName(String downloadUrl) {
        if (downloadUrl == null || TextUtils.isEmpty(downloadUrl)) {
            return System.currentTimeMillis() + ".apk";
        }
        return downloadUrl.substring(downloadUrl.lastIndexOf("/"));
    }

    private static File getDownloadDir(UpdateService service) {
        File downloadDir = null;
        if (Environment.getExternalStorageDirectory().equals(Environment.MEDIA_MOUNTED)) {
            downloadDir = new File(service.getExternalCacheDir(), "update");
        } else {
            downloadDir = new File(service.getCacheDir(), "update");
        }
        if (!downloadDir.exists()) {
            downloadDir.mkdirs();
        }
        return downloadDir;
    }

    private void start() {
        if (updateProgressListener != null) {
            updateProgressListener.start();
        }
    }

    private void update(int progress) {
        if (updateProgressListener != null) {
            updateProgressListener.update(progress);
        }
    }

    private void success(String path) {
        if (updateProgressListener != null) {
            updateProgressListener.success(path);
        }
        Intent i = installIntent(this, path);
        startActivity(i);//自动安装
        stopSelf();
    }

    private void error() {
        if (updateProgressListener != null) {
            updateProgressListener.error();
        }
        stopSelf();
    }

    private static class DownloadApk extends AsyncTask<String, Integer, String> {
        private final String md5;
        private UpdateService updateService;

        public DownloadApk(UpdateService service, String md5) {
            this.updateService = service;
            this.md5 = md5;
        }

        @Override
        protected void onPreExecute() {
            super.onPreExecute();
            if (updateService != null) {
                updateService.start();
            }
        }

        @Override
        protected String doInBackground(String... strings) {
            final String downloadUrl = strings[0];

            final File file = new File(UpdateService.getDownloadDir(updateService),
                    UpdateService.getSaveFileName(downloadUrl));

            Log.d(TAG, "download url is " + downloadUrl);
            Log.d(TAG, "download apk cache at " + file.getAbsolutePath());

            File dir = file.getParentFile();
            if (!dir.exists()) {
                dir.mkdirs();
            }

            HttpURLConnection httpConnection = null;
            InputStream is = null;
            FileOutputStream fos = null;
            long updateTotalSize = 0;
            URL url;
            try {
                url = new URL(downloadUrl);
                httpConnection = (HttpURLConnection) url.openConnection();
                httpConnection.setConnectTimeout(20000);
                httpConnection.setReadTimeout(20000);

                Log.d(TAG, "download status code: " + httpConnection.getResponseCode());


                if (httpConnection.getResponseCode() != 200) {
                    return null;
                }

                updateTotalSize = httpConnection.getContentLength();

                if (file.exists()) {
                    if (updateTotalSize == file.length()) {
                        // 下载完成
                        if (TextUtils.isEmpty(md5) || MD5Util.getMD5String(file).toUpperCase().equals(md5.toUpperCase())) {
                            return file.getAbsolutePath();
                        }
                    } else {
                        file.delete();
                    }
                }
                file.createNewFile();
                is = httpConnection.getInputStream();
                fos = new FileOutputStream(file, false);
                byte buffer[] = new byte[4096];

                int readSize = 0;
                long currentSize = 0;

                while ((readSize = is.read(buffer)) > 0) {
                    fos.write(buffer, 0, readSize);
                    currentSize += readSize;
                    publishProgress((int) (currentSize * 100 / updateTotalSize));
                }
                // download success
            } catch (Exception e) {
                e.printStackTrace();
                return null;
            } finally {
                if (httpConnection != null) {
                    httpConnection.disconnect();
                }
                if (is != null) {
                    try {
                        is.close();
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
                if (fos != null) {
                    try {
                        fos.close();
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
            }
            try {
                if (TextUtils.isEmpty(md5) || MD5Util.getMD5String(file).toUpperCase().equals(md5.toUpperCase())) {
                    return file.getAbsolutePath();
                }
            } catch (IOException e) {
                e.printStackTrace();
                return file.getAbsolutePath();
            }
            Log.e(TAG, "md5 invalid");
            return null;
        }

        @Override
        protected void onProgressUpdate(Integer... values) {
            super.onProgressUpdate(values);
            if (updateService != null) {
                updateService.update(values[0]);
            }
        }

        @Override
        protected void onPostExecute(String s) {
            super.onPostExecute(s);
            if (updateService != null) {
                if (s != null) {
                    updateService.success(s);
                } else {
                    updateService.error();
                }
            }
        }
    }

    public static class Builder {
        private String downloadUrl;
        private String md5;
        private ServiceConnection serviceConnection;

        protected Builder(String downloadUrl) {
            this.downloadUrl = downloadUrl;
        }

        public static Builder create(String downloadUrl) {
            if (downloadUrl == null) {
                throw new NullPointerException("downloadUrl == null");
            }
            return new Builder(downloadUrl);
        }

        public String getMd5() {
            return md5;
        }

        public Builder setMd5(String md5) {
            this.md5 = md5;
            return this;
        }

        public Builder build(Context context, UpdateProgressListener listener) {
            if (context == null) {
                throw new NullPointerException("context == null");
            }
            Intent intent = new Intent();
            intent.setClass(context, UpdateService.class);
            intent.putExtra(URL, downloadUrl);
            intent.putExtra(KEY_MD5, md5);

            UpdateProgressListener delegateListener = new UpdateProgressListener() {
                @Override
                public void start() {
                    if (listener != null) {
                        listener.start();
                    }
                }

                @Override
                public void update(int var1) {
                    if (listener != null) {
                        listener.update(var1);
                    }
                }

                @Override
                public void success(String path) {
                    try {
                        context.unbindService(serviceConnection);
                    } catch (Throwable t) {
                        Log.e("UpdateService", "解绑失败" + t.getMessage());
                    }

                    if (listener != null) {
                        listener.success(path);
                    }
                }

                @Override
                public void error() {
                    try {
                        context.unbindService(serviceConnection);
                    } catch (Throwable t) {
                        Log.e("UpdateService", "解绑失败" + t.getMessage());
                    }
                    if (listener != null) {
                        listener.error();
                    }
                }
            };
            serviceConnection = new ServiceConnection() {
                @Override
                public void onServiceConnected(ComponentName name, IBinder service) {
                    LocalBinder binder = (LocalBinder) service;
                    binder.setUpdateProgressListener(delegateListener);
                }

                @Override
                public void onServiceDisconnected(ComponentName name) {
                }
            };
            context.bindService(intent, serviceConnection, Context.BIND_IMPORTANT);
            context.startService(intent);
            return this;
        }
    }

    public interface UpdateProgressListener {
        void start();

        void update(int var);

        void success(String path);

        void error();
    }
}
package com.mvp.myapplication;

import androidx.appcompat.app.AppCompatActivity;

import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.widget.Button;

import com.mvp.myapplication.update.UpdateService;

public class MainActivity extends AppCompatActivity {
    private Button btnAllUpdate, btnAddUpdate, btnHotUpdate;

    private String url,md5;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        btnAddUpdate = findViewById(R.id.btn_add_update);
        btnAllUpdate = findViewById(R.id.btn_all_update);
        btnHotUpdate = findViewById(R.id.btn_hot_update);

        Log.e("MainActivity","onCreate");

        btnAllUpdate.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                UpdateService.Builder.create(url)
                        .setMd5(md5)
                        .build(MainActivity.this, new UpdateService.UpdateProgressListener() {
                            @Override
                            public void start() {
                                Log.e("MainActivity", "start");
                            }

                            @Override
                            public void update(int var) {
                                Log.e("MainActivity", "update ===> " + var);
                            }

                            @Override
                            public void success(String path) {
                                Log.e("MainActivity", "success ===> " + path);
                            }

                            @Override
                            public void error() {
                                Log.e("MainActivity", "error");
                            }
                        });
            }
        });
    }
}
  • AndroidManifest
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>

    <application
        android:allowBackup="true"
        android:dataExtractionRules="@xml/data_extraction_rules"
        android:fullBackupContent="@xml/backup_rules"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.MyApplication"
        tools:targetApi="31">
        <service
            android:name=".update.UpdateService"
            android:enabled="true"
            android:exported="true"></service>

        <activity
            android:name=".MainActivity"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>

            <meta-data
                android:name="android.app.lib_name"
                android:value="" />
        </activity>
        <!-- Android7以上需要 -->
        <provider
            android:name="androidx.core.content.FileProvider"
            android:authorities="${applicationId}.update_app.file_provider"
            android:exported="false"
            android:grantUriPermissions="true"
            tools:replace="android:authorities">
            <meta-data
                android:name="android.support.FILE_PROVIDER_PATHS"
                android:resource="@xml/update_app_path" />
        </provider>
    </application>

</manifest>
  • update_app_path
<?xml version="1.0" encoding="utf-8"?>
<paths>
    <cache-path
        name="update_app_cache_files"
        path="/update" />
    <external-path
        name="update_app_external_files"
        path="/" />
    <external-cache-path
        name="update_app_external_cache_files"
        path="/update" />
</paths>

热更新

严格意义上来说,个人认为热更新并不是用来进行包体升级,更多的用来进行修复bug的。例如,由于某个程序员的失误,在某个类中抛出了一个空指针异常,导致程序执行到该类后一直崩溃。这种情况下,其实就可以使用热更新来处理。因为,我们并没有大改app中的功能,只是某个类报错了。但个人认为热更新其实也不能解决所有的奔溃问题的,这些黑科技或多或少都是有一些兼容性的问题的,像Tinker就必须要冷启动才能修复,而且受限于Android的版本。
具体是技术实现方式可以参考笔者之前写的一篇博客:Android热修复1以及Android热更新十:自己写一个Android热修复

增量更新

什么是增量更新呢?举了例子,假设我们需要将apk从v1.0升级到v2.0,这时我们可以通过全量更新的方式,下载2.0版本的apk然后进行覆盖安装。但是,一般情况下2.0版本的apk往往包含了1.0版本的功能,理论上我们只需要下载二者的差分包,然后将差分包与1.0版本的包进行合并即可生成一个2.0版本的包。这样做的好处自然就是节约了流量了。像几乎所有的应用商店都使用增量更新的方式来更新apk。那么,我们该我们使用增量更新呢?这个就要借助一个工具:bsdiff
注意:如果想要使用增量更新,那么必须要有一个旧版本的apk,如果用户安装完apk后直接把旧版本的apk删掉了,那么还是老老实实使用全量更新的方式吧。

  • 拆——拆分出差分包

bsdiff oldfile newfile1 patchfile

  • 合——将旧版本的包与差分包进行合并

bspatch oldfile newfile2 patchfile

使用上面两步便可以完成差分包的拆分与合并,新生成的newfile2 与newfile1的md5是一致的。但是上面这两步法我们是在pc端进行的,我们该如何在代码中实现上面的逻辑呢?首先,拆分的逻辑还是在pc端中进行,客户端只需要关注如何合并差分包。
首先,我们需要导入bspatch相关的类
Android中的全量更新、增量更新以及热更新_第1张图片
接着,我们新建一个类用于调用c相关的代码:

package com.mvp.myapplication.utils;

public class BSPatchUtil {

    // Used to load the 'native-lib' library on application startup.
    static {
        System.loadLibrary("bspatch");
    }

    /**
     * native方法 使用路径为oldApkPath的apk与路径为patchPath的补丁包,合成新的apk,并存储于newApkPath
     *
     * 返回:0,说明操作成功
     *
     * @param oldApkPath 示例:/sdcard/old.apk
     * @param outputApkPath 示例:/sdcard/output.apk
     * @param patchPath  示例:/sdcard/xx.patch
     * @return
     */
    public static native int bspatch(String oldApkPath, String outputApkPath,
                                   String patchPath);

}

package com.mvp.myapplication;

import androidx.appcompat.app.AppCompatActivity;

import android.os.Bundle;
import android.os.Environment;
import android.util.Log;
import android.view.View;
import android.widget.Button;

import com.mvp.myapplication.update.UpdateService;
import com.mvp.myapplication.utils.BSPatchUtil;

import java.io.File;

public class MainActivity extends AppCompatActivity {
    private Button btnAllUpdate, btnAddUpdate, btnHotUpdate;

    private String url, md5;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        btnAddUpdate = findViewById(R.id.btn_add_update);
        btnAllUpdate = findViewById(R.id.btn_all_update);
        btnHotUpdate = findViewById(R.id.btn_hot_update);

        Log.e("MainActivity", "onCreate");

        btnAllUpdate.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                UpdateService.Builder.create(url)
                        .setMd5(md5)
                        .build(MainActivity.this, new UpdateService.UpdateProgressListener() {
                            @Override
                            public void start() {
                                Log.e("MainActivity", "start");
                            }

                            @Override
                            public void update(int var) {
                                Log.e("MainActivity", "update ===> " + var);
                            }

                            @Override
                            public void success(String path) {
                                Log.e("MainActivity", "success ===> " + path);
                            }

                            @Override
                            public void error() {
                                Log.e("MainActivity", "error");
                            }
                        });
            }
        });

        btnAddUpdate.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                genNewApk();
            }
        });
    }

    private void genNewApk() {
        String oldpath = getApplicationInfo().sourceDir;
        String newpath = (this.getCacheDir().getAbsolutePath()+ File.separator
                + "composed_hivebox_apk.apk");

        String patchpath = (this.getCacheDir().getAbsolutePath()+ File.separator
                + "bs_patch");
        Log.e("MainActivity", "oldpath is " + oldpath + "\n newpath is " + newpath + "\n patchpath is " + patchpath);
        BSPatchUtil.bspatch(oldpath, newpath, patchpath);

    }
}

注意:需要修改bspatch.c文件中的Java_com_mvp_myapplication_utils_BSPatchUtil_bspatch方法签名:改为BSPatchUtil 的包名,例如BSPatchUtil对应的路径为com.dxl.testbatch.util.BSPatchUtil,那么native方法签名就是Java_com_mvp_myapplication_utils_BSPatchUtil_bspatch
这样便可以通过jni调用到c层面的代码。

接着,修改build.gradle文件,添加下面圈中的闭包
Android中的全量更新、增量更新以及热更新_第2张图片
最后,我们执行Make Project命令,正常情况下便可以生成如下几个so库
Android中的全量更新、增量更新以及热更新_第3张图片
最后,我们在把so库放入jniLibs文件夹中,然后build下生成apk包
Android中的全量更新、增量更新以及热更新_第4张图片

然后,我们将差分包bs_patch放入手机的data/data目录下,点击按钮就会生成composed_hivebox_apk.apk这个apk包,将其与v2.0的包进行MD5对比,发现是一致的。如此,我们便实现了一个简单的增量更新逻辑。
Demo地址:https://gitee.com/hzulwy/add_-update/tree/master/MyApplication

你可能感兴趣的:(android,java,android,studio)