Unity资产引用查看器:原理、实现与性能优化指南
1. 项目概述为什么我们需要一个资产引用查看器在Unity项目开发的日常中尤其是当项目规模逐渐膨胀资源文件数以千计时一个令人头疼的问题会频繁出现你想删除一个看似“无用”的材质球、脚本或预制体但心里总是没底——它到底被哪些场景、预制体或脚本引用着贸然删除轻则导致场景中物体丢失组件变成粉红色重则可能引发运行时错误让整个功能模块崩溃。这种“牵一发而动全身”的依赖关系正是Unity项目资产管理中最核心的挑战。传统的排查方法效率极低要么在Project窗口里右键选择“Find References In Scene”但这只能查找当前打开的场景要么依靠记忆和经验去猜测这在大中型项目中几乎不可能。因此一个能够全局、清晰、可视化地展示资产引用关系的工具就成了提升开发效率、保障项目整洁度的刚需。这就是“Unity资产引用查看器”诞生的背景。它不是一个炫酷的视觉效果插件而是一个实实在在的“工程管理”利器能帮你理清资源之间的千丝万缕让项目结构一目了然。2. 核心功能与工作原理深度解析一个合格的资产引用查看器其核心远不止是“找到谁引用了它”这么简单。我们需要深入理解Unity的资产序列化与引用机制才能明白这类工具是如何工作的以及它能为我们做到什么程度。2.1 Unity的资产引用机制在Unity中资产Asset之间的引用关系是通过一个唯一的全局标识符——GUID全局唯一标识符和Local ID局部标识符来建立的。当你将一个材质球拖拽到一个模型的材质槽或者在脚本中定义一个public GameObject prefab并赋值时Unity在序列化这个关系时记录的并不是文件路径而是这个被引用资产的GUID和它在文件内部的Local ID。GUID每个.asset、.prefab、.mat等资产文件都有一个唯一的GUID存储在相同目录下的.meta文件中。这是跨项目、跨机器识别资产的核心。Local ID用于标识资产文件内部的对象比如一个Prefab文件里可能包含多个GameObject和组件每个都有其Local ID。引用查看器的工作原理就是扫描整个项目Assets文件夹下的所有文件解析它们的序列化数据通常是YAML格式的文本提取出其中包含的所有GUID和Local ID从而构建出一张庞大的“资产引用关系图”。2.2 一个完整查看器应具备的核心功能基于上述原理一个功能全面的资产引用查看器应该提供以下核心功能双向引用查询被谁引用Incoming References选择一个资产列出所有引用了它的场景、预制体、脚本化对象、甚至其他资源如材质被模型引用。这是最常用的功能用于安全删除。引用了谁Outgoing References选择一个资产列出它自身所引用的所有其他资产。例如查看一个复杂的UI预制体都用了哪些图片、字体、材质和脚本便于进行依赖分析和打包优化。可视化关系图这是高级功能。将引用关系以节点图的形式展示出来中心是你选中的资产箭头指向表示引用方向。这种可视化方式对于理解复杂模块间的依赖、发现循环引用A引用BB又引用A特别有效。循环引用可能导致资源无法正常卸载是内存泄漏的潜在元凶。搜索与过滤支持按资产类型Texture, Material, Prefab, Scene, Script等、按名称关键字进行过滤。在拥有上万资产的项目中快速定位是关键。批量操作与影响分析选择多个资产批量分析它们的引用关系。并且在计划删除或移动资产前工具应能给出一个“影响报告”预估哪些场景和预制体会受到影响让你做出更明智的决策。集成与性能优秀的查看器应该能无缝集成到Unity编辑器界面中如作为一个独立的编辑器窗口并且扫描和分析速度要快。它需要智能缓存机制避免每次打开都全盘扫描只在资产发生变更后更新相关部分的引用信息。3. 实操不依赖商店插件手动构建简易引用查看器虽然Unity Asset Store上有像“Reference Viewer”这样的成熟付费插件售价约$14.99但理解其原理后我们完全可以利用Unity Editor Scripting API自己动手打造一个满足基本需求的简易版本。这不仅零成本更能加深你对Unity底层机制的理解。3.1 核心APIAssetDatabase与Dependency相关接口Unity Editor API为我们提供了强大的资产数据库查询功能这是我们实现查看器的基础。AssetDatabase.GUIDToAssetPath(string guid)将GUID转换为我们在Project窗口中看到的实际路径。AssetDatabase.AssetPathToGUID(string path)反向操作将路径转换为GUID。AssetDatabase.GetDependencies(string pathName, bool recursive)这是最关键的函数。它返回给定资产路径所依赖的所有资产的路径列表。recursive参数为true时会递归查找所有间接依赖。注意GetDependencies获取的是“引用了谁”Outgoing References。要得到“被谁引用”Incoming References我们需要遍历所有资产检查它们的依赖列表中是否包含目标资产。3.2 分步实现一个简易查看器窗口我们来创建一个基本的编辑器窗口实现“查找选中资产被谁引用”的功能。步骤1创建编辑器脚本在项目的Assets/Editor文件夹下如果没有就新建一个创建一个C#脚本命名为SimpleReferenceViewer.cs。步骤2编写窗口类using UnityEngine; using UnityEditor; using System.Collections.Generic; using System.Linq; public class SimpleReferenceViewer : EditorWindow { // 存储当前选中的资产路径 private string selectedAssetPath ; // 存储引用该资产的路径列表 private Liststring referencingPaths new Liststring(); // 滚动视图位置 private Vector2 scrollPos; // 添加菜单项 [MenuItem(Tools/资产引用查看器)] static void Init() { var window GetWindowSimpleReferenceViewer(); window.titleContent new GUIContent(引用查看器); window.Show(); } void OnGUI() { GUILayout.Label(简易资产引用查看器, EditorStyles.boldLabel); EditorGUILayout.Space(); // 1. 显示当前Project窗口选中的资产 var selectedObj Selection.activeObject; if (selectedObj ! null AssetDatabase.Contains(selectedObj)) { var newPath AssetDatabase.GetAssetPath(selectedObj); if (newPath ! selectedAssetPath) { selectedAssetPath newPath; FindReferences(); // 选中新资产时重新查找 } EditorGUILayout.LabelField(当前选中:, selectedAssetPath); } else { selectedAssetPath ; EditorGUILayout.HelpBox(请在Project窗口中选择一个资产非场景中的对象。, MessageType.Info); } EditorGUILayout.Space(); // 2. 手动刷新按钮 if (GUILayout.Button(刷新引用关系, GUILayout.Width(120))) { if (!string.IsNullOrEmpty(selectedAssetPath)) { FindReferences(); } } EditorGUILayout.Space(); // 3. 显示引用结果 GUILayout.Label($引用此资产的资源 ({referencingPaths.Count} 个):, EditorStyles.boldLabel); if (string.IsNullOrEmpty(selectedAssetPath)) { EditorGUILayout.HelpBox(未选择有效资产。, MessageType.Warning); return; } if (referencingPaths.Count 0) { EditorGUILayout.HelpBox(未找到任何显式引用。该资产可能未被使用或仅被场景中的动态加载如Resources.Load引用。, MessageType.Info); } else { scrollPos EditorGUILayout.BeginScrollView(scrollPos); foreach (var path in referencingPaths) { EditorGUILayout.BeginHorizontal(); // 显示资产路径并可以点击ping到该资产 if (GUILayout.Button(path, EditorStyles.label)) { var asset AssetDatabase.LoadAssetAtPathObject(path); EditorGUIUtility.PingObject(asset); } EditorGUILayout.EndHorizontal(); } EditorGUILayout.EndScrollView(); } } // 核心查找函数 private void FindReferences() { referencingPaths.Clear(); if (string.IsNullOrEmpty(selectedAssetPath)) return; // 获取选中资产的GUID string targetGUID AssetDatabase.AssetPathToGUID(selectedAssetPath); if (string.IsNullOrEmpty(targetGUID)) return; // 遍历Assets目录下所有资产排除.meta文件 string[] allAssetPaths AssetDatabase.GetAllAssetPaths(); int total allAssetPaths.Length; for (int i 0; i total; i) { string assetPath allAssetPaths[i]; // 只处理Assets目录下的有效资产文件 if (!assetPath.StartsWith(Assets/) || assetPath.EndsWith(.meta)) continue; // 显示进度条对于大项目很重要 if (EditorUtility.DisplayCancelableProgressBar(扫描引用, $正在分析: {assetPath}, (float)i / total)) { EditorUtility.ClearProgressBar(); Debug.LogWarning(用户取消了扫描。); return; } // 获取当前资产的依赖项 string[] dependencies AssetDatabase.GetDependencies(assetPath, false); // 不递归提高速度 foreach (string dependency in dependencies) { if (AssetDatabase.AssetPathToGUID(dependency) targetGUID) { referencingPaths.Add(assetPath); break; // 找到引用即可跳出当前资产的依赖循环 } } } EditorUtility.ClearProgressBar(); Repaint(); // 刷新窗口UI } // 当Project窗口选择变化时触发 void OnSelectionChange() { Repaint(); // 触发OnGUI重绘从而更新选中路径并可能触发FindReferences } }步骤3使用与解读在Unity编辑器中点击顶部菜单栏的Tools - 资产引用查看器打开我们创建的窗口。在Project窗口中选择任意一个资产例如一个材质球MyMat.mat。查看器窗口会自动显示该资产的路径并开始扫描。扫描完成后会在下方列表显示所有直接引用了该资产的资源路径。点击列表中的路径可以在Project窗口中快速定位Ping到该资源。注意这个简易版本有几个重要限制。第一它只查找直接引用GetDependencies(..., false)对于间接引用如A引用BB引用C想找谁引用了C需要递归查找计算量会剧增。第二它无法检测通过Resources.Load或Addressables等运行时动态加载方式建立的“软”引用。第三性能上全盘扫描对于大型项目数万资产可能较慢需要优化为增量式或缓存式扫描。4. 高级技巧与性能优化实战当你理解了基础原理并实现了简易版本后下一步就是打造一个更实用、更高效的工具。以下是几个关键的高级实现思路和优化技巧。4.1 实现递归依赖与反向索引我们的简易版只做了“被谁引用”的查找且是遍历全盘。更高效的做法是构建一个全局的“反向索引字典”。思路是一次性或增量式扫描所有资产建立一个Dictionarystring, Liststring其中Key是依赖项被引用资产的GUIDValue是所有引用它的资产GUID列表。// 伪代码示例构建反向索引 Dictionarystring, HashSetstring referenceMap new Dictionarystring, HashSetstring(); void BuildReverseReferenceMap() { string[] allAssets AssetDatabase.GetAllAssetPaths().Where(p p.StartsWith(Assets/) !p.EndsWith(.meta)).ToArray(); foreach (var assetPath in allAssets) { string assetGUID AssetDatabase.AssetPathToGUID(assetPath); string[] dependencies AssetDatabase.GetDependencies(assetPath, true); // 递归获取所有依赖 foreach (var depPath in dependencies) { if (depPath assetPath) continue; // 排除自身依赖 string depGUID AssetDatabase.AssetPathToGUID(depPath); if (!referenceMap.ContainsKey(depGUID)) referenceMap[depGUID] new HashSetstring(); referenceMap[depGUID].Add(assetGUID); } } } // 查询时只需从referenceMap[targetGUID]中取数据速度是O(1)构建这个索引的初始成本较高但一旦建立查询就是瞬间完成的。你可以在编辑器空闲时使用EditorApplication.update监听或资产导入后使用AssetPostprocessor增量更新这个索引。4.2 处理特殊引用类型脚本中的公开变量引用AssetDatabase.GetDependencies可以捕获在Inspector中序列化的引用。但如果引用是在脚本代码中通过字符串路径硬编码的静态分析几乎无法捕获。这需要代码分析工具配合。Resources与Addressables这些是运行时动态加载其引用关系存储在配置文件中如resources.assets文件、Addressables Groups配置。查看器需要额外解析这些配置文件才能构建完整的依赖图。对于Addressables可以使用AddressableAssetSettings相关的API来查询。Shader与Shader变体材质对Shader的引用是直接的但Shader本身可能包含很多变体Keywords这些变体依赖会影响打包后的大小。更专业的工具会深入分析Shader的变体使用情况。4.3 性能优化要点异步与进度显示全量扫描必须放在后台线程或使用EditorUtility.DisplayProgressBar并提供取消选项防止编辑器卡死。缓存机制将构建好的引用关系图序列化到磁盘如JSON格式存储在Library文件夹下。每次启动编辑器或资产变更时只更新变化的部分。增量更新利用AssetPostprocessor的OnPostprocessAllAssets回调在资产被导入、移动、删除时只更新反向索引中受影响的部分而不是全量重建。延迟加载与分页当引用结果成百上千时不要在UI中一次性全部渲染。使用GUILayout.BeginScrollView和分页逻辑只渲染可视区域内的项。忽略特定文件夹通常不需要扫描Plugins、StreamingAssets或外部导入的包Package内的资产。在扫描时过滤掉这些路径可以大幅提升速度。5. 常见问题排查与工具选择建议在实际使用引用查看器或自研工具的过程中你可能会遇到一些典型问题。5.1 常见问题速查表问题现象可能原因解决方案工具显示“无引用”但删除资产后游戏出错。1.动态加载引用资源被Resources.Load或Addressables.LoadAssetAsync通过字符串路径加载。2.脚本硬编码资源路径被硬编码在脚本里。3.间接依赖工具只扫描了直接依赖而该资源被一个不直接引用它的脚本通过其他方式使用。1. 检查项目中的Resources文件夹和Addressables配置。2. 在代码中全局搜索该资源的文件名或GUID。3. 确保查看器开启了递归依赖查找。扫描速度极慢编辑器无响应。项目资产数量过多1万且工具采用每次查询全盘扫描的策略。1. 使用或开发具备反向索引缓存功能的工具。2. 扫描时过滤掉无需检查的文件夹如第三方SDK。3. 将扫描操作设置为手动触发并在夜间或休息时进行。引用关系图中出现“幽灵引用”已删除的资源仍被显示。工具的引用缓存未及时更新。手动清理工具的缓存文件或触发一次完整的重建索引操作。无法检测到对ScriptableObject实例的引用。如果ScriptableObject实例是作为资产文件.asset存在常规扫描可以检测。如果它是某个预制体或场景中组件的一个序列化字段且该字段类型是基类ScriptableObject而实际赋值是其子类实例有时扫描可能会遗漏。使用更底层的序列化对象遍历API如SerializedObject进行深度扫描。工具报错“GUID not found”。资产的.meta文件丢失或损坏导致GUID失效。在Project窗口选中父文件夹右键选择“Reimport”。如果不行可能需要从版本控制中恢复.meta文件。5.2 自研 vs 使用商店插件如何选择选择自研的情况学习与研究想深入理解Unity资产管理和序列化机制。高度定制需求项目有特殊的资源管理流程如自研的配置系统、打包系统需要查看器与之深度集成。成本敏感团队规模小预算有限且基础功能已能满足80%的需求。挑战与收获你需要处理性能、缓存、UI友好度等一系列工程问题这是一个很好的锻炼机会。选择商店插件如Reference Viewer的情况追求效率与稳定需要立即投入生产使用没有时间从头开发和调试。需要高级功能如强大的可视化图表、循环引用检测、批量重构建议、与版本控制系统集成等。大型团队协作付费插件通常有更好的技术支持、文档和持续更新能降低团队的学习和维护成本。综合考量14.99美元的价格相比于程序员数小时的开发调试时间往往是非常划算的投资。我个人在中小型项目的早期倾向于自研一个简易版因为它轻量、可控并且能精准匹配项目初期的特定需求。但当项目进入中后期资产量巨大、依赖关系复杂时我会毫不犹豫地引入或推荐团队使用经过市场验证的成熟插件把精力集中在游戏内容开发本身而不是重复造轮子。工具的最终目的是提升生产力选择那条能让你更快、更稳抵达终点的路。

