基于OPC自定义接口的OPCClient功能改进

在本人之前的一篇博文中描写了如何使用OPC自定义接口开发OPCClient,并使用SignalR实现数据的远程实时传输。

融合SignalR的OPCClient实现环境参数实时监测

但是在使用过程中发现仍有不足之处,本文就是对之前OPCClient的功能改进进行说明。


1.问题描述

原有的OPCClient在测试环境下可以正常运行,但是在实际生产环境下长时间运行后问题就逐渐暴露出来。主要的问题和不足之处有:

  1. 仅能连接一个OPCServer,不支持同时连接多个OPCServer。
  2. OPC标签增多时,界面刷新卡顿。
  3. 长时间运行导致OPCClient所在计算机内存占用率不断升高。
  4. 远程数据传输在物理网络断开重连后不能自动重连。
  5. 与OPCServer断开时会导致资源不能有效释放,从而使OPCServer所在计算机内存占用率不断升高。

2.原因分析

对于OPCClient在实际运行中出现的问题,通过在实际生产环境和测试环境中的交叉测试及对比分析,问题出现的原因主要有以下方面:

  1. 原有代码中仅定义了一个OPCServer类作为全局变量,不能多次复用,所以仅能连接一个OPCServer。
  2. OPC标签组的数据变化时对变化后的数据进行了一些处理,耗费了一定时间,同时监听数据变化的方法和刷新UI界面的方法在线程上有冲突,导致主线程也就是UI线程阻塞、界面卡顿。
  3. 原有代码中定义了一些数据刷新相关的变量,使得数据变化时不断实例化新的对象,导致分配的内存不断增大,最终导致计算机卡顿。
  4. 远程数据传输的连接被动断开时没有监听相应的事件,也没有进一步处理,使得SignalR连接端口后都要人工重启连接。
  5. OPC协议是基于COM进行通信的,OPCClient主动连接OPCServer时,OPCServer被唤醒并开启服务运行,如果OPCClient断开时没有主动释放COM资源或者释放COM资源失败,就会OPCServer所在计算机的相关服务一直运行。OPCClient再次连接就会再启用一个服务,连接的次数越多,启用的服务就越多而且占用的资源就越多。

3.改进方案

在确认了问题原因后,就开始针对每个问题进行具体的修改。

3.1 支持同时连接多个OPCServer

新建集合变量用于存储OPCServer连接实例

   private Dictionary<string, OpcServer> dicServer = new Dictionary<string, OpcServer>();

提取连接OPCServer的方法,从数据库中查询所有OPCServer信息,循环连接。

        //连接按钮点击事件
        private void btnStart_Click(object sender, RoutedEventArgs e)
        {
            ShowAwaitProgress(() =>
            {
                OperationFun operation = new OperationFun();
                DataTable dt = operation.GetServerHost();
                foreach (DataRow dr in dt.Rows)
                {
                    ConnectServer(dr["ServerId"].ToString(), dr["ServerName"].ToString(), dr["ServerHost"].ToString());
                }
            });
            
        }
        //连接服务器
        private void ConnectServer(string serverId, string serverName, string serverHost = "127.0.0.1")
        {
            try
            {
                System.Type t = System.Type.GetTypeFromProgID(serverName, serverHost);
                string prgID = t == null ? _programId : t.GUID.ToString();//部分未注册的OPCServer使用固定标识
                OpcServer server = new OpcServer(prgID, serverHost, serverName,serverId);
                server.Connect();
                if (!server.IsConnected)
                {
                    WriteToStatus(serverHost + "--" + serverName + "连接失败!");
                    return;
                }
                OperationFun operation = new OperationFun();
                DataTable dtGroup = operation.GetGroup(serverId);
                //增加标签组
                foreach (DataRow drGroup in dtGroup.Rows)
                {
                    string groupName = drGroup["GroupName"].ToString();
                    string groupId = drGroup["GroupId"].ToString();
                    OpcGroup group = server.AddGroup(groupName, _updateRate, true);
                    group.ServerId = serverId;
                    group.GroupId = groupId;
                    if (group != null)
                    {
                        group.DataChanged += Group_DataChanged;
                        //增加标签
                        DataTable dtItem = operation.GetItem(serverId, groupId);
                        ItemResult[] results = group.AddItems(dtItem);
                        foreach (ItemResult result in results)
                        {
                            if (result.ResultID.Failed())
                            {
                                string message = "Failed to add item \'" + result.ItemName + "\'" + " Error: " + result.ResultID.Name;
                                WriteToStatus(message);
                            }
                        }
                    }
                }
                dicServer.Add(serverId, server);//添加连接实例到集合中
                isConnect = true;
                this.Dispatcher.Invoke(() =>
                {
                    btnStart.IsEnabled = false;
                    btnStop.IsEnabled = true;
                    txtStatus.Text = "连接";
                });
                WriteToStatus(serverHost + "--" + serverName + "已连接!");
            }
            catch (Exception ex)
            {
                WriteToStatus(ex.TargetSite+":" + ex.Message);
            }
        }

