You need to enable JavaScript to run this app.
导航
移动端多前台任务
最近更新时间:2024.05.17 10:13:43首次发布时间:2024.02.20 10:54:49

移动端用户可以通过悬浮的小窗口边观看视频、收听音频,边浏览主屏幕或与其他应用进行交互,实现多前台任务处理。

如果你希望在应用内实现悬浮窗口布局,可以通过 setLocalVideoCanvassetRemoteVideoCanvas 设置画布大小和位置实现,参考 设置视频参数。例如,在 1 v 1 音视频通话中,将远端画面作为背景铺满设备屏幕,同时在屏幕一角展示本端画面。

前提条件

  • 你已经集成 RTC SDK,实现了基本的音视频通话。

  • iOS 端已经完成自定义视频渲染器的构建,实现视频画面的自定义渲染

  • 设备要求:

    • iOS 16 及以上版本

    • Android 8.0 及以上版本,API 级别 26

Android 端功能实现

你可以通过构建悬浮窗口在 Android 端实现前台多任务处理。悬浮窗口既可以既用于播放视频,也可以播放纯音频。

你还可以通过 Android 的画中画功能实现多前台任务。

  1. 检查是否有悬浮窗展示权限,如果没有权限需跳转到设置中心开启。
// 悬浮窗需要先请求权限
private void requestFloatingWindowPermission() {
    if (Build.VERSION.SDK_INT  >= Build.VERSION_CODES.M  && !Settings.canDrawOverlays(this)) {
        Intent intent = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:" + getPackageName()));
        startActivityForResult(intent, REQUEST_CODE_FLOATING_WINDOW);
    } else {
        // 已经获得了悬浮窗口权限,可以添加悬浮窗口
        addFloatingWindow();
    }
}

@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
    super.onActivityResult(requestCode, resultCode, data);
    if (requestCode == REQUEST_CODE_FLOATING_WINDOW) {
        if (Build.VERSION.SDK_INT  >= Build.VERSION_CODES.M  && Settings.canDrawOverlays(this)) {
            addFloatingWindow();
        } else {
            ToastUtil.showAlert(this, "未授予悬浮窗口权限,无法添加悬浮窗口");
        }
    }
}

@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
    super.onRequestPermissionsResult(requestCode, permissions, grantResults);
    if (requestCode == REQUEST_CODE_FLOATING_WINDOW) {
        if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
            addFloatingWindow();
        } else {
            ToastUtil.showAlert(this, "未授予悬浮窗口权限,无法添加悬浮窗口");
        }
    }
}
  1. 在 layout_float_window 中构建悬浮窗对应 UI。
private View createFloatWindowView(Context context, @LayoutRes int floatWindowLayoutResId) {
    return LayoutInflater.from(mContext).inflate(R.layout.layout_float_window, null);
}
  1. 构建悬浮窗 Window。
public FloatWindowManager(Context context, TextureView mainTextureView) {
    this.context = context;
    this.mainTextureView = mainTextureView;
    windowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
    DisplayMetrics displayMetrics = new DisplayMetrics();
    windowManager.getDefaultDisplay().getMetrics(displayMetrics);
    int windowSizeInPixel = convertDpToPixel(windowSizeInDp, displayMetrics.density);
    windowParams = new WindowManager.LayoutParams(
            windowSizeInPixel,
            windowSizeInPixel,
            Build.VERSION.SDK_INT  >= Build.VERSION_CODES.O  ? WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY  : WindowManager.LayoutParams.TYPE_PHONE,
            WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
            PixelFormat.TRANSLUCENT
    );
    windowParams.gravity = Gravity.TOP  | Gravity.START;
    
    // 视频渲染 View 的容器,将 SDK 进行视频渲染的目标 View 添加到此 ViewGroup 中
    floatView = new FrameLayout(context);
    closeButton = new Button(context);
    closeButton.setText("Close");
    floatView.addView(closeButton, new FrameLayout.LayoutParams(
            FrameLayout.LayoutParams.WRAP_CONTENT,
            FrameLayout.LayoutParams.WRAP_CONTENT,
            Gravity.BOTTOM  | Gravity.CENTER_HORIZONTAL
    ));
    floatView.setBackgroundResource(R.color.grey);
    isWindowOpen = false;

    // 悬浮窗拖动监听
    floatView.setOnTouchListener(new View.OnTouchListener() {
        @Override
        public boolean onTouch(View v, MotionEvent event) {
            switch (event.getAction()) {
                case MotionEvent.ACTION_DOWN:
                    initialTouchX = event.getRawX();
                    initialTouchY = event.getRawY();
                    initialWindowX = windowParams.x;
                    initialWindowY = windowParams.y;
                    return true;
                case MotionEvent.ACTION_MOVE:
                    float dx = event.getRawX() - initialTouchX;
                    float dy = event.getRawY() - initialTouchY;
                    windowParams.x = (int) (initialWindowX + dx);
                    windowParams.y = (int) (initialWindowY + dy);
                    windowManager.updateViewLayout(floatView, windowParams);
                    return true;
            }
            return false;
        }
    });
}
  1. 通过 WindowManager 展示悬浮窗。

