Android文件上传与下载

文件上传与下载

文件上传 -- 服务端

以Tomcat为服务器,Android客服端访问Servlet,经Servlet处理逻辑,最终将文件上传,这里就是简单模拟该功能,就将文件上传到本机的D:\\upload文件夹下。

还是贴出来服务端的代码

package fileupload;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List;
import java.util.UUID;

import javax.enterprise.inject.New;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.apache.commons.fileupload.FileItem;
import org.apache.commons.fileupload.disk.DiskFileItemFactory;
import org.apache.commons.fileupload.servlet.ServletFileUpload;
import org.apache.commons.io.IOUtils;


@WebServlet("/Dservlet")
public class Dservlet extends HttpServlet {

    protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
     // 1. 创建配置工厂
        DiskFileItemFactory factory = new DiskFileItemFactory();
        // 2. 根据配置工厂创建解析请求中文件上传内容的解析器
        ServletFileUpload upload = new ServletFileUpload(factory);
        // 3. 判断当前请求是不是多段提交
        if (!upload.isMultipartContent(request)) {
            throw new RuntimeException("不是多段提交!");
        }

        try {
            // 4. 解析request对象,将已经分割过的内容放进了List
            List<FileItem> list = upload.parseRequest(request);
            if (list != null) {
                for (FileItem fileItem : list) {
                    // 判断当前段是普通字段还是文件,这个方法是判断普通段
                    if (fileItem.isFormField()) {
                        // 获得name属性对应的值,这里是username
                        String fname = fileItem.getFieldName();
                        // 获得键对应的值
                        String value = fileItem.getString("utf-8");
                        System.out.println(fname +  "=>"+value );
                        // 否则就是文件了
                    } else {
              
                        // 获得文件上传段中,文件的流
                        InputStream in = fileItem.getInputStream();

                        // 使用用户上传的文件名来保存文件的话,文件名可能重复。
                        // 所以保存文件之前,要保证文件名不会重复。使用UUID生成随机字符串
                        String fileName = UUID.randomUUID().toString();
                        SimpleDateFormat simpleDateFormat = new SimpleDateFormat("/yyyy/MM/dd/");
                        String datePath = simpleDateFormat.format(new Date()); // 解析成    /2017/04/15/  的样子, 注意这是三个文件夹
                        String wholePath = "D:/upload"+datePath;
                        // 字节输出流,用以保存文件,也不需要后缀名,因为我们只是保存用户的数据,不需要查看他们的数据。待用户想下载的时候,再加上后缀名
                        File dir = new File(wholePath);
                        // mkdirs可以建立多级目录。即使所有层级的目录都不存在。这些文件夹都会创建,比如我们事先并没有创建在D盘创建upload和2017等这些文件夹
                        // mkdir只能用于父级目录已经存在的情况下使用,在已存在的父级目录下再新建一级。只能一级!比如File("D:\\upload\\2017\\04")。且D:\\upload\\2017是已经存在的。父级 目录存且只新建一级。故file.makedir()返回true成功创建。
                        // 但是File("D:\\upload\\2017\\04\\15")且D:\\upload\\2017存在,但不存在15文件夹。因为父级目录不存在所以创建失败返回false
                        if (!dir.exists()) {
                            dir.mkdirs(); 
                        }
                        FileOutputStream fos = new FileOutputStream(wholePath+fileName);
                        // 将输入流复制到输出流中
                        IOUtils.copy(in, fos);
                      fos.close();
                    }
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        }       
    }
}

文件上传 -- 客户端

待上传的文件放在sdcard的根目录下。因为要访问网络,读写外部存储。所以要先申请权限。要注意的是,从Android 6.0开始,读写内存需要动态申请

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

留意一点,这里虽然只是申请了写如外部存储的权限,但是这一声明会隐式包含READ_EXTERNAL_STORAGE这一权限。故上述申请就好了。

布局也很简单,一个输入框手动填写路径,一个按钮请求上传

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context="com.example.fileopload.MainActivity">

