news 2026/10/2 7:46:10

Android HWC硬件合成器深度解析:从SurfaceFlinger到DRM/KMS的显示优化指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Android HWC硬件合成器深度解析:从SurfaceFlinger到DRM/KMS的显示优化指南

1. 从一次黑屏问题说起:为什么需要理解HWC

做Android系统开发的朋友,几乎都遇到过这样的场景:App层明明已经把画面提交上来了,SurfaceFlinger也调用了,但屏幕就是黑的、花屏的,或者滑动列表时明显掉帧。查半天,log里翻来覆去就那几行,最后发现问题出在硬件合成器HWC(Hardware Composer)上。

我接触HWC是在做系统性能优化的时候。当时被测设备在低亮度下播放视频,功耗比竞品高了快10%,CPU占用率倒是正常,但GPU负载一直下不来。翻了一周代码,定位到合成链路:SurfaceFlinger把所有的图层都交给GPU做GPU合成(GLES Composition),完全绕过了HWC的硬件合成能力。这就像你家里明明有洗碗机,却坚持手洗所有碗筷,费时费力还费水。搞懂HWC在整个显示链路里的位置和作用,是解决这类问题的第一步。

这篇文章我会从SurfaceFlinger的合成决策开始,一路讲到显示驱动层的DRM/KMS接口,把HWC硬件合成器的完整流程拆开揉碎。内容包括:HWC1与HWC2的架构差异、Layer与Composition的决策逻辑、BufferQueue与Fence同步机制、真正的HAL层接口实现,以及怎么用dumpsys SurfaceFlinger验证合成是否真的走了硬件。同时会分享一些我在实际调试中踩过的坑——比如crop坐标计算错误、fence超时导致的显示卡顿、HWC回退到GPU合成的原因排查。

这篇内容适合正在做Android系统移植与优化的工程师、想深入理解显示子系统的应用层开发者,以及准备面试系统开发岗位的同学。不需要你有显示驱动开发经验,但对Android基本架构要有概念。

2. HWC在整个显示链路中的位置与核心职责

2.1 SurfaceFlinger到显示驱动的完整调用链

先看一条完整链路。一个App要显示一帧动画,从产生到屏幕点亮,大概要经过以下环节:

App调用ANativeWindow的dequeueBuffer获取一块可写的图形缓冲区,然后通过Canvas或OpenGL ES把内容渲染上去,再调用queueBuffer把这块缓冲区提交到BufferQueue。BufferQueue的生产者端持有这些缓冲区,消费者端通常就是SurfaceFlinger。SurfaceFlinger通过CompositionEngine汇总所有可见Surface的缓冲区,决定哪些图层可以交给HWC做硬件合成,哪些需要自己用GPU先合成一次,最终输出到显示设备。

SurfaceFlinger的下游就是HAL层。HWC(Hardware Composer)是Android定义的一套标准HAL接口,硬件厂商根据自家GPU/DPU(Display Processing Unit)的能力实现这套接口。在HWC2架构里,SurfaceFlinger通过IComposerClient与HWC服务通信,HWC服务再通过IDevice、IDisplay、ILayer这些接口操作具体的显示设备。

再往下,HWC的实现通常基于DRM(Direct Rendering Manager)子系统。以高通平台为例,HWC会通过libdrm调用drmModeSetPlane、drmModePageFlip等接口,把图层交由KMS(Kernel Mode Setting)配置到显示控制器。显示控制器负责把内存中的像素数据按时序输出到HDMI、DSI、DP等物理接口上。

2.2 HWC解决的核心问题:功耗与带宽

很多人问,SurfaceFlinger自己也能做合成,为什么非要硬件合成器?

核心原因有两个:带宽和功耗。

假设屏幕分辨率是1080p,刷新率60Hz,像素格式RGBA8888,一帧原始数据大约是192010804字节,约8.3MB。60Hz下一秒钟的数据量接近500MB。如果所有图层都让GPU先合成到一块临时缓冲区,再由显示器控制器读走,内存带宽消耗会非常可观。

但如果把多个图层直接交给显示控制器硬件去叠加,显示控制器本身就支持多个plane(硬件图层),它可以在读取每一行的像素时实时叠加多层数据,不需要先写入一块合成后的缓冲区。这就省掉了一次全屏读写的带宽开销。

