news 2026/10/13 14:11:42

鸿蒙Unity崩溃排查:用Sentry将堆栈还原到C#行号

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
鸿蒙Unity崩溃排查:用Sentry将堆栈还原到C#行号

接手一个要往鸿蒙设备上发的 Unity 项目时,最头疼的往往不是打包失败,而是打出来的包一崩,日志却完全看不懂。当时我用的是团结引擎,导出鸿蒙工程后真机测试,崩溃时系统日志里只有libil2cpp.so + 0x123ab4这种地址偏移,没有方法名,更没有 C# 行号。拿着一串十六进制偏移,想定位是哪一行写崩的,几乎等于大海捞针。这也是我把 Sentry 接入进来的原因:崩溃上报是一方面,真正的目标是让崩溃堆栈直接还原到 C# 源码行号。这篇文章记录的就是从团结引擎接入 Sentry、通过鸿蒙打包、再把崩溃符号化到 C# 行号的全过程,以及过程中踩过的坑。

1. 不接崩溃监控之前,排查鸿蒙崩溃到底有多痛

1.1 拿到一坨只有地址的堆栈是什么体验

先还原一下没接 Sentry 时我的真实处境。项目跑在鸿蒙开发板上,某个操作会让应用直接闪退。开发板日志接口里能看到崩溃段,但内容大致是这样的:

Fatal signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0 Build fingerprint: '...' Abort message: '...' backtrace: #00 pc 0000000000947fg4 /data/app/.../libil2cpp.so (offset 0x947000) #01 pc 0000000000951b38 /data/app/.../libil2cpp.so (offset 0x951000) #02 pc 0000000000d13d8c /data/app/.../libunity.so (offset 0xd13000)

看到这种堆栈,第一反应是:方法名呢?行号呢?全都没有。libil2cpp.so那么大,偏移量0x947fg4到底对应哪个函数,只能靠猜。更麻烦的是,这个问题在测试机上必现,我在本地复现不了,所以每次都要远程拿一份日志,再手动分析。分析手段也很原始:把这几个偏移记下来,去构建目录里找对应的 .so 文件,然后用 addr2line 手动解析。一次两次还能忍,等到一个版本要联调多天、崩溃率还在涨的时候,手写偏移解析根本扛不住。

这只是“原生层”崩溃。还有一种情况是 C# 层报了NullReferenceException,如果没有做全局异常捕获,Unity 的日志里会打印堆栈,但堆栈可能只有方法名,没有具体行号,因为 IL2CPP 的 Release 构建默认不保留行号映射。运气好的时候,能看到PlayerLogic.ApplyDamage(),运气不好连方法名都截断了。无论是原生崩溃还是托管异常,结论都一样:缺少一套能把“崩溃地址”翻译成“开发者看得懂的源码位置”的机制。

1.2 想要的不是“错误日志”,而是“能直接定位的堆栈”

最开始我也只是想要一个“能把崩溃日志攒起来看的地方”。但用了 Sentry 之后会发现,光有日志聚合远远不够。真正有价值的是它把崩溃事件还原成了这样:

NullReferenceException: Object reference not set to an instance of an object at PlayerLogic.ApplyDamage (System.Int32 amount) [Assets/Scripts/PlayerLogic.cs:88] at BattleController.HandleHit () [Assets/Scripts/BattleController.cs:145]

看到PlayerLogic.cs:88这种格式,业务代码的问题才算真正暴露在阳光下。我能直接打开源码第 88 行,去看那个地方到底哪个对象是空的。而不是对着pc 0000000000947fg4发呆。

Sentry 能提供的不止是堆栈还原。它还能按版本聚合崩溃率,按设备型号、系统版本筛选事件,给崩溃事件自动归类,同一个崩溃问题自动去重合并,还能配合 Release Health 看每个版本的稳定性曲线。也就是说,开发阶段能定位问题,上线之后能监控稳定性,整个链路是完整的。

2. 整体方案选型与接入链路拆解

2.1 为什么选了 Sentry 而不是自建上报

