Android串口开发:Serialport(如何进行串口开发,数据发送,TX和RX,A和B,粘包)

目录

  1. 需求场景

  2. 串口的基本开发:通讯参数(485,232,波特率)

  3. 下位机的数据发送案例(厂家自定义协议,MDB协议)

  4. Android接入Serialport,进行串口开发,基本使用

  5. 粘包、如何知道返回的数据对应谁的,数据通知…等等

  6. 对Serialport进行封装



一、需求场景


Hello,这里是前期后期,不知道大家有没有接触过自动售卖机,在自动售卖机里面装有Android屏。在海外,大部分国家都没有普及扫码支付,都是使用一些纸币、硬币以及刷卡进行支付。而这些支付大部分都是使用串口的形式去对接,比如投入多少钱,通过串口发送给数据Android屏,Android屏收到后,触发指令出商品,今天呢,我们就来聊聊串口开发。



二、串口是什么?


“串口”(Serial Port)通常是指一种用于与外部设备进行串行通信的接口。如下是其中一种DB9的形式:

更加简单的,还有这样的形式:

只要有三条线,TX、RX和GND,或者A、B和GND,就可以去实现通讯。

标准的Android智能手机和平板电脑通常并不直接暴露硬件级别的串口接口(如RS-232)给用户或开发者。这是因为这些设备为了便携性和成本考虑,往往采用了不同的通信方式(如USB、蓝牙、Wi-Fi等)来与外部设备交互。

然而,在一些特定的Android设备(如自动售卖机的大尺寸屏幕、嵌入式Android设备等)或者通过特定的硬件扩展(如USB转串口适配器)上,开发者可能能够访问到串口接口。

为了在Android应用中使用串口通信,开发者可能会使用到一些第三方库或框架,这些库或框架通过JNI(Java Native Interface)技术调用底层Linux系统的串口通信接口。这些库通常提供了更高级别的API,使得开发者能够在不直接处理底层细节的情况下实现串口通信。

2.1 通讯参数


一般这些数据,都是下位机提供给上位机的【上位机指的就是我们的Android屏幕,下位机指的就是上面我们提到的支付设备】,我们按照参数打开串口就可以收发数据了。
3在这里插入图片描述

2.1.1 通讯接口是什么?RS232和RS485


其实对于我们软件而言,是不需要修改什么的。只是物理硬件修改,下位机要换芯片,收发数据的串口也需要更换。

RS232和RS485在收发数据上的区别主要体现在传输方式、传输距离、通信模式以及电平标准等方面。

RS232支持全双工和半双工两种传输方式,全双工可以实现数据的双向同时传输,而半双工则只能实现数据的单向传输,简单来说,就是只能一边来发送数据,另外一边不能主动发数据,只能响应数据,类似客户端和服务器的通讯一样。

RS485属于半双工总线,即在同一时刻,总线上只能有一个设备在发送数据,而其他设备则处于接收状态。

2.2 波特率是什么?34800,9600


波特率表示的是单位时间内传输的码元符号的个数,波特率越高,单位时间内传输的数据量就越大。但过大也会存在丢包的情况,视情况设定

2.3 停止位、数据位、校验位的解释:


1.停止位:用于表示单个数据包的结束。常见的停止位有1位、1.5位和2位。停止位的主要作用是提供一个时间间隔,以确保数据包的完整性和正确性。例如,如果设置为1位停止位,则每个数据包后面都会跟随一个逻辑高电平(或逻辑低电平)的时间间隔,用于标识数据包的结束。

2.数据位:用于传输实际的数据信息。数据位的长度可以根据需要进行设置,常见的有5位、6位、7位和8位等。数据位越长,每个数据包所能携带的信息量就越大。在串口通信中,数据位通常是固定的,例如常用的ASCII码就是基于7位或8位数据位进行传输的。

3.校验位:用于检测数据传输过程中的错误。校验位可以通过多种方式生成,如奇校验、偶校验或无校验等。如果设置了校验位,则接收方会根据校验位的值来判断接收到的数据是否存在错误。如果存在错误,则可以根据具体的协议进行错误处理或重传。


三、下位机的数据发送案例


3.1 厂家自定义协议


