Android APK 更新之路

一、前言

提到 APK 更新,大家可能会想到友盟(umeng)更新,市场上已有数万款应用在使用友盟自动更新的服务。但友盟于 2016 年 10 月 15 日起停止了更新服务。那么我们需要自己处理 APK 更新的业务。

本篇主要讲解以下知识点:

  • 使用 DownloadManager 更新

  • 基于 RxJava 和 retrofit 扩展的 Android 线程安全 http 请求库下载 APK 更新

  • 热更新(AndFix)

我们来啾啾第一个知识点。

DownloadManager 更新

Android 2.3(API level 9)开始 Android 用系统服务(Service)的方式提供了DownloadManager 来优化处理长时间的下载操作。DownloadManager 对后台下载,下载状态回调,断点续传,下载环境设置,下载文件的操作等都有很好的支持。

本篇基于 Android 4.0 ~7.0 (SDK 14~24) 开发,众所周知 Android 6.0 的 Runtime Permissions (运行时权限)。

请参考Android 6.0 运行时权限封装之路

下面具体来看看 DownloadManager 更新的具体流程。

AndroidManifest 清单文件配置权限

下载文件需要使用到网络权限,文件读写权限:

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

获取当前的版本号

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

后台需要提供查询最新版本号的接口,获取接口数据与当前版本号对比,判定是否需要更新。

获取 DownloadManager 实例

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

下面来看看 DownloadManager 提供哪些接口:

  • public long enqueue(Request request) 执行下载,返回 downloadId,downloadId 可用于后面查询下载信息。若网络不满足条件、Sdcard 挂载中、超过最大并发数等异常则会等待下载,正常则直接下载。

  • int remove(long… ids) 删除下载,若下载中取消下载。会同时删除下载文件和记录。参数 ids 为 enqueue 返回的 downloadId 集合。

  • Cursor query(Query query) 查询下载信息。

  • getMaxBytesOverMobile(Context context) 返回移动网络下载的最大值

  • rename(Context context, long id, String displayName) 重命名已下载项的名字

  • getRecommendedMaxBytesOverMobile(Context context) 获取建议的移动网络下载的大小

  • 其它:通过查看代码我们可以发现还有个 CursorTranslator 私有静态内部类。这个类主要对 Query 做了一层代理。将 DownloadProvider 和 DownloadManager之间做了个映射。

接着来看看 DownloadManager.Request 的请求参数。

组装 DownloadManager.Request 请求参数

//获取Request的实例对象 
    DownloadManager.Request request = new DownloadManager.Request(Uri.parse(appUrl));

显示信息:

//设置一些基本显示信息
    request.setTitle(name); //通知栏标题
    request.setDescription(description);//通知栏内容
    request.setMimeType("application/vnd.android.package-archive");//文件的类型

网络类型:

//NETWORK_MOBILE移动网络
//NETWORK_WIFI  wifi网络
//NETWORK_BLUETOOTH 蓝牙
req.setAllowedNetworkTypes(DownloadManager.Request.NETWORK_WIFI);

通知栏显示类型:

    request.setNotificationVisibility(DownloadManager.Request
            .VISIBILITY_VISIBLE_NOTIFY_COMPLETED);
  • VISIBILITY_HIDDEN 下载UI不会显示,也不会显示在通知中,如果设置该值,
    需要声明android.permission.DOWNLOAD_WITHOUT_NOTIFICATION
  • VISIBILITY_VISIBLE 当处于下载中状态时,可以在通知栏中显示;当下载完成后,通知栏中不显示
  • VISIBILITY_VISIBLE_NOTIFY_COMPLETED 当处于下载中状态和下载完成时状态,均在通知栏中显示
  • VISIBILITY_VISIBLE_NOTIFY_ONLY_COMPLETION 只在下载完成时显示在通知栏中。

文件的保存位置:

  • 保存到外部环境的私有目录:file:///storage/emulated/0/Android/data/your-package/files/Download/app.apk
    request.setDestinationInExternalFilesDir(context, Environment.DIRECTORY_DOWNLOADS, "app.apk");
  • 保存到外部环境的共有目录: file:///storage/emulated/0/Download/app.apk
request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, "app.apk");
  • 自定义文件路径
setDestinationUri(Uri uri)

添加请求下载的网络链接的http头,比如User-Agent,gzip压缩等:

request.addRequestHeader(String header, String value)

漫游:

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

其他:

setAllowedOverMetered(boolean allow) //是否允许计量
setRequiresCharging(boolean requiresCharging)//是否在充电环境下
setVisibleInDownloadsUi(boolean isVisible)//是否显示下载界面
...

下面是本文创建Request的示例代码:

    request.setTitle(name);
    request.setDescription(description);
    //在通知栏显示下载进度
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) {
        request.allowScanningByMediaScanner();
        request.setNotificationVisibility(DownloadManager.Request
                .VISIBILITY_VISIBLE_NOTIFY_COMPLETED);
    }
    
    request.setAllowedNetworkTypes(DownloadManager.Request.NETWORK_WIFI);
    request.setDestinationInExternalPublicDir(SAVE_APP_LOCATION, SAVE_APP_NAME);

