unity增量更新(配置表、lua、资源)

注:本文只包含核心部分的代码,仅供参考
流程
在这里插入图片描述

备份全量文件

打app包的时候,备份一份全量的配置表、lua(用以打增量时做自动差异比较)
资源文件零碎,可以考虑使用人工选择某个资源打增量的方式

你可能会用到的

//using System.IO;

// 获取目录中的所有文件(会遍历子目录)
public static void GetAllFiles(string path, ref List<string> files)
{
    var fs = Directory.GetFiles(path);
    var dirs = Directory.GetDirectories(path);
    foreach(var f in fs)
    {
        var ext = Path.GetExtension(f);
        if(ext.Equals(".meta")) continue;
        files.Add(f.Replace('\\','/'));
    }
    foreach(var d in dirs)
    {
        GetAllFiles(d, ref files);
    }
}
//using UnityEditor;

// 显示进度条
public static void UpdateProgress(int progress, int max, string desc)
{
    var title = "Processing...[" + progress + "/" + max + "]";
    var value = (float)progress / (float)max;
    EditorUtility.DisplayProgressBar(title, desc, value);
    if(value >= 1)
        EditorUtility.ClearProgressBar();
}
//using System.IO;
//判断文件是否存在
File.Exists(path);
//删除文件
File.Delete(path);
//复制文件
File.Copy(path1, path2, true);
//移动文件
File.Move(path1, path2);
//判断目录是否存在
Directory.Exists(path);
//创建目录
Directory.CreateDirectory(path);
//删除目录
Directory.Delete(path, true);
//获取文件的路径
Path.GetDirectoryName(fpath);
//获取文件名称(带后缀)
Path.GetFileName(fpath);

//using UnityEditor;
//刷新Unity的Assets目录,特别是有文件变更的时候,最好调用一下这个
AssetDatabase.Refresh();

自动差异比较

打增量包的时候,需要先比对工程内的文件和之前备份的文件,过滤出有差异的文件,拷贝到一个临时目录(比如叫lua_update这个目录必须在Assets目录中,因为打AssetBundle的时候只会识别到Assets目录中的文件),用以打成AssetBundle。
注意.lua后缀是不会被AssetBundle识别的,所以lua文件要加上.bytes后缀,比如Game.lua.bytes,另外,建议打AssetBundle之前做一下加密,不要直接暴露明文的lua和配置
你可能需要的:

//using System;
//using System.IO;
//using System.Security.Cryptography;


// 获取文件的md5值
public static string GetMD5Hash(string pathName)
{
	if(!File.Exists(pathName))
	{
		Debug.LogError("GetMD5Hash Error, file not exist: " + pathName);
		return "";
	}
	string strResult = "";
	string strHash = "";

	byte[] bytesHash;

	FileStream fs = null;
	MD5CryptoServiceProvider oMD5Hasher = new MD5CryptoServiceProvider();
	try
	{
		fs = new FileStream(pathName, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);

		bytesHash = oMD5Hasher.ComputeHash(fs);
		fs.Close();
		strHash = BitConverter.ToString(bytesHash);
		strHash = strHash.Replace("-", "");

		strResult = strHash;
	}
	catch (System.Exception ex)
	{
		Debug.LogError("read md5 file error :" + pathName + " e: " + ex.ToString());
	}

	return strResult;
}
//using System.Text;
//using System;
//using System.Security.Cryptography;
//using System.IO;

