Android 串口通信

引言

在iot项目中,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,使得开发者能够在不直接处理底层细节的情况下实现串口通信。

应用 场景

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

正文

串口常用于连接计算机与外部设备,如打印机、调制解调器、传感器等。串口的主要特点是通信速度比较慢,但传输距离可以很长。常见的串口标准有RS-232、RS-485、TTL等。

通讯参数

一般这些数据,都是下位机提供给上位机的【上位机指的就是我们的Android屏幕,下位机指的就是上面我们提到的外部设备】,我们按照参数打开串口就可以收发数据了。

通讯接口是什么?

就是 RS232和RS485 。

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

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

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

波特率是什么?

34800,9600

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

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

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

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

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

下位机的数据发送案例

厂家自定义协议

我们简单来看看他的协议,以及我们应该如何发送数据和接收数据。

(1)需要厂家提供通讯参数

(2)通讯文档,比如,查询下位机状态,还有很多协议内容,这里就讲一个:

有了这些信息,先不着急写代码,先使用串口工具测试一下收发数据是否正常。打开串口通讯工具,设置通讯参数,然后发送数据就可以了。

例如: 发送 AA 01 02 DD 接收 AA 02 02 01 DD

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

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

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

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

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

如何处理粘包的情况?

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

比如:本来下位机返回的是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、传输数据量太大:有时候传输数据量太大,导致数据截断,或者缓存区不够。

处理办法:

**添加固定长度头部和尾部:**发送方在每个数据包前添加固定长度的头部,头部中包含数据包的长度信息,接收方根据头部中的长度信息来解析数据。如下:

左边蓝色是上位机发送给下位机的

右边橙色是下位机返回给上位机的。

**消息头,数据内容长度,结束,**这样我们就可以很好的处理数据了,如果数据发回来的不完整,或者连在一起,我们可以视情况,对数据进行解析分段,或者丢弃。

使用

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

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

谷歌给的库:

https://code.google.com/archive/p/android-serialport-api/ 仅支持串口名称及波特率 。

那么我找到了两个做了扩展的两个库,根据情况用:

1、下面示例就讲这个

GitHub - licheedev/Android-SerialPort-API: Fork自Google开源的Android串口通信Demo,修改成Android Studio项目

2、这个库用着比较简单

GitHub - xmaihh/Android-Serialport: 移植谷歌官方串口库,仅支持串口名称及波特率,该项目添加支持校验位、数据位、停止位、流控配置项

使用见: 快速使用Android串口_io.github.xmaihh:serialport-CSDN博客

示例:

添加依赖

在 module 中的 build.gradle 中的 dependencies 中添加以下依赖:

Groovy 复制代码
dependencies {
    //串口
    implementation 'com.github.licheedev:Android-SerialPort-API:2.0.0'
}

低版本的 gradle 在Project 中的 build.gradle 中的 allprojects 中添加以下 maven仓库 (不添加任然无法加载SerialPort);

Groovy 复制代码
allprojects {
    repositories {
        maven { url "https://jitpack.io" }//maven仓库
    }
}

高版本的 gradle 已经废弃了 allprojects 在 settings.gradle 中 repositories 添加以下maven仓库(不添加任然无法加载SerialPort);

Groovy 复制代码
dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories {
        google()
        mavenCentral()
        jcenter() // Warning: this repository is going to shut down soon
        maven { url "https://jitpack.io" }//maven仓库
    }
}
编写串口处理类

1、串口处理类:SerialHandle;简单概括这个类,就是通过串口对象去获取两个流(输入流、输出流),通过者两个流来监听数据 或者写入指令,硬件收到后执行。同时注意配置参数(只要支持串口通讯的硬件,一般说明书上都会有写)

java 复制代码
package com.chj233.serialmode.serialUtil;
 
import android.serialport.SerialPort;
import android.util.Log;
 
import java.io.BufferedInputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
 
/**
 * 串口实处理类
 */
public class SerialHandle implements Runnable {
 
