资讯动态

DCM4CHE医学影像显示实战:从DICOM解析到窗宽窗位渲染

发布时间:2026/10/13 11:58:59 来源:尧图企业网站定制
1. 项目缘起与核心需求拆解1.1 这个标题到底在说什么“DCM4CHE图像显示”这个标题第一次看到的人可能会有点懵。DCM4CHE 读作 “dicom for che”是一个在医学影像圈子里流传很广的开源项目集合用 Java 写成专门处理 DICOM 格式的数据。DICOM 是医学数字成像与通信的标准医院里 CT、MR、CR、US 这些设备吐出来的图像文件基本都是这个格式。所以这个标题翻译成人话就是用 DCM4CHE 这套工具链把医学影像文件读出来、解析好、显示在屏幕上。听起来好像只是“打开一张图”这么简单但真正动过手的人都知道医学影像的显示和普通图片查看器完全是两码事。一张 CT 图像里藏着上千个数据标签窗宽窗位、像素间距、重缩放斜率、光度解释、多帧结构、调色板、叠加层……任何一个参数处理错了显示出来的图像要么全黑、要么全白、要么比例失真甚至可能误导阅片。所以这个项目的核心需求其实是在 Java 环境里构建一条从 DICOM 文件到可视图像的完整渲染管线而 DCM4CHE 就是这条管线的地基。1.2 谁需要关注这个内容我把目标读者分成三类。第一类是医疗信息化方向的 Java 开发者手头有 PACS 系统、影像归档平台或者远程会诊工具的维护和开发任务需要在客户端或者服务端把影像渲染出来。第二类是医学影像处理方向的学生和研究人员想用 Java 做算法验证、图像预处理或者数据集可视化但被 DICOM 的复杂性卡在了第一步。第三类是对医学影像感兴趣的技术爱好者可能之前用 Python 的 pydicom 或者 SimpleITK 做过一些实验现在想换到 Java 生态里看看。不管你是哪一类这篇文章都会从最基础的依赖引入开始一路讲到窗宽窗位调节、多帧处理、性能优化和常见坑的排查。我不会只贴一段代码就完事而是会把每个关键决策背后的原因讲清楚让你看完之后能自己判断遇到新问题时该怎么处理。1.3 为什么选 DCM4CHE 而不是别的方案Java 生态里处理 DICOM 的库不止一个比如 PixelMed、ImageJ 的 DICOM 插件、dcm4che 的不同版本分支。我最终选择 DCM4CHE 作为主线原因有几个。第一是成熟度DCM4CHE 从 2001 年左右就开始迭代被大量开源和商业 PACS 项目使用DICOM 标准的覆盖度非常高尤其是那些冷门的 SOP Class 和传输语法它基本都支持。第二是模块化设计dcm4che-core 负责数据模型和 IOdcm4che-image 负责图像处理dcm4che-imageio 负责和 Java ImageIO 框架对接你可以按需引入不会被迫拖进来一堆用不上的东西。第三是社区活跃度虽然它的文档写得比较“工程师友好”意思是需要你自己啃源码但邮件列表和 issue 区里能找到大量实际问题的讨论。当然它也有缺点比如 API 设计偏底层很多地方需要手动管理流和缓冲区对新手不够友好。但反过来看这种底层控制能力恰恰是医学影像显示所需要的因为你要精确控制像素数据的读取方式、字节序转换、颜色空间映射这些在高层次封装里往往被隐藏了出了问题很难调试。2. DCM4CHE 图像显示的核心原理拆解2.1 DICOM 文件里到底存了什么要理解 DCM4CHE 的显示流程得先知道 DICOM 文件的结构。一个 DICOM 文件由文件元信息和数据集两部分组成。文件元信息里包含传输语法、媒体存储 SOP Class UID 等决定了后面数据集怎么解析。数据集则是一系列数据元素的集合每个元素由标签、值表示、值长度和值域组成。和图像显示最相关的标签有这么几个。Rows 和 Columns定义了图像的像素矩阵尺寸比如 512x512。Bits Allocated、Bits Stored、High Bit决定了每个像素用多少位来存储以及有效位是哪几位。Pixel Representation告诉你是无符号整数还是有符号整数CT 图像通常是 signed因为 CT 值有负数。Photometric Interpretation说明像素值的含义MONOCHROME1 表示值越大越黑MONOCHROME2 表示值越大越白RGB 和 YBR 系列则用于彩色图像。Rescale Intercept 和 Rescale Slope是 CT 图像的关键真实 CT 值等于 像素值 × Slope Intercept通常 Slope 是 1Intercept 是 -1024。Window Center 和 Window Width是默认的窗宽窗位决定了初始显示的灰度映射范围。这些标签在 DCM4CHE 里都有对应的类和方法可以读取比如Attributes.getInt(Tag.Rows, 0)、Attributes.getDouble(Tag.RescaleIntercept, 0.0)。但要注意有些标签是可选的有些有多个值读取时必须做空值判断和默认值处理否则运行时会抛异常。2.2 从像素数据到屏幕图像的完整链路DCM4CHE 的图像显示链路可以拆成五步。第一步是加载 DICOM 文件用DicomInputStream读取得到Attributes对象。第二步是提取像素数据通过Attributes.getBytes(Tag.PixelData)或者用DicomInputStream.readAll()配合BulkData来获取原始字节数组。第三步是解析像素数据根据 Bits Allocated、Pixel Representation、传输语法等参数把字节数组转换成 short[] 或 int[] 这样的数值数组。第四步是应用显示变换包括 Rescale、窗宽窗位映射、Photometric Interpretation 反转、VOI LUT 或 Presentation LUT 应用等。第五步是构建 BufferedImage把处理后的像素值写入DataBuffer设置颜色模型最终得到可以绘制的图像对象。这五步里第三步和第四步是最容易出问题的。比如传输语法如果是 JPEG Lossless像素数据是压缩的需要先解压如果是 Implicit VR Little Endian字节序和显式 VR 不同如果 Bits Allocated 是 1 而 Bits Stored 是 1那是二值图像处理方式又不一样。DCM4CHE 提供了ImageReaderFactory和ImageReader来封装这些差异但你需要正确配置ImageReadParam否则读出来的图像可能是错的。2.3 窗宽窗位的数学本质窗宽窗位是医学影像显示里最核心的概念但很多教程只讲怎么调不讲为什么。从数学上看窗宽窗位定义了一个线性映射函数把原始像素值映射到显示灰度值。假设窗宽为 W窗位为 L那么映射区间是 [L - W/2, L W/2]。低于下限的像素显示为最小灰度高于上限的显示为最大灰度区间内的做线性插值。用公式表示就是如果像素值 p 小于 L - W/2输出 0如果 p 大于 L W/2输出 255对于 8 位显示否则输出(p - (L - W/2)) / W * 255。这个公式看起来简单但实际实现时要注意几个细节。第一是数据类型p 可能是 short 或 int计算时要转成 double 避免溢出。第二是边界处理当 W 为 0 或负数时要有保护。第三是 MONOCHROME1 的反转映射完之后还要做 255 - value 的操作。DCM4CHE 的ImageUtils类里有一些辅助方法但窗宽窗位的核心逻辑通常需要自己写因为不同场景下的需求差异很大。比如多窗显示、窗宽窗位预设、非线性 VOI LUT 等都需要在基础映射之上做扩展。3. 环境搭建与依赖配置实操3.1 Maven 依赖的正确引入方式DCM4CHE 的构件发布在 Maven Central 上但版本比较多选对版本很重要。我目前用的是 5.24.x 系列这个版本对 Java 11 及以上支持比较好API 也相对稳定。在pom.xml里需要引入三个核心模块dependency groupIdorg.dcm4che/groupId artifactIddcm4che-core/artifactId version5.24.2/version /dependency dependency groupIdorg.dcm4che/groupId artifactIddcm4che-image/artifactId version5.24.2/version /dependency dependency groupIdorg.dcm4che/groupId artifactIddcm4che-imageio/artifactId version5.24.2/version /dependency如果你需要处理 JPEG 2000 或者 JPEG Lossless 压缩的 DICOM 文件还需要额外引入dcm4che-imageio-opencv或者配置ImageIO的 SPI。这里有个坑dcm4che-imageio 默认只支持未压缩和少数几种压缩格式遇到 JPEG 2000 会报No ImageReader异常。解决办法是引入dcm4che-imageio-opencv它基于 OpenCV 的原生库支持更多压缩格式但需要额外配置 native library 路径。注意dcm4che-imageio-opencv 在不同操作系统上的 native 库不一样Windows 是 .dllLinux 是 .somacOS 是 .dylib。打包部署时要确保目标机器上有对应的库文件否则运行时会报 UnsatisfiedLinkError。3.2 项目结构建议我习惯把图像显示相关的代码分成三层。数据访问层负责读取 DICOM 文件、解析标签、提取像素数据对应DicomImageReader和Attributes的操作。图像处理层负责窗宽窗位映射、重缩放、颜色空间转换对应一系列纯函数式的工具类。显示层负责把 BufferedImage 绘制到 Swing 或 JavaFX 组件上处理缩放、平移、鼠标交互等。这种分层的好处是图像处理层的逻辑可以独立测试不依赖 UI 框架。比如你可以写单元测试验证某个窗宽窗位下的映射结果是否正确而不需要启动整个界面。另外如果以后要从 Swing 换到 JavaFX只需要重写显示层数据访问和图像处理层可以复用。3.3 测试数据的准备手头没有 DICOM 文件的话可以从几个公开数据集获取。比如 TCIAThe Cancer Imaging Archive提供大量匿名化的 CT、MR 数据OSIRIX 的示例数据集也比较常用。下载之后建议先用dcmdump工具看一下文件结构确认传输语法和关键标签的值。dcmdump是 DCM4CHE 命令行工具集里的一个安装 dcm4che-tools 之后就能用。我通常会准备几类测试文件未压缩的 MONOCHROME2 CT、压缩的 JPEG Lossless MR、多帧的超声、RGB 的病理切片、以及 MONOCHROME1 的 CR。这样能覆盖大部分显示场景避免上线后才发现某种类型没处理。4. 核心代码实现与关键细节4.1 读取 DICOM 文件并提取像素数据先看最基础的读取流程。下面这段代码展示了如何用 DCM4CHE 打开一个 DICOM 文件获取 Attributes并提取像素数据public DicomImageData loadDicom(File file) throws IOException { try (DicomInputStream dis new DicomInputStream(file)) { Attributes attrs dis.readDataset(); int rows attrs.getInt(Tag.Rows, 0); int cols attrs.getInt(Tag.Columns, 0); int bitsAllocated attrs.getInt(Tag.BitsAllocated, 8); int bitsStored attrs.getInt(Tag.BitsStored, bitsAllocated); int highBit attrs.getInt(Tag.HighBit, bitsStored - 1); int pixelRepresentation attrs.getInt(Tag.PixelRepresentation, 0); double rescaleIntercept attrs.getDouble(Tag.RescaleIntercept, 0.0); double rescaleSlope attrs.getDouble(Tag.RescaleSlope, 1.0); String photometric attrs.getString(Tag.PhotometricInterpretation, MONOCHROME2); double windowCenter attrs.getDouble(Tag.WindowCenter, Double.NaN); double windowWidth attrs.getDouble(Tag.WindowWidth, Double.NaN); byte[] pixelData attrs.getBytes(Tag.PixelData); if (pixelData null) { throw new IOException(No pixel data in file: file); } short[] pixels convertToShortArray(pixelData, bitsAllocated, pixelRepresentation); return new DicomImageData(rows, cols, pixels, rescaleIntercept, rescaleSlope, photometric, windowCenter, windowWidth); } }这段代码里有几个关键点需要展开。第一是readDataset()和readAll()的区别。readDataset()只读取数据集部分不读文件元信息适合已经知道传输语法的场景。readAll()会读取包括文件元信息在内的全部内容更通用但稍慢。我一般用readDataset()因为DicomInputStream构造时会自动检测传输语法。第二是像素数据的转换。attrs.getBytes(Tag.PixelData)返回的是原始字节数组需要根据 Bits Allocated 和 Pixel Representation 转换成数值数组。如果 Bits Allocated 是 16每个像素占 2 个字节需要按小端或大端序组合成 short。如果 Pixel Representation 是 1有符号还要做符号扩展。下面是一个转换示例private short[] convertToShortArray(byte[] data, int bitsAllocated, int pixelRepresentation) { if (bitsAllocated 8) { short[] result new short[data.length]; for (int i 0; i data.length; i) { result[i] (short) (data[i] 0xFF); } return result; } else if (bitsAllocated 16) { short[] result new short[data.length / 2]; ByteBuffer buffer ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN); for (int i 0; i result.length; i) { result[i] buffer.getShort(); } return result; } throw new UnsupportedOperationException(Unsupported bitsAllocated: bitsAllocated); }注意字节序的判断不能想当然。DICOM 默认是 Little Endian但 Explicit VR Big Endian 传输语法下是大端。实际项目中应该从DicomInputStream的getTransferSyntax()获取字节序信息而不是硬编码。4.2 窗宽窗位映射的实现拿到 short[] 像素数组之后下一步是应用窗宽窗位映射生成 8 位的灰度图像。下面是一个完整的映射函数public BufferedImage applyWindow(short[] pixels, int rows, int cols, double intercept, double slope, double windowCenter, double windowWidth, boolean invert) { double low windowCenter - windowWidth / 2.0; double high windowCenter windowWidth / 2.0; double range high - low; if (range 0) { range 1; } byte[] output new byte[rows * cols]; for (int i 0; i pixels.length; i) { double value pixels[i] * slope intercept; double normalized; if (value low) { normalized 0; } else if (value high) { normalized 255; } else { normalized (value - low) / range * 255.0; } int gray (int) Math.round(normalized); if (invert) { gray 255 - gray; } output[i] (byte) gray; } BufferedImage image new BufferedImage(cols, rows, BufferedImage.TYPE_BYTE_GRAY); image.getRaster().setDataElements(0, 0, cols, rows, output); return image; }这段代码看起来直白但有几个性能陷阱。第一是循环内的浮点运算对于 512x512 的图像就是 26 万次迭代如果每帧都重新计算交互时会卡顿。优化方法是用查表法预先计算 65536 个可能的像素值对应的灰度值存到一个 byte[] 数组里循环里只做数组索引。第二是 BufferedImage 的类型选择TYPE_BYTE_GRAY 适合灰度图但如果你要做伪彩色或者叠加标注可能需要 TYPE_INT_RGB 或 TYPE_INT_ARGB。第三是 invert 的处理MONOCHROME1 需要反转但反转应该在映射之后做而不是在映射之前否则窗宽窗位的语义会变。4.3 多帧图像的处理策略超声、X 射线透视、部分 CT 会使用多帧格式一个 DICOM 文件里包含多个帧。DCM4CHE 读取多帧时Tag.NumberOfFrames会给出帧数像素数据是连续排列的。处理多帧有两种策略一次性全部加载到内存适合帧数少、分辨率低的情况按需加载单帧适合帧数多、内存受限的情况。按需加载的实现方式是使用ImageReader的read(int imageIndex)方法。DCM4CHE 的DicomImageReader实现了javax.imageio.ImageReader接口可以像读普通图片一样读 DICOM 的每一帧ImageReader reader ImageReaderFactory.getInstance() .getImageReader(ImageReaderFactory.DICOM); reader.setInput(new FileImageInputStream(file)); int numFrames reader.getNumImages(true); BufferedImage frame reader.read(frameIndex);这种方式的优点是内存占用低缺点是每次读取都要重新解析文件头频繁切换帧时性能不好。我的经验是如果帧数超过 100用按需加载如果少于 100一次性加载到ListBufferedImage里切换时直接取。注意多帧 DICOM 的ImageReader.read()返回的图像可能已经应用了窗宽窗位也可能没有取决于ImageReadParam的配置。如果发现读出来的图像和预期不符检查一下是否设置了setDestination或者setSourceRegion。5. 显示交互与性能优化5.1 在 Swing 中绘制医学图像BufferedImage 准备好之后显示就相对简单了。Swing 里可以用JLabel配合ImageIcon也可以用自定义的JPanel重写paintComponent。我推荐后者因为医学影像通常需要缩放、平移、窗宽窗位调节等交互自定义组件更灵活。public class DicomViewer extends JPanel { private BufferedImage image; private double zoom 1.0; private int offsetX 0; private int offsetY 0; Override protected void paintComponent(Graphics g) { super.paintComponent(g); if (image null) return; Graphics2D g2d (Graphics2D) g; g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); int w (int) (image.getWidth() * zoom); int h (int) (image.getHeight() * zoom); g2d.drawImage(image, offsetX, offsetY, w, h, null); } }缩放时用VALUE_INTERPOLATION_BILINEAR比NEAREST_NEIGHBOR视觉上更平滑但医学影像有时候需要看原始像素这时候应该用NEAREST_NEIGHBOR。我通常提供一个切换选项默认双线性按快捷键切换到最近邻。5.2 窗宽窗位交互的响应速度优化窗宽窗位调节是医学影像查看器里最频繁的操作鼠标拖动时每帧都要重新映射和重绘。如果每次都在完整分辨率上做映射512x512 的图像还能接受但 2048x2048 的 CR 或者多帧超声就会明显卡顿。我的优化方案是降采样预览 全分辨率延迟渲染。鼠标拖动过程中用 1/4 或 1/8 分辨率的缩略图做映射和显示响应速度能提升 10 倍以上。鼠标松开后再在后台线程里对全分辨率图像做映射完成后替换显示。这样用户感知到的就是“拖动很流畅松手后画质变清晰”。实现上可以用SwingWorker做后台渲染用AtomicReference保存最新的渲染结果避免旧任务覆盖新结果。另外查表法在这里特别有用因为降采样后的像素值范围不变查表结果可以复用。5.3 内存管理与大图像处理医学影像文件动辄几十兆甚至上百兆如果同时打开多个序列内存很容易爆掉。几个实用的内存管理技巧第一及时释放不再使用的 BufferedImage把引用置为 null 并调用System.gc()虽然不保证立即回收但聊胜于无。第二用SoftReference或WeakReference缓存图像内存紧张时 JVM 可以自动回收。第三对于超大图像考虑分块加载只把当前视口内的区域加载到内存。DCM4CHE 的ImageReader支持setSourceRegion可以只读取图像的一部分。这在浏览大尺寸病理切片时特别有用配合视口计算能实现类似地图应用的平滑浏览体验。6. 常见问题与排查技巧实录6.1 图像全黑或全白的排查思路这是最常见的问题原因通常有三个。第一是窗宽窗位不对比如 CT 图像的默认窗位是 40窗宽是 400如果你用了 0 和 255那大部分像素都会落在区间外显示为全黑或全白。解决办法是打印出像素值的实际范围然后根据范围设置合理的窗宽窗位。第二是 Rescale 没有应用CT 的原始像素值范围可能是 0 到 4095但真实 CT 值是 -1024 到 3071如果不做 Rescale窗宽窗位就完全对不上。第三是 Photometric Interpretation 判断错误MONOCHROME1 和 MONOCHROME2 的显示是反的如果搞混了图像会看起来像底片。排查时我习惯按这个顺序检查先看dcmdump输出的关键标签值确认 WindowCenter、WindowWidth、RescaleIntercept、RescaleSlope、PhotometricInterpretation 是否符合预期然后在代码里打印像素数组的最小值和最大值最后检查映射函数的边界条件。6.2 压缩格式不支持的处理遇到No ImageReader或者Unsupported Transfer Syntax异常时说明当前环境缺少对应压缩格式的解码器。DCM4CHE 默认支持的传输语法包括 Implicit VR Little Endian、Explicit VR Little Endian、Explicit VR Big Endian、JPEG Baseline、JPEG Lossless 等。对于 JPEG 2000、JPEG-LS、RLE 等格式需要额外配置。配置方式是在ImageReaderFactory初始化时注册对应的ImageReaderSpi。如果引入了dcm4che-imageio-opencv它会自动注册。如果没有可以手动添加ImageIO.scanForPlugins(); ImageReaderFactory.getInstance().registerImageReaderSpi( new DicomImageReaderSpi());注意ImageIO.scanForPlugins()会扫描 classpath 下所有 ImageIO 插件启动时会慢一点但能确保所有可用的解码器都被注册。6.3 多帧图像帧序错乱的解决有些设备的 DICOM 文件里帧的顺序和FrameIncrementPointer指定的方向不一致导致显示时帧序错乱。解决办法是读取Tag.FrameIncrementPointer根据它指向的标签通常是Tag.SliceLocation或Tag.ImagePositionPatient对帧进行排序。如果这些标签不存在就只能按文件中的物理顺序显示。另一个相关问题是Tag.NumberOfFrames和实际帧数不一致这通常是文件损坏或者传输过程中被截断。可以用ImageReader.getNumImages(true)获取实际可读的帧数和NumberOfFrames对比不一致时以实际可读为准。6.4 常见问题速查表问题现象可能原因排查方法解决方案图像全黑窗宽窗位过窄或偏移打印像素值范围根据范围重设窗宽窗位图像全白窗宽窗位过宽或反转检查 PhotometricInterpretation调整映射区间或反转图像比例失真PixelSpacing 未应用读取 PixelSpacing 标签按间距比例缩放显示颜色异常颜色空间转换错误检查 PhotometricInterpretation正确应用 YBR 到 RGB 转换读取报错压缩格式不支持查看传输语法 UID引入对应解码器内存溢出大图像未分块监控堆内存使用分块加载或降采样帧序错乱FrameIncrementPointer 未处理检查帧排序标签按位置标签排序7. 实际项目中的经验沉淀7.1 关于 DCM4CHE 版本选择的教训我最早用的是 3.x 系列后来升级到 5.x 时踩了不少坑。3.x 的DicomInputStream和 5.x 的 API 差异很大比如readDataset()的返回类型、Attributes.getBytes()的行为都有变化。如果项目里同时依赖了其他使用 DCM4CHE 的库版本冲突会很头疼。我的建议是统一用 5.24.x 或更高版本并且在pom.xml里用dependencyManagement锁定版本避免传递依赖引入不一致的版本。另外DCM4CHE 的某些版本对 Java 模块化系统JPMS支持不好如果你在用 Java 9 的模块化项目可能会遇到module-info相关的错误。解决办法是暂时不用模块化或者手动添加--add-opens参数。7.2 窗宽窗位预设的实用配置不同检查类型的窗宽窗位预设差异很大我整理了一份常用预设在实际项目中可以直接用检查类型窗宽窗位说明脑部 CT8040观察脑实质脑部 CT 骨窗2000400观察颅骨胸部 CT 肺窗1500-600观察肺实质胸部 CT 纵隔窗35050观察纵隔结构腹部 CT40050观察腹腔脏器肝脏 CT15060观察肝脏病变骨骼 CT2000300观察骨折这些预设值不是绝对的不同设备、不同扫描协议会有差异。实际使用时我通常先读 DICOM 文件里的默认窗宽窗位如果没有或者不合适再套用预设。7.3 一个容易被忽略的细节像素间距与显示比例DICOM 文件里的PixelSpacing标签定义了像素的物理尺寸单位是毫米。比如 CT 的 PixelSpacing 可能是 [0.5, 0.5]表示每个像素代表 0.5mm x 0.5mm。如果显示时不考虑这个图像会被拉伸或压缩影响测量和观察。正确的做法是在绘制时根据 PixelSpacing 计算宽高比。比如图像是 512x512PixelSpacing 是 [0.5, 0.8]那么实际物理尺寸是 256mm x 409.6mm显示时应该按这个比例缩放而不是简单地按像素尺寸显示。这个细节在骨科测量、病灶尺寸评估等场景下特别重要。7.4 后续可以扩展的方向这个项目的基础版本完成后可以往几个方向扩展。第一是 MPR多平面重建把横断面的序列重建成冠状面和矢状面需要处理体素间距和插值。第二是三维重建用 Marching Cubes 或者 Ray Casting 做表面渲染或体渲染。第三是标注和测量在图像上画线、画角、画 ROI计算长度、角度、面积、CT 值统计。第四是 DICOM 网络传输用 DCM4CHE 的DicomService实现 C-STORE、C-FIND、C-MOVE和 PACS 服务器对接。这些扩展每一个都够写一篇长文但核心的图像显示管线是基础。把这一层做扎实了后面的功能都是在这个基础上叠加。我个人在实际操作中的体会是DCM4CHE 的图像显示看起来只是“读文件、画图”两件事但真正做起来细节多到超出预期。每一个标签的处理、每一个参数的默认值、每一种传输语法的差异都可能成为卡住你的地方。我的建议是不要试图一次覆盖所有情况先把最常见的 CT 和 MR 灰度图像处理好再逐步扩展到多帧、彩色、压缩格式。遇到问题时dcmdump和 DCM4CHE 的源码是你最好的朋友前者告诉你文件里有什么后者告诉你代码怎么处理。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