dart_chromecast鸿蒙化适配:从投屏协议到分布式总线的迁移实践
最近我把 Flutter 里的 dart_chromecast 组件做了一次鸿蒙化适配整个过程比预想中折腾不少。dart_chromecast 原本负责投屏控制、设备发现与多媒体协同跨端应用靠它在多屏互动场景里统一管理会话迁移到 OpenHarmony 分布式环境后我相当于把一套面向传统投屏协议的多屏交互逻辑重新接到了另一套分布式总线上。适配过程涉及协议映射、接口重写、状态同步和性能调优也踩了不少隐藏很深的坑。这篇文章就把我的实践路径、关键设计和排查记录整理出来给同样要做鸿蒙化投屏业务的读者一个可以直接参考的样本。1. 项目背景与组件选型思路1.1 为什么要做鸿蒙化适配先说清楚这件事的起点。项目中有一部分业务是用 Flutter 写的跨端应用主要场景是手机控大屏。之前用的组件就是 dart_chromecastDart 层 API 设计得很干净设备扫描、会话建立、媒体加载和播放控制都能覆盖。但组件底层依赖的是原平台已经实现的 Cast 能力在 OpenHarmony 环境下没有对应实现直接编译跑不通。与其把业务层全部重写不如对组件做鸿蒙化适配。我的目标很明确Dart 层业务代码尽量不动底层能力换一套实现。这样后续升级 Flutter SDK 或者调整 UI 逻辑时不会牵一发动全身。实际做下来这个方向是对的但工作量比想象中大尤其是设备发现协议的差异几乎把整个连接链路重写了一遍。从商业和工程角度看鸿蒙化适配也不是把 API 换成同名函数那么简单。OpenHarmony 的分布式设备发现、组网能力和传统投屏协议在理念上就有区别传统方案偏重局域网内的单协议发现而鸿蒙生态更强调同账号、同组网条件下的多设备协同。所以设计适配层时要先认清哪些能力是通用的哪些能力是平台特有的。1.2 dart_chromecast 组件边界拆解开始写代码前我先把 dart_chromecast 的接口按职责拆成了三层。最上层是业务 API也就是CastManager、CastSession、MediaLoadRequest这些对业务方友好的对象。中间层是平台通道抽象负责把 Dart 层调用转成原生指令。最底层是具体协议的实现包括设备发现、连接管理、媒体命令等。适配时我最关心的并不是上层 API 长什么样而是中间层和底层的耦合度。dart_chromecast 原本通过 broadcaster/stream 方式抛出设备状态Android 端和 iOS 端各自实现了发现逻辑。鸿蒙化适配要做的就是新增一个 OpenHarmony 平台的实现把底层替换成分布式设备管理能力同时保证 Dart 层收到的设备列表格式、回调时机和原来保持一致。这个组件边界想清楚以后后续的适配就变成了一道填空题每个方法对应哪个鸿蒙能力每个事件流对应哪个回调通道。相比从零开发一个投屏 SDK这种做法能让业务侧完全感知不到底层切换风险也更容易控制。2. 鸿蒙化适配架构与技术路径2.1 整体分层设计我最终采用的是“三层两通道”架构。三层分别是 Dart 业务层、统一适配层、鸿蒙能力层。两通道分别是方法调用通道和事件回调通道。Dart 业务层保留原 API不感知底层实现。统一适配层负责把 dart_chromecast 的请求翻译成鸿蒙端能力调用同时把鸿蒙端上报的状态翻译回 Dart 层事件。鸿蒙能力层基于 OpenHarmony 系统能力实现设备发现、连接会话、媒体控制等。分层主要职责适配要点Dart 业务层管理业务状态、UI 交互保持接口语义稳定不依赖平台细节统一适配层协议翻译、数据校验、错误码映射定义一套内部标准 JSON 格式鸿蒙能力层设备发现、会话管理、媒体下发使用分布式接口与事件订阅设计时最需要花心思的地方是错误码映射。原来组件会返回类似notFound、connectionError的错误鸿蒙端底层报错信息往往更细但业务层不关心那么多。我加了一层错误翻译器把鸿蒙错误码统一成原组件错误码这样上层逻辑几乎不需要修改。2.2 设备发现能力替换这是整个适配中最核心的部分。dart_chromecast 的设备发现基于广播协议扫描的是局域网里声明了特定服务类型的设备。鸿蒙生态的设备发现走的是分布式软总线第一步要看设备是否已经完成组网第二步再看设备能力是否包含投屏接收端属性。我遇到的问题是两者模型完全不同。传统发现只要设备在线、服务类型匹配就认为可投屏鸿蒙分布式发现则必须先确认设备可信处于同一组网范围并且有对应的投屏接收能力才会出现在可用设备列表里。因此我的适配层没有直接去轮询广播而是先订阅系统级设备上线事件再对上线设备的属性做二次过滤筛选出支持媒体接收的设备。代码层面我是用事件监听加缓存的方式实现的。首次启动先拉一次全量设备列表之后持续监听下线、上线事件并把变化同步到 Dart 层。这个方案的优点是设备状态实时性好缺点是如果组网不稳定设备列表会抖动。为此我加了一个“消失延迟”设备离线后不立刻从 Dart 层移除而是等待几秒期间慢慢衰减状态避免 UI 层频繁刷新。2.3 投屏控制与媒体协同链路设备发现打通之后投屏控制相对直接一些。会话建立、媒体加载、播放暂停、seek、音量调节这些命令本质上都是一次远程调用。鸿蒙端每一种操作都对应不同的接口但统一适配层把流程模板化了减少了很多重复代码。媒体协同比单纯投屏复杂的地方在于状态同步。播放进度、播放状态、媒体元数据、音量变化这些信息需要从接收端回传。我采用 EventChannel 把鸿蒙端的状态事件流式推送给 Dart 层保持实时性。这里有一个关键决定进度信息是高频事件如果每次进度变化都往 Dart 层发会导致 Flutter 侧频繁重建 widget。我做了节流处理进度更新最多每 500ms 发一次其他状态秒级更新这样既保证流畅又减少通信开销。媒体加载方面我保留了 dart_chromecast 原有的MediaLoadRequest数据结构在适配层把它映射成鸿蒙端的媒体源描述。需要注意 URL 地址是否有效、是否带鉴权参数、是否需要设置自定义请求头这些在适配时要确认好否则会出现投屏端无法拉取流的情况。3. 鸿蒙化适配实操流程3.1 搭建插件工程并注册通道我用的是 Flutter 插件方式承载适配层。工程结构上Dart 层沿用原来的包名新增了open_harmony目录存放鸿蒙端代码。插件初始化时需要在鸿蒙端入口注册 MethodChannel 和 EventChannel。// Dart 侧注册 const platform MethodChannel(dart_chromecast_harmony/method); const events EventChannel(dart_chromecast_harmony/event);鸿蒙端收到 channel 注册后我会把一个统一的SessionManager实例绑定到方法通道上。这么做可以避免每次调用都重新创建连接管理器资源开销更小也方便做全局状态管理。// 鸿蒙侧示例简化 let manager new SessionManager(); let channel new MethodChannel(dart_chromecast_harmony/method); channel.setMethodCallHandler((call) { if (call.method startDiscovery) { let result manager.startDiscovery(); return Promise.resolve(result); } // ... });3.2 设备发现功能落地设备发现的整体流程是先读取本地组网状态再注册设备上线回调最后把设备列表转为 JSON 返回给 Dart 层。我维护的是一个DeviceDescriptor结构包含设备 ID、设备名称、设备类型、是否支持投屏接收这几个字段。原始设备信息里还有网络地址、设备图标等但我尽量只传业务需要的最小集。理由是分布式环境下设备信息可能会变传太多字段容易造成缓存的脏数据。function buildDeviceList(rawDevices) { return rawDevices .filter(device device.castCapability media_receiver) .map(device ({ deviceId: device.id, name: device.name, type: device.type, supportCast: true })); }这里有一个调试时容易忽略的点Dart 层把 JSON 转成对象时对布尔值、空字符串的处理很敏感。之前我返回的设备列表里有字段为null导致 Dart 侧jsonDecode后访问某个属性抛异常。后来我规定适配层返回的 JSON 必须用完整字段拿不到的值用默认值填充不出现缺 key 和 null 的情况。3.3 会话与媒体控制映射会话建立后所有媒体控制命令都走同一个方法通道。我把命令名设计成与 dart_chromecast 原有方法一一对应例如loadMedia、play、pause、seekTo、setVolume避免业务层修改调用方式。async function onLoadMedia(args) { let mediaInfo args.mediaInfo; let duration await session.loadMedia({ url: mediaInfo.contentUrl, title: mediaInfo.title, autoplay: mediaInfo.autoplay ?? true, }); return { sessionId: session.id, duration: duration }; }在鸿蒙端执行媒体命令时我加了一个命令队列。原因很简单接收到端还没就绪时如果快速收到 play、pause、seek 三个命令逐条执行反而会造成状态混乱。命令队列会保证媒体操作按顺序执行并在队列中合并连续重复的命令。例如快速点了三次 pause实际上只向接收端发送一次暂停指令。这种做法在实际体验中效果明显。尤其是用户在大屏上快速控制进度时命令队列能有效防止接收端因指令风暴而卡死。3.4 事件流与状态同步状态同步用 EventChannel 实现。鸿蒙端会在以下时机向 Dart 层发送事件会话状态变化连接中、已连接、断开、被踢下线。媒体状态变化加载开始、播放、暂停、结束、出错。接收端自定义事件例如投屏端发送的自定义消息。我统一封装成一个CastEventEnvelope包含事件类型、时间戳、负载数据。Dart 层收到后用 switch 分发到对应的业务处理器。function notifyEvent(eventType, payload) { this.eventSink?.success({ type: eventType, timestamp: Date.now(), payload: payload }); }有同事问为什么不用更轻的二进制协议我的理由是收益不大。这些投屏控制事件频率本身不高只有进度更新比较频繁已经做了节流JSON 的解析性能完全够用。工业级场景更看重的是稳定和可排查JSON 格式在日志里直接可读定位问题会快很多。4. 工业级性能与多屏交互优化4.1 把“大坝”思维用在交互洪峰控制上标题里提到的“多屏交互大坝”我理解的核心目标是在大量控制命令、多设备并发、弱网环境下系统不能因为峰值流量而崩溃更不能让操作延迟无限累积。我在适配层做了三层防洪第一层是命令合并。对同一设备的同类操作在几百毫秒内合并成最新一次。典型场景是用户拖动进度条会连续触发 seekTo如果全部下发接收端根本反应不过来。第二层是分设备队列。每台投屏接收端维护独立队列避免一个设备卡住拖慢其他设备。队列深度超过 20 条时直接丢弃最旧的非关键命令只保留最新的用户意图。第三层是状态缓存。Dart 层发起的查询类指令不会每次都穿透到鸿蒙端而是优先读取适配层缓存。缓存命中后再定期向底层刷新这样大幅减少了无效调用。if (queue.length 20) { queue.splice(0, 1); // 丢弃最旧的非关键指令 }这套机制在实测中效果很明显。我模拟过 5 台设备同时接收控制指令、每台设备每秒下发 10 条指令的场景接收端依旧能按顺序处理没有出现资源阻塞。4.2 多媒体协同的性能瓶颈拆解多媒体协同场景里性能瓶颈往往不在控制链路而在视频流本身的传输与解码。适配层能优化的点主要是限制码流下发时的内存占用避免一次性申请大块缓冲区。根据投屏端业务需要选择合理的码流封装格式避免不必要的转封装。对接收端上报的播放错误做分级临时错误自动重试持久错误直接反馈给用户。我在做投屏端播放进度同步时发现原始方案里为了保持进度准确每 200ms 就同步一次。后来改成根据视频帧率动态计算同步周期帧率高的内容缩短帧率低的内容拉长。这样既保证了进度的及时性又减少了对分布式总线的压力。另外必须关注系统休眠问题。移动端投屏时如果 App 进入后台系统可能会限制网络活动。适配层需要主动申请后台运行权限或者在前台建立一个轻量心跳。心跳也做了动态频率连接稳定时 5 秒一次弱网时缩短到 2 秒一次确保断线能被及时发现并重连。4.3 编译体积与内存治理鸿蒙化适配不能只跑通功能工程上还要考虑资源占用。我在编译侧做了几件事默认关闭调试专用日志只在 Debug 包开启发现模块采用延迟加载进入投屏页面才初始化设备发现服务媒体源对象在使用完后显式释放避免内存泄漏。内存方面最容易出问题的是设备列表缓存和事件订阅。设备离线后如果不清理缓存长时间运行会导致列表脏数据越积越多。我采用弱引用保存事件监听器并且在投屏会话结束时主动注销所有系统级监听这是我在一次长时间压力测试后总结出来的经验。那次测试跑了大概 8 小时内存增长明显排查下来就是事件订阅没有及时解绑。5. 不同角色的落地建议5.1 业务开发者如何平滑迁移如果你的项目之前用的是 dart_chromecast现在要迁移到鸿蒙化适配版本我的建议是分两步走。第一步先升级组件版本并跑通设备发现让用户能看到设备列表正常刷新。第二步再切换投屏控制能力重点验证播放、暂停、进度同步这些高频操作。不要一次性把所有 API 都替换掉否则出现问题将很难定位。代码迁移层面尽量保持原 API 调用方式。我在适配过程中只改了两个不兼容点一个是原来设备发现成功后回调的device.added事件在鸿蒙端变成了批量推送业务侧需要兼容列表与增量两种模式另一个是错误码的取值范围变了适配层已经映射但业务侧如果自己定义错误处理分支需要重新检查。5.2 开发者如何基于适配层扩展能力这套适配层搭好之后继续扩展能力比较顺手。比如以后要支持多设备同时投屏只需在 SessionManager 里维护一个设备会话 Map把当前的单会话逻辑变成多会话路由。或者要支持自定义投屏消息就可以在方法通道里新增一个sendCustomMessage方法接收端回应继续走事件流。更重要的是这套适配思路不只适用于 dart_chromecast其他依赖平台能力实现的 Flutter 插件也可以参照。将通用业务语义和平台实现解耦再通过统一适配层翻译后续接入更多系统时都能复用大部分代码。6. 常见问题与排查实录6.1 设备发现超时或列表为空这是接入期间被问得最多的问题。优先检查三个点设备是否处于同一次组网内、投屏接收端是否已启动、OpenHarmony 端是否申请了设备发现相关权限。我在定位这类问题时会先在鸿蒙端打印原始设备列表确认底层到底有没有发现设备。如果底层有但 Dart 层看不到说明是过滤条件太重或者事件通道没配对成功。6.2 MethodChannel 调用报 not implemented出现这个错误通常是插件注册顺序不对。鸿蒙端方法通道的 handler 必须确保在 Dart 层首次调用前已经设置完。我遇到过因为插件的 onLoad 时机晚于页面初始化导致的偶发崩溃后来改成在插件注册时就初始化 SessionManager问题解决。6.3 媒体加载慢或投屏后花屏媒体加载慢先看接收端是否有足够带宽再看内容源是否需要鉴权。花屏多数是视频编码格式不兼容可以在适配层检测到播放错误后返回一个带支持格式列表的错误事件让业务层根据接收端格式列表重新选流。6.4 Dart 侧状态不刷新EventChannel 事件丢失会导致状态卡在上一个节点。排查方式是在鸿蒙端事件发送点和 Dart 端回调入口各打一条日志。如果发送端正常但接收端不打印说明 EventChannel 注册时机太晚或者 stream 被取消订阅。问题现象常见原因解决方式设备列表为空权限不足或组网未建立检查权限与组网状态调用无响应SessionManager 未初始化插件注册时提前初始化进度不刷新事件订阅丢失确认 EventChannel 已订阅媒体播放失败编码格式不支持返回兼容格式列表长时间内存上涨事件订阅未解绑会话结束注销监听我在实际排查时还有一个心得给每个端都加上实例 ID 和会话 ID 一起打日志。分布式场景有好几个进程在跑日志串在一起很容易看混。有了 instanceId 之后按 ID 过滤日志整个链路的状态一目了然。这份适配工作做完之后我对跨端组件换平台这件事有了不一样的理解。真正需要保留的是接口语义和业务体验底层实现完全可以因平台而异。dart_chromecast 的鸿蒙化适配并不是简单找几个替代 API而是要把分布式设备管理、媒体控制与既有业务逻辑重新组合成一套稳定的交互链路。如果你也在做类似方向建议先从设备发现打通为第一里程碑再推进控制命令和状态同步。最后补一个小技巧调试分布式投屏问题时不要只盯着应用层日志一定要把设备组网状态、系统事件回调和应用日志放到同一个时间轴上看否则很容易被表面现象带偏方向。

