JAVA实现环形缓冲多线程读取远程文件

如果用HttpURLConnection类的方法打开连接,然后用InputStream类获得输入流,再用BufferedInputStream构造出带缓冲区的输入流,如果网速太慢的话,无论缓冲区设置多大,听起来都是断断续续的,达不到真正缓冲的目的。于是尝试编写代码实现用缓冲方式读取远程文件,以下贴出的代码是我写的MP3解码器的一部分。我是不怎么赞同使用多线程下载的,加之有的链接下载速度本身就比较快,所以在下载速度足够的情况下,就让下载线程退出,直到只剩下一个下载线程。当然,多线程中令人头痛的死锁问题、HttpURLConnection的超时阻塞问题都会使代码看起来异常复杂。

      简要介绍一下实现多线程环形缓冲的方法。将缓冲区buf[]分为16块,每块32K,下载线程负责向缓冲区写数据,每次写一块;读线程(BuffRandAcceURL类)每次读小于32K的任意字节。同步描述:写/写互斥等待空闲块;写/写并发填写buf[];读/写并发使用buf[]。

      经过我很长一段时间使用,我认为比较满意地实现了我的目标,同其它MP3播放器对比,我的这种方法能够比较流畅、稳定地下载并播放。

      本次修正了几处可能产生死锁的情况,源码中加入一些注解,略去了一些和本文无关的方法实现。

 

一、HttpReader类功能:HTTP协议从指定URL读取数据。

Java代码 复制代码
  1. package instream;   
  2.   
  3. import java.io.IOException;   
  4. import java.io.InputStream;   
  5. import java.net.HttpURLConnection;   
  6. import java.net.URL;   
  7.   
  8. public final class HttpReader {   
  9.     public static final int MAX_RETRY = 10;   
  10.     private URL url;   
  11.     private HttpURLConnection httpConnection;   
  12.     private InputStream in_stream;   
  13.     private long cur_pos;           //决定seek方法中是否执行文件读取定位   
  14.     private int connect_timeout;   
  15.     private int read_timeout;   
  16.        
  17.     public HttpReader(URL u) {   
  18.         this(u, 50005000);   
  19.     }   
  20.        
  21.     public HttpReader(URL u, int connect_timeout, int read_timeout) {   
  22.         this.connect_timeout = connect_timeout;   
  23.         this.read_timeout = read_timeout;   
  24.         url = u;   
  25.     }   
  26.   
  27.     public int read(byte[] b, int off, int len) throws IOException {   
  28.         int r = in_stream.read(b, off, len);   
  29.         cur_pos += r;   
  30.         return r;   
  31.     }   
  32.        
  33.     public int getData(byte[] b, int off, int len) throws IOException {   
  34.         //...   
  35.     }   
  36.        
  37.     public void close() {   
  38.         //...   
  39.     }   
  40.        
  41.     /*  
  42.      * 抛出异常通知重试.  
  43.      * 例如响应码503可能是由某种暂时的原因引起的,同一IP频繁的连接请求会遭服务器拒绝.  
  44.      */  
  45.     public void seek(long start_pos) throws IOException {   
  46.         if (start_pos == cur_pos && in_stream != null)   
  47.             return;   
  48.         if (httpConnection != null) {   
  49.             httpConnection.disconnect();   
  50.             httpConnection = null;   
  51.         }   
  52.         if (in_stream != null) {   
  53.             in_stream.close();   
  54.             in_stream = null;   
  55.         }   
  56.         httpConnection = (HttpURLConnection) url.openConnection();   
  57.         httpConnection.setConnectTimeout(connect_timeout);   
  58.         httpConnection.setReadTimeout(read_timeout);   
  59.         String sProperty = "bytes=" + start_pos + "-";   
  60.         httpConnection.setRequestProperty("Range", sProperty);   
  61.         //httpConnection.setRequestProperty("Connection", "Keep-Alive");   
  62.         int responseCode = httpConnection.getResponseCode();   
  63.         if (responseCode < 200 || responseCode >= 300) {   
  64.             try {   
  65.                 Thread.sleep(200);   
  66.             } catch (InterruptedException e) {   
  67.                 e.printStackTrace();   
  68.             }   
  69.             throw new IOException("HTTP responseCode="+responseCode);   
  70.         }   
  71.   
  72.         in_stream = httpConnection.getInputStream();   
  73.         cur_pos = start_pos;   
  74.         //System.out.println(Thread.currentThread().getName()+ ", cur_pos="+cur_pos);   
  75.     }   
  76. }  
