简介:面向 Android 开发者的 CAN 通信 Demo,聚焦车载电子、工业自动化等场景下的总线数据实时交换,适合希望掌握 Java 层 CAN 协议集成与调用的开发者,也可为物联网或车联网项目提供参考。压缩包共 337 个文件,大小约 2.14MB,内容完整度较高,既有 java 源码、class 与 dex 编译产物、jar 依赖库、gradle 构建配置,也有 apk 安装包、xml 布局、json 配置及 txt 说明文档,方便同时查看实现细节和运行效果。已有 1054 人学习该资源。Demo 覆盖了 CAN 标准帧与扩展帧的结构区别、USB/蓝牙适配器的连接与波特率配置、消息创建与收发、数据解析、异常处理等关键环节,并附有可安装 APK,便于在真机环境中快速验证。通过该示例可以理解多主站总线在 Android 端的实现思路,掌握从权限申请、设备检测到消息回调的完整流程,适合边看边练、对照源码排查问题。
1. 用 Android 做 CAN 通信:为什么多数人第一步就卡在驱动上
车载总线调试、工控设备巡检、无人机地面站这类场景里,经常需要拿 Android 设备直接读写 CAN 总线数据。很多人第一反应是找一根 USB 转 CAN 线接上就开干,结果插上设备后 Android 根本不识别,要么报 permission denied,要么打开 SocketCAN 找不到 can0 接口。先说结论:Android 上做 CAN 通信,核心链路是「USB/蓝牙硬件适配层 → JNI 桥接 → Java 应用层」,而这条链路上最容易翻车的不是上层收发逻辑,而是底层驱动权限和设备节点访问。这份资源是一套完整的 Android Java CAN 通信 Demo,覆盖 USB 转 CAN 与蓝牙转 CAN 两条链路,从驱动配置、JNI 封装到收发现成代码全都有,适合要快速搭调试工具或做车载/工控原型验证的开发者。它解决的不是「能不能通」的问题,而是「怎么少踩坑地通」。
2. 通信链路选型:USB 转 CAN 与蓝牙转 CAN 怎么选才不翻车
2.1 硬件链路差异与适用场景判断
Android 设备本身没有 CAN 控制器,必须靠外部硬件桥接。目前市面上主流方案就两条路:USB 转 CAN(如常见的 USBCAN-II 结构)和蓝牙转 CAN(如串口透传型 CAN 模块)。两者的差异不只是物理接口,而是决定你整个软件栈怎么写的关键因素。
USB 转 CAN 的典型做法是走 Android USB Host 模式,需要设备支持 OTG。应用层通过 USB 权限申请拿到设备接口后,用 bulkTransfer 做数据读写。但这里有个关键点:很多 USB 转 CAN 模块出厂固件是给 PC 端写的,用的是厂商私有 USB 协议,不是标准的 CDC-ACM 类。这意味着你在 Android 端不能直接用 UsbSerial 库去枚举,得先确认厂商协议格式,比如数据头、命令字、CRC 校验怎么排布。我在项目里遇到过一个模块,PC 端驱动和 Android 端枚举出来接口描述符完全对不上,后来抓 USB 描述符才发现厂商在接口里塞了一个 HID 类报表描述符作为兼容层,这种模块在 Android 上就得走 HID 通道而不是批量传输端点。
蓝牙转 CAN 相对简单,模块内部把 CAN 帧封装成串口协议,通过蓝牙 SPP 透传。Android 端只需要 BluetoothSocket 连接后用 InputStream/OutputStream 读写协议帧。好处是手机不需要 OTG 线,坏处是蓝牙传输有延迟抖动,高速 CAN 总线(500kbps 以上)连续收发时容易丢帧。我一般建议:如果是做诊断、标定这类低频交互,蓝牙方案够用;如果是做数据采集、总线监控这类需要连续收帧的场景,老老实实用 USB。
2.2 SocketCAN 与自研协议栈的取舍
Linux 内核自带 SocketCAN 协议族,Android 系统如果内核开了 CONFIG_CAN 相关配置,理论上可以直接用 Java 通过 JNI 调用 socket(PF_CAN, SOCK_RAW, CAN_RAW) 来收发。但实际项目里这条路经常走不通——很多车机、平板厂商的 Android 内核没编译 CAN 模块,或者有模块但没开放 can0 设备节点权限。
我在模拟项目X里用的是自研的轻量协议栈方案:Java 层定义统一的 CAN 帧结构,JNI 层通过 USB 或蓝牙拿到原始字节流后,解析出 ID、DLC、Data 再回调给 Java。这样设计有几个好处:
- 不依赖 Android 系统是否编译 SocketCAN,兼容性更广
- 帧过滤、时间戳标注可以直接放在 JNI 层做,减少 Java 层 GC 对收帧实时性的干扰
- 换硬件时只需要改 JNI 里的一层读取逻辑,上层业务代码不用动
当然这个方案也有代价:你没法直接复用网上的 candump、cansend 这些 Linux 工具逻辑,所有帧解析都得自己写。所以拿到这套 Demo 后,先花 10 分钟读清楚 JNI 层的数据流走向,别急着跑 UI,这个抽象层是整个工程的核心边界。
3. 工程搭建与 JNI 桥接:从空工程到能收到第一帧数据
3.1 工程结构与二进制库摆放
这套 Demo 的工程结构分三层:app 模块下的 Java 代码、src/main/cpp 下的 JNI 源码、以及预编译的 .so 库目录。打开工程后第一件事是先确认硬件对应的 .so 文件是否匹配你的 CPU 架构。很多新手直接把 armeabi-v7a 的库丢进 arm64-v8a 的设备里跑,一加载就 UnsatisfiedLinkError,然后开始怀疑代码有问题。实际上检查一下 Build 输出里的 ABI 列表就能定位。
工程里关键的目录结构如下:
app/src/main/java/com/example/canbus/ ├── CanManager.java # Java 层门面类,负责初始化和数据回调 ├── CanFrame.java # CAN 帧数据结构体 ├── UsbCanChannel.java # USB 通道实现 ├── BluetoothCanChannel.java # 蓝牙通道实现 └── CanDataListener.java # 收帧回调接口 app/src/main/cpp/ ├── can_jni.cpp # JNI 注册与桥接逻辑 ├── can_parser.c # CAN 帧解析与组装 └── usb_can_driver.c # USB 通信底层实现编译时用 CMake 生成动态库,构建脚本里的关键配置是:
cmake_minimum_required(VERSION 3.10) project(canbridge) add_library(canbridge SHARED src/main/cpp/can_jni.cpp src/main/cpp/can_parser.c src/main/cpp/usb_can_driver.c ) target_link_libraries(canbridge android log ) # 按需保留,保证 .so 体积不过度膨胀 abi_filters("armeabi-v7a" "arm64-v8a")build.gradle 里的 ABI 配置是踩坑重灾区,我一般只保留两种:
android { defaultConfig { ndk { abiFilters "armeabi-v7a", "arm64-v8a" } } externalNativeBuild { cmake { path "src/main/CMakeLists.txt" } } }这里有个容易忽略的点:如果工程里同时有 Java 层的 USB 权限处理逻辑和 JNI 层的 USB 读写,权限回调结果必须先同步到 native 层再开始收发。常见做法是在 Java 层拿到 UsbDevice 后,把文件描述符传给 JNI,由 native 层做后续的 claimInterface 和数据读写。这样 Java 层不需要反复跳线程去处理 USB 事务,性能也更稳定。
3.2 JNI 注册与 Java 侧数据回调实现
JNI 层的注册方式推荐用静态注册加动态注册混合。静态注册函数名长但直观,动态注册的 RegisterNatives 更高效。我在 Demo 里用的动态注册,这样后续扩展方法不用改函数名格式。
static jlong nativeOpenChannel(JNIEnv* env, jobject thiz, jstring path) { const char* devPath = env->GetStringUTFChars(path, nullptr); CAN_HANDLE handle = can_open(devPath); env->ReleaseStringUTFChars(path, devPath); return reinterpret_cast<jlong>(handle); } static void nativeSendFrame(JNIEnv* env, jobject thiz, jlong handle, jint id, jbyteArray data) { CAN_HANDLE h = reinterpret_cast<CAN_HANDLE>(handle); jsize len = env->GetArrayLength(data); jbyte* buf = env->GetByteArrayElements(data, nullptr); can_frame frame; frame.can_id = id; frame.can_dlc = len; memcpy(frame.data, buf, len); can_send(h, &frame); env->ReleaseByteArrayElements(data, buf, JNI_ABORT); } static const JNINativeMethod gMethods[] = { {"nativeOpenChannel", "(Ljava/lang/String;)J", (void*)nativeOpenChannel}, {"nativeSendFrame", "(J[I[B)V", (void*)nativeSendFrame}, {"nativeCloseChannel", "(J)V", (void*)nativeCloseChannel}, }; jint JNI_OnLoad(JavaVM* vm, void* reserved) { JNIEnv* env; if (vm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6) != JNI_OK) { return JNI_ERR; } jclass clazz = env->FindClass("com/example/canbus/CanManager"); env->RegisterNatives(clazz, gMethods, sizeof(gMethods) / sizeof(gMethods[0])); return JNI_VERSION_1_6; }Java 侧的 CanManager 包装这些 native 方法,同时管理一个 HandlerThread 来接收 native 层回调上来的帧数据。注意这里不能直接用 runOnUiThread 做收帧分发,因为 CAN 消息频率高了以后 UI 线程会卡到爆。我一般先用一个无界队列收,再定时批量往 UI 推,保证界面刷新频率不超过 20Hz。
public class CanManager { static { System.loadLibrary("canbridge"); } private final BlockingQueue<CanFrame> frameQueue = new LinkedBlockingQueue<>(256); private final Handler uiHandler; private final CanDataListener listener; public void startReceive() { Thread receiver = new Thread(() -> { while (!Thread.currentThread().isInterrupted()) { try { CanFrame frame = frameQueue.take(); uiHandler.post(() -> listener.onFrameReceived(frame)); } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } } }); receiver.setName("can-receiver"); receiver.start(); } private void onNativeFrameReceived(long timestamp, int id, byte[] data) { CanFrame frame = new CanFrame(id, data, timestamp); if (!frameQueue.offer(frame)) { // 队列满了说明消费速度跟不上,丢帧比阻塞好 Log.w("CanManager", "frame queue full, drop frame id=0x" + Integer.toHexString(id)); } } }逻辑说明:native 层通过 CallVoidMethod 回调 Java 的 onNativeFrameReceived 方法,Java 层把帧放入 BlockingQueue,独立的接收线程取出后再 post 到 UI 线程。参数里 timestamp 是 native 层打的时间戳,单位微秒,用于后续分析总线负载和帧间隔,这是排查丢帧问题的重要依据。
这里还有个小细节:JNI 回调 Java 方法的时候,如果帧率很高,跨 JNI 边界的 CallVoidMethod 开销不能忽视。实测在 500kbps 总线满负载下,单帧回调耗时约 80 微秒,看起来不大,但如果每帧都做一次 String 拼接或日志打印,瞬间就会把 CPU 吃掉。所以回调里只做 byte[] 拷贝,不做任何格式化。
3.3 USB 权限申请与设备节点访问配置
Android 的 USB Host 权限不是自动授权的,必须在 Activity 里动态申请。核心逻辑在 onResume 里系统会发 ACTION_USB_DEVICE_ATTACHED 广播,用 USBManager 的 deviceList 拿到设备后立即请求权限:
public class MainActivity extends Activity { private static final String ACTION_USB_PERMISSION = "com.example.canbus.USB_PERMISSION"; private final BroadcastReceiver usbReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { String action = intent.getAction(); if (ACTION_USB_PERMISSION.equals(action)) { synchronized (this) { UsbDevice device = intent.getParcelableExtra(USBManager.EXTRA_DEVICE); if (intent.getBooleanExtra(USBManager.EXTRA_PERMISSION_GRANTED, false)) { openUsbChannel(device); } else { Toast.makeText(context, "USB permission denied", Toast.LENGTH_SHORT).show(); } } } } }; private void requestUsbPermission(UsbDevice device) { PendingIntent pi = PendingIntent.getBroadcast(this, 0, new Intent(ACTION_USB_PERMISSION), PendingIntent.FLAG_MUTABLE); usbManager.requestPermission(device, pi); } }如果你在真机上跑 Demo 时发现点击授权后仍然打不开设备,大概率是 USB 转 CAN 模块的 Vendor ID 和 Product ID 没有写进 intent-filter 的 device_filter.xml 里。这个文件放在 res/xml 目录下,系统靠它来识别哪些 USB 设备应当触发你的应用:
<?xml version="1.0" encoding="utf-8"?> <resources> <usb-device vendor-id="0x1D50" product-id="0x606F" /> <usb-device vendor-id="0x0483" product-id="0x5740" /> </resources>这两个 ID 对应常见的 USB-CAN 适配器芯片方案,前者是 gs_usb 内核模块的 ID,很多国产 USB 转 CAN 线都复用了它。如果你的模块用的是其他厂商芯片,用「USB Device Info」这类工具抓一下设备的 VID/PID,替换成你自己设备的值。
4. 数据收发核心逻辑:帧格式、过滤规则与缓冲队列
4.1 CAN 帧结构解析与构造
CAN 2.0B 标准帧在总线上的真实格式是:SOF 位 + 11 位 ID(扩展帧是 29 位)+ RTR 位 + DLC + 0~8 字节数据 + CRC + ACK + EOF。但在你写代码时不需要关心物理层这些位,你只需要在应用层构造 can_frame 结构体。这套 Demo 里 CanFrame 类的设计直接影响 Java 层代码的读写便利性:
public class CanFrame { public int id; // 报文 ID,标准帧 0x000~0x7FF,扩展帧 0x00000000~0x1FFFFFFF public boolean isExtended; // 是否扩展帧 public boolean isRemote; // 是否远程帧 public byte[] data; // 数据段,长度最大 8 public long timestamp; // 纳秒级时间戳,来自 native 层 public CanFrame(int id, byte[] data, long timestamp) { this.id = id; this.data = data; this.timestamp = timestamp; this.isExtended = (id & 0x80000000) != 0; } public int getDlc() { return data != null ? data.length : 0; } }这里有个习惯值得保持:id 的符号位在 JNI 层就处理好。很多初学者把 CAN ID 直接塞进 int 的 32 位里,结果用扩展帧 29 位 ID 时,最高位被 Java 的 int 当成了符号位,导致 ID 变成负数。我在 can_parser.c 里统一约定:JNI 层传上来的 int 已经剥离了扩展帧标志位,isExtended 单独用 boolean 传,这样 Java 层拿到的一定是正的 ID 值。
发送帧时,构造过程如下:
CanFrame frame = new CanFrame(); frame.id = 0x123; frame.isExtended = false; frame.data = new byte[] {0x01, 0x02, 0x03, 0x04}; // 发送 canManager.sendFrame(frame);在 JNI 层组装实际数据时,要处理 ID 的 bit 布局。标准帧的 ID 在 can_frame 里占用 can_id 的低 11 位;如果是扩展帧,CAN_EFF_FLAG 需要置位,ID 分布在 bit 0~28。我在模拟项目X里踩过一次坑:直接拿 Java 层传来的 int 塞进 can_id,忘记处理 EFF 标志位,导致扩展帧全部发不出去。排查方式是用逻辑分析仪抓总线电平,对比 CAN 协议规范里 ID 的 bit 顺序,才发现是标志位没设。
4.2 发送路径与总线仲裁机制
CAN 总线是多主通信,任何节点都能在总线空闲时发送。这带来一个 Java 层需要考虑的问题:如果你的应用同时有多个线程在调用 sendFrame,底层 USB 通道只有一个,谁先谁后必须串行化。Demo 里用的是单写线程加锁:
public void sendFrame(CanFrame frame) { synchronized (sendLock) { nativeSendFrame(channelHandle, frame.id, frame.data); } }这个锁的粒度看起来很粗,但实际没问题。CAN 总线的发送本身是毫秒级操作,USB bulkTransfer 的单次传输约 1~2ms,synchronized 的开销在微秒级,完全可忽略。真正要小心的是不要在锁内做日志打印或字符串拼接,否则高优先级报文会被低优先级线程的打印阻塞。
总线仲裁方面,CAN 协议规定 ID 值越小优先级越高。也就是说 0x000 的优先级最高,0x7FF 最低。如果你的应用同时要发多条报文,比如周期性的心跳帧和事件触发的诊断帧,建议把动态帧的 ID 设小一点,静态周期帧设大一点,让突发数据在仲裁时能优先抢占总线。我在工程里看到过这样一段实践代码:
public class CanMessageScheduler { private static final int ID_HEARTBEAT = 0x101; private static final int ID_DIAG_REQUEST = 0x7E0; public void sendHeartbeat() { CanFrame frame = new CanFrame(); frame.id = ID_HEARTBEAT; frame.data = new byte[]{0x00, 0x11}; canManager.sendFrame(frame); } public void sendDiagRequest() { // 诊断请求优先级更高,但 ID 值反而更大,实际项目中要反过来设计 } }注意这里注释里写的「要反过来设计」是真实教训——诊断请求这种偶发但关键的帧,应该分配到比心跳更小的 ID。后来在某图像处理 Demo 的调试环节,我因为把这个顺序搞反了,导致总线上周期帧一直占着优先级,诊断请求在总线繁忙时延迟超过 50ms,直接触发了超时保护。
4.3 接收线程与队列设计:防止高频帧打垮 UI
接收侧的核心难点不在解析而在消费。500kbps 总线满载时,如果每条报文 8 字节,理论帧率大约可以达到每秒 8000 帧左右。USB 转 CAN 模块受制于 USB 传输特性,实际吞吐通常到不了这个理论值,但每秒钟收 1000 帧以上是常见的。Java 层如果每帧都刷新 UI,RecyclerView 必卡死。
Demo 里的设计是两级缓冲:native 层先按时间戳顺序塞进一个环形缓冲区,Java 层用 BlockingQueue 接住,然后单独的开一个线程做批量聚合。聚合的目的是 100ms 攒一批,一次性把帧列表丢给 UI。
private final List<CanFrame> pendingFrames = new ArrayList<>(); private static final long FLUSH_INTERVAL_MS = 100; private void startBatchDispatcher() { Thread dispatcher = new Thread(() -> { while (!Thread.currentThread().isInterrupted()) { try { Thread.sleep(FLUSH_INTERVAL_MS); } catch (InterruptedException e) { break; } if (pendingFrames.isEmpty()) continue; final List<CanFrame> batch = new ArrayList<>(pendingFrames); pendingFrames.clear(); uiHandler.post(() -> listener.onFrameBatchReceived(batch)); } }); dispatcher.start(); }关于缓冲队列大小有一个经验值:BlockingQueue 容量 256 就够了。因为消费端每 100ms 批量取一次,只要单位时间产生的帧数不超过 2560 帧/秒就不会溢出。如果你的场景是超高速采集,把容量调大到 1024 的同时,也要考虑内存开销。每帧 CanFrame 对象加上 byte[] 数据大约 40~60 字节,1024 帧也就是 60KB 左右,对 Android 设备来说不是问题。真正的问题是对象频繁创建导致的 GC 抖动,所以在高频场景下我推荐用对象池复用 CanFrame,但那是后话了,Demo 里为了可读性没做这层优化。
5. CAN 通信避坑实录:五个高频问题和它们背后的真因
5.1 设备插上没反应:权限拿到了但 USB 打不开
现象:应用已经弹出了 USB 授权框,点击允许后调用 openDevice 返回 null,或者 claimInterface 失败。
原因排查流程:
- 第一步确认 VID/PID 是否匹配,拿 USB 描述符工具抓一下真实值
- 第二步查 isClaimed 状态,如果别的应用或系统服务已经 claim 了这个接口,你这边会失败
- 第三步大概率是设备内核驱动不对。很多 USB 转 CAN 模块用的是 CDC ACM 类,Android 默认把 /dev/ttyACM0 给到了 radio 或 bluetooth 服务,不会让你的应用直接碰
解决:在 Java 层检测到 claim 失败后,尝试 detachKernelDriver 或强制 setInterface。这类操作需要设备权限,所以建议在授权回调里先做:
if (usbManager.hasPermission(device)) { UsbDeviceConnection connection = usbManager.openDevice(device); if (connection != null) { boolean claimed = connection.claimInterface(intf, true); if (!claimed) { // 尝试强制释放内核驱动再claim connection.detachKernelDriver(intf); claimed = connection.claimInterface(intf, true); } } }注意 detachKernelDriver 在部分设备上会抛异常或不生效,所以要有 fallback 逻辑:直接报错提示用户换 USB 口,有的设备只有特定 OTG 口支持 Host 模式。
5.2 能收到帧但全是乱码:字节序和帧格式错位
现象:收到的 data 数组和总线上其他节点发的不一致,比如标准 CAN 报文数据段应该是 01 02 03 04,你收到的是 04 03 02 01,或者在 JNI 层解析时 DLC 一直是 0。
原因:这是 USB 转 CAN 模块协议里最常见的坑。你的模块在 USB 传输层可能丢了一个字节的状态信息或者时间戳,导致 JNI 层从缓冲区里读数据时错位。另外 CAN 数据段本身没有字节序概念,但如果是多字节变量(比如 int32 的转速值),就有大端小端的区别——应用层解析时要按发送方的定义来处理。
解决:先在 JNI 层打印完整的裸数据 hex dump,确认模块在 USB 端口传输的帧格式。常见的传输封装是:帧头(1~2字节) + ID(4字节,含标志位) + DLC(1字节) + DATA(8字节,不足补0) + 时间戳(4字节)。如果发现 ID 和 DLC 的位置和你构建解析器时的偏移不一致,直接调整 can_parser.c 里的帧头偏移量。经验是先在 PC 上用模块自带的上位机软件抓一条已知报文,对比 Android 端收到的字节序列,一次就能定位偏移。
5.3 蓝牙方案收帧延时漂移:时序逻辑全部对不上
现象:用蓝牙转 CAN 模块时,代码层面收到的帧内容都正确,但帧间时间间隔不稳定,偶发 100ms 以上的大延迟。用总线负载分析工具看到的帧周期明明是 10ms,你的应用层面测出来有时是 15ms,有时是 9ms。
原因:蓝牙 SPP 是流式传输,没有实时性保证。协议栈重传、调度延迟都会引入抖动。另外很多蓝牙转 CAN 模块本身内部缓冲区设计得很浅,总线数据一密集就丢弃,根本不会透传给你。
解决:如果硬件方案定了蓝牙没法换,就在 JNI 层的解析器里基于帧数据内部的计数器字段做时间补偿。比如某些模块自带 1ms 精度的时间戳,你可以用它作为基准时间,而不是用 Java 层的 System.nanoTime。在模拟项目X里,我是在 JNI 层记录帧到达的墙钟时间后,减去模块传输引入的固定延迟来校准。但由于蓝牙延迟不固定,这个方案只能说治标不治本。如果项目对时序要求高,直接换 USB 方案,别折腾。
5.4 发送失败但是没异常:USB 写队列阻塞后数据被静默丢弃
现象:sendFrame 返回正常,但总线抓包发现报文丢了;或者应用连续发多条时报文间隔不均匀。
原因:USB bulkTransfer 是同步阻塞接口,当内核的 USB 写队列满时,write 操作会阻塞住 JNI 线程。如果业务线程直接调 nativeSendFrame,而底层因为前面一个大帧还没写完就堵在那,后续帧全都排队等着。看起来没有异常,但实时性已经没了。
解决:发送路径也要独立线程。把 sendFrame 设计为把帧放入发送队列,专门的写线程从队列取帧并执行 nativeSendFrame。同时给队列设置容量上限,满时按优先级丢最不重要的帧并打个日志。这是在控制类场景下必须的取舍,不能因为怕丢数据就让发送线程无限阻塞。
private final BlockingQueue<CanFrame> sendQueue = new LinkedBlockingQueue<>(128); public void sendFrame(CanFrame frame) { if (!sendQueue.offer(frame)) { // 队列满,说明总线忙或者USB带宽不够 Log.e("CanManager", "send queue full, drop frame " + Integer.toHexString(frame.id)); } } private void startSenderThread() { Thread sender = new Thread(() -> { while (!Thread.currentThread().isInterrupted()) { try { CanFrame frame = sendQueue.take(); nativeSendFrame(channelHandle, frame.id, frame.data); } catch (InterruptedException e) { break; } } }); sender.start(); }5.5 关闭应用后 USB 设备被锁死:下次启动打不开
现象:应用退出后再进,发现 USB 设备无法打开,必须重启平板或者重新插拔线缆。
原因:JNI 层没有正确释放 UsbDeviceConnection。Android 的 UsbManager 在应用进程退出时会自动释放连接,但进程还在、只是 Activity 销毁的情况下,你没有调用 close 的话,内核里的 claim 状态不会自动释放。尤其是用了 detachKernelDriver 之后,驱动状态没恢复,设备就僵在那里。
解决:在 Service 的 onDestroy 或 Activity 的 onDestroy 里做对称的释放逻辑:
@Override protected void onDestroy() { super.onDestroy(); if (canManager != null) { canManager.stop(); } if (connection != null) { connection.close(); connection = null; } }JNI 侧也要对称地做资源回收,凡是 nativeOpenChannel 返回的句柄,都要在 nativeCloseChannel 里对应释放。另外注意不要在 onPause 里关,否则切后台再切回来又要重新初始化,耗时又不稳定。
6. 用数据可视化验证链路:比 Logcat 更靠谱的调试手法
CAN 通信这类底层链路,最大的问题不是逻辑复杂度,而是「你以为收到了正确的数据,实际收到的是巧合的错误数据」。Logcat 打日志的方式在低频调试时勉强够用,但一旦帧率上来,日志输出的时间戳会因为 Log 本身的写盘延迟而失真,你根本没法判断时序对不对。我在调试模拟项目X的时候,先做了一个简单的实时波形绘制界面,把关键信号用 Canvas 画出来,用线条的周期性和幅值来直接判断数据有没有抖动,这个方法比盯着十六进制字节数组高效得多。
具体做法是:在 CanDataListener 的 onFrameBatchReceived 回调里,把帧数据缓存成一个环形数据源,然后用自定义 View 每 50ms 重绘一次。关键帧 ID 比如发动机转速、车速、电池电压这些物理量,通过一个简单的缩放公式映射到 y 轴像素坐标。绘制时不需要每次全量刷新,只需要更新最新点并滚动画布左侧的内容。工程里有一段核心绘制代码:
public class CanWaveformView extends View { private final float[] dataBuffer = new float[1024]; private int writeIndex = 0; public void addSample(float value) { dataBuffer[writeIndex] = value; writeIndex = (writeIndex + 1) % dataBuffer.length; invalidate(); } @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); Paint paint = new Paint(); paint.setColor(0xFF00AA00); paint.setStrokeWidth(2f); float midY = getHeight() / 2f; float lastX = 0, lastY = midY; int start = writeIndex; for (int i = 1; i < dataBuffer.length; i++) { int idx = (start + i) % dataBuffer.length; float x = i * getWidth() / (float) dataBuffer.length; float y = midY - dataBuffer[idx] * 0.05f; canvas.drawLine(lastX, lastY, x, y, paint); lastX = x; lastY = y; } } }这段代码里最关键的是环形缓冲区的用法:writeIndex 指向最新写入的位置,绘制时从最旧的数据开始逐点连线,这样波形始终是连续滑动的,不需要每次重新分配数组。幅值映射里的 0.05f 是缩放系数,你可以根据实际信号的量纲调整——电压信号可能是 0~300V,转速信号可能是 0~8000rpm,两者需要的缩放差着数量级。
验证链路是否真正可靠,有一个我不能不说的习惯:不要只看 Android 端的数据,务必在总线上挂一个独立的监听节点做交叉比对。没有第二路参考信号的情况下,你看到的波形再完美,也可能只是 JNI 层解析器里的自洽错误。我在项目里会用一个 USB 转 CAN 适配器同时连 PC 端的 CAN 上位机软件做旁路监听,两边同时记录同一长度的时间窗口,比对帧 ID 序列和数据段一致性。如果 PC 端和 Android 端收到的帧 ID 顺序一模一样、时间戳偏差不超过 2ms,那链路基本就是可靠的。
从那以后,我在每次 CAN 通信程序改完协议帧格式之后,都会强制走一遍「PC 上位机旁路对比 + 波形视图观察频率特性」这两步验证。总线协议这东西,看起来是软件逻辑,实际上线缆接触不良、模块供电不足、终结电阻缺失这类硬件玄学问题,都会以「帧时对时错」的诡异面貌出现在应用层。如果你的代码逻辑反复检查都没问题,就去量量 CAN_H 和 CAN_L 之间的差分电压,正常静默状态应该是 0V 左右,发送时约 2V——这一步排查能省下下班之前最后一小时的焦虑。希望帮到你。
本文还有配套的精品资源,点击获取