3.2 优化数据刷新时UI界面卡顿

本文中的OPCClient使用WPF开发的,UI线程即是主线程。原有代码中定义了一个数据类OPCValueModel,当标签组数据变化时,将变化的值(Array类型)转换为OPCValueModel类型,同时更新全局的集合并刷新前台DataGrid的数据源以达到更新UI的目的。
但是将Array类型转换为OPCValueModel类型和更新全局的数据集合都会花费较多时间,这些操作如果在主线程中进行必然会导致UI卡顿。

修改OPCItem类,并修改OPCGroup类的Data_Changed事件,使得数据变化值改为能直接绑定到前台表格的数据类型。

    public interface IResult
    {
        /// <summary>
        /// The error id for the result of an operation on an item.
        /// </summary>
        ResultID ResultID { get; set; }

        /// <summary>
        /// Vendor specific diagnostic information (not the localized error text).
        /// </summary>
        string DiagnosticInfo { get; set; }
    }

    public class ItemIdentifier : ICloneable
    {
        /// <summary>
		/// The primary identifier for an item within the server namespace.
		/// </summary>
		public string ItemName
		{
			get { return m_itemName;  }
			set { m_itemName = value; }
		}
		/// <summary>
		/// An secondary identifier for an item within the server namespace.
		/// </summary>
		public string ItemPath
		{
			get { return m_itemPath;  }
			set { m_itemPath = value; }
		}

		/// <summary>
		/// A unique item identifier assigned by the client.
		/// </summary>
		public object ClientHandle
		{
			get { return m_clientHandle;  }
			set { m_clientHandle = value; }
		}

		/// <summary>
		/// A unique item identifier assigned by the server.
		/// </summary>
		public object ServerHandle
		{
			get { return m_serverHandle;  }
			set { m_serverHandle = value; }
		}

        public string ItemId
        {
            get { return this.m_itemId; }
            set { this.m_itemId = value; }
        }

        public string GroupId
        {
            get { return this.m_groupId; }
            set { this.m_groupId = value; }
        }

        public string ServerId
        {
            get { return this.m_serverId; }
            set { this.m_serverId = value; }
        }

        public string ItemDescribe
        {
            get { return this.m_itemDescribe; }
            set { this.m_itemDescribe = value; }
        }

		/// <summary>
		/// Create a string that can be used as index in a hash table for the item.
		/// </summary>
		public string Key
		{ 
			get 
			{
				return new StringBuilder(64)
					.Append((ItemName == null)?"null":ItemName)
					.Append("\r\n")
					.Append((ItemPath == null)?"null":ItemPath)
					.ToString();
			}
		}

		/// <summary>
		/// Initializes the object with default values.
		/// </summary>
		public ItemIdentifier() {}

		/// <summary>
		/// Initializes the object with the specified item name.
		/// </summary>
		public ItemIdentifier(string itemName)
		{
			ItemPath = null;
			ItemName = itemName;
		}

		/// <summary>
		/// Initializes the object with the specified item path and item name.
		/// </summary>
		public ItemIdentifier(string itemPath, string itemName)
		{
			ItemPath = itemPath;
			ItemName = itemName;
		}
		
		/// <summary>
		/// Initializes the object with the specified item identifier.
		/// </summary>
        public ItemIdentifier(ItemIdentifier identify)
		{
			if (identify != null)
			{
				ItemPath     = identify.ItemPath;
				ItemName     = identify.ItemName;
				ClientHandle = identify.ClientHandle;
				ServerHandle = identify.ServerHandle;
                ServerId = identify.ServerId;
                GroupId = identify.GroupId;
                ItemId = identify.ItemId;
                ItemDescribe = identify.ItemDescribe;
			}
		}

        #region ICloneable Members
        /// <summary>
        /// Creates a shallow copy of the object.
        /// </summary>
        public virtual object Clone() { return MemberwiseClone(); }
        #endregion

		#region Private Members
		private string m_itemName = null;
		private string m_itemPath = null;
		private object m_clientHandle = null;
		private object m_serverHandle = null;
        private string m_itemId = null;
        private string m_groupId = null;
        private string m_serverId = null;
        private string m_itemDescribe = null;
		#endregion
	}

    public class OpcItem : ItemIdentifier
    {
        /// <summary>
        /// The data type to use when returning the item value.
        /// </summary>
        public System.Type ReqType
        {
            get { return m_reqType; }
            set { m_reqType = value; }
        }

        /// <summary>
        /// The oldest (in milliseconds) acceptable cached value when reading an item.
        /// </summary>
        public int MaxAge
        {
            get { return m_maxAge; }
            set { m_maxAge = value; }
        }

        /// <summary>
        /// Whether the Max Age is specified.
        /// </summary>
        public bool MaxAgeSpecified
        {
            get { return m_maxAgeSpecified; }
            set { m_maxAgeSpecified = value; }
        }


        /// <summary>
        /// Whether the server should send data change updates. 
        /// </summary>
        public bool Active
        {
            get { return m_active; }
            set { m_active = value; }
        }

        /// <summary>
        /// Whether the Active state is specified.
        /// </summary>
        public bool ActiveSpecified
        {
            get { return m_activeSpecified; }
            set { m_activeSpecified = value; }
        }

        /// <summary>
        /// The minimum percentage change required to trigger a data update for an item. 
        /// </summary>
        public float Deadband
        {
            get { return m_deadband; }
            set { m_deadband = value; }
        }

        /// <summary>
        /// Whether the Deadband is specified.
        /// </summary>
        public bool DeadbandSpecified
        {
            get { return m_deadbandSpecified; }
            set { m_deadbandSpecified = value; }
        }

        /// <summary>
        /// How frequently the server should sample the item value.
        /// </summary>
        public int SamplingRate
        {
            get { return m_samplingRate; }
            set { m_samplingRate = value; }
        }

        /// <summary>
        /// Whether the Sampling Rate is specified.
        /// </summary>
        public bool SamplingRateSpecified
        {
            get { return m_samplingRateSpecified; }
            set { m_samplingRateSpecified = value; }
        }

        /// <summary>
        /// Whether the server should buffer multiple data changes between data updates.
        /// </summary>
        public bool EnableBuffering
        {
            get { return m_enableBuffering; }
            set { m_enableBuffering = value; }
        }

        /// <summary>
        /// Whether the Enable Buffering is specified.
        /// </summary>
        public bool EnableBufferingSpecified
        {
            get { return m_enableBufferingSpecified; }
            set { m_enableBufferingSpecified = value; }
        }

        #region Constructors
        /// <summary>
        /// Initializes the object with default values.
        /// </summary>
        public OpcItem() { }

        /// <summary>
        /// Initializes object with the specified ItemIdentifier object.
        /// </summary>
        public OpcItem(ItemIdentifier item)
        {
            if (item != null)
            {
                ItemName = item.ItemName;
                ItemPath = item.ItemPath;
                ClientHandle = item.ClientHandle;
                ServerHandle = item.ServerHandle;
                ItemId = item.ItemId;
                GroupId = item.GroupId;
                ServerId = item.ServerId;
                ItemDescribe = item.ItemDescribe;
            }
        }

        /// <summary>
        /// Initializes object with the specified Item object.
        /// </summary>
        public OpcItem(OpcItem item)
            : base(item)
        {
            if (item != null)
            {
                ReqType = item.ReqType;
                MaxAge = item.MaxAge;
                MaxAgeSpecified = item.MaxAgeSpecified;
                Active = item.Active;
                ActiveSpecified = item.ActiveSpecified;
                Deadband = item.Deadband;
                DeadbandSpecified = item.DeadbandSpecified;
                SamplingRate = item.SamplingRate;
                SamplingRateSpecified = item.SamplingRateSpecified;
                EnableBuffering = item.EnableBuffering;
                EnableBufferingSpecified = item.EnableBufferingSpecified;
            }
        }
        #endregion

        #region Private Members
        private System.Type m_reqType = null;
        private int m_maxAge = 0;
        private bool m_maxAgeSpecified = false;
        private bool m_active = true;
        private bool m_activeSpecified = false;
        private float m_deadband = 0;
        private bool m_deadbandSpecified = false;
        private int m_samplingRate = 0;
        private bool m_samplingRateSpecified = false;
        private bool m_enableBuffering = false;
        private bool m_enableBufferingSpecified = false;
        #endregion
    }


    public class ItemValueResult : ItemValue, IResult
    {
        #region Constructors
        /// <summary>
        /// Initializes the object with default values.
        /// </summary>
        public ItemValueResult() { }

        /// <summary>
        /// Initializes the object with an ItemIdentifier object.
        /// </summary>
        public ItemValueResult(ItemIdentifier item) : base(item) { }

        /// <summary>
        /// Initializes the object with an ItemValue object.
        /// </summary>
        public ItemValueResult(ItemValue item) : base(item) { }

        /// <summary>
        /// Initializes object with the specified ItemValueResult object.
        /// </summary>
        public ItemValueResult(ItemValueResult item)
            : base(item)
        {
            if (item != null)
            {
                ResultID = item.ResultID;
                DiagnosticInfo = item.DiagnosticInfo;
            }
        }

        /// <summary>
        /// Initializes the object with the specified item name and result code.
        /// </summary>
        public ItemValueResult(string itemName, ResultID resultID)
            : base(itemName)
        {
            ResultID = resultID;
        }

        /// <summary>
        /// Initializes the object with the specified item name, result code and diagnostic info.
        /// </summary>
        public ItemValueResult(string itemName, ResultID resultID, string diagnosticInfo)
            : base(itemName)
        {
            ResultID = resultID;
            DiagnosticInfo = diagnosticInfo;
        }

        /// <summary>
        /// Initialize object with the specified ItemIdentifier and result code.
        /// </summary>
        public ItemValueResult(ItemIdentifier item, ResultID resultID)
            : base(item)
        {
            ResultID = resultID;
        }

        /// <summary>
        /// Initializes the object with the specified ItemIdentifier, result code and diagnostic info.
        /// </summary>
        public ItemValueResult(ItemIdentifier item, ResultID resultID, string diagnosticInfo)
            : base(item)
        {
            ResultID = resultID;
            DiagnosticInfo = diagnosticInfo;
        }
        #endregion

        #region IResult Members
        /// <summary>
        /// The error id for the result of an operation on an property.
        /// </summary>
        public ResultID ResultID
        {
            get { return m_resultID; }
            set { m_resultID = value; }
        }

        /// <summary>
        /// Vendor specific diagnostic information (not the localized error text).
        /// </summary>
        public string DiagnosticInfo
        {
            get { return m_diagnosticInfo; }
            set { m_diagnosticInfo = value; }
        }
        #endregion

        #region Private Members
        private ResultID m_resultID = ResultID.S_OK;
        private string m_diagnosticInfo = null;
        #endregion
    }

    public class IdentifiedResult : ItemIdentifier, IResult
    {
        /// <summary>
        /// Initialize object with default values.
        /// </summary>
        public IdentifiedResult() { }

        /// <summary>
        /// Initialize object with the specified ItemIdentifier object.
        /// </summary>
        public IdentifiedResult(ItemIdentifier item)
            : base(item)
        {
        }

        /// <summary>
        /// Initialize object with the specified IdentifiedResult object.
        /// </summary>
        public IdentifiedResult(IdentifiedResult item)
            : base(item)
        {
            if (item != null)
            {
                ResultID = item.ResultID;
                DiagnosticInfo = item.DiagnosticInfo;
            }
        }

        /// <summary>
        /// Initializes the object with the specified item name and result code.
        /// </summary>
        public IdentifiedResult(string itemName, ResultID resultID)
            : base(itemName)
        {
            ResultID = resultID;
        }

        /// <summary>
        /// Initialize object with the specified item name, result code and diagnostic info.
        /// </summary>
        public IdentifiedResult(string itemName, ResultID resultID, string diagnosticInfo)
            : base(itemName)
        {
            ResultID = resultID;
            DiagnosticInfo = diagnosticInfo;
        }

        /// <summary>
        /// Initialize object with the specified ItemIdentifier and result code.
        /// </summary>
        public IdentifiedResult(ItemIdentifier item, ResultID resultID)
            : base(item)
        {
            ResultID = resultID;
        }

        /// <summary>
        /// Initialize object with the specified ItemIdentifier, result code and diagnostic info.
        /// </summary>
        public IdentifiedResult(ItemIdentifier item, ResultID resultID, string diagnosticInfo)
            : base(item)
        {
            ResultID = resultID;
            DiagnosticInfo = diagnosticInfo;
        }

        #region IResult Members
        /// <summary>
        /// The error id for the result of an operation on an item.
        /// </summary>
        public ResultID ResultID
        {
            get { return m_resultID; }
            set { m_resultID = value; }
        }

        /// <summary>
        /// Vendor specific diagnostic information (not the localized error text).
        /// </summary>
        public string DiagnosticInfo
        {
            get { return m_diagnosticInfo; }
            set { m_diagnosticInfo = value; }
        }
        #endregion

        #region Private Members
        private ResultID m_resultID = ResultID.S_OK;
        private string m_diagnosticInfo = null;
        #endregion
    }

