美文网首页Android 干货精粹
Android应用内升级App

Android应用内升级App

作者: 巫师Android | 来源:发表于2020-09-10 16:20 被阅读0次

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

    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

    相关文章

      网友评论

        本文标题:Android应用内升级App

        本文链接:https://www.haomeiwen.com/subject/aiodektx.html