相关新闻

Burp Suite Intruder实战:Web弱口令字典爆破的原理、配置与高级绕过技巧

Burp Suite Intruder实战:Web弱口令字典爆破的原理、配置与高级绕过技巧

1. 项目概述:从“弱口令”到“字典爆破”的实战路径在安全测试的日常工作中,弱口令问题就像房间里的大象,人人都知道它存在,但往往因为其看似“简单”而被轻视。我见过太多因为一个默认的admin/admin、root/123456就让整个系统门户…

2026/10/14 8:01:12 阅读更多 →
C++ Lambda表达式底层原理与性能优化实战指南

C++ Lambda表达式底层原理与性能优化实战指南

1. 项目概述:为什么我们需要深挖Lambda的内部实现? 如果你写过现代C,尤其是C11之后的代码,那对Lambda表达式肯定不陌生。它就像一把瑞士军刀,随手就能定义一个匿名函数对象,用在 std::sort 、 std::for_…

2026/10/13 1:49:49 阅读更多 →
Qt模型/视图架构实战:构建高效数据可视化界面的核心原理与应用

Qt模型/视图架构实战:构建高效数据可视化界面的核心原理与应用

1. 项目概述:为什么需要模型/视图架构?在开发桌面应用,尤其是需要展示和管理复杂数据的应用时,我们经常会遇到一个核心矛盾:数据本身是结构化的,但展示数据的界面(UI)却千变万化。比…

