ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

鸿蒙Flutter投屏开发:dlna_dart库鸿蒙化实战与踩坑记录

鸿蒙Flutter投屏开发:dlna_dart库鸿蒙化实战与踩坑记录 从需求到实现鸿蒙生态里的大屏投播一直是个让人头疼的事。最近我在做基于 Flutter 的鸿蒙应用时需要把手机上的影音内容直接投送到客厅电视。市面上现成的投屏 SDK 要么绑定特定品牌生态要么在 HarmonyOS NEXT 上根本没适配。最后我选择了一条更通用的路把纯 Dart 的 DLNA 三方库 dlna_dart 鸿蒙化自己搭建一套基于 UPnP/DLNA 协议的直连投播体系。这篇文章记录的就是整个实战过程包括移植前的代码体检、鸿蒙工程接入、SSDP 设备发现改造、SOAP 控制链路打通以及最后实测的耗时数据和一堆避坑经验。如果你正在 Flutter 鸿蒙应用里做局域网投屏或者手头有三方库需要移植到鸿蒙这篇应该能给你一份可以直接参考的路线图。1. 大屏影音直连的现状与移植动机1.1 智能电视投屏协议的割裂现实先说说我为什么非要折腾 DLNA。现在客厅里的电视投屏协议其实非常分散苹果设备走 AirPlay安卓和 Windows 走 Miracast各品牌电视还有自己的私有协议。比如华为系有 Cast小米系有自己的小米投屏索尼、三星、LG 又各有玩法。对于做投屏应用的人来说最头疼的就是接口不统一。DLNADigital 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也可能静默失败。针对这个情况我写了一版防弹的绑定逻辑FutureRawDatagramSocket 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而不是依赖固定层级。解析完设备描述之后我得到了两个关键信息控制端点的 URLcontrolURL和事件订阅的 URLeventSubURL。这两个 URL 将直接用于后续 SOAP 指令下发和事件订阅它们可能来自 XML 中的相对路径拼接时必须基于设备描述文档的 URL 做 resolve。5. 核心改造二SOAP 控制指令与播放器联动5.1 AVTransport 服务调用的关键细节设备发现完成后接下来就是投播的核心让电视开始播放视频。这个动作在 DLNA 协议里叫 SetAVTransportURI是 AVTransport 服务的一个动作。我封装了一个通用的 SOAP 调用函数构造请求体如下?xml version1.0 encodingutf-8? s:Envelope xmlns:shttp://schemas.xmlsoap.org/soap/envelope/ s:encodingStylehttp://schemas.xmlsoap.org/soap/encoding/ s:Body u:SetAVTransportURI xmlns:uurn:schemas-upnp-org:service:AVTransport:1 InstanceID0/InstanceID CurrentURIhttp://192.168.1.100:8080/media/video.mp4/CurrentURI CurrentURIMetaData/CurrentURIMetaData /u:SetAVTransportURI /s:Body /s:Envelope除了请求体本身HTTP 头里有几个字段一个都不能错Content-Type: text/xml; charsetutf-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 秒自动重新 SUBSCRIBESID 保持一致。如果事件订阅彻底收不到我的降级方案是定期调用 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 返回 501SOAPACTION 大小写或引号格式不对严格按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 实现细节较劲上。如果你的场景和我类似建议先跑通发现设备到投播出画面的最小闭环再逐步加上事件订阅、缓存、后台发现这些增强能力。方向选对了活就完成了一半。
返回列表