修改OPCGroup的DataChanged事件

    //委托
    public delegate void DataChangedEventHandler(object subscriptionHandle, object requestHandle, ItemValueResult[] values);

    //回调  
    private OpcCallback m_callback = null;
    m_callback = new OpcCallback(this.ClientHandle, 0, this);
    //事件
    public event DataChangedEventHandler DataChanged
       {
           add { lock (this) { m_callback.DataChanged += value; Advise(); } }
           remove { lock (this) { m_callback.DataChanged -= value; Unadvise(); } }
       }
        #region 数据变化事件
        private void Group_DataChanged(object subscriptionHandle, object requestHandle, ItemValueResult[] values)
        {
        	//ItemValueResult类可直接绑定至前台界面
        	//使用Dispatcher.Invoke()更新UI界面
        	this.Dispatcher.Invoke(() =>
            {
                dgData.ItemsSource = null;
                dgData.ItemsSource = dataSource;
            });            
        }
        #endregion

3.3 有效释放COM资源

虽然C#有内存自动管理的机制,但是COM通信相关的资源并不在C#管理的范围内,需要自行管理释放。
OPCServer断开连接时,释放资源。

public partial class OPCServer
{
        public void Disconnect()
        {

            try
            {
                // remove group first
                if (this.m_groups.Count > 0)
                {
                    ArrayList grps = new ArrayList(this.m_groups);
                    foreach (OpcGroup grp in grps)
                    {
                        ((IOPCServer)m_server).RemoveGroup((int)grp.ServerHandle, 0);
                        this.m_groups.Remove(grp);
                    }
                    grps.Clear();
                    this.m_groups.Clear();
                }

                if (m_server != null)
                {
                    Interop.ReleaseServer(m_server);
                    //Marshal.ReleaseComObject(m_server);
                    m_server = null;
                }

                m_isConnected = false;

            }
            catch (Exception ex)
            {
                throw new Exception("Could not disconnect to server.");
            }
        }

}