2026/10/13 1:49:49 阅读更多 →

最新新闻

Spring Security实战:认证授权、过滤器链与SpringBoot集成全解析

Spring Security实战:认证授权、过滤器链与SpringBoot集成全解析

Spring Security这个名字,凡是用Java做过后端Web项目的朋友,基本都会在某一天撞上它。我印象很深,第一次接手一个带有登录权限的旧项目时,打开配置类看到一长串security的配置,先是懵了,再是改一个小需求就…

2026/10/15 0:29:25 阅读更多 →
Qt QSS属性选择器组合用法:多条件精准匹配与动态主题化实践

Qt QSS属性选择器组合用法:多条件精准匹配与动态主题化实践

做过几个上了量的 Qt 项目之后,最让人头疼的往往不是业务逻辑,而是界面状态怎么管。按钮要分主次、标签要分轻重、卡片要根据不同业务状态变色,如果这些全靠 if-else 挂 setStyleSheet,代码库迟早变成一团乱麻。QSS 里的属性选择器…

2026/10/15 0:29:25 阅读更多 →
OpenCL加速点云压缩:八叉树与属性预测的GPU并行实践

OpenCL加速点云压缩:八叉树与属性预测的GPU并行实践

1. 点云数据为什么需要压缩:从传感器输出到存储瓶颈点云这东西,第一次接触的人往往会被它的数据量吓一跳。一台中端激光雷达单帧就能吐出几十万个三维坐标点,如果按每秒10帧的采集频率持续工作,一分钟就是几千万个点。每个点除了X…

