HarmonyOS 鸿蒙Next中关于同一应用中画中画无缝切换的问题

HarmonyOS 鸿蒙Next中关于同一应用中画中画无缝切换的问题 背景:在应用上集成了一个视频播放的页面,通过NavPathStack将这个页面push以进入视频播放页。现在视频播放页做了一个画中画功能,点击之后预期行为时回到上一级页面,画中画组件置顶,可以正常进行其他操作,只有点击画中画的返回按钮或者点开另一个视频时返回到视频播放页,点击关闭画中画时销毁画中画组件。

现在做了个方案,有一些问题:进入画中画时,把需要持久化的存储,然后将视频页面pop,当点击画中画返回按钮的时候将页面重新push并恢复状态。这样有一个问题就是页面会重建,不能做到无缝切换的效果。

我现在找到了一个无缝切换的办法:将视频页先移动到NavPathStack栈底,这样上层操作就影响不到它,等点击画中画返回的时候再把它移到栈顶。但是这样对于已有代码可能会有兼容问题,不过不会发生重建,也可以实现无缝切换。

问题:进入画中画时,把需要持久化的存储,然后将视频页面pop,当点击画中画返回按钮的时候将页面重新push并恢复状态。这样有一个问题就是页面会重建,不能做到无缝切换的效果这个问题有没有好一点的解决方案。


更多关于HarmonyOS 鸿蒙Next中关于同一应用中画中画无缝切换的问题的实战教程也可以访问 https://www.itying.com/category-93-b0.html

11 回复

尊敬的开发者,您好,关于您反馈的问题:

  • 建议您将视频播放实例“AVPlayer”抽离出来独立管理其生命周期,这样在进入-退出页面时,不会影响到视频播放进度
  • 以XComponent实现画中画功能为例:
  1. 您可以让画中画实例与原本视频播放页面的XComponent组件关联同一个XComponentController
  2. 在创建画中画实例时,传递navigationId选项,传入该选项后系统会缓存与画中画实例关联的页面,确保还原场景下能够从画中画窗口恢复到原页面,从而实现在返回视频播放页面时无缝播放的效果.

可参考简单实现代码如下:

首页:

@Entry
@Component
export struct TransitionHomePage {
  @Provide('pathStack') pathStack: NavPathStack = new NavPathStack();

  aboutToAppear(): void {
    AppStorage.setOrCreate('MyAvPlayer', new MyAVPlayer());
  }

  @Builder
  pageMap(name: string) {
    if (name === 'TransitionDetailPage') {
      TransitionDetailPage()
    }
  }

  build() {
    Navigation(this.pathStack) {
      Column() {
        Button('点击进入视频播放页面', { type: ButtonType.Capsule})
          .width(200)
          .height(40)
          .onClick(() => {
            this.pathStack.pushPath({name: 'TransitionDetailPage'})
          })
      }.width('100%')
      .height('100%')
    }.navDestination(this.pageMap)
    .id('MyNav')
  }
}

视频播放页:

@Entry
@Component
export struct TransitionDetailPage {
  @Consume('pathStack') pathStack: NavPathStack;
  @State surfaceId: string = '';
  @StorageProp('isPlaying') isPlaying: boolean = true;
  player: MyAVPlayer = AppStorage.get('MyAvPlayer') as MyAVPlayer;
  xComponentController: XComponentController = new XComponentController();
  pipController: PiPWindow.PiPController | undefined = undefined;

  aboutToDisappear(): void {
    this.pipController?.stopPiP();
  }

