文档管理中心
我的
您当前正在浏览新版开发者文档中心,目录分类和层级有所调整。点击左侧当前文档分类名称前的“☰”图标,可切换文档分类。 了解新版目录
最佳实践创新特性开发场景创新基于长时任务与实况窗的视频后台导出方案

基于长时任务与实况窗的视频后台导出方案

概述

视频编辑类的应用在完成视频编辑导出视频时,如果应用切换到后台,或者锁屏、熄屏后,系统将冻结应用进程,导致视频导出暂停,影响用户体验和导出效率。为解决这一问题,系统提供了长时任务机制和Live View Kit(实况窗服务),使应用能够在后台持续执行视频导出,并通过实况窗向用户实时展示进度。本文旨在介绍如何基于系统能力实现完整的后台视频导出方案,通过使用长时任务和实况窗功能来实现视频的后台导出,当视频导出开始后,应用在切屏、锁屏、灭屏情况下依然可以在后台持续导出视频。

说明

本文以后台导出作为案例场景,其实现思路和部分关键源代码也适用于实现素材传输、云端上传、下载等长耗时数据处理任务的后台执行功能的场景。

本文的主要内容包括:

  1. 视频导出与进度回调:基于AVCodec NDK在C++ Native层构建多线程视频处理管线,完成视频解码、OpenGL ES水印合成与重新编码,通过解码帧数实时回调进度。
  2. 后台任务管理:基于Background Tasks Kit(后台任务开发服务)申请长时任务,保障视频导出在后台持续运行。
  3. 实况窗进度展示:通过Live View Kit(实况窗服务)创建实况窗,实时展示导出进度并在完成后通知用户。

整体方案

下图展示了正常导出流程中各模块的完整交互:用户点击导出后,应用依次完成草稿持久化、实况窗创建、长时任务启动、C++ Native管线逐帧解码-合成-编码与进度回调,最终将视频写入系统相册并结束实况窗和后台任务。

视频导出与进度回调

场景描述

视频导出涉及视频解码、水印合成与重新编码操作。本方案基于AVCodec NDK在C++ Native层构建多线程视频处理管线,通过OpenGL ES完成水印合成。导出过程中的进度信息通过NAPI线程安全回调传递回ArkTS层,驱动实况窗更新和UI展示。

实现原理

视频导出通过NativeExportUtil调用C++ Native模块,Native层内部使用音视频编解码完成视频解封装、硬件解码、OpenGL ES水印合成、硬件编码和封装的全流程。ArkTS层只需打开输入输出文件获取文件描述符,将水印参数和旋转角度传入,通过回调函数接收进度和完成通知。支持通过nativeexport.cancelExport()随时取消正在执行的导出任务。视频导出流程如下图所示:

关键接口:

  • nativeexport.startExport(inputFd, inputFileOffset, inputFileSize, outputFd, watermarks, rotation, onProgress, onDone):启动Native导出管线。
  • nativeexport.cancelExport():取消正在执行的导出任务。

开发步骤

  1. 通过Promise等待导出完成:startExport()为异步调用,进度通过onProgress实时回调,导出完成或失败时通过onDone通知,在回调中关闭文件描述符并resolve Promise。
    收起
    自动换行
    深色代码主题
    复制
    1. return new Promise<number>((resolve) => {
    2. nativeexport.startExport(
    3. inputFile?.fd ?? -1,
    4. 0,
    5. stat.size,
    6. outputFile?.fd ?? -1,
    7. wmParams,
    8. rotation,
    9. (percent: number) => {
    10. onProgress(percent);
    11. },
    12. (result: number) => {
    13. if (inputFile) {
    14. try {
    15. fileIo.closeSync(inputFile.fd);
    16. } catch (error) {
    17. Logger.error(TAG, `inputFile closeSync err, err.code is ${error.code}, message is ${error.message}`);
    18. }
    19. }
    20. if (outputFile) {
    21. try {
    22. fileIo.closeSync(outputFile.fd);
    23. } catch (error) {
    24. Logger.error(TAG, `outputFile closeSync err, err.code is ${error.code}, message is ${error.message}`);
    25. }
    26. }
    27. onProgress(PROGRESS_COMPLETE);
    28. onCoverReady(0, 0);
    29. resolve(result);
    30. }
    31. );
    32. });
  2. 支持取消正在执行的导出:调用nativeexport.cancelExport()中断Native管线,Native层会设置取消标志并唤醒所有工作线程,最终通过doneCallback(-1)通知ArkTS层。
    收起
    自动换行
    深色代码主题
    复制
    1. static cancelExport(): void {
    2. nativeexport.cancelExport();
    3. }

