一.DownloadManager的介绍 1.Android涉及到的网络数据请求,如果是零星数据、且数据量较小(几十KB到几百KB,1MB以内),一般的,可以自己使用Android原生HTTP或者第三方开源框架如Volley 2.如果下载数据大,几MB到几百MB甚至GB量级的数据,这种情况下载任务必然耗时,并且极可能需要断点续传,典型的,如现在很多手机应用市场APP,给用户提供多任务下载APP安装文件到本地的功能,而这些APP小则几MB大则上百MB,那么这种场景就应该考虑使用Android DownloadManager 3.Android DownloadManager就是为了支持大数据、断点续传这些下载任务而设计的public class MainActivity extends Activity {private DownloadManager downloadManager;private long Id;@Overrideprotected void onCreate(Bundle savedInstanceState) {super.onCreate(savedInstanceState);setContentView(R.layout.activity_main);Button button = (Button) findViewById(R.id.button);button.setOnClickListener(new View.OnClickListener() {@Overridepublic void onClick(View v) {// remove将依据Id号取消相应的下载任务// 可批量取消,remove(id1,id2,id3,id4,...);downloadManager.remove(Id);}});downloadManager = (DownloadManager) getSystemService(Context.DOWNLOAD_SERVICE);// 假设从这一个链接下载一个大文件。Request request = new Request(Uri.parse("http://apkc.mumayi.com/2015/03/06/92/927937/xingxiangyi_V3.1.3_mumayi_00169.apk"));// 仅允许在WIFI连接情况下下载request.setAllowedNetworkTypes(Request.NETWORK_WIFI);// 通知栏中将出现的内容request.setTitle("我的下载");request.setDescription("下载一个大文件");// 下载过程和下载完成后通知栏有通知消息。request.setNotificationVisibility(Request.VISIBILITY_VISIBLE | Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED);// 此处可以由开发者自己指定一个文件存放下载文件。// 如果不指定则Android将使用系统默认的// request.setDestinationUri(Uri.fromFile(new File("")));// 默认的Android系统下载存储目录request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, "my.apk");// enqueue 开始启动下载...Id = downloadManager.enqueue(request);}}添加权限:二.Android 多线程断点下载的代码流程解析Step1:创建一个用来记录线程下载信息的表DBOpenHelper.java:public class DBOpenHelper extends SQLiteOpenHelper { public DBOpenHelper(Context context) { super(context, "downs.db", null, 1); } @Override public void onCreate(SQLiteDatabase db) { //数据库的结构为:表名:filedownlog 字段:id,downpath:当前下载的资源, //threadid:下载的线程id,downlength:线程下载的最后位置 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) { //当版本号发生改变时调用该方法,这里删除数据表,在实际业务中一般是要进行数据备份的 db.execSQL("DROP TABLE IF EXISTS filedownlog"); onCreate(db); }}Step2:创建一个数据库操作类 1.我们需要一个根据URL获得每条线程当前下载长度的方法 2.接着,当我们的线程新开辟后,我们需要往数据库中插入与该线程相关参数的方法 3.还要定义一个可以实时更新下载文件长度的方法 4.我们线程下载完,还需要根据线程id,删除对应记录的方法FileService.java:/* * 该类是一个业务bean类,完成数据库的相关操作 * */public class FileService { //声明数据库管理器 private DBOpenHelper openHelper; //在构造方法中根据上下文对象实例化数据库管理器 public FileService(Context context) { openHelper = new DBOpenHelper(context); } /** * 获得指定URI的每条线程已经下载的文件长度 * @param path * @return * */ public MapgetData(String path) { //获得可读数据库句柄,通常内部实现返回的其实都是可写的数据库句柄 SQLiteDatabase db = openHelper.getReadableDatabase(); //根据下载的路径查询所有现场的下载数据,返回的Cursor指向第一条记录之前 Cursor cursor = db.rawQuery("select threadid, downlength from filedownlog where downpath=?", new String[]{path}); //建立一个哈希表用于存放每条线程已下载的文件长度 Mapdata = new HashMap(); //从第一条记录开始遍历Cursor对象 cursor.moveToFirst(); while(cursor.moveToNext()) { //把线程id与该线程已下载的长度存放到data哈希表中 data.put(cursor.getInt(0), cursor.getInt(1)); data.put(cursor.getInt(cursor.getColumnIndexOrThrow("threadid")), cursor.getInt(cursor.getColumnIndexOrThrow("downlength"))); } cursor.close();//关闭cursor,释放资源; db.close(); return data; } /** * 保存每条线程已经下载的文件长度 * @param path 下载的路径 * @param map 现在的di和已经下载的长度的集合 */ public void save(String path,Mapmap) { SQLiteDatabase db = openHelper.getWritableDatabase(); //开启事务,因为此处需要插入多条数据 db.beginTransaction(); try{ //使用增强for循环遍历数据集合 for(Map.Entryentry : map.entrySet()) { //插入特定下载路径特定线程ID已经下载的数据 db.execSQL("insert into filedownlog(downpath, threadid, downlength) values(?,?,?)", new Object[]{path, entry.getKey(), entry.getValue()}); } //设置一个事务成功的标志,如果成功就提交事务,如果没调用该方法的话那么事务回滚 //就是上面的数据库操作撤销 db.setTransactionSuccessful(); }finally{ //结束一个事务 db.endTransaction(); } db.close(); } /** * 实时更新每条线程已经下载的文件长度 * @param path * @param map */ 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(); } /** *当文件下载完成后,删除对应的下载记录 *@param path */ public void delete(String path) { SQLiteDatabase db = openHelper.getWritableDatabase(); db.execSQL("delete from filedownlog where downpath=?", new Object[]{path}); db.close(); } }Step3:创建一个文件下载器类 1.定义一堆变量,核心是线程池threads和同步集合ConcurrentHashMap,用于缓存线程下载长度的 2.定义一个获取线程池中线程数的方法; 3.定义一个退出下载的方法, 4.获取当前文件大小的方法 5.累计当前已下载长度的方法,这里需要添加一个synchronized关键字,用来解决并发访问的问题 6.更新指定线程最后的下载位置,同样也需要用同步 7.在构造方法中完成文件下载,线程开辟等操作 8.获取文件名的方法: 先截取提供的url最后的'/'后面的字符串,如果获取不到,再从头字段查找,还是找不到的话,就使用网卡标识数字+cpu的唯一数字生成一个16个字节的二进制作为文件名 9.开始下载文件的方法 10.获取http响应头字段的方法 11.打印http头字段的方法 12.打印日志信息的方法FileDownloadered.java:public class FileDownloadered { private static final String TAG = "文件下载类"; //设置一个查log时的一个标志 private static final int RESPONSEOK = 200; //设置响应码为200,代表访问成功 private FileService fileService; //获取本地数据库的业务Bean private boolean exited; //停止下载的标志 private Context context; //程序的上下文对象 private int downloadedSize = 0; //已下载的文件长度 private int fileSize = 0; //开始的文件长度 private DownloadThread[] threads; //根据线程数设置下载的线程池 private File saveFile; //数据保存到本地的文件中 private Mapdata = new ConcurrentHashMap(); //缓存个条线程的下载的长度 private int block; //每条线程下载的长度 private String downloadUrl; //下载的路径 /** * 获取线程数 */ public int getThreadSize() { //return threads.length; return 0; } /** * 退出下载 * */ public void exit() { this.exited = true; //将退出的标志设置为true; } public boolean getExited() { return this.exited; } /** * 获取文件的大小 * */ public int getFileSize() { return fileSize; } /** * 累计已下载的大小 * 使用同步锁来解决并发的访问问题 * */ protected synchronized void append(int size) { //把实时下载的长度加入到总的下载长度中 downloadedSize += size; } /** * 更新指定线程最后下载的位置 * @param threadId 线程id * @param pos 最后下载的位置 * */ protected synchronized void update(int threadId,int pos) { //把指定线程id的线程赋予最新的下载长度,以前的值会被覆盖掉 this.data.put(threadId, pos); //更新数据库中制定线程的下载长度 this.fileService.update(this.downloadUrl, threadId, pos); } /** * 构建文件下载器 * @param downloadUrl 下载路径 * @param fileSaveDir 文件的保存目录 * @param threadNum 下载线程数 * @return */ public FileDownloadered(Context context,String downloadUrl,File fileSaveDir,int threadNum) { try { this.context = context; //获取上下文对象,赋值 this.downloadUrl = downloadUrl; //为下载路径赋值 fileService = new FileService(this.context); //实例化数据库操作的业务Bean类,需要传一个context值 URL url = new URL(this.downloadUrl); //根据下载路径实例化URL if(!fileSaveDir.exists()) fileSaveDir.mkdir(); //如果文件不存在的话指定目录,这里可创建多层目录 this.threads = new DownloadThread[threadNum]; //根据下载的线程数量创建下载的线程池 HttpURLConnection conn = (HttpURLConnection) url.openConnection(); //创建远程连接句柄,这里并未真正连接 conn.setConnectTimeout(5000); //设置连接超时事件为5秒 conn.setRequestMethod("GET"); //设置请求方式为GET //设置用户端可以接收的媒体类型 conn.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, */*"); conn.setRequestProperty("Accept-Language", "zh-CN"); //设置用户语言 conn.setRequestProperty("Referer", downloadUrl); //设置请求的来源页面,便于服务端进行来源统计 conn.setRequestProperty("Charset", "UTF-8"); //设置客户端编码 //设置用户代理 conn.setRequestProperty("User-Agent", "Mozilla/4.0 (compatible; MSIE 8.0; " + "Windows NT 5.2; Trident/4.0; .NET CLR 1.1.4322; .NET CLR 2.0.50727;" + " .NET CLR 3.0.04506.30; .NET CLR 3.0.4506.2152; .NET CLR 3.5.30729)"); conn.setRequestProperty("Connection", "Keep-Alive"); //设置connection的方式 conn.connect(); //和远程资源建立正在的链接,但尚无返回的数据流 printResponseHeader(conn); //打印返回的Http的头字段集合 //对返回的状态码进行判断,用于检查是否请求成功,返回200时执行下面的代码 if(conn.getResponseCode() == RESPONSEOK) { this.fileSize = conn.getContentLength(); //根据响应获得文件大小 if(this.fileSize <= 0)throw new RuntimeException("不知道文件大小"); //文件长度小于等于0时抛出运行时异常 String filename = getFileName(conn); //获取文件名称 this.saveFile = new File(fileSaveDir,filename); //根据文件保存目录和文件名保存文件 Maplogdata = fileService.getData(downloadUrl); //获取下载记录 //如果存在下载记录 if(logdata.size() > 0) { //遍历集合中的数据,把每条线程已下载的数据长度放入data中 for(Map.Entryentry : 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()); throw new RuntimeException("服务器反馈出错"); } }catch (Exception e) { print(e.toString()); //打印错误 throw new RuntimeException("无法连接URL"); } } /** * 获取文件名 * */ 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; //如果遍历到了返回头末尾则退出循环 //获取content-disposition返回字段,里面可能包含文件名 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";//如果都没找到的话,默认取一个文件名 //由网卡标识数字(每个网卡都有唯一的标识号)以及CPU时间的唯一数字生成的一个16字节的二进制作为文件名 } return filename; } /** * 开始下载文件 * @param listener 监听下载数量的变化,如果不需要了解实时下载的数量,可以设置为null * @return 已下载文件大小 * @throws Exception */ //进行下载,如果有异常的话,抛出异常给调用者 public int download(DownloadProgressListener listener) throws Exception{ try { RandomAccessFile randOut = new RandomAccessFile(this.saveFile, "rwd"); //设置文件大小 if(this.fileSize>0) randOut.setLength(this.fileSize); randOut.close(); //关闭该文件,使设置生效 URL url = new URL(this.downloadUrl); 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);//初始化每条线程已经下载的数据长度为0 } this.downloadedSize = 0; //设置已经下载的长度为0 } for (int i = 0; i < this.threads.length; i++) {//开启线程进行下载 int downLength = this.data.get(i+1); //通过特定的线程id获取该线程已经下载的数据长度 //判断线程是否已经完成下载,否则继续下载 if(downLength < this.block && this.downloadedSizegetHttpResponseHeader(HttpURLConnection http) { //使用LinkedHashMap保证写入和便利的时候的顺序相同,而且允许空值 Mapheader = new LinkedHashMap(); //此处使用无线循环,因为不知道头字段的数量 for (int i = 0;; i++) { String mine = http.getHeaderField(i); //获取第i个头字段的值 if (mine == null) break; //没值说明头字段已经循环完毕了,使用break跳出循环 header.put(http.getHeaderFieldKey(i), mine); //获得第i个头字段的键 } return header; } /** * 打印Http头字段 * @param http */ public static void printResponseHeader(HttpURLConnection http){ //获取http响应的头字段 Mapheader = getHttpResponseHeader(http); //使用增强for循环遍历取得头字段的值,此时遍历的循环顺序与输入树勋相同 for(Map.Entryentry : header.entrySet()){ //当有键的时候则获取值,如果没有则为空字符串 String key = entry.getKey()!=null ? entry.getKey()+ ":" : ""; print(key+ entry.getValue()); //打印键和值得组合 } } /** * 打印信息 * @param msg 信息字符串 * */ private static void print(String msg) { Log.i(TAG, msg); }}Step4:自定义一个下载线程类 1.首先肯定是要继承Thread类啦,然后重写Run()方法 2.Run()方法:先判断是否下载完成,没有得话:打开URLConnection链接,接着RandomAccessFile 进行数据读写,完成时设置完成标记为true,发生异常的话设置长度为-1,打印异常信息 3.打印log信息的方法 4.判断下载是否完成的方法(根据完成标记) 5.获得已下载的内容大小DownLoadThread.java:public class DownloadThread extends Thread { private static final String TAG = "下载线程类"; //定义TAG,在打印log时进行标记 private File saveFile; //下载的数据保存到的文件 private URL downUrl; //下载的URL private int block; //每条线程下载的大小 private int threadId = -1; //初始化线程id设置 private int downLength; //该线程已下载的数据长度 private boolean finish = false; //该线程是否完成下载的标志 private FileDownloadered downloader; //文件下载器 public DownloadThread(FileDownloadered downloader, URL downUrl, File saveFile, int block, int downLength, int threadId) { this.downUrl = downUrl; this.saveFile = saveFile; this.block = block; this.downloader = downloader; this.threadId = threadId; this.downLength = downLength; } @Override public void run() { if(downLength < 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) + downLength;//开始位置 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; .NET CLR 1.1.4322; .NET CLR 2.0.50727; .NET CLR 3.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]; //设置本地数据的缓存大小为1MB int offset = 0; //每次读取的数据量 print("Thread " + this.threadId + " start download from position "+ startPos); //打印该线程开始下载的位置 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); //直接把数据写入到文件中 downLength += offset; //把新线程已经写到文件中的数据加入到下载长度中 downloader.update(this.threadId, downLength); //把该线程已经下载的数据长度更新到数据库和内存哈希表中 downloader.append(offset); //把新下载的数据长度加入到已经下载的数据总长度中 } threadfile.close(); inStream.close(); print("Thread " + this.threadId + " download finish"); this.finish = true; //设置完成标记为true,无论下载完成还是用户主动中断下载 } catch (Exception e) { this.downLength = -1; //设置该线程已经下载的长度为-1 print("Thread "+ this.threadId+ ":"+ e); } } } private static void print(String msg){ Log.i(TAG, msg); } /** * 下载是否完成 * @return */ public boolean isFinish() { return finish; } /** * 已经下载的内容大小 * @return 如果返回值为-1,代表下载失败 */ public long getDownLength() { return downLength; }}Step5:创建一个DownloadProgressListener接口监听下载进度FileDownloader中使用了DownloadProgressListener进行进度监听, 所以这里需要创建一个接口,同时定义一个方法的空实现:DownloadProgressListener.javapublic interface DownloadProgressListener { public void onDownloadSize(int downloadedSize);}Step6:编写布局代码Step7:MainActivity的编写 最后就是我们的MainActivity了,完成组件以及相关变量的初始化; 使用handler来完成界面的更新操作,另外耗时操作不能够在主线程中进行, 所以这里需要开辟新的线程,这里用Runnable实现,详情见代码 MainActivity.java:public class MainActivity extends Activity { private EditText editpath; private Button btndown; private Button btnstop; private TextView textresult; private ProgressBar progressbar; private static final int PROCESSING = 1; //正在下载实时数据传输Message标志 private static final int FAILURE = -1; //下载失败时的Message标志 private Handler handler = new UIHander(); private final class UIHander extends Handler{ public void handleMessage(Message msg) { switch (msg.what) { //下载时 case PROCESSING: int size = msg.getData().getInt("size"); //从消息中获取已经下载的数据长度 progressbar.setProgress(size); //设置进度条的进度 //计算已经下载的百分比,此处需要转换为浮点数计算 float num = (float)progressbar.getProgress() / (float)progressbar.getMax(); int result = (int)(num * 100); //把获取的浮点数计算结果转换为整数 textresult.setText(result+ "%"); //把下载的百分比显示到界面控件上 if(progressbar.getProgress() == progressbar.getMax()){ //下载完成时提示 Toast.makeText(getApplicationContext(), "文件下载成功", 1).show(); } break; case FAILURE: //下载失败时提示 Toast.makeText(getApplicationContext(), "文件下载失败", 1).show(); break; } } } @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); editpath = (EditText) findViewById(R.id.editpath); btndown = (Button) findViewById(R.id.btndown); btnstop = (Button) findViewById(R.id.btnstop); textresult = (TextView) findViewById(R.id.textresult); progressbar = (ProgressBar) findViewById(R.id.progressBar); ButtonClickListener listener = new ButtonClickListener(); btndown.setOnClickListener(listener); btnstop.setOnClickListener(listener); } private final class ButtonClickListener implements View.OnClickListener{ public void onClick(View v) { switch (v.getId()) { case R.id.btndown: String path = editpath.getText().toString(); if(Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)){ File saveDir = Environment.getExternalStorageDirectory(); download(path, saveDir); }else{ Toast.makeText(getApplicationContext(), "sd卡读取失败", 1).show(); } btndown.setEnabled(false); btnstop.setEnabled(true); break; case R.id.btnstop: exit(); btndown.setEnabled(true); btnstop.setEnabled(false); break; } } /* 由于用户的输入事件(点击button, 触摸屏幕....)是由主线程负责处理的,如果主线程处于工作状态, 此时用户产生的输入事件如果没能在5秒内得到处理,系统就会报“应用无响应”错误。 所以在主线程里不能执行一件比较耗时的工作,否则会因主线程阻塞而无法处理用户的输入事件, 导致“应用无响应”错误的出现。耗时的工作应该在子线程里执行。 */ private DownloadTask task; /** * 退出下载 */ public void exit(){ if(task!=null) task.exit(); } private void download(String path, File saveDir) {//运行在主线程 task = new DownloadTask(path, saveDir); new Thread(task).start(); } /* * UI控件画面的重绘(更新)是由主线程负责处理的,如果在子线程中更新UI控件的值,更新后的值不会重绘到屏幕上 * 一定要在主线程里更新UI控件的值,这样才能在屏幕上显示出来,不能在子线程中更新UI控件的值 */ private final class DownloadTask implements Runnable{ private String path; private File saveDir; private FileDownloadered loader; public DownloadTask(String path, File saveDir) { this.path = path; this.saveDir = saveDir; } /** * 退出下载 */ public void exit(){ if(loader!=null) loader.exit(); } public void run() { try { loader = new FileDownloadered(getApplicationContext(), path, saveDir, 3); progressbar.setMax(loader.getFileSize());//设置进度条的最大刻度 loader.download(new com.jay.example.service.DownloadProgressListener() { public void onDownloadSize(int size) { Message msg = new Message(); msg.what = 1; msg.getData().putInt("size", size); handler.sendMessage(msg); } }); } catch (Exception e) { e.printStackTrace(); handler.sendMessage(handler.obtainMessage(-1)); } } } }}Step8: AndroidMainfest.xml文件中添加相关权限
DownloadManager实现网络视频文件下载
最后编辑于 :
©著作权归作者所有,转载或内容合作请联系作者
- 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
- 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
- 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...