我们简单来看看他的协议,以及我们应该如何发送数据和接收数据。
(1)需要厂家提供通讯参数
在这里插入图片描述(2)通讯文档,比如,查询下位机状态,还有很多协议内容,这里就讲一个:
在这里插入图片描述
有了这些信息,先不着急写代码,先使用串口工具测试一下收发数据是否正常。打开串口通讯工具,设置通讯参数,然后发送数据就可以了。
在这里插入图片描述

3.2 MDB全球通用协议


MDB协议又名ICP协议是2003年3月26日发布的版本3。是由国家自动机械销售协会(NAMA)和欧洲售货机协会(EVA)的有关成员制订,是一套用于协调自动售卖机的主控制器(VMC)与多个外设之间通信的协议。

我们简单来看看他的协议,以及我们应该如何发送数据和接收数据。这些文档相对复杂一点,都是英文,你需要了解他的指令的含义,以及发送数据的流程。
(1)指令功能,比如激活,发起交易等等。
在这里插入图片描述(2)响应,比如设备的状态。

如下是查询设备的状态,是上线还是离线。
数据发送,我们就发送12就可以,数据发送格式为hex
如下是发起支付


四、Android接入Serialport,进行串口开发,基本使用


好了,上面的环节我们都搞定了以后,接下来我们就可以使用代码进行开发了。为什么要走上面的环节呢,因为有时候别人可能会怀疑你代码的问题,如果先使用串口工具过一篇,就可以堵上他的嘴了,哈哈哈哈,不过也是为了我们方便,先测试数据。数据Ok后,我们后面写代码才可以一路畅通。

4.1 Serialport是什么?


使用串口库(如SerialPort类)来与串口进行数据交互。这些库提供了一系列功能,如打开串口、设置参数、发送数据、接收数据等。通过串口库,开发者可以方便地与外部设备进行通信,读取和控制设备的数据。

Android SerialPort库通过JNI调用底层Linux的串口设备驱动,使得开发者可以通过简单的API来进行串口通信操作。

Google开源的Android串口通信Demo android-serialport-api

android-serialport-api下有两个主要的类

参数说明
SerialPort1.这个类通常负责串口设备的打开、配置(如设置波特率、数据位、停止位、校验位等)、数据的读写以及关闭操作。它是进行串口通信的核心类,提供了与串口设备进行交互的接口。2).它可能包含open(), close(), getInputStream(), getOutputStream()等方法,分别用于打开串口、关闭串口、获取输入流(用于读取数据)和获取输出流(用于写入数据)
SerialPortFinder用于在系统中查找可用的串口设备

简单的示例:设置串口路径、波特率、打开串口

// 假设已经通过某种方式(如Gradle依赖)导入了Android SerialPort库  
  
// 1.打开串口  
String devicePath = "/dev/ttyS0"; // 串口设备路径  
int baudRate = 9600; // 波特率  
SerialPort serialPort = new SerialPort(new File(devicePath), baudRate, 0); // 0为可选参数,根据具体实现可能有所不同  
serialPort.open();
// 2.发送数据  
OutputStream outputStream = serialPort.getOutputStream();  
String sendData = "Hello, Serial Port!";  
outputStream.write(sendData.getBytes());  
  
// 3.接收数据  
InputStream inputStream = serialPort.getInputStream();  
byte[] buffer = new byte[1024];  
int size = inputStream.read(buffer); // 注意:这里可能会阻塞,直到有数据可读  
String receiveData = new String(buffer, 0, size);  
  
// 4.关闭串口  
serialPort.close();

串口通讯对于Android开发者来说,只需要关注如何连接、操作(发送指令)、读取数据;无论是232、485还是422,对于开发者来说连接、操作、读取代码都是一样的。



五、粘包、如何知道返回的数据对应谁的,数据通知…等等


在真实项目中,并不会如上面怎么简单,发送数据和接收数据就可以了,需要考虑:

  1. 数据丢包情况,需要重发,知道收到数据为止。
  2. 数据粘包的情况,需要和下位机约定好规则。
  3. 数据发送过来是二进制,我们需要转换,具体也是和下位机约定好规则

5.1 如何知道返回的数据对应谁的?