/// <summary>
/// AES 加密(高级加密标准,是下一代的加密算法标准,速度快,安全级别高,目前 AES 标准的一个实现是 Rijndael 算法)
/// </summary>
/// <param name="encryptByte">待加密密文</param>
/// <param name="encryptKey">加密密钥</param>
public static byte[] AESEncrypt(byte[] encryptByte, string encryptKey)
{
	if (encryptByte.Length == 0)
	{
		//throw (new Exception("明文不得为空"));
		return new byte[0];
	}
	if (string.IsNullOrEmpty(encryptKey)) { throw (new Exception("密钥不得为空")); }
	byte[] bytesrEncrypt;
	byte[] btIV = Convert.FromBase64String("CBFdfv7d/ChblnhfqKBto3qbvb=3");
	byte[] btSalt = Convert.FromBase64String("chv34qtlerl/VBc4najgnlb");
	Rijndael m_providerAES = Rijndael.Create();
	try
	{
		MemoryStream stream = new MemoryStream();
		PasswordDeriveBytes pdb = new PasswordDeriveBytes(encryptKey, btSalt);
		ICryptoTransform transform = m_providerAES.CreateEncryptor(pdb.GetBytes(32), btIV);
		CryptoStream csstream = new CryptoStream(stream, transform, CryptoStreamMode.Write);
		csstream.Write(encryptByte, 0, encryptByte.Length);
		csstream.FlushFinalBlock();
		bytesrEncrypt = stream.ToArray();
		stream.Close(); stream.Dispose();
		csstream.Close(); csstream.Dispose();
	}
	catch (IOException ex) { throw ex; }
	catch (CryptographicException ex) { throw ex; }
	catch (ArgumentException ex) { throw ex; }
	catch (Exception ex) { throw ex; }
	finally { m_providerAES.Clear(); }
	return bytesrEncrypt;
}


/// <summary>
/// AES 解密(高级加密标准,是下一代的加密算法标准,速度快,安全级别高,目前 AES 标准的一个实现是 Rijndael 算法)
/// </summary>
/// <param name="decryptByte">待解密密文</param>
/// <param name="decryptKey">解密密钥</param>
public static byte[] AESDecrypt(byte[] decryptByte, string decryptKey)
{
	if (decryptByte.Length == 0)
	{
		//throw (new Exception("密文不得为空"));
		return new byte[0];
	}
	if (string.IsNullOrEmpty(decryptKey)) { throw (new Exception("密钥不得为空")); }
	byte[] bytesDecrypt;
	byte[] btIV = Convert.FromBase64String("CBFdfv7d/ChblnhfqKBto3qbvb=3");
	byte[] btSalt = Convert.FromBase64String("chv34qtlerl/VBc4najgnlb");
	Rijndael providerAES = Rijndael.Create();
	try
	{
		MemoryStream stream = new MemoryStream();
		PasswordDeriveBytes pdb = new PasswordDeriveBytes(decryptKey, btSalt);
		ICryptoTransform transform = providerAES.CreateDecryptor(pdb.GetBytes(32), btIV);
		CryptoStream csstream = new CryptoStream(stream, transform, CryptoStreamMode.Write);
		csstream.Write(decryptByte, 0, decryptByte.Length);
		csstream.FlushFinalBlock();
		bytesDecrypt = stream.ToArray();
		stream.Close(); stream.Dispose();
		csstream.Close(); csstream.Dispose();
	}
	catch (IOException ex) { throw ex; }
	catch (CryptographicException ex) { throw ex; }
	catch (ArgumentException ex) { throw ex; }
	catch (Exception ex) { throw ex; }
	finally { providerAES.Clear(); }
	return bytesDecrypt;
}
加密lua
//LuaFramework.AppConst.LuaSecretKey = "Fd3ht4mlgOhc/=fjC+dk2B";
byte[] allBytes = File.ReadAllBytes(f);
byte[] encryptBytes = CryptoUtil.AESEncrypt(allBytes, LuaFramework.AppConst.LuaSecretKey);
if (File.Exists(newfilePath))
{
	File.Delete(newfilePath);
}
File.WriteAllBytes(newfilePath, encryptBytes);
解密lua
//ab 为lua的AssetBundle
private byte[] ReadBytesFromAssetBundle(string fileName)
{
	...
	//这个LuaUpdateBundle.bundle要通过AssetBundle.LoadFromFile从下载保存的目录中加载
	var ab = LuaUpdateBundle.bundle;
	// 以全路径读取,防止重名
	var luaFileName = string.Format("assets/lua_update/{0}.bytes", fileName);
	TextAsset luaCode = ab.LoadAsset<TextAsset>(luaFileName);
	byte[] luaBytes = CryptoUtil.AESDecrypt(luaCode.bytes, LuaFramework.AppConst.LuaSecretKey);
	Resources.UnloadAsset(luaCode);
	...
}

