彻底掌握如何有效处理高清大图:Android-Universal-Image-Loader框架解析(二)缓存

彻底掌握如何有效处理高清大图:Android-Universal-Image-Loader框架解析(一)基本使用

在UIL框架中,最重要的技术莫过于缓存技术的使用了,缓存技术的使用不仅仅体现在UIL中,其实在Gallery里面也有很多的使用;

  1. 首先我们从内存缓存MemoryCache 说起

在初始化ImageLoader的时候,我们可以通过如下代码配设置使用何种内存缓存,以下代码表示使用LruMemoryCache来进行内存缓存

config.memoryCache(new LruMemoryCache(10));

默认的UIL框架中,内存缓存是什么

在ImageLoaderConfiguration.java中,有如下代码

			if (memoryCache == null) {
				memoryCache = DefaultConfigurationFactory.createMemoryCache(context, memoryCacheSize);
			}
	public static MemoryCache createMemoryCache(Context context, int memoryCacheSize) {
		if (memoryCacheSize == 0) {
			ActivityManager am = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
			int memoryClass = am.getMemoryClass();
			if (hasHoneycomb() && isLargeHeap(context)) {
				memoryClass = getLargeMemoryClass(am);
			}
			memoryCacheSize = 1024 * 1024 * memoryClass / 8;
		}
		return new LruMemoryCache(memoryCacheSize);
	}

因此UIL框架默认采用的内存缓存为LruMemoryCache

LruMemoryCache的核心是采用了最近最少使用算法(Lru),其大小为app可用内存的1/8, 实现了MemoryCache接口,那这个Lru是如何实现的,深拨下源码便可知晓

package com.nostra13.universalimageloader.cache.memory.impl;

import android.graphics.Bitmap;

import com.nostra13.universalimageloader.cache.memory.MemoryCache;

import java.util.Collection;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.Map;

/**
 * A cache that holds strong references to a limited number of Bitmaps. Each time a Bitmap is accessed, it is moved to
 * the head of a queue. When a Bitmap is added to a full cache, the Bitmap at the end of that queue is evicted and may
 * become eligible for garbage collection.<br />
 * <br />
 * <b>NOTE:</b> This cache uses only strong references for stored Bitmaps.
 *
 * @author Sergey Tarasevich (nostra13[at]gmail[dot]com)
 * @since 1.8.1
 */
public class LruMemoryCache implements MemoryCache {

	private final LinkedHashMap<String, Bitmap> map;

	private final int maxSize;
	/** Size of this cache in bytes */
	private int size;

	/** @param maxSize Maximum sum of the sizes of the Bitmaps in this cache */
	public LruMemoryCache(int maxSize) {
		if (maxSize <= 0) {
			throw new IllegalArgumentException("maxSize <= 0");
		}
		this.maxSize = maxSize;
		this.map = new LinkedHashMap<String, Bitmap>(0, 0.75f, true);
	}

	/**
	 * Returns the Bitmap for {@code key} if it exists in the cache. If a Bitmap was returned, it is moved to the head
	 * of the queue. This returns null if a Bitmap is not cached.
	 */
	@Override
	public final Bitmap get(String key) {
		if (key == null) {
			throw new NullPointerException("key == null");
		}

		synchronized (this) {
			return map.get(key);
		}
	}

	/** Caches {@code Bitmap} for {@code key}. The Bitmap is moved to the head of the queue. */
	@Override
	public final boolean put(String key, Bitmap value) {
		if (key == null || value == null) {
			throw new NullPointerException("key == null || value == null");
		}

		synchronized (this) {
			size += sizeOf(key, value);
			Bitmap previous = map.put(key, value);
			if (previous != null) {
				size -= sizeOf(key, previous);
			}
		}

		trimToSize(maxSize);
		return true;
	}

	/**
	 * Remove the eldest entries until the total of remaining entries is at or below the requested size.
	 *
	 * @param maxSize the maximum size of the cache before returning. May be -1 to evict even 0-sized elements.
	 */
	private void trimToSize(int maxSize) {
		while (true) {
			String key;
			Bitmap value;
			synchronized (this) {
				if (size < 0 || (map.isEmpty() && size != 0)) {
					throw new IllegalStateException(getClass().getName() + ".sizeOf() is reporting inconsistent results!");
				}

				if (size <= maxSize || map.isEmpty()) {
					break;
				}

				Map.Entry<String, Bitmap> toEvict = map.entrySet().iterator().next();
				if (toEvict == null) {
					break;
				}
				key = toEvict.getKey();
				value = toEvict.getValue();
				map.remove(key);
				size -= sizeOf(key, value);
			}
		}
	}

