Android 干货精粹

Android应用内升级App

2020-09-10  本文已影响0人  巫师Android

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

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存储卡权限

2)Android N FileProvider适配

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

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

五、具体实现

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

上一篇 下一篇

猜你喜欢

热点阅读