private string ReadStringFromAssetBundle(string fileName)
{
	...
	//这个LuaUpdateBundle.bundle要通过AssetBundle.LoadFromFile从下载保存的目录中加载
	var ab = LuaUpdateBundle.bundle;
	// 以全路径读取,防止重名
	var luaFileName = string.Format("assets/lua_update/{0}.bytes", fileName);
	TextAsset luaCode = ab.LoadAsset<TextAsset>(luaFileName);
	var luaStr = System.Text.Encoding.GetEncoding(65001).GetString(AESDecrypt(luaCode.bytes, LuaFramework.AppConst.LuaSecretKey));
	Resources.UnloadAsset(luaCode);
	...
}

打AssetBundle
//打AssetBundle
AssetBundleBuild[] buildMap = new AssetBundleBuild[1];
//因为要通过http或https下载,必须以.zip结尾
buildMap[0].assetBundleName = "lua_update.bundle.zip"; 
//注意这里的路径是相对Assets的
buildMap[0].assetNames = new string[]{ "Assets/lua_update/Game.lua.bytes", "Assets/lua_update/Hello.lua.bytes" };
string outpath = Application.dataPath + "/../Bin";
BuildPipeline.BuildAssetBundles(outpath, buildMap, BuildAssetBundleOptions.ChunkBasedCompression, BuildTarget.Android);

下载更新

构建一个update_info.json,格式如下

[
    {
        "version": "1.15.0",
        "update_type": "1",
        "update_list": [
            {
                "name": "mygame.apk",
                "size": "606469145",
                "md5": "6496571659E53ECD774352E4D309AF31",
                "url": "1.15.0/mygame_1.15.0.apk.zip"
            }
        ]
    },
    {
        "version": "1.15.1",
        "update_type": "0",
        "update_list": [
            {
                "name": "lua_update.bundle",
                "size": "1440",
                "md5": "755C4800303C19ECA9206117AAED1478",
                "url": "update_1.15.1/lua_update.bundle.zip"
            },
            {
                "name": "cfg_update.bundle",
                "size": "2392",
                "md5": "A47D1E8D1B31AD81B2FDD1C6A852E474",
                "url": "update_1.15.1/cfg_update.bundle.zip"
            }
        ]
    }
]

通过WWW远程获取到上面的json文件,通过版本比较和计算得出需要的更新列表
对应的数据类:UpdateInfoList.cs

//UpdateInfoList.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class UpdateInfoItem
{
   public string name;
   public string size;
   public string md5;
   public string savepath;
   /// <summary>
   /// 这个是相对的url
   /// </summary>
   public string url;
   /// <summary>
   /// 这个才是完整的url
   /// </summary>
   public string fullUrl;
   

   public void InitSavePath()
   {
#if UNITY_EDITOR
       savepath = Application.dataPath + "/update/" + name.ToLower();
#else
       savepath = Application.persistentDataPath + "/update/" + name.ToLower();
#endif
   }

   public void InitFullUrl()
   {
   	fullUrl = "https://linxinfa.game.com/" + url;
   }
}

public class UpdateInfoList
{
   public Dictionary<string, UpdateInfoItem> updateList;

   public List<UpdateInfoItem> GetList()
   {
       return new List<UpdateInfoItem>(updateList.Values);
   }


   public UpdateInfoList()
   {
       updateList = new Dictionary<string, UpdateInfoItem>();
   }

   public void Clear()
   {
       updateList.Clear();
   }

   public int Count()
   {
       return updateList.Count;
   }

   public UpdateInfoItem Add(string infoJson, ref bool isApp)
   {
       var item = JsonMapper.ToObject<UpdateInfoItem>(infoJson);
       item.InitSavePath();
       item.InitFullUrl();
       if (item.name.EndsWith(".apk") || item.name.EndsWith(".ipa"))
       {
           updateList.Clear();
           updateList.Add(item.name, item);
           isApp = true;
       }
       else
       {
           if(!updateList.ContainsKey(item.name))
           {
               updateList.Add(item.name, item);
           }
           isApp = false;
       }
       return item;
   }

   /// <summary>
   /// 总大小
   /// </summary>
   public long TotalSize()
   {
       long size = 0;
       foreach (var item in updateList.Values)
       {
           size += long.Parse(item.size);
       }
       return size;
   }