package instream;

import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;

public final class HttpReader {
	public static final int MAX_RETRY = 10;
	private URL url;
	private HttpURLConnection httpConnection;
	private InputStream in_stream;
	private long cur_pos;			//决定seek方法中是否执行文件读取定位
	private int connect_timeout;
	private int read_timeout;
	
	public HttpReader(URL u) {
		this(u, 5000, 5000);
	}
	
	public HttpReader(URL u, int connect_timeout, int read_timeout) {
		this.connect_timeout = connect_timeout;
		this.read_timeout = read_timeout;
		url = u;
	}

	public int read(byte[] b, int off, int len) throws IOException {
		int r = in_stream.read(b, off, len);
		cur_pos += r;
		return r;
	}
	
	public int getData(byte[] b, int off, int len) throws IOException {
		//...
	}
	
	public void close() {
		//...
	}
	
	/*
	 * 抛出异常通知重试.
	 * 例如响应码503可能是由某种暂时的原因引起的,同一IP频繁的连接请求会遭服务器拒绝.
	 */
	public void seek(long start_pos) throws IOException {
		if (start_pos == cur_pos && in_stream != null)
			return;
		if (httpConnection != null) {
			httpConnection.disconnect();
			httpConnection = null;
		}
		if (in_stream != null) {
			in_stream.close();
			in_stream = null;
		}
		httpConnection = (HttpURLConnection) url.openConnection();
		httpConnection.setConnectTimeout(connect_timeout);
		httpConnection.setReadTimeout(read_timeout);
		String sProperty = "bytes=" + start_pos + "-";
		httpConnection.setRequestProperty("Range", sProperty);
		//httpConnection.setRequestProperty("Connection", "Keep-Alive");
		int responseCode = httpConnection.getResponseCode();
		if (responseCode < 200 || responseCode >= 300) {
			try {
				Thread.sleep(200);
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
			throw new IOException("HTTP responseCode="+responseCode);
		}

		in_stream = httpConnection.getInputStream();
		cur_pos = start_pos;
		//System.out.println(Thread.currentThread().getName()+ ", cur_pos="+cur_pos);
	}
}

  

二、IWriterCallBack接口

Java代码 复制代码
  1. package instream;   
  2.   
  3. /*  
  4.  * 读/写通信接口.类似于C++的回调函数  
  5.  *   
  6.  * 例:  
  7.  * class BuffRandAcceURL 内实现本接口的方法tryWriting()  
  8.  * class BuffRandAcceURL 内new Writer(this, ...)传值到Writer.icb  
  9.  * class Writer 内调用icb.tryWriting()  
  10.  */  
  11. public interface IWriterCallBack {   
  12.     public int tryWriting() throws InterruptedException;   
  13.     public void updateBuffer(int i, int len);   
  14.     public void updateWriterCount();   
  15.     public int getWriterCount();   
  16.     public void terminateWriters();   
  17. }  
package instream;

/*
 * 读/写通信接口.类似于C++的回调函数
 * 
 * 例:
 * class BuffRandAcceURL 内实现本接口的方法tryWriting()
 * class BuffRandAcceURL 内new Writer(this, ...)传值到Writer.icb
 * class Writer 内调用icb.tryWriting()
 */
public interface IWriterCallBack {
	public int tryWriting() throws InterruptedException;
	public void updateBuffer(int i, int len);
	public void updateWriterCount();
	public int getWriterCount();
	public void terminateWriters();
}

  

三、Writer类:下载线程,负责向buf[]写数据。

Java代码 复制代码
  1. package instream;   
  2. import java.net.URL;   
  3.   
  4. public final class Writer implements Runnable {   
  5.     private static boolean isalive = true;  // 一个线程超时其它线程也能退出   
  6.     private static byte[] buf;   
  7.     private static IWriterCallBack icb;   
  8.     private HttpReader hr;   
  9.        
  10.     public Writer(IWriterCallBack cb, URL u, byte[] b, int i) {   
  11.         hr = new HttpReader(u);   
  12.         icb = cb;   
  13.         buf = b;   
  14.         Thread t = new Thread(this,"dt_"+i);   
  15.         t.setPriority(Thread.NORM_PRIORITY + 1);   
  16.         t.start();   
  17.     }   
  18.        
  19.     public void run() {   
  20.         int wbytes=0, wpos=0, rema = 0, retry = 0;   
  21.         int idxmask = BuffRandAcceURL.UNIT_COUNT - 1;   
  22.         boolean cont = true;   
  23.         int index = 0;      //buf[]内"块"索引号   
  24.         int startpos = 0;   //index对应的文件位置(相对于文件首的偏移量)   
  25.         long time0 = 0;   
  26.         while (cont) {   
  27.             try {   
  28.                 // 1.等待空闲块   
  29.                 if(retry == 0) {   
  30.                     if ((startpos = icb.tryWriting()) == -1)   
  31.                         break;   
  32.                     index = (startpos >> BuffRandAcceURL.UNIT_LENGTH_BITS) & idxmask;   
  33.                     wpos = startpos & BuffRandAcceURL.BUF_LENGTH_MASK;   
  34.                     wbytes = 0;   
  35.                     rema = BuffRandAcceURL.UNIT_LENGTH;   
  36.                     time0 = System.currentTimeMillis();   
  37.                 }   
  38.                    
  39.                 // 2.定位   
  40.                 hr.seek(startpos);   
  41.   
  42.                 // 3.下载"一块"   
  43.                 int w;   
  44.                 while (rema > 0 && isalive) {   
  45.                     w = (rema < 2048) ? rema : 2048//每次读几K合适?   
  46.                     if ((w = hr.read(buf, wpos, w)) == -1) {   
  47.                         cont = false;   
  48.                         break;   
  49.                     }   
  50.                     rema -= w;   
  51.                     wpos += w;   
  52.                     startpos += w;  // 能断点续传   
  53.                     wbytes += w;   
  54.                 }   
  55.                    
  56.                 // 下载速度足够快就结束本线程   
  57.                 long t = System.currentTimeMillis() - time0;   
  58.                 if(icb.getWriterCount() > 1 && t < 500)   
  59.                     cont = false;   
  60.                    
  61.                 //4.通知"读"线程   
  62.                 retry = 0;   
  63.                 icb.updateBuffer(index, wbytes);   
  64.             } catch (Exception e) {   
  65.                 if(++retry == HttpReader.MAX_RETRY) {   
  66.                     isalive = false;   
  67.                     icb.terminateWriters();   
  68.                     break;   
  69.                 }   
  70.             }   
  71.         }   
  72.         icb.updateWriterCount();   
  73.         try {   
  74.             hr.close();   
  75.         } catch (Exception e) {}   
  76.         hr = null;   
  77.     }   
  78. }  
package instream;
import java.net.URL;

public final class Writer implements Runnable {
	private static boolean isalive = true;	// 一个线程超时其它线程也能退出
	private static byte[] buf;
	private static IWriterCallBack icb;
	private HttpReader hr;
	
