news 2026/10/2 18:35:11

sonar_analysis鸿蒙适配:从通道改造到全栈质量门禁实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
sonar_analysis鸿蒙适配:从通道改造到全栈质量门禁实战

有段时间我在做 Flutter 项目的代码质量门禁时,发现sonar_analysis这个三方库在 iOS 和 Android 上表现一直很稳定,但一放到鸿蒙(HarmonyOS/OpenHarmony)环境里,整个通道直接就哑火了。翻了一圈社区资料,相关的适配文档基本都停留在“理论可行”的阶段,真正能把代码审计、质量指标采集和门禁闭环跑起来的实战案例非常少。这篇文章就是一次完整的鸿蒙适配过程记录,我会把sonar_analysis从插件工程改造、ArkTS 通道实现到对接 sonar-scanner 的完整链路讲清楚,适合正在做 Flutter 鸿蒙化、或者准备把现有 Flutter 三方库移植到鸿蒙系统的读者参考。

我这里说的“全栈质量守卫”,不是简单地把代码扫描结果展示出来,而是让sonar_analysis在鸿蒙端也能完成三个层面的任务:第一,采集 Flutter 侧的 Dart/TS 静态扫描结果;第二,通过原生通道读取鸿蒙工程产物和 HAP 包内的可分析文件;第三,把分析数据汇聚到 SonarQube 服务端,形成统一的代码质量门禁。这套东西跑通之后,鸿蒙应用和 Flutter 应用在同一个质量体系下被审查,才是真正的“全栈”。

1. 为什么要给 sonar_analysis 做鸿蒙适配,而不是另起炉灶

1.1 sonar_analysis 到底解决了什么问题

sonar_analysis不是一个简单的“报错插件”,它的定位是 Flutter 侧的代码审计客户端。在 Android 和 iOS 上,它通常负责三件事:收集 Dart 代码的静态分析指标(复杂度、重复率、代码行数)、触发本地声纳扫描器(sonar-scanner)进行分析、然后通过通道把分析结果推给上层应用或后端服务。

听起来功能不复杂,但它所在的生态位置很特殊。很多中大型 Flutter 项目并不是只有 Flutter 一门语言,工程里混杂着原生代码、ArkTS(鸿蒙侧)、Kotlin/Swift(双端原生)。如果只用原生 SonarQube 插件分析,Flutter 层的 Dart 代码基本处于“裸奔”状态。而sonar_analysis这种插件能充当桥梁,让 Flutter 层的质量数据也进入同一个 SonarQube 项目。

鸿蒙适配的难点不在于插件复杂度高,而在于它的通信链路完全依赖 Flutter 与原生平台之间的通道机制。通道一端是 Dart,另一端是平台实现,鸿蒙没有 Android 的PluginRegistry,也不能直接用 iOS 的FlutterPlugin协议,需要重新在 ArkTS 侧实现一套等价的通道逻辑。

1.2 所谓“全栈质量守卫”具体指什么

“全栈”这个词在质量领域经常被滥用,但放在这个场景里是有明确含义的。一个鸿蒙应用从源码到可交付的 HAP 包,中间要经历 Dart 层编写、ArkTS 层编写、资源编译、依赖打包等多个阶段。质量守卫需要覆盖到每个阶段产生的可分析产物:

  • Dart 源码:使用flutter analyze和自定义 linter 规则扫描。
  • ArkTS 源码:使用鸿蒙侧的代码检查工具和 SonarQube 的 ArkTS 规则插件。
  • 构建产物 HAP:解包后检查 so 库、资源文件、配置文件是否存在安全隐患。
  • 运行时行为:通过日志、性能指标回传,发现内存泄漏、异常调用等问题。

sonar_analysis在 Android/iOS 上已经跑通了这套流程,鸿蒙适配要做的不是重新发明轮子,而是把其中依赖平台能力的部分(文件访问、进程调用、数据通道)迁移到鸿蒙的 ArkTS 环境中。

1.3 鸿蒙适配和普通 Android 移植的差异

很多人第一个念头是“鸿蒙不是兼容 APK 吗?直接把 Android 插件拿过来跑不就行了?”这个想法在早期兼容 Android 的版本里有一定道理,但在面向鸿蒙原生应用开发时,插件必须走 ArkTS 的通道实现,和 Android 插件完全是两套东西。