团队其实纠结过要不要自己写一套崩溃上报系统。毕竟鸿蒙是新的目标平台,市面上现成的 Unity 崩溃监控方案大多只支持 Android/iOS,直接硬套到鸿蒙上确实有风险。自己写的方案无非是:App 内捕获崩溃 → 写入本地文件 → 下次启动时上传 → 服务端解析堆栈。这套东西在原型阶段能用,但很快就发现成本会失控。

首先是“捕获”这一步。Unity 应用崩溃类型有好几种:C# 未捕获异常、C++ 层错误、系统信号触发的原生崩溃。每一种都有单独的捕获方式。自建方案意味着这些都要自己处理,处理完了还得保证日志写盘不丢、上传不失败、服务端能自动按版本归类,这些加起来是一个不小的工程,而且做出来的东西大概率没有别人耕耘多年的好用。

Sentry 是现成的开源方案,有官方维护的 Unity SDK,服务端可以私有化部署,也支持直接使用云服务。Unity SDK 本身把托管异常、原生崩溃、日志回调这些都接好了,我只需要配置 DSN 和符号文件。另一个加分项是 Sentry 的符号化引擎非常成熟,它不仅能处理 ELF 文件的 DWARF 调试信息,还能关联源码文件,把堆栈行号映射到前端页面。对 Unity + IL2CPP 这种“托管代码编译成原生代码”的场景,它已经形成了标准做法。

2.2 崩溃上报与符号化还原的完整链路

先把这个方案的整体链路拆清楚,后面看问题会更有方向感。从一次崩溃发生到后台显示 C# 行号,大致经过这几个环节:

  1. 崩溃被 SDK 捕获:Unity 的 C# 未捕获异常由 Sentry SDK 注册的AppDomain.UnhandledException和Application.logMessageReceived接管;原生层崩溃则由系统的信号处理机制接管。
  2. 崩溃信息组装:SDK 收集异常堆栈、设备信息、操作系统版本、应用版本号、当前页面操作等上下文,组装成一条事件。
  3. 事件上报到 Sentry 服务端:上传的内容里除了堆栈文本,还包含当前进程中每个动态库的 build-id(也叫 debug_id),这个 ID 是后面匹配符号文件的关键。
  4. 服务端符号化:Sentry 收到事件后,根据 debug_id 在符号文件库里找到对应构建产物的 .so 符号文件,用崩溃地址减去 .so 加载基址得到偏移量,再通过 DWARF 调试信息把偏移量翻译成“文件名 + 方法名 + 行号”。
  5. 前端展示:如果上传符号时带了源码文件,页面里还能直接看到那一行源码的上下文。

这里有一个核心认知:崩溃地址还原成 C# 行号,并不是 SDK 在设备端完成的,而是服务端拿到崩溃事件后,拿着开发上传的符号文件,在服务端算出来的。也就是说,真正决定符号化成败的,不是 SDK 初始化得怎么样,而是你有没有把正确的符号文件上传干净。这个认知我在后面踩坑时反复验证过。

3. 团结引擎工程里接入 Sentry SDK

3.1 依赖安装与初始化

团结引擎是基于 Unity 的定制发行版,在使用习惯上和其他 Unity 分支没有太大区别。接入 Sentry 时我直接用了官方维护的 Unity SDK,通过 UPM 方式添加到工程依赖里。在Packages/manifest.json中增加一行:

{ "dependencies": { "io.sentry.unity": "2.0.0" } }

如果网络环境不太方便拉取包,也可以直接下载.unitypackage手动导入。导入后 SDK 会在菜单栏生成一个Tools > Sentry菜单,从这里可以查看初始化向导,也能直接配置 DSN。

初始化推荐放在游戏启动后的最早阶段。我是挂了一个空的GameObject,在Awake里调用初始化,也有人在[RuntimeInitializeOnLoadMethod]里做,效果一样。关键是保证初始化发生在第一次抛异常之前。

using Sentry; public class SentryBootstrap : MonoBehaviour { private void Awake() { SentrySdk.Init(options => { options.Dsn = "https://your-public-dsn@sentry.example.com/12345"; options.Release = $"{Application.version}+{Application.buildGUID}"; options.Environment = isProductionBuild ? "production" : "development"; options.TracesSampleRate = 1.0f; }); } }

