Android应用内升级App

一、为什么需要应用内升级?

1、apk上架市场周期慢,无法回退
2、可以小规模实验以及试错(新功能实验,稳定性检测)
3、可以快速收敛版本(新功能覆盖、严重bug修复)

二、在app中存在的几种升级形式

1、应用启动时静默检测,提示更新
2、用户手动在设置页,点击检测更新

三、实现流程

应用内更新App流程图

四、案例实现步骤

1、网络模块设计

1)考虑通过接口隔离具体实现
好处:
(1)方便以后替换实现
(2)可以并行开发
2)使用okhttp完成接口实现,实现get请求,文件下载

2、UI实现

1)使用DialogFragment而不是使用Dialog
2)接入网络请求,进度回调

3、安装apk以及做一些细节处理

1)用户下载过程中cancel,如何及时的取消请求,中断下载
2)apk的完整性校验

4、适配

1)避免Android存储卡权限

  • 使用应用内部的cache文件夹,避免涉及到存储卡权限

2)Android N FileProvider适配

  • 应用安装,涉及到文件uri的传递,需要进行适配

3)Android O 对应用安装进行的权限的限制

  • 需要引入安装权限

4)Android P 对http网络请求的约束

  • 在Android P 上,默认不允许直接使用http的请求,需要使用https

五、具体实现

1、搭建网络访问模块

1)定义接口,共三个

网络访问接口,负责发起get请求、下载文件请求、取消

public interface INetManager {

    /**
     * 发起请求
     *
     * @param url         地址
     * @param netCallback 处理返回的结果
     * @param tag         标识当前的请求
     */
    void get(String url, INetCallback netCallback, Object tag);

    /**
     * 下载
     *
     * @param url              资源地址
     * @param targetFile       保存到:targetFile
     * @param downloadCallback 下载结果回调
     * @param tag              标识当前的下载请求
     */
    void download(String url, File targetFile, IDownloadCallback downloadCallback, Object tag);

    /**
     * 取消数据请求
     *
     * @param tag 标识要取消的请求
     */
    void cancel(Object tag);
}

处理网络请求结果的接口

public interface INetCallback {
    /**
     * 请求成功,再此进行处理
     * @param response
     */
    void onSuccess(String response);

    /**
     * 请求失败,在此进行处理
     * @param throwable
     */
    void onFailed(Throwable throwable);
}

处理下载结果的接口

public interface IDownloadCallback {
    /**
     * 下载成功,在此处理
     * @param apkFile
     */
    void onSuccess(File apkFile);

    /**
     * 下载进度,在此处理
     * @param progress
     */
    void progress(int progress);

    /**
     * 下载失败,在此处理
     * @param throwable
     */
    void onFailure(Throwable throwable);
}

2)接口实现类:

接口定义好了,自然就是实现了,这里使用Okhttp来完成网络的访问。
待会在业务代码:AppUpdater中就可以看到接口隔离实现的好处之一:
可以很方便的替换具体实现。当不想用Okhttp的时候,可以便捷的修改为其他网络访问框架。

public class OkHttpNetManager implements INetManager {
    private static final String TAG = "OkHttpNetManager";

    private static OkHttpClient sOkHttpClient;

    private static Handler sHandler = new Handler(Looper.getMainLooper());

    static {
        OkHttpClient.Builder builder = new OkHttpClient.Builder();
        builder.connectTimeout(15, TimeUnit.SECONDS);
        sOkHttpClient = builder.build();
    }

    @Override
    public void get(String url, final INetCallback netCallback, Object tag) {
        Request.Builder builder = new Request.Builder();
        Request request = builder.url(url).get().tag(tag).build();

        Call call = sOkHttpClient.newCall(request);

        call.enqueue(new Callback() {
            @Override
            public void onFailure(@NotNull Call call, @NotNull final IOException e) {
                sHandler.post(new Runnable() {
                    @Override
                    public void run() {
                        netCallback.onFailed(e);
                    }
                });
            }

            @Override
            public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException {
                try {
                    final String string = response.body().string();
                    sHandler.post(new Runnable() {
                        @Override
                        public void run() {
                            netCallback.onSuccess(string);
                        }
                    });
                } catch (final IOException e) {
                    e.printStackTrace();
                    sHandler.post(new Runnable() {
                        @Override
                        public void run() {
                            netCallback.onFailed(e);
                        }
                    });
                }
            }
        });
    }