具体差异体现在三个地方。第一,插件发现机制不同,Flutter 引擎在鸿蒙上会寻找ohos目录下的原生插件入口,而不是 Android 的android目录;第二,通道的值编码和平台侧 API 名称有差异,MethodChannel在 ArkTS 侧要使用兼容层的MethodChannel实现,不能直接照搬 Android 代码;第三,权限模型不同,鸿蒙的沙箱、权限声明方式和 Android 截然不同,sonar_analysis要读取 HAP 文件、扫描目录,就必须重新申请权限。

2. 适配前必须吃透的通道模型与工程骨架

2.1 sonar_analysis 依赖的三类通道

sonar_analysis不是只用一种通道,而是根据业务场景混合使用。理解这一点,是适配成功的前提。它通常依赖三类 Flutter 通道:

一是MethodChannel,用于方法调用,比如“启动一次扫描”、“获取当前工程路径”、“取消任务”。这类通道是双向的,Dart 侧发起调用,原生侧返回结果,适合一次性的请求-响应模式。

二是EventChannel,用于持续事件流,比如扫描进度、实时日志、文件遍历状态等。这类通道是单向的,原生侧主动向 Dart 侧推送事件,Dart 侧只负责监听。sonar_analysis中告警信息的分批到达就是通过它实现的。

三是BasicMessageChannel,用于双向的低层消息通信,通常在传递大量自定义数据结构时使用,比如扫描结果的 JSON 序列化大对象。这类通道不会在本次适配中重点展开,但工程里也会用到。

2.2 鸿蒙 Flutter 引擎下的通道兼容层

鸿蒙的 Flutter SDK(俗称 flax 或 flutter-flutter 的鸿蒙版)在设计时保留了和官方 Flutter 一致的通道 API。也就是说,Dart 侧代码完全不用为鸿蒙单独写一套,真正要适配的是原生侧。

ArkTS 侧实现通道的姿势大概是下面这样:在鸿蒙工程中创建一个继承自Plugin的类,实现对应的方法,然后用引擎内部的PluginRegistry注册。和 Android 的MethodCallHandler相比,ArkTS 侧多了Result的异步回调方式,稍不留神就会写出无法返回结果的空通道代码。

我在实际适配中经常遇到一个现象:Dart 侧invokeMethod一直等不到回调,但插件在应用启动时明明已经被加载了。原因多半是 ArkTS 侧的消息编解码器和 Dart 侧不一致,鸿蒙版本的引擎虽然兼容通道,但值类型的转换和标准 Flutter 引擎仍有细微差别。

2.3 工程骨架:在 pubspec.yaml 里为鸿蒙开一扇门

sonar_analysis的鸿蒙适配,第一步不是写 ArkTS 代码,而是先在插件工程里把鸿蒙平台声明出来。Flutter 插件在发布时,需要通过pubspec.yaml声明各平台的原生入口。

对鸿蒙来说,核心就是在插件声明里增加ohos的pluginClass。示例配置大致长这样:

flutter: plugin: platforms: android: package: com.example.sonar_analysis pluginClass: SonarAnalysisPlugin ios: pluginClass: SonarAnalysisPlugin ohos: pluginClass: SonarAnalysisPlugin dartPluginClass: SonarAnalysisFlutterPlugin

这里有两个细节要注意。第一,dartPluginClass是可选字段,但如果你希望在 Dart 侧统一处理原生通道注册顺序,建议配置上,这样可以让插件在 Dart 层初始化时自动注册通道,避免偶发的时序问题;第二,ohos平台声明后,插件工程根目录下必须存在ohos目录,内部要符合鸿蒙模块的标准结构,否则 Flutter 工具链在编译时会直接跳过这个平台,而且不会报明显的错误,表现得像“适配没做一样”。

3. ArkTS 侧通道落地:从空 channel 到真实审计数据

3.1 先搭 MethodChannel:把“扫描一次”变成一次方法调用

sonar_analysis最核心的方法调用是startAnalysis。在 ArkTS 侧实现时,我会在插件类里定义一个方法处理器,用MethodChannel接收来自 Flutter 侧的调用。下面是一段简化后的 ArkTS 实现示意:

import { MethodChannel, MethodCall, Plugin } from '@ohos/flutter_ohos'; export default class SonarAnalysisPlugin implements Plugin { private channel: MethodChannel = new MethodChannel('sonar_analysis/methods'); onAttachedToEngine(binding: any): void { this.channel.setMethodCallHandler(this.handleMethodCall.bind(this)); } private async handleMethodCall(call: MethodCall, result: any): Promise<void> { switch (call.method) { case 'startAnalysis': const path: string = call.arguments['path'] as string; try { const summary = await this.runAnalysis(path); result.success(summary); } catch (e) { result.error('ANALYSIS_FAILED', JSON.stringify(e), null); } break; default: result.notImplemented(); } } private async runAnalysis(projectPath: string): Promise<object> { // 调用鸿蒙侧代码检查服务,这里根据实际能力扩展 return { files: 1024, warnings: 13, defects: 2 }; } }

这段代码虽然简单,但能带你跑通第一个链路。关键的几个点:MethodChannel的名字必须和 Dart 侧完全一致,否则 Dart 会报MissingPluginException;call.arguments在鸿蒙侧可能被解码成Map<string, Object>,所以在取值前最好做一个类型守卫;result是异步回调,如果你在runAnalysis里调用耗时任务,千万不要把result.success放到同步代码后面,否则一旦任务长时间未返回,Dart 侧会超时。

3.2 用 EventChannel 把扫描进度和告警推成流

代码审计是一个耗时操作,用户肯定不希望等所有分析完成才看到结果。sonar_analysis在设计上会把扫描进度、命中的规则、实时日志通过EventChannel分批推送。鸿蒙侧的实现要比 MethodChannel 稍微复杂一点,因为你需要实现一个事件流对象。

import { EventChannel, EventSink } from '@ohos/flutter_ohos'; export default class AnalysisEventPlugin { private eventChannel: EventChannel = new EventChannel('sonar_analysis/events'); private sink: EventSink | null = null; constructor() { this.eventChannel.setStreamHandler({ onListen: (args: any, events: EventSink) => { this.sink = events; }, onCancel: (args: any) => { this.sink = null; } }); } pushProgress(percent: number, message: string): void { if (this.sink) { this.sink.success({ percent: percent, message: message }); } } pushWarning(defect: object): void { if (this.sink) { this.sink.success(defect); } } pushError(error: Error): void { if (this.sink) { this.sink.error('SCAN_ERROR', error.message); } } }

这里最容易翻车的点在于EventSink的生命周期。如果 Dart 侧的EventChannel.receiveBroadcastStream没有被激活,onListen就不会触发,你在 ArkTS 侧拼命推数据是没用的,数据全部被引擎丢弃。所以我在适配时会在 Dart 侧做一次“冷启动探测”,先让事件流处于监听状态,再触发扫描任务,保证进度推送不会丢失。

3.3 沙箱与权限:能让分析器读到 HAP 才算第一步

sonar_analysis到鸿蒙之后要做的第一件实际工作,是找到 HAP 包的位置并读取出可分析内容。鸿蒙应用的沙箱机制比 Android 更严格,普通应用能读到的路径只有自己的沙箱目录和公共媒体目录。

我一开始最天真的做法是直接读取应用安装路径下的 HAP,结果发现代码审计进程根本没有权限访问安装目录。正确的做法是:通过应用上下文获取到 bundle 信息,把需要分析的产物复制到沙箱的 cache 目录,然后让分析逻辑在沙箱内完成读取。

权限部分的 ArkTS 声明也只能落在两处:一是module.json5中声明相关权限,比如读取存储的权限;二是运行时判断权限是否已授权。有一点需要特别提醒:如果你只是分析 HAP 内的静态资源,有时候并不需要存储权限,因为你可以直接通过BundleManager拿到资源的绝对路径,但不一定能直接打开文件流,还是要靠沙箱中转。

3.4 线程模型:别让扫描任务堵住 UI

ArkTS 侧实现代码审计时,最危险的做法是直接在方法调用线程里执行文件遍历、zip 解包、规则匹配。这些操作动辄几百毫秒到几秒,一旦跑在 UI 线程,用户就会明显感到卡顿,严重时会被系统判定为应用无响应。

我建议的做法是:把分析任务放到 WorkScheduler 或者自建的 worker 线程中去执行。下面这个思路在鸿蒙上是可行的:先构建一个任务队列,把startAnalysis请求封装成任务,然后利用异步回调把结果传递回通道。ArkTS 的async/await并不自动切换线程,所以如果你在插件方法里写了await this.unzip(),它依然可能阻塞调用线程。

实测下来,最稳的方案是在Ability侧启动一个独立线程来处理扫描,或者用鸿蒙提供的任务分发机制。这里不能为了图省事而省略线程设计,否则线上环境一旦在高频上报日志时触发通道拥堵,整个质量守卫链路都会反噬业务。

4. 打通代码审计链路:把 sonar_analysis 与 sonar-scanner 接起来

4.1 拿到可分析的构建产物

sonar_analysis在 Flutter 侧的工作本质上只是“数据采集和质量指标托管”,真正完成代码审计的是 sonar-scanner。鸿蒙适配的第二步,是把 Flutter 构建产物和鸿蒙构建产物都整理成 sonar-scanner 能识别的目录结构。

Flutter 产物通常位于build/app/outputs/flutter-apk或鸿蒙引擎对应的输出目录中,鸿蒙的 HAP 产物则位于entry/build/default/outputs/。要让 sonar-scanner 同时分析 Dart 和 ArkTS,我需要把两种产物映射到一个临时目录,或者直接用多个sonar.sources指定。

4.2 sonar-project.properties 关键配置

SonarQube 的项目配置是整个质量守卫的“指挥中心”。结合鸿蒙项目的特殊性,我整理了一份通常可以跑通的配置模板:

sonar.projectKey=com.example.harmony_quality sonar.projectName=Harmony Flutter Quality Guard sonar.projectVersion=1.0.0 sonar.sources=lib,entry/src/main/ets sonar.exclusions=**/*.g.dart,**/generated/** sonar.sourceEncoding=UTF-8 sonar.language=multi sonar.dart.analysisTimeout=120 sonar.analysis.mode=publish sonar.host.url=https://sonar.internal.example.com sonar.login=${SONAR_TOKEN}

这里几个字段值得展开说一下。sonar.sources必须同时包含 Flutter 的lib目录和鸿蒙的ets目录,否则审计范围就会偏科,达不到“全栈”的目的;sonar.language=multi是为了让 SonarQube 的多个语言插件同时生效;sonar.exclusions一定要排除生成代码,Flutter 里最常见的噪音来源是*.g.dart和*.freezed.dart,不排除的话重复率指标会被严重稀释。

4.3 规则集覆盖:Dart、ArkTS 双线审计

大多数团队为了快点上线,会让 SonarQube 只跑默认规则集,这样在鸿蒙项目上的结果其实很有限。因为默认规则集不会覆盖 ArkTS 的专属问题类型,比如状态管理滥用、taskpool 误用、分布式对象生命周期问题等。

在我的适配方案里,sonar_analysis会作为 Dart 侧的扫描触发器,执行完成后生成sonar-report.json;ArkTS 侧则通过自定义分析器导出问题列表。最终这些结果都会被sonar-scanner收集起来,汇总成可审计的质量数据。两条线的规则集建议分开管理:

  • Dart 层规则:聚焦可维护性、复杂度、空安全、反模式。
  • ArkTS 层规则:聚焦生命周期、并发模型、资源释放、API 合规。

4.4 CI/CD 门禁集成

质量守卫不能只在本地跑,要真正“守卫”工程,就得接入 CI 流水线。我这里的落地方式是在 GitLab 流水线里加一个 job,先执行 Flutter 侧分析和 ArkTS 侧分析,再由 sonar-scanner 把结果上报给 SonarQube,最后用 Quality Gate 的返回状态来决定构建是否继续。

#!/usr/bin/env bash set -euo pipefail flutter pub get flutter analyze --no-fatal-infos --no-fatal-warnings || true dart run sonar_analysis \ --project-path . \ --output build/sonar/dart-report.json ohpm install --all hvigorw assembleHap --mode module -p product=default sonar-scanner \ -Dsonar.host.url=$SONAR_HOST_URL \ -Dsonar.login=$SONAR_TOKEN \ -Dsonar.projectKey=$SONAR_PROJECT_KEY \ -Dsonar.sources=lib,entry/src/main/ets \ -Dsonar.dart.reportPath=build/sonar/dart-report.json

需要注意一个细节:flutter analyze不能因为发现 warning 就直接终止,否则会被 CI 的set -e卡死。做法是先允许分析完成,把结果转成报告,再交给 sonar-scanner 统一判定。这套流水线跑起来之后,每次提交的质量反馈不再是零散的,而是一个带告警趋势、文件维度和规则分布的中枢报告。

5. 适配过程中最容易翻车的五个场景(完整排查链路)

5.1 插件声明不生效:ohos 目录存在但 Flutter 引擎不加载

我遇到的第一个坑是:pubspec.yaml里明明写了ohos的pluginClass,Flutter 项目在鸿蒙引擎上运行也没有报错,但 Dart 侧一调用就提示MissingPluginException。这种问题的排查链路是:

  • 先检查ohos目录是否存在于插件根目录,并且是否被正确发布到 pub 缓存中。
  • 再检查Index.ets中是否导出了插件入口类。鸿蒙插件的注册,依赖Index.ets里把插件实例抛给 Flutter 引擎,漏掉这一步引擎根本感知不到。
  • 最后检查插件类的onAttachedToEngine是否被调用,如果这个生命周期方法没执行,多半是注册方式写错了,而不是通道实现的问题。

5.2 通道收到的数据从 Map 变成 List 乱码

sonar_analysis传参和返回值经常使用嵌套的 Map。在 Android 上这套做法很稳,但鸿蒙引擎的编解码器对Map的键顺序和嵌套类型处理会有差异。表现为:Android 侧好好的数据,鸿蒙侧解析出来键名全没了,或者Map变成了Array。

排查时我先在 Dart 侧打了日志,确认传入的Map是正常的,然后回归 ArkTS 侧打印call.arguments的类型,发现直接被解析成了Array。根源是标准通道编码对Map的 Key 类型要求是字符串,而我在 Dart 侧传的时候用了非字符串键,Android 的编解码器自动容错了,鸿蒙侧没有。

解决办法不复杂:统一参数结构,所有 map 的 key 强制字符串,避免使用数字或枚举做键。

5.3 权限与沙箱:读取不到缓存目录

sonar_analysis在鸿蒙上读取cacheDir的时候,一度返回空目录,但应用明明可以正常写入缓存。最后排查发现:Flutter 引擎的 Dart 侧拿到的path_provider缓存路径和鸿蒙原生沙箱路径并不一致,直接拼接出的路径根本不存在。

破解方法是在数据返回前,先通过鸿蒙 API 归一化路径,确保 Dart 拿到的路径是沙箱内真实存在且可读的。有时还需要主动在 native 侧创建目录,因为某些系统版本下缓存子目录不会自动生成。处理完后,最好再回头在 Dart 侧做一个existsSync校验,避免后续扫描流程静默失败。

5.4 HAP 内的 so 没有被 sonar-scanner 识别

代码审计不能只看源码,HAP 包里集成的 so 库、二进制资源也是检查对象。我在对接 sonar-scanner 时发现,它默认只按源码扩展名识别文件,so、hap都没被纳入分析范围。

解决方法是在配置中增加二进制分析插件,或者先把 HAP 解包到临时目录、把里面的*.so导出成 ELF 分析产物,再让 sonar-scanner 扫描这个临时目录。说白了就是让扫描器不要直接面对 HAP 这个压缩包,而是面对解包后的真实文件树。这个环节最容易出现“结果假绿”的假象:看着质量门禁通过了,实际上核心资产根本没有被审计。

5.5 回归验证:Charles 抓包与日志对照

适配完成后,必须做的事是回归验证。我的习惯是同时打开 Charles 抓包和鸿蒙侧的 hilog 日志,检查sonar_analysis是否真的把分析请求发到了 SonarQube 服务端,而不是在本地模拟结果糊弄过去。

具体验证步骤是:执行一次全量分析,然后在 Charles 里过滤 SonarQube 域名,应该能看到 scanner 上报的POST请求;再在 hilog 里观察 ArkTS 侧的事件推送日志,确认进度流和告警流都有数据。两端数据对照完整,才算真正跑通。这一步不能省,很多团队就是栽在“本地截图看起来成功,服务端却没有记录”的假适配里。

6. 实测收益和后续可以继续做的事

这套方案在我的工程里跑通之后,最直观的变化是:Dart 层和 ArkTS 层的质量数据终于合到同一个 SonarQube 仪表盘里了。团队做代码审查时,不再是“看谁的报错少”,而是能直接按模块、按规则、按提交记录去追溯质量波动。鸿蒙侧新引入的规则问题,也会在 MR 阶段被门禁拦住,而不是等到发版之后才被用户反馈暴露。

后续我计划做两件事。第一,把sonar_analysis的分析能力从“静态扫描”扩展到“运行时采集”,定时上报鸿蒙应用的内存占用、崩溃堆栈和分布式调用链数据,进一步补全运行时质量维度。第二,把当前依赖本地构建产物路径的逻辑改成服务端下发配置,这样 CI 和本地开发环境就不需要手工维护目录映射,每位新同事接入时也不会因为路径问题卡壳。

如果你也在做 Flutter 插件的鸿蒙适配,我给你一个建议:先别急着改业务逻辑,把一个最小的通道跑通,从 MethodChannel 到 EventChannel 一点点递进。通道通了,后续的权限、线程、产物收集这些问题都会有明确的方向。鸿蒙的适配表象上看是 API 迁移,实际上是对插件架构理解的重新梳理,至少我现在看任何 Flutter 三方库,第一反应都是先拆解它的通道依赖,鸿蒙侧能不能接得住,心里马上有数。

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

Keil MDK用RTE一键安装FreeRTOS:自动配置依赖,告别手动移植

做嵌入式这几年&#xff0c;经常有人问我一个问题&#xff1a;FreeRTOS到底怎么装到Keil工程里&#xff1f;网上的教程大多数是“下载源码包→手动复制文件→改include path→改FreeRTOSConfig.h”&#xff0c;整套流程没半小时下不来&#xff0c;而且版本一换就踩坑。其实Keil…

作者头像 李华
网站建设 2026/10/2 18:34:28

数据预处理全链路实战:从数据清洗到特征工程的关键技巧

数据预处理这件事&#xff0c;我在数据科学项目里翻来覆去折腾了很多年。刚入行时总觉得建模才是核心&#xff0c;后来被现实教育过几次才发现&#xff0c;真正决定项目成败的往往不是模型&#xff0c;而是你在建模之前那十几个小时面对脏数据所下的功夫。数据预处理听着基础&a…

作者头像 李华
网站建设 2026/10/2 18:32:56

Serial Studio:让串口数据可视化与嵌入式调试更高效

1. 为什么建议用Serial Studio这类工具替代传统串口助手 做嵌入式开发、单片机调试或者传感器数据采集的朋友&#xff0c;应该都有过这样的经历&#xff1a;打开串口助手&#xff0c;看到一大片十六进制或者ASCII码数据在屏幕上飞快滚动。数据少的时候还能凑合看&#xff0c;可…

作者头像 李华
网站建设 2026/10/2 18:30:59

从su到sudo:Linux权限管理与sudoers配置实战指南

这两年我接手过的服务器和开发机&#xff0c;几乎每一台都会遇到"权限之问"——为什么su切不过去&#xff1f;为什么sudo报错说我不在sudoers文件里&#xff1f;为什么同样的命令在这台机器上能跑、在那台就卡住&#xff1f;大部分问题的根源&#xff0c;其实都落在s…

作者头像 李华
网站建设 2026/10/2 18:30:55

Spring Boot自习室预约系统:并发预约冲突控制与状态机设计实战

简介&#xff1a;这套基于Spring Boot框架的自习室管理与预约系统源码&#xff0c;适合计算机专业学生、初级Java开发者用于课程设计、毕业设计或熟悉典型Web项目开发流程。系统采用MVC分层架构&#xff0c;前台支持用户注册登录、自习室预约、座位与开放时段查看&#xff0c;后…

作者头像 李华
网站建设 2026/10/2 18:29:23

NISP一级题库doc高效备考:从文档处理到错题管理的完整攻略

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

作者头像 李华