    private static final String TAG = "串口处理类";
    private String path = "";//串口地址
    private SerialPort mSerialPort;//串口对象
    private InputStream mInputStream;//串口的输入流对象
    private BufferedInputStream mBuffInputStream;//用于监听硬件返回的信息
    private OutputStream mOutputStream;//串口的输出流对象 用于发送指令
    private SerialInter serialInter;//串口回调接口
    private ScheduledFuture readTask;//串口读取任务
 
    /**
     * 添加串口回调
     *
     * @param serialInter
     */
    public void addSerialInter(SerialInter serialInter) {
        this.serialInter = serialInter;
    }
 
    /**
     * 打开串口
     *
     * @param devicePath 串口地址(根据平板的说明说填写)
     * @param baudrate   波特率(根据对接的硬件填写 - 硬件说明书上"通讯"中会有标注)
     * @param isRead     是否持续监听串口返回的数据
     * @return 是否打开成功
     */
    public boolean open(String devicePath, int baudrate, boolean isRead) {
        return open(devicePath, baudrate, 7, 1, 2, isRead);
    }
 
    /**
     * 打开串口
     *
     * @param devicePath 串口地址(根据平板的说明说填写)
     * @param baudrate   波特率(根据对接的硬件填写 - 硬件说明书上"通讯"中会有标注)
     * @param dataBits   数据位(根据对接的硬件填写 - 硬件说明书上"通讯"中会有标注)
     * @param stopBits   停止位(根据对接的硬件填写 - 硬件说明书上"通讯"中会有标注)
     * @param parity     校验位(根据对接的硬件填写 - 硬件说明书上"通讯"中会有标注)
     * @param isRead     是否持续监听串口返回的数据
     * @return 是否打开成功
     */
    public boolean open(String devicePath, int baudrate, int dataBits, int stopBits, int parity, boolean isRead) {
        boolean isSucc = false;
        try {
            if (mSerialPort != null) close();
            File device = new File(devicePath);
            mSerialPort = SerialPort // 串口对象
                    .newBuilder(device, baudrate) // 串口地址地址,波特率
                    .dataBits(dataBits) // 数据位,默认8;可选值为5~8
                    .stopBits(stopBits) // 停止位,默认1;1:1位停止位;2:2位停止位
                    .parity(parity) // 校验位;0:无校验位(NONE,默认);1:奇校验位(ODD);2:偶校验位(EVEN)
                    .build(); // 打开串口并返回
            mInputStream = mSerialPort.getInputStream();
            mBuffInputStream = new BufferedInputStream(mInputStream);
            mOutputStream = mSerialPort.getOutputStream();
            isSucc = true;
            path = devicePath;
            if (isRead) readData();//开启识别
        } catch (Throwable tr) {
            close();
            isSucc = false;
        } finally {
            return isSucc;
        }
    }
 
