Unity安卓串口通信开发实战:解决黑屏无响应与多串口线程安全 1. 项目概述为什么Unity安卓串口开发是个“技术活”如果你正在用Unity开发一个需要连接硬件设备的安卓应用比如控制一个智能小车、读取传感器数据或者和一台老旧的工业PLC打交道那么“串口通信”这个词对你来说绝对不陌生。我最近就刚完成了一个智能农业监控终端的项目核心就是用Unity开发的安卓App通过USB转串口线连接土壤温湿度、光照强度等一系列传感器。整个过程踩了不少坑也积累了不少实战经验。今天这篇教程就是把我从零开始到最终稳定通信的完整过程包括那些官方文档里不会写的“坑”和“技巧”毫无保留地分享出来。这不仅仅是一个API调用教程更是一个针对Unity在安卓平台进行串口开发的系统性解决方案。很多人觉得串口通信不就是打开端口、读写数据吗在Windows或桌面平台或许如此但一旦涉及到Unity和安卓这两个环境的结合事情就复杂多了。安卓系统本身对硬件访问有严格的权限和线程管理机制而Unity作为一个游戏引擎其主线程UI线程的运作方式与传统的安卓应用开发截然不同。直接在主线程里进行耗时的串口读写操作分分钟让你的应用卡死、无响应也就是大家常遇到的“unity程序打开黑屏无响应”的罪魁祸首之一。更别提多串口同时通信的需求了线程管理不当会导致数据包混乱、丢失调试起来让人头大。所以这篇教程的目标读者很明确有一定Unity和C#基础需要在安卓设备上实现稳定、可靠串口通信的开发者。无论你是做物联网、工业控制、数据采集还是智能硬件交互下面的内容都将为你提供一个经过实战检验的、可直接复用的框架。我们会从原理讲起贯穿工具选择、代码实现、线程安全、数据解析到真机调试的全流程并附上完整的项目源码。让我们开始吧。2. 核心原理与安卓环境特殊性解析在撸起袖子写代码之前我们必须先搞清楚Unity在安卓平台上做串口通信到底特殊在哪里。理解这些底层原理是避开后续无数大坑的关键。2.1 串口通信基础与Unity的局限串口Serial Port是一种非常古老但至今仍在工业、嵌入式领域广泛使用的通信接口。我们常说的UART串口通信、USB转串口依赖CH340串口驱动或FTDI串口驱动本质上都是基于字节流的异步通信。在C#中我们通常使用System.IO.Ports.SerialPort类来操作串口这在Windows和部分桌面平台上是直接可用的。然而Unity的运行时环境尤其是跨平台时并未原生集成完整的SerialPort实现。更关键的是在安卓系统上应用无法像在Windows上那样直接访问“COM3”这样的端口。安卓系统将USB设备或蓝牙串口模拟设备抽象为文件路径例如/dev/ttyUSB0或/dev/ttyS0。访问这些设备文件需要root权限这对于普通应用是不可行的。因此我们必须通过安卓的Java层接口使用特定的API来与串口设备交互然后再通过Unity的AndroidJavaClass/AndroidJavaObject机制即Android Java Native Interface, JNI将功能桥接到C#脚本中。2.2 安卓主线程阻塞黑屏无响应的元凶这是Unity安卓开发中最经典的陷阱没有之一。Unity引擎的主循环包括所有Update()函数、UI渲染、物理计算都运行在同一个主线程上这个线程同时也是安卓的UI线程。串口通信的Read()和Write()方法在等待数据或写入大量数据时都是阻塞式的调用。如果直接在Update()里调用serialPort.ReadExisting()而此刻串口缓冲区没有数据线程就会被挂起等待。后果就是主线程卡住画面停止渲染应用表现为“黑屏”、“卡死”、“无响应”最终被安卓系统弹出“应用程序未响应ANR”的对话框。注意这一点在Unity官方社区也有明确警示参考网络内容Unity安卓环境下不能在主线程执行串口读写操作。任何试图在主线程进行同步串口读写的操作都是自杀行为。2.3 多串口与线程安全的数据流管理当你的项目需要连接多个传感器比如一个串口接温度传感器另一个接GPS模块即实现多串口通信时复杂性指数级上升。你不能简单地为每个串口创建一个后台线程就了事还需要考虑线程管理如何优雅地创建、启动和停止这些线程数据同步后台线程读取到的字节流数据如何安全地传递到Unity主线程进行处理和显示资源竞争避免多个线程同时操作UI或共享数据结构导致崩溃。性能开销线程不是免费的不当的线程数量会消耗大量系统资源。解决这些问题的核心是建立一个生产者-消费者模型。后台串口线程作为“生产者”不断读取原始字节数据一个线程安全的队列如ConcurrentQueue作为缓冲区Unity主线程在Update()中作为“消费者”从队列中取出并解析数据。这样耗时操作在后台主线程仅进行轻量的队列检查和数据消费保证了流畅性。3. 工具链准备与环境搭建工欲善其事必先利其器。在开始编码前确保你的开发环境已经就绪。3.1 开发环境与必要工具Unity版本建议使用较新的LTS版本如2021.3 LTS或2022.3 LTS。我使用的是2022.3.20f1稳定性较好。确保在安装时勾选了Android Build Support模块。Android SDK NDK JDK通过Unity Hub安装Android模块时通常会一并安装。也可以手动指定路径。JDK版本需与Unity要求匹配通常为OpenJDK 11或17。目标设备一部真实的安卓手机或平板。虽然安卓模拟器电脑版如蓝叠、雷电在某些情况下可用但串口直通模拟器非常麻烦且不稳定强烈建议使用真机调试。串口硬件USB转串口模块推荐使用CP2102或CH340芯片驱动普及率高以及你的传感器/下位机设备。串口驱动在电脑上安装对应的USB转串口驱动如CH340串口驱动用于在电脑上使用串口调试助手如XCOM、SSCOM预先测试你的硬件和下位机程序确保硬件链路和通信协议本身是通的。这是硬件调试的第一步至关重要。安卓端串口调试App在真机上安装一个如“Serial USB Terminal”的App用于测试安卓设备是否能正确识别你的USB转串口模块并完成基础通信。这能排除硬件和安卓系统层面的问题。3.2 Unity项目设置与安卓配置创建新项目使用3D或2D模板均可。切换构建平台File - Build Settings - 选择 Android - Switch Platform。Player Settings关键配置Other Settings区域Package Name填写你的应用包名格式如com.YourCompany.YourProduct。这将是应用的唯一标识。Minimum API Level根据你的目标设备选择建议至少设为Android 8.0 (API Level 26)。Target API Level设为与Minimum相同或更高。Publishing Settings区域Keystore如果你要发布应用需要创建或使用一个已有的Keystore。调试阶段可以使用Unity自动生成的debug.keystore。3.3 核心插件选择为什么不推荐纯C#实现网上有一些纯C#调用Linux系统调用来操作串口的尝试但它们在安卓上兼容性极差且无法处理复杂的USB权限申请。因此使用成熟的第三方插件或自己封装JNI接口是唯一可靠的选择。经过对比测试我推荐两种方案对于快速原型和简单应用使用Asset Store中评价较高的插件如“Serial Controller”或“Android Serial Port”。它们封装好了底层细节提供简单的C# API但灵活性和深度定制能力有限。对于需要深度控制和生产级应用本教程采用使用github.com/geekcompany/AndroidSerialPort这个开源库。它是一个经典的、久经考验的安卓串口库Java。我们需要将其引入到Unity项目中并自己编写C#的JNI封装层。这种方式虽然步骤稍多但让你对整个过程有完全的控制权能处理任何复杂情况。操作步骤从GitHub下载AndroidSerialPort项目我们只需要其中的library模块。在Unity项目的Assets文件夹下创建Plugins/Android目录。将library中的src目录拷贝到Plugins/Android下并确保其包路径为android_serialport。将library中的AndroidManifest.xml文件内容合并到Unity自动生成的Manifest中位于Plugins/Android主要是添加USB权限声明uses-feature android:nameandroid.hardware.usb.host / uses-permission android:nameandroid.permission.WAKE_LOCK / !-- 对于Android 6.0可能还需要在运行时请求USB权限 --准备编写C#桥接脚本。4. 核心代码实现从JNI封装到线程安全通信这是本教程的核心部分。我们将一步步构建一个健壮的串口通信管理器。4.1 封装安卓串口库的C#接口首先创建一个AndroidSerialPortController.cs脚本负责与Java层通信。using UnityEngine; using System; using System.Collections; using System.Collections.Concurrent; using System.Threading; public class AndroidSerialPortController : MonoBehaviour { // 单例模式便于全局访问 private static AndroidSerialPortController _instance; public static AndroidSerialPortController Instance { get { if (_instance null) { GameObject go new GameObject(AndroidSerialPortManager); _instance go.AddComponentAndroidSerialPortController(); DontDestroyOnLoad(go); } return _instance; } } // 用于存储不同串口对象的字典 private ConcurrentDictionarystring, SerialPortWrapper _serialPorts new ConcurrentDictionarystring, SerialPortWrapper(); // 封装一个串口单元 private class SerialPortWrapper { public AndroidJavaObject SerialPort { get; set; } public Thread ReadThread { get; set; } public bool IsRunning { get; set; } public ConcurrentQueuebyte[] DataQueue { get; set; } new ConcurrentQueuebyte[](); public System.IO.Stream InputStream { get; set; } public System.IO.Stream OutputStream { get; set; } } void Awake() { if (_instance ! null _instance ! this) { Destroy(this.gameObject); return; } _instance this; } // 打开串口 public bool OpenSerialPort(string devicePath, int baudRate, int flags 0) { if (_serialPorts.ContainsKey(devicePath)) { Debug.LogWarning($串口 {devicePath} 已经打开。); return false; } try { // 通过JNI调用Java层的SerialPort类 AndroidJavaClass serialPortClass new AndroidJavaClass(android_serialport.SerialPort); AndroidJavaObject serialPort serialPortClass.CallStaticAndroidJavaObject( newSerialPort, new object[] { devicePath, baudRate, flags } ); if (serialPort ! null) { var wrapper new SerialPortWrapper { SerialPort serialPort, IsRunning true, InputStream serialPort.CallAndroidJavaObject(getInputStream).CallAndroidJavaObject(read).GetRawObject(), OutputStream serialPort.CallAndroidJavaObject(getOutputStream).CallAndroidJavaObject(write).GetRawObject() }; // 启动该串口的专属读取线程 wrapper.ReadThread new Thread(() ReadSerialData(wrapper, devicePath)); wrapper.ReadThread.IsBackground true; wrapper.ReadThread.Start(); _serialPorts[devicePath] wrapper; Debug.Log($成功打开串口: {devicePath} {baudRate}bps); return true; } } catch (Exception e) { Debug.LogError($打开串口 {devicePath} 失败: {e.Message}); } return false; } // 串口数据读取线程方法 private void ReadSerialData(SerialPortWrapper wrapper, string devicePath) { byte[] buffer new byte[1024]; // 读取缓冲区 while (wrapper.IsRunning wrapper.InputStream ! null) { try { // 阻塞式读取这里在后台线程所以是安全的 int bytesRead wrapper.InputStream.Read(buffer, 0, buffer.Length); if (bytesRead 0) { // 将读取到的数据拷贝到新的数组并放入队列 byte[] receivedData new byte[bytesRead]; Array.Copy(buffer, 0, receivedData, 0, bytesRead); wrapper.DataQueue.Enqueue(receivedData); } } catch (System.IO.IOException ioEx) { // 串口被关闭时可能抛出IO异常正常退出循环 if (!wrapper.IsRunning) break; Debug.LogError($串口 {devicePath} 读取IO异常: {ioEx.Message}); break; } catch (Exception e) { Debug.LogError($串口 {devicePath} 读取线程异常: {e.Message}); break; } } Debug.Log($串口 {devicePath} 读取线程退出。); } // 在主线程的Update中消费数据 void Update() { foreach (var kvp in _serialPorts) { string port kvp.Key; var wrapper kvp.Value; // 尝试从队列中取出所有累积的数据包 while (wrapper.DataQueue.TryDequeue(out byte[] dataPacket)) { // 这里是主线程可以安全地处理数据例如解析、触发事件、更新UI OnSerialDataReceived(port, dataPacket); } } } // 写入数据到串口 public void WriteData(string devicePath, byte[] data) { if (_serialPorts.TryGetValue(devicePath, out SerialPortWrapper wrapper) wrapper.OutputStream ! null) { try { wrapper.OutputStream.Write(data, 0, data.Length); wrapper.OutputStream.Flush(); } catch (Exception e) { Debug.LogError($向串口 {devicePath} 写入数据失败: {e.Message}); } } else { Debug.LogWarning($串口 {devicePath} 未打开或输出流无效无法写入。); } } // 关闭串口 public void CloseSerialPort(string devicePath) { if (_serialPorts.TryRemove(devicePath, out SerialPortWrapper wrapper)) { wrapper.IsRunning false; // 通知线程退出 try { wrapper.ReadThread?.Join(500); // 等待线程结束最多500ms wrapper.InputStream?.Close(); wrapper.OutputStream?.Close(); wrapper.SerialPort?.Call(close); } catch (Exception e) { Debug.LogError($关闭串口 {devicePath} 时发生错误: {e.Message}); } Debug.Log($已关闭串口: {devicePath}); } } void OnDestroy() { // 应用退出时关闭所有串口 var keys new System.Collections.Generic.Liststring(_serialPorts.Keys); foreach (var port in keys) { CloseSerialPort(port); } } // 数据接收事件示例实际使用时可以替换为委托/事件系统 private void OnSerialDataReceived(string portName, byte[] data) { // 示例将字节数组转为十六进制字符串打印 string hexString BitConverter.ToString(data).Replace(-, ); Debug.Log($来自 [{portName}] 的数据: {hexString}); // 这里可以添加你的协议解析逻辑例如 // if (data[0] 0xAA) { ... } } }4.2 设备路径发现与权限处理在安卓上USB串口设备通常挂载在/dev/ttyUSB*或/dev/ttyACM*下。但具体路径需要动态枚举。我们可以写一个辅助方法来获取所有可用的串口设备路径。// 在AndroidSerialPortController类中添加方法 public Liststring GetAvailableSerialPorts() { Liststring ports new Liststring(); // 常见的USB串口设备节点 string[] possiblePaths { /dev/ttyUSB0, /dev/ttyUSB1, /dev/ttyUSB2, /dev/ttyACM0, /dev/ttyACM1, /dev/ttyS0, /dev/ttyS1, /dev/ttyS2, /dev/ttyS3, /dev/ttyS4 }; foreach (var path in possiblePaths) { try { // 尝试以只读方式打开文件判断是否存在且有权限 using (var fs new System.IO.FileStream(path, System.IO.FileMode.Open, System.IO.FileAccess.Read)) { // 如果能打开说明设备存在 ports.Add(path); fs.Close(); } } catch { // 打开失败设备可能不存在或无权限忽略 } } return ports; }对于Android 6.0 (API 23) 以上连接USB设备可能需要运行时权限。这通常需要与一个UsbDevice对象交互过程更复杂。一个更健壮的做法是使用安卓的UsbManager来枚举USB设备并请求权限。这需要编写额外的Java代码和C#封装。对于大多数使用常见USB转串口芯片CH340, CP2102, FTDI的设备系统通常会自动加载驱动并创建/dev/ttyUSBx节点上述文件检测方法在已获得root权限的定制系统或已授权环境下是有效的。对于普通应用更推荐使用封装好的插件它们内部处理了复杂的USB Host API调用和权限请求。4.3 数据协议解析与处理框架串口通信收到的是原始字节流。你需要根据和下位机约定的协议来解析它。常见的协议有定长协议、变长协议带长度头、以及类似Modbus RTU的工业协议。这里提供一个简单的变长协议解析器示例协议格式为[起始符0xAA] [数据长度N] [数据1] ... [数据N] [校验和]。// 创建一个协议解析器类 public class SimpleSerialProtocolParser { private enum ParseState { WaitForStart, WaitForLength, WaitForData, WaitForChecksum } private ParseState _currentState ParseState.WaitForStart; private Listbyte _currentPacket new Listbyte(); private int _expectedLength 0; private byte _expectedChecksum 0; // 处理接收到的原始字节流返回解析出的完整数据包列表 public Listbyte[] ProcessBytes(byte[] rawBytes) { Listbyte[] completePackets new Listbyte[](); foreach (byte b in rawBytes) { switch (_currentState) { case ParseState.WaitForStart: if (b 0xAA) { _currentPacket.Clear(); _currentPacket.Add(b); // 包含起始符 _currentState ParseState.WaitForLength; } break; case ParseState.WaitForLength: _expectedLength b; // 假设长度字节就是数据域长度 _currentPacket.Add(b); if (_expectedLength 0) { _currentState ParseState.WaitForData; } else { _currentState ParseState.WaitForChecksum; // 无数据直接等校验和 } break; case ParseState.WaitForData: _currentPacket.Add(b); // 检查是否已收到足够的数据 // _currentPacket.Count 现在包含 [起始符长度数据...] if (_currentPacket.Count (2 _expectedLength)) // 起始符1 长度1 数据N { _currentState ParseState.WaitForChecksum; } break; case ParseState.WaitForChecksum: byte calculatedChecksum CalculateChecksum(_currentPacket); // 计算已收到部分的校验和 if (calculatedChecksum b) { // 校验通过这是一个完整的数据包 completePackets.Add(_currentPacket.ToArray()); } else { Debug.LogWarning($校验和错误计算值:{calculatedChecksum:X2}, 接收值:{b:X2}); } // 无论对错重置状态机寻找下一个包 _currentState ParseState.WaitForStart; _currentPacket.Clear(); break; } } return completePackets; } private byte CalculateChecksum(Listbyte data) { byte sum 0; // 通常校验和是除起始符和自身外所有字节的和或异或 for (int i 1; i data.Count; i) // 从长度字节开始计算 { sum data[i]; } return sum; // 或 return (byte)(sum 0xFF); } }然后在AndroidSerialPortController.OnSerialDataReceived中使用这个解析器private SimpleSerialProtocolParser _parser new SimpleSerialProtocolParser(); private void OnSerialDataReceived(string portName, byte[] data) { var packets _parser.ProcessBytes(data); foreach (var packet in packets) { // 处理每一个完整的协议包 Debug.Log($收到完整数据包长度: {packet.Length}); // 解析具体数据... // int sensorValue packet[2] 8 | packet[3]; // 示例解析16位数据 } }5. 真机调试、问题排查与性能优化代码写完了部署到真机上才是挑战的开始。下面是我在无数次调试中总结出的宝贵经验。5.1 调试部署流程与关键检查点连接设备与驱动用USB线连接安卓设备和电脑。在手机上开启“开发者选项”和“USB调试”。在电脑端确保ADB驱动正常可通过adb devices命令检查设备是否列出。Unity构建与运行在Unity中Build Settings里选择“Development Build”和“Script Debugging”。点击“Build And Run”。第一次构建会较慢。查看日志这是最重要的调试手段。使用adb logcat命令查看设备日志或者在Unity编辑器中选择Window - Analysis - Profiler连接到设备进行性能分析。在代码中关键位置使用Debug.Log输出信息。检查权限如果应用打开后无法找到串口设备首先检查AndroidManifest.xml是否已添加USB权限。对于更高版本的安卓可能需要动态申请权限这需要更复杂的JNI交互。验证设备路径在应用启动后先调用GetAvailableSerialPorts()并打印结果确认你的USB转串口模块在安卓系统中被识别成了哪个/dev/ttyXXX节点。5.2 常见问题排查速查表问题现象可能原因排查步骤与解决方案应用安装后打开立即闪退1. 缺少必要的安卓权限。2. JNI调用错误Java类找不到。3. 最低API版本设置过高设备不支持。1. 检查AndroidManifest.xml权限声明。2. 查看adb logcat崩溃日志重点看AndroidJavaException。3. 确认Plugins/Android下的Java源码包路径正确。4. 降低Minimum API Level测试。能找到串口路径但打开失败1. 设备节点路径错误。2. 波特率等参数不匹配。3. 设备已被其他进程占用。4. 应用没有访问该设备的权限。1. 使用安卓终端App如Termux执行ls /dev/tty*确认设备节点。2. 确认波特率、数据位、停止位、校验位与下位机严格一致。3. 关闭其他可能占用串口的App。4. 对于系统级设备可能需要root权限。考虑使用USB Host模式动态申请权限。应用运行卡顿、主线程阻塞在主线程中执行了同步串口读写操作。绝对禁止在Update()或任何由主线程调用的函数中直接调用Read()或ReadExisting()。确保所有阻塞操作都在后台线程完成。数据接收不全、丢包1. 读取缓冲区大小不足。2. 后台线程处理不及时队列溢出。3. 波特率过高线程处理速度跟不上。4. 硬件线路干扰。1. 增大读取线程的缓冲区如从1024改为4096。2. 检查主线程Update()中消费队列的速度是否太慢。可以考虑每帧限制处理的数据包数量避免单帧卡顿。3. 适当降低波特率测试。9600, 115200是常用值。4. 检查USB线缆质量或使用带屏蔽的线缆。写入数据后下位机无反应1. 写入的数据格式或编码错误。2. 流未刷新(Flush())。3. 硬件流控RTS/CTS未启用但下位机需要。1. 使用串口调试助手在电脑上模拟上位机发送相同数据确认下位机协议正确。2. 确保写入后调用了OutputStream.Flush()。3. 在打开串口时尝试设置硬件流控标志需要Java库支持。多串口时数据混乱多个串口的读取线程共用了同一个数据队列或静态变量。确保每个SerialPortWrapper实例都有自己独立的ConcurrentQueuebyte[]和后台线程。数据消费时根据portName区分来源。5.3 性能优化与稳定性技巧线程池管理如果串口数量很多超过4个为每个串口创建一个独立线程可能开销较大。可以考虑使用一个线程池让少数几个工作线程以轮询或事件驱动的方式处理多个串口的读取。但复杂度会提高需要谨慎处理数据分隔。队列容量限制为ConcurrentQueue设置一个最大容量当队列满时可以选择丢弃旧数据或新数据并记录警告防止内存无限增长。心跳与超时重连对于需要长期稳定运行的应用实现一个心跳机制。定期向下位机发送心跳包并监测接收超时。如果超时则触发串口关闭、重新初始化的流程。电量优化持续的串口读取会阻止CPU休眠耗电增加。如果不是必须实时通信可以考虑让下位机主动上报或让安卓端定时唤醒读取。日志分级在发布版本中将大量的Debug.Log替换为条件编译避免日志输出成为性能瓶颈。#if UNITY_EDITOR || DEBUG Debug.Log($收到数据: {data.Length} bytes); #endif6. 项目构建、发布与后续维护当调试通过功能稳定后就需要考虑构建发布版本。6.1 构建Release版本在Build Settings中取消勾选“Development Build”。在Player Settings - Publishing Settings中使用正式的Keystore文件签名APK并设置好版本号。建议勾选“Minify”中的ProGuard或R8代码混淆对于Gradle构建以减小APK体积并保护代码。点击Build生成最终的APK文件。6.2 应对安卓系统版本差异存储权限如果你的应用需要保存日志或数据到外部存储Android 10及以上版本引入了分区存储Scoped Storage需要动态申请MANAGE_EXTERNAL_STORAGE权限并可能需要在应用商店声明。后台限制Android 8.0以后后台服务受到严格限制。如果你的应用需要在后台保持串口连接可能需要使用前台服务Foreground Service并显示一个持续的通知。USB Host API为了获得最好的兼容性尤其是非root设备最终的生产代码建议集成安卓官方的UsbManager和UsbDeviceConnection来访问USB串口设备而不是直接操作/dev/tty文件节点。这涉及到更多的JNI封装但能确保在绝大多数设备上正常工作。6.3 扩展思路蓝牙串口SPP原理类似安卓端使用BluetoothSocket进行连接数据读写同样需要在后台线程进行。你可以将本文的串口管理器抽象成一个“数据通道”接口然后分别实现“USB串口”和“蓝牙串口”两种具体类。网络套接字转发可以在一个拥有root权限的设备如树莓派上运行一个本地服务将串口数据通过TCP Socket转发给Unity应用。这样Unity端就变成了纯粹的网络通信跨平台兼容性更好。与UI框架集成将解析后的数据与Unity的UI系统如Unity UIToolkit或UGUI绑定实现数据的实时可视化。注意UI更新必须在主线程进行。整个流程走下来你会发现Unity安卓串口开发的核心难点不在于串口通信本身而在于如何安全、高效地在Unity的单线程游戏循环与安卓的多线程、权限管控环境之间架起一座可靠的桥梁。一旦你掌握了这个“后台线程读取 线程安全队列 主线程消费”的核心模式并将其封装成可复用的模块今后再遇到任何类似的硬件交互需求都可以从容应对。希望这篇超详细的教程能帮你扫清开发路上的障碍。如果在实践中遇到新的问题不妨回头看看线程管理和数据流的设计那往往是问题的根源。