相关新闻

VS2022 MFC图形学实践:从环境搭建到三维线框模型完整教程

VS2022 MFC图形学实践:从环境搭建到三维线框模型完整教程

做计算机图形学实验,最卡人的往往不是数学变换,而是环境搭建。很多同学打开VS2022,想建一个MFC程序,结果发现“新建项目”里根本没有MFC应用这个模板,折腾半天连窗口都弹不出来。这篇博文,我就用VS2022把MF…

2026/10/20 18:44:49 阅读更多 →
Flutter鸿蒙适配:自定义Lint规则与静态分析实战

Flutter鸿蒙适配:自定义Lint规则与静态分析实战

Flutter 应用宣布要适配鸿蒙的那一刻,团队里最先炸锅的不是渲染管线,也不是原生通信桥,而是静态检查工具链整个失灵。跑一遍flutter analyze,密密麻麻的报错来自 SDK 路径里的鸿蒙厂商适配层,真正的业务代码反而没人管…

2026/10/20 18:44:44 阅读更多 →
单目相机测距原理与OpenCV实战:厘米级距离估算

单目相机测距原理与OpenCV实战:厘米级距离估算

简介:本资源是一份基于OpenCV与Python实现单目测距的轻量级计算机视觉项目,面向图像处理初学者、嵌入式视觉开发者及高校课程实践者,解决已知尺寸物体到相机距离的实时估算问题。方案采用三角形相似原理,通过标定阶段获取相机焦距…