简单来说,就是你发送一个数据的时候,记录到一个变量里面。等读到数据后,你把数据和变量里面记录的内容发送上来,然后再继续发送下一个数据。以此类推。这样你就会知道数据是谁的了。

注意,这样的话,数据的发送,你就需要存储到一个集合里面,不断的往里面取,而不是异步随便调用send方法发送数据了。

5.2 如何处理粘包的情况?


粘包是指在串口通信过程中,由于多种原因导致的多个独立的数据包在传输过程中被接收端视为一个连续的数据流,从而使得数据包之间的边界变得不明确,进而使得数据的解析变得困难。

比如:本来下位机返回的是AA 03 03 07 00 DD变成了AA 03 03 07 00 DD AA 03 03 07 00 DD,或者AA 03 03 07 00 DDAA 03 03两条数据连在一起情况。

发生的原因是什么?

  1. 发送方发送数据的速度较快:当发送方连续发送多个数据包,且发送速度较快时,如果接收方的处理速度跟不上,就可能导致多个数据包在接收端被合并成一个大的数据流,即发生粘包现象。【降低上位机数据发送的频率】
  2. 接收方处理数据的速度较慢:接收方的处理速度是影响是否发生粘包的重要因素。如果接收方的处理速度较慢,无法及时将接收到的数据按照数据包进行分割和处理,就会发生粘包。【优化下位机代码】
  3. 传输数据量太大:有时候传输数据量太大,导致数据截断,或者缓存区不够。

采取措施

  1. **添加固定长度头部和尾部:**发送方在每个数据包前添加固定长度的头部,头部中包含数据包的长度信息,接收方根据头部中的长度信息来解析数据。如下:
    在这里插入图片描述
    左边蓝色是上位机发送给下位机的,右边橙色是下位机返回给上位机的。消息头,数据内容长度,结束,这样我们就可以很好的处理数据了,如果数据发回来的不完整,或者连在一起,我们可以视情况,对数据进行解析分段,或者丢弃。

数据通知

数据通知,可以使用很多种方法,通过静态变量存储也可以,也可以通过EventBus来推送接收数据也可以。



六、对Serialport进行封装


6.1 定义指令

package com.example.myapplication.seria.frame;

/**
 * 原始命令
 */
public class Command {
    /**
     * 帧开始
     */
    public final static String STX = "AA";
    /**
     * 帧结束
     */
    public final static String ETX = "DD";
    /**
     * CMD指令:查询设备ID
     */
    public final static String CMD_QUERY_ID = "01";

}


/**
 * 命令帧
 */
public class CommandFrame {

    /**
     * 开始字符
     */
    private final String stx = Command.STX;

    /**
     * 字节长度
     */
    private int length;

    /**
     * CMD指令
     */
    private String cmd;

    /**
     * data
     */
    private String data;


    /**
     * 结束字符
     */
    private final String etx = Command.ETX;


    public String getStx() {
        return stx;
    }

    public int getLength() {
        return length;
    }

    public void setLength(int length) {
        this.length = length;
    }

    public String getCmd() {
        return cmd;
    }

    public void setCmd(String cmd) {
        this.cmd = cmd;
    }

    public String getData() {
        return data;
    }

    public void setData(String data) {
        this.data = data;
    }

    public String getEtx() {
        return etx;
    }


    public CommandFrame(String cmd, String data) {
        this.cmd = cmd;
        this.data = data;
    }
}
public class WriteCommand  extends CommandFrame {
    /**
     * 发送命令
     * @param data 数据
     *
     */
    public WriteCommand(String cmd,String data) {
        super(cmd, data);
    }
}

6.2 创建串口管理类

  1. 该类用于打开串口,写入数据和读取数据

public final class SerialPortManager {
    private static final String TAG = "SerialPortManager";
    /**
     * 串口设备
     */
    private SerialPort mSerialPort;

    /**
     * 输出流
     */
    private OutputStream mOutputStream;
    private SerialPortReadThread mReadThread;


    /**
     * 不允许生成对象
     */
    private SerialPortManager() {
    }