这个初始化代码是上线版的最终形态,开发期我还加了options.Debug = true来看 SDK 自己的日志,确认上报链路是否正常,确认没问题后才在发布版里关掉。

3.2 关键配置项逐个说明

几个配置项值得花时间理解一下,因为它们直接影响后续符号化和问题定位的效果。

DSN 是 Sentry 项目的唯一上报地址,格式是https://public-key@你的Sentry域名/项目ID。它区分了每个项目的数据归属,配错了会把测试数据打到一个无关项目里。

Release 是最容易忽视但最重要的配置。它标示当前事件属于哪个构建产物,我直接用Application.version + Application.buildGUID拼接,保证每次构建的 Release 字符串都是唯一的。为什么必须唯一?因为后端按 Release 来筛选版本和匹配符号文件上传记录。如果两个不同包用了同一个 Release,崩溃事件会被混到同一个版本下面,符号匹配会非常混乱。

Environment 用来区分开发、测试、生产环境,方便在后台快速过滤。如果只有一个环境,可以省略,但建议保留。

TracesSampleRate 控制性能追踪的采样率。如果项目对性能上报需求不高,可以不关注;但如果你之后想看崩溃率趋势、页面加载耗时,这个参数就很有用。需要注意的是采样率太高会把请求量撑起来,注意别让上报量反过来影响性能。

还有一个容易被忽略的点:如果项目里有敏感数据,要注意SendDefaultPii这类开关。默认情况不要打开,涉及用户信息的部分需要自己做脱敏处理。合规问题是早晚要面对的,不如一开始就收紧。

3.3 先测能不能收到第一条崩溃

接完初始化,第一件事不是去打包,而是先验证这条链路在编辑器或真机上通不通。我习惯用一个临时脚本,绑到一个空物体上,按键触发一次人为异常:

using UnityEngine; public class CrashTrigger : MonoBehaviour { private void Update() { if (Input.GetKeyDown(KeyCode.C)) { throw new System.Exception("test exception from sentry integration"); } } }

在编辑器里跑,按 C,等几秒,打开 Sentry 后台,看Issues列表里有没有出现这条异常。如果没有出现,先排查本地日志。把options.Debug = true打开后,SDK 会在 Unity Console 打印详细的发送结果,比如Sentry: Event was sent successfully或者网络错误码。这里大概率能解决八成接入问题。

这个测试只要在编辑器里通过一次,我才会继续往下做真机鸿蒙打包。因为联网环境、时间同步、HTTP 配置在真机上会有更多变数,提前把基础链路搞稳,后面排错会简单很多。

4. 鸿蒙打包 + C# 行号符号化是这次的核心

4.1 鸿蒙导出工程里先找到自己的 so

团结引擎导出鸿蒙工程的流程我简化一下:在 Build Settings 里切到 HarmonyOS 平台,勾选需要导出的场景,执行 Export Project。导出的工程已经是一个可以被 DevEco Studio 或其他鸿蒙构建工具识别的工程,需要在这个工程里完成签名和最终的 Hap 打包。

鸿蒙真机上跑 Unity 应用,核心二进制是libil2cpp.so和libunity.so,它们会被打进 Hap 里。导出工程后,最快的定位方式是在工程目录里全局搜libil2cpp.so,通常它会在特定路径的libs/arm64-v8a/下。这个 .so 就是运行时真正加载的库,也是崩溃时出现频率最高的动态库。

这里明确一点:崩溃地址出现的libil2cpp.so偏移量,对应的就是这份 .so 文件里的逻辑偏移。所以要还原崩溃堆栈,必须把同一份构建产物的 .so 符号文件交给 Sentry 服务端,而不只是随便传一份相同名字的文件。构建一次,就应该传一次。

4.2 留好带 DWARF 调试信息的符号文件

关键来了。并不是每个 .so 都适合拿来做符号化。鸿蒙打包时,最终产物里的libil2cpp.so很可能已经被剥离了调试信息(stripped),因为调试符号会让包体变大。剥离之后,崩溃地址只能对应到加载地址,而且崩溃 backtrace 里看不到函数名,符号化工作也就无从谈起。