	/** Removes the entry for {@code key} if it exists. */
	@Override
	public final Bitmap remove(String key) {
		if (key == null) {
			throw new NullPointerException("key == null");
		}

		synchronized (this) {
			Bitmap previous = map.remove(key);
			if (previous != null) {
				size -= sizeOf(key, previous);
			}
			return previous;
		}
	}

	@Override
	public Collection<String> keys() {
		synchronized (this) {
			return new HashSet<String>(map.keySet());
		}
	}

	@Override
	public void clear() {
		trimToSize(-1); // -1 will evict 0-sized elements
	}

	/**
	 * Returns the size {@code Bitmap} in bytes.
	 * <p/>
	 * An entry's size must not change while it is in the cache.
	 */
	private int sizeOf(String key, Bitmap value) {
		return value.getRowBytes() * value.getHeight();
	}

	@Override
	public synchronized final String toString() {
		return String.format("LruCache[maxSize=%d]", maxSize);
	}
}

LruMemoryCache.java核心作用是:

LruMemoryCache保存了限量的bitmap,并且每一张bitmap都保持着强引用关系;当把一个bitmap添加到满了的缓存的时候,在缓存末尾的图片会被删除,并且将被垃圾回收;而每一次访问一个bitmap的时候,会将该bitmap添加到队首;

LruMemoryCache.java核心成员是:

   . LinkedHashMap<String, Bitmap> map 实现Lru算法的关键
   . int maxSize 缓存的最大大小
   . int size 当前缓存的大小

LruMemoryCache.java代码分析
   第36行,构造LinkedHashMap,在构造的时候,将LinkedHashMap的参数accessOrder设置为true,其是Lru的关键所在,其表示map里面元素的排序顺序,为true的时候表示按照访问顺序来排序,为false的时候表示按照插入的顺序来访问;(LinkedHashMap这里不做特别分析,后面会在集合/多线程章节中详细分解)
  第44行,get方法通过传入的key,获得对应的bitmap
  第56行,put方法就是传入key和bitmap,放入到map中,放入的时候会做如下操作
    首先根据sizeOf方法获得该图片的具体的字节大小,计算当前缓存的当前总大小,即62行的方法的含义
    然后将该bitmap存入到map中,我们知道map的put方法中,key可以相同,但是value会覆盖前一个值,map的put方法返回的当前key的前一个对象,即63行的方法的含义
    如果通过map的put方法返回的对象已经存在,则我们需要重新计算当前缓存大小,即减去该图片的字节,即64,65行的方法的含义
    即将你存入一个对象,那么就得考虑是否超过了缓存的最大大小,顾通过trimToSize方法,删除最旧的对象,直到剩余总数等于或低于请求的大小。因为LinkedHashMap里面的数据是有序的,而且创建LinkedHashMap的时候,数据排序是以访问顺序来定的,即你只要使用了map的里面的元素(通过get方法)那么这个元素就会被排至末尾(Lru算法),顾在这个map中,排在对头的元素就是使用次数就是最少的;所以trimToSize方法使用了迭代器的方式,从头一个元素开始删除,直到大小小于等于maxSize
  第105行,根据key值移除map中的对象,并重新计算当前缓存大小;


  1. 我们在看下硬盘缓存DiskCache

在初始化ImageLoader的时候,我们可以通过如下代码配设置使用何种硬盘缓存

config.diskCache(... ...)

默认的UIL框架中,硬盘缓存使用的是什么类?硬盘缓存是如何保存图片的?

在ImageLoaderConfiguration.java中,有如下代码

			if (diskCache == null) {
				if (diskCacheFileNameGenerator == null) {
					diskCacheFileNameGenerator = DefaultConfigurationFactory.createFileNameGenerator();
				}
			}