  build() {
    NavDestination() {
      Stack({ alignContent: Alignment.BottomStart }) {
        XComponent({
          type: XComponentType.SURFACE,
          controller: this.xComponentController
        })
          .onLoad(() => {
            this.surfaceId = this.xComponentController.getXComponentSurfaceId();
            this.player.setSurfaceID(this.surfaceId);
            this.player.avPlayerFdSrc(this.getUIContext().getHostContext()!);
          });

        Column() {
          Image($r('app.media.back'))
            .width(10);
        }
        .width(40)
        .height(40)
        .borderRadius(20)
        .justifyContent(FlexAlign.Center)
        .backgroundColor('#33000000')
        .margin({ bottom: 170, left: 16 })
        .onClick(() => {
          this.pathStack.pop();
        });

        Row() {
          Image(this.isPlaying ? $r('app.media.ic_pause') : $r('app.media.ic_play'))
            .width(24)
            .height(24)
            .margin({ left: 12, right: 8 })
            .opacity(0.5)
            .onClick(() => {
              this.player.switchPlayOrPause();
              this.isPlaying = !this.isPlaying;
            });

          Image($r('app.media.startIcon'))
            .width(24)
            .height(24)
            .margin({ bottom: 0, right: 3 })
            .onClick(() => {
              if (this.pipController) {
                this.pipController.startPiP();
                this.pathStack.pop();
              } else {
                PiPWindow.create({
                  context: this.getUIContext().getHostContext(),
                  componentController: this.xComponentController,
                  navigationId: 'MyNav',
                  templateType: PiPWindow.PiPTemplateType.VIDEO_PLAY,
                }).then((pipWindow: PiPWindow.PiPController) => {
                  if (pipWindow) {
                    this.pipController = pipWindow;
                    this.pipController.startPiP();
                    this.pathStack.pop();
                  }
                });
              }
            });
        }
        .width('100%')
        .height(42)
        .backgroundColor('#33000000');
      }
      .width('100%')
      .height(220);
    }
    .backgroundColor('#F1F3F5')
    .hideTitleBar(true);
  }
}

MyAVPlayer实现:

import { hilog } from '@kit.PerformanceAnalysisKit';
import { media } from '@kit.MediaKit';
import { BusinessError } from '@kit.BasicServicesKit';

export class MyAVPlayer {
  // surfaceID用于播放画面显示,具体的值需要通过Xcomponent接口获取
  private surfaceID: string = '';
  private static avPlayer: media.AVPlayer | null = null;
  private status: string = '';
  public currentTime: number = 0;
  public duration: number = 0;

  // 注册avplayer回调函数
  setAVPlayerCallback(avPlayer: media.AVPlayer) {
    // startRenderFrame首帧渲染回调函数
    avPlayer.on('startRenderFrame', () => {
      hilog.info(0x0000, 'testTag', '%{public}s', 'AVPlayer start render frame');
    });
    // seek操作结果回调函数
    avPlayer.on('seekDone', (seekDoneTime: number) => {
      hilog.info(0x0000, 'testTag', '%{public}s', `AVPlayer seek succeeded, seek time is ${seekDoneTime}`);
    });
    // error回调监听函数,当avPlayer在操作过程中出现错误时调用reset接口触发重置流程
    avPlayer.on('error', (err: BusinessError) => {
      hilog.error(0x0000, 'testTag', '%{public}s',
        `Invoke avPlayer failed, code is ${err.code}, message is ${err.message}`);
      // 调用reset重置资源,触发idle状态
      avPlayer.reset();
    });
    avPlayer.on('timeUpdate', (time: number) => {
      this.currentTime = time;
      avPlayer.surfaceId = this.surfaceID;
    });
    avPlayer.on('durationUpdate', (time: number) => {
      this.duration = time;
    });
    // 状态机变化回调函数
    avPlayer.on('stateChange', async (state: string) => {
      switch (state) {
        // 成功调用reset接口后触发该状态机上报
        case 'idle':
          break;
        // avplayer设置播放源后触发该状态上报
        case 'initialized':
          // 设置显示画面,当播放的资源为纯音频时无需设置
          avPlayer.surfaceId = this.surfaceID;
          avPlayer.prepare();
          break;
        // prepare调用成功后上报该状态机
        case 'prepared':
          // 调用播放接口开始播放
          avPlayer.play();
          if (AppStorage.get('muteMode') === true) {
            avPlayer.setMediaMuted(media.MediaType.MEDIA_TYPE_AUD, true);
          }
          break;
        // play成功调用后触发该状态机上报
        case 'playing':
          this.status = state;
          AppStorage.set('isPlaying', true);
          break;
        // pause成功调用后触发该状态机上报
        case 'paused':
          this.status = state;
          AppStorage.set('isPlaying', false);
          break;
        // 播放结束后触发该状态机上报
        case 'completed':
          //调用播放结束接口
          avPlayer.stop();
          break;
        // stop接口成功调用后触发该状态机上报
        case 'stopped':
          // 调用prepare接口初始化avplayer状态重播
          avPlayer.prepare();
          break;
        case 'released':
          break;
        default:
          break;
      }
    });
  }

