【共创稿事节】退桌面也不能断!我用「连续任务 + 闪控球」给端侧 3DGS 重建做了一套后台护航

本文是我在 HarmonyOS 7(API 26)上开发端侧 3DGS(三维高斯泼溅)重建 App 的实战记录。三维重建一次要跑几十秒到几分钟,用户不可能一直盯着页面。这篇文章完整拆解我如何用连续任务(Continuous Task)保活 + 系统闪控球(FloatingBall)回窗,解决"用户一按 Home,重建就可能被挂起、也找不到回来的入口"这个问题。

文中所有代码均来自我工程里编译通过、真机验证过的实现,附上踩过的错误码和架构思考。

img

一、问题:长耗时任务遇上"按 Home 键"

端侧 3DGS 重建在手机本地跑 SLAM 和几十万高斯点优化,期间用户有两个非常自然的动作:

  1. 退到桌面去回个消息、刷一下别的 App;
  2. 过一会儿想回到重建页看进度。

这两个动作对应两个必须解决的工程问题:

  • 保活:应用退后台后,重建线程随时可能被系统挂起,任务半途而废;
  • 回流:用户需要一个足够轻的入口,一键回到重建页,而不是重新找图标、进 App、再层层导航。

我的方案是"双保险":

重建启动
   ├── 连续任务 DATA_TRANSFER ── 退后台保活 + 常驻通知(通知可点回 App)
   └── 系统闪控球 FloatingBall ── 退桌面悬浮小球,点击 restoreMainWindow 直达重建页
            └── 能力/权限不满足时静默降级,全程不阻断重建主链路
重建终态(完成/失败/取消)
   └── 停连续任务 + 取消通知 + 收起闪控球

关键设计原则是:护航能力是"锦上添花",绝不能成为重建的单点故障。闪控球是受限能力,一旦不可用必须优雅降级,保活则由连续任务兜底。

img

二、第一道保险:连续任务保活

HarmonyOS 的后台长时任务要用 backgroundTaskManager 申请连续任务,并声明合理的 BackgroundMode。重建本质是持续的数据处理,我选了 DATA_TRANSFER:

import { backgroundTaskManager } from '@kit.BackgroundTasksKit';
import { wantAgent, WantAgent } from '@kit.AbilityKit';

async start(context: Context): Promise<boolean> {
  if (this.running) return true;
  // 乐观置位防并发重入:startBackgroundRunning 是异步的,
  // 若置位放在 await 成功之后,期间第二次 start 会重复申请
  this.running = true;
  try {
    const agent = await this.createReturnWantAgent(context);
    await backgroundTaskManager.startBackgroundRunning(
      context,
      backgroundTaskManager.BackgroundMode.DATA_TRANSFER,
      agent
    );
    return true;
  } catch (err) {
    this.running = false;
    return false;
  }
}

这里有两个真实踩到的点:

1)WantAgent 里包名要取对。 常驻通知要能一键拉回应用,需要构造 START_ABILITY 的 WantAgent。包名我用的是 abilityContext.abilityInfo.bundleName,而不是 applicationInfo.name——后者在某些配置下可能返回应用显示名而不是包名,导致点通知拉不起应用:

const launchWant: Want = {
  bundleName: abilityContext.abilityInfo.bundleName,
  abilityName: 'EntryAbility',
  action: 'ohos.want.action.home'
};

2)DATA_TRANSFER 必须持续更新进度,否则会被系统终止。 这是文档里容易忽略、但真机上会真出事的一条:该模式下进度通知长时间(约 10 分钟)不更新,连续任务可能被系统回收。我加了一个 30 秒心跳,重建过程中周期性 publish 进度通知:

this.heartbeatId = setInterval(() => {
  void BackgroundTaskHelper.getInstance().updateProgress(this.viewModel.progress);
}, 30000);

// updateProgress 内部 publish 一条带百分比的常驻通知
const request: notificationManager.NotificationRequest = {
  id: PROGRESS_NOTIFICATION_ID,
  content: {
    notificationContentType: notificationManager.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT,
    normal: { title: '三维重建中', text: `正在重建模型… ${percent}%`, additionalText: `${percent}%` }
  },
  deliveryTime: Date.now()
};