    <EditText
        android:id="@+id/et_filepath"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:hint="请输入文件路径"/>

    <Button
        android:id="@+id/bt_upload"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="上传"/>

</LinearLayout>

MainActivity

package com.example.fileopload;

import android.Manifest;
import android.content.Context;
import android.content.pm.PackageManager;
import android.os.Bundle;
import android.support.v4.app.ActivityCompat;
import android.support.v4.content.ContextCompat;
import android.support.v7.app.AppCompatActivity;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;
import android.widget.Toast;

import java.io.File;
import java.io.IOException;

import okhttp3.Call;
import okhttp3.Callback;
import okhttp3.MediaType;
import okhttp3.MultipartBody;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;

public class MainActivity extends AppCompatActivity {
    public static final MediaType MEDIA_TYPE_PNG = MediaType.parse("image/png");

    private EditText editText;
    private Context mContext;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        mContext = this;
        // 申请并获得权限
        if (ContextCompat.checkSelfPermission(MainActivity.this, Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
            ActivityCompat.requestPermissions(MainActivity.this, new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE} ,1);
        }
        // 获取控件
        editText = (EditText) findViewById(R.id.et_filepath);
        Button btUpload = (Button) findViewById(R.id.bt_upload);
        btUpload.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                fileupload(new Callback() {
                    @Override
                    public void onFailure(Call call, IOException e) {
                        runOnUiThread(new Runnable() {
                            @Override
                            public void run() {
                                Toast.makeText(mContext, "上传失败!", Toast.LENGTH_SHORT).show();
                            }
                        });
                    }

                    @Override
                    public void onResponse(Call call, Response response) throws IOException {
                        runOnUiThread(new Runnable() {
                            @Override
                            public void run() {
                                Toast.makeText(mContext, "上传成功!", Toast.LENGTH_SHORT).show();
                            }
                        });
                    }
                });
            }
        });
    }

    public void fileupload(Callback callback) {
        // 获得输入框中的路径
        String path = editText.getText().toString().trim();
        File file = new File(path);
        OkHttpClient client = new OkHttpClient();
        // 上传文件使用MultipartBody.Builder
        RequestBody requestBody = new MultipartBody.Builder()
                .setType(MultipartBody.FORM)
                .addFormDataPart("username", "sunhaiyu") // 提交普通字段
                .addFormDataPart("image", file.getName(), RequestBody.create(MEDIA_TYPE_PNG, file)) // 提交图片,第一个参数是键(name="第一个参数"),第二个参数是文件名,第三个是一个RequestBody
                .build();
        // POST请求
        Request request = new Request.Builder()
                .url("http://10.175.42.160:8080/fileupload/Dservlet")
                .post(requestBody)
                .build();
        client.newCall(request).enqueue(callback);
    }
}

上面的代码很简单,唯一要注意的就是需要申请运行时权限。

OK,上传成功。去D:\\upload文件夹下,就能看到我们从手机上传的图片!

多文件同时下载

先实现同时下载几个文件。

常见的下载文件有两种情况。

  • 先请求一次文件下载地址,获取到文件的大小,在本地创建一个和待下载文件一样大小的文件,作为占位。这样有个好处就是当磁盘空间不足的时候,刚开始下载系统就会提醒。
  • 也是先获得文件大小,创建空文件(不设置大小)从待下载文件处读取到了多少,就往本地的文件写入多少,因此占用的空间在不断增长。

我们要实现同时下载多个文件,首先要知道待下载文件的大小,这样才能知道什么时候才算下载成功了。也方便进度条根据已下载的大小和总大小的比值显示准确的进度。

综上,具体步骤如下

  1. 请求服务器获取文件大小
  2. 为每个下载任务开一个子线程
  3. 开启线程并行下载,显示进度条