  // 以下demo为使用资源管理接口获取打包在HAP内的媒体资源文件并通过fdSrc属性进行播放示例
  async avPlayerFdSrc(ctx: Context) {
    // 创建avPlayer实例对象
    let avPlayer: media.AVPlayer = await media.createAVPlayer();
    // 创建状态机变化回调函数
    this.setAVPlayerCallback(avPlayer);
    // 通过UIAbilityContext的resourceManager成员的getRawFd接口获取媒体资源播放地址
    // 返回类型为{fd,offset,length},fd为HAP包fd地址,offset为媒体资源偏移量,length为播放长度
    let fileDescriptor = await ctx.resourceManager.getRawFd('product.mp4');
    let avFileDescriptor: media.AVFileDescriptor =
      { fd: fileDescriptor.fd, offset: fileDescriptor.offset, length: fileDescriptor.length };
    // 为fdSrc赋值触发initialized状态机上报
    avPlayer.fdSrc = avFileDescriptor;
    MyAVPlayer.avPlayer = avPlayer;
  }

  // 切换播放暂停状态
  switchPlayOrPause() {
    if (MyAVPlayer.avPlayer === null) {
      return;
    }
    if (this.status === 'playing') {
      MyAVPlayer.avPlayer.pause();
    } else {
      MyAVPlayer.avPlayer.play();
    }
  }

  // 播放
  play() {
    MyAVPlayer.avPlayer?.play();
  }

  // 暂停
  pause() {
    MyAVPlayer.avPlayer?.pause();
  }

  // 传入surface id
  setSurfaceID(surfaceId: string) {
    this.surfaceID = surfaceId;
  }

  getPlayer(): media.AVPlayer | null {
    return MyAVPlayer.avPlayer;
  }

  // 设置静音
  setToMuteMode() {
    MyAVPlayer.avPlayer?.setMediaMuted(media.MediaType.MEDIA_TYPE_AUD, true);
  }

  // 取消静音
  cancelMuteMode() {
    MyAVPlayer.avPlayer?.setMediaMuted(media.MediaType.MEDIA_TYPE_AUD, false);
  }
}

如果您需要实现不同页面间视频无缝播放,如列表页跳转至详情页无缝播放,可以参考示例代码:视频列表页跳转到详情页无缝播放
可参考文档:

更多关于HarmonyOS 鸿蒙Next中关于同一应用中画中画无缝切换的问题的实战系列教程也可以访问 https://www.itying.com/category-93-b0.html


对,思路基本就是让播放器实例不要跟着路由页面一起销毁。可以把 AVPlayer/播放状态放到页面外层的播放管理器或更高层组件里,LivePage 只是它的一种展示形态:全屏时显示大播放器,进入画中画时切成悬浮组件,返回时再恢复展示状态。重点是不要在 NavPathStack pop 时把播放器页面销毁后再重建,否则一定会遇到进度、缓冲、Surface 重新绑定的问题。如果项目必须走路由,也建议路由只切 UI 容器,播放器实例和 controller 维持同一个引用,退出播放业务时再统一 release。

更推荐把播放器实例从 NavDestination 生命周期里拆出来,而不是反复 pop/push 视频页。

可以按这个结构处理:

  1. 播放器、播放进度、当前媒体、播放状态放到页面外的 PlayerController。

  2. 视频页和画中画小窗都只绑定同一个 controller。

  3. 进入画中画时,在根容器或全局 Overlay 显示小窗,视频页可以隐藏或返回上级,但不要销毁播放器。

  4. 点击小窗返回时再导航到视频页,并把同一个 controller 重新绑定到页面 UI。

“把视频页移动到 NavPathStack 栈底”能保住页面,但会让路由语义变复杂,后续和返回栈、深链、页面恢复容易冲突。播放能力独立成 controller,页面只负责展示,一般更好维护。

有要学HarmonyOS AI的同学吗,联系我:https://www.itying.com/goods-1206.html,

你的两个方案本质上是:

方案A:Pop 页面 + PiP 独立存在

视频页
↓
进入PiP
↓
NavPathStack.pop()
↓
页面销毁
↓
PiP保留状态
↓
点击返回
↓
重新push视频页

优点:

符合Navigation设计
内存占用低

缺点:

页面重建
播放器重建
WebView重建
Surface重建
状态恢复复杂
无法做到无缝切换

方案B:把视频页沉到底部

Home
Video
↓
PiP
↓
Video移动到栈底
↓
Home成为栈顶

优点:

页面不销毁
播放器不销毁
真正无缝

缺点:

破坏现有路由语义
维护成本高
容易影响返回逻辑

从ArkUI Navigation机制看

NavPathStack 的设计本身是:

栈顶可见
非栈顶隐藏
Pop即销毁

并没有类似 Android Fragment 的:

detach
attach
hide
show

能力。

所以:

Pop后不销毁

目前做不到。


更推荐的方案