所以构建时要确认一件事:除了给设备用的 stripped 版本 .so,还需要保留一份带 DWARF 调试符号的 .so(通常叫libil2cpp.so.dbg或者在构建目录的symbols目录下)。Unity 构建时如果能产出符号压缩包,直接保留那份也是可以的。

我实际建议在构建脚本里做一步检查:构建完成后,用file命令确认符号文件里确实包含with debug_info字样。例如:

file libil2cpp.so.dbg # 输出里应包含 debug_info 这样的标志

这一步能提前拦住“传了没用的文件”这种坑。很多团队符号化失败,最后查下来都是因为上传的文件是不带调试信息的 stripped 版本,后台能匹配上 debug_id,但里面没有行号信息,显示出来一堆??。

4.3 用 sentry-cli 把符号传上去

Sentry 提供了sentry-cli命令行工具来上传符号文件,鸿蒙构建场景下我选择用它而不是依赖 Unity 编辑器里的自动上传菜单,因为灵活性更高,而且方便 CI 集成。

先安装 CLI 工具,然后用 auth token 登录:

sentry-cli --auth-token YOUR_AUTH_TOKEN \ debug-files upload \ --org my-org \ --project my-project \ --include-sources \ /path/to/symbols

几个参数的作用我说明一下:

  • --org和--project指定上传到 Sentry 的哪个组织、哪个项目,必须和 DSN 里的项目一致。
  • --include-sources会把源码文件一起上传。这样当堆栈被还原到具体行号时,Sentry 页面还能直接展示那一行的源码片段。如果忘了这个参数,行号通常还是能显示,但看不到源码上下文,定位效率会打折扣。
  • 上传的路径可以是包含符号文件的目录,也可以是单个 .so 文件。CLI 会扫描目录里所有包含调试信息的文件并计算 debug_id。

上传完成后,用sentry-cli debug-files list可以确认这个项目下已经有哪些符号文件,以及对应的 debug_id。

我在实际项目里,更习惯把符号上传这一步和构建产物绑定在一起,而不是手动跑命令。因为手动跑容易漏,漏了之后线上崩溃可能就白记录了。

4.4 构建后自动上传的一键脚本

为了解决“每次构建后都手动传符号”的重复劳动,我在项目里放了一个自动上传脚本。流程是这样:

  1. 构建导出鸿蒙工程,保留 Debug Symbols。
  2. 从构建产物中提取带 DWARF 信息的 .so。
  3. 调用sentry-cli debug-files upload上传。
  4. 上传完成后,记录当前 Release 与符号文件的对应关系。

脚本用 bash 写大概是这样:

#!/bin/bash SYMBOL_DIR="./build/harmony/symbols" ORG="my-org" PROJECT="my-project" SENTRY_AUTH_TOKEN="$SENTRY_AUTH_TOKEN" # 只上传包含调试信息的文件 sentry-cli --auth-token "$SENTRY_AUTH_TOKEN" \ debug-files upload \ --org "$ORG" \ --project "$PROJECT" \ --include-sources \ "$SYMBOL_DIR" echo "Symbol upload finished, release check done."

这个脚本我放在本地打包机上,也放到过 CI 流水线里跑。核心原则只有一个:只要产生了新的构建产物,就必须运行一次上传,不管这个包是给 QA 测的,还是给线上用户的。因为崩溃事件一旦在用户手机上发生并上报,回头再补传符号也还有机会,但版本号对不上的话,补传了也匹配不上。

构建脚本和上传脚本分离还有一个好处:如果某次上传失败,构建不会跟着失败,我可以集中处理上传失败的原因,不影响发版节奏。

5. 线上崩溃案例复盘:一列 C# 堆栈解决一个问题

5.1 一次典型的符号化崩溃长什么样

符号化跑通之后,后台看起来完全是另一个世界。有一次线上反馈某个鸿蒙版本崩溃率抬升,我打开 Sentry,看到一条聚合后的崩溃事件:

NullReferenceException: Object reference not set to an instance of an object at GamePlayData.ResetData () [Assets/Scripts/GamePlayData.cs:132] at BattleController.StartBattle () [Assets/Scripts/BattleController.cs:210] at UIButtonHandler.OnClickStart () [Assets/Scripts/UIButtonHandler.cs:45]