2026/10/20 13:48:37 阅读更多 →

最新新闻

C++智能指针循环引用检测与修复:从内存泄露到工程实践

C++智能指针循环引用检测与修复:从内存泄露到工程实践

说实话,我第一次在正式项目里撞上shared_ptr循环引用,并不是因为查内存泄漏教程,而是因为一段“怎么看都正常”的代码把服务的常驻内存从300M干到了3个G。当时所有人盯着监控面板都觉得是哪里new了没delete,结果用valgrind一跑&am…

2026/10/20 22:56:49 阅读更多 →
从零搭建PPT自动化生成引擎:python-pptx核心设计与批量实践

从零搭建PPT自动化生成引擎:python-pptx核心设计与批量实践

1. 从零搭建一个PPT自动化生成工具,我为什么选了这条路第一次听到"ppt-master"这个命名,直觉告诉我它不是一个普通的PPT模板合集,而是一个带有"主控"意味的工具——master这个词在技术圈通常意味着调度者、控制中枢。结合…

2026/10/20 22:56:49 阅读更多 →
中文放进char[]合法吗?C/C++字符编码与乱码深度解析

中文放进char[]合法吗?C/C++字符编码与乱码深度解析

前阵子在一个内部工具项目里,我写了一句char file[] "未命名文件.txt";,编译一切正常,可等到程序跑起来,日志里的文件名变成了乱码。旁边同事下意识地说:“中文不能放进char[]吧。”我愣了两秒——编译期没…

