SEO 信息

  • SEO 标题:【共创季稿事节】动图魔方技术拆解 04:HarmonyOS 6.1 实况窗与服务卡片实践:从功能接入到上架能力门控
  • SEO 摘要:基于 HarmonyOS NEXT / ArkTS 项目“动图魔方”,拆解一个 GIF 导出工具如何把后台长任务、服务卡片和实况窗能力接进真实工程:用 BackgroundTasksKit 保住后台导出,用 FormExtensionAbility + formProvider.updateForm 推进度,用显式开关门控 LiveViewKit,把“开发态无法启用”的平台约束写进代码结构而不是留成口头 TODO。
  • 关键词:HarmonyOS, ArkTS, LiveViewKit, FormExtensionAbility, BackgroundTasksKit, 服务卡片, 实况窗, 后台任务, GIF 导出
  • 文章封面https://i-blog.csdnimg.cn/direct/d1be1e1bcf6740588450ecd4489c5152.png
  • 投稿方向:HarmonyOS 6.1 创新特性适配实战
  • 项目环境:HarmonyOS SDK 6.1.0(23)、ArkTS、DevEco Studio、GIFRubiksCube

“动图魔方”前 3 篇分别解决了首页沉浸光感、视频抽帧编码和本地优先素材链路,但真正把导出做成一个像样的工具,还差最后一层体验闭环:用户把 App 切到后台后,长任务不能突然失联,导出进度要有能看见、能点回 App、能在能力未批准时安全降级的承接面。

一、真实工程问题背景

很多工具类 App 在做导出体验时,容易只停留在页面内进度条。

这在桌面 Demo 里没问题,但一到真实手机场景,问题马上暴露出来:

  1. 用户导出大素材时会退到后台,前台页面进度条就失去意义了。
  2. 如果后台任务能力没接,系统很可能在切后台后回收执行链路。
  3. 如果直接上实况窗,开发阶段又会被“应用需先上架并申请能力”卡住。
  4. 如果没有一个显式门控,未获批能力就会变成随机报错点。
  5. 如果没有服务卡片这样的稳定降级面,导出中与待命态都缺少系统级承接入口。

所以第 04 篇要解决的不是“能不能调用一个新 API”,而是怎样把下面 4 件事串成一条可靠链路:

  1. 导出切后台后继续执行。
  2. 进度能被系统级 UI 承接。
  3. 未获批能力在开发态完全惰性。
  4. 失败或不支持时不影响 GIF 主流程。

二、目标与边界

当前这一版实现的目标很明确:

  1. 导出开始后申请后台长任务,避免退后台后直接中断。
  2. 已添加到桌面的服务卡片可以显示“进行中 / 百分比 / 阶段文案”。
  3. 实况窗能力在代码里预留接入点,但默认关闭,不对开发态撒谎。
  4. 导出结束、取消、异常时统一回收后台任务并把卡片恢复为待命态。

边界也必须说清楚:

  1. 当前项目没有把 Live View 当成开发期可验证能力。
  2. LIVE_VIEW_CAPABILITY_GRANTED 默认是 false,不会在开发态调用任何 liveViewManager 接口。
  3. 服务卡片是当前可落地、可验证、可降级的主承接面。
  4. 本文聚焦“导出期间的系统能力门控”,不展开 GIF 编码细节。

这也是这篇文章最有工程价值的地方:不是硬把一个平台限制包装成“已经做完”,而是把限制本身写进架构。

三、链路拆分:后台任务、实况窗、服务卡片如何串起来

项目里这条链路被拆成了 4 层:

层级 责任 对应文件
导出页面层 发起导出、接收进度、统一编排开始/更新/结束 entry/src/main/ets/pages/Index.ets
后台任务层 申请/释放后台长任务能力 entry/src/main/ets/services/BackgroundRenderService.ets
实况窗门控层 预留实况窗接入点并做显式开关控制 entry/src/main/ets/services/LiveViewService.ets
服务卡片层 维护 formId 注册表并把进度推送到所有卡片 entry/src/main/ets/services/CardBridge.etsentry/src/main/ets/entryformability/EntryFormAbility.etsentry/src/main/ets/widget/pages/WidgetCard.ets

这样拆的好处是:

  1. 页面层只负责“当前导出状态是什么”。
  2. 系统能力层各自封装,不把页面代码绑死到具体 Kit。
  3. Live View 能力获批前后,导出主流程都不用改动。
  4. 服务卡片和实况窗可以共用同一套进度语义。