   public string TotalSizeFormat()
   {
       long size = TotalSize();
       if (size >= 1024 * 1024)
       		return (size / 1024f / 1024f).ToString("#0.00MB");
       else if (size >= 1024)
       		return (size / 1024f).ToString("#0.00KB");
       else
       		return size + "B";
   }
}

写一个更新的类: GameUpdateHelper.cs,用来监控下载过程

using UnityEngine;
using System.Collections;
using System.IO;
using System.Collections.Generic;
using LitJson;

public enum UpdateResult
{
	NONE,
	Success,                    // 更新成功
	GetVersionFail,             // 获取版本号失败
	GetFileListFail,            // 获取文件列表失败
	DownloadInComplete,         // 下载文件不完全
	DownloadFail,               // 下载文件失败
	CopyDataFileFail,           // 拷贝文件失败
	GenerateVersionFileFail,    // 生成版本号文件失败
	GenerateFileListFail,       // 生成文件列表文件失败
	CleanCacheFail,             // 生成文件列表文件失败
	LoadRomoteFailListError,    // 读取下载的文件列表失败
	GetCheckFileFail,           // 下载MD5对比文件失败
}

public enum UpdateStep
{
	NONE,
	CheckVersion,
	GetFileList,
	CompareRes,
	AskIsDonwload,               // 询问是否下载文件
	DownloadRes,
	CheckRes,
	DecompressRes,
	CleanCache,
	FINISH,
}

public class GameUpdateHelper : MonoBehaviour
{
	
	private JsonData m_updateInfoJsonData;
	
	/// <summary>
	/// 更新列表
	/// </summary>
	private UpdateInfoList m_updateInfoList = new UpdateInfoList();
	private DownloadHelper m_downloader = null;
	
	// 更新状态
	public UpdateStep CurUpdateStep { get { return m_curUpdateStep; } }
    public UpdateResult CurUpdateResult { get { return m_curUpdateResult; } }
	
	private UpdateStep m_curUpdateStep = UpdateStep.NONE;
    private UpdateResult m_curUpdateResult = UpdateResult.NONE;
   
	
	
	public long AlreadyDownloadSize { get { return (null != m_downloader) ? m_downloader.AlreadyDownloadSize : 0; } }
	public long NeedDownloadTotalSize { get { return m_updateInfoList.TotalSize(); } }
	
	private UpdateStep m_lastUpateStep = UpdateStep.NONE;
	
	public void InitUpdateInfoList()
	{
		var updateInfoTxt = "[]"; //更新列表的json文本,可以通过WWW从http服务器拉取
		m_updateInfoJsonData = JsonMapper.ToObject(updateInfoTxt);
		//根据json构造UpdateInfoList
		if (null == m_updateInfoJsonData)
        {
            UpdateFinish(UpdateResult.Success);
        }
        else
        {
            for (int i = m_updateInfoJsonData.Count - 1; i >= 0; --i)
            {
                var data = m_updateInfoJsonData[i];
                var version = (string)data["version"];
                if (CompareVersion(version, m_localAppVersion) > 0)
                {
                    var update_list = data["update_list"];
                    for (int j = 0, cnt2 = update_list.Count; j < cnt2; ++j)
                    {
                        var updateItem = update_list[j];
                        var item = m_updateInfoList.Add(updateItem.ToJson(), ref m_isUpdateFullApp);
                        if (m_isUpdateFullApp)
                        {
                            // 整包更新的app下载路径
                            m_updateFullAppSavePath = item.savepath;
                            break;
                        }
                    }
                }
            }
            if (m_updateInfoList.Count() > 0)
            {
                m_curUpdateStep = UpdateStep.AskIsDonwload;
            }
            else
            {
                UpdateFinish(UpdateResult.Success);
            }
        }
	}
	
	void UpdateFinish(UpdateHelper.UpdateResult result)
    {
        m_curUpdateStep = UpdateHelper.UpdateStep.FINISH;
        m_curUpdateResult = result;
    }
	
	public void StartDownload()
	{
		m_downloader = new DownloadHelper(m_updateInfoList.GetList());
		m_downloader.StartDownload();
	}