public partial class Interop
{
	    /// <summary>
        /// 释放COM资源.
        /// </summary>
        public static void ReleaseServer(object server)
        {
            if (server != null && server.GetType().IsCOMObject)
            {
                int result = 0;
                do
                {
                    result = Marshal.ReleaseComObject(server);
                }
                while (result > 0);
            }
        }
}

提取断开服务方法,点击“断开”按钮或关闭窗口时断开所有OPCServer。

        //断开服务方法
        private void DisConnectServer(bool isShow=true)
        {
            if (dicServer != null && dicServer.Count > 0)
            {
                foreach (var kv in dicServer)
                {
                    OpcServer server = kv.Value;
                    foreach (OpcGroup group in server.Groups)
                    {
                        group.DataChanged -= Group_DataChanged;//解绑数据变化事件
                    }
                    string serverId = kv.Key;
                    server.Disconnect();//断开服务连接
                    if (isShow)//是否在界面上显示消息
                    {
                        WriteToStatus(server.HostName + "--" + server.ServerName+ "已断开!");
                    }
                }
                dicServer.Clear();
            }
        }

注意应在OPCServer断开前将OPCGroup标签组上绑定的事件全部解绑掉。在实际实验中发现,OPCServer类内置的断开方法调用的Marshal.ReleaseComObject()方法始终无反馈,使得COM资源没有被释放,UI线程也被阻塞卡死。将OPCGroup上绑定的事件全部解绑后,OPCServer的Disconnect()就能顺利执行。现象如此,是否有必然联系及深层次原因还不清楚。