功耗上的差异更明显。GPU做合成时,需要把整个屏幕的像素处理一遍,GPU频率会被拉高,耗电自然增加。HWC硬件合成时,图层仅仅是硬件叠加,GPU可以进入低功耗状态。尤其是在视频播放、相机预览这类持续全屏显示的场景里,HWC是否生效直接决定设备的续航表现。

提示:在低刷新率或静态画面场景,HWC省电效果更明显。如果只是滑动列表这种UI持续变化的场景,GPU合成与HWC的功耗差距反而不大。评测HWC效果时要注意场景选择。

3. HWC2架构详解:从接口设计到核心概念

3.1 HWC1到HWC2的演进逻辑

Android 8.0之前使用的是HWC1接口,SurfaceFlinger需要把全套Layer信息传给HWC,HWC返回每个Layer的合成方式。HWC1的接口设计偏向固定功能硬件,很多处理逻辑被硬编码在HAL里。

Android 8.0开始引入HWC2,设计思路做了很大调整。HWC2把决策权更多地收回到SurfaceFlinger侧,HAL层主要提供能力声明和图层配置能力。HWC2定义了三种合成类型:

  • DEVICE:图层由HWC硬件合成,SurfaceFlinger不需要参与
  • CLIENT:图层需要SurfaceFlinger用GPU合成到一块目标缓冲区
  • CURSOR:专门为光标图层优化,硬件支持的话可以不占用正常Layer资源

HWC2还有一个重要的变化:支持多Display。HWC1时代主要面向内置屏幕,HWC2把外部显示(HDMI、DP、无线投屏)也纳入统一管理,每个Display有独立的合成配置。这对现代Android设备的双屏、多屏交互场景至关重要。

3.2 HWC2的四大核心接口族

HWC2的HAL定义在hardware/interfaces/graphics/composer/2.x目录下,核心接口可以分为四组:

  • IComposerClient:HWC服务的入口,负责创建Display、创建Layer、设置全局属性
  • IDevice:代表一个物理显示控制器,操作包括创建Layer、设置Power Mode、验证合成方案
  • IDisplay:具体的显示输出,1024号Display通常是主屏(Internal Display),外接HDMI等设备会有独立的Display ID
  • ILayer:指一个可配置的显示图层,包含Buffer、SourceCrop、DisplayFrame、Transform、BlendMode、PlaneAlpha等属性

实际开发中接触最多的接口是IDevice的validateDisplay和presentDisplay,以及ILayer的setLayerBuffer、setLayerSourceCrop、setLayerDisplayFrame。

一个典型的HWC2配置顺序是:SurfaceFlinger先调用setLayerBuffer等接口配置好所有Layer的属性,然后调用validateDisplay让HWC检查这套配置是否可行,HWC返回结果并标记每个图层应该走DEVICE还是CLIENT。SurfaceFlinger根据返回值做处理,如果所有图层都走DEVICE,就直接调用presentDisplay提交显示;如果有CLIENT图层,SurfaceFlinger先用GPU合成这些图层,再重新validate,直到所有图层都被HWC接受。

3.3 Layer、Plane与合成方式的核心概念

Layer是SurfaceFlinger传给HWC的图层抽象,每个Layer包含一个Buffer、显式的位置信息、裁剪信息、透明度等。Plane是显示控制器物理上支持的图层叠加单元,数量有限,常见的是4到8个。

可以这样理解:Plane是硬件资源,Layer是软件对象。HWC要做的事就是把Layer映射到Plane上。如果Layer数量超过硬件Plane数量,或者某些Layer属性硬件不支持,就超出的部分就要靠GPU合成来合并。

合成方式的选择有几个关键判断条件:

  • Layer是否带有透明度或者复杂的混合模式
  • Layer是否做了缩放、旋转等变换
  • Layer是否使用了硬件不支持的像素格式
  • Layer数量是否超出Plane能力

实际调优中,最常见的HWC回退原因是Transform不被支持。比如SurfaceFlinger把旋转角度设置成Transform::ROT_90,但显示控制器的plane不支持90度旋转,HWC只能把这部分Layer标记为CLIENT。