至此,视频导出的核心链路已打通:ArkTS层通过NativeExportUtil.exportVideo()调用C++ Native管线完成视频处理,进度实时回调驱动后续的实况窗更新和后台任务管理。

后台任务管理

场景描述

视频导出类任务通常耗时较长,用户可能在导出过程中切换到其他应用或锁屏。如果没有后台任务保障,系统会挂起应用进程,导致导出中断。开发者需要申请长时任务,使应用在后台保持活跃状态,持续执行视频导出。

实现原理

Background Tasks Kit(后台任务开发服务)提供了ContinuousTaskRequest机制,应用通过声明MODE_SPECIAL_SCENARIO_PROCESSING模式和SUBMODE_MEDIA_PROCESS_NORMAL_NOTIFICATION子模式,向系统申请特殊场景的后台长时任务。该任务允许应用在后台持续运行,同时系统会在通知栏展示任务状态。后台任务管理流程如下图所示:

关键接口:

说明

后台长时任务接入需遵循Background Tasks Kit接入规范。

开发步骤

  1. 在module.json5配置文件中声明后台任务模式backgroundModes为specialScenarioProcessing,并且在requestPermissions中申请两项后台运行权限。
    收起
    自动换行
    深色代码主题
    复制
    1. "abilities": [
    2. {
    3. // ...
    4. "backgroundModes": [
    5. "specialScenarioProcessing"
    6. ]
    7. }
    8. ],
    9. "requestPermissions": [
    10. {
    11. "name": "ohos.permission.KEEP_BACKGROUND_RUNNING_SPECIAL_SCENARIO",
    12. "reason": "$string:background_running_reason",
    13. "usedScene": {
    14. "abilities": ["EntryAbility"],
    15. "when": "always"
    16. }
    17. },
    18. {
    19. "name": "ohos.permission.KEEP_BACKGROUND_RUNNING",
    20. "reason": "$string:background_running_reason",
    21. "usedScene": {
    22. "abilities": ["EntryAbility"],
    23. "when": "always"
    24. },
    25. }
    26. ],
  2. 构建长时任务请求并配置后台模式:创建ContinuousTaskRequest对象,设置backgroundTaskModes为MODE_SPECIAL_SCENARIO_PROCESSING,backgroundTaskSubmodes为SUBMODE_MEDIA_PROCESS_NORMAL_NOTIFICATION,指定任务类型为媒体处理通知场景。
    收起
    自动换行
    深色代码主题
    复制
    1. private static getTask(): backgroundTaskManager.ContinuousTaskRequest {
    2. let continuousTaskRequest = new backgroundTaskManager.ContinuousTaskRequest();
    3. let modeList: number[] = [backgroundTaskManager.BackgroundTaskMode.MODE_SPECIAL_SCENARIO_PROCESSING];
    4. continuousTaskRequest.backgroundTaskModes = modeList;
    5. let subModeList: number[] =
    6. [backgroundTaskManager.BackgroundTaskSubmode.SUBMODE_MEDIA_PROCESS_NORMAL_NOTIFICATION];
    7. continuousTaskRequest.backgroundTaskSubmodes = subModeList;
    8. return continuousTaskRequest;
    9. }
  3. 检查权限并启动后台长时任务:先通过isRunning()防止重复启动,构建WantAgent用于通知栏点击跳转,调用startBackgroundRunning()启动任务并保存返回的continuousTaskId。
    收起
    自动换行
    深色代码主题
    复制
    1. public isRunning(): boolean {
    2. // Prevent duplicate start of background continuous task
    3. return this.continuousTaskId !== undefined && this.continuousTaskId > 0;
    4. }
    5. public async start(): Promise<void> {
    6. if (this.isRunning()) {
    7. Logger.warn(TAG, `continuous task already running, id=${this.continuousTaskId}`);
    8. return;
    9. }
    10. if (!this.context) {
    11. Logger.error(TAG, `invalid context`);
    12. return;
    13. }
    14. try {
    15. let wantAgentObj = this.wantAgentCache ?? await this.buildWantAgent();
    16. if (!wantAgentObj) {
    17. Logger.error(TAG, `wantAgent is undefined`);
    18. return;
    19. }
    20. // Build continuousTaskRequest object
    21. let continuousTaskRequest: backgroundTaskManager.ContinuousTaskRequest = ContinueTaskController.getTask();
    22. if (continuousTaskRequest === undefined) {
    23. Logger.info(TAG, `continuousTaskRequest is undefined`);
    24. return;
    25. }
    26. continuousTaskRequest.wantAgent = wantAgentObj;
    27. continuousTaskRequest.combinedTaskNotification = false;
    28. continuousTaskRequest.continuousTaskId = -1;
    29. let res: backgroundTaskManager.ContinuousTaskNotification =
    30. await backgroundTaskManager.startBackgroundRunning(this.context, continuousTaskRequest);
    31. Logger.info(TAG,
    32. `Operation startBackgroundRunning succeeded.
    33. notificationId=${res.notificationId} continuousTaskId=${res.continuousTaskId}`);
    34. this.continuousTaskId = res.continuousTaskId;
    35. } catch (error) {
    36. Logger.error(TAG, `Operation startBackgroundRunning failed, code is ${error.code}, message is ${error.message}`);
    37. }
    38. }
  4. 在导出页面检查权限并按需申请:导出页面在aboutToAppear()中检查长时任务权限状态,已授权则直接启动后台任务,未授权则弹窗请求。若用户拒绝,则页面内提示无法退出后台继续导出。
    收起
    自动换行
    深色代码主题
    复制
    1. continueTaskController.checkSpecialScenarioAuth().then(async (result) => {
    2. // Request permission when no background continuous task permission for special scenario
    3. if (result !== backgroundTaskManager.UserAuthResult.GRANTED_ONCE &&
    4. result !== backgroundTaskManager.UserAuthResult.GRANTED_ALWAYS) {
    5. this.hasContinueTaskPermission = false;
    6. continueTaskController.requestPermit(async (authResult) => {
    7. // Start continuous task if permission is granted
    8. if (authResult === backgroundTaskManager.UserAuthResult.GRANTED_ONCE ||
    9. authResult === backgroundTaskManager.UserAuthResult.GRANTED_ALWAYS) {
    10. this.hasContinueTaskPermission = true;
    11. await ContinueTaskController.getInstance(this.context.getHostContext() as common.UIAbilityContext).start();
    12. return;
    13. }
    14. });
    15. } else { // Start continuous task directly if permission already granted
    16. this.hasContinueTaskPermission = true;
    17. await ContinueTaskController.getInstance(this.context.getHostContext() as common.UIAbilityContext).start();
    18. }
    19. }).catch((err: BusinessError) => {
    20. Logger.error(TAG, `checkSpecialScenarioAuth failed: ${err.code} ${err.message}`);
    21. });
  5. 导出完成后停止后台任务:在视频导出成功或失败后,在适当时机调用stopBackgroundRunning()释放后台任务资源。停止时机通过TimeoutManager管理,应用处于前台时立即停止,应用处于后台时则设置一定延迟,确保用户有足够时间通过实况窗感知结果,本案例中通过BACKGROUND_LIVEVIEW_TIMEOUT_MS常量将延迟设为5分钟。
    收起
    自动换行
    深色代码主题
    复制
    1. const isForeground = AppStorage.get<boolean>(StorageConstants.IS_APP_FOREGROUND) ?? true;
    2. TimeoutManager.getInstance().addTimeout(
    3. async () => { // Timer: delay stopping LiveView if app is in background
    4. try {
    5. await liveViewController.stopLiveViewSucc(isForeground);
    6. await ContinueTaskController.getInstance(context).stop();
    7. } catch (e) {
    8. Logger.error(TAG, `stopLiveView fail`);
    9. }
    10. },
    11. async () => { // Stop LiveView immediately if app enters foreground after export
    12. try {
    13. await liveViewController.stopLiveViewSucc(true);
    14. await ContinueTaskController.getInstance(context).stop();
    15. } catch (e) {
    16. Logger.error(TAG, `stopLiveViewSucc on user click fail`);
    17. }
    18. },
    19. isForeground ? BACKGROUND_LIVEVIEW_TIMEOUT_0 : BACKGROUND_LIVEVIEW_TIMEOUT_MS // Timer delay duration
    20. );
    21. WaterMarkNativeUtil.cleanupTempFiles(outVideoPath, inputVideoPath, tempFiles, outVideoPath + '.cover.jpg');
  6. 在UIAbility销毁时兜底清理:在onDestroy()生命周期中停止所有活跃的实况窗和后台任务,防止资源泄漏。
    收起
    自动换行
    深色代码主题
    复制
    1. async onDestroy(): Promise<void> {
    2. Logger.info(TAG, `Ability onDestroy`);
    3. await LiveViewController.stopAllActiveLiveView(this.context);
    4. await ContinueTaskController.getInstance(this.context).stop();
    5. }