	void Update()
	{
		if (m_lastUpateStep != CurUpdateStep)
		{
			switch (m_curUpdateHelper.CurUpdateStep)
			{
				case UpdateStep.AskIsDonwload:
				{
					//TOOD 询问下载,点击确定执行StartDownload()
				}
				break;
				case UpdateStep.FINISH:
				{
					if (UpdateResult.Success == CurUpdateResult)
					{
						//TODO 下载完成
					}
					else
					{
						//TODO 下载失败
					}
				}
				break;
			}
			m_lastUpateStep = CurUpdateStep;
		}
		if (m_lastUpateStep == UpdateHelper.UpdateStep.DownloadRes)
		{
			Debug.Log("下载中: {0}/{1}", AlreadyDownloadSize, NeedDownloadTotalSize);
		}
	}
}

下载类:DownloadHelper.cs,开启独立的线程下载

//DownloadHelper.cs
using UnityEngine;
using System.Collections;
using System.IO;
using System.Net;

using System.Threading;
using GCGame;
using System.Collections.Generic;
using System;
using System.Net.Security;
using System.Security.Cryptography.X509Certificates;

public class DownloadHelper
{
    private HttpWebRequest m_request;
    private Stream m_fs, m_ns;
    private Thread m_thread;
    private byte[] m_buffer;
    private int m_readSize;
    private int m_curDownloadFileIndex;


    private List<UpdateInfoItem> m_updateInfoList;
    private UpdateInfoItem m_curUpdateInfo;


    public long AlreadyDownloadSize { get { return m_alreadyDownloadSize; } }
    private long m_alreadyDownloadSize = 0;

    public DownloadState downlodadState;

    public enum DownloadState
    {
        None,
        Ready,
        Ing,
        CommonError,
        Md5Error,
        Finish,
    }


    public DownloadHelper(List<UpdateInfoItem> updateInfoList)
    {
        downlodadState = DownloadState.None;
        m_updateInfoList = updateInfoList;
        m_buffer = new byte[1024 * 8];
    }

    public void ResetState()
    {
        downlodadState = DownloadState.None;
    }

    public void StartDownload()
    {
        downlodadState = DownloadState.Ready;
        if (null == m_updateInfoList || 0 == m_updateInfoList.Count)
        {
            DownloadFinish();
            return;
        }
        DownloadNext(false);
    }

    public static string AddTimestampToUrl(string url)
    {
        return url + "?" + DateTime.Now.Millisecond.ToString();
    }

    public bool isDone { get { return m_curDownloadFileIndex >= m_updateInfoList.Count; } }

    /// <summary>
    /// 创建一个HttpWebRequest,兼容https的身份验证
    /// </summary>
    /// <param name="url"></param>
    /// <returns></returns>
    private HttpWebRequest MakeOneWebRequest(string url)
    {
        HttpWebRequest request = null;
        if (url.StartsWith("https", StringComparison.OrdinalIgnoreCase))
        {
            ServicePointManager.ServerCertificateValidationCallback = new RemoteCertificateValidationCallback(CheckValidationRequest);
            request = WebRequest.Create(url) as HttpWebRequest;
            request.ProtocolVersion = HttpVersion.Version11;
        }
        else
        {
            request = WebRequest.Create(url) as HttpWebRequest;
        }
        return request;
    }

    private bool CheckValidationRequest(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors sslPolicyErrors)
    {
        //直接返回true即可,信任https身份验证
        return true;
    }