注意:查看HWC合成决策时,优先关注HWC的getCapabilities能力集。不同芯片方案的HWC能力差异很大,同样的Layer配置,在A平台走DEVICE,在B平台可能走CLIENT,这不一定是bug,跟硬件能力直接相关。

4. 实战:一次视频播放HWC合成全流程拆解

4.1 环境与工具准备

我这次实战使用的设备是高通骁龙平台的Android 12系统,内核版本5.10,HWC实现基于DRM。调试工具包括:

  • adb shell dumpsys SurfaceFlinger:查看合成决策、Layer信息、帧率统计
  • adb shell dumpsys display:查看显示设备状态
  • adb shell cat /sys/kernel/debug/dri/0/state:查看DRM状态
  • adb shell dumpsys gfxinfo:查看App渲染性能
  • 自定义的HWC trace点,通过systrace抓取

提示:如果设备没有root权限,DRM调试节点可能无法访问。可以先用adb shell dumpsys SurfaceFlinger --list来确认SurfaceFlinger版本,再决定后续调试方式。

4.2 SurfaceFlinger合成决策日志解读

启动一个视频播放App,让它全屏播放视频。播放过程中执行:

adb shell dumpsys SurfaceFlinger

关键输出如下:

Display 0: HWC 2.x Layers: | Z | Comp Type | Disp Frame | Source Crop | Surface | ... |----|------------|--------------|---------------------|-----------| | 0 | DEVICE | [ 0, 0] | [ 0, 0] | videoDec | | 1 | DEVICE | [ 0, 0] | [ 0, 0] 1280x720| videoLayer | | 2 | DEVICE | [ 0, 0] | [ 0, 0] 1080x1920| statusbar | | 3 | DEVICE | [ 0, 0] | [ 0, 0] 1080x1920| navigationbar |

这里的Comp Type是我们最关心的字段。DEVICE表示该图层由HWC硬件合成,CLIENT表示需要GPU参与。

一个常见的疑问是:状态栏和导航栏也是独立的图层,为什么它们也能走DEVICE?原因是大多数显示控制器支持至少4个plane,系统UI层虽然透明混合,但如果HWC声明支持BLEND_PREMULTIPLIED,就可以直接硬件叠加。

4.3 DRM驱动侧的实际状态验证

SurfaceFlinger给出的结论是“我请求了DEVICE合成”,但真正显示出来的效果,还得看DRM层是否按预期配置了Plane。通过DRM状态节点验证:

cat /sys/kernel/debug/dri/0/state

输出节选:

plane[31]: crtc=24 fb=256 src=(0,0) 1280x720 dst=(0,0) 1024x768 rotation=0 plane[32]: crtc=24 fb=257 src=(0,0) 1080x1920 dst=(0,0) 1080x1920 rotation=0

每个有内容的fb对应一块framebuffer,src是源裁剪区域,dst是目标显示区域。如果src和dst与SurfaceFlinger配置的Layer信息一致,说明HWC确实把配置下发到了DRM。

4.4 帧率与延迟的实测数据

用systrace抓取一次视频播放的合成过程。重点看三个时间段:

  • App的queueBuffer到SurfaceFlinger消费该Buffer的间隔
  • SurfaceFlinger调用HWC的validateDisplay到presentDisplay之间的间隔
  • presentDisplay返回到Vsync下一次到来的间隔

在纯DEVICE合成场景,SurfaceFlinger的处理时间通常在1到2ms以内,其中大部分耗时消耗在Binder IPC和BufferQueue的acquire/release上。GPU合成场景下,这个时间会拉长到5到8ms,同时也伴随着GPU频率上升。

一个值得关注的数字是合成耗时占比。如果在一段时间内,合成耗时持续超过帧间隔的50%,就说明显示链路压力很大,需要考虑减少图层数或者优化合成策略。

5. HWC关键参数与BufferQueue、Fence同步机制

5.1 BufferQueue在合成流程中的角色

要理解HWC的工作方式,必须理解BufferQueue。它是Android图形系统的数据管道,由生产者(App)和消费者(SurfaceFlinger)通过共享内存的方式传递图形缓冲区。