列出来的三个文件、三个行号,直接就把崩溃链路指清楚了。我打开GamePlayData.cs第 132 行,发现是把某个玩家存档里的列表直接赋给了战斗数据引用,而存档读取失败时列表是空的,所以ResetData()里对这个列表做遍历时就崩了。前后花了不到十分钟。

换作之前只有地址堆栈的时间点,这个问题的排查路径会是这样:先还原 Native 地址,得到一串 C++ 层面的函数名,再根据函数名反推对应的 C# 方法,最后去看业务代码。运气好可能需要半小时,运气不好还会因为符号缺失直接卡死。而现在 Sentry 的页面里,崩溃问题本身已经按堆栈指纹归好类了,同一类崩溃自动合并,能看到影响用户量、崩溃次数、首次出现时间、最后出现时间,以及崩溃所在的设备分布。

5.2 崩溃率、告警和 Release Health 的配套用法

只把崩溃堆栈还原到行号,算是解决了“定位”问题。但稳定性的核心其实是“发现”问题:这个版本什么时候开始崩的、崩了多少用户、有没有突然恶化。Sentry 的 Release Health 功能会按 Release 维度聚合崩溃率和无崩溃用户占比。

在后台可以看某条 Release 的曲线,比如从某一天开始崩溃率从 0.5% 涨到了 3%,点进去能直接看到是哪一个 Issue 在拉动曲线。我习惯把告警规则配置成:当一个 Issue 在 24 小时内的发生次数超过预设阈值时,通过 webhook 推到即时通讯工具。这样不需要人肉盯后台,崩溃恶化时会第一时间收到通知。

还有一个小技巧:在每次发版之前,先在 Sentry 里创建一个新的 Release,并在 SDK 初始化时统一更新版本号。这样新旧版本的数据在后台是分开的,比较稳定性和回滚决策都更方便。如果版本号语义化不清晰,后台的 Release 列表会非常混乱,团队成员也不确定某个崩溃到底来自哪个包。

6. 常见问题速查与踩坑记录

6.1 几个绕不开的坑

接入过程中遇到最多的问题,基本集中在符号化和上报两条链路上。逐个说。

**第一个坑:后台堆栈全部是??。**这基本可以断定是符号文件没匹配上。排查顺序是:确认崩溃事件的 release 是否和实际构建版本一致;确认该 release 是否上传过符号文件;用sentry-cli debug-files list查看符号文件列表,看里面文件的 debug_id 是否和崩溃事件里的 debug_id 一致。如果 debug_id 不一致,说明这次崩溃对应的包不是上次上传符号时对应的包,需要重新上传当前包的符号。

**第二个坑:方法名能看到,但行号丢失。**这种情况比全是??好一点,说明符号文件本身带函数信息,但行号信息不足。原因通常是构建时裁剪了部分调试信息,或者编译器没有生成源文件与行号的映射关系。解决方法是回到构建步骤,检查-g或debug_info相关的构建选项,确保保留源文件级别的调试信息,并在上传时加上--include-sources。

**第三个坑:Release 重复导致崩溃聚合混乱。**之前项目组有一个旧版本的 Release 写成Application.version这种格式,比如1.0.0。后来同样版本号发了修复包,后台把新旧包崩溃事件混到一起,导致看起来旧版本还在持续崩溃。改成version + buildGUID的组合后,问题立刻消失。所以版本号必须保证唯一,这是一个硬性要求。

**第四个坑:鸿蒙设备上报延迟或丢失。**有的设备会限制后台网络,应用被切后台后连接被挂起。我处理的办法是设置options.SendDefaultPii之外,尽量让 SDK 在崩溃前把旧事件缓存到本地,下次启动再补报。Sentry SDK 默认有缓存机制,但要确认磁盘空间足够,否则缓存写入失败事件就丢了。

**第五个坑:SDK 初始化放在日志回调之后。**Unity 的Application.logMessageReceived非常早就生效,但 Sentry 初始化之前的异常根本不会被拦截。如果初始化代码放在比较晚的生命周期函数里,启动阶段的崩溃就会漏报。我最终把初始化挪到了[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)],这样几乎在游戏启动的最早瞬间就接好了上报。