2026/10/20 22:56:49 阅读更多 →
多旋翼飞行器工程实战:从需求分析到飞控调参的完整指南

多旋翼飞行器工程实战:从需求分析到飞控调参的完整指南

1. 先别急着买零件:把任务需求翻译成设计指标1.1 你手里的这块场地,决定了机架怎么选多旋翼飞行器这个东西,看起来是四个电机加一个机身,实际上一开始最容易犯的错误就是先买零件后想需求。我见过不少第一次做工程项目的朋友&…

2026/10/20 22:56:49 阅读更多 →
用Python自动生成PPT:内容与样式分离的工程实践

用Python自动生成PPT:内容与样式分离的工程实践

1. 为什么我要自己动手做一个 PPT 生成工具先说说我做这个项目的起因。我在一家中型公司做技术方案和产品汇报,平均每周要出三到五份 PPT,多的时候一天两份。最开始我也是老老实实打开演示软件,一页一页拖文本框、调对齐、找图标、配颜色。一…

2026/10/20 22:56:49 阅读更多 →
开放式代码审查:从规则沉淀到AI辅助的团队实践

开放式代码审查:从规则沉淀到AI辅助的团队实践

那一次Review,至今让我印象深刻。我自认为提交了一段“完美”的代码:类型明确、命名讲究、注释齐全,甚至提前把函数的三种边界情况都测了一遍。结果同事在评审里只问了我三个问题:为什么定义成接口而不是具体类型?三秒…