HWC并不直接消费BufferQueue的缓冲区,它只负责把SurfaceFlinger已经acquire到的Buffer配置到硬件Plane上。SurfaceFlinger在把Layer信息传给HWC之前,需要保证对应的Buffer状态正确。dequeueBuffer和queueBuffer的过程中,Buffer有四种状态:FREE、DEQUEUED、QUEUED、ACQUIRED。

合成流程中,SurfaceFlinger作为消费者从BufferQueue acquire一块Buffer后,会把Buffer的handle传给HWC。HWC在使用这个Buffer之前,必须等它的ReleaseFence信号——也就是说,生产者(App的GPU渲染)真正完成了写入,HWC才能读取。

5.2 Fence同步机制:跨进程的“完成信号”

Fence是Android图形系统里的同步原语,本质上是一个文件描述符,对应内核的sync_fence机制。它解决的核心问题是:多个硬件单元(GPU、DPU、Display Controller)之间的依赖关系。

举一个场景。视频解码器输出一帧视频数据到Buffer A,App的SurfaceFlinger想把这帧数据显示到屏幕。解码器写Buffer A的操作可能还没完成,SurfaceFlinger不能贸然把Buffer交给显示控制器。这就是AcquireFence的作用:SurfaceFlinger在queueBuffer时传入AcquireFence,HWC在配置Plane前会等待这个fence。

同样,SurfaceFlinger在GPU合成CLIENT图层时,GPU可能在渲染目标缓冲区,HWC不能立即读取。SurfaceFlinger会为合成结果传入ReleaseFence,HWC等待ReleaseFence之后再做Display输出。

Fence超时是最常见的显示问题来源之一。如果某个硬件单元一直没有signal fence,显示就会卡住,最终触发SurfaceFlinger的FenceTimeout机制,导致掉帧甚至屏幕黑掉。排查这类问题时,dumpsys SurfaceFlinger的Fence状态和内核的/sys/kernel/debug/sync信息非常有用。

提示:如果用adb shell dumpsys SurfaceFlinger看到某一帧长时间卡在acquireFence状态,优先检查GPU是否hang住。在部分平台,GPU hang会导致fence一直不signal,进而阻塞整个合成流程。

5.3 HWC与Vsync的配合

显示系统还有一个核心概念是Vsync(垂直同步)。屏幕的刷新是一个持续过程,从左上角扫描到右下角,每扫完一帧就触发一个Vsync信号。HWC和SurfaceFlinger都依赖Vsync来推进渲染节奏。

SurfaceFlinger在Vsync到来时开始处理合成任务,HWC在Vsync间隔内完成Plane配置。如果HWC合成耗时超过Vsync间隔,就会出现掉帧。这也是为什么在设计合成方案时,硬件Plane的数量和配置速度至关重要。

6. 显示驱动侧的核心实现:DRM/KMS接口解析

6.1 DRM与KMS的基本概念

DRM(Direct Rendering Manager)是Linux内核的图形子系统,负责管理GPU、显示控制器、输出接口等。KMS(Kernel Mode Setting)是DRM里的显示管理部分,负责配置显示模式、分辨率、帧率以及Plane的布局。

Android的HWC HAL实现基于DRM/KMS的情况下,HWC通过libdrm用户态库操作DRM设备节点(/dev/dri/card0)。关键的DRM对象有:

  • CRTC(Cathode Ray Tube Controller):显示控制器,负责把内存中的画面按特定时序输出
  • Encoder:编码器,把CRTC输出的信号转换成物理接口需要的格式
  • Connector:物理连接器,比如HDMI、DP、DSI
  • Plane:硬件图层,可以理解为显示控制器里的独立图像叠加层

6.2 HWC如何与DRM/KMS交互

一个完整的HWC到DRM的调用链路是:

HWC的validate阶段,会把一组Layer提交给DRM的Atomic Test接口,查询硬件是否支持这套配置。drmModeAtomicCommit的flag如果包含DRM_MODE_ATOMIC_TEST_ONLY,则表示只做校验不真正生效。如果校验失败,HWC会把部分Layer标记为CLIENT,要求SurfaceFlinger先用GPU合成。

校验通过后,HWC进入commit阶段,调用drmModeAtomicCommit正式提交配置,KMS会更新硬件Plane的寄存器,在下一个Vsync开始按新配置输出画面。

