最近的周末,一时无聊也用起了手机里的爱奇艺App追剧,每次都是把视频先缓存到本地习惯有的快进播放。突然好奇,想试试App的离线下载功能怎么实现。想到以前在github上看到的daimajia做的一个专注动画视频的App(https://github.com/daimajia/AnimeTaste)的实现,想借鉴借鉴思路。本文的实习是在daimajia的下载模块的基础上实现爱奇艺的视频下载效果,增加对下载的暂停、断点下载功能和对下载使用功能的优化等。下面来说说视频缓存的实现:
先看看爱奇艺的下载效果(没有找到合适的gif制作软件,下面截图贴出图片):
以下是我的实现下载模块效果图:
看完效果图,以下来谈谈关于下载模块的实现要点:
1. 对现在任务的监听,通过观察者模式实现对下载页和通知栏的同时监听;
2. 手机应用的内存大小有限,通过线程池来限制下载的线程个数(节约系统资源);
3. Http协议和文件的读出位置重定向,保证了对断点下载的支持;
下面我们来分析观察者模式的实现,首选我们通过类图说明:
我们的实现里有三个观察者,MissionListenerForAdapter观察者对应下载详情页(DownloadActivity),MissionListenerForNotification观察者对应通知栏,MissionSaver观察者对应数据库下载记录。我们看下载任务Mission的代码如下:
public class Mission implements Runnable { public static int ID = 0; private final int MissionID; private String Uri; private String SaveDir; private String SaveName; protected long mDownloaded; protected long mFileSize; private String mOriginFilename; private String mExtension; private String mShowName; private long mPreviousNotifyTime; private int mProgressNotifyInterval = 1; private TimeUnit mTimeUnit = TimeUnit.SECONDS; private long mLastSecondDownloadTime = 0; private long mLastSecondDownload = 0; private int mPreviousPercentage = -1; protected long mStartTime; protected long mEndTime; public enum RESULT_STATUS { STILL_DOWNLOADING, SUCCESS, FAILED } private RESULT_STATUS mResultStatus = RESULT_STATUS.STILL_DOWNLOADING; private boolean isContinuing = false; private boolean isDone = false; private boolean isPaused = false; private boolean isSuccess = false; private boolean isCanceled = false; private final Object o = new Object(); private List<MissionListener<Mission>> missionListeners; private HashMap<String, Object> extras; public Mission(String uri, String saveDir,long downloadedNum,long fileSize) { MissionID = ID++; Uri = uri; SaveDir = saveDir; this.mFileSize = fileSize; mStartTime = System.currentTimeMillis(); mPreviousNotifyTime = mStartTime; setOriginFileName(FileUtil.getFileName(uri)); setExtension(FileUtil.getFileExtension(uri)); SaveName = getOriginFileName() + "." + getExtension(); missionListeners = new ArrayList<MissionListener<Mission>>(); extras = new HashMap<String, Object>(); mDownloaded = downloadedNum; } public Mission(String uri, String saveDir, String saveFileName) { this(uri, saveDir,0,0); SaveName = getSafeFilename(saveFileName); } public Mission(String uri,String saveDir,String saveFileName,long downloadedNum,long fileSize){ this(uri,saveDir,downloadedNum,fileSize); SaveName = getSafeFilename(saveFileName); } public Mission(String uri, String saveDir, String saveFilename, String showName) { this(uri, saveDir, saveFilename); this.mShowName = showName; } //注册回调接口 public void addMissionListener(MissionListener<Mission> listener) { missionListeners.add(listener); } public void removeMissionListener(MissionListener<Mission> listener) { missionListeners.remove(listener); } public void setProgressNotificateInterval(TimeUnit unit, int interval) { mTimeUnit = unit; mProgressNotifyInterval = interval; } @Override public void run() { notifyStart(); InputStream in = null; RandomAccessFile fos = null; HttpURLConnection httpURLConnection = null; try { httpURLConnection = (HttpURLConnection) new URL(Uri) .openConnection(); httpURLConnection.setRequestMethod("GET"); httpURLConnection.setRequestProperty("Range", "bytes=" + mDownloaded+"-"); //当文件没有开始下载,记录文件大小 if(mFileSize <= 0) setFileSize(httpURLConnection.getContentLength()); in = httpURLConnection.getInputStream(); File accessFile = getSafeFile(getSaveDir(), getSaveName()); fos = new RandomAccessFile(accessFile, "rw"); fos.seek(mDownloaded); byte data[] = new byte[1024]; int count; notifyMetaDataReady(); while (!isCanceled() && (count = in.read(data, 0, 1024)) != -1) { fos.write(data, 0, count); mDownloaded += count; notifyPercentageChange(); notifySpeedChange(); checkPaused(); } if (isCanceled == false) { notifyPercentageChange(); notifySuccess(); } else { notifyCancel(); } } catch (Exception e) { notifyError(e); } finally { try { if (in != null) in.close(); if (fos != null) fos.close(); } catch (IOException e) { notifyError(e); } notifyFinish(); } } protected FileOutputStream getSafeOutputStream(String directory, String filename) { String filepath; if (directory.lastIndexOf(File.separator) != directory.length() - 1) { directory += File.separator; } File dir = new File(directory); dir.mkdirs(); filepath = directory + filename; File file = new File(filepath); try { file.createNewFile(); return new FileOutputStream(file.getCanonicalFile().toString()); } catch (Exception e) { e.printStackTrace(); throw new Error("Can not get an valid output stream"); } } protected File getSafeFile(String directory, String filename) { String filepath; if (directory.lastIndexOf(File.separator) != directory.length() - 1) { directory += File.separator; } File dir = new File(directory); dir.mkdirs(); filepath = directory + filename; File file = new File(filepath); try { file.createNewFile(); } catch (IOException e) { e.printStackTrace(); throw new Error("Can not Create A New File"); } return file; } protected String getSafeFilename(String name) { return name.replaceAll("[\\\\|\\/|\\:|\\*|\\?|\\\"|\\<|\\>|\\|]", "-"); } protected void checkPaused() { synchronized (o) { while (isPaused) { try { notifyPause(); o.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } } } public void pause() { if (isPaused() || isDone()) return; isPaused = true; } public void resume() { if (!isPaused() || isDone()) { return; } synchronized (o) { isPaused = false; o.notifyAll(); } notifyResume(); } public void cancel() { isCanceled = true; if (isPaused()) { resume(); } } //通知进度改变 protected final void notifyPercentageChange() { if (missionListeners != null && missionListeners.size() != 0) { int currentPercentage = getPercentage(); if (mPreviousPercentage != currentPercentage) { for (MissionListener<Mission> l : missionListeners) { l.onPercentageChange(this); } mPreviousPercentage = currentPercentage; } } } //通知下载速度更新 protected final void notifySpeedChange() { if (missionListeners != null && missionListeners.size() != 0) { long currentNotifyTime = System.currentTimeMillis(); long notifyDuration = currentNotifyTime - mPreviousNotifyTime; long spend = mTimeUnit.convert(notifyDuration, TimeUnit.MILLISECONDS); if (spend >= mProgressNotifyInterval) { mPreviousNotifyTime = currentNotifyTime; for (MissionListener<Mission> l : missionListeners) { l.onSpeedChange(this); } } long speedRecordDuration = currentNotifyTime - mLastSecondDownloadTime; if (TimeUnit.MILLISECONDS.toSeconds(speedRecordDuration) >= 1) { mLastSecondDownloadTime = currentNotifyTime; mLastSecondDownload = getDownloaded(); } } } protected final void notifyAdd() { if (missionListeners != null && missionListeners.size() != 0) { for (MissionListener<Mission> l : missionListeners) { l.onAddMission(this); } } } protected final void notifyStart() { isContinuing = true; if (missionListeners != null && missionListeners.size() != 0) { for (MissionListener<Mission> l : missionListeners) { l.onStart(this); } } } protected final void notifyPause() { if (missionListeners != null && missionListeners.size() != 0) { for (MissionListener<Mission> l : missionListeners) { l.onPause(this); } } } protected final void notifyResume() { if (missionListeners != null && missionListeners.size() != 0) { for (MissionListener<Mission> l : missionListeners) { l.onResume(this); } } } protected final void notifyCancel() { isContinuing = false; //取消被通知到的时候,isContinuing为fasle if (missionListeners != null && missionListeners.size() != 0) { for (MissionListener<Mission> l : missionListeners) { l.onCancel(this); } } } protected final void notifyMetaDataReady() { if (missionListeners != null && missionListeners.size() != 0) { for (MissionListener<Mission> l : missionListeners) { l.onMetaDataPrepared(this); } } } protected final void notifySuccess() { mResultStatus = RESULT_STATUS.SUCCESS; isDone = true; isSuccess = true; if (missionListeners != null && missionListeners.size() != 0) { for (MissionListener<Mission> l : missionListeners) { l.onSuccess(this); } } } protected final void notifyError(Exception e) { mResultStatus = RESULT_STATUS.FAILED; isDone = true; isSuccess = false; if (missionListeners != null && missionListeners.size() != 0) { for (MissionListener<Mission> l : missionListeners) { l.onError(this, e); } } } protected final void notifyFinish() { isContinuing = false; mEndTime = System.currentTimeMillis(); if (missionListeners != null && missionListeners.size() != 0) { for (MissionListener<Mission> l : missionListeners) { l.onFinish(this); } } } public String getUri() { return Uri; } public String getSaveDir() { return SaveDir; } public String getSaveName() { return SaveName; } public long getDownloaded() { return mDownloaded; } public long getFilesize() { return mFileSize; } protected void setFileSize(int size) { mFileSize = size; } public long getTimeSpend() { if (mEndTime != 0) { return mEndTime - mStartTime; } else { return System.currentTimeMillis() - mStartTime; } } public String getAverageReadableSpeed() { return FileUtil.getReadableSpeed(getDownloaded(), getTimeSpend(), TimeUnit.MILLISECONDS); } public String getAccurateReadableSpeed() { return FileUtil.getReadableSize(getDownloaded() - mLastSecondDownload, false) + "/s"; } public int getPercentage() { if (mFileSize == 0) { return 0; } else { return (int) (mDownloaded * 100.0f / mFileSize); } } public String getAccurateReadableSize() { return FileUtil.getReadableSize(getDownloaded()); } public String getReadableSize() { return FileUtil.getReadableSize(getFilesize()); } public String getReadablePercentage() { StringBuilder builder = new StringBuilder(); builder.append(getPercentage()); builder.append("%"); return builder.toString(); } public void setOriginFileName(String name) { mOriginFilename = name; } public String getOriginFileName() { return mOriginFilename; } public void setExtension(String extension) { mExtension = extension; } public String getExtension() { return mExtension; } public String getShowName() { if (mShowName == null || mShowName.length() == 0) { return getSaveName(); } else { return mShowName; } } public int getMissionID() { return MissionID; } public boolean isDownloading() { return isContinuing; } public boolean isDone() { return isDone; } public boolean isSuccess() { return isSuccess; } public boolean isPaused() { return isPaused; } public void setCanceled(boolean isCanceled){ this.isCanceled = isCanceled; } public boolean isCanceled() { return isCanceled; } public RESULT_STATUS getResultStatus() { return mResultStatus; } public void addExtraInformation(String key, Object value) { extras.put(key, value); } public void removeExtraInformation(String key) { extras.remove(key); } public Object getExtraInformation(String key) { return extras.get(key); } public interface MissionListener<T extends Mission> { public void onAddMission(T mission); public void onStart(T mission); public void onMetaDataPrepared(T mission); public void onPercentageChange(T mission); public void onSpeedChange(T mission); public void onError(T mission, Exception e); public void onSuccess(T mission); public void onFinish(T mission); public void onPause(T mission); public void onResume(T mission); public void onCancel(T mission); } }
通过代码我们知道,Mission实现的Runnable接口,下载任务运行时候,开始通知注册下载任务的观察者们。
我们选择MissionSaver观察者的实习来分析:
public class MissionSaver implements Mission.MissionListener<Mission>{ private DownloadDetail getDownloadDetail(Mission mission){ Object obj = mission.getExtraInformation(mission.getUri()); DownloadDetail detail = null; if(obj!=null) detail = (DownloadDetail)obj; return detail; } private void save(Mission mission){ DownloadDetail detail = getDownloadDetail(mission); if(detail!=null){ DownloadRecord.save(detail,mission); } } private void delete(Mission mission){ DownloadDetail detail = getDownloadDetail(mission); if(detail!=null){ DownloadRecord.deleteOne(detail); } } @Override public void onStart(Mission mission) { save(mission); } @Override public void onMetaDataPrepared(Mission mission) { } @Override public void onPercentageChange(Mission mission) { } @Override public void onSpeedChange(Mission mission) { } @Override public void onError(Mission mission, Exception e) { save(mission); } @Override public void onSuccess(Mission mission) { save(mission); } @Override public void onFinish(Mission mission) { } @Override public void onPause(Mission mission) { } @Override public void onResume(Mission mission) { } /* * 此处的onCancel,目的是暂停下载任务退出当前线程,并不是取消下载,此时下载进度会被保存起来 */ @Override public void onCancel(Mission mission) { save(mission); } @Override public void onAddMission(Mission mission) { } }通过此处的代码我们知道,当我们会在下载任务开始(onStart),暂停(onCancel),成功(onSuccess)和错误(onError)的时候都会保存下载进度。因此,我们知道我们的下载模块会存在一个问题,当我们强制关闭App进程的时候,我们的下载线程很可能来不及执行以上的某一个方法来保存下载进度,所以,该次下载信息丢失。但是,爱奇艺的的下载服务是一个独立的进程,所以存在强制关闭App下载信息丢失来不及保存。下面我们来看线程池封装,实现控制下载的线程次数,代码实现如下:
public class DownloadManager { private final String TAG = "DownLoadManager"; private static int MAX_MISSION_COUNT = 5; //设置线程池最大线程个数 private static DownloadManager Instance; protected ThreadPoolExecutor mExecutorService; protected HashMap<Integer,Mission> mMissionBook; public static synchronized DownloadManager getInstance(){ if(Instance == null || Instance.mExecutorService.isShutdown()){ Instance = new DownloadManager(); } return Instance; } private DownloadManager(){ mMissionBook = new HashMap<Integer, Mission>(); mExecutorService = new ThreadPoolExecutor(MAX_MISSION_COUNT,MAX_MISSION_COUNT,15,TimeUnit.SECONDS, new LinkedBlockingDeque<Runnable>()); //实例化线程池 } public boolean containMission(int missionID){ return mMissionBook.containsKey(missionID); } public void addMission(Mission mission){ mMissionBook.put(mission.getMissionID(), mission); mission.notifyAdd(); } public void executeMission(Mission mission){ if(mission.isCanceled()) mission.setCanceled(false); mExecutorService.execute(mission); } public void pauseMission(int missionID){ if(mMissionBook.containsKey(missionID)){ mMissionBook.get(missionID).pause(); } } public void resumeMission(int missionID){ if(mMissionBook.containsKey(missionID)){ mMissionBook.get(missionID).resume(); } } public void cancelMission(int missionID){ if(mMissionBook.containsKey(missionID)){ mMissionBook.get(missionID).cancel(); } } /* * 停止所有任务 */ public void surrenderMissions(){ for(Map.Entry<Integer, Mission> mission : mMissionBook.entrySet()){ mission.getValue().cancel(); mMissionBook.get(mission).cancel(); } } public static void setMaxMissionCount(int count){ if(Instance == null && count > 0) MAX_MISSION_COUNT = count; else throw new IllegalStateException("Can not change max mission count after getInstance been called"); } }最后我们来看看下载帮助类DownloadHelper:
public class DownloadHelper { private Context mContext; private DownloadServiceBinder mDownloadServiceBinder; private Boolean isConnected = false; private Object o = new Object(); public DownloadHelper(Context context) { mContext = context; } private ServiceConnection connection = new ServiceConnection() { @Override public void onServiceConnected(ComponentName name, IBinder service) { mDownloadServiceBinder = (DownloadService.DownloadServiceBinder) service; synchronized (o) { isConnected = true; o.notify();//当DownloadService连接上,通知下载线程继续 } } @Override public void onServiceDisconnected(ComponentName name) { isConnected = false; } }; public void startDownload(final DownloadDetail detail) { final DownloadRecord record = new Select().from(DownloadRecord.class) .where("DownloadId = ? ", detail.VideoId).executeSingle(); if (record != null) { if (record.Status == DownloadRecord.STATUS.SUCCESS) { new AlertDialog.Builder(mContext) .setTitle(R.string.tip) .setMessage(R.string.redownload_tips) .setPositiveButton(R.string.yes, new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { File file = new File(record.SaveDir + record.SaveFileName); if (file.exists() && file.isFile()) { file.delete(); } safeDownload(detail); } }) .setNegativeButton(R.string.no, new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { dialog.dismiss(); } }).create().show(); } else if (record.Status == DownloadRecord.STATUS.PAUSE) { // 继续上次下载 safeDownload(record); } else { safeDownload(detail); } } else { // 新建下载 safeDownload(detail); } } private void safeDownload(final DownloadRecord record) { if (NetworkUtils.isNetworkAvailable(mContext)) { if (NetworkUtils.isWifiConnected(mContext)) { download(record); } else { new AlertDialog.Builder(mContext) .setTitle(R.string.tip) .setMessage(R.string.no_wifi_download) .setPositiveButton(R.string.yes, new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { download(record); } }).setNegativeButton(R.string.no, null) .create().show(); } } else { Toast.makeText(mContext, R.string.no_network, Toast.LENGTH_LONG) .show(); } } private void safeDownload(final DownloadDetail detail) { if (NetworkUtils.isNetworkAvailable(mContext)) { if (NetworkUtils.isWifiConnected(mContext)) { download(detail); } else { new AlertDialog.Builder(mContext) .setTitle(R.string.tip) .setMessage(R.string.no_wifi_download) .setPositiveButton(R.string.yes, new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { download(detail); } }).setNegativeButton(R.string.no, null) .create().show(); } } else { Toast.makeText(mContext, R.string.no_network, Toast.LENGTH_LONG) .show(); } } /* * 下载任务线程重新开始执行 */ public void download(final Mission mission) { new Thread() { @Override public void run() { super.run(); if (!isDownloadServiceRunning()) { mContext.startService(new Intent(mContext, DownloadService.class)); } if (mDownloadServiceBinder == null || isConnected == false) { mContext.bindService(new Intent(mContext, DownloadService.class), connection, Context.BIND_AUTO_CREATE); synchronized (o) { while (!isConnected) { try { o.wait();//当DownloadService还未连接,下载线程被通知等待 } catch (InterruptedException e) { e.printStackTrace(); } } } } mDownloadServiceBinder.continueDownload(mission); } }.start(); } public void download(final DownloadRecord record) { new Thread() { @Override public void run() { super.run(); if (!isDownloadServiceRunning()) { mContext.startService(new Intent(mContext, DownloadService.class)); } if (mDownloadServiceBinder == null || isConnected == false) { mContext.bindService(new Intent(mContext, DownloadService.class), connection, Context.BIND_AUTO_CREATE); synchronized (o) { while (!isConnected) { try { o.wait();//当DownloadService还未连接,下载线程被通知等待 } catch (InterruptedException e) { e.printStackTrace(); } } } } File file = Environment .getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES); Mission mission = new Mission(record.DownloadUrl, file.getAbsolutePath() + "/Download/", record.FileName + "." + record.Extension, record.DownloadedSize,record.FileSize); mission.addExtraInformation(mission.getUri(), DownloadRecord.getDownloadDetail(record)); mDownloadServiceBinder.startDownload(mission); } }.start(); } /* * 将未完成的下载记录添加到下载任务列表,并让下载任务开始执行 */ public void AddDownload(final DownloadRecord record) { new Thread() { @Override public void run() { super.run(); if (!isDownloadServiceRunning()) { mContext.startService(new Intent(mContext, DownloadService.class)); } if (mDownloadServiceBinder == null || isConnected == false) { mContext.bindService(new Intent(mContext, DownloadService.class), connection, Context.BIND_AUTO_CREATE); synchronized (o) { while (!isConnected) { try { o.wait();//当DownloadService还未连接,下载线程被通知等待 } catch (InterruptedException e) { e.printStackTrace(); } } } } File file = Environment .getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES); Mission mission = new Mission(record.DownloadUrl, file.getAbsolutePath() + "/Download/", record.FileName + "." + record.Extension, record.DownloadedSize,record.FileSize); mission.addExtraInformation(mission.getUri(), DownloadRecord.getDownloadDetail(record)); mDownloadServiceBinder.startDownload(mission); } }.start(); } /* * 将未完成的下载记录添加到下载任务列表 */ public void addDownloadMission(final List<DownloadRecord> records) { new Thread() { @Override public void run() { super.run(); if (!isDownloadServiceRunning()) { mContext.startService(new Intent(mContext, DownloadService.class)); } if (mDownloadServiceBinder == null || isConnected == false) { mContext.bindService(new Intent(mContext, DownloadService.class), connection, Context.BIND_AUTO_CREATE); synchronized (o) { while (!isConnected) { try { o.wait();//当DownloadService还未连接,下载线程被通知等待 } catch (InterruptedException e) { e.printStackTrace(); } } } } File file = Environment .getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES); file.mkdirs(); for (DownloadRecord record : records) { Mission mission = new Mission(record.DownloadUrl, file.getAbsolutePath() + "/Download/", record.FileName + "." + record.Extension, record.DownloadedSize,record.FileSize); mission.addExtraInformation(mission.getUri(), DownloadRecord.getDownloadDetail(record)); mDownloadServiceBinder.AddDownload(mission); } } }.start(); } /* * 将未完成的下载记录添加到下载任务列表 */ public void addDownloadMission(final DownloadRecord record) { new Thread() { @Override public void run() { super.run(); if (!isDownloadServiceRunning()) { mContext.startService(new Intent(mContext, DownloadService.class)); } if (mDownloadServiceBinder == null || isConnected == false) { mContext.bindService(new Intent(mContext, DownloadService.class), connection, Context.BIND_AUTO_CREATE); synchronized (o) { while (!isConnected) { try { o.wait();//当DownloadService还未连接,下载线程被通知等待 } catch (InterruptedException e) { e.printStackTrace(); } } } } File file = Environment .getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES); file.mkdirs(); Mission mission = new Mission(record.DownloadUrl, file.getAbsolutePath() + "/Download/", record.FileName + "." + record.Extension, record.DownloadedSize,record.FileSize); mission.addExtraInformation(mission.getUri(), DownloadRecord.getDownloadDetail(record)); mDownloadServiceBinder.AddDownload(mission); } }.start(); } private void download(final DownloadDetail detail) { new Thread() { @Override public void run() { super.run(); if (!isDownloadServiceRunning()) { mContext.startService(new Intent(mContext, DownloadService.class)); } if (mDownloadServiceBinder == null || isConnected == false) { mContext.bindService(new Intent(mContext, DownloadService.class), connection, Context.BIND_AUTO_CREATE); synchronized (o) { while (!isConnected) { try { o.wait();//当DownloadService还未连接,下载线程被通知等待 } catch (InterruptedException e) { e.printStackTrace(); } } } } File file = Environment .getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES); file.mkdirs(); Mission mission = new Mission(detail.DownloadUrl, file.getAbsolutePath() + "/Download/", detail.FileName + "." + detail.Extension); mission.addExtraInformation(mission.getUri(), detail); mDownloadServiceBinder.startDownload(mission); } }.start(); } /* * 判断下载服务是否在运行 */ private boolean isDownloadServiceRunning() { ActivityManager manager = (ActivityManager) mContext .getSystemService(Context.ACTIVITY_SERVICE); for (ActivityManager.RunningServiceInfo service : manager .getRunningServices(Integer.MAX_VALUE)) { if (DownloadService.class.getName().equals( service.service.getClassName())) { return true; } } return false; } public void unbindDownloadService() { if (isDownloadServiceRunning() && isConnected && connection != null) { mContext.unbindService(connection); } } }以上简单对常用App的下载模块的部分代码的分析,对比爱奇艺App下载模块实现略显简陋。本代码完全是个人业余时间凭兴趣捣鼓,现提供 下载地址,供有兴趣的园友自己琢磨。还有不成熟的地方和考虑不周存在的bug,还望路过的朋友不吝啬指教,有好的点子欢迎一起学习。