加入下载队列

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

manager.enqueue(request);

下载信息查询

DownloadManager 下载工具并没有提供相应的回调接口用于返回实时的下载进度状态。可以通过 DownloadManager.query 方法进行查询,该方法返回一个 Cursor 对象,具体看以下代码:

    private void queryDownloadManager(long id) {
        DownloadManager mDownloadManager = (DownloadManager)
                this.getSystemService(Context.DOWNLOAD_SERVICE);
        DownloadManager.Query query = new DownloadManager.Query().setFilterById(id);
        //可以对query设置一些过滤条件
        //setFilterById(long… ids)根据下载id进行过滤
        //setFilterByStatus(int flags)根据下载状态进行过滤
        Cursor cursor = mDownloadManager.query(query);

        if (cursor != null) {

            while (cursor.moveToNext()) {

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

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

        }
    }

本篇示例的打印结果如下:

man

注册广播监听通知栏点击事件和下载完成事件

当用户点击通知栏中的下载列表时,系统会发出 ACTION_NOTIFICATION_CLICKED 事件广播;下载完成时会发出 ACTION_DOWNLOAD_COMPLETE 事件广播,那么我们就可以实现一个广播接收器处理点击和完成时的状态。请看下面代码:

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

            installApk(context);

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

        }
    }

如文本下载 apk 文件,下载完成时就自动安装,使用意图进行 apk 安装:

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

DownloadManager 更新就讲到这里了,源码在文章的后面会附上。

基于 RxJava 和 retrofit 扩展的 Android 线程安全 http 请求库下载 APK 更新

针对 DownloadManager 更新,我们还可以通过 http 请求库下载 apk 文件进行更新。

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

  • 加入基础API,减少Api冗余
  • 支持离线缓存
  • 支持多种方式访问网络(get,put,post ,delete)
  • 支持Json字符串,表单提交
  • 支持文件下载和上传
  • 支持请求头统一加入
  • 支持对返回结果的统一处理
  • 支持自定义的扩展API
  • 支持统一请求访问网络的流程控制

Novate官方文档

我下载了源码,并修改了进度条的接口。下载文件相信大家都比较熟悉了,我这里就不再细讲了。如果有什么疑问请链接上面地址查看。

新建通知

以下给出本篇用到的消息代码:

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

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

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

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

如果你还想了解更多 Notification 实现显示下载进度,请连接 Android中使用Notification实现应用更新显示下载进度

apk下载

    private void downloadApk() {

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

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

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

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

    }

如果你还有疑问,在文章结尾处下载源码进行查看。

更新全过程效果图:

app

热更新(AndFix)

热更新技术近段时间非常火爆,各个大公司都相继开发自己的热更新框架。由于公司主要项目基于电商商城,所以我选择了阿里巴巴的 AndFix 热更新的实现,使用起来也比较简单。至少在我的测试下修改一些小的 BUG 是没有问题的。

我的开发工具是 Android Studio ,第一步导包:

app 的 dependencies 的节点下:

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

第二步配置 MyApplication 类:

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

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

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

首先获取到版本号,系统会判断版本号,只有相同的版本号的时候会执行热更新。其中 String patchFileString = "/sdcard" + APATCH_PATH; 是我测试的补丁存放路径。你需要替换成你自己的存放路径。

注意:文件的权限。

然后在 MainActivity 中写一个打印吐司的方法:

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

然后打包,重命名为 old.apk

接着修改吐司的内容:

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

重新打包,命名为 new.apk

下载apkpatch工具

下载路径

下面是我的目录结构:

app

用红线框框住的是签名文件,补丁包,旧包。

打开 cmd ->cd 到 apkpatch 的目录,如我 F:\AndroidTools\apkpatch 目录下,下图我已用红框圈住:

app

然后输入:

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

其中:

  • -f 是新apk的名字

  • -t 是旧apk的名字

  • -o 是输出补丁的文件夹位置

  • -k 是 keystore(jks)文件的名称

  • -p 是keystore文件的密码

  • -a 是项目的别名

  • -e 别名的密码

回车,不出现错误,补丁打包成功。

打开 output 目录,则可以看到 out.apatch 文件。

app

补丁文件上传到后台,然后通过接口下载到 /sdcard/out.apatch 目录下。

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

看看效果:

安装 old.apk 包:

app

安装补丁,接着运行:

app

若你有什么疑问请留言,如果对你有所帮助,请关注一下。

源码地址

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

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,428评论 25 707
  • 场景 在直播领域,明星用户短期内可能收到海量的用户点赞。那么,如何将这些点赞数据入库? 问题分析 点赞类似于秒杀。...
    老吴学技术阅读 3,198评论 2 1
  • 小时候 乡愁是一枚小小的邮票 我在这头 母亲在那头 长大后 乡愁是一枚窄窄的船票 我在这头 新娘在那头 后来...
    玉焕阅读 1,472评论 0 0
  • 虽然碎片都是嵌入在活动中显示的,可以实际上它们的关系并没有那么亲密。你可以看出,碎片和活动各自存在于一个独立的类当...
    AndYMJ阅读 1,432评论 2 0