3.4 其他改进

除了以上功能的改进外,还引入了新的UI框架Mahapps.Metro,同时利用UI框架的特性,在后台进行耗时操作时,前台显示进度条来提醒用户。

        #region 耗时操作提示
        private async void ShowAwaitProgress(Action action)
        {
            var mySetting = new MetroDialogSettings()
            {
                AnimateShow = false,
                AnimateHide = false
            };
            var controller = await this.ShowProgressAsync("提示", "正在处理数据中...");
            controller.SetIndeterminate();
            
            await Task.Run(action);//等待当前操作完成

            await controller.CloseAsync();
        }
        #endregion

UI框架Mahapps.Metro的使用可以参考

WPF随笔(一)–UI框架MahApps.Metro的使用

4.下阶段改进方向

经过以上的修改完善,新的OPCClient不仅界面上更加漂亮,执行效率更高,资源占用率也稳定的保持在较低的水平。但通过最近查阅资料发觉现有的解决方案仍有很大的提升空间。

  1. 在OPC连接方面,目前采用的是OPC DA标准,而目前较新的OPC UA标准能够兼容OPC DA标准,同时在底层实现上不再受制于COM,适用范围更广。所以从OPC DA转向OPC UA是有必要的。

OPC DA自定义接口的封装参考了开源库TitaniumAS.Opc.Client,GitHub地址为TitaniumAS.Opc.Client

  1. 在实时数据传输方面,目前采用的是微软的SignalR解决方案,但是在实际使用范围并不是很广,跨语言平台的支持性也不够好。从各方面的反馈来看,MQTT是比较流行的传输协议之一,适用范围也更广,相关资源也更多些。所以可以考虑添加对MQTT协议的支持。

