尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

Flutter视频会议项目鸿蒙画中画适配:pip_ios改造实录

发布时间:2026/9/29 21:59:27

资讯中心
01
ARTICLE

Flutter视频会议项目鸿蒙画中画适配:pip_ios改造实录

Flutter视频会议项目鸿蒙画中画适配:pip_ios改造实录
软件这东西最怕的不是“没有”而是“有但只能在一个平台上跑”。我手头就有一个典型的例子一个基于 Flutter 的视频会议项目客户端原本用的是pip_ios这个三方库来实现 iOS 上那种原生画中画体验悬浮窗控制、动态比例缩放都做得挺顺手。结果业务要扩展到鸿蒙设备问题立刻来了——pip_ios本质上绑定的是 iOS 的AVPictureInPictureController鸿蒙这边根本没有这套东西。这篇文章不是讲理论就是把我把pip_ios鸿蒙化的完整适配过程拆开来讲包含方案选型、鸿蒙画中画能力接入、悬浮窗控制器设计、动态比例缩放的实现以及一堆踩完坑之后才知道的细节。如果你也在做 Flutter 库的鸿蒙移植或者准备在鸿蒙设备上实现画中画、悬浮窗这类交互这篇可以直接当成一份参考指南。1. 鸿蒙化适配的背景与整体决策1.1 pip_ios 到底做了什么我们鸿蒙化的是什么先把这个三方库的底层逻辑说清楚。pip_ios在 Flutter 生态里定位很明确把 iOS 系统级画中画能力暴露给 Dart 层。它的内部链路是DartAPI →MethodChannel→ iOS 原生AVPictureInPictureController同时配合AVPlayerLayer做视频渲染再通过 PiP 的contentSource机制让画中画窗口可以承载自定义内容而不只是系统播放器的那个固定样式。所以鸿蒙化适配不是简单地把 MethodChannel 换个名字而是要从三层来对应替换Dart 层 API要保持原有调用方式不太变这样上层业务代码不用大改。原生能力层iOS 的AVPictureInPictureController换成鸿蒙的 PiP 能力组件。交互层iOS 的 PiP 支持手势缩放、拖动、关闭、返回全屏等鸿蒙侧需要重新绑定对应的事件实现。举个更具体的例子原来pip_ios的悬浮窗控制器支持外部传入一个contentViewiOS 原生会把这个 view 挂到 PiP 的contentSource上。鸿蒙的 PiP 窗口是基于系统组件PiPWindow的模板化能力它并不是一个随便塞 view 的容器而是要求你按官方模板设置内容。这个差异是所有适配工作里第一个也是最关键的岔路口。1.2 方案选型封装接管、通道桥接、双端重写面对pip_ios在鸿蒙上失效的问题我当时在高强度讨论后收敛出三条路线各自的取舍列个表对比一下方案做法优势劣势适用场景方案A原生封装替换保留pip_ios的 Dart API 形状在鸿蒙侧写一套原生 Kotlin/ArkTS 实现通过同一套 MethodChannel 名称对接Dart 层不感知平台差异原生实现工作量大鸿蒙 PiP 模板机制需要适配业务已经深度使用pip_ios不想动 Dart 代码方案B桥接层转换把pip_ios呼叫的 MethodChannel 在鸿蒙侧用一个桥接代理拦截转调鸿蒙 PiP API可以不改pip_ios源码靠运行时拦截完成替换桥接层易碎版本升级容易失效不推荐长期维护临时验证、Demo 演示方案C双端重写设计一个跨平台PipService抽象各自平台独立实现通过依赖注入接入结构最干净后续扩展平台最容易工作量大需要动业务层 Dart 代码新项目、或者适配周期充裕我最终选了方案A的变体不直接改pip_ios包源码而是注册一套同名的 MethodChannel handler让pip_ios框架在鸿蒙上找到对应实现。这里有个关键点——鸿蒙端运行 Flutter 时引擎本身已经支持 MethodChannel 机制也就是说只要通道名和参数格式能对齐上层 Dart 代码是完全可以做到零修改的。1.3 鸿蒙化整体架构设计最终的鸿蒙化架构分成了四层Dart 业务层调用PipController、PipOptions、PipConfig等对外的 API这部分和 iOS 上的写法保持一致。通道抽象层沿用pip_ios定义的 method、参数、回调格式在鸿蒙 Flutter 引擎中以 MethodChannel 注册同名 handler。能力适配层把鸿蒙的PiPWindow、PiPController能力做一次薄封装赋予它和 iOS 接口类似的语义。系统资源层申请权限、监听前后台切换、处理系统窗口层级。这样做的一个额外好处是后续如果还要支持 Android 的 PiP可以在抽象层再插一个实现业务代码不用再填坑。2. 鸿蒙画中画能力与权限体系准备2.1 HarmonyOS PiP 能力总览鸿蒙系统的画中画能力从 API 9 开始逐步完善到 API 12 时已经形成了比较成熟的接口集。核心的类和接口包括PiPWindow提供一个可以控制系统级画中画窗口的控制器。PiPConfiguration配置画中画窗口的参数包括尺寸、位置、是否可拖动、是否可缩放。PiPTemplate指定内容类型比如视频、网页、自定义 UI。PiPController操控画中画窗口的行为比如启动、停止、更新内容。需要特别留意的是鸿蒙 PiP 的窗口管理逻辑和 iOS 不太一样。iOS 的 PiP 是系统级统一管理以一种“悬浮于所有页面之上”的方式存在。而鸿蒙 PiP 更像是一个在应用内部生命周期内运行的系统窗口它需要绑定UIAbility或者UIExtension上下文当应用进入后台时依然可以显示但如果应用被清理PiP 窗口也会随之关闭。我画个比对表方便大家理解两者特性的差异能力维度iOS AVPictureInPictureControllerHarmonyOS PiPWindow内容承载方式支持任意 UIView 作为 contentSource模板化内容需通过PiPTemplate定义启动时机应用进入后台或主动 Start需要主动调用 start且要求特定上下文拖动系统自动处理通过setDragEnabled控制比例缩放不原生支持通过自定义尺寸更新支持自定义交互控件可添加辅助控制按钮支持模板内自定义控制按钮生命周期监听系统回调事件通过OnPipStateChanged监听2.2 画中画生命周期的接入鸿蒙 PiP 的生命周期接口设计得比较“正统”绑定在 UIAbility 里。核心是在 Ability 的onWindowStageCreate或onForeground阶段创建 PiP 控制器在onBackground阶段决定是否启动画中画。我的接入方式是这样的在EntryAbility中持有PiPController实例。在 Flutter 层需要开启画中画时通过 MethodChannel 发送startPip指令。原生侧收到指令后先判断当前 UIAbility 的状态是否为后台如果是后台直接创建PiPWindow如果还处于前台需要先注册一个生命周期监听等待onBackground之后自动拉起。在onDestroy时调用stopPip避免窗口残留。这里有个实际很容易犯的错误很多开发者都在前台直接调startPip结果画中画窗口不显示又找不到原因。鸿蒙 PiP 虽然允许在前台启动但窗口的层级表现和后台场景完全不同而且部分设备要求必须进入后台才会显示 PiP 窗口。稳妥的做法是——Promote 原则先把必要的 PiPController 初始化好但启动动作放在后台切换之后。2.3 权限与前置条件鸿蒙画中画本身不需要像 Android 那样动态申请“悬浮窗”权限但有两个前置条件必须满足应用必须具备ohos.permission.KEEP_BACKGROUND_RUNNING权限用于在后台保持应用的运行状态。如果没有这个权限应用进入后台后可能会很快被挂起PiP 窗口还没来得及渲染就停住。应用需要配置backgroundModes对于画中画场景需要在module.json5里配置关联的后台模式一般是pip或audioPlayback。权限配置的代码位置在entry/src/main/module.json5中requestPermissions字段里加{ name: ohos.permission.KEEP_BACKGROUND_RUNNING, reason: $string:keep_background_reason, usedScene: { abilities: [EntryAbility], when: inuse } }还有一个容易被忽略的点如果应用目标是 API 12 及以上还需要在app.json5中配置requestPermissions的权限申请方式不能只写在 module 里。这个我踩过一次模拟器上跑得好好的真机上 PiP 窗口就是不出现查日志发现权限 silently denied。3. 悬浮窗控制器的设计与实现3.1 Flutter 侧接口抽象pip_ios对外暴露的主要是PiPViewController它承担了三件事设置画中画内容、控制画中画的显示/隐藏、响应画中画状态事件。我在鸿蒙化过程中基于这个接口设计了一套平台无关的抽象abstract class PipController { Futurevoid start(PipConfig config); Futurevoid stop(); Futurevoid updateContent(FittedView view); Futurevoid updateScale(double scale); StreamPipEvent get pipEventStream; } class PipConfig { final double width; final double height; final bool draggable; final bool zoomable; final PipGravity gravity; final String contentTemplate; }这套抽象和pip_ios原本的 API 保持了高度相似只是把一些 iOS 特有的参数比如是否启用系统自动旋转拆走替成了鸿蒙 PiP 相关的参数例如contentTemplate。可能有同学会问为什么不直接 fork 一个pip_ios_harmony保留一模一样的 API理论上可以但实践上不推荐——因为pip_ios的 iOS 侧实现非常“依赖系统视图”很多参数在鸿蒙上没有对应物强行保留会造成 API 假象调用者以为生效了实际没有。3.2 原生侧悬浮窗实现鸿蒙侧的悬浮窗不是普通应用内的FloatingWindow而是PiPWindow这种系统级窗口控制器。初始化代码大致这样// EntryAbility.ets let pipController: PiPController | null null; private initPipController() { let config new PiPConfiguration(); config.setTemplate(PiPTemplateType.VIDEO_PLAY); config.setVideoSize({ width: 320, height: 180 }); // 这里允许后续动态更新尺寸 config.setDragEnabled(true); config.setZoomEnabled(true); pipController PiPController.create(config); pipController.setEventHandler({ onStateChanged: (state: PiPState) { // 把状态回传 Flutter this.sendPipEventToFlutter(state); } }); }PiPController.create返回的控制器就是整个悬浮窗控制器的核心。它支持的能力有更新画中画的内容比如从一个视频源切换到另一个。修改窗口的宽高以实现动态比例缩放。控制窗口是否可拖动、是否允许用户通过手势缩放。监听窗口的显示、隐藏、恢复全屏等状态。3.3 自定义内容渲染与触摸事件处理鸿蒙 PiP 的模板机制在自定义内容上走了一条相对封闭的路。虽然支持PiPTemplateType.CUSTOM但自定义内容是靠PixelMap或者系统控件快照来更新的也就是说它不是实时渲染视图树而是一帧一帧地提供画面。这个限制对视频场景基本没影响因为视频帧本来就是逐帧更新的。但如果你要在悬浮窗里显示一个带有交互按钮的自定义 UI那就不能依赖渲染树的实时更新了。我的做法是在 Flutter 侧把自定义 UI 通过RepaintBoundary截成图片。转成PixelMap或ArrayBuffer通过 MethodChannel 传给鸿蒙侧。鸿蒙侧调用PiPController.updateContent更新内容。触摸事件方面鸿蒙 PiP 模板内的控制按钮是预定义的比如“关闭”“全屏”“上一集”“下一集”通过controlGroup配置。要接收这些按钮事件需要在创建PiPController时设置一个controlEventHandler然后把事件转发给 Flutter。这里有个比较隐蔽的问题PiPController不会把用户在 Flutter 层自定义的某个 Dart 方法自动映射到模板按钮上必须自己维护一个“按钮ID → Dart 回调”的映射表所以我在原生侧专门写了一个ControlActionDispatcher用MapInt, String记录按钮 ID 到 Dart method name 的关系。4. 动态比例缩放的手势与数学实现4.1 比例计算模型动态比例缩放是本项目里比较精细的一部分。pip_ios在 iOS 上的实现并不原生支持用户手势缩放它是通过应用自己计算并调用AVPictureInPictureController来更新渲染内容的。鸿蒙侧则要接住PiPWindow的尺寸更新接口。比例计算的核心逻辑是维护一个baseSize和currentScale当前宽 baseWidth * currentScale 当前高 baseHeight * currentScale但光有这个公式不够实际操作中要处理两件事用户手势的累计缩放值怎么映射到currentScale。不同方向的捏合操作如何归一化解算。我的实现是scaleFactor (当前两指距离 / 初始两指距离) * lastScale currentScale clamp(scaleFactor, minScale, maxScale)其中minScale和maxScale由平台侧约束决定。鸿蒙 PiP 窗口有一个系统级的最小尺寸阈值当用户缩得太小时窗口会被系统强制恢复到一个安全尺寸。所以 Flutter 侧要做的其实是比系统更早地钳制范围避免在接近边界时频繁触发系统干预造成画面跳跃。4.2 手势识别与参数传递动态缩放的触发源有两种用户在画中画窗口上直接捏合缩放原生侧手势识别。用户通过应用内滑杆/Fluid 手势调整比例Dart 层主动调用。对于第一种鸿蒙侧可以用GestureDetector或者PiPController的事件回调拿到原始触摸事件然后在原生层完成计算直接更新 PiPWindow 尺寸不需要走一次 Flutter 桥接。这条路最快最稳。但对于第二种涉及跨层通信。我设计了一个带防抖的通道协议// Flutter 侧 const channel MethodChannel(pip_ios_harmony/pip); await channel.invokeMethod(updateScale, { scale: currentScale, animate: true, durationMs: 200, });鸿蒙侧收到updateScale后会更新PiPConfiguration里的尺寸并调用PiPController.updateWindowSize。如果开启动画则用animateTo方式平滑过渡。这个延时和动画时长需要根据实际设备表现调参不同设备在系统窗口合成效率上差异挺大我最终把默认动画时长定在 250ms既不会晃眼也不会拖沓。4.3 边界约束与防抖处理缩放值不能只做一个上下界钳制还需要处理边界附近的阻尼感否则用户缩到最大时会直接“卡死”没有物理反馈。我在 Dart 层实现了一个带阻力区域的映射当 scale 超过 maxScale 时继续捏合不再放大但保留一个 12% 的顺滑缓冲 缓冲区内 scale 变化速度减半 手指离开时如果处于缓冲区自动回弹到 maxScale这么做是模仿 iOS 原生交互中的弹性和惯性。如果用原生控件来实现会很复杂但在自己控制的 Flutter 层里做一个数学映射就简单很多效果也几乎一致。防抖处理主要针对频繁的updateScale调用。鸿蒙的系统窗口更新并不是完全免费的每帧都更新会导致 UI 线程吃紧所以在原生侧我加了一个 16ms 的最小间隔限制if (now - lastUpdateTime 16) { return } lastUpdateTime now也就是说手势哪怕每秒触发 120 次实际落在鸿蒙 PiP 窗口上的更新也只会有接近 60 次刚好匹配帧率。5. 实操过程与踩坑记录5.1 鸿蒙工程目录结构与接入步骤实际操作中工程的模块结构是按鸿蒙标准工程配置的entry/ ├── src/main/ │ ├── ets/ │ │ ├── entryability/ │ │ │ └── EntryAbility.ets │ │ ├── pages/ │ │ │ └── Index.ets │ │ └── pip/ │ │ ├── PipHost.ets │ │ ├── PipEventHandler.ets │ │ └── ControlActionDispatcher.ets │ ├── resources/ │ └── module.json5Flutter 侧的接入步骤分四步注册 MethodChannel handler在鸿蒙工程里通过FlutterAbility拿到engine调用setMethodCallHandler。映射通道名pip_ios在 iOS 上用的通道名要保留例如com.example.pip_ios/controller不能改否则 Dart 侧调用直接找不到 handler。初始化 PiPController在EntryAbility的onWindowStageCreate中完成。回传事件把 PiP 状态变化、按钮点击事件通过EventChannel或MethodChannel.invokeMethod回传 Flutter。5.2 关键桥接代码走读Dart 侧的入口方法还是pip_ios用法只是内部通过Platform.isHarmonyOS判断走不同的实现class PipControllerFactory { static PipController create() { if (Platform.isIOS) { return IOSPipControllerImpl(); } else if (isHarmonyOS()) { return HarmonyPipControllerImpl(); } throw UnsupportedError(not supported); } }鸿蒙原生的 handler 核心是这样engine.setMethodCallHandler((call: MethodCall) { switch (call.method) { case startPip: this.pipController.start(); break; case stopPip: this.pipController.stop(); break; case updateScale: this.handleUpdateScale(call.arguments); break; case updateContent: this.handleUpdateContent(call.arguments); break; default: // 返回 not implemented } });这个方法走通之后整个链路就是Dart 调start()→ MethodChannel → 鸿蒙 handler → PiPController.start() → 系统画出画中画窗口。5.3 踩坑记录真机调试中的典型问题第一个坑就是类型转换不一致。pip_ios在 iOS 侧传递参数时很多数字是NSNumber类型Dart 侧解析成int。但鸿蒙侧 MethodChannel 传参时ArkTS 的Number会统一转成doubleDart 侧如果写arguments[scale] as int就会直接崩溃。解决办法是统一用num解析再转。第二个坑是生命周期事件丢失。鸿蒙的 PiP 窗口在应用杀进程时会走一个非常快的销毁流程如果不提前把 PipController 的监听注册好很可能会漏掉onDestroy事件导致 Flutter 层拿到的是窗口已经关闭但状态还是“开启中”的异常数据。我的处理是在onDestroy里同步发送一个“强制停止”事件给 Flutter让业务层无论之前是什么状态都重置到未启动。第三个坑是内容更新频率过高导致卡顿。我们把 Flutter 层的自定义 UI 截图后传给 PiP 窗口如果截图间隔太短鸿蒙窗口的内容更新队列会堆积最终表现为画面延迟甚至黑屏。解决方式是引入节流只有画面内容发生变化时才截图传过去连续相同画面则跳过。第四个坑是动态缩放在某些折叠屏上出现比例失真。这主要是因为部分鸿蒙设备的 PiP 窗口在缩放时会把内容裁剪而非等比缩放我需要在原生侧对齐目标宽高比后再调用更新接口不能直接把 Flutter 层计算好的 scale 原样传过去。还有一个小细节使用PiPTemplateType.VIDEO_PLAY模板时窗口中会自带一套播放控制控件如果业务方想完全自定义控制按钮必须换成CUSTOM模板。否则你传进去的自定义按钮会被系统模板排挤掉UI 上直接不显示。6. 测试、兼容性与性能验证6.1 测试用例设计适配完最怕的是“能用但不敢用”。我针对这个鸿蒙化版本设计了下面几组测试场景测试分组用例描述预期结果基础启动从 Flutter 调用 startPip应用同时退回后台PiP 窗口显示视频内容正常状态同步PiP 窗口被用户手动关闭Flutter 收到关闭事件业务态更新无残留缩放控制通过 Flutter 侧接口把 scale 从 1.0 调到 0.6窗口尺寸等比变化无闪烁手势缩放在 PiP 窗口上双指捏合放大缩小缩放顺滑不超过边界内容更新切换视频源PiP 内容同步更新延迟在 200ms 以内生命周期异常杀进程、强停应用、连续开关 PiP无崩溃、无错误日志多任务切换PiP 显示时切到其他应用再回来窗口稳定不被异常关闭6.2 多版本兼容性验证鸿蒙的 API 等级对 PiP 能力差异影响非常大。API 9 的 PiP 还比较初级PiPWindow的窗口尺寸更新能力很弱API 12 开始支持updateWindowSize和更完善的自定义模板API 14 在窗口动画和事件传递上做了一次大升级。所以这个适配版本的兼容策略是最低兼容到 API 9但不启用动态缩放能力只做基础画中画。API 9 ~ API 11使用PiPWindow.create(config)兼容路径。API 12启用完整能力包括动态缩放、自定义模板、控制按钮事件。代码里用canIUse(PiPController.create)之类的条件判断来做能力降级避免在低版本设备上崩溃。6.3 性能与内存检查画中画功能本身比较吃内存和合成带宽。实测中对一个 1280×720 视频源做画中画鸿蒙 PiP 窗口的 CPU 占用率约在 5%-12% 浮动波动取决于是否开启逐帧截图式自定义 UI。如果开启逐帧截图CPU 会明显上涨到 15% 左右。内存方面PiP 控制器实例本身只有几十 KB但自定义 UI 的 PixelMap 如果每帧都生成堆内存会有明显波动。后续我改成了“内容变化时刷新非变化时复用”内存曲线就稳定多了。一个比较有意思的发现是如果把PiPWindow的setZoomEnabled设置为 true系统会用自带的分辨率适配算法去处理内容缩放这时 Flutter 侧的截图更新频率要低于手势缩放频率否则会出现“两个缩放逻辑互相打架”的现象——画中画窗口时而按用户手势缩放时而被系统强制回弹。实际修复方式就是让原生手势缩放和 Dart 主动缩放共用同一个缩放源互斥更新。写在最后的经验分享这次适配做下来最大的感受是跨平台库的鸿蒙化难点不在写代码而在理解两个系统的“交互哲学”差异。iOS 的 PiP 更像一个“凌驾于应用之上的播放器”鸿蒙的 PiP 则更像“应用能力的一部分”前者强调系统的独立性后者强调应用的掌控力。所以适配中不要试图把一个心智模型生搬到另一个平台而是要把接口语义对齐能力实现各做各的。有一点我之后做项目一定会坚持从一开始就把跨平台抽象层留好哪怕业务目前只跑 iOS。等真的需要鸿蒙化或者 Android 适配时你会发现提前留的接口边界能省一半的时间。另外建议所有做鸿蒙画中画的同学一定尽早准备真机模拟器上 PiP 窗口的层级控制和真机差异非常大尤其是缩放和自定义按钮这两个点。这一套流程走完后面再做其他平台的 PiP 适配基本就是复制粘贴的水平了。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。