四、关键实现

4.1 先把后台长任务接上,保证导出退后台不掉链

如果用户一边导出一边切到后台,最先要保住的不是 UI,而是执行权。

BackgroundRenderService 的做法很直接:通过 backgroundTaskManager.startBackgroundRunning() 申请 DATA_TRANSFER 模式后台任务,并用 WantAgent 指回当前 Ability。

export class BackgroundRenderService {
  private static active: boolean = false;

  static async start(context: common.UIAbilityContext): Promise<void> {
    if (BackgroundRenderService.active) {
      return;
    }
    try {
      const info: wantAgent.WantAgentInfo = {
        wants: [
          {
            bundleName: context.abilityInfo.bundleName,
            abilityName: context.abilityInfo.name
          }
        ],
        operationType: wantAgent.OperationType.START_ABILITY,
        requestCode: 0,
        wantAgentFlags: [wantAgent.WantAgentFlags.UPDATE_PRESENT_FLAG]
      };
      const agent: WantAgent = await wantAgent.getWantAgent(info);
      await backgroundTaskManager.startBackgroundRunning(
        context,
        backgroundTaskManager.BackgroundMode.DATA_TRANSFER,
        agent
      );
      BackgroundRenderService.active = true;
    } catch (err) {
    }
  }
}

这里有两个工程判断值得单独拎出来:

  1. 失败是允许的。没有把后台能力申请失败等同于导出失败。
  2. active 标记让重复点击导出时不会反复申请后台任务。

同时 entry/src/main/module.json5 里明确声明了权限:

"requestPermissions": [
  {
    "name": "ohos.permission.KEEP_BACKGROUND_RUNNING"
  }
]

这段配置本身就是本文的重要证据,因为它说明项目确实进入了“长任务可持续运行”的工程语境,而不只是页面上写了个进度条。

4.2 实况窗不冒进:用显式门控把平台限制变成代码结构

很多文章会把实况窗写成“接下来补一下就行”的泛泛 TODO,但这类能力最大的风险恰恰是平台前置条件不在代码里。

项目里的处理方式更保守,也更真实:

import { liveViewManager } from '@kit.LiveViewKit';

const LIVE_VIEW_CAPABILITY_GRANTED = false;

export class LiveViewService {
  private static active: boolean = false;

  private static async enabled(): Promise<boolean> {
    try {
      return await liveViewManager.isLiveViewEnabled();
    } catch (err) {
      return false;
    }
  }

  static async start(title: string, percent: number): Promise<void> {
    if (!LIVE_VIEW_CAPABILITY_GRANTED || LiveViewService.active) {
      return;
    }
    if (!await LiveViewService.enabled()) {
      return;
    }
    LiveViewService.active = true;
    // TODO: 能力正式获批后在这里接 startLiveView
  }
}

这段代码的价值不在于“已经点亮了实况窗”,而在于它明确表达了三件事:

  1. 未获批前,LiveViewKit完全惰性的。
  2. 就算系统接口存在,也不会因为误调用把导出主流程搞崩。
  3. 真正接入时只需补 start / update / stop,不必重写页面编排。

这就是我在 HarmonyOS 项目里很看重的一种写法:先把能力门槛写死,再谈体验增强。否则一上线前发现没有审批通过,整条链路都得回炉。

4.3 服务卡片用作稳定降级面,进度语义比视觉更重要

实况窗在开发态不可用,并不代表系统级进度承接无解。当前项目把服务卡片作为主承接面,这个选择比“等待未来能力开放”更务实。

CardBridge 的职责是维护已添加卡片的 formId,并在进度变化时主动刷新所有卡片:

export class CardBridge {
  static buildBinding(active: boolean, percent: number, stage: string): formBindingData.FormBindingData {
    const safe = percent < 0 ? 0 : (percent > 100 ? 100 : Math.round(percent));
    return formBindingData.createFormBindingData({
      active: active,
      percent: safe,
      stage: stage
    });
  }

  static async pushAll(context: common.Context, active: boolean, percent: number, stage: string): Promise<void> {
    try {
      const ids = await CardBridge.getFormIds(context);
      if (ids.length === 0) {
        return;
      }
      const data = CardBridge.buildBinding(active, percent, stage);
      for (let index = 0; index < ids.length; index++) {
        try {
          await formProvider.updateForm(ids[index], data);
        } catch (err) {
        }
      }
    } catch (err) {
    }
  }
}