终态时记得 stopBackgroundRunning 并 notificationManager.cancel() 掉这条通知,否则重建都结束了通知还赖在通知栏。

三、第二道保险:系统闪控球 FloatingBall

连续任务解决了"别断",闪控球解决的是"怎么优雅地回来"。这部分是 API 26 的新能力,坑也最密集。

3.1 先认清权限:USE_FLOAT_BALL 是 ACL 受限权限

ohos.permission.USE_FLOAT_BALL 不是普通的运行时权限,它是 system_grant + system_basic 级、provisionEnable=true 的 ACL 受限权限:没有运行时弹窗,需要在签名 Profile 里授予 ACL,安装时决定有无。而且官方当前只对搜题、账单、比价、抢单、翻译、金融盯盘等明确场景开放,3D 重建并不在公开开放列表里。

这意味着两件事:

  1. 不能假设权限一定有,调用前必须自己查;
  2. 没权限时不能弹个窗问用户要(要了也没有),只能静默降级。

查权限时还有个坑:bundleManager.getBundleInfoForSelf 必须显式带 GET_BUNDLE_INFO_WITH_APPLICATION,用默认 flag 拿不到 appInfo.accessTokenId(是 null):

const bundleInfo = await bundleManager.getBundleInfoForSelf(
  bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_APPLICATION
);
const tokenId = bundleInfo.appInfo.accessTokenId;
const atManager = abilityAccessCtrl.createAtManager();
const status = atManager.checkAccessTokenSync(tokenId, 'ohos.permission.USE_FLOAT_BALL');
const granted = status === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED;

img

3.2 双重门控 + 全程降级

启动前先做"设备能力 + 权限"两道门,任何一道不过都直接返回,重建照常进行:

async start(context: common.UIAbilityContext, title: string, content: string) {
  if (this.started) { await this.update(title, content); return true; }

  // 门控 1:设备是否支持闪控球
  let supported = false;
  try { supported = floatingBall.isFloatingBallEnabled(); } catch { supported = false; }
  if (!supported) return false;

  // 门控 2:ACL 权限是否授予(无运行时弹窗,不授予直接降级)
  if (!(await this.hasPermission())) return false;

  try {
    // 注意:context 必须是组件 getHostContext() 返回的 UIAbilityContext,
    // 传 applicationContext 会报 1300023
    this.controller = await floatingBall.create({ context });
    await this.controller.startFloatingBall({
      template: floatingBall.FloatingBallTemplate.NORMAL,
      title, content
    });
    // 应用在前台时隐藏小球,避免遮挡重建页;退桌面才浮现
    await this.controller.setFloatingBallVisibilityInApp(false);
    this.started = true;
  } catch (err) {
    // 1300022 重复操作 / 1300025 状态不支持 / 1300034 浮窗冲突,统一降级
    await this.destroyController();
    return false;
  }
}

我把闪控球相关错误码整理成一张小抄,实际排查全靠它:

错误码含义处理
1300022重复 start/stop幂等保护,忽略
1300023内部错误(常见于 context 类型不对)改用 UIAbilityContext
1300025当前状态不支持该操作降级
1300034与其他悬浮窗冲突降级,不与系统抢浮窗

3.3 点击小球回窗:restoreMainWindow 只能在 click 回调里调

这是最反直觉的一条约束。我没有 AUTO_RESTORE_MAIN_WINDOW 权限,不能在任意时机主动把主窗拉到前台,restoreMainWindow 必须在用户点击小球的 click 事件回调里调用。而且回传的 Want 只放 bundleName + abilityName,不能带 action(带了会恢复失败):

this.controller.on('click', () => { void this.restoreApp(); });

private async restoreApp() {
  const want: Want = {
    bundleName: this.appContext.applicationInfo.name,
    abilityName: 'EntryAbility'   // 不要加 action
  };
  await this.controller.restoreMainWindow(want);
}

3.4 生命周期与异步竞态:API 26 新增的 onDestroy 很关键