2026/10/15 0:29:25 阅读更多 →
TreeMap源码深度解析:红黑树在Java中的工业级实践

TreeMap源码深度解析:红黑树在Java中的工业级实践

1. 为什么TreeMap不是“带排序的HashMap”,而是一个被严重低估的平衡树实践课很多人第一次接触TreeMap,是在面试题里看到“HashMap和TreeMap的区别”——然后脱口而出:“TreeMap是有序的,HashMap是无序的”。这话没错,…

2026/10/15 0:29:25 阅读更多 →
CleanCode标准融入AI编程:“生成即规范”的代码生成器实践指南

CleanCode标准融入AI编程:“生成即规范”的代码生成器实践指南

我做了七年多一线开发,大半时间都在跟两类东西搏斗:一类是屎山一样的存量代码,另一类是人手不够时咬牙快速堆出来的新功能。后来开始认真用AI编程辅助干活,发现一个很尴尬的事实——AI确实能快,但生成的代码风格千奇百…

2026/10/15 0:28:21 阅读更多 →
ibm-watson 4.2.1源码包安装、语音识别调用与迁移避坑指南

ibm-watson 4.2.1源码包安装、语音识别调用与迁移避坑指南

简介:IBM Watson 4.2.1 的 Python SDK 压缩包,面向需要在 Python 项目中快速接入 Watson 人工智能服务的开发者,覆盖自然语言理解、语音转写、语音合成、机器翻译、智能对话等常用功能。压缩包内共 59 个文件,以 46 个 Python 源文…