主布局很简单,准备同时下载三个文件,点一个按钮就开始一个任务。注意:在没有开始下载的时候,不要显示进度条。点击了下载按钮才开始下载。

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context="com.example.filedownload.MainActivity"
    android:layout_margin="16dp"
    >

    <Button
        android:id="@+id/bt_download1"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="下载1" />
    <!--进度条开始下载时才显示-->
    <ProgressBar
        android:visibility="invisible"
        android:id="@+id/progress1"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        style="?android:attr/progressBarStyleHorizontal"
        android:layout_marginTop="10dp"/>

    <Button
        android:id="@+id/bt_download2"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="下载2" />
    <ProgressBar
        android:visibility="invisible"
        android:id="@+id/progress2"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        style="?android:attr/progressBarStyleHorizontal"
        android:layout_marginTop="10dp"/>

    <Button
        android:id="@+id/bt_download3"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="下载3" />
    <ProgressBar
        android:visibility="invisible"
        android:id="@+id/progress3"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        style="?android:attr/progressBarStyleHorizontal"
        android:layout_marginTop="10dp"/>
</LinearLayout>

MainActivity

package com.example.filedownload;

import android.content.Context;
import android.os.Bundle;
import android.support.v7.app.AppCompatActivity;
import android.view.View;
import android.widget.Button;
import android.widget.ProgressBar;
import android.widget.Toast;

import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.RandomAccessFile;

import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;

public class MainActivity extends AppCompatActivity implements View.OnClickListener {

    private String[] urls = {"https://mirrors.tuna.tsinghua.edu.cn/cygwin/x86_64/setup.bz2",
            "https://mirrors.tuna.tsinghua.edu.cn/centos/filelist.gz",
            "https://mirrors.tuna.tsinghua.edu.cn/anaconda/miniconda/Miniconda-3.6.0-Linux-x86.sh"};

    private Context mContext;
    private ProgressBar progressbar1;
    private ProgressBar progressbar2;
    private ProgressBar progressbar3;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        mContext = this;

        progressbar1 = (ProgressBar) findViewById(R.id.progress1);
        progressbar2 = (ProgressBar) findViewById(R.id.progress2);
        progressbar3 = (ProgressBar) findViewById(R.id.progress3);

        Button btDownload1 = (Button) findViewById(R.id.bt_download1);
        Button btDownload2 = (Button) findViewById(R.id.bt_download2);
        Button btDownload3 = (Button) findViewById(R.id.bt_download3);

