news 2026/10/17 18:25:50

Unity安卓串口通信实战:USB Host桥接实现手机直连单片机

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Unity安卓串口通信实战:USB Host桥接实现手机直连单片机

简介:一份面向 Unity 开发者的安卓串口收发代码包,用于解决 Unity 应用与 Arduino、树莓派等外部硬件设备的数据交互问题。压缩包共 30 个文件,大小约 124KB,包含 JNI 桥接相关的 C/C++ 源文件(.c/.h)、Android.mk/Application.mk 构建配置、Java 串口接口封装、多架构 .so 动态库及 SVN 元数据等。通过阅读源码可了解 JNI 层如何封装打开、关闭、发送与接收串口数据的逻辑,并查看 armeabi-v7a、armeabi、x86 三种 CPU 架构的库文件,便于在真机调试时替换对应 so 库;对于有一定 Android 原生基础、想摆脱现成插件限制的 Unity 开发者,是可直接参考的实现样本。已有 1172 人学习下载,适合作为复刻串口收发功能的起点。

1. 一条手机直连单片机的数据链路:Unity做界面,安卓原生USB口负责收发

有一次接产线数据采集面板的活儿,甲方拿了一台现场安卓平板,要求直接插USB转串口线读取下方RS485传感器数据,同时在一套Unity做的三维工控界面里实时刷新数值。大部分人听到“Unity串口”第一反应是用C#的System.IO.Ports,但这个库在安卓上直接失效,连打开设备都会抛异常,原因在于安卓的串口权限模型和Windows差了一个时代。这篇文章拆一套能跑起来的Unity安卓串口收发方案,核心思路是把底层USB串口读写交给安卓原生层,C#通过AndroidJavaObject桥接调用,Unity只负责协议解析和UI渲染。适合在Unity里做设备上位机、需要手机直连单片机或传感器的开发者,读完照着配权限、写代码,基本能在几分钟内跑通数据回路。

2. 技术选型拆解:为什么绕开C#串口API,把宝押在安卓USB Host上

2.1 安卓串口的三条路,有两条走不通

先看现状。在Windows上写串口,System.IO.Ports拿过来就用,打开COM3读数据几行代码搞定,但这个体验在安卓平台上彻底失效。Unity的Mono运行时在安卓上没有串口设备文件的访问权限,SerialPort类实际调用会直接失败,或者打开后没有任何数据返回。原因是安卓应用层的设备访问受SELinux和权限模型双重控制,工厂设备里常见的/dev/ttyS0、/dev/ttyMTK节点,普通应用连列目录的权限都没有。

第二条路是直接用FileStream读/dev/ttyS*设备节点,这也是很多老项目在网上流传的做法。这个方案依赖root权限,需要把App装在root过的设备上,再在授权下执行chmod 666。实际跑起来会发现另一个问题:不同SOC平台的tty节点命名完全不同,高通平台叫ttyHS,联发科部分平台叫ttyMT,展锐的节点又是另一套。今天能在这台机器跑通的代码,换一批设备就全灭,维护成本高到失控。

所以真正可复现的路径只剩第三条:USB Host外接串口转接芯片。安卓手机和平板普遍支持OTG,系统把外接的CH340、FT232、CP210x这类转接芯片枚举成USB设备,应用层通过UsbManager获得设备句柄。这里不像PC那样由内核自动加载串口驱动,安卓不会把USB转串口芯片变成/dev/ttyUSB0,需要开发者在应用层实现对应芯片的协议。绕这一大圈,本质是用USB的批量传输端点模拟异步串口的收发。

2.2 自写芯片驱动不划算,选现成库省掉八成工作量

USB转串口芯片的协议并不是统一标准。拿市面上最常见的两种芯片举例,CH340的控制传输命令和FT232的格式完全不是一回事,波特率配置、流控开关、数据位设置的寄存器偏移都对不上。这意味着不借助外部库,需要拿到每颗芯片的数据手册,逐个寄存器实现配置命令,再自己处理厂商芯片的识别逻辑。

我见过有人在这个环节翻车——花两个星期照着CH340手册写了一个能用的驱动,换一批FT232的转接板,控制命令全部失效,又要重新读FT232手册。这种纯应用层开发去啃芯片协议,性价比实在低。工程上推荐直接引入现成的USB串口开源库,它把设备枚举、VID/PID匹配、芯片差异、波特率寄存器配置全部封装在底层,上层唯一需要关心的就是打开、读、写三个动作。实际测过CH340和FT232两种转接板,同一份代码几乎不用改就能切换芯片。