    // 读取数据
    private void readData() {
        if (readTask != null) {
            readTask.cancel(true);
            try {
                Thread.sleep(160);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            //此处睡眠:当取消任务时 线程池已经执行任务,无法取消,所以等待线程池的任务执行完毕
            readTask = null;
        }
        readTask = SerialManage
                .getInstance()
                .getScheduledExecutor()//获取线程池
                .scheduleAtFixedRate(this, 0, 150, TimeUnit.MILLISECONDS);//执行一个循环任务
    }
 
    @Override//每隔 150 毫秒会触发一次run
    public void run() {
        if (Thread.currentThread().isInterrupted()) return;
        try {
            int available = mBuffInputStream.available();
            if (available == 0) return;
            byte[] received = new byte[1024];
            int size = mBuffInputStream.read(received);//读取以下串口是否有新的数据
            if (size > 0 && serialInter != null) serialInter.readData(path, received, size);
        } catch (IOException e) {
            Log.e(TAG, "串口读取数据异常:" + e.toString());
        }
    }
 
    /**
     * 关闭串口
     */
    public void close(){
        try{
            if (mInputStream != null) mInputStream.close();
        }catch (Exception e){
            Log.e(TAG,"串口输入流对象关闭异常:" +e.toString());
        }
        try{
            if (mOutputStream != null) mOutputStream.close();
        }catch (Exception e){
            Log.e(TAG,"串口输出流对象关闭异常:" +e.toString());
        }
        try{
            if (mSerialPort != null) mSerialPort.close();
            mSerialPort = null;
        }catch (Exception e){
            Log.e(TAG,"串口对象关闭异常:" +e.toString());
        }
    }
 
    /**
     * 向串口发送指令
     */
    public void send(final String msg) {
        byte[] bytes = hexStr2bytes(msg);//字符转成byte数组
        try {
            mOutputStream.write(bytes);//通过输出流写入数据
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
 
    /**
     * 把十六进制表示的字节数组字符串,转换成十六进制字节数组
     *
     * @param
     * @return byte[]
     */
    private byte[] hexStr2bytes(String hex) {
        int len = (hex.length() / 2);
        byte[] result = new byte[len];
        char[] achar = hex.toUpperCase().toCharArray();
        for (int i = 0; i < len; i++) {
            int pos = i * 2;
            result[i] = (byte) (hexChar2byte(achar[pos]) << 4 | hexChar2byte(achar[pos + 1]));
        }
        return result;
    }
 
    /**
     * 把16进制字符[0123456789abcde](含大小写)转成字节
     * @param c
     * @return
     */
    private static int hexChar2byte(char c) {
        switch (c) {
            case '0':
                return 0;
            case '1':
                return 1;
            case '2':
                return 2;
            case '3':
                return 3;
            case '4':
                return 4;
            case '5':
                return 5;
            case '6':
                return 6;
            case '7':
                return 7;
            case '8':
                return 8;
            case '9':
                return 9;
            case 'a':
            case 'A':
                return 10;
            case 'b':
            case 'B':
                return 11;
            case 'c':
            case 'C':
                return 12;
            case 'd':
            case 'D':
                return 13;
            case 'e':
            case 'E':
                return 14;
            case 'f':
            case 'F':
                return 15;
            default:
                return -1;
        }
    }
 
}

2、串口回调SerialInter;简单概括一下这个类,就是将SerialHandle类中产生的结果,返回给上一层的业务代码,解偶合。

java 复制代码
package com.chj233.serialmode.serialUtil;
 
/**
 * 串口回调
 */
public interface SerialInter {
 
    /**
     * 连接结果回调
     * @param path 串口地址(当有多个串口需要统一处理时,可以用地址来区分)
     * @param isSucc 连接是否成功
     */
    void connectMsg(String path,boolean isSucc);
 
    /**
     * 读取到的数据回调
     * @param path 串口地址(当有多个串口需要统一处理时,可以用地址来区分)
     * @param bytes 读取到的数据
     * @param size 数据长度
     */
    void readData(String path,byte[] bytes,int size);
 
}

3、串口统一管理SerialManage;简单概括一下这个类,用于管理串口的连接以及发送等功能,尤其是发送指令,极短时间内发送多个指令(例如:1毫秒内发送10个指令),多个指令之间会相互干扰。可能执行了第一个指令,可能一个都没执行。这个类不是必须的,如果有更好的方法可以自己定义。

java 复制代码
package com.chj233.serialmode.serialUtil;
 
import java.util.Queue;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
 
/**
 * 串口管理类
 */
public class SerialManage {
 
    private static SerialManage instance;
    private ScheduledExecutorService scheduledExecutor;//线程池 同一管理保证只有一个
    private SerialHandle serialHandle;//串口连接 发送 读取处理对象
    private Queue<String> queueMsg = new ConcurrentLinkedQueue<String>();//线程安全到队列
    private ScheduledFuture sendStrTask;//循环发送任务
    private boolean isConnect = false;//串口是否连接
 
    private SerialManage() {
        scheduledExecutor = Executors.newScheduledThreadPool(8);//初始化8个线程
    }
 
    public static SerialManage getInstance() {
        if (instance == null) {
            synchronized (SerialManage.class) {
                if (instance == null) {
                    instance = new SerialManage();
                }
            }
        }
        return instance;
    }
 
    /**
     * 获取线程池
     *
     * @return
     */
    public ScheduledExecutorService getScheduledExecutor() {
        return scheduledExecutor;
    }
 
    /**
     * 串口初始化
     *
     * @param serialInter
     */
    public void init(SerialInter serialInter) {
        if (serialHandle == null) {
            serialHandle = new SerialHandle();
            startSendTask();
        }
        serialHandle.addSerialInter(serialInter);
 
    }
 
    /**
     * 打开串口
     */
    public void open() {
        isConnect = serialHandle.open("/dev/ttyS1", 9600, true);//设置地址,波特率,开启读取串口数据
    }
 
    /**
     * 发送指令
     *
     * @param msg
     */
    public void send(String msg) {
        /*
         此处没有直接使用 serialHandle.send(msg); 方法去发送指令
         因为 某些硬件在极短时间内只能响应一个指令,232通讯一次发送多个指令会有物理干扰,
         让硬件接收到指令不准确;所以 此处将指令添加到队列中,排队执行,确保每个指令一定执行.
         若不相信可以试试用serialHandle.send(msg)方法循环发送10个不同的指令,看看10个指令
         的执行结果。
         */
        queueMsg.offer(msg);//向队列添加指令
    }
 
    /**
     * 关闭串口
     */
    public void colse() {
        serialHandle.close();//关闭串口
    }
 
    //启动发送发送任务
    private void startSendTask() {
        cancelSendTask();//先检查是否已经启动了任务 ? 若有则取消
        //每隔100毫秒检查一次 队列中是否有新的指令需要执行
        sendStrTask = scheduledExecutor.scheduleAtFixedRate(new Runnable() {
            @Override
            public void run() {
                if (!isConnect) return;//串口未连接 退出
                if (serialHandle == null) return;//串口未初始化 退出
                String msg = queueMsg.poll();//取出指令
                if (msg == null || "".equals(msg)) return;//无效指令 退出
                serialHandle.send(msg);//发送指令
            }
        }, 0, 100, TimeUnit.MILLISECONDS);
    }
 
    //取消发送任务
    private void cancelSendTask() {
        if (sendStrTask == null) return;
        sendStrTask.cancel(true);
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        sendStrTask = null;
    }
 
}

demo调用

java 复制代码
package com.chj233.serialmode;
 
import androidx.appcompat.app.AppCompatActivity;
 
import android.os.Bundle;
import android.util.Log;
import android.view.View;
 
import com.chj233.serialmode.serialUtil.SerialInter;
import com.chj233.serialmode.serialUtil.SerialManage;
 
public class MainActivity extends AppCompatActivity implements SerialInter {
 
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        SerialManage.getInstance().init(this);//串口初始化
        SerialManage.getInstance().open();//打开串口
        findViewById(R.id.send_but).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                SerialManage.getInstance().send("Z");//发送指令 Z 
            }
        });
    }
 
    @Override
    public void connectMsg(String path, boolean isSucc) {
        String msg = isSucc ? "成功" : "失败";
        Log.e("串口连接回调", "串口 "+ path + " -连接" + msg);
    }
 
    @Override//若在串口开启的方法中 传入false 此处不会返回数据
    public void readData(String path, byte[] bytes, int size) {
//        Log.e("串口数据回调","串口 "+ path + " -获取数据" + bytes);
    }
}