        btDownload1.setOnClickListener(this);
        btDownload2.setOnClickListener(this);
        btDownload3.setOnClickListener(this);
    }

    @Override
    public void onClick(View v) {
        switch (v.getId()) {
            case R.id.bt_download1:
                progressbar1.setVisibility(View.VISIBLE);
                fileDownload(urls[0], progressbar1);
                break;
            case R.id.bt_download2:
                progressbar2.setVisibility(View.VISIBLE);
                fileDownload(urls[1], progressbar2);
                break;
            case R.id.bt_download3:
                progressbar3.setVisibility(View.VISIBLE);
                fileDownload(urls[2], progressbar3);
                break;
            default:
        }
    }

    public void fileDownload(final String url, final ProgressBar progressBar) {
        new Thread(new Runnable() {
            @Override
            public void run() {
                InputStream is;
                File file = null;
                RandomAccessFile savedFile = null;

                try {
                    long fileLength = getFileLength(url);
                    final String fileName = getFileName(url);
                    long downLoadLength = 0;
                    OkHttpClient client = new OkHttpClient();
                    Request request = new Request.Builder().url(url).build();
                    Response response = client.newCall(request).execute();
                    if (response != null && response.isSuccessful()) {
                        // 应用关联目录,无需申请读写存储的运行时权限
                        // 位于/sdcard/Android/data/包名/cache
                        file = new File(getExternalCacheDir() + fileName);
                        // 随机访问,可通过seek方法定位到文件的任意位置,方便断点续传
                        savedFile = new RandomAccessFile(file, "rw");
                        is = response.body().byteStream();
                        byte[] buffer = new byte[1024];
                        int len;

                        int total = 0;
                        while ((len = is.read(buffer)) != -1) {
                            savedFile.write(buffer, 0, len);
                            total += len;
                            // 注意这里要先乘以100再除,否则java的除法中小数直接抹去后面的,我们得到的比值比如0.5直接就变成0,progress也就为0了
                            int progress = (int) ((total + downLoadLength) * 100 / fileLength);
                            progressBar.setProgress(progress);

                        }
                        // response.body().string()只能调用一次,再次调用报错。
                        // 写完后可以把body关了
                        response.body().close();

                        // 能运行到这儿说明下载成功
                        runOnUiThread(new Runnable() {
                            @Override
                            public void run() {
                                Toast.makeText(mContext, "下载成功", Toast.LENGTH_SHORT).show();
                            }
                        });
                        // response为空或者请求的状态码没有成功
                    } else {
                        runOnUiThread(new Runnable() {
                            @Override
                            public void run() {
                                Toast.makeText(mContext, "下载失败", Toast.LENGTH_SHORT).show();
                            }
                        });
                    }
                } catch (IOException e) {
                    e.printStackTrace();
                } finally {
                    try {
                        if (savedFile != null) {
                            savedFile.close();
                        }
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }

            }
        }).start();
    }

    // 获得文件长度
    private long getFileLength(String url) throws IOException{
        long contentLength = 0;
        OkHttpClient client = new OkHttpClient();
        Request request = new Request.Builder()
                .url(url)
                .build();
            Response response = client.newCall(request).execute();
            // 有响应且不为空
            if (response != null && response.isSuccessful()) {
                contentLength = response.body().contentLength();
                response.body().close();
            }
        return contentLength;
    }

    // 得到的是 /xxx.xxx,注意有斜杠
    private String getFileName(String url) {
        return url.substring(url.lastIndexOf("/"));
    }
}

写到这里就实现了多文件同时下载,去/sdcard/Android/data/com.example.filedownload/cache/目录下就能找到刚才下载的文件。上面有些变量和控件没有用到,为下面多线程下载单个文件作准备。

多线程下载单个文件

上面的实现都是一个线程管一个下载任务,这样速度比较慢。可以将文件切割成几部分,同时由几个线程并发下载,一定要保证每个线程负责的那一段文件的开始位置和结束位置精准无误。举个例子,一个文件有10M,开三个线程下载这个文件,平均一段是10 / 3M,那么第一段是02,第二段必须是35,最后一段稍微长点,从6~ length-1。

同时还要实现断点续传,在读取流的while循环里,不断存入当前子线程的已下载字节数,使得退出应用的时候存取的刚好是已下载的字节数。下次进入应用的时候,再从数据库里取出已下载的字节数,使用RandomAccessFile快速定位到该位置。然后再次请求下载的时候,一定要加上一个RANGE头,指定从哪个位置开始访问这个资源。当然是从已下载好部分开始。当然第一次进入应用还没下载过的时候,数据库肯定是空的,这时候肯定取不到已下载字节数,就需要作具体判断了。当文件下载完成,将保存断点的数据删除,因为已经没用了嘛。

到此文件下载功能完成。可以拓展一下,增加暂停下载和取消下载的功能。其中暂停下载就是简单地不将流写入,而取消下载则需要删除文件及断点。

!!!注意不能使用SharedPreferences,当删除断点时候有问题。我尝试过直接删除xml文件,文件确实可以删除成功,但是不知为何又会重新恢复,而且保存的还是上次的数据;也尝试过editor.clear()后提交,单线程下可以清空,在多线程的时候总是不能清空,还有数据。怎么都不行。试试用数据库来保存断点。

一一实现上述功能。

这次的实现我们一开始就设置和待下载文件一样的长度。有如下好处:

  • 一开始就设置和待下载文件一样的长度,可以避免下载结束后才告知磁盘空间不足。
  • 如果不设置,seek函数不断移动到文件末尾,不断开辟空间。频繁的I/O操作降低了性能

由于需要用到数据库,准备一个Bean和数据库帮助类。