    @Override
    public void download(String url, final File targetFile, final IDownloadCallback downloadCallback, Object tag) {
        if (!targetFile.exists()) {
            targetFile.getParentFile().mkdirs();
        }

        //发起请求
        Request.Builder builder = new Request.Builder();
        final Request request = builder.url(url).get().tag(tag).build();
        Call call = sOkHttpClient.newCall(request);
        call.enqueue(new Callback() {
            @Override
            public void onFailure(@NotNull Call call, @NotNull final IOException e) {
                sHandler.post(new Runnable() {
                    @Override
                    public void run() {
                        downloadCallback.onFailure(e);
                    }
                });
            }

            @Override
            public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException {
                InputStream is = null;
                OutputStream os = null;

                try {
                    final long totalLen = response.body().contentLength();

                    is = response.body().byteStream();
                    os = new FileOutputStream(targetFile);

                    byte[] buffer = new byte[8 * 1024];
                    int bufferLen;
                    int curLen = 0;
                    while (!call.isCanceled() && (bufferLen = is.read(buffer)) != -1) {
                        os.write(buffer, 0, bufferLen);
                        os.flush();
                        curLen += bufferLen;

                        final int finalCurLen = curLen;
                        sHandler.post(new Runnable() {
                            @Override
                            public void run() {
                                downloadCallback.progress((int) (finalCurLen * 1.0f / totalLen * 100));
                            }
                        });
                    }

                    if (call.isCanceled()){
                        return;
                    }

                    sHandler.post(new Runnable() {
                        @Override
                        public void run() {
                            downloadCallback.onSuccess(targetFile);
                        }
                    });

                } catch (final FileNotFoundException e) {
                    if (call.isCanceled()){
                        return;
                    }
                    e.printStackTrace();
                    sHandler.post(new Runnable() {
                        @Override
                        public void run() {
                            downloadCallback.onFailure(e);
                        }
                    });
                } finally {
                    if (is != null) {
                        is.close();
                    }
                    if (os != null) {
                        os.close();
                    }
                }
            }
        });
    }

    @Override
    public void cancel(Object tag) {
        List<Call> queuedCalls = sOkHttpClient.dispatcher().queuedCalls();
        if (queuedCalls != null) {
            for (Call call : queuedCalls) {
                if (tag.equals(call.request().tag())) {
                    Log.d("cancel", "find call = " + tag);
                    call.cancel();
                }
            }
        }

        List<Call> runningCalls = sOkHttpClient.dispatcher().runningCalls();
        if (runningCalls != null) {
            for (Call call : runningCalls) {
                if (tag.equals(call.request().tag())) {
                    Log.d("cancel", "find call = " + tag);
                    call.cancel();
                }
            }
        }
    }
}

2、AppUpdater类,为应用提供App更新的接口

默认使用OkHttpNetManager()实现类,当要换其他网络访问框架时,使用setINetManager更新即可。

public class AppUpdater {

    private static AppUpdater sInstance = new AppUpdater();

    public static AppUpdater getInstance() {
        return sInstance;
    }

    /**
     * 默认的网络访问方式:OkHttpNetManager
     */
    private static INetManager sINetManager = new OkHttpNetManager();

    public INetManager getINetManager() {
        return sINetManager;
    }

    /**
     * 指定网络访问方式
     *
     * @param netManager
     */
    public void setINetManager(INetManager netManager) {
        sINetManager = netManager;
    }

3、使用网络模块请求数据并更新UI

1)发起获取新版本信息的请求,并根据结果做具体处理