这里最关键的不是 UI 长什么样,而是先把进度语义定稳:

  1. active 决定卡片处于导出中还是待命态。
  2. percent 统一钳制到 0..100
  3. stage 直接复用导出链路里的阶段文案。

一旦这 3 个字段稳定下来,服务卡片和未来实况窗都可以消费同一份数据,不会出现两套状态机。

4.4 FormExtensionAbility 负责注册卡片实例,主应用只管推状态

服务卡片最容易踩坑的点,是把“卡片生命周期”和“主应用进度更新”混成一块。

这里项目做了明确拆分:EntryFormAbility 只处理 formId 注册与初始态返回,实时进度由主应用主动推送。

export default class EntryFormAbility extends FormExtensionAbility {
  onAddForm(want: Want): formBindingData.FormBindingData {
    let formId = '';
    if (want.parameters) {
      const idValue = want.parameters['ohos.extra.param.key.form_identity'];
      if (typeof idValue === 'string') {
        formId = idValue;
      }
    }
    if (formId.length > 0) {
      CardBridge.addFormId(this.context, formId);
    }
    return CardBridge.buildBinding(false, 0, '待命中');
  }

  onRemoveForm(formId: string): void {
    CardBridge.removeFormId(this.context, formId);
  }
}

对应的 form_config.jsonmodule.json5 也都有实配:

{
  "name": "EntryFormAbility",
  "srcEntry": "./ets/entryformability/EntryFormAbility.ets",
  "type": "form",
  "metadata": [
    {
      "name": "ohos.extension.form",
      "resource": "$profile:form_config"
    }
  ]
}
{
  "forms": [
    {
      "name": "GifRubikWidget",
      "src": "./ets/widget/pages/WidgetCard.ets",
      "isDynamic": true,
      "defaultDimension": "2*2"
    }
  ]
}

这说明服务卡片不是概念设计,而是已经进入了模块注册与动态刷新配置阶段。

4.5 页面编排要统一开始、更新、结束,不要把系统能力散在各处

真正把整条链路串起来的是 Index.ets 的导出流程。

开始导出时,项目会先申请后台任务,再尝试启动实况窗,再把卡片置为“准备中”:

await BackgroundRenderService.start(ctx);
await LiveViewService.start('准备中', 0);
await CardBridge.pushAll(ctx, true, 0, '准备中');

导出进度回调时,页面并没有逐帧去刷新系统 UI,而是只在整数百分比变化时推送一次:

signal.onProgress = (done: number, total: number, stage: string) => {
  const ratio = total > 0 ? done / total : 0;
  this.exportProgress = ratio;
  this.exportStage = stage;
  const pct = Math.round(ratio * 100);
  if (pct !== this.lastCardPercent) {
    this.lastCardPercent = pct;
    LiveViewService.update(stage, pct);
    CardBridge.pushAll(ctx, true, pct, stage);
  }
};

结束或取消时则统一回收:

await LiveViewService.stop();
await BackgroundRenderService.stop(ctx);
await CardBridge.pushAll(ctx, false, 0, '待命中');

这段编排看起来普通,但它解决了一个很实在的问题:系统 UI 更新频率必须被节流。否则编码阶段每处理一帧就刷一次卡片或实况窗,性能收益还没拿到,UI 层先被自己打爆了。

五、踩坑与修正

5.1 实况窗不是“有 API 就能上”,上架审批前必须接受不可用事实

这是这篇最重要的踩坑结论。

实况窗能力和普通页面组件不同,它不是 import 成功就代表可以验证。当前项目明确把“应用已上架并申请审批通过”当作前置条件,因此开发态默认不启用。

这不是保守,而是避免两个更坏的结果:

  1. 代码里混入一堆只在少数环境可跑的试探调用。
  2. 测试同学或后续维护者误以为功能“只是偶发失败”。

5.2 后台任务、卡片、实况窗都可能失败,所以主流程必须 try/catch 降级

这组系统能力没有一个适合用“失败即终止导出”来处理。

原因很简单:用户真正要的是 GIF 文件,不是系统能力全绿。

因此当前实现基本都遵循同一原则:

  1. 能力接上最好。
  2. 能力失败时静默跳过。
  3. 主流程只围绕导出结果成败判断。