package com.example.filedownload.dao;


import android.content.Context;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;

public class MyDatabaseHelper extends SQLiteOpenHelper {
    private Context mContext;
    public static final String CREATE_TASK = "create table point ("
            + "_id integer primary key autoincrement,"
            + "task text,"
            + "thread integer,"
            + "position integer);";

    public MyDatabaseHelper(Context context, String name, SQLiteDatabase.CursorFactory factory, int version) {
        super(context, name, factory, version);
        mContext = context;
    }

    @Override
    public void onCreate(SQLiteDatabase db) {
        db.execSQL(CREATE_TASK);
    }

    @Override
    public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
        // TODO: 2017/4/20
    }
}

bean对应数据库的字段

package com.example.filedownload.bean;


public class Task {
    public int _id;
    public String task;
    public int thread;
    public long position;
}

实现数据库的增删改查

package com.example.filedownload.dao;


import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;

import com.example.filedownload.bean.Task;

public class TaskDao {

    private SQLiteDatabase db;

    public TaskDao(Context context, String dbName, int version) {
        MyDatabaseHelper databaseHelper = new MyDatabaseHelper(context, dbName, null, version);
        db = databaseHelper.getReadableDatabase();
    }

    // 更新断点
    public void savePoint(Task taskName) {
        // update point set position = ? where task = ? and thread = ?;
        ContentValues values = new ContentValues();
        values.put("position", taskName.position);
        db.update("point",values, "task = ? and thread = ?", new String[] {taskName.task, String.valueOf(taskName.thread)});
    }
    // 每次有一个线程,就准备一个断点
    public void addPoint(Task taskName) {
        // insert into point(task, thread, position) values(?, ?, ?);
        ContentValues values = new ContentValues();
        values.put("task", taskName.task);
        values.put("thread", taskName.thread);
        values.put("position", taskName.position);
        db.insert("point", null, values);
    }

    // 下载完成后,删除已下载文件的所有断点
    // delete from point where task = ?;
    public void delete(String taskName) {
        db.delete("point", "task = ?", new String[]{taskName});
    }

    // 从数据库获取断点
    public long getLastPoint(String taskName, int threadId) {
        // 没有断点就返回-1
        long lastPoint = -1;
        // select position form point where task = ? and thread = ?;
        Cursor cursor = db.query("point", new String[] {"position"}, "task = ? and thread = ? ", new String[]{taskName, String.valueOf(threadId)}, null, null, null);
        // 条件,游标能否定位到下一行。这里只有一个唯一结果用if就行
        if (cursor.moveToNext()) {
            lastPoint = cursor.getLong(cursor.getColumnIndex("position"));
        }
        // 关闭结果集
        cursor.close();
        return lastPoint;
    }
}

布局简化,只下载一个文件。要写成多文件下载,就现在的所学写起来比较麻烦,所以就简化了。

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context="com.example.filedownload.MainActivity"
    android:layout_margin="16dp"
    >
    <LinearLayout
        android:orientation="horizontal"
        android:layout_width="match_parent"
        android:layout_height="wrap_content">
        <Button
            android:id="@+id/bt_download"
            android:layout_weight="1"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:text="下载" />

        <Button
            android:visibility="invisible"
            android:id="@+id/bt_pause"
            android:layout_weight="1"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:text="暂停" />

        <Button
            android:visibility="invisible"
            android:id="@+id/bt_cancel"
            android:layout_weight="1"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:text="取消" />
    </LinearLayout>

    <!--进度条开始下载时才显示-->
    <ProgressBar
        android:visibility="invisible"
        android:id="@+id/progress"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        style="?android:attr/progressBarStyleHorizontal"
        android:layout_marginTop="10dp"/>
</LinearLayout>

最后来看MainActivity,一些细节注意一下。刚进入应用,只能看见“下载”的按钮,其余按钮被隐藏。当用户点击下载按钮后,暂停和取消的按钮才可见。当当文件下载成功后,这两个按钮又变成不可见。在下载过程中,可以点击暂停以跳过流写入和断点存储的步骤;点击取消,删除文件及断点。