btnCheckVersion.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                AppUpdater.getInstance().getINetManager().get(Constants.Url.appUpdaterJsonUrl, new INetCallback() {
                    @Override
                    public void onSuccess(String response) {
                        //TODO 分析结果,看是否要更新

                        //1、解析json
                        //2、做版本适配
                        //如果需要更新
                        //3、弹窗
                        //4、点击下载

                        AppVersionInfoBean appVersionInfoBean = AppVersionInfoBean.parse(response);

                        if (appVersionInfoBean == null){
                            Toast.makeText(SettingActivity.this, "版本检测接口返回数据异常", Toast.LENGTH_SHORT).show();
                            return;
                        }

                        // TODO 检测是否需要更新
                        try {
                            long versionCode = Long.parseLong(appVersionInfoBean.getVersionCode());
                            if (versionCode <= AppUtils.getVersionCode(SettingActivity.this)){
                                Toast.makeText(SettingActivity.this, "已经是最新版本,无需更新", Toast.LENGTH_SHORT).show();
                                return;
                            }
                        } catch (NumberFormatException e) {
                            e.printStackTrace();
                            Toast.makeText(SettingActivity.this, "版本检测接口返回版本号异常", Toast.LENGTH_SHORT).show();
                            return;
                        }

                        // TODO 弹出更新窗口
                        UpdateVersionShowDialog.show(SettingActivity.this,appVersionInfoBean);
                    }

                    @Override
                    public void onFailed(Throwable throwable) {
                        throwable.printStackTrace();
                        Toast.makeText(SettingActivity.this, "版本更新接口请求失败", Toast.LENGTH_SHORT).show();
                    }
                },SettingActivity.this);
            }
        });

2)上面有一个AppVersionInfoBean类
我们把获取到的版本信息解析、封装成一个Bean类,用于版本验证和UI更新的数据来源。

这里有一个解析的小技巧:
把解析代码放到Bean类中。

public class AppVersionInfoBean implements Serializable {

    private String title;
    private String content;
    private String url;
    private String md5;
    private String versionCode;

    private AppVersionInfoBean(String title, String content, String url, String md5, String versionCode) {
        this.title = title;
        this.content = content;
        this.url = url;
        this.md5 = md5;
        this.versionCode = versionCode;
    }

    /**
     * 把response转换为AppVersionInfoBean。
     *
     * @param response
     * @return
     */
    public static AppVersionInfoBean parse(String response) {
        try {
            JSONObject responseJson = new JSONObject(response);
            String title = responseJson.optString("title");
            String content = responseJson.optString("content");
            String url = responseJson.optString("url");
            String md5 = responseJson.optString("md5");
            String versionCode = responseJson.optString("versionCode");

            //TODO 是否需要对获取到的值进行检验
            // 不应该在这里检测,检测属于使用这个bean,不适合在这里处理

            return new AppVersionInfoBean(title,content,url,md5,versionCode);
        } catch (JSONException e) {
            e.printStackTrace();
        }

        return null;
    }

    public String getTitle() {
        return title;
    }

    public String getContent() {
        return content;
    }

    public String getUrl() {
        return url;
    }

    public String getMd5() {
        return md5;
    }

    public String getVersionCode() {
        return versionCode;
    }
}

3)UI模块以及安装apk
使用的是一个DialogFragment。
在这里发起了下载Apk的请求,并对请求结果做处理。

public class UpdateVersionShowDialog extends DialogFragment {
    private static final String TAG = "UpdateVersionShowDialog";

    private static final String KEY_APP_VERSION_INFO_BEAN = "app_version_info_bean";

    /**
     * 版本更新信息bean,由show方法传入
     */
    private AppVersionInfoBean appVersionInfoBean;