6.2 直接保存的避坑清单

用表格整理一份常用排查速查,方便之后直接对照:

现象可能原因解决思路
后台堆栈全是??符号文件未上传或 debug_id 不匹配核对 release,重新上传当前包对应符号
方法名存在但行号缺失调试信息被裁剪构建时保留 DWARF 调试信息并开启源代码行号映射
某些版本崩溃聚合混乱Release 字符串不唯一用版本号 + 构建 ID 拼接 Release
崩溃事件上报延迟严重后台网络限制或缓存异常检查缓存配置,确认初始化时机
启动阶段崩溃漏报初始化晚于第一帧改用 BeforeSceneLoad 初始化
上传符号时提示权限不足auth token 权限不够给 token 配置正确的项目权限
源码片段无法展示上传时缺少源码上传命令加--include-sources

还有一条容易被忽略的:上传符号文件和崩溃事件的网络路径如果经过代理或内网转发,要注意时间同步。设备时间和服务端时间差太多,会导致事件的时间戳错乱,后台按时间排序时会非常难用。

另外,团队协作时建议在 README 里写明符号上传命令和 release 命名规范。我自己就吃过“换了台打包机,符号没传,线上崩溃全还原不了”的亏。现在我把上传命令做成了脚本放在仓库里,任何人拉下来跑一遍构建就能自动带上,不用记一堆命令。

最后再补一个经验:别只盯着 C# 异常,原生崩溃符号化同样重要。如果发现反馈里有一条SIGSEGV的原生崩溃,先把对应的.so符号文件找对,再让 Sentry 去还原。很多 Unity 项目在 Android 上能自动符号化,到鸿蒙上容易把这个环节漏掉,漏掉后果就是原生崩溃又变回“只有地址”的状态。只要把 SDK 初始化、release 命名、符号上传这三件事都按文章里这套流程跑通,鸿蒙打包的崩溃排查体验基本可以做到和其他平台一致,再也不用对着pc + 偏移量干瞪眼了。

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

AI基础设施实战:算力调度、数据管理与模型部署全解析

最近这半年,跟做AI应用和平台的同学聊得多了,我最大的感受是:模型算法层面的差距正在快速缩小,真正决定一个团队能跑多快、能撑多稳的,反而变成了底座那部分“不性感”的东西——算力怎么调度、数据怎么管、模型怎么部…

作者头像 李华
网站建设 2026/10/13 14:10:17

云计算双语报告写作指南:术语对照与章节结构全解析

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

作者头像 李华
网站建设 2026/10/13 14:08:46

开源项目如何用吐槽大会精准采集痛点并落地改进?

开源项目圈子里最常被忽略的需求,其实不是新功能,而是“能有人心平气和地把难听话说完”。我参加过不少项目社区的活动,发现一种现象:日常维护靠 issue 和 PR 沟通,大家客客气气,但真正的摩擦、困惑、失望&…

作者头像 李华
网站建设 2026/10/13 14:08:43

LiteLLM:统一LLM调用的API网关与工程实践指南

1. 项目概述:LiteLLM不是“轻量版大模型”,而是统一调用层的工程实践LiteLLM这个词最近在技术社区里出现频率明显升高,但很多人第一眼看到,下意识会把它理解成“Lite LLM”,以为是某种压缩版、蒸馏版或边缘端专用的小…

作者头像 李华
网站建设 2026/10/13 14:08:20

从贝叶斯滤波到卡尔曼滤波:五大公式推导与工程实践

1. 从贝叶斯视角重新认识卡尔曼滤波很多做状态估计的朋友第一次接触卡尔曼滤波,都是从那一堆矩阵公式开始的:预测、更新、卡尔曼增益、协方差矩阵,推导过程翻来覆去,最后代码跑通了,但心里始终有个疙瘩——这些公式到底…

作者头像 李华
网站建设 2026/10/13 14:07:37

Go语言接口核心考点:隐式实现、类型断言与空接口陷阱

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

作者头像 李华