实况窗进度展示

场景描述

用户在后台导出视频时,需要直观地了解导出进度和结果。实况窗(Live View)是HarmonyOS提供的系统级UI能力,可以在状态栏、锁屏和通知中心展示实时信息,是后台任务进度反馈的最佳载体。

实现原理

实况窗的生命周期分为三个阶段:创建(startLiveView)、更新(updateLiveView)和结束(stopLiveView)。导出开始时创建实况窗并显示初始进度,Native管线每回调一次进度就更新实况窗内容,导出完成或失败时更新为最终状态并结束实况窗。实况窗管理流程如下图所示:

关键接口:

开发步骤

  1. 构建实况窗初始配置:构造LiveView对象,标题和胶囊文案均通过resourceManager从资源文件加载,进度为0%。通过buildWantAgent()配置点击跳转能力。
    收起
    自动换行
    深色代码主题
    复制
    1. private static async buildDefaultView(context: common.UIAbilityContext, id: number,
    2. routeUri?: string): Promise<liveViewManager.LiveView> {
    3. return {
    4. // Build live view request
    5. id: id, // Live view ID, generated by developer
    6. event: 'PROGRESS',
    7. liveViewData: {
    8. primary: {
    9. title: LiveViewController.str(LVStr.EXPORTING_TITLE),
    10. content: [
    11. { text: LiveViewController.str(LVStr.EXPORTING_CONTENT) },
    12. ],
    13. keepTime: LIVEVIEW_KEEP_TIME,
    14. clickAction: await LiveViewController.buildWantAgent(context, routeUri),
    15. layoutData: {
    16. layoutType: liveViewManager.LayoutType.LAYOUT_TYPE_DEFAULT,
    17. color: '#FF317AF7',
    18. backgroundColor: '#F7819AE0',
    19. },
    20. extensionData: {
    21. type: liveViewManager.ExtensionType.EXTENSION_TYPE_PROGRESS,
    22. progress: 0,
    23. }
    24. },
    25. // Live view capsule parameters
    26. capsule: {
    27. type: liveViewManager.CapsuleType.CAPSULE_TYPE_TEXT,
    28. status: CommonConstants.CAPSULE_STATUS_ACTIVE,
    29. icon: 'icon.png', // Capsule icon, filename under "/resources/rawfile" or image.PixelMap
    30. backgroundColor: '#FF308977',
    31. title: LiveViewController.str(LVStr.START_EXPORT),
    32. content: `0%`
    33. }
    34. }
    35. };
    36. }
  2. 创建实况窗:调用liveViewManager.startLiveView()创建实况窗。
    收起
    自动换行
    深色代码主题
    复制
    1. public async startLiveView(): Promise<liveViewManager.LiveViewResult | undefined> {
    2. // ...
    3. this.liveViewId = LiveViewController.genLiveId();
    4. // Create live view
    5. const defaultView = await LiveViewController.buildDefaultView(this.context, this.liveViewId);
    6. try {
    7. let result = await liveViewManager.startLiveView(defaultView);
    8. LiveViewController.liveViewTemplates.set(this.liveViewId, defaultView);
    9. return result;
    10. } catch (error) {
    11. Logger.error(TAG, `startLiveView fail, code is ${error.code}, message is ${error.message}`);
    12. }
    13. return undefined;
    14. }
  1. 响应进度回调更新实况窗:在Native导出进度回调中,将进度值同步到实况窗。为避免更新过于频繁触发限流(错误码1003500008),通过isUpdating标志和nextProgress缓存实现更新合并。
    收起
    自动换行
    深色代码主题
    复制
    1. public async updateLiveView(progress: number): Promise<void> {
    2. if (this.isStopping) {
    3. return;
    4. }
    5. if (this.isUpdating) {
    6. this.nextProgress = progress;
    7. return;
    8. }
    9. this.isUpdating = true;
    10. let currentProgress = progress;
    11. if (this.context === undefined) {
    12. return;
    13. }
    14. try {
    15. while (this.isStopping === false) {
    16. await this.executeLiveViewUpdate(currentProgress);
    17. if (this.nextProgress !== undefined) {
    18. currentProgress = this.nextProgress;
    19. this.nextProgress = undefined;
    20. } else {
    21. break;
    22. }
    23. }
    24. } finally {
    25. this.isUpdating = false;
    26. }
    27. return;
    28. }
  1. 控制更新频率防止限流:在executeLiveViewUpdate()中检查距上次更新是否不足1秒,以及进度是否回退,满足条件则跳过本次更新。更新成功后记录时间戳和进度值,用于下次判断。
    收起
    自动换行
    深色代码主题
    复制
    1. private async executeLiveViewUpdate(progress: number, isSucc: boolean = true,
    2. isMute: boolean = true): Promise<liveViewManager.LiveViewResult | undefined> {
    3. // ...
    4. let currentTimeMs = Date.now();
    5. if ((progress !== PROGRESS_COMPLETE && (currentTimeMs - this.lastUpdateTimeMs) < LIVEVIEW_FREQ_INTERVAL) ||
    6. (progress < this.lastUpdateProgress)) {
    7. return undefined;
    8. }
    9. this.lastUpdateTimeMs = currentTimeMs;
    10. // ...
    11. }
  2. 结束实况窗:更新实况窗结束态展示内容,设置keepTime明确实况通知在实况结束后保留时间,并调用liveViewManager.stopLiveView()结束实况窗。
    收起
    自动换行
    深色代码主题
    复制
    1. // Update live view content
    2. const defaultView = await LiveViewController.buildDefaultView(this.context, this.liveViewId, routeUri);
    3. defaultView.liveViewData.primary.title = title;
    4. defaultView.liveViewData.primary.content = [
    5. { text: content },
    6. ];
    7. defaultView.isMute = isMute;
    8. defaultView.liveViewData.primary.keepTime = keeptime;
    9. defaultView.liveViewData.primary.layoutData = {
    10. layoutType: liveViewManager.LayoutType.LAYOUT_TYPE_PROGRESS
    11. };
    12. if (showProgress) {
    13. defaultView.liveViewData.primary.extensionData =
    14. { type: liveViewManager.ExtensionType.EXTENSION_TYPE_PROGRESS, progress: PROGRESS_COMPLETE };
    15. } else {
    16. defaultView.liveViewData.primary.extensionData = { type: liveViewManager.ExtensionType.EXTENSION_TYPE_DEFAULT };
    17. }
    18. defaultView.liveViewData.capsule = {
    19. type: liveViewManager.CapsuleType.CAPSULE_TYPE_TEXT,
    20. status: CommonConstants.CAPSULE_STATUS_STOPPED,
    21. icon: 'icon.png', // Capsule icon, filename under "/resources/rawfile" or image.PixelMap
    22. backgroundColor: CAPSULE_COLOR,
    23. title: capsuleText,
    24. content: '100%'
    25. };
    26. // ...
    27. try {
    28. let result = await liveViewManager.stopLiveView(defaultView);
    29. LiveViewController.liveViewTemplates.delete(this.liveViewId);
    30. this.isStopping = false;
    31. this.isUpdating = false;
    32. return result;
    33. } catch (error) {
    34. // ...
    35. }
  1. 处理实况窗不可用时的降级通知:当实况窗开关关闭或API调用失败(错误码1003500009)时,降级为系统通知,通过NotificationController.publish()发送普通文本通知,确保用户仍能收到导出结果。
    收起
    自动换行
    深色代码主题
    复制
    1. // Check if live view is enabled
    2. if (!await this.isLiveViewEnabled() && needSendNotification === true) {
    3. Logger.error(TAG, `LiveView is disabled.`);
    4. NotificationController.publish(this.context, title, content);
    5. return undefined;
    6. }
  1. 应用被系统回收时批量结束实况窗:在onDestroy()中调用stopAllActiveLiveView()遍历所有活跃的实况窗Id,根据导出状态调用对应方法结束实况窗。
    收起
    自动换行
    深色代码主题
    复制
    1. public static async stopAllActiveLiveView(context: common.UIAbilityContext): Promise<void> {
    2. const liveViewIds = Array.from(LiveViewController.liveViewTemplates.keys());
    3. const store = new DraftStore(context);
    4. const allDrafts = store.listDrafts();
    5. // Iterate all active LiveViews and stop them
    6. for (let liveViewId of liveViewIds) {
    7. const template = LiveViewController.liveViewTemplates.get(liveViewId);
    8. if (template !== undefined) {
    9. const matchedDraft = allDrafts.find(draft => draft.liveViewId === liveViewId);
    10. // Determine video export status
    11. const isSucc = matchedDraft === undefined || matchedDraft.status === DraftStatus.SUCC;
    12. try {
    13. // Update LiveView display content based on export status
    14. if (isSucc) {
    15. await LiveViewController.getInstance(context).stopLiveViewSucc(true);
    16. } else {
    17. let routeUri: string | undefined;
    18. if (matchedDraft !== undefined && matchedDraft.status !== DraftStatus.CANCELLED) {
    19. routeUri = `draft://${matchedDraft.draftId}`;
    20. }
    21. await LiveViewController.getInstance(context).stopLiveViewKill(routeUri);
    22. }
    23. Logger.info(TAG, `fast stopLiveView succ id=${liveViewId} isSucc=${isSucc}`);
    24. } catch (error) {
    25. Logger.error(TAG,
    26. `fast stopLiveView fail id=${liveViewId}, code is ${error.code}, message is ${error.message}`);
    27. }
    28. } else {
    29. Logger.info(TAG, `no template for id=${liveViewId}, skip`);
    30. }
    31. // Remove stopped LiveView from cache
    32. LiveViewController.liveViewTemplates.delete(liveViewId);
    33. }
    34. // ...
    35. }