在这段代码中,我们创建了FileNameGenerator的实例diskCacheFileNameGenerator,该类的作用是命名保存的图片,那在硬盘中一张图片的命名形式是怎么样的

	public static FileNameGenerator createFileNameGenerator() {
		return new HashCodeFileNameGenerator();
	}
	
public class HashCodeFileNameGenerator implements FileNameGenerator {
	@Override
	public String generate(String imageUri) {
		return String.valueOf(imageUri.hashCode());
	}
}

这个类比较简单,是根据图片的url的哈希值来进行命名的。有了文件命名方式,我们自然就可以很好的创建硬盘缓存了,接着看ImageLoaderConfiguration.java中的一段代码

diskCache = DefaultConfigurationFactory
						.createDiskCache(context, diskCacheFileNameGenerator, diskCacheSize, diskCacheFileCount);

	public static DiskCache createDiskCache(Context context, FileNameGenerator diskCacheFileNameGenerator,
			long diskCacheSize, int diskCacheFileCount) {
		File reserveCacheDir = createReserveDiskCacheDir(context);
		if (diskCacheSize > 0 || diskCacheFileCount > 0) {
			File individualCacheDir = StorageUtils.getIndividualCacheDirectory(context);
			try {
				return new LruDiskCache(individualCacheDir, reserveCacheDir, diskCacheFileNameGenerator, diskCacheSize,
						diskCacheFileCount);
			} catch (IOException e) {
				L.e(e);
				// continue and create unlimited cache
			}
		}
		File cacheDir = StorageUtils.getCacheDirectory(context);
		return new UnlimitedDiskCache(cacheDir, reserveCacheDir, diskCacheFileNameGenerator);
	}

可见我们会首先尝试创建LruDiskCache硬盘缓存,如果LruDiskCache创建失败,我们会创建UnlimitedDiskCache硬盘缓存;

    LruDiskCache实现了DiskCache接口,DiskCache是硬盘缓存的统一接口,LruDiskCache也是基于Lru算法实现的一个硬盘缓存,同时他的缓存是有最大限度的,在彻底掌握如何有效处理高清大图:Android-Universal-Image-Loader框架解析(一)基本使用中,我们通过如下方法设置了最大硬盘存储空间为50M

config.diskCacheSize(50 * 1024 * 1024);

    UnlimitedDiskCache继承自BaseDiskCache类,BaseDiskCache实现了DiskCache接口,他没有采用Lru算法,也没有限制最大缓存大小;针对这个类我们就不做单独分析了,有兴趣的自己看下源码

总结:在硬盘缓存中
   . 默认缓存到硬盘缓存的实现类是LruDiskCache.java,他采用了Lru算法保证按访问顺序排列,并限制了最大存储大小
   . 默认缓存到硬盘的图片的命名方式是以图片URL的哈希值来命名的

我们来看下LruDiskCache.java

LruDiskCache.java核心成员是:

   . DiskLruCache cache,真正的硬盘缓存类,LruDiskCache通过操作DiskLruCache来实现对象的增删改查操作
   . FileNameGenerator fileNameGenerator,如何命名缓存到硬盘中的图片,默认为图片URL的哈希值
   . Bitmap.CompressFormat compressFormat,图片的压缩格式,默认为png,因为png是无损压缩
   . int compressQuality ,图片的压缩质量,默认为100,compressQuality和compressFormat会被bitmap的compress方法所使用;bitmap的compress的圆形是

compress(CompressFormat format, int quality, OutputStream stream)

  其含义是按照format指定的图片格式以及quality指定的画质,将图片转换为stream输出流
举个例子,如果compressFormat为png,compressQuality的值为60,则这张图片会按照png的图片格式,保留60%的图像品质写入到输出流中,当然这种方式会使得图片失真;
  compress方法压缩的是存储大小,即你放到disk上的大小,他并没有改变图片占用内存的大小,不过我们可以通过BitmapFactory.Options options = new BitmapFactory.Options()的方式改变图片占用内存的大小

