Android网络编程TCP、UDP(一)

一、TCP与UDP简介

TCP和UDP都属于TCP/IP参考模型中传输层的协议,且都是基于网际互联层IP协议。

一位大神作了一个很形象的比喻:TCP和UDP使用IP协议从一个网络传送数据包到另一个网络。把IP想像成一种高速公路,它允许其它协议在上面行驶并找到到其它电脑的出口。TCP和UDP是高速公路上的“卡车”,它们携带的货物就是像HTTP,文件传输协议FTP这样的协议等。(参考:http://blog.csdn.net/magister_feng/article/details/8634518

以下是些简单的区别,详细请参考其他资料。

TablesTCPUDP
名称Transmission Control Protocol,传输控制协议User Datagram Protocol,用户数据报协议
连接方式面向连接(收发数据前,必须与对方建立可靠连接)面向非连接
握手3次
可靠性可靠不可靠
效率
应用可靠性高、数据量大的场合快速、数据量小的场合

代码这东西,跟生活一样,生活怎么来,代码就怎么写。

TCP、UDP就像生活中与别人通信,需要知道对方的地址和姓名。地址就是对方的IP,收件人就是对方的端口。
发送方:把信件装好,根据地址和收件人给对方送信。
收件方:在家等着收信,根据信中内容作出相应动作。
在程序中,把发送方和收件方分别封装起来,叫Socket。Socket连接必须确保有一对Socket,一个是发送方,另一个是收件方。
不比喻了,不然要被自己套进去。。。

在编程时,注意以下几点:

  • 端口号应该大于等于1024,因为0~1023内都被系统内部占用了。
  • 在清单文件中添加权限<uses-permission android:name="android.permission.INTERNET" />
  • 在Android中,TCP和UDP都是网络连接,属于耗时操作,so必须放在子线程中。

二、TCP

通信的时候,总要一方在等待收件,即处于端口监听状态,此方成为服务器。TCP的Socket分为ServerSocket(服务器)和Socket(客户端),ServerSocket一直处于端口监听,以便客户端可以随时连接进来。而Socket就是请求连接。然后双方通信。

通信的方式,一般都是先一问一答式,然后双方协定先后顺序发送接收数据。即首先客户端请求,服务器响应,再按协定相互通信。
这样协定,是因为有些方法是阻塞式,但为了更加人性化,一般都会设置超时时间。

2.1、TCP服务器

常用API:

  1. ServerSocket
    • new ServerSocket(int port) —— 创建监听端口号为port的ServerSocket
    • getLocalPort() —— 获取本地端口,即Socket监听的端口
    • setSoTimeout(int timeout) —— 设置accept()的连接超时时间
    • accept() —— 等待连接。返回客户端的Socket实例对象。若设置了超时,连接超时将抛异常SocketTimeoutException,否则阻塞等待
    • isClosed() —— 连接是否关闭
    • close() —— 关闭连接
  2. Socket
    • setSoTimeout(int timeout) —— 设置read()读取流的超时时间
    • getInetAddress().getHostAddress() —— 获取客户端的主机IP地址
    • getPort() —— 获取客户端与本服务器连接端口
    • getLocalPort() —— 获取本地端口,与serverSocket.getLocalPort()获取的端口一致
    • getInputStream() —— 获取输入流,用于接收客户端发送过来的信息。一般使用read(byte[] data)来读取,此方法也属于阻塞式,但若设置了读取流的超时时间,超时将抛异常SocketTimeoutException
    • getOutputStream() —— 获取输出流,用户给客户端发送信息

这里是一个简单的服务器实例,只实现一次请求,然后响应一次即完毕:

private void startTCPServer() {
    final int port = 8989;

    new Thread(new Runnable() {
        @Override
        public void run() {
            ServerSocket server = null;
            try {
                // 1、创建ServerSocket服务器套接字
                server = new ServerSocket(port);
                // 设置连接超时时间,不设置,则是一直阻塞等待
                server.setSoTimeout(8000);

                // 2、等待被连接。在连接超时时间内连接有效,超时则抛异常,
                Socket client = server.accept();
                logD("connected...");
                // 设置读取流的超时时间,不设置,则是一直阻塞读取
                client.setSoTimeout(5000);

                // 3、获取输入流和输出流
                InputStream inputStream = client.getInputStream();
                OutputStream outputStream = client.getOutputStream();

                // 4、读取数据
                byte[] buf = new byte[1024];
                int len = inputStream.read(buf);
                String receData = new String(buf, 0, len, Charset.forName("UTF-8"));
                logD("received data from client: " + receData);

                // 5、发送响应数据
                byte[] responseBuf = "Hi, I am Server".getBytes(Charset.forName("UTF-8"));
                outputStream.write(responseBuf, 0, responseBuf.length);

            } catch (IOException e) {
                logD("Exception:" + e.toString());
                e.printStackTrace();
            } finally {
                if (server != null) {
                    try {
                        server.close();
                        server = null;
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }

            }
        }
    }).start();
}

2.2、TCP客户端

常用API:

  1. Socket
    • new Socket(String host, int port) —— 创建连接。在90秒内未连接主机,会抛异常ConnectException。可以缩短连接超时时间,使用connect方法
    • new Socket() —— 创建一个套接字。主要用于connect().
    • connect(SocketAddress socketAddress, int timeout); —— 开始连接给定的套接字地址,并设置连接超时时间。SocketAddress中封装了主机地址与端口。
    • setSoTimeout(int timeout) —— 设置读取流的超时时间。必须在getInputStream()与getOutputStream()之前设置才有效
    • isConnected() —— 是否连接上,返回布尔值
    • getOutputStream() —— 获取输出流
    • getInputStream() —— 获取输入流
    • shutdownOutput() —— 关闭输入流。属于半关闭,而close()属于全关闭
    • close() —— 关闭连接
  2. SocketAddress
    • new InetSocketAddress(host, port) —— SocketAddress是抽象类,使用SocketAddress子类来实例化。把主机IP地址与端口封装进去。

客户端代码也是最简单的实例,一发一收即结束。

private void startTCPClient() {
        final String host = "192.168.1.214";
        final int port = 8989;

        new Thread(new Runnable() {
            @Override
            public void run() {
                Socket socket = null;
                try {
                    // 1、创建连接
                    socket = new Socket(host, port);
                    if (socket.isConnected()) {
                        logD("connect to Server success");
                    }

                    // 2、设置读流的超时时间
                    socket.setSoTimeout(8000);

                    // 3、获取输出流与输入流
                    OutputStream outputStream = socket.getOutputStream();
                    InputStream inputStream = socket.getInputStream();

                    // 4、发送信息
                    byte[] sendData = "Hello, I am client".getBytes(Charset.forName("UTF-8"));
                    outputStream.write(sendData, 0, sendData.length);
                    outputStream.flush();

                    // 5、接收信息
                    byte[] buf = new byte[1024];
                    int len = inputStream.read(buf);
                    String receData = new String(buf, 0, len, Charset.forName("UTF-8"));
                    logD(receData);

                } catch (IOException e) {
                    e.printStackTrace();
                    logD(e.toString());
                } finally {
                    if (socket != null) {
                        try {
                            socket.close();
                            socket = null;
                        } catch (IOException e) {
                            e.printStackTrace();
                        }
                    }
                }
            }
        }).start();
    }

2.3、TCP总结

2.3.1 读取流阻塞的处理

因为上面的demo中,都是假设对方发送的数据长度小于等于1024。可是现实中数据的长度一般都是未知的,很多人用读取文件的方法来读取,如:

byte[] buf = new byte[1024];
StringBuilder rece = new StringBuilder();
int len;
while ((len = inputStream.read(buf)) != -1) {
    String part = new String(buf, 0, len);
    rece.append(part);
}
String receData = rece.toString();

这样,看上去很好,但实际socket的流与文件流不同,文件读到末尾,有一个结束标记。而socket的流只有在关闭流的时候,才会有被告知结束。这里的结束就是read()返回的长度等于-1。
TCP通信时,一般是不会立马关闭,而read()又是阻塞式方法,所以会导致程序一直停留在这里while ((len = inputStream.read(buf)) != -1)

首先,有两种不太靠谱的解决方法:
1. 不要用-1作为判断条件,而用>0。但我这结果都一样,不管你信不信,反正我信了
2. 使用BufferedReader的readLine()去读取数据。这是读行,我的数据里没有换行,怎么读?传文件很好,但绝大多数情况下不适合。

BufferedReader bufReader = new BufferedReader(new InputStreamReader(inputStream));
String receData = bufReader.readLine();

稍微靠谱点的方法:
使用while(inputStream.available() > 0),先进行判断是否有数据可读,有就在循环内部读取。这个方法不是阻塞式,如果对方已经把数据发送出来了,这时可以正常读取;如果对方慢了一点,这里就直接执行下去了,数据的读取就被跳过了。当然也可以每次都先等待一段时间再去查。

目前没有找到特别完美又通用的解决方法(如果您有,麻烦分享下),但总有适合的,以下是个人总结的几种解决方法:
1. 制定协议,添加报文头:报文总数据长度 + 是否继续保持链接 + 当前报文的序号,本报文就根据此长度来判断是否接受完毕;
2. 设置读入超时时间。如果后面的读取后面的程序还要执行,那就只对读取进行异常处理;
3. 发送端发送完毕后关闭连接。socket.shutdownOutput()和socket.close()都可以,前者是半关闭,后者是全关闭。前者安全点;
4. 读取到某些字符后,当成结束。如读到byebye就不读了;
5. 让每次传输的数据长度都不等于缓冲区buffer大小的整数倍。接收到数据后,判断长度是否为buffer长度。是,则说明还有数据要读;否,则说明已结束;
6. 用一个足够大的缓冲区buffer来一次性装完所有的数据。

2.3.2 流的处理

个人还是喜欢用byte,可能因为以前使用C的原因,再加上项目中也是字节数据+字符数据。很多情况下,我们都是字符串数据或对象实例之类的,可以用封装类进行处理,如BufferedWriter/BufferedReader、ObjectInputStream/ObjectOutputStream、DataInputStream/DataOutputStream与PrintWriter。

对象流就不写demo了,需注意的是:
- 被读写的对象必须实现Serializable;
- 读写顺序必须一致。

DataInputStream+DataOutputStream:

// 发送
DataOutputStream dataOS = new DataOutputStream(outputStream);
dataOS.writeUTF("Hi你好啊, I am Server");
dataOS.flush();
dataOS.close(); // 如果发送后不再接收或发送,就可以关闭,否则不要关闭。因为这样也会把socket关闭,导致无法再接收或发送了,并抛SocketException异常。若不再发送,只接收,可用socket.shutdownOutput();

// 接收
DataInputStream dataIS = new DataInputStream(inputStream);
String receData = dataIS.readUTF();
logD("From Client:" + receData);

PrintWriter + BufferedReader:

// 发送
// 使用带自动flush刷新的构造函数,自动刷新仅对这三个方法有效:println, printf, format
PrintWriter printWriter = new PrintWriter(outputStream, true);
printWriter.format("First info from client = %s", host);
printWriter.printf("Second info: Android");
socket.shutdownOutput();

// 接收
// 这里可以一次性把上面两次的写入流读出来。
BufferedReader bufReader = new BufferedReader(new InputStreamReader(inputStream));
char[] buffer = new char[1024];
int length;
if ((length = bufReader.read(buffer)) != -1) {
    String receData = new String(buffer, 0, length);
    logD("From Client:" + receData);
}
2.3.3 TCP心跳保活

心跳是双方约定好保活时间,在此保活时间内告诉对方自己还在线,不要关闭连接。对方在保活时间内没有收到保活信息,就会关闭连接。
正常情况下,TCP有默认的保活时间,为2小时,可在客户端开启保活功能socket.setKeepAlive(true);。
但我们并不需要那么长的保活时间,一般10分钟就够了。方法有两个:

  1. 反射去修改默认的保活时间;
    (参考:http://stackoverflow.com/questions/6565667/how-to-set-the-keepalive-timeout-in-android
  2. 在应用层面去定时发送心跳包,实现方法很多,就不多讲了。

2.4、实例:TCP服务器与客户端自由通信

这里的自由通信,指的是服务器或客户端可以任意的发送和接收数据,而不需按一发一收,想发就发,任意时刻都能接收数据那种。

2.4.1 原理分析

发送用的是OutputStream,接收用的是InputStream。一发一收制,发送和接收的都是顺序写到代码中的。现在要自由,无非就是分开来,发送只管发送,接收只管接收。
发送,好处理,需要发送的时候发送就行。
接收,好像没那么容易,因为对方无论何时发送数据,我们必须接收。这里开了个子线程在循环读流。又由于读流是阻塞式,使用的处理方法是上面的第2种,设置读流超时时间。

2.4.2 代码实现

代码包含服务器类SimpleTCPServer与客户端类SimpleTCPClient,及各自的demo。这两个类可以当做简单的工具类使用。
两个工具类对接收的数据使用了不同的处理方法,SimpleTCPServer要求传入Handler进行处理。而SimpleTCPClient是要求调用者实现其抽象方法processData(byte[] data),此方法是在子线程中处理,把更多的处理权交给了调用者。

SimpleTCPServer:

public class SimpleTCPServer {
    private static final String TAG = SimpleTCPServer.class.getSimpleName();
    private static final int INPUT_STREAM_READ_TIMEOUT = 300;

    /**
     * 服务器,连接服务器的客户端
     */
    private ServerSocket mServer;
    private List<Socket> mClientList = new ArrayList<>();

    private Handler mHandler;

    public SimpleTCPServer(Handler handler) {
        this.mHandler = handler;
    }

    public void listen(final int port) {
        if (mServer != null && !mServer.isClosed()) {
            close();
        }

        new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    mServer = new ServerSocket(port);
                    while (mServer != null && !mServer.isClosed()) {
                        logI("start to accept");
                        Socket client = mServer.accept();
                        if (client.isConnected()) {
                            logI(String.format("accepted from: %s[%d]", client.getInetAddress().getHostAddress(), client.getPort()));
                            mClientList.add(client);
                            new Thread(new ReceiveRunnable(mClientList.size() - 1, client)).start();
                        }
                    }
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }).start();
    }

    /**
     * 接收客户端数据的子线程
     */
    class ReceiveRunnable implements Runnable {
        private int which;
        private Socket mSocket;

        ReceiveRunnable(int which, Socket socket) {
            this.which = which;
            this.mSocket = socket;
        }

        @Override
        public void run() {
            try {
                // 给读取流设置超时时间,否则会一直在read()那阻塞
                mSocket.setSoTimeout(INPUT_STREAM_READ_TIMEOUT);
                InputStream in = mSocket.getInputStream();
                while (mSocket != null && mSocket.isConnected()) {
                    // 读取流
                    byte[] data = new byte[0];
                    byte[] buf = new byte[1024];
                    int len;
                    try {
                        while ((len = in.read(buf)) != -1) {
                            byte[] temp = new byte[data.length + len];
                            System.arraycopy(data, 0, temp, 0, data.length);
                            System.arraycopy(buf, 0, temp, data.length, len);
                            data = temp;
                        }
                    } catch (SocketTimeoutException stExp) {
                        // 只catch,不做任何处理
                        // stExp.printStackTrace();
                    }

                    // 处理流
                    if (data.length != 0) {
                        pushMsgToHandler(which, data);
                    }
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

    /**
     * 推送信息给Handler
     */
    private void pushMsgToHandler(int which, byte[] data) {
        Message message = mHandler.obtainMessage();
        message.what = which;
        message.obj = data;
        mHandler.sendMessage(message);
    }

    /**
     * 发送数据
     */
    public boolean sendData(int which, byte[] bytes) {
        if (which < 0 || which >= mClientList.size()) {
            return false;
        }

        Socket socket = mClientList.get(which);
        if (socket != null && socket.isConnected()) {
            try {
                OutputStream out = socket.getOutputStream();
                out.write(bytes);
                out.flush();
                return true;
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        return false;
    }

    public boolean sendData(int which, String data) {
        return sendData(which, data.getBytes(Charset.forName("UTF-8")));
    }

    public void close() {
        if (mServer != null) {
            try {
                mServer.close();
                mServer = null;
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        for (Socket client : mClientList) {
            try {
                client.close();
                client = null;
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        mClientList.clear();
    }

    public Socket getClient(int which) {
        return which < 0 || which >= mClientList.size() ? null : mClientList.get(which);
    }

    public int getClientCount() {
        return mClientList.size();
    }

    private void logI(String msg) {
        Log.i(TAG, msg);
    }
}

SimpleTCPServer demo:

public class ServerActivity extends AppCompatActivity {

    private TextView tv_msg;
    private Button btn_restart;

    private SimpleTCPServer mSimpleTCPServer;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_server);

        initData();
        initView();
    }

    private void initData() {
        mSimpleTCPServer = new SimpleTCPServer(mHandler);
        mSimpleTCPServer.listen(9000);
    }

    private void initView() {
        tv_msg = (TextView) findViewById(R.id.tv_msg);
        btn_restart = (Button) findViewById(R.id.btn_restart);

        tv_msg.setMovementMethod(ScrollingMovementMethod.getInstance());
        btn_restart.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                if (mSimpleTCPServer != null) {
                    for (int i = 0; i < mSimpleTCPServer.getClientCount(); i++) {
                        mSimpleTCPServer.sendData(i, "Push From Sever, 服务器发送数据来了".getBytes(Charset.forName("UTF-8")));
                    }
                }
            }
        });
        btn_restart.setOnLongClickListener(new View.OnLongClickListener() {
            @Override
            public boolean onLongClick(View v) {
                tv_msg.setText("");
                return true;
            }
        });
    }


    @Override
    protected void onDestroy() {
        super.onDestroy();
        mSimpleTCPServer.close();
    }

    private MyHandler mHandler = new MyHandler(this);
    private static class MyHandler extends Handler {
        WeakReference<ServerActivity> refActivity;

        public MyHandler(ServerActivity activity) {
            refActivity = new WeakReference<>(activity);
        }

        @Override
        public void handleMessage(Message msg) {
            ServerActivity activity = refActivity.get();
            int which = msg.what;
            byte[] data = (byte[]) msg.obj;
            String result = new String(data, Charset.forName("UTF-8"));
            activity.tv_msg.append("第" + which + "位访客"
                    + activity.mSimpleTCPServer.getClient(which).getInetAddress().getHostAddress() + ":"
                    + result + "\n");
            activity.mSimpleTCPServer.sendData(msg.what, "Hi你好,I am server".getBytes(Charset.forName("UTF-8")));
        }
    }
}

SimpleTCPClient:

public abstract class SimpleTCPClient {
    private static final String TAG = SimpleTCPClient.class.getSimpleName();
    private static final int CONNECT_TIMEOUT = 5000;
    private static final int INPUT_STREAM_READ_TIMEOUT = 300;

    private Socket mSocket;
    private InputStream mInputStream;
    private OutputStream mOutputStream;

    public SimpleTCPClient() {
    }

    /**
     * 连接主机
     * @param host 主机IP地址
     * @param port 主机端口
     */
    public void connect(final String host, final int port) {
        if (mSocket != null) {
            close();
        }

        new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    mSocket = new Socket();
                    SocketAddress socketAddress = new InetSocketAddress(host, port);
                    // 设置连接超时时间
                    mSocket.connect(socketAddress, CONNECT_TIMEOUT);
                    if (mSocket.isConnected()) {
                        logI("connected to host success");
                        // 设置读流超时时间,必须在获取流之前设置
                        mSocket.setSoTimeout(INPUT_STREAM_READ_TIMEOUT);
                        mInputStream = mSocket.getInputStream();
                        mOutputStream = mSocket.getOutputStream();
                        new ReceiveThread().start();
                    } else {
                        mSocket.close();
                    }
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }).start();
    }

    /**
     * 接收进程
     */
    class ReceiveThread extends Thread {
        @Override
        public void run() {
            super.run();

            while (mSocket != null && mSocket.isConnected() && mInputStream != null) {
                // 读取流
                byte[] data = new byte[0];
                byte[] buf = new byte[1024];
                int len;
                try {
                    while ((len = mInputStream.read(buf)) != -1) {
                        byte[] temp = new byte[data.length + len];
                        System.arraycopy(data, 0, temp, 0, data.length);
                        System.arraycopy(buf, 0, temp, data.length, len);
                        data = temp;
                    }
                } catch (IOException e) {
//                    e.printStackTrace();
                }

                // 处理流
                if (data.length != 0) {
                    processData(data);
                }
            }

        }
    }

    /**
     * process data from received,which is not run in the main thread。
     */
    public abstract void processData(byte[] data);

    /**
     * 发送数据
     */
    public void send(byte[] data) {
        if (mSocket != null && mSocket.isConnected() && mOutputStream != null) {
            try {
                mOutputStream.write(data);
                mOutputStream.flush();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

    public void send(String data) {
        send(data.getBytes(Charset.forName("UTF-8")));
    }

    public void close() {
        if (mSocket != null) {
            try {
                mInputStream.close();
                mOutputStream.close();
                mSocket.close();
                mInputStream = null;
                mOutputStream = null;
                mSocket = null;
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

    public Socket getSocket() {
        return mSocket;
    }

    private void logI(String msg) {
        Log.i(TAG, msg);
    }
}

SimpleTCPClient demo:

public class ClientActivity extends AppCompatActivity {

    private Button btn_send;
    private TextView tv_msg;
    private SimpleTCPClient mSimpleTCPClient;


    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_client);

        initData();
        initView();
    }

    private void initData() {
        mSimpleTCPClient = new SimpleTCPClient() {
            @Override
            public void processData(byte[] data) {
                sendMessageToActivity(new String(data, Charset.forName("UTF-8")));
            }
        };
        mSimpleTCPClient.connect("192.168.1.214", 9000);
    }

    private void initView() {
        tv_msg = (TextView) findViewById(R.id.tv_msg);
        btn_send = (Button) findViewById(R.id.btn_send);

        tv_msg.setMovementMethod(ScrollingMovementMethod.getInstance());
        btn_send.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                mSimpleTCPClient.send("Hello,from client:测试数据来了");
            }
        });
        btn_send.setOnLongClickListener(new View.OnLongClickListener() {
            @Override
            public boolean onLongClick(View v) {
                tv_msg.setText("");
                return true;
            }
        });
    }

    private void sendMessageToActivity(String msg) {
        Message message = Message.obtain();
        message.obj = msg + "\n";
        mHandler.sendMessage(message);
    }

    private MyHandler mHandler = new MyHandler(this);
    private static class MyHandler extends Handler {
        private WeakReference<ClientActivity> refActivity;

        MyHandler(ClientActivity activity) {
            refActivity = new WeakReference<>(activity);
        }

        @Override
        public void handleMessage(Message msg) {
            ClientActivity activity = refActivity.get();
            activity.tv_msg.append((CharSequence) msg.obj);
        }
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        mSimpleTCPClient.close();
    }
}

本来想在9月1日前写完,目录早就计划好了,但发现越写越多,到现在也只写完TCP。考虑到代码太多,还是分二篇来写。也算作是休息会。先定一个小目标,比如挣它一个亿﹏﹏﹏﹏﹏﹏

  • 13
    点赞
  • 53
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值