多串口的使用

使用思想:**一个单例对象控制一个串口,多串口时,写多个"SerialManage"就可以了。**这里仅仅做举例不去考虑代码是否优雅,可以自行优化这段代码。(此案例中的SerialManage1、SerialManage2、SerialManage3、SerialManage4需要自己去复制,参照上面的SerialManage)

java 复制代码
public class MainActivity extends AppCompatActivity {
 
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        //初始化串口1
        SerialManage1.getInstance().init(new SerialInter(){
            @Override
            public void connectMsg(String path, boolean isSucc) {
                String msg = isSucc ? "成功" : "失败";
                Log.e("串口连接回调", "串口 "+ path + " -连接" + msg);
            }
 
            @Override//若在串口开启的方法中 传入false 此处不会返回数据
            public void readData(String path, byte[] bytes, int size) {
//        Log.e("串口数据回调","串口 "+ path + " -获取数据" + bytes);
            }
        });
        //开启串口1
        SerialManage1.getInstance().open();
 
        //初始化串口2
        SerialManage2.getInstance().init(new SerialInter(){
            @Override
            public void connectMsg(String path, boolean isSucc) {
                String msg = isSucc ? "成功" : "失败";
                Log.e("串口连接回调", "串口 "+ path + " -连接" + msg);
            }
 
            @Override//若在串口开启的方法中 传入false 此处不会返回数据
            public void readData(String path, byte[] bytes, int size) {
//        Log.e("串口数据回调","串口 "+ path + " -获取数据" + bytes);
            }
        });
        //打开串口2
        SerialManage2.getInstance().open();
        //初始化串口3
        SerialManage3.getInstance().init(new SerialInter(){
            @Override
            public void connectMsg(String path, boolean isSucc) {
                String msg = isSucc ? "成功" : "失败";
                Log.e("串口连接回调", "串口 "+ path + " -连接" + msg);
            }
 
            @Override//若在串口开启的方法中 传入false 此处不会返回数据
            public void readData(String path, byte[] bytes, int size) {
//        Log.e("串口数据回调","串口 "+ path + " -获取数据" + bytes);
            }
        });
        //打开串口3
        SerialManage3.getInstance().open();
        //初始化串口4
        SerialManage4.getInstance().init(new SerialInter(){
            @Override
            public void connectMsg(String path, boolean isSucc) {
                String msg = isSucc ? "成功" : "失败";
                Log.e("串口连接回调", "串口 "+ path + " -连接" + msg);
            }
 
            @Override//若在串口开启的方法中 传入false 此处不会返回数据
            public void readData(String path, byte[] bytes, int size) {
//        Log.e("串口数据回调","串口 "+ path + " -获取数据" + bytes);
            }
        });
        //打开串口4
        SerialManage4.getInstance().open();
 