下载过程中用到三个标志位isDownloadingisPausedisCanceled

  • 每次点击下载,将isPausedisCanceled置为false。isDownloading得分情况,第一次进入应用,isDownloading没有初始化,默认false,故其下代码得到执行,isDownloading变true。之后若没有点击过暂停或者取消按钮,isDownloading标志位并没有变化,则不会重复执行download的代码。
  • 每次点击暂停,就把isDownloading置为false,isPaused置为true。
  • 每次点击取消,就把isDownloading置为false,isCanceled置为true。
package com.example.filedownload;

import android.content.Context;
import android.os.Bundle;
import android.support.v7.app.AppCompatActivity;
import android.view.View;
import android.widget.Button;
import android.widget.ProgressBar;
import android.widget.Toast;

import com.example.filedownload.bean.Task;
import com.example.filedownload.dao.TaskDao;

import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.RandomAccessFile;

import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;

public class MainActivity extends AppCompatActivity implements View.OnClickListener {

    private String fileUrl = "https://mirrors.tuna.tsinghua.edu.cn/mysql/downloads/Win32/Perl-5.00502-mswin32-1.1-x86.zip";

    private Context mContext;
    // 设置数据库连接为全局变量。所有线程共用一个数据库连接。也不会close掉。
    // 所有线程用了就close的话,可能A线程在close的时候,B线程又想打开连接进行读写。
    // 不频繁开关连接,性能更好。等到生命周期结束才自动close
    private TaskDao taskDao;

    private ProgressBar progressbar;

    private Button btPause;
    private Button btCancel;

    private boolean isDownloading;
    private boolean isPaused;
    private boolean isCanceled;

    // 可以改线程数目,不要太多。原因你懂的
    public static final int THREAD_COUNT = 5;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        mContext = this;
        // 一进应用就创建数据库
        taskDao = new TaskDao(mContext, "tasks.db", 1);
        progressbar = (ProgressBar) findViewById(R.id.progress);

        Button btDownload1 = (Button) findViewById(R.id.bt_download);
        btPause = (Button) findViewById(R.id.bt_pause);
        btCancel = (Button) findViewById(R.id.bt_cancel);