    private void HttpRequest(string url, string saveFullPath)
    {
        try
        {
            Debug.Log("download from : " + url);
            m_request = MakeOneWebRequest(url);
            m_request.Timeout = 10000;
            var dir = Path.GetDirectoryName(m_curUpdateInfo.savepath);
            if(!Directory.Exists(dir))
            {
                Directory.CreateDirectory(dir);
                Debug.Log("CreateDirectory, dir: " + dir);
            }

            if (File.Exists(saveFullPath))  // 下载续传
            {
                if (null != m_fs) m_fs.Close();

                FileInfo tmpF = new FileInfo(saveFullPath);
                m_fs = File.OpenWrite(saveFullPath);

                if (tmpF.Length < long.Parse(m_curUpdateInfo.size))
                {
                    Debug.LogYellow("文件续传, url: " + url);
                    m_fs.Seek(tmpF.Length, SeekOrigin.Current);
                    Debug.Log("m_fs.Seek: " + tmpF.Length);
                    m_request.AddRange((int)m_fs.Length);
                    m_alreadyDownloadSize += m_fs.Length;
                }
                else if (tmpF.Length == long.Parse(m_curUpdateInfo.size))
                {
                    //TODO CheckMd5
                    if (null != m_fs) m_fs.Close();
                    var md5 = Utils.GetMD5Hash(saveFullPath);
                    if(md5 == m_curUpdateInfo.md5)
                    {
                        DownloadNext(true);
                        return;
                    }
                    else
                    {
                        // 重新下载
                        if (null != m_fs) m_fs.Close();
                        m_fs = new FileStream(m_curUpdateInfo.savepath, FileMode.Create);
                    }
                }
                else
                {
                    // 重新下载
                    if (null != m_fs) m_fs.Close();
                    m_fs = new FileStream(m_curUpdateInfo.savepath, FileMode.Create);
                }
            }
            else
            {
                m_fs = new FileStream(saveFullPath, FileMode.Create);
            }

            HttpWebResponse response = (HttpWebResponse)m_request.GetResponse();
            Debug.Log("response.StatusCode: " + response.StatusCode.ToString());
            if (response.StatusCode != HttpStatusCode.PartialContent)
            {
                Debug.Log("server not support partial content.");
            }

            m_ns = response.GetResponseStream();
            m_ns.ReadTimeout = 15000;
            if (m_thread == null)
            {
                m_thread = new Thread(DownloadThread);
                m_thread.Start();
            }
        }
        catch (Exception ex)
        {
            DownloadError(ex);
        }

    }

    private void DownloadThread()
    {
        Debug.Log("download thread start");
        while (true)
        {
            try
            {
                Write2file();
                if (m_readSize == 0)
                {
                    DownloadOneFileEnd();
                    if (isDone) break;
                }
            }
            catch (Exception ex)
            {
                DownloadError(ex);
            }
        }

        Debug.Log("download thread stop");
    }

    private void Write2file()
    {
        m_readSize = m_ns.Read(m_buffer, 0, m_buffer.Length);
        if (m_readSize > 0)
        {
            m_fs.Write(m_buffer, 0, m_readSize);
            m_alreadyDownloadSize += m_readSize;
            Thread.Sleep(0);
        }
    }

    private void DownloadOneFileEnd()
    {
        //TODO check md5
        if (null != m_fs) m_fs.Close();
        var localMd5 = Utils.GetMD5Hash(m_curUpdateInfo.savepath);
        if(localMd5 == m_curUpdateInfo.md5)
        {
            DownloadNext(true);
        }
        else
        {
            if(File.Exists(m_curUpdateInfo.savepath))
            {
                File.Delete(m_curUpdateInfo.savepath);
            }
            if (null != m_fs) m_fs.Close();
            DownloadNext(false);
        }
    }

    private void DownloadNext(bool next)
    {
        if(next)
            ++m_curDownloadFileIndex;
        if (m_curDownloadFileIndex < m_updateInfoList.Count)
        {
            m_curUpdateInfo = m_updateInfoList[m_curDownloadFileIndex];
            var url = m_curUpdateInfo.fullUrl;
            var savePath = m_curUpdateInfo.savepath;
            HttpRequest(url, savePath);
        }
        else
        {
            DownloadFinish();
        }
    }

    private void DownloadError(Exception ex)
    {
        Debug.LogError(ex);
        if (m_ns != null) m_ns.Close();
        if (m_fs != null) m_fs.Close();
        downlodadState = DownloadState.CommonError;
    }

    private void DownloadFinish()
    {
        if (m_ns != null) m_ns.Close();
        if (m_fs != null) m_fs.Close();
        downlodadState = DownloadState.Finish;
    }
}

末尾,分享一个开源的Unity多线程下载Demo
https://github.com/ihaiucom/ihaiu.MultiThreadDownload

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

林新发

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值