LruDiskCache.java核心方法

  . 初始化

	private void initCache(File cacheDir, File reserveCacheDir, long cacheMaxSize, int cacheMaxFileCount)
			throws IOException {
		try {
			cache = DiskLruCache.open(cacheDir, 1, 1, cacheMaxSize, cacheMaxFileCount);
		} catch (IOException e) {
			L.e(e);
			if (reserveCacheDir != null) {
				initCache(reserveCacheDir, null, cacheMaxSize, cacheMaxFileCount);
			}
			if (cache == null) {
				throw e; //new RuntimeException("Can't initialize disk cache", e);
			}
		}
	}

通过调用DiskLruCache的全局静态方法完成DiskLruCache实例的创建cache

  . 根据图片URL获得存储的文件

	@Override
	public File get(String imageUri) {
		DiskLruCache.Snapshot snapshot = null;
		try {
			snapshot = cache.get(getKey(imageUri));
			return snapshot == null ? null : snapshot.getFile(0);
		} catch (IOException e) {
			L.e(e);
			return null;
		} finally {
			if (snapshot != null) {
				snapshot.close();
			}
		}
	}

可见硬盘缓存中在得到DiskLruCache实例之后,LruDiskCache变可以拿到DiskLruCache的快照类,通过快照类就可以找到对应的保存的文件

  . 根据图片URL将图片缓存起来

	@Override
	public boolean save(String imageUri, InputStream imageStream, IoUtils.CopyListener listener) throws IOException {
		DiskLruCache.Editor editor = cache.edit(getKey(imageUri));
		if (editor == null) {
			return false;
		}

		OutputStream os = new BufferedOutputStream(editor.newOutputStream(0), bufferSize);
		boolean copied = false;
		try {
			copied = IoUtils.copyStream(imageStream, os, listener, bufferSize);
		} finally {
			IoUtils.closeSilently(os);
			if (copied) {
				editor.commit();
			} else {
				editor.abort();
			}
		}
		return copied;
	}

可见硬盘缓存中在得到DiskLruCache实例之后,LruDiskCache变可以拿到DiskLruCache的Editor类,通过Editor类就可以将对应的输入流写到DiskLruCache的Entry中

总结:
  . LruDiskCache是硬盘缓存对外暴露的实体类,其具体实现的硬盘缓存是DiskLruCache;
  . LruDiskCache默认压缩图片格式是PNG,默认压缩比率是100,即保持原画质
  . LruDiskCache默认硬盘缓存的文件名是图片URL的哈希值
  . LruDiskCache在初始化的时候会获得DiskLruCache实例
  . LruDiskCache通过DiskLruCache的快照类完成获取缓存的文件
  . LruDiskCache通过DiskLruCache的编辑类完成将流或者图片写入到DiskLruCache的Entry中,从而完成添加的动作
  . LruDiskCache通过DiskLruCache的remove方法根据传入的图片URL完成对一个缓存的删除操作


  1. 既然硬盘缓存的真正实现是DiskLruCache,那就让我们细看一下他的实现

  DiskLruCache.java概览
DiskLruCache缓存在文件系统中创建了一个有限的空间,在这个空间当中存储了一条条的数据,每一条的数据是由key,value来对应的;key需要满足[a-z0-9_-]{1,64}的正则表达,value是由字节组成的数据,可被流或者文件获取;每一条数据我们可以称呼为一个Entry

  DiskLruCache.java展示
在默认情况下,被缓存的文件的目录地址是/data/packagename/cache/uil-images目录,下图展示的是demo当中的被缓存的内容
在这里插入图片描述
  一个DiskLruCache最重要的部分有两个
. journal文件,例如上图中的journal
. 每一条cache entry,例如上图中的6cdg7wdkrtb8dmd05gdj8x87n0

打开journal文件你可以发现
在这里插入图片描述

  其中红色方框依次表示:libcore.io.DiskLruCache,DiskLruCache的版本,应用程序的版本,每个条目中保存值的个数
  其中绿色方框表示被缓存的内容的操作记录

  这里我们只要知道journal文件里面有哪些内容即可,就不详细了解journal文件的生成过程了,其过程在DiskLruCache.java的rebuildJournal方法中

  我们在打开6cdg7wdkrtb8dmd05gdj8x87n0文件看下
在这里插入图片描述
可以发现里面多是一些字节信息

  顾我们可以简单的猜测下DiskLruCache的增删改查的过程
