场景

安卓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 --><providerandroid:name="androidx.core.content.FileProvider"android:authorities="com.badao.appupdatedemo.fileProvider"android:exported="false"android:grantUriPermissions="true"><meta-dataandroid: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-pathname="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() {@Overridepublic void onFailure(Call call, IOException e) {result.error(e);}@Overridepublic 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;}@Overrideprotected 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();}@Overrideprotected 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;}@Overrideprotected void onProgressUpdate(Integer... values) {int progress = values[0];if (progress > lastProgress){listener.onProgress(progress);lastProgress = progress;}}@Overrideprotected 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

        <serviceandroid: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() {@Overridepublic 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;}}};@Overridepublic IBinder onBind(Intent intent) {return null;}@Overridepublic void onCreate() {super.onCreate();Log.i("EricLog", "服务启动");filePath = Environment.getExternalStorageDirectory().getAbsolutePath() + File.separator + "MyApp/";Log.i("onCreate-filePath", "filePath="+filePath);}@Overridepublic 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);}@Overridepublic 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.apkString 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() {@Overridepublic 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() {@Overridepublic 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() {@Overridepublic void onCancel(DialogInterface dialog) {dismiss();}});}else if (type == DialogType.ERROR){mDialog = UpdateDialog.errorDialog(getApplicationContext(),error_msg,new DialogInterface.OnClickListener() {@Overridepublic 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() {@Overridepublic void success(UpdateBean info) {if (info != null && info.getData() != null) {checkUpdate(info);}else {handler.sendEmptyMessage(UpdateConfig.CLIENT_INFO_ERROR);}}@Overridepublic void fail(int code) {Log.e("EricLog", "Code = "  + code);handler.sendEmptyMessage(UpdateConfig.CLIENT_INFO_ERROR);}@Overridepublic 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() {@Overridepublic void onProgress(int progress) {mProgressDialog.setProgress(progress);}@Overridepublic void onSuccess() {disProgress(true);UpdateFile.installApp(targetFile, getApplicationContext(), handler);}@Overridepublic 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方法

就能通过请求码获取到跳转到开启悬浮窗页面的返回结果

如果已经开启了则直接检测更新,否则的话会弹窗提示

    @Overrideprotected 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 {@Overrideprotected 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();}}}@Overrideprotected void onCreate(Bundle savedInstanceState) {super.onCreate(savedInstanceState);setContentView(R.layout.activity_main);//应用启动后会先走此方法//如果已经开启了悬浮窗的权限if (UpdateCheck.checkFloatPermission(this)){//直接启动检测更新的servicestartService(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);// 清空responseresponse.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 {@Autowiredprivate ISysAppVersionService sysAppVersionService;@Autowiredprivate 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 desclimit 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-buttontype="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-columnprop="appName"label="应用名称"width="180"></el-table-column><el-table-columnprop="versionNum"label="版本号"width="180"></el-table-column><el-table-columnprop="updateTime"label="更新时间"><template slot-scope='scope'>{{ scope.row.updateTime | dataFormat }}</template></el-table-column>               <el-table-columnprop="updateInstructions"label="更新说明"></el-table-column><el-table-column label="操作" align="center" class-name="small-padding fixed-width"><template slot-scope="scope"><el-buttonsize="mini"type="text"icon="el-icon-edit"@click="handleUpdate(scope.row)">修改</el-button></template></el-table-column></el-table><paginationv-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,//返回的文件urlfileUrl: '',  // 文件列表// 上传按钮闸口btnLoding:false,// 列表加载动画listLoding:true,// 表单校验rules: {appName: [{ required: true, message: "应用名称不能为空", trigger: "blur" }],updateInstructions: [{ required: true, message: "更新说明不能为空", trigger: "blur" }],},// 修改字段显隐editStatus:true,// 修改ideditId: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 = trueif(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下载与安装

然后点击更新,就会下载安装包并安装

Android+SpringBoot+Vue实现安装包前台上传,后台管理,移动端检测自动更新相关推荐

  1. Springboot+vue 社团管理系统(前后端分离)

    Springboot+vue 社团管理系统(前后端分离) zero.项目功能设计图 一.数据库设计(项目准备) 1.建表 2.表目录 二.前端编写(vue) 1.搭建Vue框架 2.放入静态资源(as ...

  2. Android开发--构建项目安装包APK(debug版)

    1.build→Build APK(s),点击即可构建 2.点击日志可以查看构建情况 3.点击locate即可进入debug文件夹 4.也可以在构建完成后直接按照路径找到debug文件夹 其中apk文 ...

  3. Springboot + Vue实现大文件切片上传

    Springboot + Vue实现大文件切片上传 大文件切片上传原理就是将一个大文件分成若干份大小相等的块文件,等所有块上传成功后,再将文件进行合并. 一.Springboot后端 1.实体TChu ...

  4. Android家长老师家校校园通(IDEA开发,后台管理,前台app)

    [源码下载] 手把手教做Android家长老师家校校园通(IDEA开发,后台管理,前台app)视频教程 开发工具: AndroidStudio  Idea  Mysql 技术栈: Web端 后台前端: ...

  5. Android oppo手机显示安装包异常(Bug6)

    最近在安装自己写的demo时,华为手机没啥问题,用OPPO安装时居然显示安装包异常,还以为是自己代码有问题. 解决办法; 在自己的Android项目里 找到gradle.properties,在该文件 ...

  6. Android App 导出APK安装包以及制作App图标讲解及实战(图文解释 简单易懂)

    操作有问题请点赞关注收藏后评论区留言~~~ 一.导出APK安装包 之前在运行App的时候,都是先由数据线连接手机和电脑,再通过Android Studio的Run菜单把App安装到手机上,这种方式只能 ...

  7. Android安装失败,安装包解析出错

    Android项目打包,部分OPPO VIVO新机型出现安装失败,安装包解析出错问题 项目配置:minSdkVersion 21,targetSdkVersion 30. 正常步骤打包,在OPPO R ...

  8. 毕设:基于SpringBoot+Vue 实现云音乐(前后端分离)

    文章目录 一.简介 2.项目介绍 二.功能 2.功能介绍 三.核心技术 1.系统架构图 2.技术选型 五.运行 3.截图 前端界面 后台管理界面 总结 1.完整工程 2.其他 一.简介 2.项目介绍 ...

  9. Java基于springboot+vue的电子相册管理系统 前后端分离node

    智能电子相册是一个可以永久保留记忆的东西,用户可以讲自己美好的一面展示在网络上,人更多的人了解到自己的生活,为此我们通过Java语言并结合springboot+vue开发了本次的电子相册管理系统,希望 ...

  10. 基于 SpringBoot + Vue 的在线课堂前后端分离项目

    开发时间:2022.10.17 - 2022.11.04 开源项目: 服务端:atguigu-course-backend 后台管理系统:atguigu-course-frontend 移动端微信公众 ...

最新文章

  1. 安装php出现 “make: *** [ext/gd/libgd/gd_jpeg.lo] Error ”
  2. 创建Invoice和公司间Invoice
  3. Boost:GPU上的2D图像中绘制最终的随机“walk”,并使用OpenCV进行显示
  4. C#中Lambda表达式类型Expression不接受lambda函数
  5. java程序员遇到的问题_JAVA程序员最常遇见的10个异常
  6. 【LeetCode笔记】剑指 Offer 93. 复原 IP 地址(Java、DFS、字符串)
  7. jvm垃圾回收机制_JVM 垃圾回收机制之堆的分代回收
  8. (王道408考研操作系统)第五章输入/输出(I/O)管理-第一节4:I/O软件层次结构
  9. js数组往队头添加数据、js数组从队头移出数据
  10. Xposed从入门到弃坑:0x03、XposedHelpers类解析
  11. Tree树 递归查询,显示成JSON格式
  12. 在CMakeLists.txt文件中包含Eigen
  13. Jmeter小程序压力测试案例
  14. UDP的单播广播和组播
  15. Fastdb安装与使用
  16. 为什么Windows鼠标指针是弯的?
  17. JavaSE基础案例之模拟斗地主
  18. centos挂载盘到根下_centos挂载磁盘及扩展根目录
  19. 【转】关于第三方支付,看这篇文章就够了!
  20. 最新(2014-09-22)中国IT互联网公司市值排名(单位:亿美元)

热门文章

  1. 新个人所得税计算公式
  2. 史上首次!个人所得税退税来了!如何退?怎么操作?
  3. 道场与世间修行的区别
  4. 论文阅读:Permutation Matters: Anisotropic Convolutional Layer for Learning on Point Clouds
  5. ShareX加七牛云免费搭建快速博客图床
  6. 【Axure高保真原型】上传表格数据
  7. UT2011学习笔记
  8. Kryo官方文档学习笔记
  9. 房租,社会教给年轻人的第一课
  10. 股票学习-量柱和k线-第五天