只有经过不断的思考、实验,才能真正有所收获。谨以自勉。

  • 2
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 6
    评论
opc.ua.client.dll是一个与OPC UA服务器进行通信的接口OPC UA(OPC Unified Architecture)是一种用于工业自动化和数据交换的开放标准。它提供了一种统一的通信和数据模型,使得不同生产厂家的设备和系统可以相互通信和交换数据。 opc.ua.client.dll作为客户端接口,允许应用程序通过OPC UA协议与远程OPC UA服务器建立连接,并访问其提供的数据和功能。这个接口通过封装了底层的网络通信协议和数据处理细节,使得开发者可以更方便地使用OPC UA功能。 使用opc.ua.client.dll接口,开发者可以实现以下功能: 1. 连接到OPC UA服务器:通过接口提供的方法,可以建立到远程OPC UA服务器的连接。这样,应用程序就可以获取服务器上的数据并进行操作。 2. 浏览和读取数据节点:接口提供了获取服务器上的数据节点列表,并读取这些节点的值的方法。开发者可以通过这些方法来获取需要的数据。 3. 写入数据节点:开发者可以使用接口提供的方法将数据写入OPC UA服务器上的数据节点。这样,可以实现对服务器上数据的控制和修改。 4. 订阅和发布数据变化:通过使用接口提供的订阅和发布机制,可以实现对数据变化的实时监听。这样,当服务器上的数据发生变化时,客户端可以及时收到通知。 总之,opc.ua.client.dll接口为开发者提供了使用OPC UA协议与OPC UA服务器进行通信的便捷方式,并支持实现与服务器之间的数据交互和控制操作。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值