. 增加一个缓存:新建一个根据图片url的哈希值来命名的文件,将缓存以字节的形式存入并更新journal文件
. 删除一个缓存:在一个map,根据缓存图片的url来进行查找,从而删除缓存
. 查询一个缓存:在map中,科举图片url来查找具体的文件, 并读取文件保存的字节信息

  DiskLruCache.java核心成员
.LinkedHashMap<String, Entry> lruEntries:核心成员,使用Lru算法存储缓存的内容
.static final String JOURNAL_FILE = “journal”;:硬盘缓存内容操作镜像文件,通过这个文件可以知道缓存被进行了怎样的操作
.Callable cleanupCallable:硬盘缓存是有大小限制的,顾当缓存大小超过总大小的时候,就会启动cleanupCallable来执行清空任务,直到小于总缓存大小;这点和LruMemoryCache没有什么差别;这里我们注意到这是一个callable接口,他和runable接口一样,都是实现线程的接口,只不过callable接口实现了call方法,runable接口实现了run方法;callable接口可以有返回值,而runable接口没有返回值;
    注意点:
Callable接口支持返回执行结果,此时需要调用FutureTask.get()方法实现,此方法会阻塞主线程直到获取‘将来’结果;当不调用此方法时,主线程不会阻塞!

  DiskLruCache.java核心方法

    . 打开DiskLruCache

	public static DiskLruCache open(File directory, int appVersion, int valueCount, long maxSize, int maxFileCount)
			throws IOException {
	/*省略部分代码*/
		// If a bkp file exists, use it instead.
		File backupFile = new File(directory, JOURNAL_FILE_BACKUP);
		if (backupFile.exists()) {
			File journalFile = new File(directory, JOURNAL_FILE);
			// If journal file also exists just delete backup file.
			if (journalFile.exists()) {
				backupFile.delete();
			} else {
				renameTo(backupFile, journalFile, false);
			}
		}
/*以上都是对journal文件的操作*/
		// Prefer to pick up where we left off.
		DiskLruCache cache = new DiskLruCache(directory, appVersion, valueCount, maxSize, maxFileCount);
		if (cache.journalFile.exists()) {
			try {
				cache.readJournal();
				cache.processJournal();
				cache.journalWriter = new BufferedWriter(
						new OutputStreamWriter(new FileOutputStream(cache.journalFile, true), Util.US_ASCII));
				return cache;
				/*以上都是对journal文件的操作*/
			} catch (IOException journalIsCorrupt) {
				System.out
						.println("DiskLruCache "
								+ directory
								+ " is corrupt: "
								+ journalIsCorrupt.getMessage()
								+ ", removing");
				cache.delete();
			}
		}

		// Create a new empty cache.
		directory.mkdirs();
		/*创建空的DiskLruCache*/
		cache = new DiskLruCache(directory, appVersion, valueCount, maxSize, maxFileCount);
		/*在journal文件中写入固定内容*/
		cache.rebuildJournal();
		return cache;
	}

  . 存储一个缓存,主要有两个步骤
第一步是获得DiskLruCache类的Editor类,对应edit方法
第二步是将Edit类中的保存的字节信息写到到Entry中,对应commit方法

	private synchronized Editor edit(String key, long expectedSequenceNumber) throws IOException {
		checkNotClosed();
		validateKey(key);
		Entry entry = lruEntries.get(key);
		if (expectedSequenceNumber != ANY_SEQUENCE_NUMBER && (entry == null
				|| entry.sequenceNumber != expectedSequenceNumber)) {
			return null; // Snapshot is stale.
		}
		if (entry == null) {
			entry = new Entry(key);
			lruEntries.put(key, entry);
		} else if (entry.currentEditor != null) {
			return null; // Another edit is in progress.
		}

		Editor editor = new Editor(entry);
		entry.currentEditor = editor;

		// Flush the journal before creating files to prevent file leaks.
		journalWriter.write(DIRTY + ' ' + key + '\n');
		journalWriter.flush();
		return editor;
	}

  第4行,根据传入key,在lruEntriesmap中查找一个Entry entry,一个entry表示一个缓存的字节内容
  第11行,如果这个entry为空,则新建一个entry,并保存到lruEntries中
  第16行,创建DiskLruCache的Edit类