    @Override
    public void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        Bundle arguments = getArguments();
        if (arguments != null) {
            appVersionInfoBean = (AppVersionInfoBean) arguments.getSerializable(KEY_APP_VERSION_INFO_BEAN);
        }
    }

    @Nullable
    @Override
    public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
        View view = inflater.inflate(R.layout.dialog_update_app_version, container, false);
        bindView(view);
        return view;
    }

    private void bindView(View view) {
        TextView tvTitle = view.findViewById(R.id.tv_title);
        TextView tvContent = view.findViewById(R.id.tv_content);
        final TextView tvUpdate = view.findViewById(R.id.tv_update);

        tvTitle.setText(appVersionInfoBean.getTitle());
        tvContent.setText(appVersionInfoBean.getContent());

        tvUpdate.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(final View v) {
                v.setEnabled(false);

                //安装包的下载地址,选择getCacheDir路径,可以避免存储权限的处理
                final File targetFile = new File(getActivity().getCacheDir(), "target.apk");
                AppUpdater.getInstance().getINetManager().download(appVersionInfoBean.getUrl(), targetFile, new IDownloadCallback() {
                    @Override
                    public void onSuccess(File apkFile) {
                        v.setEnabled(true);

                        dismiss();

                        //下载成功
                        Log.d(TAG, "success = " + apkFile.getAbsolutePath());

                        //TODO check MD5
                        String fileMd5 = AppUtils.getFileMd5(targetFile);
                        Log.d(TAG, "md5 = " + fileMd5);

                        if (fileMd5 != null && fileMd5.equals(appVersionInfoBean.getMd5())) {
                            //校验成功,安装
                            Toast.makeText(getActivity(), "开始安装", Toast.LENGTH_SHORT).show();

                            AppUtils.installApk(getActivity(), apkFile);
                        } else {
                            Toast.makeText(getActivity(), "md5检测失败", Toast.LENGTH_SHORT).show();
                        }
                    }

                    @Override
                    public void progress(int progress) {
                        Log.d(TAG, "progress = " + progress);

                        tvUpdate.setText(progress + "%");
                    }

                    @Override
                    public void onFailure(Throwable throwable) {
                        v.setEnabled(true);

                        throwable.printStackTrace();
                        Toast.makeText(getActivity(), "文件下载失败", Toast.LENGTH_SHORT).show();
                    }
                }, UpdateVersionShowDialog.this);
            }
        });
    }

    @Override
    public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
        super.onViewCreated(view, savedInstanceState);

        getDialog().requestWindowFeature(Window.FEATURE_NO_TITLE);
        getDialog().getWindow().setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT));
    }

    @Override
    public void onDismiss(@NonNull DialogInterface dialog) {
        super.onDismiss(dialog);

        Log.d("tag", "onDismiss: ");
        AppUpdater.getInstance().getINetManager().cancel(this);
    }

    public static void show(FragmentActivity fragmentActivity, AppVersionInfoBean appVersionInfoBean) {
        Bundle bundle = new Bundle();
        bundle.putSerializable(KEY_APP_VERSION_INFO_BEAN, appVersionInfoBean);

        UpdateVersionShowDialog updateVersionShowDialog = new UpdateVersionShowDialog();
        updateVersionShowDialog.setArguments(bundle);

        updateVersionShowDialog.show(fragmentActivity.getSupportFragmentManager(), "updateVersionShowDialog");
    }
}

4)最后,就是一个工具类 AppUtils

public class AppUtils {