Atomic API的设计让HWC可以一次性提交多个属性更新,保证显示的原子性。传统Legacy API是一次一个属性设置,应用层在配置过程中可能出现撕裂。Android的HWComposer要保证画面切换不产生撕裂(tearing),Atomic API是关键机制。

很多显示问题与DRM/KMS配置错误有关。举一个典型的案例:某设备外接4K显示器时,HWC配置了4K分辨率的Plane,但CRTC仍停留在1080p模式,结果只显示了左上角四分之一画面。定位后发现是HWC的Display配置和DRM的Connector配置未同步,重新设置CRTC的模式后解决。

6.3 显示驱动的调试技巧

遇到显示异常时,我常用的排查顺序是:

第一步,看DRM state节点确认Plane配置是否正确。如果配置正确,说明HWC与DRM的交互正常,问题可能出在硬件或面板本身。第二步,看内核dmesg有没有DRM或DPU报错。第三步,看SurfaceFlinger的日志,判断是合成决策异常还是Buffer处理异常。

内核对DRM的错误处理通常有完整日志。比如drm_atomic_check_failed就表示Atomic校验失败,后面会带上具体失败的原因,诸如plane does not support rotation等。这类日志对定位HWC能力声明错误非常有帮助。

7. 实战调优:让合成真正走HWC的配置策略

7.1 确认当前合成方式

在做任何优化之前,先要确认设备当前的真实合成路径。上面讲过dumpsys SurfaceFlinger可以查看Comp Type,但更快速的方式是看SF的log:

adb logcat -s SurfaceFlinger

搜索Composition或validate关键字的输出,可以看到类似这样的日志:

SurfaceFlinger: Composition DISPLAY_0: DEVICE(3) CLIENT(0)

DEVICE后面的数字表示有多少个图层走了硬件合成,CLIENT后面的数字表示多少个图层走了GPU合成。如果CLIENT数量大于0,说明至少有一部分图层没有走HWC。

7.2 常见的HWC回退原因及对策

我在实际操作中总结了几种高频的HWC回退场景:

第一种,Layer数量超过Plane数量。常见于多窗口模式或分屏模式。SurfaceFlinger会把超过Plane能力的Layer合并,或者部分Layer回退到CLIENT。对策是检查HWC的getMaxLayerCount声明,同时注意Surface的过度分层,避免不必要的图层叠加。

第二种,Transform旋转导致回退。硬件Plane通常只支持ROT_0,部分平台支持ROT_90等。如果App层设置了Transform,HWC校验失败就会回退。对策是让App层尽量避免使用无法硬件加速的旋转,或者检查HWC是否正确声明了ROTATE能力。

第三种,像素格式不支持。比如RGB_888与RGBA_8888的Plane支持情况不同。某些平台对YUV格式的硬件叠加能力有限。对策是检查显示控制器的格式支持表,必要的时候让App规范像素格式的使用。

第四种,Buffer维度超过硬件限制。显示控制器对Plane的宽高有限制,如果Surface过大或过小,超出硬件能力范围,也会回退。这类问题通常伴随硬件规格文档限制,需要详细比对。

7.3 优化Layer以提升合成效率

在实际开发中改变App的UI层级往往不现实,所以HWC优化更多是系统侧的配置工作:

一是减少Surface数量。不必要的Surface会增加Layer数,超出Plane能力后就必然有回退。对于纯系统UI,可以考虑合并Surface或者在低端设备上减少分屏的图层。

二是合理使用SetLayerBuffer的时机。SurfaceFlinger频繁把Buffer交给HWC,HWC内部有一整套Buffer管理和缓存逻辑。如果配置不当,可能导致每次合成都要重新设置Buffer,增加IPC开销。

三是利用好隐藏显示(Display Off)策略。在屏幕熄灭状态,HWC可以停止合成以省电。MR(Mipi Read)模式下屏幕内容可以自刷新,HWC只需要在内容变化时更新。这个功能在黑屏待机场景下功耗优化效果明显。

7.4 用systrace定位掉帧根因

通过systrace抓取一次卡顿现场的合成耗时。执行以下步骤:

# 启动systrace,抓取gfx、view、sf、hwc相关tag python systrace.py -a 你的应用包名 -b 16384 gfx view wm am sf hwc idle sched freq