2026/10/15 0:28:21 阅读更多 →

日新闻

Docker Compose 容器化部署 ChirpStack LoRaWAN 网络服务器实战

Docker Compose 容器化部署 ChirpStack LoRaWAN 网络服务器实战

1. 为什么选择容器化方式部署 LoRa 网络服务LoRa 物联网项目落地时,网络服务器是整个链路的核心枢纽。它负责接收网关上报的数据包、完成去重与解密、再把有效载荷分发给上层应用。早期大家习惯把网络服务器直接装在物理机上,依赖一堆系统库和特定版本的…

2026/10/15 0:00:46 阅读更多 →
共享LLM服务下的跨模型自动扩缩容:Kubernetes实战与避坑指南

共享LLM服务下的跨模型自动扩缩容:Kubernetes实战与避坑指南

1. 共享推理服务里,扩缩容到底难在哪做过大模型推理服务部署的人,大概都经历过这样的场景:一个团队共用一套推理集群,A 业务跑的是 7B 的小模型,B 业务跑的是 72B 的大模型,C 业务白天流量高、晚上几乎归零…

2026/10/15 0:00:46 阅读更多 →
Systemd 启动阶段根文件系统只读之谜:fstab 与 remount-fs 服务深度解析

Systemd 启动阶段根文件系统只读之谜:fstab 与 remount-fs 服务深度解析

1. 引子:一次内核恐慌引发的追查先从一个真实场景说起。某次我给一台老旧的嵌入式设备折腾系统裁减,为了把启动时间压进两秒以内,我手贱地把一串自认为“没用”的 systemd 服务给 disable 了,其中就包括systemd-remount-fs.servic…

2026/10/15 0:00:46 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/14 3:30:28 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/14 16:30:29 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/14 3:29:58 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

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

2026/10/15 0:14:36 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

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

2026/10/14 22:45:47 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

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

2026/10/14 7:53:34 阅读更多 →