Android APK 更新之路

iuds8979 7年前
   <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>