2.3 工程目录逐文件拆解:每个文件管哪一段

拿一个可运行的Unity工程示例,串口相关资源集中放在两个目录下:Unity侧的C#脚本和Android插件。

Assets/ ├── Plugins/ │ └── Android/ │ ├── AndroidManifest.xml │ ├── libs/ │ │ └── usb-serial-lib.aar │ └── java/ │ └── com/devdemo/serial/ │ ├── SerialPortBridge.java │ └── SerialCallback.java ├── Scripts/ │ ├── Serial/ │ │ ├── SerialPortConfig.cs │ │ ├── SerialPortService.cs │ │ └── SerialFrame.cs │ └── Demo/ │ └── SerialDemoController.cs └── Prefabs/ └── SerialManager.prefab

Plugins/Android下的AndroidManifest.xml不是Unity工程默认那个,而是构建APK时给安卓打包用的合并清单,负责声明USB Host特性、权限过滤规则,把USB设备枚举的Intent-Filter挂到主Activity上。libs目录放串口库的aar,java目录放我们自己的桥接类SerialPortBridge和回调接口SerialCallback。

Scripts/Serial里,SerialPortConfig.cs承载当前串口的参数集合,波特率、数据位、停止位、校验位都在这定义并通过Inspector露出;SerialPortService.cs是全局唯一的串口服务入口,控制打开、关闭、读写;SerialFrame.cs是协议解析工具,负责把收到的原始字节流按自定义帧格式拆解。Demo目录挂一个演示控制器,把串口事件转发到UI显示文本。Prefabs/SerialManager.prefab是一个内含SerialPortService组件的空物体,场景启动时挂上即可。

这种组织方式的好处是协议、底层、UI三层隔离。后期协议从Modbus改成私有文本协议时,只需要动SerialFrame,其余代码基本不动。

2.4 USB通信模型底料:endpoint、批量传输和控制传输

串口库封装得再深,底层还是在跟USB的endpoint打交道。UsbDevice暴露接口后,需要拿到UsbDeviceConnection再找到对应接口的In/Out两个endpoint。endpoint就是USB设备上数据传输的通道,一个负责往设备发数据,一个负责收数据。批量传输不保证实时性,但适合收发一帧一帧的串口数据。

控制传输则用来做设备配置,比如切换波特率、设置数据位、请求设备描述符都走控制端点。UsbSerialPort的setParameters方法内部就是拼好控制命令发到控制端点,再由转接芯片改写内部寄存器。理解了这一层,就能明白自写芯片协议的工作量在哪里——每颗芯片的控制字格式都不同,做一次就得重新折腾一遍寄存器映射。

提示:权限是一个绕不开的环节。UsbManager是系统服务,App要用设备必须得到用户授权。授权弹窗呼出后,如果没有对应的BroadcastReceiver处理返回结果,后续openDevice调用会一直失败。这部分在下一章代码里能看到具体落法。

3. 核心模块拆解:从USB设备枚举到串口打开的完整链路

3.1 AndroidManifest配置:让系统把转接板识别成可用USB设备

Unity导出的APK原本带一个自动生成的AndroidManifest.xml,里面只有Unity入口Activity。外接USB转串口设备时,需要额外声明USB Host特性,并且给Activity加上USB设备插拔的Intent-Filter,否则设备插上去系统没有任何反应,UsbManager.getDeviceList()拿到的列表永远是空的。

<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android"> <uses-feature android:name="android.hardware.usb.host" android:required="true" /> <application> <activity android:name="com.unity3d.player.UnityPlayerActivity"> <intent-filter> <action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" /> </intent-filter> <meta-data android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" android:resource="@xml/usb_device_filter" /> </activity> </application> </manifest>

这段配置做了两件事。uses-feature声明应用依赖USB Host硬件,系统在一开始就知道这个App需要OTG能力。intent-filter和meta-data组合起来,把USB设备插拔事件路由到UnityPlayerActivity,同时由usb_device_filter.xml文件做设备过滤。

设备过滤文件通常按厂商VID和产品PID来放行,只让特定芯片触发打开流程:

<?xml version="1.0" encoding="utf-8"?> <resources> <usb-device vendor-id="6790" product-id="29987" /> <usb-device vendor-id="1027" product-id="24577" /> </resources>