需要将 RTC SDK 渲染远端视频的 View(TextureView 或 SurfaceView) 添加到 Window 中。

public void openWindow() {
    if (!isWindowOpen) {
        floatView.addView(mainTextureView);
        windowManager.addView(floatView, windowParams);
        isWindowOpen = true;
    }
}
  1. 渲染视频,以渲染远端视频为例。
private void setRemoteRenderView(String uid) {
    if (textureView.getParent() == null) {
        remoteViewContainer.removeAllViews();
        remoteViewContainer.addView(textureView);
    }
    VideoCanvas videoCanvas = new VideoCanvas();
    videoCanvas.renderView = textureView;
    videoCanvas.renderMode = VideoCanvas.RENDER_MODE_HIDDEN;

    RemoteStreamKey remoteStreamKey = new RemoteStreamKey(roomID, uid, StreamIndex.STREAM_INDEX_MAIN);
    // 设置远端视频渲染视图
    rtcVideo.setRemoteVideoCanvas(remoteStreamKey, videoCanvas);
}
  1. 关闭悬浮窗。
// 关闭悬浮窗,并将 SDK 渲染的视频 View 添加到 activity 的页面中
private void closeFloatingWindow() {
    floatWindowManager.closeWindow();
    if (textureView.getParent() != null) {
        remoteViewContainer.addView(textureView);
    }
}

public void closeWindow() {
    if (isWindowOpen) {
        floatView.removeView(mainTextureView);
        windowManager.removeView(floatView);
        isWindowOpen = false;
    }
}

iOS 端功能实现

iOS 端可以通过画中画 (Picture in Picture)功能实现前台多任务处理。

你也可以利用 UIWindow 创建悬浮窗口,实现该功能。

  1. 配置项目开启画中画选项。

  1. 创建画中画控制器。
func setupPipController(with sourceView: UIView) {
    
    if #available(iOS 16, *) {
        let callViewController = AVPictureInPictureVideoCallViewController()
        callViewController.preferredContentSize = CGSize(width: 360, height: 640)
        callViewController.view.backgroundColor = UIColor.clear
        
        let source = AVPictureInPictureController.ContentSource(activeVideoCallSourceView: sourceView, contentViewController: callViewController)
        
        let pipVC = AVPictureInPictureController(contentSource: source)
        pipVC.canStartPictureInPictureAutomaticallyFromInline = true
        pipVC.delegate = self
        self.pipVC = pipVC
    } else {
        ToastComponents.shared.show(withMessage: "当前系统不支持画中画功能")
    }
}
  1. 使用系统 API 开启画中画。
@objc func startPip()  {
    if self.pipVC!.isPictureInPictureActive {
        self.pipVC!.stopPictureInPicture() // 关闭画中画
    } else {
        self.pipVC!.startPictureInPicture()
    }
}
  1. 开启画中画,将 Canvas.view 加载到画中画上。
// 画中画已经开始
func pictureInPictureControllerDidStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
    
    ToastComponents.shared.show(withMessage: "pictureInPictureControllerDidStart")
    
    if #available(iOS 16.0, *) {
        if let vc = pictureInPictureController.contentSource?.activeVideoCallContentViewController {
            vc.view.addSubview(self.customRenderView)
            self.customRenderView.snp.remakeConstraints() { make in
                make.edges.equalTo(vc.view)
            }
        }
    }
}
  1. 开启外部渲染。使用内部渲染会导致黑屏。
// 注册外部渲染
func bindRemoteRenderView(view: CustomVideoRenderView, roomId: String, userId: String) {
    let streamKey = ByteRTCRemoteStreamKey.init()
    streamKey.userId = userId;
    streamKey.roomId = roomId;
    streamKey.streamIndex = .main;
    
    // 使用外部渲染
    self.rtcVideo?.setRemoteVideoSink(streamKey, withSink: self.customRenderView, withPixelFormat: .original)
}

示例项目

你可以前往示例项目,查看其中的源代码。

API 参考

平台AndroidiOS
设置本端画面setLocalVideoCanvassetLocalVideoCanvas:withCanvas:
设置远端画面setRemoteVideoCanvassetRemoteVideoCanvas:withCanvas: