Android+SpringBoot+Vue實現安裝包前臺上傳,後臺管理,移動端檢測自動更新
場景
安卓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下載與安裝
然後點選更新,就會下載安裝包並安裝