当创建完Edit类之后,LruDiskCache.java在save方法中

	@Override
	public boolean save(String imageUri, InputStream imageStream, IoUtils.CopyListener listener) throws IOException {
		DiskLruCache.Editor editor = cache.edit(getKey(imageUri));
		if (editor == null) {
			return false;
		}

		OutputStream os = new BufferedOutputStream(editor.newOutputStream(0), bufferSize);
		boolean copied = false;
		try {
			copied = IoUtils.copyStream(imageStream, os, listener, bufferSize);
		} finally {
			IoUtils.closeSilently(os);
			if (copied) {
				editor.commit();
			} else {
				editor.abort();
			}
		}
		return copied;
	}

通过editor.newOutputStream(0)的方法来新建一个输出流,然后将需要被缓存的内容的流写入到输出流中,然后通过调用editor.commit()完成最后的刷新操作

  . 获得一个存储的缓存,这里也分为两个步骤
第一步:根据key查找对应的快照,对应get方法
第二步:根据Snapshot的snapshot.getFile(0)方法返回这个缓存的文件

	public synchronized Snapshot get(String key) throws IOException {
		checkNotClosed();
		validateKey(key);
		Entry entry = lruEntries.get(key);
		if (entry == null) {
			return null;
		}

		if (!entry.readable) {
			return null;
		}

		// Open all streams eagerly to guarantee that we see a single published
		// snapshot. If we opened streams lazily then the streams could come
		// from different edits.
		File[] files = new File[valueCount];
		InputStream[] ins = new InputStream[valueCount];
		try {
			File file;
			for (int i = 0; i < valueCount; i++) {
				file = entry.getCleanFile(i);
				files[i] = file;
				ins[i] = new FileInputStream(file);
			}
		} catch (FileNotFoundException e) {
			// A file must have been deleted manually!
			for (int i = 0; i < valueCount; i++) {
				if (ins[i] != null) {
					Util.closeQuietly(ins[i]);
				} else {
					break;
				}
			}
			return null;
		}

		redundantOpCount++;
		journalWriter.append(READ + ' ' + key + '\n');
		if (journalRebuildRequired()) {
			executorService.submit(cleanupCallable);
		}

		return new Snapshot(key, entry.sequenceNumber, files, ins, entry.lengths);
	}

这里删除和关闭方法也不做分析,比较简单

总结:
. DiskLruCache.java通过LinkedHashMap的方式来实现Lru算法
. DiskLruCache.java 硬盘缓存是有大小限制的,顾当缓存大小超过总大小的时候,就会启动 cleanupCallable来执行清空任务,知道小于总缓存大小
. LinkedHashMap<String, Entry> lruEntries里面的key对应图片URL的哈希值,其Entry保存了该key,
  那么当新增一个缓存的时候,我们会创建一个Entry,这个Entry练保存了缓存的key(为以后查找做准备),然后创建Editor类,将缓存的字节信息输出到输出流中,即目标缓存文件中;
  当查找一个缓存的时候,我们会根据key在lruEntries查找这样Entry,找到对应的Entry之后,创建snapshot类,通过Entry找到对应的文件并获取文件的输入流,获取里面的字节信息,在调用snapshot的getfile方法返回这个缓存
  当清空一个缓存的时候,删除掉这个key对应的所有缓存文件,然后重新计算当前缓存的大小,然后从lruEntries中删除对应的key


  1. 最后看下硬盘缓存UnlimitedDiskCache

  .UnlimitedDiskCache.java概述
UnlimitedDiskCache也是硬盘缓存的一种,在创建LruDiskCache失败的时候,就会创建UnlimitedDiskCache,他缓存大小没有限制,也不采用Lru来控制缓存;UnlimitedDiskCache继承自BaseDiskCache类,BaseDiskCache实现了DiskCache接口;而LruDiskCache只实现了DiskCache接口;
UnlimitedDiskCache针对缓存的增删改查比较简单,就是通过缓存的url地址,查找到对应的文件,然后通过对流的操作来实现增删改查的操作

似乎这样UnlimitedDiskCache的实现方式比较简单,那为什么LruDiskCache不按照这样的方式去做了?那还不是因为LruDiskCache支持Lru算法,并且缓存大小有限制

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值