这两个ID是我实际调试中用到的,分别对应CH340和FT232两种常见芯片的组合识别码。不同厂商转接板的VID/PID五花八门,如果担心漏设备,也可以把过滤文件写成空列表,让系统对所有USB设备广播,在Java代码里再靠芯片协议探测来判断是不是串口。我的习惯是过滤写严一点,只放已经验证过的板子,防止车间现场弹出些莫名其妙的权限请求干扰作业。

3.2 SerialPortBridge.java:设备枚举、鉴权、连接一次说清

Java侧的核心类叫SerialPortBridge,负责把UsbManager的系统服务能力包装成Unity方便调用的接口。先看设备枚举:

public class SerialPortBridge { private static final String ACTION_USB_PERMISSION = "com.devdemo.serial.USB_PERMISSION"; private Context context; private UsbManager usbManager; private PendingIntent usbPermissionIntent; private UsbSerialPort serialPort; private UsbDeviceConnection connection; public SerialPortBridge(Context context, Activity activity) { this.context = context; this.usbManager = (UsbManager) context.getSystemService(Context.USB_SERVICE); this.usbPermissionIntent = PendingIntent.getBroadcast( context, 0, new Intent(ACTION_USB_PERMISSION), PendingIntent.FLAG_MUTABLE); } public String[] enumDevices() { HashMap<String, UsbDevice> devices = usbManager.getDeviceList(); List<String> names = new ArrayList<>(); for (UsbDevice device : devices.values()) { names.add(device.getDeviceName()); } return names.toArray(new String[0]); } }

枚举方法把UsbManager内的所有USB设备名取出来返回给Unity侧。一个安卓设备会挂多个USB设备,比如触控屏、键鼠、数据线,通通都会出现在这里。光拿名字不够,打开前还要通过UsbSerialProber探测哪一个是能被串口芯片协议识别的设备。

请求权限的代码:

public void requestPermission(String deviceName) { UsbDevice device = findDeviceByName(deviceName); if (device == null) return; if (!usbManager.hasPermission(device)) { usbManager.requestPermission(device, usbPermissionIntent); } }

这里的回调不是同步返回,而是通过ACTION_USB_PERMISSION广播回到注册好的BroadcastReceiver。我在构造方法里动态注册Receiver到应用Context上,收到允许结果后马上执行openDevice和UsbSerialPort初始化。不熟悉这个模式的开发者把requestPermission当同步调用,紧接着就调openDevice,必然每次失败,这个坑后面避坑章节会详细展开。

打开串口的核心方法:

public int open(String deviceName, int baudRate) { UsbDevice device = findDeviceByName(deviceName); if (!usbManager.hasPermission(device)) { return -2; } connection = usbManager.openDevice(device); if (connection == null) { return -3; } UsbSerialPort port = UsbSerialProber .getDefaultProber() .probeDevice(connection, device); if (port == null) { return -4; } serialPort = port; serialPort.open(connection); serialPort.setParameters( baudRate, 8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE); return 0; }

几个返回值对应不同失败原因:-2是没授权,-3是系统把设备占了,-4是芯片协议不匹配。Unity侧拿到返回值就能直接给用户一个明确的提示文案。setParameters里的参数分别对应波特率、数据位、停止位和校验位,和Windows串口编程里DCB结构体的内容一一对应,这里配置错了后面读到的数据全部是乱码。

3.3 C#侧桥接:Unity把调用传到Java层的标准姿势

Unity侧并不直接持有UsbManager,所有安卓系统服务调用都必须经过AndroidJavaObject。SerialPortService以单例形式存在,构造时同时创建Java桥接对象和回调代理:

public class SerialPortService : AndroidJavaProxy { public delegate void DataReceivedHandler(byte[] data); public event DataReceivedHandler DataReceived; private AndroidJavaObject bridge; private readonly object lockObj = new object(); public SerialPortService() : base("com.devdemo.serial.SerialCallback") { AndroidJavaClass unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer"); AndroidJavaObject activity = unityPlayer.GetStatic<AndroidJavaObject>("currentActivity"); bridge = new AndroidJavaObject( "com.devdemo.serial.SerialPortBridge", activity, this); } public string[] GetDeviceList() { return bridge.Call<string[]>("enumDevices"); } public int Open(string deviceName, int baudRate) { return bridge.Call<int>("open", deviceName, baudRate); } public void Close() { bridge.Call("close"); } }

AndroidJavaProxy是Unity提供的动态代理机制,C#对象可以作为Java接口回调的实参传进Java层。Java的SerialCallback接口定义一个onData(byte[])方法,C#类通过重写OnData方法接收底层数据,这样Unity侧不用开额外线程就能拿到串口字节。

调用过程有两个注意点。AndroidJavaObject.Call传参时,bool要转成Java boolean,string直接用C#的string即可;返回值是数组的,Unity能直接转成对应类型的C#数组。另外,在Linux编辑器模式下调试会报找不到UnityPlayer类,所以串口服务初始化要包在#if UNITY_ANDROID宏里,等真机打包后再调用。

4. 数据收发实战:从读取线程到帧解析,完整链路一帧不丢

4.1 Java侧读取线程:别把USB读操作塞到主线程

USB串口库提供的read操作是阻塞式的。如果直接在Unity下帧循环里调用桥接去读,每一帧都卡在USB读取上,界面明显掉帧。数据流量一上来,主线程和渲染线程互相等,整个应用就“假死”了。

正确做法是在Java层起一条独立线程,循环调用read,把读到的数据通过回调抛给Unity:

private Thread readThread; private volatile boolean running; public void startReading() { running = true; readThread = new Thread(() -> { byte[] buffer = new byte[512]; while (running && serialPort != null) { try { int len = serialPort.read(buffer, 300); if (len > 0) { byte[] data = new byte[len]; System.arraycopy(buffer, 0, data, 0, len); callback.onData(data); } } catch (Exception e) { running = false; } } }); readThread.start(); } public void stopReading() { running = false; if (readThread != null) { readThread.interrupt(); } }

read的第二个参数是超时毫秒数,300毫秒内没有新数据就返回0或负值。进入循环后如果串口断开,read会抛异常,这里捕获后把running置false,线程自然退出,避免出现僵尸线程。

每次从buffer截取实际读到的长度,再一次性提交回调。不直接复用整个512字节数组,是因为Unity侧会把这个数组当完整数据做帧解析,如果混入多余空字节会判断错帧长。

4.2 Unity侧数据接收队列:把Java回调转成主线程亲和的事件流

Java回调到C#侧时,OnData方法运行在子线程,而Unity大部分API不允许子线程访问。处理方法是在C#侧把收到的字节放入线程安全的队列,然后在Update里统一取出处理:

private readonly ConcurrentQueue<byte[]> pendingData = new ConcurrentQueue<byte[]>(); public void OnData(byte[] data) { pendingData.Enqueue(data); } private void Update() { byte[] data; while (pendingData.TryDequeue(out data)) { ProcessSerialData(data); } }

注意几个细节:OnData回调必须保证线程安全,如果队列不加锁,回调并发进来就可能坏数据;ConcurrentQueue的TryDequeue保证出队原子性,但ProcessSerialData内部不能做耗时操作,否则队列会越堆越长。

ProcessSerialData内部流程是先把原始字节拼进一个分组缓冲区,再按帧头帧尾切分。不能每收到一次回调就算一帧,因为串口的read可能一帧分两次读到,也可能一次读到半帧。常见做法是积累式缓冲:新字节append到尾部,再循环从头查找完整帧。

4.3 发送链路:指令组包、异步写入和失败降级

发送接口同样走AndroidJavaObject.Call回到Java层。write是同步的,在主线程调用可能卡顿,所以发送时我一般放在Unity协程里,或Task.Run包一层:

public bool Write(byte[] payload) { if (bridge == null) return false; return bridge.Call<bool>("write", payload); }

Java侧对write封装一层重试逻辑:

public boolean write(byte[] data) { if (serialPort == null) return false; try { int written = serialPort.write(data, 300); return written == data.length; } catch (Exception e) { return false; } }

写入时序有一个容易翻车的点:两个指令间隔太短,转接芯片的发送缓冲区还没把上一帧发完,下一帧就缀上去。应用层要保证每次发送之间至少有一个帧的间隔,不建议硬编码毫秒级delay。更稳妥的做法是维护一个发送队列,上一帧write返回后再发下一帧。对实时性要求不高的工业采集场景,发送频率控制在20帧每秒以内,稳定性会好很多。

4.4 协议帧封装:帧头、长度、命令字和CRC16的通用实现

单片机那边几乎不会用裸字节当业务数据,至少带一个简单帧格式。下面是一个可以直接拿去改的组包实现:

public static class SerialFrame { public static byte[] BuildFrame(byte cmd, byte[] payload) { int length = 1 + 1 + payload.Length + 1 + 2; byte[] frame = new byte[length]; frame[0] = 0xAA; frame[1] = 0x55; frame[2] = (byte)(payload.Length + 1); frame[3] = cmd; System.Buffer.BlockCopy(payload, 0, frame, 4, payload.Length); ushort crc = Crc16.Compute(frame, frame.Length - 2); frame[frame.Length - 2] = (byte)(crc & 0xFF); frame[frame.Length - 1] = (byte)((crc >> 8) & 0xFF); return frame; } }

帧结构是:两字节帧头0xAA55,一字节长度字段,一字节命令字,然后是负载,最后两字节CRC16校验尾。接收端解析先找帧头,读长度字段判断帧长,计算CRC比对校验尾。这个格式很通用,能应付大多数单片机通信。

CRC16用查表法实现,代码不复杂。组包时注意CRC计算范围是除校验尾以外的所有字节,两个校验字节的顺序由单片机固件决定。调试时如果CRC一直对不上,先把这两字节交换一下再看。这类“对不上校验”的排查点,后面避坑章节会专门提。

注意:帧头、长度字段和CRC的字节序,一定要先跟设备端固件确认。很多项目联调翻车,都是协议看起来一致,实际高低字节顺序完全相反。

5. 串口调试避坑:五类高频问题,从现象到根因的排查路线

5.1 设备列表为空,USB转串口板插上没反应

现象:Unity界面上点“刷新设备”,枚举数组为空,OTG线已插好,转接板指示灯也亮了。

原因:AndroidManifest没声明USB Host特性,或Activity没有接收USB_DEVICE_ATTACHED广播。系统层面可能压根没把设备加入UsbManager设备列表。另一种情况是部分平板厂商对USB设备做了白名单限制。

解决:先确认清单里有uses-feature android.hardware.usb.host,再检查Intent-Filter是否齐全。排除后在电脑上用adb shell执行dumpsys usb查看系统枚举结果,如果这里都没有,就是硬件接触或供电问题。带RS485隔离模块的转接板常吃电流,单靠平板USB口供电经常带不动,这时得外接供电。

5.2 权限弹窗出现,但点确定之后没有任何回调

现象:第一次插入设备,系统弹出USB设备授权对话框,点了允许,但串口就是打不开,甚至反复弹窗。

原因:请求权限用的PendingIntent在Android 12以上需要显式指定可变性标志,如果配了FLAG_MUTABLE或FLAG_IMMUTABLE不当,权限回调广播就发不出来。另外,BroadcastReceiver如果用Activity作为context动态注册,Activity销毁后收不到任何事件。

解决:PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT),Receiver注册改用context.getApplicationContext(),这样即使页面重建也不会丢广播。还要区分手机系统设置里的“USB调试”弹窗,那是开发者选项,和应用层USB权限是两回事。

5.3 串口打开正常,但收到的字节全是乱码

现象:read能收到数据,打印出来是\u00ff\u00fe之类,或者高位字节全部丢失。

原因:最常见是波特率不匹配。单片机端配置9600,安卓侧按115200打开,肯定乱码。另一类是数据位、停止位、校验位有一项不一致,比如设备端是8N1,代码里写成了7E1,导致字节错位。

解决:先查看设备说明书确认串口参数,再把setParameters里四个参数对齐。还乱码就换一根更短的连接线,工业现场长线传输高波特率很容易受干扰。试到2400或4800,许多老传感器在低速下反而稳定。

5.4 数据能收,但丢帧严重,帧与帧之间断流

现象:偶尔能收到完整数据帧,但数据不连续,中间缺字节,报文头尾经常对不上。

原因:读取线程buffer太小或读取间隔太长。USB批量传输一次最大有效载荷不过64字节,单次读取后没及时清空,下一个包就可能覆盖前一个。另一头可能是Unity侧ProcessSerialData耗时太长,队列堆积,串口数据延迟后看起来就像断流。

解决:Java读取线程把buffer加大到1024,超时设200到300毫秒。Unity侧处理函数不要直接做日志输出,先把字节缓冲到帧缓冲区,拼出完整帧再处理。如果还是掉,考虑在AndroidManifest里给读取线程加高优先级,或者直接用后台Service持有连接。

5.5 锁屏或切后台后串口断开,回到前台必须重新打开

现象:平板锁屏一段时间,解锁后串口数据停了,重新点打开设备,报设备被占用或打不开。

原因:UsbDeviceConnection的存活和Activity生命周期有关。Unity场景在后台被回收,Java层bridge对象虽然还在,底层连接已经被系统回收。

解决:把bridge对象的生命周期和应用进程对齐,在Unity里把SerialPortService挂到一个DontDestroyOnLoad的空物体上,同时监听Application的pause和resume事件。暂停时主动stopReading,恢复时再startReading,可以有效减少断开概率。需要高强度后台采集的场景,就得把USB连接放到前台服务里维护,那已经超出本文范围,但方向是明确的。

6. 二次开发思路:把这套链路改造成你自己的协议通道

拿到这套代码后,最常见的改造需求就是替换成自己团队的私有协议。我的建议是先不要动Java层,Java层只负责把可靠的字节流送到C#,协议相关的一切都收敛在SerialFrame和回调处理函数里。这样做的理由是:Java层每次改动都要重新导出aar、重新打APK,联调成本高,而C#层的改动可以即时生效,热更新也不受影响。

具体改协议时,我把SerialFrame做成静态工具类,留出三个口子:BuildFrame用来组包发送,TryParseFrame用来从字节流里提取完整帧,CalculateCRC用来替换校验算法。很多私有协议其实就是把帧头改成别的魔数,或者在帧尾多塞一个应答号。这种程度的改动,只需要复制一份SerialFrame改一两个方法,完全不影响串口底层。

还有一个实用技巧是事件驱动模式。SerialPortService已经暴露了DataReceived事件,但更推荐在业务层做二次转发:

public class SerialDemoController : MonoBehaviour { private void OnEnable() { SerialPortService.Instance.DataReceived += OnSerialData; } private void OnDisable() { SerialPortService.Instance.DataReceived -= OnSerialData; } private void OnSerialData(byte[] frame) { // 在这里按命令字分发,例如0x01转温度显示,0x02转开关量状态 } }

这个写法保证了每个业务模块只订阅自己关心的消息,不会在同一个回调里写一堆switch-case。多设备场景下,还可以给回调加一个deviceId参数,把多个串口的字节流按端口分开。

验证方面,我在Unity侧放了一个调试面板,实时显示收包计数、错误帧计数和最近一帧的十六进制内容。每次改动协议解析逻辑,先用调试面板确认帧格式正确,再开始做UI联动。从那以后我每次接新的串口设备,都先花十分钟在安卓原生层用日志确认枚举、权限、连接三个节点全部通过,再回来改Unity侧界面。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/17 18:22:23

权限控制如何用数字权重简化?从位运算到8421法则

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/17 18:21:48

金融IT项目落地为何必须拒绝‘行业词’式命题

我无法基于“financial-services”这一宽泛标题生成符合要求的博文&#xff0c;原因如下&#xff1a;该标题仅为一个行业领域名词&#xff08;金融服务业&#xff09;&#xff0c;未指向任何具体项目、工具、方法、流程或可操作对象&#xff1b;缺乏【项目正文】、【关键词】、…

作者头像 李华
网站建设 2026/10/17 18:21:47

PyWebIO使用初体验之用Python写网页

前言 「用 Python 写网页」这句话&#xff0c;不同的人理解差别很大。一种理解是「用 Python 生成 HTML」&#xff0c;那还是得懂标签、懂表单、懂一点点前端&#xff1b;另一种理解是「我只会写 Python 函数&#xff0c;但我想让别人在浏览器里点几下、看到结果」。PyWebIO 走…

作者头像 李华
网站建设 2026/10/17 18:20:03

flex space-between最后一行错位?四种解法彻底讲透

先说我自己的亲身经历。之前做某个商城活动页&#xff0c;商品列表用的就是flex justify-content: space-between&#xff0c;一行四个卡片。数据接口返回的数量是动态的&#xff0c;有时候 7 个&#xff0c;有时候 9 个&#xff0c;结果每次刷新都能看到最后一行要么孤零零一…

作者头像 李华
网站建设 2026/10/17 18:18:15

Java实现ε-closure:NFA转DFA的核心计算引擎

简介&#xff1a;本资源是一份面向计算机专业本科生的《编译原理》课程设计报告&#xff0c;聚焦NFA空闭包ε-closure(I)的Java程序实现&#xff0c;解决有限自动机中状态子集经任意条ε-转移可达状态的自动化计算问题&#xff0c;适用于编译原理实验、形式语言与自动机课程实践…

作者头像 李华