闪控球可能被系统或用户(长按删除)直接销毁。我监听了三类事件做引用清理,其中 onDestroy 是 API 26 新增的控制器销毁回调,不处理的话会残留一个"逻辑上以为球还在、实际已经没了"的状态:

this.controller.on('stateChange', (state) => {
  if (state === floatingBall.FloatingBallState.STOPPED) this.started = false;
});
this.controller.onDestroy((_reason: string) => {
  this.started = false;
  this.controller = undefined;
});

另外 create / startFloatingBall 都是异步的,用户可能在 await 期间直接退出页面触发了 stop()。我在每个 await 之后都重新判空 controller,一旦发现已被清理就中止启动,避免"页面都没了球才弹出来"。

3.5 一个容易被忽视的细节:64 字节文案与 emoji 代理对

闪控球的 title/content 有 64 字节(UTF-8)上限,超长会失败。我写了按字节截断的工具,这里有个 JavaScript 经典坑:不能用 charCodeAt 逐码元数,否则 emoji 这种占两个码元、UTF-8 占 4 字节的增补平面字符会被算成 6 字节,还可能截断在代理对中间产生乱码。正确做法是按 codePointAt 以完整码点推进:

const cp = input.codePointAt(i);
if (cp >= 0x10000) { charBytes = 4; codeUnits = 2; }  // emoji
else if (cp >= 0x0800) { charBytes = 3; codeUnits = 1; }
else if (cp >= 0x0080) { charBytes = 2; codeUnits = 1; }
else { charBytes = 1; codeUnits = 1; }

进度文案我还在 Helper 内部做了去重(缓存上次 title/content,相同就不再 IPC 更新),避免重建进度高频刷新时重复调用 updateFloatingBall。

img

四、页面侧如何编排

重建页只关心"开始"和"结束"两个时机,把复杂逻辑都收敛进两个单例 Helper:

// 重建启动:先保活,再起闪控球
private async onReconstructionStarted(context: Context) {
  await BackgroundTaskHelper.getInstance().start(context);
  this.heartbeatId = setInterval(
    () => BackgroundTaskHelper.getInstance().updateProgress(this.viewModel.progress), 30000);
  await FloatingBallHelper.getInstance()
    .start(context as common.UIAbilityContext, '三维重建', this.buildBallContent());
}

// 终态:停心跳、停连续任务、收球
private async onReconstructionFinished(context: Context) {
  clearInterval(this.heartbeatId);
  await BackgroundTaskHelper.getInstance().stop(context);
  await FloatingBallHelper.getInstance().stop();
}

buildBallContent() 把"百分比 · 阶段文案(重建中/保存中/已暂停)"组装成一句话,保证小球副文案和页内状态口径一致。

五、复盘:受限能力的正确打开方式

这套护航做完,最大的收获不是某个 API 怎么调,而是一条架构原则:

面对系统的受限/分级能力,默认假设它"可能没有",用能力探测 + 权限校验双门控接入,再准备一条不依赖它的兜底路径。

img

在我这个场景里:

  • 闪控球在 → 用户退桌面看到悬浮小球,一键直达重建页,体验最好;
  • 闪控球不在(设备不支持 / 没拿到 ACL)→ 连续任务的常驻通知依然保活、依然能点回 App,功能不缺失;
  • 两者都失败 → 重建本身在前台期间已经能跑完,主链路毫发无伤。

每一层的失败都被下一层接住,没有任何一个"增强能力"能反过来阻断核心业务。这也是我在 HarmonyOS 上接系统能力时越来越坚持的思路:门控要严、降级要静、兜底要稳、清理要净。

希望这套"连续任务 + 闪控球 + 静默降级"的组合,能帮到同样在做长耗时后台任务的同学。你们在接 FloatingBall 时还遇到过哪些错误码?评论区交流。


开发环境:HarmonyOS 7(compileSdkVersion 26)、DevEco Studio;真机调试。主要涉及 @ohos.window.floatingBall、@kit.BackgroundTasksKit、@kit.AbilityKit。

Logo

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

更多推荐