2026/10/20 22:55:46 阅读更多 →

日新闻

CLRC663芯片Pin-to-Pin替代实战:从评估到量产全记录

CLRC663芯片Pin-to-Pin替代实战:从评估到量产全记录

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

2026/10/20 1:01:30 阅读更多 →
ESP32舵机控制指南:原理、多路驱动与避坑实战

ESP32舵机控制指南:原理、多路驱动与避坑实战

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

2026/10/20 1:01:30 阅读更多 →
位移法实现一阶低通滤波:单片机ADC采样降噪实战

位移法实现一阶低通滤波:单片机ADC采样降噪实战

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

2026/10/20 1:01:30 阅读更多 →

周新闻

基于GMM的语音识别系统原理与MATLAB实现详解

基于GMM的语音识别系统原理与MATLAB实现详解

说到基于高斯混合模型(GMM)的语音识别系统,很多人第一反应就是一堆数学公式,觉得门槛很高。但如果你真的在MATLAB里把它落地跑通,会发现核心其实就是三件事:把声音变成特征向量,用GMM给每个类别…

2026/10/20 10:02:13 阅读更多 →
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理这四个字听起来挺唬人,但落到具体操作上,无非就是让计算机按你的想法“摆弄”像素。我在一个模拟项目X里用了一套特别经典的基础处理流程:对图像做反白、改调色板、彩色转灰度,最后把像素数据导出成txt文件。单看每一…

2026/10/20 20:43:59 阅读更多 →
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

机器人大脑技术盘点:具身智能的感知、决策与数据闭环

如果你的信息流最近也天天刷到“机器人大脑”这个词,你会发现一个很有意思的现象:每个人说的“大脑”根本不是同一个东西。有人指的是大语言模型,有人指的是运动控制系统,还有人指的是能理解物理世界的端到端模型。在2026年这个时…

2026/10/20 2:50:50 阅读更多 →

月新闻

我发现了一个新思路:用 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/20 10:02:08 阅读更多 →
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/20 9:50:17 阅读更多 →
黑夜航拍船只数据集训练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/20 9:50:45 阅读更多 →