        findViewById(R.id.send_but1).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                SerialManage1.getInstance().send("Z");//给串口1发送指令 Z
            }
        });
        findViewById(R.id.send_but2).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                SerialManage2.getInstance().send("Z");//给串口2发送指令 Z
            }
        });
        findViewById(R.id.send_but3).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                SerialManage3.getInstance().send("Z");//给串口3发送指令 Z
            }
        });
        findViewById(R.id.send_but4).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                SerialManage4.getInstance().send("Z");//给串口4发送指令 Z
            }
        });
    }
 
}

总结

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

参考文章 :

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

相关推荐
雨白5 小时前
Jetpack系列(二):Lifecycle与LiveData结合,打造响应式UI
android·android jetpack
kk爱闹6 小时前
【挑战14天学完python和pytorch】- day01
android·pytorch·python
每次的天空8 小时前
Android-自定义View的实战学习总结
android·学习·kotlin·音视频
恋猫de小郭8 小时前
Flutter Widget Preview 功能已合并到 master,提前在体验毛坯的预览支持
android·flutter·ios
断剑重铸之日9 小时前
Android自定义相机开发(类似OCR扫描相机)
android
随心最为安9 小时前
Android Library Maven 发布完整流程指南
android
岁月玲珑9 小时前
【使用Android Studio调试手机app时候手机老掉线问题】
android·ide·android studio
还鮟14 小时前
CTF Web的数组巧用
android
小蜜蜂嗡嗡15 小时前
Android Studio flutter项目运行、打包时间太长
android·flutter·android studio
aqi0015 小时前
FFmpeg开发笔记(七十一)使用国产的QPlayer2实现双播放器观看视频
android·ffmpeg·音视频·流媒体