这种设计对工具类 App 很关键。否则新增一个系统能力增强,反而把原本稳定的导出链路变脆弱了。

5.3 卡片更新语义要固定,别让不同入口各自产生自己的状态机

如果首页按钮、导出页、后台任务回调、卡片渲染各自产生一套“进行中/已完成/待命/异常”的命名,后面越接能力越乱。

当前项目提前把卡片数据压成 active + percent + stage 这 3 个字段,未来无论换卡片样式、补实况窗模板还是做通知栏兜底,都能复用这套状态表达。

这比先冲 UI 更值钱,因为它直接决定后续接能力时要不要重构。

六、截图与代码证据

6.1 导出页已经具备真实的长任务入口和进度承接场景

导出页真实状态截图

这张图说明导出不是抽象能力,而是已经进入真实页面流程:用户会从这里发起导出、调整参数并进入长任务阶段。后台任务、卡片、实况窗门控,服务的都是这个真实入口。

6.2 当前工程已经有稳定的首页入口与主流程承载面

首页真实状态截图

这张图的意义在于证明“动图魔方”不是一个只留在实验页的系统能力样例,而是有完整主流程的工具 App。也正因为它是完整产品,导出体验才必须覆盖后台、卡片和能力门控,而不能只靠页面内提示。

6.3 module.json5、form_config 与服务实现共同构成了可验收证据

除了界面截图,这篇的另一类证据来自工程配置与代码本身:

  1. module.json5 已声明 ohos.permission.KEEP_BACKGROUND_RUNNING
  2. module.json5 已注册 EntryFormAbility,类型为 form
  3. form_config.json 已声明 GifRubikWidget 动态卡片。
  4. LiveViewService.ets 已把实况窗能力门控写进代码。
  5. Index.ets 已把开始、更新、结束三段编排串通。

对于这类“开发态受平台约束”的能力,这比硬贴一张伪截图更有说服力,因为它反映的是可维护的工程结构。

七、工程复盘

做完这一轮之后,我对 HarmonyOS 系统能力接入有一个很明确的判断:

第一,系统能力不是越多越好,而是越可控越好。
第二,能力前置条件必须在代码里可见,而不是写在群消息或脑子里。
第三,工具类 App 的增强能力应该始终服从主流程,而不是反过来绑架主流程。

“动图魔方”这一版没有为了展示实况窗而伪造完成度,而是先用后台任务和服务卡片把真实可交付部分做稳,再给 Live View 留出无侵入接入口。这种节奏更像工程,而不是演示。

八、验收清单

验收项 结果 说明
后台任务权限已声明 通过 module.json5 已声明 ohos.permission.KEEP_BACKGROUND_RUNNING
导出开始会申请后台长任务 通过 BackgroundRenderService.start() 已接入导出开始阶段
服务卡片 Extension 已注册 通过 EntryFormAbility 已在 module.json5 中注册为 form
卡片动态刷新链路已具备 通过 CardBridge.pushAll() 使用 formProvider.updateForm() 主动推送
导出结束能统一回收状态 通过 finally 中统一调用 stop()pushAll(false, 0, '待命中')
实况窗开发态安全降级 通过 LIVE_VIEW_CAPABILITY_GRANTED = false,未获批时完全惰性
能力失败不影响 GIF 主流程 通过 后台任务、卡片、实况窗服务均采用 try/catch 降级
真实产品入口与页面场景存在 通过 已有首页与导出页真实截图作为证据

九、小结

第 04 篇真正想说明的是:HarmonyOS 的系统能力接入,重点从来不是“把 API 调起来”,而是把能力边界、平台前置条件、失败降级和产品主流程一起设计好。

在“动图魔方”里,后台任务负责保住执行权,服务卡片负责承担当前可落地的系统级进度面,实况窗则通过显式门控留好未来入口。这样即使实况窗审批还没到位,项目依然有一条完整、诚实、可维护的导出体验链路。

十、下一篇衔接

下一篇我会继续拆“动图魔方技术拆解 05”,主题转向 HarmonyOS 7.0 视角下的创作能力前瞻:为什么 GIF 工具会提前预留 3D 预览、多模态创作和跨设备体验的结构位置,以及这些能力在现阶段应该如何做出“不冒进但不堵死”的工程铺垫。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