        btDownload1.setOnClickListener(this);
        btPause.setOnClickListener(this);
        btCancel.setOnClickListener(this);
    }

    @Override
    public void onClick(View v) {
        switch (v.getId()) {
            case R.id.bt_download:
                btPause.setVisibility(View.VISIBLE);
                btCancel.setVisibility(View.VISIBLE);
                progressbar.setVisibility(View.VISIBLE);
                fileDownload(fileUrl, progressbar, btPause, btCancel);
                Toast.makeText(mContext, "正在下载...", Toast.LENGTH_SHORT).show();
                break;

            case R.id.bt_pause:
                pauseDownload();
                Toast.makeText(mContext, "下载暂停", Toast.LENGTH_SHORT).show();
                break;
            case R.id.bt_cancel:
                btPause.setVisibility(View.INVISIBLE);
                btCancel.setVisibility(View.INVISIBLE);
                progressbar.setVisibility(View.INVISIBLE);
                canceledDownload(fileUrl);
                Toast.makeText(mContext, "下载取消,删除文件...", Toast.LENGTH_SHORT).show();
                break;
            default:
        }
    }

    private class DownloadTask implements Runnable {
        private int thread;
        private long startIndex;
        private long endIndex;
        private long lastPosition;
        private String url;

        public DownloadTask(String url, int thread, long startIndex, long endIndex) {
            this.thread = thread;
            this.startIndex = startIndex;
            this.endIndex = endIndex;
            this.url = url;

        }


        @Override
        public void run() {
            // 先尝试读取断点,两种情况可导致不存在断点。
            // 1. 第一次进入应用,还没开始下载
            // 2. 下载完毕,断点被删除。重新下载
            // 能读取到,肯定下了一部分但是没下载完
            if (taskDao.getLastPoint(getFileName(url), thread) != -1) { // -1表示找不到键对应的值
                lastPosition = taskDao.getLastPoint(getFileName(url), thread);
                // 如果这部分下载完毕,直接返回,不再请求网络
                if (lastPosition == endIndex + 1) {
                    return;
                }
            }
            // 没找到就重新下载
            else {
                lastPosition = startIndex;
            }

            OkHttpClient client = new OkHttpClient();
            // 设置RANGE头,分段文件下载,从上次下载处继续
            Request request = new Request.Builder().addHeader("RANGE", "bytes=" + lastPosition + "-" + endIndex)
                    .url(url)
                    .build();

            File file = null;
            RandomAccessFile savedFile = null;
            try {
                Response response = client.newCall(request).execute();
                if (response != null && response.isSuccessful()) {
                    // 应用关联目录,无需申请读写存储的运行时权限
                    // 位于/sdcard/Android/data/包名/cache
                    file = new File(getExternalCacheDir() + "/" + getFileName(url));
                    savedFile = new RandomAccessFile(file, "rw");
                    savedFile.seek(lastPosition);
                    // 响应成功了准备断点
                    // new 一个task,初始化task和thread和position
                    Task threadTask = new Task();
                    threadTask.task = getFileName(url);
                    threadTask.thread = thread;
                    // 上面的两个是固定的,更新的时候只更新position
                    threadTask.position = -1;
                    // 必须先插入这条新的数据,才能在下面对其update
                    taskDao.addPoint(threadTask);

                    InputStream is = response.body().byteStream();
                    byte[] buffer = new byte[1024 * 1024];
                    int len;

                    int total = 0;
                    while ((len = is.read(buffer)) != -1) {
                        if (!isPaused && !isCanceled) {
                            savedFile.write(buffer, 0, len);
                            total += len;
                            threadTask.position = total + lastPosition;
                            // 保存断点
                            taskDao.savePoint(threadTask);
                        }
                    }
                    // 写完后可以把body关了
                    response.body().close();
                }
            } catch (IOException e) {
                e.printStackTrace();
            } finally {
                try {
                    if (savedFile != null) {
                        savedFile.close();
                    }
                } catch (IOException e) {
                    e.printStackTrace();
                }

            }
        }
    }


    public void fileDownload(final String url, final ProgressBar progressBar, final Button pause, final Button cancel) {
        // 每次开始下载,自然要把这两个标志位置为false
        isPaused = false;
        isCanceled = false;

        // 注意boolean没有初始化默认为false,第一次进入点击下载肯定会执行,此后isDownloading为true。
        // 之后若没有点击暂停取消,标志位保持true。多次重复点击下载按钮,标志位没有改变故不会执行
        if (!isDownloading) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    isDownloading = true;
                    RandomAccessFile savedFile = null;
                    try {
                        final String fileName = getFileName(url);
                        long fileLength = getFileLength(url);
                        long partLength = fileLength / THREAD_COUNT;
                        // 应用关联目录,无需申请读写存储的运行时权限, 位于/sdcard/Android/data/包名/cache
                        File file = new File(getExternalCacheDir() + "/" + fileName);

                        // 随机访问,可通过seek方法定位到文件的任意位置,方便断点续传。
                        savedFile = new RandomAccessFile(file, "rw");
                        // 一开始就设置和待下载文件一样的长度,可以避免下载结束后才告知磁盘空间不足
                        // 如果不设置,seek函数不断移动到文件末尾,不断开辟空间。频繁的I/O操作降低了性能
                        savedFile.setLength(fileLength);
                        // 下面的算法适用于THREAD_COUNT等于任何数值
                        for (int thread = 0; thread < THREAD_COUNT; thread++) {
                            long startIndex = thread * partLength;
                            long endIndex = (thread + 1) * partLength - 1;
                            // 如果是最后一段,剩余的全部
                            if (thread == THREAD_COUNT - 1) {
                                endIndex = fileLength - 1;
                            }
                            // 开启线程下载
                            new Thread(new DownloadTask(url, thread, startIndex, endIndex)).start();
                        }

                        while (true) {
                            long totalProgress = 0;
                            for (int i = 0; i < THREAD_COUNT; i++) {
                                // 所有段加起来的下载字节数。推导一下,很简单
                                totalProgress += taskDao.getLastPoint(getFileName(url), i) - i * partLength;
                            }

                            // 这里有先乘100再除,否则先除是零点几,java除法抹去小数后就是0,再乘100也还是0
                            int progress = (int) (totalProgress * 100 / fileLength);
                            progressBar.setProgress(progress);
                            if (totalProgress == fileLength) {
                                progressBar.setProgress(100);
                                // 运行到此说明下载成功
                                taskDao.delete(getFileName(url));
                                runOnUiThread(new Runnable() {
                                    @Override
                                    public void run() {
                                        pause.setVisibility(View.INVISIBLE);
                                        cancel.setVisibility(View.INVISIBLE);
                                        Toast.makeText(mContext, "下载成功", Toast.LENGTH_SHORT).show();
                                    }
                                });
                                break;
                            }

                        }

                    } catch (IOException e) {
                        runOnUiThread(new Runnable() {
                            @Override
                            public void run() {
                                Toast.makeText(mContext, "下载失败", Toast.LENGTH_SHORT).show();
                            }
                        });
                        e.printStackTrace();
                    } finally {
                        try {
                            if (savedFile != null) {
                                savedFile.close();
                            }
                        } catch (IOException e) {
                            e.printStackTrace();
                        }
                    }

                }
            }).start();
        }
    }

    // 获得文件长度
    private long getFileLength(String url) throws IOException {
        long contentLength = 0;
        OkHttpClient client = new OkHttpClient();
        Request request = new Request.Builder()
                .url(url)
                .build();
        Response response = client.newCall(request).execute();
        // 有响应且不为空
        if (response != null && response.isSuccessful()) {
            contentLength = response.body().contentLength();
            response.body().close();
        }
        return contentLength;
    }

    // 得到的是 xxx.xxx,注意不带斜杠
    private String getFileName(String url) {
        return url.substring(url.lastIndexOf("/") + 1);
    }

    // 暂停下载
    private void pauseDownload() {
        isPaused = true;
        isDownloading = false;
    }

    // 取消下载
    private void canceledDownload(String url) {
        isCanceled = true;
        isDownloading = false;
        File file = new File(getExternalCacheDir() + "/" + getFileName(url));
        if (file.exists()) {
            file.delete();
        }
        taskDao.delete(getFileName(url));
    }
}