抓取后,在浏览器打开trace文件,重点观察SurfaceFlinger线程的执行时间线。如果发现SF单次合成耗时超过5ms,进一步下钻到这个时间段内SF调用了哪些函数,比较HWC validate与HWC present的耗时占比。

如果validate阶段耗时偏高,往往是Layer属性配置复杂导致HWC内部校验时间长;如果present阶段耗时偏高,可能是Buffer的AcquireFence等待太久,也就是生产者端跟不上节奏。

实操心得:present耗时偏高,但GPU并不繁忙时,优先检查BufferQueue的dequeueBuffer超时,这通常意味着Buffer数量配置不足。SurfaceFlinger默认BufferQueue会分配3块Buffer,在某些高帧率场景可以尝试增加Buffer数量,但要权衡内存占用。

8. 文档之外的进阶认知与学习路线

8.1 HWC学习资源推荐

我日常工作里最常用的几份资料:

  • AOSP源码的hardware/interfaces/graphics/composer/2.x目录:HWC2的接口定义,必须通读
  • 各芯片厂商的HWC实现源码,比如高通平台的sdm(Snapdragon Display Manager),代码量大,但能学到完整的显示控制器管理逻辑
  • Linux内核的drivers/gpu/drm目录:DRM原子接口的实际实现
  • 官方文档《Android Graphics Architecture》:AOSP源码的docs目录下可以找到,是理解整个图形架构的入门材料

如果做的是MTK平台,则重点看vendor/mediatek/proprietary/packages/modules/Gallery和vendor/mediatek/proprietary/hardware/hwc相关部分;展锐平台的HWC则主要在sprd相关目录,思路大同小异,但细节差异不小。

8.2 建议的动手路线

如果从头开始学,我建议的顺序是:

第一步,先跑通SurfaceFlinger的dumpsys分析,理解合成决策的输出字段。第二步,修改HWC的配置文件,人为让某个图层回退到CLIENT合成,感受合成方式变化带来的性能差异。第三步,在HWC HAL里加日志或trace点,跟踪validate和present的调用细节。第四步,进入DRM驱动层面,尝试调整Plane的数量或者修改CRTC模式,观察显示输出的变化。

这套路线从用户态到内核态,循序渐进。每一步都会加深你对“一帧画面是如何显示的”这个问题的理解。

8.3 我对HWC调试的一点心得

调试显示问题需要同时具备三层知识:Android框架层的合成决策逻辑、HAL层的接口实现、Linux内核DRM的硬件管理能力。多数工程师精通其中一层,遇到问题就容易被卡住。

我自己的经验是,先把dumpsys SurfaceFlinger的输出读透。它能告诉你最终发生了什么,然后顺着线索向下找原因。大多数显示问题,80%的信息都藏在这份dumpsys的Layer列表和Frame统计里,只有少数情况需要深入到驱动层用示波器去测波形。

在真正动手写HWC代码之前,先在现有的HWC实现上做增量修改,会比从零开始更高效。比如尝试调整一个Layer的BlendMode,看看dumpsys里的Comp Type有什么变化。这种小步迭代的方式,能快速建立起对合成器行为的直觉。

这个领域还有一个容易被忽略的点是功耗调优。很多人遇到功耗问题喜欢先查CPU和GPU频率,但真正的问题可能在显示链路——HWC没有生效导致GPU持续高频工作。学会看功耗数据在gfxinfo和battery historian里的表现,再对照合成方式,往往能快速定位问题。

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

主成分得分与因子得分:差异、计算与实战应用解析

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

作者头像 李华
网站建设 2026/10/2 7:45:16

抖音聊天记录解析:安卓逆向中SQLite+SQLCipher实战指南

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

作者头像 李华
网站建设 2026/10/2 7:44:08

SRS编写实战:八章模板、需求追踪与验收标准

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

作者头像 李华
网站建设 2026/10/2 7:43:49

C# Winform搭建工业视觉检测框架:从环境选型到实战踩坑

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

作者头像 李华
网站建设 2026/10/2 7:43:15

LM Studio中文版安装教程:国内镜像源配置与模型导入全攻略

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

作者头像 李华