	public Writer(IWriterCallBack cb, URL u, byte[] b, int i) {
		hr = new HttpReader(u);
		icb = cb;
		buf = b;
		Thread t = new Thread(this,"dt_"+i);
		t.setPriority(Thread.NORM_PRIORITY + 1);
		t.start();
	}
	
	public void run() {
		int wbytes=0, wpos=0, rema = 0, retry = 0;
		int idxmask = BuffRandAcceURL.UNIT_COUNT - 1;
		boolean cont = true;
		int index = 0;		//buf[]内"块"索引号
		int startpos = 0;	//index对应的文件位置(相对于文件首的偏移量)
		long time0 = 0;
		while (cont) {
			try {
				// 1.等待空闲块
				if(retry == 0) {
					if ((startpos = icb.tryWriting()) == -1)
						break;
					index = (startpos >> BuffRandAcceURL.UNIT_LENGTH_BITS) & idxmask;
					wpos = startpos & BuffRandAcceURL.BUF_LENGTH_MASK;
					wbytes = 0;
					rema = BuffRandAcceURL.UNIT_LENGTH;
					time0 = System.currentTimeMillis();
				}
				
				// 2.定位
				hr.seek(startpos);

				// 3.下载"一块"
				int w;
				while (rema > 0 && isalive) {
					w = (rema < 2048) ? rema : 2048; //每次读几K合适?
					if ((w = hr.read(buf, wpos, w)) == -1) {
						cont = false;
						break;
					}
					rema -= w;
					wpos += w;
					startpos += w;	// 能断点续传
					wbytes += w;
				}
				
				// 下载速度足够快就结束本线程
				long t = System.currentTimeMillis() - time0;
				if(icb.getWriterCount() > 1 && t < 500)
					cont = false;
				
				//4.通知"读"线程
				retry = 0;
				icb.updateBuffer(index, wbytes);
			} catch (Exception e) {
				if(++retry == HttpReader.MAX_RETRY) {
					isalive = false;
					icb.terminateWriters();
					break;
				}
			}
		}
		icb.updateWriterCount();
		try {
			hr.close();
		} catch (Exception e) {}
		hr = null;
	}
}

   