最后来看下截图

点击下载按钮

点击暂停按钮

点击取消按钮

终于折腾完了,代码太粗糙。当然下载功能不能这样写。体验更好的做法是使用后台服务下载文件,这样我们可以在使用其他应用的时候继续保持下载。而且上面的代码,异步处理可能有意想不到的异常,最好使用AsyncTask更方便的进行异步消息处理。


by @sunhaiyu

2017.5.3

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,390评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,821评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,632评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,170评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,033评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,098评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,511评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,204评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,479评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,572评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,341评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,213评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,576评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,893评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,171评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,486评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,676评论 2 335

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,050评论 25 707
  • 发现 关注 消息 iOS 第三方库、插件、知名博客总结 作者大灰狼的小绵羊哥哥关注 2017.06.26 09:4...
    肇东周阅读 11,943评论 4 60
  • 《等爱的狐狸》 如果可以, 想变成一只等爱的狐狸, 孤单静躺在临海的礁石地, 云霞为彩手做笔。 如果可以, 想变成...
    漫不经阅读 221评论 1 4
  • 1. 对于减肥人群,建议先进行无氧运动再进行有氧运动,燃脂效率大大提高,减肥事半功倍。有氧运动包括跑步、骑车、游泳...
    何舒卉阅读 281评论 0 2