    /**
     * 获取当前App的版本号
     *
     * @return  版本号
     */
    public static long getVersionCode(Context context) {
        PackageManager packageManager = context.getPackageManager();
        try {
            PackageInfo packageInfo = packageManager.getPackageInfo(context.getPackageName(), 0);
            if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) {
                long longVersionCode = packageInfo.getLongVersionCode();
                return longVersionCode;
            }else {
                return packageInfo.versionCode;
            }
        } catch (PackageManager.NameNotFoundException e) {
            e.printStackTrace();
        }
        return -1;
    }

    /**
     * MD5校验
     *
     * @param targetFile    要校验md5的文件
     * @return              文件的md5
     */
    public static String getFileMd5(File targetFile) {
        if (targetFile == null || !targetFile.isFile()){
            return null;
        }

        MessageDigest digest;
        FileInputStream fis = null;
        byte[] buffer = new byte[1024];
        try {
            digest = MessageDigest.getInstance("MD5");
            fis = new FileInputStream(targetFile);
            int bufferLen;
            while ((bufferLen = fis.read(buffer)) != -1){
                digest.update(buffer,0,bufferLen);
            }
        } catch (NoSuchAlgorithmException e) {
            e.printStackTrace();
            return null;
        } catch (IOException e) {
            e.printStackTrace();
            return null;
        }finally {
            if (fis != null){
                try {
                    fis.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
        byte[] result = digest.digest();
        BigInteger bigInteger = new BigInteger(1,result);

        return bigInteger.toString(16);
    }

    /**
     * 安装apk
     *
     * @param activity
     * @param apkFile
     */
    public static void installApk(FragmentActivity activity, File apkFile) {
        //文件有所有者概念,现在是属于当前进程的,需要把这个文件暴露给系统安装程序(其他进程)去安装
        //因此,可能会存在权限问题,需要做下面的设置
        //如果文件是sdcard上的,就不需要这个操作了
        try {
            apkFile.setExecutable(true, false);
            apkFile.setReadable(true, false);
            apkFile.setWritable(true, false);
        } catch (Exception e) {
            e.printStackTrace();
        }

        Intent intent = new Intent();
        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        intent.setAction(Intent.ACTION_VIEW);
        Uri uri;

        //TODO N FileProvider
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N){
            uri = FileProvider.getUriForFile(activity, activity.getPackageName() + ".fileprovider", apkFile);
            intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
            intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
        }else {
            uri = Uri.fromFile(apkFile);
        }

        intent.setDataAndType(uri,"application/vnd.android.package-archive");
        activity.startActivity(intent);

        //TODO 0 INSTALL PERMISSION
        //在AndroidManifest中加入权限即可
    }
}

4、适配与问题处理

1)N FileProvider

<!--N FileProvider适配-->
        <provider
            android:name="androidx.core.content.FileProvider"
            android:authorities="${applicationId}.fileprovider"
            android:exported="false"
            android:grantUriPermissions="true">
            <meta-data
                android:name="android.support.FILE_PROVIDER_PATHS"
                android:resource="@xml/fileproviderpath" />
        </provider>

xml/fileproviderpath:

<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
    <root-path name="root" path="."/>

    <files-path
        name="files"
        path="."/>

    <cache-path
        name="cache"
        path="."/>

    <external-path
        name="external"
        path="."/>

    <external-cache-path
        name="external_cache"
        path="."/>

    <external-files-path
        name="external_file"
        path="."/>
</paths>

2)O INSTALL PERMISSION
//在AndroidManifest中加入权限:

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

3)问题记录:java.net.UnknownServiceException: CLEARTEXT communication to 59.110.162.30 not permitted by network security policy
解决:
(1)在res/xml中新建:network_security_config

<?xml version="1.0" encoding="utf-8"?>
<network-security-config>

    <base-config cleartextTrafficPermitted="true"/>
</network-security-config>

(2)在AndroidManifest.xml的application中:

android:networkSecurityConfig="@xml/network_security_config"

六、总结

其实应用内更新的基本逻辑很简单,就是获取一个Apk,然后安装。

重要的是学习,如何构建整个功能模块的思路及其思考:
1、要获取Apk,需要用到网络吧?
所以得构建网络访问框架。
2、网络访问时,http/https可能会带来什么问题?如何处理呢?
3、下载apk后,存储策略是什么?是存在sdcard还是应用内部的cache?
4、如果是cache,那么要交给系统程序去安装,就涉及到文件的跨进程传递了?要如何处理?
5、O以后涉及到了安装权限问题

除了上面,我们还有如下思考:大文件,如何下载?
1、断点续下,分区间下载
原理:http,head中有range,可以指定下载一个文件的:起始字节和终止字节
实现:
如果target.apk有300字节,所以我们可以用多个线程去下载:
线程1:0,100
线程2:101,200
线程3:201,300
最后,在本地合并,使用RandomAccessFile进行seek操作。

2、使用增量更新
apk1 本地
apk2 server

apk diff apk2 --> patch

download patch

涉及到算法 bsdiff。

Android应用内升级该思考的问题

参考:慕课网视频

写于:
2020/09/10

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,009评论 5 474
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,808评论 2 378
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 148,891评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,283评论 1 272
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,285评论 5 363
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,409评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,809评论 3 393
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,487评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,680评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,499评论 2 318
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,548评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,268评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,815评论 3 304
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,872评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,102评论 1 258
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,683评论 2 348
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,253评论 2 341