ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Flutter画中画库pip鸿蒙化适配:从平台通道到PiP控制器实战

Flutter画中画库pip鸿蒙化适配:从平台通道到PiP控制器实战 上个月接了个任务要把公司 App 里基于 Flutter 实现的那套视频播放能力迁到 HarmonyOS NEXT 上。功能清单里最让人头疼的不是播放器本身而是那个叫pip的三方库——它负责的就是画中画Picture-in-Picture模式、视频悬浮窗以及多任务场景下的并行交互。Flutter 侧的代码写得挺规整但鸿蒙原生侧完全是一个新生态平台通道、窗口能力、生命周期模型全都不一样。这趟适配做下来踩了不少坑也把整套思路摸透了所以整理成这篇指南给后面要做 Flutter 三方库鸿蒙化的团队一个参考。这篇文章适合正在做 Flutter 应用鸿蒙适配、或者准备把某个视频类三方库迁移到 HarmonyOS 的开发者。你会看到我怎么拆解原库的职责边界、怎么设计鸿蒙侧的对接方案、具体代码怎么写以及真机实测时那几个最折磨人的问题是怎么一步步定位的。1. 为什么 Flutter 的 pip 库在鸿蒙上不能直接用三方插件的运行边界1.1 先澄清一个容易撞车的名字此 pip 非彼 pip网上搜pip这个词十有八九出来的是 Python 的包管理工具。但这次要适配的是 Flutter 生态里专门做画中画的三方库库名就叫pip。它做的事情说白了就是在原生系统层创建一个能被系统托管的悬浮视频窗口让用户在主应用退到后台、切去聊天或者看别的页面时视频还能继续播放而且用户可以在这个小窗口上做播放、暂停、关闭、恢复大屏这些操作。在 Flutter 里涉及到原生窗口、系统层级交互的功能插件一般都会走平台通道。pip 库也不例外它典型的链路是// Flutter 端暴露给业务方的统一入口 final controller PipController(); await controller.start( videoId: 1024, position: Position(seconds: 120), size: PipSize(width: 200, height: 112), );这段 Dart 代码背后其实是通过MethodChannel跟原生端通信让原生端去创建真正的系统级悬浮窗。你光看 Flutter 端代码会觉得这就是个普通 API但它依赖的是 Android 系统对画中画模式的完整支撑。在鸿蒙上这一整套底层支撑要换成鸿蒙自己的。1.2 Flutter 插件鸿蒙化的两条路很多团队第一次接触Flutter 应用跑鸿蒙这件事会以为重写一遍 UI 就行。实际上 Flutter 上鸿蒙现在主流路线是把 Flutter engine 在 OpenHarmony 环境里编译运行然后让三方插件通过鸿蒙侧的插件桥接层跟系统能力通信。具体到插件层面有两条路可以走第一条路是找 OpenHarmony SIG 维护的官方适配版本把原来面向 Android/iOS 的插件换成鸿蒙实现API 尽量对齐业务方代码几乎不用动。第二条路是自研插件直接把原库的 Dart API 保留下来把原生实现从 Android 换成鸿蒙的 ArkTS 接口。这次适配 pip 库我走的是第二条路。原因很简单画中画这个能力跟系统绑定太深官方适配生态里对三方库的覆盖还没那么全而且业务方已经有大量基于 pip 库 Dart 接口写的代码换个 API 成本太高。保留 Dart 层、替换原生层是性价比最高的方案。1.3 画中画这个能力在跨端上的特殊性画中画和普通视频播放最大的区别在于它不是一个纯粹的窗口而是系统窗口管理的一部分。在 Android 上它跟Activity生命周期绑定在鸿蒙上它跟UIAbility的生命周期和系统 PiP 控制器绑定。也就是说Flutter 端那个PipController.start()调下去原生端要做的事情远不止弹个小窗这么简单要把视频渲染的输出从原来的 Flutter 页面窗口迁移到系统 PiP 窗口。要处理前后台切换应用退到后台时触发进入画中画。要在 PiP 窗口上提供用户控制按钮并把控制事件回传给 Flutter 层。这三点任意一点在鸿蒙上的实现方式都跟 Android 不一样。所以不能指望跑通编译就完事必须把鸿蒙的系统能力逐项对齐。2. 鸿蒙侧画中画能力拆解从 PiP 控制器到媒体会话的对接方案2.1 系统给应用开放了什么HarmonyOS 从 API 版本演进开始逐步开放了系统级的画中画能力。应用侧拿到的是一套 PiP 控制器和窗口相关的接口通过它来创建画中画窗口、设置控制按钮、控制窗口显示和结束。因为不同版本 SDK 的接口命名会有差异这里我用的是我们项目当时实际使用的接口语义你对接时以你们工程依赖的 SDK 对应文档为准但整体职责是一一对应的。系统 PiP 能力的几个核心职责创建画中画窗口指定视频内容的显示区域系统会把它托管在全局窗口层级上。设置纵横比和控制按钮比如播放/暂停、关闭、恢复全屏这些按钮由系统自动渲染应用只需要绑定回调。监听 PiP 生命周期进入画中画、退出画中画、用户点击恢复大屏等事件都需要回传给业务层。这些职责正好对应 Flutter 端 pip 库暴露给业务方的那几个接口。搞清楚这张映射关系后面写桥接代码就有的放矢了。2.2 视频流是画中画的灵魂画中画窗口本身只是一个容器它得知道要显示什么画面。这就要说到鸿蒙的媒体能力——AVPlayer。项目里的视频播放器在鸿蒙侧是通过AVPlayer播放的播放器产生的视频流可以绑定到 PiP 窗口的显示区域上。这里有个特别容易搞混的点画中画不是再开一个播放器而是同一个播放器换一个渲染出口。业务方在主界面播放视频时AVPlayer的 surface 绑定在普通页面窗口上进入画中画后要把这个渲染输出迁到 PiP 窗口去。如果重新拉一个播放器来播就会出现进度对不上、音频重叠、双倍内存这类问题。所以适配工作里最核心的一步是让 PiP 窗口显示的内容和主界面播放的内容是同一条视频流。我在设计对接方案时把播放器实例是否唯一当作一条硬性检查项所有场景下都保证只有一个AVPlayer实例在工作。2.3 多任务窗口的层级逻辑画中画之所以能在多任务下并行交互是因为系统把 PiP 窗口放在了一个独立的窗口层级不依附于应用主窗口。用户在聊天、看新闻、刷设置页时PiP 窗口始终浮在最上层但又不至于遮挡关键内容。这种窗口层级逻辑Flutter 侧是感知不到的。Flutter 只知道自己的页面窗口在哪至于系统是否把那个悬浮小窗插到全局层级是鸿蒙原生侧的事。适配时我认为最好把这个逻辑封装在原生插件内部Dart 端不要关心窗口层级只用进入画中画/退出画中画这种业务语义。这样后续鸿蒙系统升级、窗口策略调整Dart 代码可以不动。2.4 接口映射表在动手写代码之前我先把 Flutter 端 pip 库的 Dart API 和鸿蒙系统能力做了一个映射这份表是整个适配方案的基础Flutter pip 库 Dart API业务语义鸿蒙侧对应能力PipController.start()创建画中画窗口并进入创建 PiP 控制器绑定 AVPlayer 画面PipController.updateRect()更新悬浮窗尺寸/位置设置 PiP 窗口显示区域和纵横比PipController.setState()更新播放/暂停状态同步控制按钮状态到 PiP 控制器PipController.restore()从 PiP 恢复主界面退出 PiP 窗口重新绑定渲染出口到主窗口PipController.close()结束画中画停止 PiP 控制器并释放关联资源PipEvent.onStateChanged业务监听 PiP 状态变化PiP 生命周期事件通过 EventChannel 回传有了这张映射表整个适配工程就被拆成了两个模块一是把 Dart API 调用翻译成鸿蒙系统调用二是把鸿蒙系统回调翻译成 Dart 事件。下面第三部分的工程实践全部围绕这两条线展开。3. 动手适配把 pip 库拆成通道控制器两层做鸿蒙插件3.1 工程骨架和依赖声明鸿蒙插件和 Android 插件在工程形态上很像也是宿主工程 插件模块的结构只不过构建方式和配置文件换成了鸿蒙的一套。插件模块需要用oh-package.json5声明依赖通过 DevEco Studio 的构建链把它打包给宿主工程引用。我当时搭的插件模块大致这样的结构ohos_pip_plugin/ ├── oh-package.json5 ├── src/ │ ├── main/ │ │ ├── module.json5 │ │ ├── ets/ │ │ │ ├── PipPlugin.ets │ │ │ ├── PipController.ets │ │ │ └── PipEventChannel.ets │ └── ...依赖声明里要注意插件要能拿到 Flutter 引擎的通道注册能力所以需要在oh-package.json5里加上 Flutter 插件基础依赖。这个依赖版本要和宿主工程的 Flutter SDK 版本、鸿蒙侧适配版本严格对齐不然会出现运行时找不到符号的问题。3.2 控制通道MethodChannel 下发创建、更新、恢复、销毁Flutter 端 pip 库和鸿蒙原生端的控制通路用MethodChannel实现。我把所有需要原生侧执行的操作都定义成方法enterPip、updatePipSize、updatePlayState、restorePip、closePip。原生侧的处理入口大概是这样的// PipPlugin.ets class PipPlugin { private controller: PipController; handleMethodCall(method: string, args: Recordstring, Object): PromiseObject { switch (method) { case enterPip: return this.controller.enterPip(args); case updatePipSize: return this.controller.updatePipSize(args); case restorePip: return this.controller.restorePip(); case closePip: return this.controller.closePip(); default: return Promise.reject(new Error(unknown method: ${method})); } } }ArkTS 的Recordstring, Object接收 Flutter 侧传来的参数跟 Dart 侧传的MapString, dynamic一一对应。这里需要注意Flutter 的int、double在鸿蒙侧收到之后类型可能是number但具体是整型还是浮点型取决于序列化过程。我建议所有跟窗口尺寸、视频时长相关的参数在两边都统一用 double 传输避免精度截断带来的显示问题。Dart 侧调用保持原库的 API 不变class PipController { static const _kChannel MethodChannel(com.example.pip/native); Futurevoid enterPip(PipOptions options) async { await _kChannel.invokeMethod(enterPip, { videoId: options.videoId, position: options.position.inMilliseconds.toDouble(), width: options.width, height: options.height, }); } }这样业务方接入时看到的还是原来的pip库的接口完全无感。3.3 状态通道EventChannel 把 PiP 生命周期事件推给 Flutter画中画是异步交互用户可能随时在 PiP 窗口上点关闭、点恢复大屏。这些事件原生侧得主动往 Flutter 推所以要用EventChannel而不是MethodChannel。我在鸿蒙侧封装了一个PipEventChannel专门负责把 PiP 状态事件流推给 Dart 端// PipEventChannel.ets class PipEventChannel { private handler: EventChannelHandler | null null; setHandler(handler: EventChannelHandler): void { this.handler handler; } emit(event: string, data: Recordstring, Object): void { if (this.handler) { this.handler.success({ event, data }); } } }Dart 端原库本来就定义了PipEvent回调我只需要在适配层把 EventChannel 收到的事件转成原来的事件对象void _bindEventChannel() { const _kEventChannel EventChannel(com.example.pip/events); _kEventChannel.receiveBroadcastStream().listen((event) { final map Mapdynamic, dynamic.from(event as Map); switch (map[event]) { case onPipEnter: // 通知业务方视频已经进入画中画 case onPipRestore: // 通知业务方从悬浮窗恢复大屏 case onPipClose: // 通知业务方 PiP 窗口已关闭 } }); }这里有一个挺容易被忽略的点在 Flutter 应用从后台回到前台、或者 PiP 窗口状态切换比较频繁时EventChannel 的订阅关系要确保是单例的。我在项目里遇到过重复订阅导致事件被回调两次的问题后来在PipController初始化时做了幂等处理重复调用_bindEventChannel会先取消上一个订阅。3.4 ArkTS 端 PiP 控制器的关键实现PipController.ets是原生侧的核心类。它要做的事情有两个一是管理 PiP 控制器的创建和销毁二是把系统的 PiP 事件回调转给PipEventChannel。创建 PiP 控制器时最关键的是把视频画面绑到 PiP 窗口。在我用的这套接口上大概逻辑是// PipController.ets export class PipController { private pipController: PiPController | null null; private player: AVPlayer | null null; private eventChannel: PipEventChannel; async enterPip(args: Recordstring, Object): Promisevoid { const videoId args[videoId] as string; // 1. 拿到当前正在播放的 AVPlayer 实例 this.player MediaManager.getInstance().getCurrentPlayer(videoId); if (!this.player) { throw new Error(player not found for videoId: ${videoId}); } // 2. 创建 PiP 控制器绑定播放器画面 const config this.buildPipConfig(args); this.pipController await PiPController.create(config); this.pipController.setVideoSurface(this.player); // 3. 注册生命周期回调 this.pipController.on(stateChange, (state) { const mappedEvent this.mapStateToEvent(state); this.eventChannel.emit(mappedEvent, { videoId }); }); } }这段代码虽然只是简化版但体现了适配的核心思想播放器实例唯一PiP 控制器只是播放器画面的另一个显示出口。3.5 必要配置权限、后台运行、Ability 模式鸿蒙对画中画这类悬浮能力管控比较严配置没到位代码写得再好也弹不出悬浮窗。当时我在module.json5里检查了这几个点应用需要声明媒体播放相关的权限否则AVPlayer无法正常初始化。PiP 窗口需要在后台继续播放应用的后台任务策略得配置为允许媒体播放类型的任务。UIAbility的启动模式会影响多任务并行表现。我当时用的启动模式如果设置成多实例每次切前台可能拉起新页面导致 PiP 恢复大屏时状态错乱改成单实例之后恢复逻辑就正常了。这几个配置项不同版本的 SDK 提供的配置入口可能不一样但排查思路是一致的画中画弹不出来先看权限和任务策略恢复大屏状态错乱先看 Ability 的实例模式。3.6 生命周期对接前后台切换的语义要在原生侧收口Flutter 侧对应用前后台的生命周期感知和鸿蒙不完全同步。鸿蒙的UIAbility切后台时会回调几个生命周期方法这个时机正好适合判断是否要自动进入画中画。我的处理方式是把这层逻辑全部收口在原生插件里UIAbility切后台时如果当前有正在播放的视频而且业务方设置了自动进入画中画原生侧直接创建 PiP 控制器。Flutter 业务代码不需要监听生命周期也不需要自己决定什么时候调enterPip整个体验跟 Android 原生应用的行为一致。这也是标准化的关键——同样的业务代码在 Android 和鸿蒙上表现出一致的前后台行为而不是每个平台各写一套调用逻辑。4. 真机实测中踩过的坑从黑屏到闪跳的完整排查链路4.1 PiP 窗口弹不出来先查媒体会话和权限第一天真机联调最打击人的场景Flutter 侧调了enterPipMethodChannel 也收到方法了鸿蒙侧日志显示一切正常但屏幕上就是没有 PiP 窗口。这时候我做的第一件事不是改代码而是看系统日志里有没有权限或者媒体会话相关的告警。果然日志里出现了媒体会话异常的提示。原因是AVPlayer虽然初始化成功但它没有一个有效的媒体会话绑定PiP 控制器拒绝在无效会话上创建窗口。处理方法是先把媒体会话创建好让播放器与会话建立关联然后再创建 PiP 控制器。这个顺序不能乱一旦乱序系统会认为应用没有在合法播视频自然不给画中画权限。4.2 悬浮窗里黑屏问题出在窗口绑定顺序PiP 窗口能弹出来了但里面是黑屏。排查链路是这样的先在普通播放页面确认视频画面正常排除 Flutter 页面渲染问题。在进入 PiP 的代码里打印播放器状态确认播放器没有暂停、没有 seek。怀疑是渲染 surface 绑定时机不对于是做了个实验进入 PiP 后延迟 500ms 再绑定画面黑屏消失但偶尔出现画面闪烁。最后定位到根因PiP 控制器创建后系统要给窗口分配渲染 surface这个分配是异步的。如果我立刻绑定播放器画面绑定的 surface 可能还没就绪画面自然就丢了。修正方案是在 PiP 控制器状态回调里等窗口就绪再执行绑定操作并且绑定前暂停一帧的渲染输出避免丢帧。4.3 从 PiP 恢复大屏时位置闪跳黑屏解决之后又碰到一个交互级 bug用户在 PiP 窗口上点恢复大屏应用退回主界面但页面里的视频画面会先闪一下错位然后才回到正常位置。这个问题的排查比黑屏更绕因为它不涉及崩溃只是视觉上的抖动。我一开始怀疑是 Flutter 页面AspectRatio计算的问题把视频容器尺寸和 PiP 窗口比例对比了一遍没发现异常。后来才注意到是原生侧把 surface 从 PiP 窗口切回主页面窗口时主窗口的视频渲染层还停留在上一个窗口状态需要重新触发一次布局。解决方案是在恢复大屏时先把视频渲染出口绑定到主窗口再强制刷新页面视频容器的布局。Flutter 侧只需要提供一个restore完成回调业务方在这个回调里执行一次setState问题就消失了。4.4 后台播放被打断媒体焦点与任务策略还有一个场景视频在 PiP 里正常播放用户切到另一个 App 刷了十分钟短视频回来发现 PiP 窗口还在但声音停了。这个问题表面上像播放器被系统杀死了实际上是被系统的媒体策略打断了。鸿蒙对后台媒体播放有自己的一套管理机制多个媒体应用同时播放时系统可能暂停掉不活跃的那个。处理方式有两步一是确保应用配置了合理的媒体播放任务策略二是在 PiP 模式下媒体会话要标记为活跃让系统知道这个应用正在持续播放。4.5 同样的代码在平板上失效多实例 Ability 的坑最诡异的一个问题是同一套适配代码在手机上一切正常在平板上进入 PiP 后只要一点恢复大屏应用就重新走初始化流程视频状态全丢了。后来查了官方资料才发现不同设备上应用可能被系统以不同的实例模式拉起而平板对分屏多任务的策略跟手机不一样。修复方式是把UIAbility的启动模式改成单实例然后恢复大屏时通过startAbility的参数把当前视频 ID 和播放进度带过去。这个 case 也提醒我鸿蒙适配一定要做多尺寸设备验证不能只在一种设备上自测通过就上线。下面把几个坑整理成一张排查表方便后续团队快速对照现象可能根因排查链路处理方案PiP 窗口弹不出媒体会话未绑定或权限缺失检查日志中会话/权限告警先建会话和绑定再创建 PiP 控制器悬浮窗黑屏surface 绑定时机过早延迟绑定/等待就绪回调在窗口就绪回调里绑定视频画面恢复大屏位置闪跳surface 切换后布局未刷新对比窗口比例、检查绑定顺序恢复后触发主界面重新布局后台播放被打断媒体会话任务策略未配置检查系统媒体日志配置后台播放策略标记会话活跃平板恢复大屏重走初始化Ability 多实例导致状态丢失核对设备实例策略差异单实例模式恢复时携带视频参数5. 多任务并行交互的验收清单与效果验证5.1 核心场景逐个过适配做完光能跑不算数还得验证多任务下的并行交互是否符合预期。我列了一份验收清单团队每台测试设备都按这份清单过一遍播放中切后台视频自动进入 PiP 悬浮窗声音连续画面不闪烁。PiP 内操作控制点暂停、播放、关闭按钮状态和主界面播放器状态保持同步。PiP 与桌面切换回到桌面、打开别的应用PiP 窗口始终悬浮。PiP 恢复大屏点击恢复按钮主界面回到视频页面播放进度不丢失。全屏/小窗/竖屏切换旋转屏幕后PiP 窗口纵横比不拉伸、不变形。多任务并行在聊天应用里打字时PiP 窗口不遮挡输入区域可手动拖到屏幕边缘。每一项都要求 Flutter 侧业务代码不做任何分支处理仅通过原 pip 库的统一接口就能得到一致表现。能做到这一点才叫真正完成了标准化适配。5.2 怎么判断标准化是否到位我在项目里常跟团队说标准化的验证不在于你写了多少适配代码而在于业务方的调用代码有没有被平台差异污染。检查方式很简单——把 Android 和鸿蒙两份实现跑起来对比 Flutter 侧侧拿到的状态事件序列是否一致。比如 Android 上进入 PiP 的事件顺序是onPipEnter - onPlayStateChanged鸿蒙侧也必须走同样的顺序否则业务方写的事件驱动逻辑就可能出现竞态问题。这个工作在整个适配过程中花费的精力不比写代码少。因为平台底层 API 的调用节奏不一样需要反复调试点对齐状态机的时序。这也是最容易返工的地方一开始就对齐事件顺序后面能少走很多弯路。5.3 性能观察内存、帧率、切换流畅度画中画场景最容易出现的性能问题是切换瞬间的掉帧和内存增长。我在真机上分别记录了普通播放、进入 PiP、PiP 内操作、恢复大屏四个阶段的内存和帧率数据。实测下来只要保证 AVPlayer 实例唯一、PiP 窗口渲染复用同一份视频流内存增长基本可以忽略。画面切换掉帧主要发生在 surface 重新绑定的一瞬间这是系统窗口切换的正常开销控制在几帧以内不影响体验。如果发现严重掉帧优先检查是不是在切换过程中发生了重复的播放器初始化这种问题会造成双倍内存和明显卡顿。另外要提醒一点PiP 窗口虽小但它的渲染频率和主窗口一样长时间挂着会持续消耗资源。如果业务场景不需要画中画一定要确保关闭 PiP 后播放器也能正常释放不要留在后台空转。6. 适配 Flutter 三方库前建议先想清楚这三件事这次把 Flutter 的 pip 库搬到鸿蒙我最大的体会是真正费时间的不是 ArkTS 语法也不是平台通道怎么注册而是先吃透原库在 Android 上的职责边界和状态流转。你只有知道它每一步在做什么才能判断鸿蒙侧该用哪个能力去对应。如果你也要适配类似的 Flutter 三方库哪怕是做推送、支付、地图这类常见能力我建议动手前先想清楚三件事第一这个库的 Dart API 是否足够稳定如果 API 还在频繁变动适配层写完很容易被库升级冲掉。尽量锁定一个版本或者给适配层做个隔离接口。第二这个库依赖的原生能力在鸿蒙上是否有对等物比如画中画在 Android 有成熟方案鸿蒙也在逐步开放但有些库依赖的 Google 服务能力在鸿蒙上根本没有对等物这种库不是适配而是要换替代方案。第三业务方是否需要无感迁移如果只是让库在鸿蒙上能跑可以接受部分行为不一致但如果你要的是标准化那就得把事件顺序、生命周期、错误码、异常回调全部对齐这是一项独立的工程不是写个 MethodChannel 就完事。这次 pip 库鸿蒙化我最后留了一个小小的自定义能力给业务方在 PiP 控制器上扩展了两个自定义控制按钮用于视频倍速和清晰度切换。这个在 Android 上要改系统菜单鸿蒙侧接口也支持但需要业务方在原生层注册回调。如果你在做类似功能时也遇到系统 PiP 按钮不够用的问题可以顺着这个思路做不过要注意别塞太多按钮悬浮窗本来空间就有限控制项一多反而影响体验。
返回列表