Android APK 更新之路
iuds8979
8年前
<h3>一、前言</h3> <p>提到 APK 更新,大家可能会想到友盟(umeng)更新,市场上已有数万款应用在使用友盟自动更新的服务。但友盟于 2016 年 10 月 15 日起停止了更新服务。那么我们需要自己处理 APK 更新的业务。</p> <p>本篇主要讲解以下知识点:</p> <ul> <li> <p>使用 DownloadManager 更新</p> </li> <li> <p>基于 RxJava 和 retrofit 扩展的 Android 线程安全 http 请求库下载 APK 更新</p> </li> <li> <p>热更新(AndFix)</p> </li> </ul> <p>我们来啾啾第一个知识点。</p> <h3>DownloadManager 更新</h3> <p>Android 2.3(API level 9)开始 Android 用系统服务(Service)的方式提供了DownloadManager 来优化处理长时间的下载操作。DownloadManager 对后台下载,下载状态回调,断点续传,下载环境设置,下载文件的操作等都有很好的支持。</p> <p>本篇基于 Android 4.0 ~7.0 (SDK 14~24) 开发,众所周知 Android 6.0 的 Runtime Permissions (运行时权限)。</p> <p>下面具体来看看 DownloadManager 更新的具体流程。</p> <p>AndroidManifest 清单文件配置权限</p> <p>下载文件需要使用到网络权限,文件读写权限:</p> <pre> <code class="language-java"><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"/></code></pre> <p>获取当前的版本号</p> <pre> <code class="language-java">getPackageManager().getPackageInfo(getPackageName(), 0).versionName;</code></pre> <p>后台需要提供查询最新版本号的接口,获取接口数据与当前版本号对比,判定是否需要更新。</p> <p>获取 DownloadManager 实例</p> <pre> <code class="language-java">DownloadManager manager = (DownloadManager) appContext.getSystemService(Context.DOWNLOAD_SERVICE);</code></pre> <p>下面来看看 DownloadManager 提供哪些接口:</p> <ul> <li> <p>public long enqueue(Request request) 执行下载,返回 downloadId,downloadId 可用于后面查询下载信息。若网络不满足条件、Sdcard 挂载中、超过最大并发数等异常则会等待下载,正常则直接下载。</p> </li> <li> <p>int remove(long… ids) 删除下载,若下载中取消下载。会同时删除下载文件和记录。参数 ids 为 enqueue 返回的 downloadId 集合。</p> </li> <li> <p>Cursor query(Query query) 查询下载信息。</p> </li> <li> <p>getMaxBytesOverMobile(Context context) 返回移动网络下载的最大值</p> </li> <li> <p>rename(Context context, long id, String displayName) 重命名已下载项的名字</p> </li> <li> <p>getRecommendedMaxBytesOverMobile(Context context) 获取建议的移动网络下载的大小</p> </li> <li> <p>其它:通过查看代码我们可以发现还有个 CursorTranslator 私有静态内部类。这个类主要对 Query 做了一层代理。将 DownloadProvider 和 DownloadManager之间做了个映射。</p> </li> </ul> <p>接着来看看 DownloadManager.Request 的请求参数。</p> <p>组装 DownloadManager.Request 请求参数</p> <pre> <code class="language-java">//获取Request的实例对象 DownloadManager.Request request = new DownloadManager.Request(Uri.parse(appUrl));</code></pre> <p>显示信息:</p> <pre> <code class="language-java">//设置一些基本显示信息 request.setTitle(name); //通知栏标题 request.setDescription(description);//通知栏内容 request.setMimeType("application/vnd.android.package-archive");//文件的类型</code></pre> <p>网络类型:</p> <pre> <code class="language-java">//NETWORK_MOBILE移动网络 //NETWORK_WIFI wifi网络 //NETWORK_BLUETOOTH 蓝牙 req.setAllowedNetworkTypes(DownloadManager.Request.NETWORK_WIFI);</code></pre> <p>通知栏显示类型:</p> <pre> <code class="language-java">request.setNotificationVisibility(DownloadManager.Request .VISIBILITY_VISIBLE_NOTIFY_COMPLETED);</code></pre> <ul> <li>VISIBILITY_HIDDEN 下载UI不会显示,也不会显示在通知中,如果设置该值,<br> 需要声明android.permission.DOWNLOAD_WITHOUT_NOTIFICATION</li> <li>VISIBILITY_VISIBLE 当处于下载中状态时,可以在通知栏中显示;当下载完成后,通知栏中不显示</li> <li>VISIBILITY_VISIBLE_NOTIFY_COMPLETED 当处于下载中状态和下载完成时状态,均在通知栏中显示</li> <li>VISIBILITY_VISIBLE_NOTIFY_ONLY_COMPLETION 只在下载完成时显示在通知栏中。</li> </ul> <p>文件的保存位置:</p> <ul> <li>保存到外部环境的私有目录:file:///storage/emulated/0/Android/data/your-package/files/Download/app.apk</li> </ul> <pre> <code class="language-java">request.setDestinationInExternalFilesDir(context, Environment.DIRECTORY_DOWNLOADS, "app.apk");</code></pre> <ul> <li>保存到外部环境的共有目录: file:///storage/emulated/0/Download/app.apk</li> </ul> <pre> <code class="language-java">request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, "app.apk");</code></pre> <ul> <li>自定义文件路径</li> </ul> <pre> <code class="language-java">setDestinationUri(Uri uri)</code></pre> <p>添加请求下载的网络链接的http头,比如User-Agent,gzip压缩等:</p> <pre> <code class="language-java">request.addRequestHeader(String header, String value)</code></pre> <p>漫游:</p> <pre> <code class="language-java">//true 允许 //false 不允许 request.setAllowedOverRoaming(false);</code></pre> <p>其他:</p> <pre> <code class="language-java">setAllowedOverMetered(boolean allow) //是否允许计量 setRequiresCharging(boolean requiresCharging)//是否在充电环境下 setVisibleInDownloadsUi(boolean isVisible)//是否显示下载界面 ...</code></pre> <p>下面是本文创建Request的示例代码:</p> <pre> <code class="language-java">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);</code></pre> <p>加入下载队列</p> <pre> <code class="language-java">DownloadManager manager = (DownloadManager) appContext.getSystemService(Context.DOWNLOAD_SERVICE); manager.enqueue(request);</code></pre> <p>下载信息查询</p> <p>DownloadManager 下载工具并没有提供相应的回调接口用于返回实时的下载进度状态。可以通过 DownloadManager.query 方法进行查询,该方法返回一个 Cursor 对象,具体看以下代码:</p> <pre> <code class="language-java">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); } } }</code></pre> <p>本篇示例的打印结果如下:</p> <p><img src="https://simg.open-open.com/show/5934ade02089d0a0aef6c9254f80eaa1.png"></p> <p>man</p> <p>注册广播监听通知栏点击事件和下载完成事件</p> <p>当用户点击通知栏中的下载列表时,系统会发出 ACTION_NOTIFICATION_CLICKED 事件广播;下载完成时会发出 ACTION_DOWNLOAD_COMPLETE 事件广播,那么我们就可以实现一个广播接收器处理点击和完成时的状态。请看下面代码:</p> <pre> <code class="language-java">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(); } }</code></pre> <p>如文本下载 apk 文件,下载完成时就自动安装,使用意图进行 apk 安装:</p> <pre> <code class="language-java">// 安装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(); } }</code></pre> <p>DownloadManager 更新就讲到这里了,源码在文章的后面会附上。</p> <h3>基于 RxJava 和 retrofit 扩展的 Android 线程安全 http 请求库下载 APK 更新</h3> <p>针对 DownloadManager 更新,我们还可以通过 http 请求库下载 apk 文件进行更新。</p> <p>提到 http 请求库,就不得不提到 Novate 库,功能非常强大,使用便利,看看它有哪些功能:</p> <ul> <li>加入基础API,减少Api冗余</li> <li>支持离线缓存</li> <li>支持多种方式访问网络(get,put,post ,delete)</li> <li>支持Json字符串,表单提交</li> <li>支持文件下载和上传</li> <li>支持请求头统一加入</li> <li>支持对返回结果的统一处理</li> <li>支持自定义的扩展API</li> <li>支持统一请求访问网络的流程控制</li> </ul> <p>我下载了源码,并修改了进度条的接口。下载文件相信大家都比较熟悉了,我这里就不再细讲了。如果有什么疑问请链接上面地址查看。</p> <p>新建通知</p> <p>以下给出本篇用到的消息代码:</p> <pre> <code class="language-java">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); }</code></pre> <p>apk下载</p> <pre> <code class="language-java">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); } }); }</code></pre> <p>如果你还有疑问,在文章结尾处下载源码进行查看。</p> <p>更新全过程效果图:</p> <p style="text-align:center"><img src="https://simg.open-open.com/show/9b6334f82676c82c68fc391f9dbba412.gif"></p> <p> </p> <h3>热更新(AndFix)</h3> <p>热更新技术近段时间非常火爆,各个大公司都相继开发自己的热更新框架。由于公司主要项目基于电商商城,所以我选择了阿里巴巴的 AndFix 热更新的实现,使用起来也比较简单。至少在我的测试下修改一些小的 BUG 是没有问题的。</p> <p>我的开发工具是 Android Studio ,第一步导包:</p> <p>app 的 dependencies 的节点下:</p> <pre> <code class="language-java">compile 'com.alipay.euler:andfix:0.3.1@aar'</code></pre> <p>第二步配置 MyApplication 类:</p> <pre> <code class="language-java">@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(); } }</code></pre> <p>首先获取到版本号,系统会判断版本号,只有相同的版本号的时候会执行热更新。其中 String patchFileString = "/sdcard" + APATCH_PATH; 是我测试的补丁存放路径。你需要替换成你自己的存放路径。</p> <p>注意:文件的权限。</p> <p>然后在 MainActivity 中写一个打印吐司的方法:</p> <pre> <code class="language-java">private void showToast() { Toast.makeText(this, "你好啊", Toast.LENGTH_LONG).show(); }</code></pre> <p>然后打包,重命名为 old.apk</p> <p>接着修改吐司的内容:</p> <pre> <code class="language-java">private void showToast() { Toast.makeText(this, "你好啊,世界", Toast.LENGTH_LONG).show(); }</code></pre> <p>重新打包,命名为 new.apk</p> <p>下载apkpatch工具</p> <p>下面是我的目录结构:</p> <p style="text-align:center"><img src="https://simg.open-open.com/show/b823013814714cba824fa65e5e01f26b.png"></p> <p>用红线框框住的是签名文件,补丁包,旧包。</p> <p>打开 cmd ->cd 到 apkpatch 的目录,如我 F:\AndroidTools\apkpatch 目录下,下图我已用红框圈住:</p> <p style="text-align:center"><img src="https://simg.open-open.com/show/1a33523c1ed68f139887768eb42b189d.png"></p> <p>然后输入:</p> <pre> <code class="language-java">apkpatch.bat -f new.apk -t old.apk -o output -k demo.jks -p 123456 -a boby -e 123456</code></pre> <p>其中:</p> <ul> <li> <p>-f 是新apk的名字</p> </li> <li> <p>-t 是旧apk的名字</p> </li> <li> <p>-o 是输出补丁的文件夹位置</p> </li> <li> <p>-k 是 keystore(jks)文件的名称</p> </li> <li> <p>-p 是keystore文件的密码</p> </li> <li> <p>-a 是项目的别名</p> </li> <li> <p>-e 别名的密码</p> </li> </ul> <p>回车,不出现错误,补丁打包成功。</p> <p>打开 output 目录,则可以看到 out.apatch 文件。</p> <p style="text-align:center"><img src="https://simg.open-open.com/show/84a02f3e0786740f1bc77cfd85b148fd.png"></p> <p>补丁文件上传到后台,然后通过接口下载到 /sdcard/out.apatch 目录下。</p> <p>注意 /sdcard/out.apatch 路径,跟 MyApplication 中的一致。</p> <p>看看效果:</p> <p>安装 old.apk 包:</p> <p style="text-align:center"><img src="https://simg.open-open.com/show/afebcbe8d04f2d49d8263770a1bc5e40.png"></p> <p>安装补丁,接着运行:</p> <p style="text-align:center"><img src="https://simg.open-open.com/show/258fee3874072a2d1e3a00a8171d3676.png"></p> <p> </p> <p> </p> <p>来自:http://www.jianshu.com/p/61336c6f750a</p> <p> </p>