(泥煤...实习还是没找落...)最近看了看关于文件断点续传的功能 ,觉得十分有用 。原来在java上就有想过,但是因为在网站上有相关的下载工具,只要允许断点续传就可以实现这个功能,但是由于android的下载一般是基于c/s结构的系统设定,所以有必要还是自己写一下这个功能的。
实现断点续传最关键的就是RandomAccessFile这个类。他可以自由的选择读取文件的位置,这就使断点续传成为了可能。基本思想就是每次下载的时候把每个文件和下载线程的信息存储到数据库中,当然了最重要的就是没完下载的文件的下载的位置,然后通过RandomAccessFile的seek()方法就可以找到了,然后接着download就搞定了。(ps貌似这个类在JDK1.4之后就被nio的内存映射文件给取代了,还没做研究..以后有机会试试)
具体代码如下:
项目结构:
DownloadActivity:
package com.down;
import java.io.File;
import com.down.downloader.DownloadProgressListener;
import com.down.downloader.FileDownloader;
import android.app.Activity;
import android.os.Bundle;
import android.os.Environment;
import android.os.Handler;
import android.os.Message;
import android.view.View;
import android.view.View.OnClickListener;
import android.widget.Button;
import android.widget.ProgressBar;
import android.widget.TextView;
import android.widget.Toast;
public class DownActivity extends Activity {
private static final int PROCESSING = 1;
private static final int FAILURE = -1;
private TextView txtView;
private Button btnDown;
private Button btnPause;
private ProgressBar progressBar;
private Handler handler = new UIHander();
private final class UIHander extends Handler {
@Override
public void handleMessage(Message msg) {
// TODO Auto-generated method stub
switch (msg.what) {
case PROCESSING:
int size = msg.getData().getInt("size");
progressBar.setProgress(size);
float num = (float) progressBar.getProgress()
/ progressBar.getMax();
int result = (int) (num * 100);
txtView.setText(result + "%");
if (progressBar.getProgress() == progressBar.getMax()) {
Toast.makeText(getApplicationContext(), "下载成功",
Toast.LENGTH_LONG).show();
}
break;
case -1:
Toast.makeText(getApplicationContext(), "下载失败",
Toast.LENGTH_LONG).show();
break;
default:
break;
}
super.handleMessage(msg);
}
}
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.main);
btnDown = (Button) findViewById(R.id.ButtonDown);
btnPause = (Button) findViewById(R.id.ButtonPause);
txtView = (TextView) findViewById(R.id.TextView01);
progressBar = (ProgressBar) findViewById(R.id.ProgressBar01);
btnDown.setOnClickListener(l);
btnPause.setOnClickListener(l);
}
private final class DownloadTask implements Runnable {
private String path;
private File saveDir;
private FileDownloader loader;
public DownloadTask(String path, File saveDir) {
this.path = path;
this.saveDir = saveDir;
}
public void exit() {
if (loader != null)
loader.exit();
}
DownloadProgressListener downloadProgressListener = new DownloadProgressListener() {
@Override
public void onDownloadSize(int size) {
// TODO Auto-generated method stub
Message msg = new Message();
msg.what = PROCESSING;
msg.getData().putInt("size", size);
handler.sendMessage(msg);
}
};
@Override
public void run() {
// TODO Auto-generated method stub
try {
loader = new FileDownloader(getApplicationContext(), path,
saveDir, 3);
progressBar.setMax(loader.getFileSize());
loader.donwload(downloadProgressListener);
} catch (Exception e) {
handler.sendMessage(handler.obtainMessage(FAILURE));
e.printStackTrace();
}
}
}
private void download(String path, File saveDir) {
task = new DownloadTask(path, saveDir);
new Thread(task).start();
}
private DownloadTask task;
public void exit() {
if (task != null) {
task.exit();
}
}
private OnClickListener l = new OnClickListener() {
@Override
public void onClick(View v) {
// TODO Auto-generated method stub
if (v == btnDown) {
String path = "http://10.0.2.2:8080/server/a.exe";
if (Environment.getExternalStorageState().equals(
Environment.MEDIA_MOUNTED)) {
File saveDir = Environment.getExternalStorageDirectory();
download(path, saveDir);
} else {
Toast.makeText(getApplicationContext(), "SDCard不存在",
Toast.LENGTH_LONG);
}
btnDown.setEnabled(false);
btnPause.setEnabled(true);
}
if (v == btnPause) {
exit();
btnDown.setEnabled(true);
btnPause.setEnabled(false);
}
}
};
}
DBopenHelper:
package com.down.db;
import android.content.Context;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteDatabase.CursorFactory;
import android.database.sqlite.SQLiteOpenHelper;
public class DBOpenHelper extends SQLiteOpenHelper {
private static final String dbName = "eric.db";
private static final int VERTION = 1;
public DBOpenHelper(Context context, String name, CursorFactory factory,
int version) {
super(context, name, factory, version);
// TODO Auto-generated constructor stub
}
public DBOpenHelper(Context context) {
this(context, dbName, null, VERTION);
// TODO Auto-generated constructor stub
}
@Override
public void onCreate(SQLiteDatabase db)
{ // TODO Auto-generated method stub
db.execSQL("create table if not exists filedownlog(id integer primary key autoincrement,downpath varchar(100),threadid integer,downlength integer)");
}
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion)
{ // TODO Auto-generated method stub
db.execSQL("drop table if exists filedownlog");
this.onCreate(db);
}
}
DownloadProgressListener:
package com.down.downloader;
public interface DownloadProgressListener {
public void onDownloadSize(int size);
}
FileDownloader:
package com.down.downloader;
import java.io.File;
import java.io.RandomAccessFile;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import com.down.service.FileService;
import com.down.thread.DownloadThread;
import android.content.Context;
import android.text.Html.TagHandler;
import android.util.Log;
public class FileDownloader
{
private static final int RESPONSEOK=200;
private Context context;
private FileService fileService;
private boolean exited;
private int fileSize=0;
private int downloadedSize;
private DownloadThread []threads;
private File saveFile;
private Map<Integer,Integer> data=new ConcurrentHashMap<Integer, Integer>();
private int block;
private String downloadUrl;
public int getThreadSize()
{
return threads.length;
}
public void exit()
{
this.exited=true;
}
public boolean getExited()
{
return this.exited;
}
public int getFileSize()
{
return fileSize;
}
public synchronized void append(int size) {
downloadedSize+=size;
}
public synchronized void update(int threadid,int pos)
{
this.data.put(threadid, pos);
this.fileService.update(this.downloadUrl, threadid, pos);
}
/*
* 此构造方法主要实现获取下载数据的大小及判断是否已经下载,若已经下载,把各个线程总下载大小计算出来
* */
public FileDownloader(Context context,String downloadUrl,File fileSaveDir,int threadNum)
{
try
{
this.context=context;
this.downloadUrl=downloadUrl;
fileService=new FileService(context);
URL url=new URL(this.downloadUrl);
if(!fileSaveDir.exists())
fileSaveDir.mkdir();
this.threads=new DownloadThread[threadNum];
HttpURLConnection conn=(HttpURLConnection)url.openConnection();
conn.setConnectTimeout(5*1000);
conn.setRequestMethod("GET");
conn.setRequestProperty("Accept", "image/gif,image/jpeg,image/pjpeg,application/x-xpsdocument,application/xaml+xml,application/vnd.ms-xpsdocument,application/x-ms-xbap,application/x-ms-xbap,application/x-ms-application,application/vnd.ms-excel");
conn.setRequestProperty("Accept-Language", "zh-CN");
conn.setRequestProperty("Referer", downloadUrl);
conn.setRequestProperty("Charset", "UTF-8");
conn.setRequestProperty("User-Agent", "Mozilla/4.0");
conn.setRequestProperty("Connection", "Keep-Alive");
conn.connect();
if(conn.getResponseCode()==RESPONSEOK)
{
this.fileSize=conn.getContentLength();
if(this.fileSize<=0) throw new RuntimeException("unkown file size");
String filename=getFileName(conn);
//保存路径
this.saveFile=new File(fileSaveDir,filename);
//查询数据库是否有本url的下载数据
Map<Integer,Integer> logdata=fileService.getData(downloadUrl);
if(logdata.size()>0)
{
for(Map.Entry<Integer, Integer> entry:logdata.entrySet())
{
data.put(entry.getKey(), entry.getValue());
}
}
//计算已经下载的大小
if(this.data.size()==this.threads.length)
{
for(int i=0;i<this.threads.length;i++)
{
this.downloadedSize+=this.data.get(i+1);
}
print("已经下载的长度"+this.downloadedSize+"个字节");
}
//获取每个线程取多少字节的数据
this.block=(this.fileSize%this.threads.length)==0?this.fileSize/this.threads.length:this.fileSize/this.threads.length+1;
}
else
{
print("服务器响应错误:"+conn.getResponseCode()+""+conn.getResponseMessage());
}
}
catch(Exception e)
{
e.printStackTrace();
}
}
public static void print(String msg)
{
Log.i("INFO", msg);
}
private String getFileName(HttpURLConnection conn)
{
String filename=this.downloadUrl.substring(this.downloadUrl.lastIndexOf('/')+1);
if(filename==null || "".equals(filename.trim()))
{
for(int i=0;;i++)
{
String mine=conn.getHeaderField(i);
if(mine==null)break;
if("content-disposition".equals(conn.getHeaderFieldKey(i).toLowerCase()))
{
Matcher m=Pattern.compile(".*filename=(.*)").matcher(mine.toLowerCase());
if(m.find()) return m.group(1);
}
}
filename=UUID.randomUUID()+".tmp";
}
return filename;
}
/*
* 此方法实现下载判断,对已经下载的资源,重新创建线程,继续下载,对没有下载的资源启动线程开始下载,同时,启动死循环,获取各个线程的下载进度
* */
public int donwload(DownloadProgressListener listener)
{
try
{
//创建随机读写流
RandomAccessFile randOut=new RandomAccessFile(this.saveFile, "rwd");
if(this.fileSize>0)
{
//设置此文件的总大小
randOut.setLength(this.fileSize);
}
//下载地址
URL url=new URL(this.downloadUrl);
//如果数据库中的下载信息不等于线程数,则是新的下载任务,把各个线程的线程编号存入data中,并把总共大小设置为0
if(this.data.size()!=this.threads.length)
{
this.data.clear();
for(int i=0;i<this.threads.length;i++)
{
this.data.put(i+1, 0);
}
this.downloadedSize=0;
}
//如果数据中有此url的下载信息,启动线程,并开始下载
for(int i=0;i<this.threads.length;i++)
{
int donwloadLength=this.data.get(i+1);
if(downloadedSize<this.block&&this.downloadedSize<this.fileSize)
{
this.threads[i]=new DownloadThread(this,url,this.saveFile,this.block,this.data.get(i+1),i+1);
this.threads[i].setPriority(7);
this.threads[i].start();
}
else
{
this.threads[i]=null;
}
}
//删除已有的下载信息
fileService.delete(this.downloadUrl);
//保存下载信息
fileService.save(this.downloadUrl,this.data);
boolean notFinished=true;
while(notFinished)
{
Thread.sleep(900);
notFinished=false;
for(int i=0;i<this.threads.length;i++)
{
if(this.threads[i]!=null&&!this.threads[i].isFinished()){
notFinished=true;
if(this.threads[i].getDownloadedLength()==-1)
{
this.threads[i]=new DownloadThread(this,url,this.saveFile,this.block,this.data.get(i+1),i+1);
this.threads[i].setPriority(7);
this.threads[i].start();
}
}
}
if(listener!=null)
listener.onDownloadSize(this.downloadedSize);
}
}
catch(Exception e)
{
e.printStackTrace();
}
return this.downloadedSize;
}
public static Map<String,String> getHttpReponseHeader(HttpURLConnection http)
{
Map<String,String> header=new LinkedHashMap<String, String>();
for(int i=0;;i++)
{
String fieldValue=http.getHeaderField(i);
if(fieldValue==null)break;
header.put(http.getHeaderFieldKey(i), fieldValue);
}
return header;
}
public static void printHttpReponseHeader(HttpURLConnection http)
{
Map<String,String> header=getHttpReponseHeader(http);
for(Map.Entry<String, String> entry:header.entrySet())
{
String key=entry.getKey()!=null?entry.getKey()+"":"";
print(key+entry.getValue());
}
}
}
FileService:
package com.down.downloader;
import java.io.File;
import java.io.RandomAccessFile;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import com.down.service.FileService;
import com.down.thread.DownloadThread;
import android.content.Context;
import android.text.Html.TagHandler;
import android.util.Log;
public class FileDownloader
{
private static final int RESPONSEOK=200;
private Context context;
private FileService fileService;
private boolean exited;
private int fileSize=0;
private int downloadedSize;
private DownloadThread []threads;
private File saveFile;
private Map<Integer,Integer> data=new ConcurrentHashMap<Integer, Integer>();
private int block;
private String downloadUrl;
public int getThreadSize()
{
return threads.length;
}
public void exit()
{
this.exited=true;
}
public boolean getExited()
{
return this.exited;
}
public int getFileSize()
{
return fileSize;
}
public synchronized void append(int size) {
downloadedSize+=size;
}
public synchronized void update(int threadid,int pos)
{
this.data.put(threadid, pos);
this.fileService.update(this.downloadUrl, threadid, pos);
}
/*
* 此构造方法主要实现获取下载数据的大小及判断是否已经下载,若已经下载,把各个线程总下载大小计算出来
* */
public FileDownloader(Context context,String downloadUrl,File fileSaveDir,int threadNum)
{
try
{
this.context=context;
this.downloadUrl=downloadUrl;
fileService=new FileService(context);
URL url=new URL(this.downloadUrl);
if(!fileSaveDir.exists())
fileSaveDir.mkdir();
this.threads=new DownloadThread[threadNum];
HttpURLConnection conn=(HttpURLConnection)url.openConnection();
conn.setConnectTimeout(5*1000);
conn.setRequestMethod("GET");
conn.setRequestProperty("Accept", "image/gif,image/jpeg,image/pjpeg,application/x-xpsdocument,application/xaml+xml,application/vnd.ms-xpsdocument,application/x-ms-xbap,application/x-ms-xbap,application/x-ms-application,application/vnd.ms-excel");
conn.setRequestProperty("Accept-Language", "zh-CN");
conn.setRequestProperty("Referer", downloadUrl);
conn.setRequestProperty("Charset", "UTF-8");
conn.setRequestProperty("User-Agent", "Mozilla/4.0");
conn.setRequestProperty("Connection", "Keep-Alive");
conn.connect();
if(conn.getResponseCode()==RESPONSEOK)
{
this.fileSize=conn.getContentLength();
if(this.fileSize<=0) throw new RuntimeException("unkown file size");
String filename=getFileName(conn);
//保存路径
this.saveFile=new File(fileSaveDir,filename);
//查询数据库是否有本url的下载数据
Map<Integer,Integer> logdata=fileService.getData(downloadUrl);
if(logdata.size()>0)
{
for(Map.Entry<Integer, Integer> entry:logdata.entrySet())
{
data.put(entry.getKey(), entry.getValue());
}
}
//计算已经下载的大小
if(this.data.size()==this.threads.length)
{
for(int i=0;i<this.threads.length;i++)
{
this.downloadedSize+=this.data.get(i+1);
}
print("已经下载的长度"+this.downloadedSize+"个字节");
}
//获取每个线程取多少字节的数据
this.block=(this.fileSize%this.threads.length)==0?this.fileSize/this.threads.length:this.fileSize/this.threads.length+1;
}
else
{
print("服务器响应错误:"+conn.getResponseCode()+""+conn.getResponseMessage());
}
}
catch(Exception e)
{
e.printStackTrace();
}
}
public static void print(String msg)
{
Log.i("INFO", msg);
}
private String getFileName(HttpURLConnection conn)
{
String filename=this.downloadUrl.substring(this.downloadUrl.lastIndexOf('/')+1);
if(filename==null || "".equals(filename.trim()))
{
for(int i=0;;i++)
{
String mine=conn.getHeaderField(i);
if(mine==null)break;
if("content-disposition".equals(conn.getHeaderFieldKey(i).toLowerCase()))
{
Matcher m=Pattern.compile(".*filename=(.*)").matcher(mine.toLowerCase());
if(m.find()) return m.group(1);
}
}
filename=UUID.randomUUID()+".tmp";
}
return filename;
}
/*
* 此方法实现下载判断,对已经下载的资源,重新创建线程,继续下载,对没有下载的资源启动线程开始下载,同时,启动死循环,获取各个线程的下载进度
* */
public int donwload(DownloadProgressListener listener)
{
try
{
//创建随机读写流
RandomAccessFile randOut=new RandomAccessFile(this.saveFile, "rwd");
if(this.fileSize>0)
{
//设置此文件的总大小
randOut.setLength(this.fileSize);
}
//下载地址
URL url=new URL(this.downloadUrl);
//如果数据库中的下载信息不等于线程数,则是新的下载任务,把各个线程的线程编号存入data中,并把总共大小设置为0
if(this.data.size()!=this.threads.length)
{
this.data.clear();
for(int i=0;i<this.threads.length;i++)
{
this.data.put(i+1, 0);
}
this.downloadedSize=0;
}
//如果数据中有此url的下载信息,启动线程,并开始下载
for(int i=0;i<this.threads.length;i++)
{
int donwloadLength=this.data.get(i+1);
if(downloadedSize<this.block&&this.downloadedSize<this.fileSize)
{
this.threads[i]=new DownloadThread(this,url,this.saveFile,this.block,this.data.get(i+1),i+1);
this.threads[i].setPriority(7);
this.threads[i].start();
}
else
{
this.threads[i]=null;
}
}
//删除已有的下载信息
fileService.delete(this.downloadUrl);
//保存下载信息
fileService.save(this.downloadUrl,this.data);
boolean notFinished=true;
while(notFinished)
{
Thread.sleep(900);
notFinished=false;
for(int i=0;i<this.threads.length;i++)
{
if(this.threads[i]!=null&&!this.threads[i].isFinished()){
notFinished=true;
if(this.threads[i].getDownloadedLength()==-1)
{
this.threads[i]=new DownloadThread(this,url,this.saveFile,this.block,this.data.get(i+1),i+1);
this.threads[i].setPriority(7);
this.threads[i].start();
}
}
}
if(listener!=null)
listener.onDownloadSize(this.downloadedSize);
}
}
catch(Exception e)
{
e.printStackTrace();
}
return this.downloadedSize;
}
public static Map<String,String> getHttpReponseHeader(HttpURLConnection http)
{
Map<String,String> header=new LinkedHashMap<String, String>();
for(int i=0;;i++)
{
String fieldValue=http.getHeaderField(i);
if(fieldValue==null)break;
header.put(http.getHeaderFieldKey(i), fieldValue);
}
return header;
}
public static void printHttpReponseHeader(HttpURLConnection http)
{
Map<String,String> header=getHttpReponseHeader(http);
for(Map.Entry<String, String> entry:header.entrySet())
{
String key=entry.getKey()!=null?entry.getKey()+"":"";
print(key+entry.getValue());
}
}
}
package com.down.service;
import java.util.HashMap;
import java.util.Map;
import com.down.db.DBOpenHelper;
import android.content.Context;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
public class FileService
{
private Context c;
DBOpenHelper openHelper;
public FileService(Context c)
{
this.c=c;
openHelper=new DBOpenHelper(c);
}
public Map<Integer, Integer> getData(String path)
{
SQLiteDatabase db=openHelper.getReadableDatabase();
Cursor cursor=db.rawQuery("select threadid,downlength from filedownlog where downpath=?", new String[]{path});
Map<Integer, Integer> data=new HashMap<Integer, Integer>();
while(cursor.moveToNext())
{
//data.put(cursor.getInt(0), cursor.getInt(1));
data.put(cursor.getInt(cursor.getColumnIndexOrThrow("threadid")), cursor.getInt(cursor.getColumnIndexOrThrow("downlength")));
}
cursor.close();
db.close();
return data;
}
public void save(String path,Map<Integer,Integer> map)
{
SQLiteDatabase db=openHelper.getWritableDatabase();;
db.beginTransaction();
try
{
for(Map.Entry<Integer, Integer> entry:map.entrySet())
{
db.execSQL("insert into filedownlog(downpath,threadid,downlength) values(?,?,?)",new Object[]{path,entry.getKey(),entry.getValue()});
}
db.setTransactionSuccessful();
}
finally
{
db.endTransaction();
}
db.close();
}
public void update(String path,int threadid,int pos)
{
SQLiteDatabase db=openHelper.getWritableDatabase();
db.execSQL("update filedownlog set downlength=? where downpath=? and threadid=?",new Object[]{pos,path,threadid});
db.close();
}
public void delete(String path)
{
SQLiteDatabase db=openHelper.getWritableDatabase();
db.execSQL("delete from filedownlog where downpath=?", new Object[]{path});
db.close();
}
}
DownLoadThread:
package com.down.thread;
import java.io.File;
import java.io.InputStream;
import java.io.RandomAccessFile;
import java.net.HttpURLConnection;
import java.net.URL;
import android.util.Printer;
import com.down.downloader.FileDownloader;
public class DownloadThread extends Thread
{
private boolean finished=false;
private int downloadedLength;
private static final String TAG="DonwloadTHread";
private File saveFile;
private URL downUrl;
private int block;
private int threadid=-1;
private FileDownloader downloader;
public DownloadThread(FileDownloader downloader,URL downUrl,File saveFile,int block,int downloadedLength,int threadid){
this.downUrl=downUrl;
this.saveFile=saveFile;
this.block=block;
this.downloader=downloader;
this.threadid=threadid;
this.downloadedLength=downloadedLength;
}
@Override
public void run() {
if(downloadedLength<block)
{
try
{
HttpURLConnection http=(HttpURLConnection)downUrl.openConnection();
http.setConnectTimeout(5*1000);
http.setRequestMethod("GET");
http.setRequestProperty("Accept", "image/gif,image/jpeg,image/pjpeg,image/pjpeg,application/x-shockwave-flash,application/xaml+xml,application/vnd.ms-xpsdocument,application/x-ms-xbap,application/x-ms-application,application/vnd.ms-excel,application/vnd.ms-powerpoint,application/msword,*/*");
http.setRequestProperty("Accept-Language", "zh-CN");
http.setRequestProperty("Referer", downUrl.toString());
http.setRequestProperty("Charset", "UTF-8");
int startPos=block*(threadid-1)+downloadedLength;
int endPos=block*threadid-1;
http.setRequestProperty("Range", "bytes="+startPos+"-"+endPos);
http.setRequestProperty("User-Agent", "Mozilla/4.0(compatible;MSIE 8.0;Windows NT 5.2;Trident/4.0;.NETCLR 1.1.4322;.NET CLR 2.0.50727;.NET CLR3.0.04506.30;.NET CLR 3.0.4506.2152;.NET CLR 3.5.30729)");
http.setRequestProperty("Connection", "Keep-Alive");
InputStream inStream=http.getInputStream();
byte[]buffer=new byte[1024];
int offset=0;
RandomAccessFile threadFile=new RandomAccessFile(this.saveFile, "rwd");
threadFile.seek(startPos);
while(!downloader.getExited()&&(offset=inStream.read(buffer,0,1024))!=-1)
{
threadFile.write(buffer, 0, offset);
downloadedLength+=offset;
downloader.update(this.threadid, downloadedLength);
downloader.append(offset);
}
threadFile.close();
if(downloader.getExited())
{
System.out.println("Thread"+this.threadid+" has bean pauseed");
}
else
{
System.out.println("Thread"+this.threadid+" has finish");
}
this.finished=true;
}
catch(Exception e)
{
e.printStackTrace();
}
}
}
public int getDownloadedLength() {
return downloadedLength;
}
public void setDownloadedLength(int downloadedLength) {
this.downloadedLength = downloadedLength;
}
public boolean isFinished() {
return finished;
}
public void setFinished(boolean finished) {
this.finished = finished;
}
}
效果图:
layout文件就很简单了...这里就不贴了
我的资源有源代码 需要的可以下来看看 但是需要点分 哈哈 互相帮助啦~
资源地址:资源传送门