从需求到实现,鸿蒙生态里的大屏投播一直是个让人头疼的事。最近我在做基于 Flutter 的鸿蒙应用时,需要把手机上的影音内容直接投送到客厅电视。市面上现成的投屏 SDK 要么绑定特定品牌生态,要么在 HarmonyOS NEXT 上根本没适配。最后我选择了一条更通用的路:把纯 Dart 的 DLNA 三方库 dlna_dart 鸿蒙化,自己搭建一套基于 UPnP/DLNA 协议的直连投播体系。这篇文章记录的就是整个实战过程,包括移植前的代码体检、鸿蒙工程接入、SSDP 设备发现改造、SOAP 控制链路打通,以及最后实测的耗时数据和一堆避坑经验。如果你正在 Flutter 鸿蒙应用里做局域网投屏,或者手头有三方库需要移植到鸿蒙,这篇应该能给你一份可以直接参考的路线图。
1. 大屏影音直连的现状与移植动机
1.1 智能电视投屏协议的割裂现实
先说说我为什么非要折腾 DLNA。现在客厅里的电视投屏协议其实非常分散:苹果设备走 AirPlay,安卓和 Windows 走 Miracast,各品牌电视还有自己的私有协议。比如华为系有 Cast+,小米系有自己的小米投屏,索尼、三星、LG 又各有玩法。对于做投屏应用的人来说,最头疼的就是接口不统一。
DLNA(Digital Living Network Alliance)虽然是 2003 年的老协议,但在智能电视上的覆盖率依然很高。绝大多数电视厂商,包括很多互联网品牌,都会内置 DLNA 接收端(DMR)。它的核心优势在于:只在局域网内工作,不依赖公网服务器;基于 UPnP 标准,协议文档公开;设备种类覆盖广,从大屏电视到机顶盒甚至投影仪,都保留着这个能力。
但 DLNA 也有明显的年代感:设备兼容性参差不齐,有的电视默认开启 DLNA,有的要进入设置菜单打开"多屏互动"或"无线显示"选项。用户如果不知道这个入口,功能就等于摆设。所以我在应用里专门加了一个引导页,提醒用户在电视上检查 DLNA/多屏互动开关。
1.2 HarmonyOS NEXT 生态下的 Flutter 三方库缺口
到了 HarmonyOS NEXT 时代,事情变得更复杂。NEXT 不再兼容 Android APK,之前社区里大量基于 Android 原生实现的投屏库基本全部失效。过去投屏开发最省事的做法是直接集成 Android 的 DLNA 库,比如 Cling、Platinum,它们都有非常成熟的原生实现。但在鸿蒙 NEXT 上,这条路断了。
Flutter 在鸿蒙上跑起来没有问题,但三方库生态的鸿蒙化程度还很低。你去 pub.dev 搜 DLNA、UPnP、投屏这些关键词,能找到的包屈指可数,而且大多年久失修,很多还强依赖 Android/iOS 平台通道。大部分 Flutter 插件想跑在鸿蒙上,都需要单独适配 ohos 平台目录,这本身就是一件体力活。
我的场景更具体:应用是 Flutter 写的鸿蒙版,控制端和被控端可能都是鸿蒙设备,但电视大概率是传统 DLNA 设备。这意味着投屏协议不能依赖任何一家私有生态,必须要走标准 DLNA 协议栈。
1.3 为什么最终锁定了 dlna_dart
我给了自己三个备选方案,对比之后才做的决定:
| 方案 | 优点 | 缺点 | 结论 |
|---|---|---|---|
| 华为/荣耀私有 Cast SDK | 官方支持,鸿蒙生态内体验稳定 | 只对自家电视有效,传统 DLNA 电视完全覆盖不了 | 放弃 |
| 自研 UPnP/DLNA 协议栈 | 完全可控,想怎么改怎么改 | 从 SSDP 发现到 SOAP 控制全手写,工期至少一两个月 | 放弃 |
| dlna_dart 鸿蒙化 | 控制面现成,纯 Dart 实现,几乎不依赖平台 | 需要自己验证 dart:io 在鸿蒙上的兼容性,播放面要自己做 | 采用 |
dlna_dart 是一个纯 Dart 实现的 DLNA/UPnP 控制面库。它处理了设备发现、设备描述解析、AVTransport 服务调用这些核心流程。库的主要依赖集中在 dart:io 和几个纯 Dart 包上,没有 Android/iOS 原生的平台通道依赖。这对我来说是最理想的移植目标,因为我要做的事情被大大简化了:先把库在鸿蒙 Flutter 工程里跑起来,再解决真机上的网络兼容细节,最后把播放链路和 DMC 控制逻辑串起来。整个过程不需要去啃 UPnP 协议栈的每一行规范。
2. 移植前的代码体检:dlna_dart 到底依赖了什么
2.1 库的分层结构与职责边界
在动手做任何修改之前,我先把 dlna_dart 的代码结构从头到尾过了一遍。它本质上是一个按协议分层组织的库,我把它分成四个模块来理解:
- 设备发现层:通过 SSDP 发送 M-SEARCH 搜索网络中的 DLNA 设备,监听组播端口上的 NOTIFY 广播,处理设备上线和下线事件。
- 设备描述层:拿到设备返回的 Location URL 之后,抓取设备描述 XML,解析出 friendlyName、deviceType、UDN(唯一设备名)以及 serviceList 里的控制端点。
- 控制服务层:封装 AVTransport、ConnectionManager 这类 UPnP 服务的 SOAP 调用。这个模块是投播的核心,负责 SetAVTransportURI、Play、Pause、Stop 这些指令。
- 底层工具层:HTTP 客户端、XML 解析器、Socket 封装,这些是为上面三层服务的。
理解了分层,就很容易判断移植工作量。库本身不提供播放器实现,它只是控制面的"遥控器"。播放动作由被投设备(DMR)自己完成,比如电视收到 SetAVTransportURI 指令后,会用内置播放器去拉取媒体流。这点想清楚了,后面很多设计就不会跑偏。
2.2 扫描出的平台敏感点
我把库的代码里涉及 dart:io 的地方全部筛了一遍,真正对鸿蒙平台敏感的其实只有三处:
第一是 UDP 组播收发。SSDP 发现依赖向 239.255.255.250:1900 发送组播请求,并接收设备回发的单播响应。RawDatagramSocket 在鸿蒙 Flutter SDK 上的表现需要通过真机验证。第二是本地 HTTP 服务。DLNA 事件订阅(Eventing)机制要求控制端提供一个本地回调端点,接收设备的 NOTIFY 通知。这个需要用 dart:io 的 HttpServer 在应用内监听端口。第三是网络接口枚举。多网卡、多 IP 环境下,发组播前要决定绑定哪个本地地址,避免广播发到了错误的网卡上。
至于 XML 解析、HTTP 请求构造、URL 处理这些,都是纯 Dart 能力,在鸿蒙上不会有区别。所以移植的核心,其实就围绕这三个 dart:io 敏感点展开。
2.3 移植风险预判
体检之后,我整理了一张风险表,对应每项风险都提前想好了兜底方案:
| 风险点 | 风险表现 | 应对策略 |
|---|---|---|
| dart:io Socket 多播兼容性 | 收不到组播响应,设备发现失败 | 准备降级方案:失败时绑定 1900 端口被动监听 |
| 鸿蒙网络权限模型差异 | 应用收不到局域网广播数据包 | 进 module.json5 配置网络权限,真机排查本地网络授权 |
| 老设备 XML 实现不规范 | 设备描述解析失败 | 增加容错解析,处理编码和 BOM |
| 电视 SOAP 实现差异 | 控制指令返回 500 | 记录响应体日志,做重试和超时保护 |
| 事件订阅长连接失效 | 播放状态不同步 | 实现订阅续期,并在异常时降级为轮询 |
这张表让我清楚知道哪些地方必须真机验证,哪些地方只要在代码里做好容错就能兜底。事实证明,大部分时间确实都花在了表里的前两项上。
3. 鸿蒙工程接入:环境、权限与编译适配
3.1 Flutter 鸿蒙工程初始化与 SDK 选择
工程准备这一步没有什么捷径。我用 DevEco Studio 创建了支持鸿蒙的 Flutter 工程,然后使用鸿蒙适配版 Flutter SDK 作为运行环境。这里有个很实用的建议:用 fvm 管理多个 Flutter SDK 版本,因为鸿蒙适配版的 SDK 版本和官方稳定版可能不同步。同一台机器上同时存在几个 Flutter 版本时,fvm 能避免频繁切换环境变量的麻烦。
工程创建完之后,我第一时间做了一件事:写一个最小 Demo 验证 dart:io 的基础能力。一个简单的 UDP socket 收发测试,确认鸿蒙 Flutter SDK 上 RawDatagramSocket 能正常 bind 和 send。这个小实验成本极低,却能把"环境问题"和"代码问题"提前切开,后面接入 dlna_dart 时排查范围会小很多。
3.2 权限声明与局域网访问配置
鸿蒙应用要访问网络,第一步是在 module.json5 里添加网络权限。我实际用到的基础权限是 ohos.permission.INTERNET,没有它,所有网络请求、组播发送都会直接失败。
这里有一个容易被忽略的细节:DLNA 投播场景要求的不是单纯的"能上网",而是"能和局域网内其他设备通信"。鸿蒙不同 API 版本对本地网络权限的管理有差异,在某些真机上即使加了 INTERNET 权限,组播数据可能仍然收不到,需要在系统设置中允许应用访问本地网络设备。我在开发阶段遇到"搜不到电视"这类问题时,第一反应永远是先检查这层,而不是去改代码。
3.3 依赖解析与编译期调整
dlna_dart 加入 pubspec.yaml 之后,pub get 一般能顺利通过,因为它依赖的包基本都是纯 Dart 实现。但如果你的工程里同时引用了其他三方库,某些包可能没有适配 ohos 平台,pub get 就会报版本解析错误。这种情况我的处理方式是:先锁定版本号,再用 dependency_overrides 强制指定兼容版本;实在不行就改用 git 依赖,直接指向某次提交。
编译期还需要关注鸿蒙 Flutter SDK 内置的 Dart 版本。如果 dlna_dart 用了高于当前 SDK 支持的语法特性,编译时会有明确报错。这时候要么降级 dlna_dart 版本,要么 fork 一份然后调整语法。我实际使用中没遇到这个问题,因为库本身的代码风格比较保守,但提前知道这个检查项,能省下不少查错时间。
4. 核心改造一:SSDP 设备发现链路的鸿蒙化
4.1 RawDatagramSocket 在鸿蒙上的真实表现
这是整个移植过程中我最没底的部分。从原理上讲,鸿蒙 Flutter SDK 会把 dart:io 的 Socket API 映射到鸿蒙系统能力上,但"映射了"和"行为完全一致"是两回事。组播这块尤其如此,因为涉及加入多播组、设置网络接口等底层操作。
真机验证的结果是:基础收发没问题,bind 和 send 都正常。但 addMembership 方法在部分鸿蒙设备上有兼容问题,可能抛 UnsupportedError,也可能静默失败。针对这个情况,我写了一版防弹的绑定逻辑:
Future<RawDatagramSocket> bindAndJoin({ required InternetAddress multicastAddress, int port = 0, }) async { final socket = await RawDatagramSocket.bind( InternetAddress.anyIPv4, port, reuseAddress: true, reusePort: true, ); try { socket.addMembership(multicastAddress); } on UnsupportedError catch (e) { // 部分鸿蒙设备上 addMembership 不可用。 // 这里做降级:不加入组播组,但保留主动发送 M-SEARCH 的能力, // 同时依靠绑定 1900 端口监听响应。 debugPrint('[dlna] addMembership fallback: $e'); } return socket; }降级方案虽然不能让设备主动把组播通知发给我们,但至少不影响主动发现这条主线。M-SEARCH 是发往组播地址的,设备收到后会单播回复,而单播响应只要 socket 绑定了对应端口就能收到。实测下来,降级方案在大多数场景下依然能完成发现。
4.2 M-SEARCH 组播请求与响应监听改造
SSDP 发现的核心是一个 M-SEARCH 请求。标准请求长这样:
final request = [ 'M-SEARCH * HTTP/1.1', 'HOST: 239.255.255.250:1900', 'MAN: "ssdp:discover"', 'MX: 2', 'ST: ssdp:all', '', '', ].join('\r\n'); socket.send(utf8.encode(request), InternetAddress('239.255.255.250'), 1900);这个请求的作用是问局域网里所有 DLNA 设备:"谁在线?" 设备收到之后会在 MX 指定的 2 秒内回响应。这里有三个细节值得注意:
- ST 可以设成 ssdp:all 搜全部设备,也可以设成 urn:schemas-upnp-org:device:MediaRenderer:1 只搜媒体渲染器。我建议开发阶段用 ssdp:all,因为你永远不知道用户的设备会把自己注册成什么类型。
- 响应包要带 ST、USN、Location 这些头。USN 是去重的关键。同一个设备通常会在短时间内回多条消息,必须用 USN(或 UDN)做缓存和过滤,否则设备列表会疯狂重复。
- Location 头有时是相对的,有时带了奇怪的引号,还有的老设备会把头字段的大小写写错。解析响应的时候要用宽松方式处理,比如统一
toLowerCase()再匹配键名。
监听响应我用的是 socket 的 Stream 事件循环,读到的数据先按 CRLF 拆行,再转成 Map 解析。每收到一条数据就更新一次设备缓存,同时记录最后活跃时间,方便后续做"设备离线"判断。
4.3 被动发现与设备描述 XML 解析
只靠 M-SEARCH 主动发现有一个缺陷:设备可能在你想搜索的时候刚好离线,或者网络有延迟。所以我还加了一个被动发现通道:监听组播端口上的 NOTIFY 消息。设备联网时会主动发送ssdp:alive广播,断网或关机前发送ssdp:byebye。
收到 alive 消息后,我会去抓取设备的 Location URL,拉取设备描述 XML。描述 XML 就是设备自报家门的文件,里面包含设备名称、类型、服务列表。老设备对 XML 的实现非常随意,有的带 BOM,有的编码声明和实际字节对不上,有的 XML 结构缺失命名空间。我在解析时做了两层防护:
- 抓取内容时用
utf8.decode(response.bodyBytes, allowMalformed: true),避免编码问题直接抛异常。 - 解析时先去掉 BOM,再遍历整个节点树查找 friendlyName 和 deviceType,而不是依赖固定层级。
解析完设备描述之后,我得到了两个关键信息:控制端点的 URL(controlURL)和事件订阅的 URL(eventSubURL)。这两个 URL 将直接用于后续 SOAP 指令下发和事件订阅,它们可能来自 XML 中的相对路径,拼接时必须基于设备描述文档的 URL 做 resolve。
5. 核心改造二:SOAP 控制指令与播放器联动
5.1 AVTransport 服务调用的关键细节
设备发现完成后,接下来就是投播的核心:让电视开始播放视频。这个动作在 DLNA 协议里叫 SetAVTransportURI,是 AVTransport 服务的一个动作。我封装了一个通用的 SOAP 调用函数,构造请求体如下:
<?xml version="1.0" encoding="utf-8"?> <s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"> <s:Body> <u:SetAVTransportURI xmlns:u="urn:schemas-upnp-org:service:AVTransport:1"> <InstanceID>0</InstanceID> <CurrentURI>http://192.168.1.100:8080/media/video.mp4</CurrentURI> <CurrentURIMetaData></CurrentURIMetaData> </u:SetAVTransportURI> </s:Body> </s:Envelope>除了请求体本身,HTTP 头里有几个字段一个都不能错:
Content-Type: text/xml; charset="utf-8",很多老设备只认这个格式,少写 charset 都会 500。SOAPACTION必须带引号且全小写服务名严格按设备描述来,我见过因为大小写不一致导致 501 的设备。正确写法是"urn:schemas-upnp-org:service:AVTransport:1#SetAVTransportURI"。
请求发出去之后,电视会自己解析 URL、建立连接、开始缓冲播放。整个投播命令到出画面通常不到一秒。但这里有一个非常容易踩的坑:CurrentURI 必须是一个电视能直接访问的地址。如果你的源 URL 是https://而电视的 DLNA 实现不支持 TLS,它播放不了;如果媒体源在手机本地,你给电视传了http://localhost:8080/,电视访问的是它自己的 localhost,必失败。所以我做了一层地址抽换,统一把源 URL 映射成局域网内可达的 HTTP 地址。
5.2 事件订阅机制与播放状态同步
光让电视播起来还不够,你还需要知道电视当前处于什么状态:是播放中、已暂停、还是播完停止了。DLNA 提供的事件订阅机制就是为了解决这个问题的,原理是控制端向设备的事件订阅 URL 发送一个 SUBSCRIBE 请求,设备在有状态变化时主动 POST 事件通知回来。
SUBSCRIBE 请求的关键头是:
CALLBACK: <http://192.168.1.5:5800/notify>,控制端必须提供一个本地 HTTP 端点。NT: upnp:event,声明订阅类型。TIMEOUT: Second-1800,协商订阅时长。
鸿蒙应用里我用 dart:io 的 HttpServer 在应用内开了一个端口做事件接收端。这里有个实际经验:某些电视的事件订阅实现很不靠谱,到了订阅过期时间后并不会主动续约。所以我在应用层加了一个定时器,在订阅过期前 30 秒自动重新 SUBSCRIBE,SID 保持一致。
如果事件订阅彻底收不到,我的降级方案是:定期调用 GetTransportInfo 动作,轮询当前传输状态。轮询虽然比不上事件推送实时,但作为兜底完全够用。
5.3 鸿蒙设备自身作为接收端时的播放器接入
我最初的需求是鸿蒙应用作为控制端,指挥大屏电视播放。但整套体系设计时,我顺便把"鸿蒙设备作为接收端"这个角色也考虑进去了,因为很多场景下,用户希望手机能投到鸿蒙平板或智慧屏上。这个能力靠 dlna_dart 是完不成的,它本身不含播放器。
在鸿蒙侧,我通过 MethodChannel 调原生 AVPlayer,把收到的 mediaUrl 交给系统播放器去解码渲染。整个链路是:SSDP 发现来自网络的控制请求,SOAP 解析出媒体地址,然后下发到原生播放器。如果你不想碰原生代码,也可以直接用鸿蒙适配过的 video_player 类插件,但这些插件本身也是要额外适配的三方依赖,需要权衡。
6. 实测效果、性能数据与高频坑位复盘
6.1 局域网实测数据
改造完成之后,我在真实局域网环境里做了一轮端到端测试。网络环境是普通家用路由器(Wi-Fi 5),被测设备包括一台支持 DLNA 的鸿蒙电视和一台老款智能电视。实测数据如下:
| 测试场景 | 耗时/表现 |
|---|---|
| 冷启动全量设备发现 | 1.5 秒到 2.8 秒 |
| 热缓存后重新发现 | 200 到 500 毫秒 |
| 投播指令下发到电视出画面 | 600 到 1200 毫秒 |
| 事件订阅状态回传延迟 | 小于 300 毫秒 |
| 连续投播 2 小时 | 无明显断流,内存稳定 |
冷启动发现偏慢的原因,是老电视的响应时间接近 2 秒的上限,这是协议层面的行为,不是代码能绕过的。热缓存策略是把上次发现的设备信息持久化到本地,再次进入页面时先展示缓存列表,同时后台重新做一次增量发现,实际体感会好很多。
6.2 高频坑位复盘表
整个移植过程我整理了一张踩坑清单,每个问题都是真机调出来的:
| 问题现象 | 根因 | 处理方式 |
|---|---|---|
| 搜不到电视 | 路由器的 AP 隔离/双频段隔离,或电视端的 DLNA 开关没开 | 排查网络配置,应用内引导用户检查电视设置 |
| 同一设备重复出现 | USN 未处理,设备回包到达多次 | 用 UDN 做主键去重,更新缓存时间戳 |
| SetAVTransportURI 返回 501 | SOAPACTION 大小写或引号格式不对 | 严格按"urn:schemas-upnp-org:service:AVTransport:1#SetAVTransportURI"发送 |
| 视频播放 30 秒后停止 | 媒体地址是 https,电视 DLNA 实现不支持 | 内容服务增加 http 直出能力 |
| 事件订阅收不到回调 | 本地端口被系统策略拦截或订阅超时 | 由应用内 HttpServer 承担回调,并不停续订 |
| 设备描述 XML 解析失败 | 响应带 BOM、编码声明与实际不符 | 用配合 allowMalformed 的 utf8 解码,并剥离 BOM |
其中最隐蔽的是后两个,它们的报错都不够直观,事件订阅失败可能只是静默收不到任何消息,XML 解析失败可能只是设备列表里少了一台设备。这类问题的排查思路就一条:把协议交互的原始报文全部输出到日志里,看到实际返回的内容再判断。
6.3 稳定性优化与长期维护建议
最后说几个让整套体系稳定落地的优化点。
一个是设备发现不要每次都全量扫。我在应用里开了一个后台轻量任务,每 60 秒监听一次 NOTIFY alive 消息,增量更新设备列表。这样既保持设备状态新鲜,又避免高频率组播给路由器造成压力。另一个是投播失败要有重试策略。有的电视刚开机 DLNA 服务还没起来,第一次投播会失败,我会在失败后自动做一次 GetTransportInfo 查询,确认设备状态,然后延时 1 秒重试 SetAVTransportURI。
最后,如果你也要做类似的移植,我建议从一开始就准备好一个 DLNA 测试工具配合开发,比如用现成的 UPnP 调试器对照报文,能快速判断问题是来自我们的代码还是设备的实现。老协议的设备差异实在太多,遇到问题先把双方报文拉出来对比,基本能解决九成以上。
说实话,DLNA 这套协议栈对比现代投屏协议,既不炫酷也不高效,但它的覆盖面和通用性在鸿蒙生态尚未完全统一的当下,依然是最务实的选择。把 dlna_dart 这样的纯 Dart 库鸿蒙化,实际改造周期我花了大概两周,一半时间都消耗在和不同电视的 UPnP 实现细节较劲上。如果你的场景和我类似,建议先跑通"发现设备到投播出画面"的最小闭环,再逐步加上事件订阅、缓存、后台发现这些增强能力。方向选对了,活就完成了一半。