    public void sendCommandFrame(CommandFrame frame) {
        try {
            sendData(frame.getData());
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    /**
     * 静态内部类控制单例
     */
    private static class SerialPortManagerHolder {
        final static SerialPortManager instance = new SerialPortManager();
    }

    /**
     * 获取串口设备管理器单例
     * @return 串口设备管理器
     */
    public static SerialPortManager getInstance() {
        return SerialPortManagerHolder.instance;
    }

    /**
     * 打开串口
     *
     * @param device 串口设备
     * @return 串口
     */
    public SerialPort open(SerialPortDevice device) {
        return open(device.getPath(), device.getBaudRate());
    }

    private SerialPort open(String devicePath, String baudRate) {

        try {
            // 打开串口
            Log.d(TAG, "open dev: "+devicePath+":"+baudRate);
            File device = new File(devicePath);
            int bauRate = Integer.parseInt(baudRate);
            mSerialPort = new SerialPort(device, bauRate,1,8,0,0,0);
            //开始读数据 开启读线程
            mReadThread = new SerialPortReadThread(mSerialPort.getInputStream());
            mReadThread.start();
            //获得输出流,发送数据
            mOutputStream = mSerialPort.getOutputStream();
            return mSerialPort;

        } catch (IOException e) {
            Log.d(TAG, "open error : "+e);
            e.printStackTrace();
            return null;
        }
    }


    /**
     * 发送数据
     *
     * @param data 数据
     */
    public void sendData(String data) throws IOException {
        //我想将 AA+01+
        String s = ByteUtil.Byte2Hex((byte) (data.length()/2));
        String hexStr = Command.STX + s + data + Command.ETX;
        byte[] bOutArray = ByteUtil.HexToByteArr(hexStr);
        mOutputStream.write(bOutArray);
    }


    /**
     * 关闭串口
     */
    public void close() {
        if (mReadThread != null) {
            mReadThread.close();
        }

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

        if (mSerialPort != null) {
            mSerialPort.close();
            mSerialPort = null;
        }
    }
}

6.3 开启线程来读取数据:解析数据,处理粘包,分发的情况


/**
 * 串口读取线程
 */
public class SerialPortReadThread extends Thread {

    private static final String TAG = "SerialPortReadThread";
    private BufferedInputStream mInputStream;
    private boolean running;

    public SerialPortReadThread(InputStream inputStream) {
        mInputStream = new BufferedInputStream(inputStream);

    }


    @Override
    public void run() {
        super.run();
        byte[] received = new byte[1024];
        int size;
        running = true;

        while (running) {
            //开始读数据
            SerialPortSendQueue.getInstance().startReadListThread();
            try {
                int available = mInputStream.available();
                Log.d(TAG, "run available: " + available);
                if (available > 0) {
                    size = mInputStream.read(received);
                    if (size > 0) {
                        onDataReceive(received, size);
                    }
                } else {
                    // 暂停一点时间,免得一直循环造成CPU占用率过高
                    SystemClock.sleep(500);
                }
            } catch (IOException e) {
                running = false;
            }
        }
    }

    private void onDataReceive(byte[] received, int size) {
        if (size >= 1) {
            //现在来增加,这个数据究竟是谁。
            CommandFrame commandFrame =SerialPortSendQueue.getInstance().getCurrentFrame();
            SerialPortSendQueue.getInstance().setReceived();//收到数据了,要停止了。

            // 处理接收到的数据
            String hexStr = ByteUtil.ByteArrToHex(received, 0, size);

            // 1. 找帧数据,stx开始,etx结束:粘包处理,丢弃。
            int start = -1, end = -1;
            for (int i = 0; i < size; i++) {
                if (Command.STX.equals(ByteUtil.Byte2Hex(received[i]))) {
                    start = i + 1;
                } else if (Command.ETX.equals(ByteUtil.Byte2Hex(received[i]))) {
                    end = i;
                    break;
                }
            }

            //2. start 和 end 整数表示收到的数据包包含帧数据
            if (start >= 0 && end >= 0) {
                start+=2;//去除字节长度、CMD指令
                byte[] data = new byte[]{};
                if (start < end) {
                    int len = end - start;
                    data = new byte[len];
                    System.arraycopy(received, start, data, 0, len);
                }
                ResponseFrame frame = new ResponseFrame();
                frame.setData(data);
                //3. 得到最终的数据,要进行推送。
                String finallyData = ByteUtil.ByteArrToHex(data);
                Log.d(TAG, "finallyData onDataReceive: "+finallyData);
                //4. todo 数据有了那么就是通知的【可以使用Eventbus,可以根据自己的需求场景】
            }
        }
    }


    /**
     * 停止读线程
     */
    public void close() {
        try {
            mInputStream.close();
        } catch (IOException e) {
        } finally {
            running = false;
        }
    }
}

6.4 数据的发送,通过一个集合来进行管理,不能随意发,这样会导致返回的数据不知道是谁的【如果下位机没有做标识的情况】


/**
 * 串口发送队列
 */
public class SerialPortSendQueue {
    private static final String TAG = "SerialPortSendQueue";
    private CommandFrame currentFrame;

    /**
     * 线程名称
     */
    private final String THREAD_NAME = "SERIAL_PORT_SEND_QUEUE";

    /**
     * 不允许创建对象
     */
    private SerialPortSendQueue() {
        mSendCommandHandlerThread = new HandlerThread(THREAD_NAME);
        if (!mSendCommandHandlerThread.isAlive()) {
            mSendCommandHandlerThread.start();
            mHandler = new SendCommandHandler();
        }
    }

    /**
     * 静态内部类控制单例
     */
    private static class SerialPortManagerHolder {
        final static SerialPortSendQueue instance = new SerialPortSendQueue();
    }

    /**
     * 获取串口设备管理器单例
     *
     * @return 串口设备管理器
     */
    public static SerialPortSendQueue getInstance() {
        return SerialPortSendQueue.SerialPortManagerHolder.instance;
    }


    //指令
    private LinkedList<Order> sMessageListRead = new LinkedList<>();
    private boolean isSendSuccess = true;

    private HandlerThread mSendCommandHandlerThread;
    private SendCommandHandler mHandler;


    /**
     * 发送命令的handler
     */
    private class SendCommandHandler extends Handler {
        /**
         * 最大等待次数
         */
        private final int MAX_WAIT_TIMES = 10;
        /**
         * 最大超时重试次数,0为不重试
         */
        private final int MAX_TIMEOUT_RETRY_TIMES = 1;
        // 是否接收到数据
        private boolean isReceived;
        private CommandFrame frame;

        SendCommandHandler() {
            super(mSendCommandHandlerThread.getLooper());
            Log.d(TAG, "sendMessage: " + mSendCommandHandlerThread.getLooper());
            if (!mSendCommandHandlerThread.isAlive()) {
                throw new RuntimeException("发送命令队列线程尚未建立");
            }
        }

        @Override
        public void handleMessage(Message msg) {
            // 读取命令
            frame = (CommandFrame) msg.obj;
            currentFrame = frame;
            // 初始化参数
            isReceived = false;
            boolean success = true;
            int times = 0;
            int retry = 0;

            SerialPortManager.getInstance().sendCommandFrame(frame);
            Log.d(TAG, "handleMessage: "+currentFrame);

            // 轮询等待
            while (!isReceived) {
                // 增加轮询次数
                times++;
                try {
                    // 单次等待时间,越小轮询越快
                    Thread.sleep(30);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                if (!isReceived && times >= MAX_WAIT_TIMES) {
                    // 等待超时
                    if (retry < MAX_TIMEOUT_RETRY_TIMES) {
                        // 进入重试
                        times = 0;
                        retry++;
                        // TODO 发送命令
                        SerialPortManager.getInstance().sendCommandFrame(frame);
                    } else {
                        // 重试超过最大次数,跳出循环
                        success = false;
                        break;
                    }
                }
            }

            synchronized (SerialPortSendQueue.class) {
                if (success) {
                    sMessageListRead.pollFirst();
                }
                if (sMessageListRead.size() >= 10) {
                    sMessageListRead.clear();
                }
                isSendSuccess = true;
            }
        }
    }

    /**
     * 发送命令消息
     *
     * @param message 命令消息
     */
    private void sendMessage(Message message) {
        if (mHandler != null) {
            mHandler.sendMessage(message);
            Log.d(TAG, "sendMessage: ");
        } else {
            throw new RuntimeException("发送命令handler未启动");
        }
    }

    private void sendOrderMessage(Order order) {
        if (order != null) {
            Message message = new Message();
            CommandFrame commandFrame = new CommandFrame(order.getCmd(),order.getData());
            message.obj = commandFrame;
            sendMessage(message);
        } else {
            isSendSuccess = true;
        }
    }

    /**
     * 开启读list的线程
     */
    public void startReadListThread() {
        try {
            if (sMessageListRead.size() != 0) {
                if (isSendSuccess == true) {
                    isSendSuccess = false;
                    synchronized (SerialPortSendQueue.class) {
                        sendOrderMessage(sMessageListRead.getFirst());
                    }
                }
                Thread.sleep(10);
            }
        } catch (Exception e) {
            e.printStackTrace();
            isSendSuccess = true;
        }
    }

    /**
     * 发送命令
     *
     * @param command 命令帧
     */
    public void sendCommand(CommandFrame command) {

        synchronized (SerialPortSendQueue.class) {
            if (sMessageListRead.size() >= 15) {
                sMessageListRead.clear();
            }
//            Message message = new Message();
//            message.obj = command;
//            sendMessage(message);

            Order order = new Order();
            order.setCmd(command.getCmd());
            order.setData(command.getData());
            if (!sMessageListRead.contains(order)) {
                sMessageListRead.add(order);
            }
        }
    }

    /**
     * 设置收到数据
     */
    public CommandFrame setReceived() {
        return received();
    }

    private CommandFrame received() {
        if (mHandler == null) {
            throw new RuntimeException("发送命令handler未启动");
        }
        CommandFrame frame = mHandler.frame;
        mHandler.isReceived = true;
        return frame;
    }

    public CommandFrame getCurrentFrame() {
        return currentFrame;
    }
}

6.4 MainActivity

SerialPortDevice device = new SerialPortDevice("dev/ttyS3", "38400");
SerialPortManager.getInstance().open(device);

SerialPortSendQueue.getInstance().sendCommand(new WriteCommand(Command.CMD_ONLINE_STATE, "103"));

好了,内容就介绍到这里。

Android串口SerialPort)接收数据的Demo示例如下: ```java import android.support.v7.app.AppCompatActivity; import android.os.Bundle; import android.util.Log; import java.io.File; import java.io.IOException; import java.io.InputStream; import android_serialport_api.SerialPort; public class SerialPortActivity extends AppCompatActivity { private SerialPort mSerialPort; private InputStream mInputStream; private ReadThread mReadThread; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_serial_port); try { mSerialPort = new SerialPort(new File("/dev/ttyS1"), 9600, 0); mInputStream = mSerialPort.getInputStream(); } catch (SecurityException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); } mReadThread = new ReadThread(); mReadThread.start(); } private class ReadThread extends Thread { @Override public void run() { super.run(); while (!isInterrupted()) { try { if (mInputStream == null) { return; } byte[] buffer = new byte[1024]; int size = mInputStream.read(buffer); if (size > 0) { String data = new String(buffer, 0, size); Log.d("SerialPort", "Received data: " + data); // 处理接收到的数据... } } catch (IOException e) { e.printStackTrace(); } } } } @Override protected void onDestroy() { super.onDestroy(); if (mReadThread != null) { mReadThread.interrupt(); mReadThread = null; } if (mSerialPort != null) { mSerialPort.close(); mSerialPort = null; } } } ``` 以上是一个Android中通过串口接收数据的示例代码。在`onCreate`方法中,首先打开串口并获取输入流。然后创建一个`ReadThread`线程用于循环读取串口数据。在`ReadThread`线程的`run`方法中通过`InputStream`的`read`方法读取数据,并将读取到的数据进行处理(这里只是简单地打印出来)。在`onDestroy`方法中,关闭串口和销毁线程。 需要注意的是,这里使用到了一个`android_serialport_api`库,需要在项目的`build.gradle`文件中添加对应的依赖。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

前期后期

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

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

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

打赏作者

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

抵扣说明:

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

余额充值