實踐是檢驗真理的唯一標準,對于編程來說,理解了不一定會做,所以還是敲一遍讓身體也記住它吧。
現在讓我們來做一款簡單的單線程下載器吧。
本文的DEMO示例github下載地址:
https://github.com/liaozhoubei/Download
本文還有后續多線程下載器:http://www.lxweimin.com/p/c23b0c10c919
下載原理
對于Android來說,其下載器的原理非常簡單,僅僅是I/O流的實現而已,只要了解I/O流就能夠寫得出,下面這個是一個簡單java項目的下載代碼:
try {
// strUrl 下載的網絡地址
URL url = new URL(strUrl);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setConnectTimeout(10 * 1000);
conn.setRequestMethod("GET");
int code = conn.getResponseCode();
if (code == 200) {
System.out.println("下載開始");
File file = new File("e:\\" + strUrl.substring((strUrl.lastIndexOf("/") + 1)));
long length = conn.getContentLength();
if (length > 1024) {
long size = length / (1024 * 1024);
System.out.println("下載大小" + size + "mb");
}
InputStream is = conn.getInputStream();
byte[] bt = new byte[1024];
int len = 0;
RandomAccessFile raf = new RandomAccessFile(file, "rwd");
raf.setLength(length);
while ((len = is.read(bt)) != -1) {
raf.write(bt, 0, len);
}
System.out.println("下載完成");
is.close();
raf.close();
}
} catch (Exception e) {
e.printStackTrace();
}
代碼很簡單,用支持http協議的網絡地址進行下載,然后使用I/O流下載,或許大家不熟悉的只有RandomAccessFile這個API了,這是一個支持任意位置下載的一個API,同時它有個setLength()方法,可以直接設置RandomAccessFile文件,的長度。還有個seek()方法,可以直接設定從文件的哪個位置開始寫入文件。
RandomAccessFile是個很重要的API,對于斷點下載而言。
直接下載可以了,那么如何斷點下載呢?
所謂斷點下載,就是在停止下載文件的時候記住停止時的下載位置,等下次繼續下載的時候從這個位置繼續下載。
這個時候我們只需設置一個停止位置,然后用RandomAccessFile的seek()方法讀取這個位置就可以了。
所以這時我們要分兩步走
1、初始化下載線程,獲取文件的信息,如文件的大小等
2、開始下載文件,如果文件信息已存在,則查詢先前下載到哪一個位置。
代碼斷點續傳代碼如下:
private static void mutilDownload(String path) {
HttpURLConnection conn = null;
try {
URL url = new URL(path);
conn = (HttpURLConnection) url.openConnection();
conn.setConnectTimeout(10 * 1000);
conn.setRequestMethod("GET");
conn.setReadTimeout(5 * 1000);
int code = conn.getResponseCode();
if (code == HttpURLConnection.HTTP_OK) {
File file = new File("e:\\" + path.substring(path.lastIndexOf("/") + 1));
long filelength = conn.getContentLength();
RandomAccessFile randomFile = new RandomAccessFile(file, "rwd");
randomFile.setLength(filelength);
randomFile.close();
long endposition = filelength;
new newThreadDown(path, endposition).start();
}
} catch (Exception e) {
} finally {
conn.disconnect();
}
}
public static class newThreadDown extends Thread {
private String urlstr;
private long lastPostion;
private long endposition;
public newThreadDown(String urlstr, long endposition) {
this.urlstr = urlstr;
this.endposition = endposition;
}
@Override
public void run() {
HttpURLConnection conn = null;
try {
URL url = new URL(urlstr);
conn = (HttpURLConnection) url.openConnection();
conn.setConnectTimeout(10 * 1000);
conn.setRequestMethod("GET");
conn.setReadTimeout(10 * 1000);
long startposition = 0;
// 創建記錄緩存文件
File tempfile = new File("e:\\" + 1 + ".txt");
if (tempfile.exists()) {
InputStreamReader isr = new InputStreamReader(new FileInputStream(tempfile));
BufferedReader br = new BufferedReader(isr);
String lastStr = br.readLine();
lastPostion = Integer.parseInt(lastStr);
conn.setRequestProperty("Range", "bytes=" + lastPostion + "-" + endposition);
br.close();
} else {
lastPostion = startposition;
conn.setRequestProperty("Range", "bytes=" + lastPostion + "-" + endposition);
}
if (conn.getResponseCode() == HttpURLConnection.HTTP_PARTIAL) {
System.out.println(206 + "請求成功");
InputStream is = conn.getInputStream();
RandomAccessFile accessFile = new RandomAccessFile(new File("e:\\" + path.substring(path.lastIndexOf("/") + 1)),
"rwd");
accessFile.seek(lastPostion);
System.out.println("開始位置" + lastPostion);
byte[] bt = new byte[1024 * 200];
int len = 0;
long total = 0;
while ((len = is.read(bt)) != -1) {
total += len;
accessFile.write(bt, 0, len);
long currentposition = startposition + total;
File cachefile = new File("e:\\" + 1 + ".txt");
RandomAccessFile rf = new RandomAccessFile(cachefile, "rwd");
rf.write(String.valueOf(currentposition).getBytes());
rf.close();
}
System.out.println("下載完畢");
is.close();
accessFile.close();
}
} catch (Exception e) {
e.printStackTrace();
}
super.run();
}
}
這些都是java項目,可以在eclipse中直接運行測試。
下載的原理已經梳理清楚了,剩下的只要把下載程序移植到Android項目中去就好了。
Android下載器實現
作為一個實戰項目,我們要盡可能的完善,盡可能的使用Android中的控件,所以我們不做自己將上面的代碼復制到項目中,然后用ProgressBar更新UI的事情,我們要盡可能的復制!
我們的口號是:不做簡單活!
知識要點
- Android四大組件之Service
- Android四大組件之Broadcast
- 數據存儲SQLiteDatabase
現在讓我們開始完善這個單線程的下載器吧
下載器的布局
做一個簡單的界面,我們用到開始下載按鍵,停止下載按鍵,一個Progressbar,以及一個TextView顯示文件名。
如此簡單的布局就不寫代碼了,詳情可以下載我的Github項目研究。
封裝實體對象
在本項目中有個兩個實體類對象,即FileInfo類和ThreadInfo類,他們之中的變量都擁有get和set方法,FileInfo類需要實現序列化,詳細代碼請查看項目地址
FileInfo類代碼(略):
public class FileInfo implements Serializable {
private int id;
private String url;
private String fileName;
private int length;
private int finished;
public FileInfo() {
super();
}
/**
*
* @param id 文件的ID
* @param url 文件的下載地址
* @param fileName 文件的名字
* @param length 文件的總大小
* @param finished 文件已經完成了多少
*/
public FileInfo(int id, String url, String fileName, int length, int finished) {
super();
this.id = id;
this.url = url;
this.fileName = fileName;
this.length = length;
this.finished = finished;
}
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
}
ThreadInfo類代碼(略):
public class ThreadInfo {
private int id;
private String url;
private int start;
private int end;
private int finished;
public ThreadInfo() {
super();
}
/**
* @param id 綫程的ID
* @param url 下載文件的網絡地址
* @param start 綫程下載的開始位置
* @param end 綫程下載的結束位置
* @param finished 綫程已經下載到哪個位置
*/
public ThreadInfo(int id, String url, int start, int end, int finished) {
super();
this.id = id;
this.url = url;
this.start = start;
this.end = end;
this.finished = finished;
}
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
創建數據庫
使用SQLiteDatabase,我們首先要實現一個數據庫的幫助類,然后創建一個操作數據庫的接口類,最后實現這個接口的數據庫操作類。
使用數據庫是用于保存ThreadInfo對象的信息,并且實時更新下載進度,但需要斷點續傳的時候從數據庫中取出保存的信息,繼續下載。
這里提示一下,保存斷點信息可以不使用數據庫,試用SharedPreference也是可以起到同樣的作用,具體方法請讀著自己摸索。
數據庫幫助類代碼如下:
public class DBHelper extends SQLiteOpenHelper {
private static final String DB_NAME = "download.db";
private static final int VERSION = 1;
private static final String SQL_CREATE = "create table thread_info(_id integer primary key autoincrement, "
+ "thread_id integer, url text, start integer, end integer, finished integer)";
private static final String SQL_DROP = "drop table if exists thread_info";
public DBHelper(Context context) {
super(context, DB_NAME, null, VERSION);
}
@Override
public void onCreate(SQLiteDatabase db) {
db.execSQL(SQL_CREATE);
}
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
db.execSQL(SQL_DROP);
db.execSQL(SQL_CREATE);
}
}
操作數據庫的接口類代碼:
public interface ThreadDAO {
// 插入綫程
public void insertThread(ThreadInfo info);
// 刪除綫程
public void deleteThread(String url, int thread_id);
// 更新綫程
public void updateThread(String url, int thread_id, int finished);
// 查詢綫程
public List<ThreadInfo> queryThreads(String url);
// 判斷綫程是否存在
public boolean isExists(String url, int threadId);
}
實現接口的數據庫工具類:
public class ThreadDAOImple implements ThreadDAO {
private DBHelper dbHelper = null;
public ThreadDAOImple(Context context) {
super();
this.dbHelper = new DBHelper(context);
}
// 插入綫程
@Override
public void insertThread(ThreadInfo info) {
SQLiteDatabase db = dbHelper.getReadableDatabase();
ContentValues values = new ContentValues();
values.put("thread_id", info.getId());
values.put("url", info.getUrl());
values.put("start", info.getStart());
values.put("end", info.getEnd());
values.put("finished", info.getFinished());
db.insert("thread_info", null, values);
db.close();
}
// 刪除綫程
@Override
public void deleteThread(String url, int thread_id) {
SQLiteDatabase db = dbHelper.getReadableDatabase();
db.delete("thread_info", "url = ? and thread_id = ?", new String[] { url, thread_id + "" });
db.close();
}
// 更新綫程
@Override
public void updateThread(String url, int thread_id, int finished) {
SQLiteDatabase db = dbHelper.getReadableDatabase();
db.execSQL("update thread_info set finished = ? where url = ? and thread_id = ?",
new Object[]{finished, url, thread_id});
db.close();
}
// 查詢綫程
@Override
public List<ThreadInfo> queryThreads(String url) {
SQLiteDatabase db = dbHelper.getReadableDatabase();
List<ThreadInfo> list = new ArrayList<ThreadInfo>();
Cursor cursor = db.query("thread_info", null, "url = ?", new String[] { url }, null, null, null);
while (cursor.moveToNext()) {
ThreadInfo thread = new ThreadInfo();
thread.setId(cursor.getInt(cursor.getColumnIndex("thread_id")));
thread.setUrl(cursor.getString(cursor.getColumnIndex("url")));
thread.setStart(cursor.getInt(cursor.getColumnIndex("start")));
thread.setEnd(cursor.getInt(cursor.getColumnIndex("end")));
thread.setFinished(cursor.getInt(cursor.getColumnIndex("finished")));
list.add(thread);
}
cursor.close();
db.close();
return list;
}
// 判斷綫程是否爲空
@Override
public boolean isExists(String url, int thread_id) {
SQLiteDatabase db = dbHelper.getReadableDatabase();
Cursor cursor = db.query("thread_info", null, "url = ? and thread_id = ?", new String[] { url, thread_id + "" },
null, null, null);
boolean exists = cursor.moveToNext();
db.close();
db.close();
return exists;
}
}
從Activity中向Service傳參
很好,前期的準備工作已經做好了,需要從Activity中啟動線程,并且將Activity中獲得的關于下載文件的信息傳遞到Service中去,我們只需要用Intent便可以將FileInfo對象傳遞過去。在這里要注意的是如果FileInfo沒有序列化,繼承Serializable接口,那么Intent無法將FileInfo對象傳送出去。
首先創建一個DownloadService服務類,繼承自Service,定義ACITON_START和ACTION_STOP兩個常量,重新onStartCommand方法,代碼如下:
public class DownloadService extends Service {
public static final String ACTION_START = "ACTION_START";
public static final String ACTION_STOP = "ACTION_STOP";
@Override
public IBinder onBind(Intent intent) {
return null;
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
// 獲得Activity穿來的參數
if (ACTION_START.equals(intent.getAction())) {
FileInfo fileInfo = (FileInfo) intent.getSerializableExtra("fileInfo");
Log.i("test", "START" + fileInfo.toString());
} else if (ACTION_STOP.equals(intent.getAction())) {
FileInfo fileInfo = (FileInfo) intent.getSerializableExtra("fileInfo");
}
return super.onStartCommand(intent, flags, startId);
}
}
然后修改MainActivity中的代碼:
定義Intent常量
定義FileInfo常量
在onCreate方法中初始化兩個常量:
fileInfo = new FileInfo(0, urlstr, getfileName(urlstr), 0, 0);
intent = new Intent(MainActivity.this, DownloadService.class);
設置點擊事件:
@Override
public void onClick(View v) {
switch (v.getId()) {
case R.id.start_button:
// 開啟服務
fileName.setText(getfileName(urlstr));
intent.setAction(DownloadService.ACTION_START);
intent.putExtra("fileInfo", fileInfo);
startService(intent);
break;
case R.id.stop_button:
intent.setAction(DownloadService.ACTION_STOP);
intent.putExtra("fileInfo", fileInfo);
startService(intent);
break;
}
}
相親我們已經可以啟動服務了,點擊按鍵啟動服務之后,就能調用DownloadService中的onStartCommand方法,接收到從MainActivity傳過來的fileInfo對象。
從DownloadService中初始化線程
在剛才從MainActivity傳過來的fileInfo對象中只有下載的URL地址以及文件名,但是我們還不知道文件的長度,也沒有設定好文件的保存位置等信息,初始化線程就是為了配置好這些信息。
從初始化線程中配置好fileInfo對象之后,需要將它傳遞給Handler,然后在Handler啟動真正的下載任務,
Handler代碼如下:
// 從InitThread綫程中獲取FileInfo信息,然後開始下載任務
Handler mHandler = new Handler() {
public void handleMessage(android.os.Message msg) {
switch (msg.what) {
case MSG_INIT:
FileInfo fileInfo = (FileInfo) msg.obj;
Log.i("test", "INIT:" + fileInfo.toString());
// 獲取FileInfo對象,開始下載任務
mTask = new DownloadTask(DownloadService.this, fileInfo);
mTask.download();
break;
}
};
};
InitThread內部類在完成初始化線程之后,將fileInfo傳遞給Handler,代碼如下:
// 初始化下載綫程,獲得下載文件的信息
class InitThread extends Thread {
private FileInfo mFileInfo = null;
public InitThread(FileInfo mFileInfo) {
super();
this.mFileInfo = mFileInfo;
}
@Override
public void run() {
HttpURLConnection conn = null;
RandomAccessFile raf = null;
try {
URL url = new URL(mFileInfo.getUrl());
conn = (HttpURLConnection) url.openConnection();
conn.setConnectTimeout(5 * 1000);
conn.setRequestMethod("GET");
int code = conn.getResponseCode();
int length = -1;
if (code == HttpURLConnection.HTTP_OK) {
length = conn.getContentLength();
}
//如果文件長度為小于0,表示獲取文件失敗,直接返回
if (length <= 0) {
return;
}
// 判斷文件路徑是否存在,不存在這創建
File dir = new File(DownloadPath);
if (!dir.exists()) {
dir.mkdir();
}
// 創建本地文件
File file = new File(dir, mFileInfo.getFileName());
raf = new RandomAccessFile(file, "rwd");
raf.setLength(length);
// 設置文件長度
mFileInfo.setLength(length);
// 將FileInfo對象傳遞給Handler
Message msg = Message.obtain();
msg.obj = mFileInfo;
msg.what = MSG_INIT;
mHandler.sendMessage(msg);
msg.setTarget(mHandler);
} catch (Exception e) {
e.printStackTrace();
} finally {
conn.disconnect();
try {
raf.close();
} catch (IOException e) {
e.printStackTrace();
}
}
super.run();
}
}
然后修改onStartConnand方法,在點擊開啟服務的時候初始化線程
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
// 獲得Activity穿來的參數
if (ACTION_START.equals(intent.getAction())) {
FileInfo fileInfo = (FileInfo) intent.getSerializableExtra("fileInfo");
Log.i("test", "START" + fileInfo.toString());
new InitThread(fileInfo).start();
} else if (ACTION_STOP.equals(intent.getAction())) {
FileInfo fileInfo = (FileInfo) intent.getSerializableExtra("fileInfo");
Log.i("test", "STOP" + fileInfo.toString());
}
return super.onStartCommand(intent, flags, startId);
}
開啟下載任務
終于輪到真正的下載任務了,這就是最后一步了。
在之前的代碼中,即使用Handler接收InitThread中傳遞過來的fileInfo對象時,有一段代碼還沒有實現,這段代碼是正在的下載邏輯:
// 獲取FileInfo對象,開始下載任務
mTask = new DownloadTask(DownloadService.this, fileInfo);
mTask.download();
現在我們開始實現DownloadTask下載任務這個類吧。
DownloadTask類有以下成員變量:
private Context mComtext = null;
private FileInfo mFileInfo = null;
private ThreadDAO mDao = null;
private int mFinished = 0;
public boolean mIsPause = false;
mComtext就不做介紹了,mFileInfo是封裝了下載文件的信息對象;
mDAO是對數據庫進行操作的工具類,它將會引用實現了它的接口的ThreadDAOImple類。
mFinished用于臨時存儲文件下載的進度。
mIsPause則用于判斷文件是否在下載狀態又或者停止狀態。
設定好成員變量,在創建DownloadTask的構造函數,將成員變量初始化
public DownloadTask(Context comtext, FileInfo fileInfo) {
super();
this.mComtext = comtext;
this.mFileInfo = fileInfo;
this.mDao = new ThreadDAOImple(mComtext);
}
下面開始的便是下載線程的代碼實現,將之前的代碼原理搬過來,改一改就好了,這里還是展示給大家看吧,代碼如下:
class DownloadThread extends Thread {
private ThreadInfo threadInfo = null;
public DownloadThread(ThreadInfo threadInfo) {
super();
this.threadInfo = threadInfo;
}
@Override
public void run() {
// 如果數據庫不存在下載信息,添加下載信息
if (!mDao.isExists(threadInfo.getUrl(), threadInfo.getId())) {
mDao.insertThread(threadInfo);
}
HttpURLConnection conn = null;
RandomAccessFile raf = null;
InputStream is = null;
try {
URL url = new URL(mFileInfo.getUrl());
conn = (HttpURLConnection) url.openConnection();
conn.setConnectTimeout(5 * 1000);
conn.setRequestMethod("GET");
int start = threadInfo.getStart() + threadInfo.getFinished();
// 設置下載文件開始到結束的位置
conn.setRequestProperty("Range", "bytes=" + start + "-" + threadInfo.getEnd());
File file = new File(DownloadService.DownloadPath, mFileInfo.getFileName());
raf = new RandomAccessFile(file, "rwd");
raf.seek(start);
mFinished += threadInfo.getFinished();
int code = conn.getResponseCode();
if (code == HttpURLConnection.HTTP_PARTIAL) {
is = conn.getInputStream();
byte[] bt = new byte[1024];
int len = -1;
// 定義UI刷新時間
long time = System.currentTimeMillis();
while ((len = is.read(bt)) != -1) {
raf.write(bt, 0, len);
mFinished += len;
// 設置爲500毫米更新一次
if (System.currentTimeMillis() - time > 500) {
time = System.currentTimeMillis();
Intent intent = new Intent(DownloadService.ACTION_UPDATE);
intent.putExtra("finished", mFinished * 100 / mFileInfo.getLength());
Log.i("test", mFinished * 100 / mFileInfo.getLength() + "");
// 發送廣播給Activity
mComtext.sendBroadcast(intent);
}
if (mIsPause) {
mDao.updateThread(threadInfo.getUrl(), threadInfo.getId(), mFinished);
return;
}
}
}
// 下載完成后,刪除數據庫信息
mDao.deleteThread(threadInfo.getUrl(), threadInfo.getId());
} catch (Exception e) {
e.printStackTrace();
} finally {
conn.disconnect();
try {
is.close();
raf.close();
} catch (IOException e) {
e.printStackTrace();
}
}
super.run();
}
}
在這段代碼中我們還要在下載的時候發送廣播給Activity,用于更新Progressbar的進度,但更新不易過頻,以免影響UI效果,所以設置為每500毫米更新一下,根據系統時間設定。
我們在下載邏輯中,還要判斷當前下載的文件是否在數據庫中存在,如果不存在就添加,如果存在就要從數據庫中獲取當前下載位置,然后繼續下載,所以增加以下方法:
public void download(){
// 從數據庫中獲取到下載的信息
List<ThreadInfo> list = mDao.queryThreads(mFileInfo.getUrl());
ThreadInfo info = null;
if (list.size() == 0) {
info = new ThreadInfo(0, mFileInfo.getUrl(), 0, mFileInfo.getLength(), 0);
}else{
info= list.get(0);
}
new DownloadThread(info).start();
}
好了,整個的下載任務類已經完成了,下面我們繼續完善我們的代碼吧。
完善Service和MainActivity代碼
在之前,我們雖然把初始化下載線程InitThread寫好了,然后通過初始化線程獲取FileInfo對象,將其傳遞給Handler,在Handler中開啟真正的下載任務。但是當時并沒有調用這個InitThread類,現在再次修改DownloadService中的onStartCommand方法來啟動InitThread任務吧
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
// 獲得Activity穿來的參數
if (ACTION_START.equals(intent.getAction())) {
FileInfo fileInfo = (FileInfo) intent.getSerializableExtra("fileInfo");
Log.i("test", "START" + fileInfo.toString());
new InitThread(fileInfo).start();
} else if (ACTION_STOP.equals(intent.getAction())) {
FileInfo fileInfo = (FileInfo) intent.getSerializableExtra("fileInfo");
Log.i("test", "STOP" + fileInfo.toString());
if (mTask != null) {
mTask.mIsPause = true;
}
}
return super.onStartCommand(intent, flags, startId);
}
我們通過MainActivity的點擊事件開啟服務,如果Intent中傳過來的值為ACTION_START的時候,開啟初始化線程,獲得FileInfo對象,然后將其傳遞給Handler啟動下載任務。
如果MainActivity傳遞過來的值為ACTION_STOP,就判斷當前是否有下載任務,如果有下載任務,就將DownloadTask中的成員變量mIsPause設置為true,這時就更新數據庫中的下載進度了。
然后我們在修改MainActivity中代碼,添加一個廣播接收者的內部類,它接收從DownloadTask中傳過來的廣播--下載進度,然后實時更新ProgressBar。代碼如下:
// 從DownloadTadk中獲取廣播信息,更新進度條
class UIRecive extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
if (DownloadService.ACTION_UPDATE.equals(intent.getAction())) {
int finished = intent.getIntExtra("finished", 0);
downloadProgress.setProgress(finished);
}
}
}
這是一個自己手動撰寫的廣播,因此需要動態注冊,在MainActivity中的創建一個成員變量廣播接收者對象mRecive,在onCreate方法注冊廣播接收者:
// 從DownloadTadk中獲取廣播信息,更新進度條
class UIRecive extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
if (DownloadService.ACTION_UPDATE.equals(intent.getAction())) {
int finished = intent.getIntExtra("finished", 0);
downloadProgress.setProgress(finished);
}
}
}
最后不要忘記在Activity的onDestry方法中注銷廣播!
最后的最后就是千萬記得在Androidmainfest中獲取網絡、存儲卡讀取/寫入權限等等。
總結
這是一個單線程的斷點續傳下載,也僅僅是一個下載DEMO,但是它包含了Android中各種組件的混合使用,對于Android新手全面的了解Android項目很有好處。
但是要注意的是這個項目仍然有許多BUG等著大家自己去修復,如在下載的時候再次點擊下載,你會發現又多了一個新的下載線程,導致進度條跳來跳去。解決這個問題也簡單,只需要在開啟之前判斷一下是否已經有這個文件了,如果有就直接跳。
其次還有個bug,那就是在本項目中存儲進度的數據類型是int類型,如果你說下載的文件過大,如超過30M的時候,你會發現你的進度條下載到一半就消失了。這是因為下載數據超過int的數據范圍,導致內存泄漏。這個問題只需要將數據類型修改為long類型就好了。
但是,不管怎么說這是一個很好的練習項目。
最后做一下自來水,這個項目在慕課網中有教程,哈哈。
如果有哪位大神看到本文有什么錯誤之處,還請不吝賜教~~
本文的DEMO示例github下載地址:
https://github.com/liaozhoubei/Download
本文后續多線程下載器:http://www.lxweimin.com/p/c23b0c10c919