 四、IRandomAccess接口:随机读取文件接口,BuffRandAcceURL类和BuffRandAcceFile类实现接口方法。BuffRandAcceFile类实现读取本地磁盘文件,这儿就不给出其源码了。

Java代码 复制代码
  1. package instream;   
  2.   
  3. public interface IRandomAccess {   
  4.     public int read() throws Exception;   
  5.     public int read(byte b[]) throws Exception;   
  6.     public int read(byte b[], int off, int len) throws Exception;   
  7.     public int dump(int src_off, byte b[], int dst_off, int len) throws Exception;   
  8.     public void seek(long pos) throws Exception;   
  9.     public long length();   
  10.     public long getFilePointer();   
  11.     public void close();   
  12. }  
package instream;

public interface IRandomAccess {
	public int read() throws Exception;
	public int read(byte b[]) throws Exception;
	public int read(byte b[], int off, int len) throws Exception;
	public int dump(int src_off, byte b[], int dst_off, int len) throws Exception;
	public void seek(long pos) throws Exception;
	public long length();
	public long getFilePointer();
	public void close();
}

  

 

五、BuffRandAcceURL类功能:创建下载线程;read方法从buf[]读数据。

关键是如何简单有效防止死锁?

Java代码 复制代码
  1. package instream;   
  2.   
  3. import java.net.URL;   
  4. import java.net.URLDecoder;   
  5. import tag.ID3Tag;   
  6.   
  7. /*  
  8.  * FIFO方式共享环形缓冲区buf[]  
  9.  * buf[]逻辑上分成16块, 每一块的长度UNIT_LENGTH=32K不小于最大帧长1732  
  10.  *   
  11.  * 同步: 写/写 -- 互斥等待空闲块  
  12.  *       读/写 -- 并发访问buf[]  
  13.  *  
  14.  */  
  15. public final class BuffRandAcceURL implements IRandomAccess, IWriterCallBack {   
  16.     public static final int UNIT_LENGTH_BITS = 15;   
  17.     public static final int UNIT_LENGTH = 1 << UNIT_LENGTH_BITS; //2^16=32K   
  18.     public static final int BUF_LENGTH = UNIT_LENGTH << 4;   
  19.     public static final int UNIT_COUNT = BUF_LENGTH >> UNIT_LENGTH_BITS; //16块   
  20.     public static final int BUF_LENGTH_MASK = (BUF_LENGTH - 1);   
  21.     private static final int MAX_WRITER = 5;   
  22.     private static long file_pointer;   
  23.     private static int read_pos;   
  24.     private static int fill_bytes;   
  25.     private static byte[] buf;          //同时作写线程同步锁   
  26.     private static int[] unit_bytes;    //同时作读线程互斥锁   
  27.     private static int alloc_pos;   
  28.     private static URL url;   
  29.     private static boolean isalive = true;   
  30.     private static int writer_count;   
  31.     private static long file_length;   
  32.     private static long frame_bytes;   
  33.     private static int free_unit = UNIT_COUNT;  // "信号量"计数器   
  34.        
  35.     public BuffRandAcceURL(String sURL) throws Exception {   
  36.         this(sURL,MAX_WRITER);   
  37.     }   
  38.        
  39.     public BuffRandAcceURL(String sURL, int download_threads) throws Exception {   
  40.         buf = new byte[BUF_LENGTH];   
  41.         unit_bytes = new int[UNIT_COUNT];   
  42.         url = new URL(sURL);   
  43.   
  44.         // 打印文件名   
  45.         try {   
  46.             String s = URLDecoder.decode(sURL, "GBK");   
  47.             System.out.println(s.substring(s.lastIndexOf("/") + 1));   
  48.         } catch (Exception e) {   
  49.             System.out.println(sURL);   
  50.         }   
  51.   
  52.         // 获取文件长度   
  53.         // 为何同一URL得到的文件长度有时对有时又不对?   
  54.         frame_bytes = file_length = url.openConnection().getContentLength();   
  55.         if (file_length == -1)   
  56.             throw new Exception("ContentLength=-1");   
  57.   
  58.         // 创建线程以异步方式解析tag   
  59.         new TagThread(url, file_length);   
  60.   
  61.         // 创建"写"线程   
  62.         // 线程被创建后立即连接URL开始下载,由于服务器限制了同一IP每秒最大连接次数,频繁连接   
  63.         // 会被服务器拒绝,因此延时.   
  64.         writer_count = download_threads;   
  65.         for (int i = 0; i < download_threads; i++) {   
  66.             new Writer(this, url, buf, i + 1);   
  67.             Thread.sleep(200);   
  68.         }   
  69.   
  70.         // 缓冲   
  71.         try_cache();   
  72.   
  73.         // 跳过 ID3 v2   
  74.         ID3Tag tag = new ID3Tag();   
  75.         int v2_size = tag.checkID3V2(buf, 0);   
  76.         if (v2_size > 0) {   
  77.             frame_bytes -= v2_size;   
  78.             seek(v2_size);   
  79.         }   
  80.         tag = null;   
  81.     }   
  82.        
  83.     /*  
  84.      * 缓冲  
  85.      */  
  86.     private void try_cache() throws InterruptedException {   
  87.         int cache_size = BUF_LENGTH;   
  88.         int bi = unit_bytes[read_pos >> UNIT_LENGTH_BITS];   
  89.         if(bi != 0)   
  90.             cache_size -= UNIT_LENGTH - bi;   
  91.         while (fill_bytes < cache_size) {   
  92.             if (writer_count == 0 || isalive == false)   
  93.                 return;   
  94.             System.out.printf("/r[缓冲%1$6.2f%%] ",(float)fill_bytes / cache_size * 100);   
  95.             synchronized (unit_bytes) {   
  96.                 unit_bytes.wait(200);   //wait(200)错过通知也可结束循环?   
  97.             }   
  98.         }   
  99.         System.out.printf("/r");   
  100.     }   
  101.   
  102.     private int try_reading(int i, int len) throws Exception {   
  103.         int n = (i + 1) & (UNIT_COUNT - 1);   
  104.         int r = (unit_bytes[i] > 0) ? (unit_bytes[i] + unit_bytes[n]) : unit_bytes[i];   
  105.         if (r < len) {   
  106.             if (writer_count == 0 || isalive == false)   
  107.                 return r;   
  108.             try_cache();   
  109.         }   
  110.            
  111.         return len;   
  112.     }   
  113.        
  114.     /*  
  115.      * 各个"写"线程互斥等待空闲块  
  116.      * 空闲块按由小到大的顺序分配;管理空闲块采用类似于C++的信号量机制.  
  117.      */  
  118.     public int tryWriting() throws InterruptedException {   
  119.         int ret = -1;   
  120.         synchronized (buf) {   
  121.             while (free_unit == 0 && isalive)   
  122.                 buf.wait();   
  123.                
  124.             if(alloc_pos >= file_length || isalive == false)   
  125.                 return -1;   
  126.             ret = alloc_pos;   
  127.             alloc_pos += UNIT_LENGTH;   
  128.             free_unit--;   
  129.         }   
  130.         return ret;   
  131.     }   
  132.        
  133.     /*  
  134.      * "写"线程向buf[]写完数据后调用,通知"读"线程  
  135.      */  
  136.     public void updateBuffer(int i, int len) {   
  137.         synchronized (unit_bytes) {   
  138.             unit_bytes[i] = len;   
  139.             fill_bytes += len;   
  140.             unit_bytes.notify();   
  141.         }   
  142.     }   
  143.        
  144.     /*  
  145.      * "写"线程准备退出时调用  
  146.      */  
  147.     public void updateWriterCount() {   
  148.         synchronized (unit_bytes) {   
  149.             writer_count--;   
  150.             unit_bytes.notify();   
  151.         }   
  152.     }   
  153.        
  154.     public int getWriterCount() {   
  155.         return writer_count;   
  156.     }   
  157.        
  158.     /*  
  159.      * read方法内调用  
  160.      */  
  161.     public void notifyWriter() {   
  162.         synchronized (buf) {   
  163.             buf.notifyAll();   
  164.         }   
  165.     }   
  166.        
  167.     /*  
  168.      * 被某个"写"线程调用,用于终止其它"写"线程;isalive也影响"读"线程流程  
  169.      */  
  170.     public void terminateWriters() {   
  171.         synchronized (unit_bytes) {    
  172.             if (isalive) {   
  173.                 isalive = false;   
  174.                 System.out.println("/n读取文件超时。重试 " + HttpReader.MAX_RETRY   
  175.                         + " 次后放弃,请您稍后再试。");   
  176.             }   
  177.             unit_bytes.notify();   
  178.         }   
  179.         notifyWriter();   
  180.     }   
  181.        
  182.     public int read() throws Exception {   
  183.         //...   
  184.     }   
  185.        
  186.     public int read(byte b[]) throws Exception {   
  187.         return read(b, 0, b.length);   
  188.     }   
  189.   
  190.     public int read(byte[] b, int off, int len) throws Exception {   
  191.         int i = read_pos >> UNIT_LENGTH_BITS;   
  192.            
  193.         // 1.等待有足够内容可读   
  194.         if(try_reading(i, len) < len || isalive == false)   
  195.             return -1;   
  196.   
  197.         // 2.读取   
  198.         int tail = BUF_LENGTH - read_pos; // write_pos != BUF_LENGTH   
  199.         if (tail < len) {   
  200.             System.arraycopy(buf, read_pos, b, off, tail);   
  201.             System.arraycopy(buf, 0, b, off + tail, len - tail);   
  202.         } else  
  203.             System.arraycopy(buf, read_pos, b, off, len);   
  204.   
  205.         fill_bytes -= len;   
  206.         file_pointer += len;   
  207.         read_pos += len;   
  208.         read_pos &= BUF_LENGTH_MASK;   
  209.         unit_bytes[i] -= len;   
  210.         if (unit_bytes[i] < 0) {   
  211.             int ni = read_pos >> UNIT_LENGTH_BITS;   
  212.             unit_bytes[ni] += unit_bytes[i];   
  213.             unit_bytes[i] = 0;   
  214.             free_unit++;   
  215.             notifyWriter();   
  216.         } else if (unit_bytes[i] == 0) {   
  217.             free_unit++;        // 空闲块"信号量"计数加1   
  218.             notifyWriter();     // 3.通知   
  219.         }   
  220.         // 如果下一块未填满,意味着文件读完,第1步已处理一次读空两块的情况   
  221.            
  222.         return len;   
  223.     }   
  224.        
  225.     /*  
  226.      * 从buf[]偏移src_off位置复制,不移动文件"指针",不发信号.  
  227.      */  
  228.     public int dump(int src_off, byte b[], int dst_off, int len) throws Exception {   
  229.         //...   
  230.     }   
  231.        
  232.     public long length() {   
  233.         //...   
  234.     }   
  235.        
  236.     public long getFilePointer() {   
  237.         //...   
  238.     }   
  239.   
  240.     public void close() {   
  241.         //...   
  242.     }   
  243.        
  244.     /*  
  245.      * 随机读取定位  
  246.      */  
  247.     public void seek(long pos) throws Exception {   
  248.         //...   
  249.     }   
  250.        
  251. }  
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值