zoukankan      html  css  js  c++  java
  • Andoid 更好的Android多线程下载框架

    概述

    为什么是更好的Android多线程下载框架呢,原因你懂的,广告法嘛!

    本篇我们我们就来聊聊多线程下载框架,先聊聊我们框架的特点:

    1. 多线程
    2. 多任务
    3. 断点续传
    4. 支持大文件
    5. 可以自定义下载数据库
    6. 高度可配置,像超时时间这类
    7. 业务数据和下载数据分离

    下面我们在说下该框架能实现那些的应用场景:

    1. 该框架可以很方便的下载单个文件,并且显示各种状态,包括开始下载,下载中,下载失败,删除等状态。
    2. 也可以实现常见的需要下载功能应用,比如:某某手机助手,在该应用内可以说是下载是核心功能,所以对框架的稳定性,代码可靠性,框架扩展性依赖很大,所以该框架真是从这种出发点而生的。通常这类应用的表示形式分三个页面需要用到下载功能,一个列表用来显示来自业务数据的列表,在该列表右边可以点击单个条目,或者多选实现下载,点击每个条目进入详情,同时还有个一个下载管理,包括大概两个界面,正在下载,下载完成的,在这几个界面都需要一个核心的功能就是都可以暂停,恢复,删除并且能显示下载进度。在列表一个最重要的问题就是界面刷新,如果每次更新都刷新整个列表,那么这将是异常灾难,而我们这个框架正好解决了该问题,采用了回调单个条目并更新该条目的进度和状态。

    该项目状态

    该项目的雏形始于14年的公司项目需要用到多线程下载,但当时实现的单线程多任务断点续传,后面不断完善,在这之间遇到过很多坑,也对一个下载框架有了更深的认识,所以在16年又重写了该框架。

    项目的Github地址:https://github.com/lifengsofts/AndroidDownloader

    项目的官网地址:http://i.woblog.cn/AndroidDownloader

    项目还处于发展状态,但已经趋于稳定,并且有一定的编码规范,同时采用了多个开源项目的质量控制方案以保证每次代码提交的可靠性。

    下面上几张框架Demo的截图,这样用户在心中有一个自己的概念,但是推荐各位还是讲Demo下载到本地亲自,运行一下。

    第一个界面是单独下载一个文件。

    第二个界面是应用中最常用的一个界面,该界面来自业务数据。

    第三个页面是离线管理中的下载中的界面。

    第四个页面是离线管理中的下载完成的界面。

    可以看到他们在每个界面都能暂停下载,继续下载,以及删除,并且都能拿到进度,状态等信息。

    下面就来看看这么强大的下载框架那该如何来使用呢?

    添加权限

    我相信这一步任何一个项目都已经添加了,但是还是不得不提一下。

    该框架需要网络访问权限,如果你是讲文件下载到存储卡,那相应的需要添加存储卡访问权限。

    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

    配置Service

    因为该框架采用才service中下载一个文件的。这样做的目的是下载任务一般都需要在后头下载,如果在Activity中来做这类任务,我想任何一个新手都知道这样不行。

    <service android:name="cn.woblog.android.downloader.DownloadService">
      <intent-filter>
        <action android:name="cn.woblog.android.downloader.DOWNLOAD_SERVICE" />
      </intent-filter>
    </service>

    添加依赖

    我们提供了多种集成方式,比如:gradle,maven,jar。选择适合你自己的就行了。

    Gradle

    在module目录下面的build.gradle文件中添加如下内容:

    compile 'cn.woblog.android:downloader:1.0.1'

    Maven

    或者你使用的Maven依赖管理工具。那道理其实是一样的,在pom文件中添加:

    <dependency>
      <groupId>cn.woblog.android</groupId>
      <artifactId>downloader</artifactId>
      <version>1.0.0</version>
    </dependency>

    或者你也可以参考该链接使用Snapshots版本

    混淆配置

    如果你的项目使用了混淆规则,那么一定要加上。

    -keep public class * implements cn.woblog.android.downloader.db.DownloadDBController
    -keep class cn.woblog.android.downloader.domain.** { *; }

    创建下载管理器

    现在万事俱备只欠东风了,接下来只需要创建一个下载管理器,该框架所有的操作都是通过该来实现的:

    downloadManager = DownloadService.getDownloadManager(context.getApplicationContext());

    或者你可以使用更详细的来配置该框架:

    Config config = new Config();
    //set database path.
    //    config.setDatabaseName("/sdcard/a/d.db");
    //      config.setDownloadDBController(dbController);
    
    //set download quantity at the same time.
    config.setDownloadThread(3);
    
    //set each download info thread number
    config.setEachDownloadThread(2);
    
    // set connect timeout,unit millisecond
    config.setConnectTimeout(10000);
    
    // set read data timeout,unit millisecond
    config.setReadTimeout(10000);
    downloadManager = DownloadService.getDownloadManager(this.getApplicationContext(), config);

    下载一个文件

    //create download info set download uri and save path.
    final DownloadInfo downloadInfo = new DownloadInfo.Builder().setUrl("http://example.com/a.apk")
        .setPath("/sdcard/a.apk")
        .build();
    
    //set download callback.
    downloadInfo.setDownloadListener(new DownloadListener() {
    
      @Override
      public void onStart() {
        tv_download_info.setText("Prepare downloading");
      }
    
      @Override
      public void onWaited() {
        tv_download_info.setText("Waiting");
        bt_download_button.setText("Pause");
      }
    
      @Override
      public void onPaused() {
        bt_download_button.setText("Continue");
        tv_download_info.setText("Paused");
      }
    
      @Override
      public void onDownloading(long progress, long size) {
        tv_download_info
            .setText(FileUtil.formatFileSize(progress) + "/" + FileUtil
                .formatFileSize(size));
        bt_download_button.setText("Pause");
      }
    
      @Override
      public void onRemoved() {
        bt_download_button.setText("Download");
        tv_download_info.setText("");
        downloadInfo = null;
      }
    
      @Override
      public void onDownloadSuccess() {
        bt_download_button.setText("Delete");
        tv_download_info.setText("Download success");
      }
    
      @Override
      public void onDownloadFailed(DownloadException e) {
        e.printStackTrace();
        tv_download_info.setText("Download fail:" + e.getMessage());
      }
    });
    
    //submit download info to download manager.
    downloadManager.download(downloadInfo);
    
    下载一个文件时直接创建一个DownloadInfo,然后设置下载链接和下载路径。再添加一个监听。就可以提交到下载框架了。
    
    通过下载监听器我们可以获取到很多状态。开始下载,等待中,暂停完成,下载中,删除成功,下载成功,下载失败等状态。
    
    在列表控件使用
    
    我们这里演示如何在RecyclerView这类列表控件使用。当然如果你用的是ListView那道理是一样的。
    
    class ViewHolder extends RecyclerView.ViewHolder {
    
      private final ImageView iv_icon;
      private final TextView tv_size;
      private final TextView tv_status;
      private final ProgressBar pb;
      private final TextView tv_name;
      private final Button bt_action;
      private DownloadInfo downloadInfo;
    
      public ViewHolder(View view) {
        super(view);
    
        iv_icon = (ImageView) view.findViewById(R.id.iv_icon);
        tv_size = (TextView) view.findViewById(R.id.tv_size);
        tv_status = (TextView) view.findViewById(R.id.tv_status);
        pb = (ProgressBar) view.findViewById(R.id.pb);
        tv_name = (TextView) view.findViewById(R.id.tv_name);
        bt_action = (Button) view.findViewById(R.id.bt_action);
      }
    
      @SuppressWarnings("unchecked")
      public void bindData(final MyDownloadInfo data, int position, final Context context) {
        Glide.with(context).load(data.getIcon()).into(iv_icon);
        tv_name.setText(data.getName());
    
        // Get download task status
        downloadInfo = downloadManager.getDownloadById(data.getUrl().hashCode());
    
        // Set a download listener
        if (downloadInfo != null) {
          downloadInfo
              .setDownloadListener(new MyDownloadListener(new SoftReference(ViewHolder.this)) {
                //  Call interval about one second
                @Override
                public void onRefresh() {
                  if (getUserTag() != null && getUserTag().get() != null) {
                    ViewHolder viewHolder = (ViewHolder) getUserTag().get();
                    viewHolder.refresh();
                  }
                }
              });
    
        }
    
        refresh();
    
    //      Download button
        bt_action.setOnClickListener(new OnClickListener() {
          @Override
          public void onClick(View v) {
            if (downloadInfo != null) {
    
              switch (downloadInfo.getStatus()) {
                case DownloadInfo.STATUS_NONE:
                case DownloadInfo.STATUS_PAUSED:
                case DownloadInfo.STATUS_ERROR:
    
                  //resume downloadInfo
                  downloadManager.resume(downloadInfo);
                  break;
    
                case DownloadInfo.STATUS_DOWNLOADING:
                case DownloadInfo.STATUS_PREPARE_DOWNLOAD:
                case STATUS_WAIT:
                  //pause downloadInfo
                  downloadManager.pause(downloadInfo);
                  break;
                case DownloadInfo.STATUS_COMPLETED:
                  downloadManager.remove(downloadInfo);
                  break;
              }
            } else {
    //            Create new download task
              File d = new File(Environment.getExternalStorageDirectory().getAbsolutePath(), "d");
              if (!d.exists()) {
                d.mkdirs();
              }
              String path = d.getAbsolutePath().concat("/").concat(data.getName());
              downloadInfo = new Builder().setUrl(data.getUrl())
                  .setPath(path)
                  .build();
              downloadInfo
                  .setDownloadListener(new MyDownloadListener(new SoftReference(ViewHolder.this)) {
    
                    @Override
                    public void onRefresh() {
                      if (getUserTag() != null && getUserTag().get() != null) {
                        ViewHolder viewHolder = (ViewHolder) getUserTag().get();
                        viewHolder.refresh();
                      }
                    }
                  });
              downloadManager.download(downloadInfo);
            }
          }
        });
    
      }
    
      private void refresh() {
        if (downloadInfo == null) {
          tv_size.setText("");
          pb.setProgress(0);
          bt_action.setText("Download");
          tv_status.setText("not downloadInfo");
        } else {
          switch (downloadInfo.getStatus()) {
            case DownloadInfo.STATUS_NONE:
              bt_action.setText("Download");
              tv_status.setText("not downloadInfo");
              break;
            case DownloadInfo.STATUS_PAUSED:
            case DownloadInfo.STATUS_ERROR:
              bt_action.setText("Continue");
              tv_status.setText("paused");
              try {
                pb.setProgress((int) (downloadInfo.getProgress() * 100.0 / downloadInfo.getSize()));
              } catch (Exception e) {
                e.printStackTrace();
              }
              tv_size.setText(FileUtil.formatFileSize(downloadInfo.getProgress()) + "/" + FileUtil
                  .formatFileSize(downloadInfo.getSize()));
              break;
    
            case DownloadInfo.STATUS_DOWNLOADING:
            case DownloadInfo.STATUS_PREPARE_DOWNLOAD:
              bt_action.setText("Pause");
              try {
                pb.setProgress((int) (downloadInfo.getProgress() * 100.0 / downloadInfo.getSize()));
              } catch (Exception e) {
                e.printStackTrace();
              }
              tv_size.setText(FileUtil.formatFileSize(downloadInfo.getProgress()) + "/" + FileUtil
                  .formatFileSize(downloadInfo.getSize()));
              tv_status.setText("downloading");
              break;
            case STATUS_COMPLETED:
              bt_action.setText("Delete");
              try {
                pb.setProgress((int) (downloadInfo.getProgress() * 100.0 / downloadInfo.getSize()));
              } catch (Exception e) {
                e.printStackTrace();
              }
              tv_size.setText(FileUtil.formatFileSize(downloadInfo.getProgress()) + "/" + FileUtil
                  .formatFileSize(downloadInfo.getSize()));
              tv_status.setText("success");
              break;
            case STATUS_REMOVED:
              tv_size.setText("");
              pb.setProgress(0);
              bt_action.setText("Download");
              tv_status.setText("not downloadInfo");
            case STATUS_WAIT:
              tv_size.setText("");
              pb.setProgress(0);
              bt_action.setText("Pause");
              tv_status.setText("Waiting");
              break;
          }
    
        }
      }
    }

    关键代码就是bindData方法中先通过业务的id,我们这里使用的url来获取该业务数据是否有对应的下载任务。如果有,则从新绑定监听器,也就是这段代码

    //create download info set download uri and save path.
    final DownloadInfo downloadInfo = new DownloadInfo.Builder().setUrl("http://example.com/a.apk")
        .setPath("/sdcard/a.apk")
        .build();
    
    //set download callback.
    downloadInfo.setDownloadListener(new DownloadListener() {
    
      @Override
      public void onStart() {
        tv_download_info.setText("Prepare downloading");
      }
    
      @Override
      public void onWaited() {
        tv_download_info.setText("Waiting");
        bt_download_button.setText("Pause");
      }
    
      @Override
      public void onPaused() {
        bt_download_button.setText("Continue");
        tv_download_info.setText("Paused");
      }
    
      @Override
      public void onDownloading(long progress, long size) {
        tv_download_info
            .setText(FileUtil.formatFileSize(progress) + "/" + FileUtil
                .formatFileSize(size));
        bt_download_button.setText("Pause");
      }
    
      @Override
      public void onRemoved() {
        bt_download_button.setText("Download");
        tv_download_info.setText("");
        downloadInfo = null;
      }
    
      @Override
      public void onDownloadSuccess() {
        bt_download_button.setText("Delete");
        tv_download_info.setText("Download success");
      }
    
      @Override
      public void onDownloadFailed(DownloadException e) {
        e.printStackTrace();
        tv_download_info.setText("Download fail:" + e.getMessage());
      }
    });
    
    //submit download info to download manager.
    downloadManager.download(downloadInfo);
    
    下载一个文件时直接创建一个DownloadInfo,然后设置下载链接和下载路径。再添加一个监听。就可以提交到下载框架了。
    
    通过下载监听器我们可以获取到很多状态。开始下载,等待中,暂停完成,下载中,删除成功,下载成功,下载失败等状态。
    
    在列表控件使用
    
    我们这里演示如何在RecyclerView这类列表控件使用。当然如果你用的是ListView那道理是一样的。
    
    class ViewHolder extends RecyclerView.ViewHolder {
    
      private final ImageView iv_icon;
      private final TextView tv_size;
      private final TextView tv_status;
      private final ProgressBar pb;
      private final TextView tv_name;
      private final Button bt_action;
      private DownloadInfo downloadInfo;
    
      public ViewHolder(View view) {
        super(view);
    
        iv_icon = (ImageView) view.findViewById(R.id.iv_icon);
        tv_size = (TextView) view.findViewById(R.id.tv_size);
        tv_status = (TextView) view.findViewById(R.id.tv_status);
        pb = (ProgressBar) view.findViewById(R.id.pb);
        tv_name = (TextView) view.findViewById(R.id.tv_name);
        bt_action = (Button) view.findViewById(R.id.bt_action);
      }
    
      @SuppressWarnings("unchecked")
      public void bindData(final MyDownloadInfo data, int position, final Context context) {
        Glide.with(context).load(data.getIcon()).into(iv_icon);
        tv_name.setText(data.getName());
    
        // Get download task status
        downloadInfo = downloadManager.getDownloadById(data.getUrl().hashCode());
    
        // Set a download listener
        if (downloadInfo != null) {
          downloadInfo
              .setDownloadListener(new MyDownloadListener(new SoftReference(ViewHolder.this)) {
                //  Call interval about one second
                @Override
                public void onRefresh() {
                  if (getUserTag() != null && getUserTag().get() != null) {
                    ViewHolder viewHolder = (ViewHolder) getUserTag().get();
                    viewHolder.refresh();
                  }
                }
              });
    
        }
    
        refresh();
    
    //      Download button
        bt_action.setOnClickListener(new OnClickListener() {
          @Override
          public void onClick(View v) {
            if (downloadInfo != null) {
    
              switch (downloadInfo.getStatus()) {
                case DownloadInfo.STATUS_NONE:
                case DownloadInfo.STATUS_PAUSED:
                case DownloadInfo.STATUS_ERROR:
    
                  //resume downloadInfo
                  downloadManager.resume(downloadInfo);
                  break;
    
                case DownloadInfo.STATUS_DOWNLOADING:
                case DownloadInfo.STATUS_PREPARE_DOWNLOAD:
                case STATUS_WAIT:
                  //pause downloadInfo
                  downloadManager.pause(downloadInfo);
                  break;
                case DownloadInfo.STATUS_COMPLETED:
                  downloadManager.remove(downloadInfo);
                  break;
              }
            } else {
    //            Create new download task
              File d = new File(Environment.getExternalStorageDirectory().getAbsolutePath(), "d");
              if (!d.exists()) {
                d.mkdirs();
              }
              String path = d.getAbsolutePath().concat("/").concat(data.getName());
              downloadInfo = new Builder().setUrl(data.getUrl())
                  .setPath(path)
                  .build();
              downloadInfo
                  .setDownloadListener(new MyDownloadListener(new SoftReference(ViewHolder.this)) {
    
                    @Override
                    public void onRefresh() {
                      if (getUserTag() != null && getUserTag().get() != null) {
                        ViewHolder viewHolder = (ViewHolder) getUserTag().get();
                        viewHolder.refresh();
                      }
                    }
                  });
              downloadManager.download(downloadInfo);
            }
          }
        });
    
      }
    
      private void refresh() {
        if (downloadInfo == null) {
          tv_size.setText("");
          pb.setProgress(0);
          bt_action.setText("Download");
          tv_status.setText("not downloadInfo");
        } else {
          switch (downloadInfo.getStatus()) {
            case DownloadInfo.STATUS_NONE:
              bt_action.setText("Download");
              tv_status.setText("not downloadInfo");
              break;
            case DownloadInfo.STATUS_PAUSED:
            case DownloadInfo.STATUS_ERROR:
              bt_action.setText("Continue");
              tv_status.setText("paused");
              try {
                pb.setProgress((int) (downloadInfo.getProgress() * 100.0 / downloadInfo.getSize()));
              } catch (Exception e) {
                e.printStackTrace();
              }
              tv_size.setText(FileUtil.formatFileSize(downloadInfo.getProgress()) + "/" + FileUtil
                  .formatFileSize(downloadInfo.getSize()));
              break;
    
            case DownloadInfo.STATUS_DOWNLOADING:
            case DownloadInfo.STATUS_PREPARE_DOWNLOAD:
              bt_action.setText("Pause");
              try {
                pb.setProgress((int) (downloadInfo.getProgress() * 100.0 / downloadInfo.getSize()));
              } catch (Exception e) {
                e.printStackTrace();
              }
              tv_size.setText(FileUtil.formatFileSize(downloadInfo.getProgress()) + "/" + FileUtil
                  .formatFileSize(downloadInfo.getSize()));
              tv_status.setText("downloading");
              break;
            case STATUS_COMPLETED:
              bt_action.setText("Delete");
              try {
                pb.setProgress((int) (downloadInfo.getProgress() * 100.0 / downloadInfo.getSize()));
              } catch (Exception e) {
                e.printStackTrace();
              }
              tv_size.setText(FileUtil.formatFileSize(downloadInfo.getProgress()) + "/" + FileUtil
                  .formatFileSize(downloadInfo.getSize()));
              tv_status.setText("success");
              break;
            case STATUS_REMOVED:
              tv_size.setText("");
              pb.setProgress(0);
              bt_action.setText("Download");
              tv_status.setText("not downloadInfo");
            case STATUS_WAIT:
              tv_size.setText("");
              pb.setProgress(0);
              bt_action.setText("Pause");
              tv_status.setText("Waiting");
              break;
          }
    
        }
      }
    }

    其中要注意到的是缓存每个条目我们使用了SoftReference,这样做的目的内容在吃紧的情况下而已及时的是否这些条目。

    接下来又一个重要的点是,设置按钮的点击事件,通常在这样的列表中有一个或多个按钮控制下载状态。

    if (downloadInfo != null) {
    
      switch (downloadInfo.getStatus()) {
        case DownloadInfo.STATUS_NONE:
        case DownloadInfo.STATUS_PAUSED:
        case DownloadInfo.STATUS_ERROR:
    
          //resume downloadInfo
          downloadManager.resume(downloadInfo);
          break;
    
        case DownloadInfo.STATUS_DOWNLOADING:
        case DownloadInfo.STATUS_PREPARE_DOWNLOAD:
        case STATUS_WAIT:
          //pause downloadInfo
          downloadManager.pause(downloadInfo);
          break;
        case DownloadInfo.STATUS_COMPLETED:
          downloadManager.remove(downloadInfo);
          break;
      }
    } else {
    //            Create new download task
      File d = new File(Environment.getExternalStorageDirectory().getAbsolutePath(), "d");
      if (!d.exists()) {
        d.mkdirs();
      }
      String path = d.getAbsolutePath().concat("/").concat(data.getName());
      downloadInfo = new Builder().setUrl(data.getUrl())
          .setPath(path)
          .build();
      downloadInfo
          .setDownloadListener(new MyDownloadListener(new SoftReference(ViewHolder.this)) {
    
            @Override
            public void onRefresh() {
              if (getUserTag() != null && getUserTag().get() != null) {
                ViewHolder viewHolder = (ViewHolder) getUserTag().get();
                viewHolder.refresh();
              }
            }
          });
      downloadManager.download(downloadInfo);
    }

    关键点就是如果没有下载任务就创建一个下载任务,如果已有下载任务就根据任务现在的状态执行相应的操作,比如当前是没有下载,点击就是创建一个下载任务。

    接下还有一个重点就是,我们在回调监听中调用了refresh方法,在该方法中根据状态显示进度和相应的操作按钮。这样做的好处上面已经提到了。

    private void refresh() {
      if (downloadInfo == null) {
        tv_size.setText("");
        pb.setProgress(0);
        bt_action.setText("Download");
        tv_status.setText("not downloadInfo");
      } else {
        switch (downloadInfo.getStatus()) {
          case DownloadInfo.STATUS_NONE:
            bt_action.setText("Download");
            tv_status.setText("not downloadInfo");
            break;
          case DownloadInfo.STATUS_PAUSED:
          case DownloadInfo.STATUS_ERROR:
            bt_action.setText("Continue");
            tv_status.setText("paused");
            try {
              pb.setProgress((int) (downloadInfo.getProgress() * 100.0 / downloadInfo.getSize()));
            } catch (Exception e) {
              e.printStackTrace();
            }
            tv_size.setText(FileUtil.formatFileSize(downloadInfo.getProgress()) + "/" + FileUtil
                .formatFileSize(downloadInfo.getSize()));
            break;
    
          case DownloadInfo.STATUS_DOWNLOADING:
          case DownloadInfo.STATUS_PREPARE_DOWNLOAD:
            bt_action.setText("Pause");
            try {
              pb.setProgress((int) (downloadInfo.getProgress() * 100.0 / downloadInfo.getSize()));
            } catch (Exception e) {
              e.printStackTrace();
            }
            tv_size.setText(FileUtil.formatFileSize(downloadInfo.getProgress()) + "/" + FileUtil
                .formatFileSize(downloadInfo.getSize()));
            tv_status.setText("downloading");
            break;
          case STATUS_COMPLETED:
            bt_action.setText("Delete");
            try {
              pb.setProgress((int) (downloadInfo.getProgress() * 100.0 / downloadInfo.getSize()));
            } catch (Exception e) {
              e.printStackTrace();
            }
            tv_size.setText(FileUtil.formatFileSize(downloadInfo.getProgress()) + "/" + FileUtil
                .formatFileSize(downloadInfo.getSize()));
            tv_status.setText("success");
            break;
          case STATUS_REMOVED:
            tv_size.setText("");
            pb.setProgress(0);
            bt_action.setText("Download");
            tv_status.setText("not downloadInfo");
          case STATUS_WAIT:
            tv_size.setText("");
            pb.setProgress(0);
            bt_action.setText("Pause");
            tv_status.setText("Waiting");
            break;
        }
    
      }
    }

    到这里改下框架的核心使用方法就介绍完了。

    支持

    如有任何问题可以在加我们的QQ群或者在Github上提Issue,另外请提Issue或者的PR的一定要看下项目的贡献代码的方法以及一要求,因为如果要保证一个开源项目的质量就必须在各方面都规范化。

  • 相关阅读:
    学习笔记5_Day09_网站访问量统计小练习
    学习笔记4_ServletContext(重要整个Web应用的动态资源之间共享数据)
    学习笔记03_Day09-----Servle与反射()
    学习笔记2_Day09_servlet的细节
    学习笔记1_Day09_Servlet
    OC对数组排序的方法
    sqlite事务处理
    封装数据库
    JsonModel的使用
    RBAC表
  • 原文地址:https://www.cnblogs.com/zhujiabin/p/7396540.html
Copyright © 2011-2022 走看看