常见问题

实况窗更新过于频繁导致报错

问题描述

调用liveViewManager.updateLiveView()时抛出错误码1003500008,实况窗内容无法更新。

可能根因

实况窗API对更新频率有限制,短时间内高频调用会触发限流保护。Native管线的进度回调间隔可能较短,直接转发会导致更新过于频繁。

解决方案

在updateLiveView()中增加频率控制:记录上次更新时间戳,距上次更新不足1秒且进度未达100%时跳过本次更新。同时使用isUpdating标志和nextProgress缓存合并连续更新请求,确保每次更新完成后才处理下一个进度值。

后台任务权限未授权导致导出中断

问题描述

用户切换到其他应用后,视频导出任务被系统挂起,导出进度停滞。

可能根因

应用未获得KEEP_BACKGROUND_RUNNING_SPECIAL_SCENARIO权限,或用户拒绝了后台权限弹窗,导致长时任务无法启动。

解决方案

在导出页面aboutToAppear()中先调用checkSpecialScenarioAuth()检查权限状态。若未授权,调用requestAuthFromUser()弹窗申请。若用户拒绝,在UI上展示引导文案,点击后通过startAbility()跳转系统设置页面的应用信息页,引导用户手动开启后台运行权限。

应用被系统回收后实况窗状态异常

问题描述

应用被系统清理后,实况窗仍显示"导出中"状态,未更新为最终结果。

可能根因

应用进程被终止时,stopLiveView()未被调用,实况窗停留在最后一次更新的状态。

解决方案

在EntryAbility.onDestroy()中调用stopAllActiveLiveView(),遍历所有活跃的实况窗模板,根据导出结果,设置对应的结束文案后调用liveViewManager.stopLiveView()。