場景
安卓app應用更新全流程如下
管理員登錄后台系統,從瀏覽器上通過前端將apk以及版本號和更新說明等信息上傳到后台。
后台提供app版本的管理的上傳接口和增刪改查的接口以及檢測最新版本的接口。
app在啟動后會首先調用檢測最新版本的接口,獲得最新版本的相關信息,如果最新版本的版本號大於當前應用的版本號則提示是否更新,點擊更新后則會后后台提供的下載接口去下載最新的安裝包並安裝。
注:
博客主頁:
https://blog.csdn.net/badao_liumang_qizhi
關注公眾號
霸道的程序猿
獲取編程相關電子書、教程推送與免費下載。
實現
Android使用Service+OKHttp實現應用后台檢測與下載安裝
新建一個Android項目,這里叫AppUpdateDemo
然后打開build.gradle,添加gson和okhttp的依賴
implementation 'com.google.code.gson:gson:2.8.6' implementation 'com.squareup.okhttp3:logging-interceptor:3.4.1'
添加位置如下
然后打開AndroidManifest.xml添加相關權限
<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" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <!-- 彈出系統對話框 因為要在Service中彈出對話框,故添加該權限,使得對話框獨立運行,不依賴某個Activity --> <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" /> <!--安裝文件--> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
添加位置如下
因為在下載完apk之后需要打開下載的apk安裝包進而調出安裝,而在安卓7.0以上禁止在應用外部公開file://URL
所以需要在AndroidManifest.xml中做如下配置
<!-- 在安卓7.0以上禁止在應用外部公開 file://URI --> <provider android:name="androidx.core.content.FileProvider" android:authorities="com.badao.appupdatedemo.fileProvider" android:exported="false" android:grantUriPermissions="true"> <meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_path" /> <!-- 上面的resource="xxx"指的是一個文件,file_path是文件名 --> </provider>
配置位置如下
一定要注意這里代碼中的包名要修改為跟自己的包名一致
然后上面的配置會指向一個res下xml下的file_path.xml的路徑,此時在Android Studio中會報紅色提示,將鼠標放在紅色提示上,
根據提示新建此文件
回車之后,點擊OK即可
建立成功之后的路徑為
建立成功之后將其代碼修改為
<?xml version="1.0" encoding="utf-8"?> <paths xmlns:android="http://schemas.android.com/apk/res/android"> <external-path name="install_eric" path="."/> <root-path name="root_path" path="."/> </paths>
這樣在下載安裝包之后就能調起安裝
然后在包下新建config文件夾,然后新建一個UpdateConfig類
package com.badao.appupdatedemo.config; public class UpdateConfig { //文件存在且完整 public static final int FILE_EXIST_INTACT = 1; //文件不存在 public static final int FILE_NOT_EXIST = 2; //文件不完整 public static final int FILE_NOT_INTACT = 3; //文件不完整 刪除文件異常 public static final int FILE_DELETE_ERROR = 4; //獲取版本信息異常 public static final int CLIENT_INFO_ERROR = 5; //需要彈出哪個對話框 public static final int DIALOG_MUST = 6; public static final int DIALOG_CAN = 7; //下載異常 public static final int DOWNLOAD_ERROR = 8; //安裝異常 public static final int INSTALL_ERROR = 9; }
再新建一個update目錄,此目錄下新建三個類
第一個類是UpdateCheck
package com.badao.appupdatedemo.update; import android.content.Context; import android.net.ConnectivityManager; import android.net.NetworkInfo; import android.os.Binder; import android.os.Build; import android.provider.Settings; import android.text.TextUtils; import android.util.Log; import com.badao.appupdatedemo.bean.UpdateBean; import com.google.gson.Gson; import java.io.IOException; import java.lang.reflect.Field; import java.lang.reflect.Method; import okhttp3.Call; import okhttp3.Callback; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.Response; public class UpdateCheck { /** * 當前已連接的網絡是否可用 * @param context * @return */ public static boolean isNetWorkAvailable(Context context) { if (context != null) { ConnectivityManager connectivityManager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); if (connectivityManager != null) { NetworkInfo activeNetworkInfo = connectivityManager.getActiveNetworkInfo(); if (activeNetworkInfo.isConnected()){ return activeNetworkInfo.isAvailable(); }else{ return false; } } else { return false; } } return false; } /** * 網絡是否已經連接 * @param context * @return */ public static boolean isNetWorkConnected(Context context){ if (context!=null){ ConnectivityManager connectivityManager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); if (connectivityManager!=null){ NetworkInfo activeNetworkInfo = connectivityManager.getActiveNetworkInfo(); return activeNetworkInfo.isConnected(); }else { return false; } } return false; } /** * 檢查版本 后台需要傳的是版本名和包名, 可以根據自己需求更改 * @param client * @param url * @param packageName * @param result */ public static void checkVersion(OkHttpClient client, String url, String versionName, String packageName, CheckVersionResult result){ if (TextUtils.isEmpty(url)){ result.fail(-1); }else { Log.i("EricLog", "url = \n" + url); Request.Builder request = new Request.Builder(); request.url(url); client.newCall(request.get().build()).enqueue(new Callback() { @Override public void onFailure(Call call, IOException e) { result.error(e); } @Override public void onResponse(Call call, Response response) throws IOException { if (response.isSuccessful()){ Gson gson = new Gson(); String s = response.body().string(); //UpdateBean實體類, 根據自己需求寫 UpdateBean info = gson.fromJson(s, UpdateBean.class); //后台只給返回在服務器磁盤上的地址 String oldUrl = info.getData().getDownloadLink(); Log.i("oldUrl", "oldUrl = \n" + oldUrl); //這里將下載apk的地址適配為自己的下載地址 String newUrl = "http://自己服務器的ip:8888/system/file/download?fileName="+oldUrl; Log.i("newUrl", "newUrl = \n" + newUrl); info.getData().setDownloadLink(newUrl); result.success(info); if (!call.isCanceled()){ call.cancel(); } }else { result.fail(response.code()); if (!call.isCanceled()) { call.cancel(); } } } }); } } /** * 檢查懸浮窗權限 * @param context * @return */ public static boolean checkFloatPermission(Context context) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) return true; if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { try { Class cls = Class.forName("android.content.Context"); Field declaredField = cls.getDeclaredField("APP_OPS_SERVICE"); declaredField.setAccessible(true); Object obj = declaredField.get(cls); if (!(obj instanceof String)) { return false; } String str2 = (String) obj; obj = cls.getMethod("getSystemService", String.class).invoke(context, str2); cls = Class.forName("android.app.AppOpsManager"); Field declaredField2 = cls.getDeclaredField("MODE_ALLOWED"); declaredField2.setAccessible(true); Method checkOp = cls.getMethod("checkOp", Integer.TYPE, Integer.TYPE, String.class); int result = (Integer) checkOp.invoke(obj, 24, Binder.getCallingUid(), context.getPackageName()); return result == declaredField2.getInt(cls); } catch (Exception e) { return false; } } else { return Settings.canDrawOverlays(context); } } public interface CheckVersionResult{ //UpdateBean是實體類 自己替換就行 void success(UpdateBean info); void fail(int code); void error(Throwable throwable); } }
此類主要是一些工具類方法。
使用時需要將此類中下載apk的地址修改為自己后台服務器的下載地址
這里需要下載地址進行拼接並重新賦值的原因是,后台在上傳apk時調用的是通用的apk上傳接口
返回的是在服務器上的完整路徑,而在下載時調用的是通用的文件下載接口,傳遞的是文件在服務器上的
全路徑。
第二個類是UpdateDialog
package com.badao.appupdatedemo.update; import android.app.AlertDialog; import android.app.Dialog; import android.app.ProgressDialog; import android.content.Context; import android.content.DialogInterface; import android.os.Build; import android.view.WindowManager; public class UpdateDialog { /** * 強制更新對話框 * @param context * @param msg * @param listener * @return */ public static Dialog mustUpdate(Context context, String msg, DialogInterface.OnClickListener listener){ AlertDialog.Builder builder = new AlertDialog.Builder(context); builder.setTitle("版本更新"); builder.setMessage(msg); builder.setCancelable(false); builder.setPositiveButton("更新", listener); return builder.create(); } /** * 可以更新對話框 * @param context * @param msg * @param listener * @param cancel * @return */ public static Dialog canUpdate(Context context, String msg, DialogInterface.OnClickListener listener, DialogInterface.OnCancelListener cancel){ AlertDialog.Builder builder = new AlertDialog.Builder(context); builder.setTitle("版本更新"); builder.setMessage(msg); builder.setCancelable(false); builder.setPositiveButton("更新", listener); builder.setNegativeButton("暫不更新", listener); builder.setOnCancelListener(cancel); return builder.create(); } /** * 版本獲取異常對話框 * @param context * @param msg * @param listener * @return */ public static Dialog errorDialog(Context context, String msg, DialogInterface.OnClickListener listener){ AlertDialog.Builder builder = new AlertDialog.Builder(context); builder.setTitle("版本更新"); builder.setMessage(msg); builder.setCancelable(false); builder.setNegativeButton("確定", listener); return builder.create(); } /** * 更新進度對話框 * @param context * @return */ public static ProgressDialog durationDialog(Context context){ ProgressDialog dialog = new ProgressDialog(context); dialog.setTitle("版本更新"); dialog.setProgressStyle(ProgressDialog.STYLE_HORIZONTAL); dialog.setCancelable(false); return dialog; } /** * 設置系統對話框 * @param dialog */ public static void setType(Dialog dialog){ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { dialog.getWindow().setType(WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY); }else { dialog.getWindow().setType(WindowManager.LayoutParams.TYPE_SYSTEM_ALERT); } } }
此類主要是聲明一些彈窗。
第三個類是UpdateFile
package com.badao.appupdatedemo.update; import android.content.Context; import android.content.Intent; import android.net.Uri; import android.os.AsyncTask; import android.os.Build; import android.os.Handler; import android.util.Log; import androidx.core.content.FileProvider; import com.badao.appupdatedemo.BuildConfig; import com.badao.appupdatedemo.config.UpdateConfig; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.Response; public class UpdateFile { //異步校驗網絡文件 如果后台沒有把文件長度傳你可以使用這個判斷 public static class CheckFile extends AsyncTask<String, Integer, Void> { private File file; private String fileUrl; private Handler handler; public CheckFile(File file, String fileUrl, Handler handler){ this.file = file; Log.i("CheckFile-file","file="+file.toString()); this.fileUrl = fileUrl; Log.i("CheckFile-fileUrl","fileUrl="+fileUrl.toString()); this.handler = handler; } @Override protected Void doInBackground(String... strings) { if (file.exists()){ if (verifyFile(fileUrl,file)){ //如果文件完整 handler.sendEmptyMessage(UpdateConfig.FILE_EXIST_INTACT); }else { //如果文件不完整則先刪除現有文件,然后下載文件 if (!file.delete()) { handler.sendEmptyMessage(UpdateConfig.FILE_DELETE_ERROR); return null; } handler.sendEmptyMessage(UpdateConfig.FILE_NOT_INTACT); } }else { handler.sendEmptyMessage(UpdateConfig.FILE_NOT_EXIST); } return null; } } /** * 校驗網絡文件 * @param mFile * @param fileUrl * @param handler */ public static void checkFile(File mFile, String fileUrl, Handler handler) { Log.i("EricLog", "校驗文件"); if (mFile.exists()){ if (verifyFile(fileUrl,mFile)){ //如果文件完整 handler.sendEmptyMessage(UpdateConfig.FILE_EXIST_INTACT); }else { //如果文件不完整則先刪除現有文件,然后下載文件 if (!mFile.delete()) { handler.sendEmptyMessage(UpdateConfig.FILE_DELETE_ERROR); return; } handler.sendEmptyMessage(UpdateConfig.FILE_NOT_INTACT); } }else { handler.sendEmptyMessage(UpdateConfig.FILE_NOT_EXIST); } } /** * 校驗文件是否完整 * @param urlLoadPath * @param file * @return */ private static boolean verifyFile(String urlLoadPath, File file){ long length = file.length();//已下載的文件長度 long realLength = getFileLength(urlLoadPath);//網絡獲取的文件長度 Log.e("EricLog", "下載長度:" +length +"\t\t文件長度:" +realLength); if (length != 0){ if (realLength != 0 && (realLength == length)){ return true; } } return false; } /** * 獲取需要下載的文件長度 * @param url * @return */ private static long getFileLength(String url) { OkHttpClient client = new OkHttpClient(); Request request = new Request.Builder() .url(url) .build(); Response response; try { response = client.newCall(request).execute(); if (response.isSuccessful()){ long length = response.body().contentLength(); response.body().close(); return length; } } catch (IOException e) { e.printStackTrace(); } return 0; } //安裝 public static void installApp(File file, Context context, Handler handler) { if (!file.exists()) { return; } // 跳轉到新版本應用安裝頁面 try { Intent intent = new Intent(Intent.ACTION_VIEW); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); if (Build.VERSION.SDK_INT > Build.VERSION_CODES.N){ intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); Uri uri = FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID+ ".fileProvider",file); intent.setDataAndType(uri, "application/vnd.android.package-archive"); }else { intent.setDataAndType(Uri.fromFile(file), "application/vnd.android.package-archive"); } context.startActivity(intent); }catch (Throwable throwable){ Log.e("EricLog", "Error = " +throwable.getMessage()); handler.sendEmptyMessage(UpdateConfig.INSTALL_ERROR); } } /** * 異步網絡文件下載並保存 * */ public static class DownloadAsync extends AsyncTask<String,Integer,Integer> { private static final int DOWNLOAD_SUCCESS = 1; private static final int DOWNLOAD_FAIL = 2; private DownloadListener listener; private int lastProgress; private File file; public DownloadAsync(File file, DownloadListener listener){ this.file = file; this.listener = listener; } public interface DownloadListener{ void onProgress(int progress); void onSuccess(); void onFail(); } @Override protected Integer doInBackground(String... strings) { InputStream inputStream = null; FileOutputStream fileOutputStream = null; long contentLength = getFileLength(strings[0]); try { OkHttpClient client = new OkHttpClient(); Request request = new Request.Builder() .url(strings[0]) .build(); Response response = client.newCall(request).execute(); if (response.code() == 200) { inputStream = response.body().byteStream(); fileOutputStream = new FileOutputStream(file); byte[] b = new byte[1024]; int total = 0; int len; while ((len = inputStream.read(b)) != -1) { total += len; fileOutputStream.write(b, 0, len); //百分比的計算在這里 float pressent = (float) total / contentLength * 100; int progress = (int) pressent; publishProgress(progress); } response.body().close(); return DOWNLOAD_SUCCESS; }else { return DOWNLOAD_FAIL; } } catch (IOException e) { e.printStackTrace(); } finally { try { if (inputStream != null) { inputStream.close(); } if (fileOutputStream != null) { fileOutputStream.close(); } } catch (IOException e) { e.printStackTrace(); } } return DOWNLOAD_FAIL; } @Override protected void onProgressUpdate(Integer... values) { int progress = values[0]; if (progress > lastProgress){ listener.onProgress(progress); lastProgress = progress; } } @Override protected void onPostExecute(Integer integer) { switch (integer){ case DOWNLOAD_SUCCESS: listener.onSuccess(); break; case DOWNLOAD_FAIL: listener.onFail(); break; default: break; } } } }
此類主要是用於校驗文件、獲取文件大小和下載與安裝文件的一些方法
新建完這三個工具類之后,再在包下新建一個service目錄,在此目錄下新建一個Service
然后修改名字為UpdateService
點擊Finish之后,會在AndroidManifest.xml中自動添加一個service
<service android:name=".service.UpdateService" android:enabled="true" android:exported="true"></service>
然后修改UpdateService的代碼
package com.badao.appupdatedemo.service; import android.annotation.SuppressLint; import android.app.Dialog; import android.app.ProgressDialog; import android.app.Service; import android.content.Context; import android.content.DialogInterface; import android.content.Intent; import android.content.pm.PackageInfo; import android.content.pm.PackageManager; import android.os.Environment; import android.os.Handler; import android.os.IBinder; import android.os.Message; import android.text.TextUtils; import android.util.Log; import androidx.annotation.NonNull; import com.badao.appupdatedemo.bean.UpdateBean; import com.badao.appupdatedemo.config.UpdateConfig; import com.badao.appupdatedemo.update.UpdateCheck; import com.badao.appupdatedemo.update.UpdateDialog; import com.badao.appupdatedemo.update.UpdateFile; import java.io.File; import java.io.IOException; import java.util.concurrent.TimeUnit; import okhttp3.OkHttpClient; public class UpdateService extends Service { public static boolean isRunning = false; public static String checkUrl = "http://你自己服務器的地址:8888/fzyscontrol/sys/version/getLastestVersion"; //當前版本 private int versionCode = -1; //錯誤信息 private String error_msg = ""; //更新地址 private String updateUrl = ""; //更新描述 private String description = ""; //文件路徑 private String filePath = ""; //文件名稱 private String appName = ""; //目標文件 private File targetFile; private static OkHttpClient client = getClient(); private Dialog mDialog; private ProgressDialog mProgressDialog; @SuppressLint("HandlerLeak") private Handler handler = new Handler() { @Override public void handleMessage(@NonNull Message msg) { super.handleMessage(msg); int code = msg.what; switch (code) { case UpdateConfig.FILE_EXIST_INTACT: Log.i("EricLog", "文件完整 需要安裝"); UpdateFile.installApp(targetFile, getApplicationContext(), handler); break; case UpdateConfig.FILE_NOT_EXIST: Log.i("EricLog", "文件不存在"); mProgressDialog = UpdateDialog.durationDialog(getApplicationContext()); //設置為系統對話框 UpdateDialog.setType(mProgressDialog); mProgressDialog.show(); UpdateFile.DownloadAsync dFne = new UpdateFile.DownloadAsync(targetFile, listener); dFne.execute(updateUrl); break; case UpdateConfig.FILE_DELETE_ERROR: Log.i("EricLog", "文件刪除異常"); error_msg = "文件刪除出錯了"; showDialog(DialogType.ERROR); break; case UpdateConfig.FILE_NOT_INTACT: Log.i("EricLog", "文件不完整"); mProgressDialog = UpdateDialog.durationDialog(getApplicationContext()); UpdateDialog.setType(mProgressDialog); mProgressDialog.show(); UpdateFile.DownloadAsync dFni = new UpdateFile.DownloadAsync(targetFile, listener); dFni.execute(updateUrl); break; case UpdateConfig.DIALOG_MUST: Log.i("EricLog", "彈出必須更新對話框"); showDialog(DialogType.MUST); break; case UpdateConfig.DIALOG_CAN: Log.i("EricLog", "彈出可以更新對話框"); showDialog(DialogType.CAN); break; case UpdateConfig.CLIENT_INFO_ERROR: Log.i("EricLog", "連接異常"); error_msg = "獲取版本異常,請檢查網絡"; showDialog(DialogType.ERROR); break; case UpdateConfig.DOWNLOAD_ERROR: Log.i("EricLog", "下載異常"); disProgress(false); error_msg = "下載出錯了,請重新下載"; showDialog(DialogType.ERROR); break; case UpdateConfig.INSTALL_ERROR: Log.i("EricLog", "安裝異常"); error_msg = "安裝出錯了,請手動安裝"; showDialog(DialogType.ERROR); break; } } }; @Override public IBinder onBind(Intent intent) { return null; } @Override public void onCreate() { super.onCreate(); Log.i("EricLog", "服務啟動"); filePath = Environment.getExternalStorageDirectory() .getAbsolutePath() + File.separator + "MyApp/"; Log.i("onCreate-filePath", "filePath="+filePath); } @Override public int onStartCommand(Intent intent, int flags, int startId) { if (!isRunning) { isRunning = true; versionCode = getVersionCode(getApplicationContext()); String versionName = getVersionName(getApplicationContext()); if (versionCode == -1 || TextUtils.isEmpty(versionName)){ handler.sendEmptyMessage(UpdateConfig.CLIENT_INFO_ERROR); }else { if (UpdateCheck.isNetWorkConnected(getApplicationContext()) && UpdateCheck.isNetWorkAvailable(getApplicationContext())) { UpdateCheck.checkVersion(client, checkUrl, versionName, getPackageName(), result); } else { handler.sendEmptyMessage(UpdateConfig.CLIENT_INFO_ERROR); } } } return super.onStartCommand(intent, flags, startId); } @Override public void onDestroy() { super.onDestroy(); Log.i("EricLog", "服務銷毀"); isRunning = false; } /** * 獲取版本號 * @return */ public static int getVersionCode(Context context){ PackageManager manager = context.getPackageManager(); PackageInfo info; try { info = manager.getPackageInfo(context.getPackageName(), 0); return info.versionCode; } catch (PackageManager.NameNotFoundException e) { return -1; } } public static String getVersionName(Context context){ PackageManager manager = context.getPackageManager(); PackageInfo info; try { info = manager.getPackageInfo(context.getPackageName(), 0); return info.versionName; } catch (PackageManager.NameNotFoundException e) { return ""; } } private static OkHttpClient getClient(){ return new OkHttpClient.Builder() .connectTimeout(15, TimeUnit.SECONDS) .build(); } /** * 檢查是否需要更新 按需求寫就行 */ private void checkUpdate(UpdateBean info){ //目標版本號 int targetCode = info.getData().getVersionNum(); //是否強制更新 //int isCompulsory = info.getData().getIsCompulsory(); updateUrl = info.getData().getDownloadLink(); Log.i("updateUrl","updateUrl="+updateUrl); appName = info.getData().getAppName(); description = info.getData().getUpdateInstructions(); //這里設置下載apk后存儲在本地的目標路徑的文件 //這里使用的是臨時文件的路徑 ///data/data/com.badao.appupdatedemo/cache/badao79427110100998067.apk String mPath = filePath + appName + ".apk"; try { File tempPath = File.createTempFile("badao", ".apk"); mPath = tempPath.getAbsolutePath(); } catch (IOException e) { e.printStackTrace(); } Log.i("mPath","mPath="+mPath); Log.i("EricLog", "目標文件:" + mPath); targetFile = new File(mPath); if (TextUtils.isEmpty(description)){ description = "修復了若干bug"; } if (versionCode == targetCode){ stopSelf(); }else { handler.sendEmptyMessage(UpdateConfig.DIALOG_CAN); //其他的一些情況就按需求寫吧 } } /** * 應該展示哪個對話框 * @param type */ private void showDialog(DialogType type){ if (type == DialogType.MUST){ mDialog = UpdateDialog.mustUpdate(getApplicationContext(), description, new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { UpdateFile.CheckFile checkFile = new UpdateFile.CheckFile( targetFile, updateUrl, handler); checkFile.execute(); } }); }else if (type == DialogType.CAN){ mDialog = UpdateDialog.canUpdate(getApplicationContext(), description, new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { if (which == DialogInterface.BUTTON_POSITIVE){ UpdateFile.CheckFile checkFile = new UpdateFile.CheckFile( targetFile, updateUrl, handler); checkFile.execute(); }else if (which == DialogInterface.BUTTON_NEGATIVE){ dismiss(); } } }, new DialogInterface.OnCancelListener() { @Override public void onCancel(DialogInterface dialog) { dismiss(); } }); }else if (type == DialogType.ERROR){ mDialog = UpdateDialog.errorDialog(getApplicationContext(), error_msg, new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { dismiss(); } }); } UpdateDialog.setType(mDialog); mDialog.show(); } private void dismiss(){ if (mDialog != null && mDialog.isShowing()){ stopSelf(); mDialog.dismiss(); } } private void disProgress(boolean finishService){ if (finishService){ stopSelf(); } if (mProgressDialog != null && mProgressDialog.isShowing()){ mProgressDialog.dismiss(); } } private UpdateCheck.CheckVersionResult result = new UpdateCheck.CheckVersionResult() { @Override public void success(UpdateBean info) { if (info != null && info.getData() != null) { checkUpdate(info); }else { handler.sendEmptyMessage(UpdateConfig.CLIENT_INFO_ERROR); } } @Override public void fail(int code) { Log.e("EricLog", "Code = " + code); handler.sendEmptyMessage(UpdateConfig.CLIENT_INFO_ERROR); } @Override public void error(Throwable throwable) { Log.e("EricLog", "Error = " +throwable.getMessage()); handler.sendEmptyMessage(UpdateConfig.CLIENT_INFO_ERROR); } }; private UpdateFile.DownloadAsync.DownloadListener listener = new UpdateFile.DownloadAsync.DownloadListener() { @Override public void onProgress(int progress) { mProgressDialog.setProgress(progress); } @Override public void onSuccess() { disProgress(true); UpdateFile.installApp(targetFile, getApplicationContext(), handler); } @Override public void onFail() { handler.sendEmptyMessage(UpdateConfig.DOWNLOAD_ERROR); } }; /** * 要展示的對話框類型 */ public enum DialogType{ MUST, CAN, ERROR } }
將此service的checkUrl修改為自己的服務器的ip和端口
此service中用到的將服務端的遠程apk下載到本地的路徑為臨時文件路徑,在data/data/包名下cache目錄下
然后還需要在包下新建bean包,在此包下新建版本更新接口返回的json數據對應的實體類
后台檢測更新的接口返回的json數據為
然后根據此json數據生成bean的方式參考如下
AndroidStudio中安裝GsonFormat插件並根據json文件生成JavaBean:
https://blog.csdn.net/BADAO_LIUMANG_QIZHI/article/details/110426851
然后根據json數據生成的UpdateBean為
package com.badao.appupdatedemo.bean; public class UpdateBean { /** * msg : 操作成功 * code : 200 * data : {"id":9,"appName":"測試1","versionNum":16,"downloadLink":"D://fzys/file/2020/11/30/8a4ac525-8c28-45be-834b-6db0889b7aa9.jpg","updateInstructions":"測試11122","updateTime":"2020-11-30T16:51:09.000+08:00"} */ private String msg; private int code; private DataBean data; public String getMsg() { return msg; } public void setMsg(String msg) { this.msg = msg; } public int getCode() { return code; } public void setCode(int code) { this.code = code; } public DataBean getData() { return data; } public void setData(DataBean data) { this.data = data; } public static class DataBean { /** * id : 9 * appName : 測試1 * versionNum : 16 * downloadLink : D://fzys/file/2020/11/30/8a4ac525-8c28-45be-834b-6db0889b7aa9.jpg * updateInstructions : 測試11122 * updateTime : 2020-11-30T16:51:09.000+08:00 */ private int id; private String appName; private int versionNum; private String downloadLink; private String updateInstructions; private String updateTime; public int getId() { return id; } public void setId(int id) { this.id = id; } public String getAppName() { return appName; } public void setAppName(String appName) { this.appName = appName; } public int getVersionNum() { return versionNum; } public void setVersionNum(int versionNum) { this.versionNum = versionNum; } public String getDownloadLink() { return downloadLink; } public void setDownloadLink(String downloadLink) { this.downloadLink = downloadLink; } public String getUpdateInstructions() { return updateInstructions; } public void setUpdateInstructions(String updateInstructions) { this.updateInstructions = updateInstructions; } public String getUpdateTime() { return updateTime; } public void setUpdateTime(String updateTime) { this.updateTime = updateTime; } } }
最后項目的總目錄為
然后打開MainActivity
在OnCreate方法中進行檢測是否已經開啟了懸浮窗的權限,如果已經開啟了懸浮窗的權限
則直接通過
startService(new Intent(this,UpdateService.class));
的方式啟動service進行是否更新的檢測。
否則的話會通過
//否則跳轉到開啟懸浮窗的設置頁面 Intent intent = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION); if (Build.VERSION.SDK_INT > Build.VERSION_CODES.M){ intent.setData(Uri.parse("package:" + getPackageName())); } //指定一個請求碼,這樣在重寫的onActivityResult就能篩選到設置懸浮窗之后的結果 startActivityForResult(intent, 212); }
跳轉到開啟懸浮窗權限的頁面,並指定一個請求碼為212,然后在MainActivity中重寫onActivityResult方法
就能通過請求碼獲取到跳轉到開啟懸浮窗頁面的返回結果
如果已經開啟了則直接檢測更新,否則的話會彈窗提示
@Override protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { super.onActivityResult(requestCode, resultCode, data); //跟開啟懸浮窗的請求碼一致 if (requestCode == 212){ //如果開啟了懸浮窗的權限 if (UpdateCheck.checkFloatPermission(this)){ //直接檢測更新 startService(new Intent(this,UpdateService.class)); }else { //否則彈窗提示 Toast.makeText(this, "請授予懸浮窗權限", Toast.LENGTH_SHORT).show(); } } }
MainActivity完整示例代碼
package com.badao.appupdatedemo; import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; import android.content.Intent; import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.provider.Settings; import android.widget.Toast; import com.badao.appupdatedemo.service.UpdateService; import com.badao.appupdatedemo.update.UpdateCheck; public class MainActivity extends AppCompatActivity { @Override protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { super.onActivityResult(requestCode, resultCode, data); //跟開啟懸浮窗的請求碼一致 if (requestCode == 212){ //如果開啟了懸浮窗的權限 if (UpdateCheck.checkFloatPermission(this)){ //直接檢測更新 startService(new Intent(this,UpdateService.class)); }else { //否則彈窗提示 Toast.makeText(this, "請授予懸浮窗權限", Toast.LENGTH_SHORT).show(); } } } @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); //應用啟動后會先走此方法 //如果已經開啟了懸浮窗的權限 if (UpdateCheck.checkFloatPermission(this)) { //直接啟動檢測更新的service startService(new Intent(this,UpdateService.class)); }else { //否則跳轉到開啟懸浮窗的設置頁面 Intent intent = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION); if (Build.VERSION.SDK_INT > Build.VERSION_CODES.M){ intent.setData(Uri.parse("package:" + getPackageName())); } //指定一個請求碼,這樣在重寫的onActivityResult就能篩選到設置懸浮窗之后的結果 startActivityForResult(intent, 212); } } }
安卓端完整示例代碼下載
https://download.csdn.net/download/BADAO_LIUMANG_QIZHI/13218755
然后就是搭建后台服務端。
前后端分離的方式搭建后台服務
這里使用了若依的前后端分離的版的框架搭建的后台服務。
若依前后端分離版手把手教你本地搭建環境並運行項目:
https://blog.csdn.net/BADAO_LIUMANG_QIZHI/article/details/108465662
若依微服務版手把手教你本地搭建環境並運行前后端項目:
https://blog.csdn.net/BADAO_LIUMANG_QIZHI/article/details/109363303
上面是基於SpringBoot搭建的前后端分離的項目
下面是基於SpringCloud搭建的微服務版的項目
最終都是搭建一個前端項目和后台服務接口項目。
這里以后台微服務版的版的基礎上去搭建后台接口
首先是新建通用的文件上傳和下載的接口,注意此接口一定要做好權限驗證與安全管理
import com.ruoyi.common.core.utils.DateUtils; import com.ruoyi.common.core.web.domain.AjaxResult; import com.ruoyi.system.utils.FileUtils; import com.ruoyi.system.utils.UploadUtil; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import org.apache.ibatis.annotations.Param; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; import javax.servlet.http.HttpServletResponse; import java.io.*; /** * 通用文件上傳下載接口 * @author Chrisf */ @RestController @RequestMapping("file") @Api(tags = "文件通用上傳下載") public class FileController { /** * 上傳文件 * * @param file * @return */ @PostMapping("upload") @ApiOperation("上傳") public AjaxResult head_portrait(@Param("file") MultipartFile file) { AjaxResult ajaxResult = AjaxResult.success(); try { //文件夾路徑 String path = "D://fzys/file/" + DateUtils.datePath() + "/"; FileUtils.check_folder(path); // 上傳后的文件名稱 String auth_file_name = UploadUtil.save_file(file, path); ajaxResult.put("code", 200); ajaxResult.put("message", "成功"); ajaxResult.put("fileName", path + auth_file_name); } catch (IOException e) { ajaxResult.put("code", 400); ajaxResult.put("message", "上傳失敗"); ajaxResult.put("head_portrait", null); e.printStackTrace(); } return ajaxResult; } /** * 下載文件 * @param fileName * @param response * @throws IOException */ @GetMapping("download") @ApiOperation("下載") public void down_file(String fileName, HttpServletResponse response) throws IOException { File file = new File(fileName); // 清空response response.reset(); // 設置response的Header 通知瀏覽器 已下載的方式打開文件 防止文本圖片預覽 response.addHeader("Content-Disposition", "attachment;filename=" + new String(fileName.getBytes("gbk"), "iso-8859-1")); // 轉碼之后下載的文件不會出現中文亂碼 response.addHeader("Content-Length", "" + file.length()); // 以流的形式下載文件 InputStream fis = new BufferedInputStream(new FileInputStream(fileName)); byte[] buffer = new byte[fis.available()]; fis.read(buffer); fis.close(); OutputStream toClient = new BufferedOutputStream(response.getOutputStream()); toClient.write(buffer); toClient.flush(); toClient.close(); } }
在這兩個接口中用到的工具類方法有UploadUtil.save_file
public static String save_file(MultipartFile file, String path) throws IOException { String filename=file.getOriginalFilename(); String suffix = filename.substring(filename.indexOf(".")); filename = UUID.randomUUID().toString() + suffix; File file_temp=new File(path,filename); if (!file_temp.getParentFile().exists()) { file_temp.getParentFile().mkdir(); } if (file_temp.exists()) { file_temp.delete(); } file_temp.createNewFile(); file.transferTo(file_temp); return file_temp.getName(); }
和工具類FileUtils.check_folder
public static void check_folder(String path) { File dir = new File(path); // 判斷文件夾是否存在 if (dir.isDirectory()) { } else { dir.mkdirs(); } }
以及DateUtils.datePath(),是用來生成日期文件目錄的方法
/** * 日期路徑 即年/月/日 如2018/08/08 */ public static final String datePath() { Date now = new Date(); return DateFormatUtils.format(now, "yyyy/MM/dd"); }
通用的文件上傳與下載的接口做好之后就是版本檢測更新的接口
首先我們需要設計一個數據庫來用來存儲app的版本信息
然后使用若依自帶的代碼生成工具去生成前后端的代碼,前端代碼一會也要修改,這里先找到生成的Controller
@RestController @RequestMapping("/sys/version") @Api(tags = "APP版本管理") public class SysAppVersionController extends BaseController { @Autowired private ISysAppVersionService sysAppVersionService; @Autowired private SysAppVersionMapper sysAppVersionMapper; /** * 查詢版本更新記錄列表 * @return */ @GetMapping("/getList") @ApiOperation("查詢版本更新記錄列表") public TableDataInfo getList(){ startPage(); List<SysAppVersion> list = sysAppVersionService.getList(); return getDataTable(list); } /** * 新增版本更新記錄 */ @PostMapping("/add") @ApiOperation("新增版本更新記錄") public AjaxResult addAppVersion(@RequestBody SysAppVersion sysAppVersion){ if (StringUtils.isNull(sysAppVersion.getVersionNum()) || StringUtils.isEmpty(sysAppVersion.getDownloadLink())){ return AjaxResult.error(400, "缺少必要參數"); } return sysAppVersionService.insertSysAppVersion(sysAppVersion); } /** * 修改版本更新記錄 */ @PostMapping("/edit") @ApiOperation("修改版本更新記錄") public AjaxResult editAppVersion(@RequestBody SysAppVersion sysAppVersion){ if (sysAppVersion.getId() == null){ return AjaxResult.error(400, "缺少必要參數"); } return sysAppVersionService.updateSysAppVersion(sysAppVersion); } @GetMapping("/getLastestVersion") @ApiOperation("獲取最新版本信息") public AjaxResult getLastestVersion(){ SysAppVersion sysAppVersion = sysAppVersionMapper.getLast(); return AjaxResult.success(sysAppVersion); } }
下面調用的service和mapper都是生成的對單表的進行增刪改的代碼
這里主要是添加一個檢測版本更新的接口,即上面的獲取最新版本信息。
其最終執行mapper方法為
<!--查詢最新的更新記錄--> <select id="getLast" resultMap="SysAppVersionResult"> <include refid="selectSysAppVersionVo"></include> order by version_num desc limit 1 </select>
此接口從數據庫中查詢出來版本號最高的那條記錄並將此記錄的相關信息返回給app端
app獲取到版本好之后跟自己的當前的版本的版本號進行對比,如果高於當前版本則提示更新。
app端版本號的設置位置在
此接口的地址就是對應安卓端UpdateService中的checkUrl的地址。
然后就是修改前端頁面,將vue頁面修改如下
<template> <div class="app-container"> <el-row :gutter="10" class="mb8"> <el-row class="btn_box"> <el-button type="primary" icon="el-icon-plus" size="mini" @click="handleAdd" >新增</el-button> </el-row> <el-table :data="tableData" :height="tableHeight" :loading="listLoding" style="width: 100%"> <el-table-column prop="appName" label="應用名稱" width="180"> </el-table-column> <el-table-column prop="versionNum" label="版本號" width="180"> </el-table-column> <el-table-column prop="updateTime" label="更新時間"> <template slot-scope='scope'> {{ scope.row.updateTime | dataFormat }} </template> </el-table-column> <el-table-column prop="updateInstructions" label="更新說明"> </el-table-column> <el-table-column label="操作" align="center" class-name="small-padding fixed-width"> <template slot-scope="scope"> <el-button size="mini" type="text" icon="el-icon-edit" @click="handleUpdate(scope.row)" >修改</el-button> </template> </el-table-column> </el-table> <pagination v-show="total>0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="listData" /> <!-- 添加或修改通訊錄對話框 --> <el-dialog :title="title" :visible.sync="open" width="500px" append-to-body> <el-form ref="form" :model="form" :rules="rules" label-width="80px"> <el-form-item label="應用名稱" prop="appName" v-if="editStatus "> <el-input v-model="form.appName" placeholder="請輸入應用名稱" /> </el-form-item> <el-form-item label="版本號" prop="versionNum" :rules="[ { required: true, message: '版本號不能為空'}, { type: 'number', message: '版本號必須為數字值'} ]" v-if="editStatus" > <el-input v-model.number="form.versionNum" placeholder="請輸入版本號" /> </el-form-item> <el-form-item label="更新說明" prop="updateInstructions"> <el-input v-model="form.updateInstructions" placeholder="請輸入更新說明" /> </el-form-item> </el-form> <el-col v-if="editStatus" class="upload_box"> <el-upload :headers="headers" :action="url" :multiple="false" :file-list="fileList" :on-remove="fileRemove" :on-success="uploadSuccess" :on-error="uploadError" :on-progress="uploadProgress" :limit="1" :on-exceed="beyond" > <el-button size="small"> 上傳 <i class="el-icon-upload el-icon--right"></i> </el-button> </el-upload> </el-col> <div slot="footer" class="dialog-footer"> <el-button type="primary" @click="submitForm" :loading="btnLoding" v-show="editStatus">確 定</el-button> <el-button type="primary" @click="editSubmit" v-show="!editStatus">確 定</el-button><!--修改按鈕 --> <el-button @click="cancel">取 消</el-button> </div> </el-dialog> </el-row> </div> </template> <script> import {upload,getList,add,edit} from "@/api/tool/edition.js" import {getToken} from '@/utils/auth' export default { components: {}, props: {}, data() { return { //列表數據 tableData:[], // 查詢參數 queryParams: { pageNum: 1, pageSize: 10, }, title:"", // 表單參數 form: { id:null, appName:null, downloadLink:null, updateInstructions:null, versionNum:null }, // 文件列表 fileList:[], // 表格自適應高度 tableHeight: document.body.clientHeight - 230, // 是否顯示彈出層 open:false, // 總條數 total: 0, //返回的文件url fileUrl: '', // 文件列表 // 上傳按鈕閘口 btnLoding:false, // 列表加載動畫 listLoding:true, // 表單校驗 rules: { appName: [ { required: true, message: "應用名稱不能為空", trigger: "blur" } ], updateInstructions: [ { required: true, message: "更新說明不能為空", trigger: "blur" } ], }, // 修改字段顯隱 editStatus:true, // 修改id editId:null, progess: 0, // 請求頭 headers:{Authorization:"Bearer" +' ' + getToken()}, // 上傳地址 url:process.env.VUE_APP_BASE_API + '/system/file/upload' }; }, watch: {}, computed: {}, methods: { listData(){ getList(this.queryParams).then(res=>{ this.tableData = res.rows; this.total = res.total; this.listLoding = false; }) }, // 顯現新增彈窗 handleAdd(){ this.title = '新增'; this.open = true; this.editStatus = true if(this.$refs['form']){ this.$refs['form'].resetFields(); } this.fileList = []; this.btnLoding = false; }, // 文件上傳成功 uploadSuccess(res,file,fileList){ console.log(fileList) let fileParam={ name:null, url:null } this.btnLoding = false; this.form.downloadLink = res.fileName; fileParam.url =res.fileName; fileParam.name =res.name; this.fileList= fileList; this.$message(res.msg); }, // 文件上傳失敗 uploadError(err){ this.btnLoding = false; this.$message.error(res.msg); }, // 上傳中 uploadProgress(e){ this.btnLoding = true; console.log(e,'上傳中') }, beyond(file, fileList){ this.$message({ message: '最多上傳一個文件', type: 'warning' }); }, // 移除選擇的文件 fileRemove(file, fileList) { this.btnLoding = false; console.log(file) this.fileList = []; this.form.downloadLink = null; }, // 新增 submitForm(){ this.$refs["form"].validate(valid => { if (valid) { // console.log(this.form.fileName) if(!this.form.downloadLink){ this.$notify({ title: '警告', message: '請上傳文件后在進行提交', type: 'warning' }); }else{ add(this.form).then(res =>{ if(res.code == 200){ this.$message(res.msg); this.$refs['form'].resetFields(); this.fileList = []; this.open = false; this.listData(); }else{ this.$message.error(res.msg); } }) } } }); }, // 修改 handleUpdate(row){ this.editStatus = false; this.title = '修改'; this.open = true; this.form.updateInstructions = row.updateInstructions; this.form.id = row.id; }, // 修改提交 editSubmit(){ this.$refs["form"].validate(valid => { if (valid) { edit(this.form).then(res=>{ if(res.code == 200){ this.$message(res.msg); this.$refs['form'].resetFields(); this.open = false; this.listData(); }else{ this.$message.error(res.msg); } }) } }) }, format(percentage) { return percentage === 100 ? '上傳完成' : `${percentage}%`; }, cancel(){ this.open = false; this.$refs['form'].resetFields(); this.fileList = []; }, }, created() { this.listData(); }, mounted() {} }; </script> <style lang="scss" scoped> .upload_box{ min-height: 80px; padding-bottom: 10px; } .btn_box{ margin-bottom: 20px; } </style>
除了自動生成的主要修改新增的頁面,添加一個apk安裝包上傳的控件el-upload
調用的是前面的通用上傳接口,會將apk安裝包上傳到服務器上並將在服務器上的地址返回,然后在點擊新增頁面的確認按鈕后將
安裝包地址一並提交到后台的新增接口,后台將其存儲到數據庫。
vue頁面調用的js方法為
import request from '@/utils/request' //上傳文件 export function upload(query) { return request({ url: '/system/file/upload', method: 'post', data:query }) } //查詢列表 export function getList(query){ return request({ url:'/fzyscontrol/sys/version/getList', method:'get', params: query }) } //新增版本記錄 export function add(query){ return request({ url:'/fzyscontrol/sys/version/add', method:'post', data: query }) } // 修改版本記錄 export function edit(query){ return request({ url:'/fzyscontrol/sys/version/edit', method:'post', data: query }) }
然后新增完一個版本之后就會在數據庫中新增一個高版本的記錄
就能實現后台將新版本的apk傳遞到后台,然后app在啟動后會查詢最新版本的信息,如果高於當前版本則會將apk下載與安裝
然后點擊更新,就會下載安裝包並安裝