不要让视频页离开路由栈。

方案C:视频页一直存在,只切换显示模式

例如:

Home
└── VideoPage

进入 PiP:

this.isPip = true

页面不 Pop。

而是:

VideoPage
↓
主播放器隐藏
↓
PiP窗口显示

用户看到的是:

Home页面
+
悬浮PiP

但实际上:

VideoPage还在栈中

只是视觉上切换了状态。


更像 Android 的实现

很多视频APP:

腾讯视频
B站
优酷

其实不是:

销毁播放器
↓
重建播放器

而是:

播放器实例全局化

即:

class PlayerManager {
    static player?: AVPlayer
}

页面只是:

View

播放器才是:

Model

推荐架构

1. PlayerManager

全局唯一:

AppStorage.setOrCreate(
  'GlobalPlayer',
  player
)

播放器不跟页面生命周期绑定。


2. VideoPage

负责:

全屏播放器UI
弹幕
进度条
评论

3. PiPWindow

负责:

小窗展示

进入PiP

VideoPage
↓
隐藏自身播放器视图
↓
显示PiPWindow

播放器实例不变。


点击PiP返回

PiPWindow隐藏
↓
VideoPage恢复显示

播放器实例不变。


如果必须离开视频页

那建议:

保留页面

不要:

navStack.pop()

而是:

navStack.pushPath({
    name: 'Home'
})

让:

Home
Video

变成:

Home
Video
Home

Video页仍然留在栈中。

返回PiP:

pop()

直接回到原Video页。

页面不会重建。


我的建议

对于 HarmonyOS NEXT:

视频页 + PiP

最稳的方案不是:

持久化状态
↓
Pop
↓
重新Push

而是:

播放器实例全局化
+
VideoPage保活
+
PiP只是另一种展示形态

这样才能做到:

播放位置不丢
缓冲不丢
WebView不重载
弹幕不重建
真正无缝切换

也是目前视频类应用最常采用的架构思路。

看了下,核心点就是我的LivePage不需要通过路由启动了,而是作为1个 @ComponentV2 组件,悬浮在 Home 上面。是

这个意思吗?

对,差不多就是这个意思。

核心思路是让播放器的生命周期独立于路由页面生命周期,而不是把播放器和 NavPathStack 中的某个页面强绑定。

进入画中画时不要 pop 销毁视频页,而是让播放器组件(例如 @ComponentV2)继续存活,只切换展示状态(全屏/画中画)。这样返回时实际上还是同一个播放器实例,不会触发页面重建,也不需要恢复播放状态。

不过如果现有项目已经深度依赖 NavPathStack,你现在想到的“将视频页移到栈底、返回时再移回栈顶”的方案也是可行的,本质上也是为了保留页面实例,避免 pop -> push 导致重建。

你好,可以参考 https://developer.huawei.com/consumer/cn/forum/topic/0208203791677861025?fid=0104164651529951067 中我之前的回答。

主要思想就是:动态生成一个全局的播放页,避免重建组件。

画中画这类场景建议先把“播放实例”和“页面路由”解耦,不要把 AVPlayer/播放控制器绑在某个 NavDestination 的生命周期里。

可以把 player、当前媒体信息、进度、播放状态放到页面外的 controller 或全局状态中,视频页和画中画小窗都只是订阅同一个 controller。进入画中画时 pop 播放页,同时在根容器 Stack 上显示小窗,但 player 不销毁;点击小窗返回时重新 push 页面,页面重新绑定已有 player,而不是重新创建播放。关闭小窗时再统一 release。

这样页面重建只影响 UI,不会导致视频重新加载。切换到另一个视频时,也建议先让 controller 切源,再同步路由和小窗状态。

在鸿蒙Next中,同一应用内画中画无缝切换可通过 PipControllersetMediaSource 方法实现。先释放当前媒体源,再加载新源,并调用 updatePipParams 更新播放状态。确保 AVPlayerVideoPlayer 实例复用,避免重建窗口。注意 onPipModeChanged 回调中同步控件状态。

你可以采用“页面保留 + 画中画浮层”的方式来避免重建。
进入画中画时,不执行 pop,而是让视频页面变为不可见或尺寸置零,同时将画中画窗口以绝对定位悬浮在页面上层,这样原页面状态完整保留。返回时只需切换显隐,不涉及 navPathStack 的移动或重建,即可实现无缝效果。
若需降低栈管理复杂度,可通过控制原 VideoPage 的可见性(如 visibility 或尺寸)结合 Stack 容器实现。,

回到顶部