那是一个测试的鸿蒙应用。内测一切正常,骑友使用甩出截图:他骑了 40 分钟,记录页面上赫然写着 8 分钟,中间整整 32 分钟是空白。

我拿着手机骑楼下绕了一圈复现:一锁屏,应用就被"冻"住了。锁屏、切桌面、开别的应用——只要退到后台,进程被系统挂起,定时器不走了,GPS 定位停了,等我解锁回来,它才"诈尸"继续跑。用户的 40 分钟骑行, App 只"醒着"8 分钟。

之前写 Android 时处理过前台服务,我以为鸿蒙大差不差,结果人家把规则写得明明白白:后台不是你的。这篇把这个坑从根因到解法一次讲清,附一个完整可跑通的"骑行记录"Demo——所有 API 我都对着官方文档逐条核过。

一、先搞懂:你的应用为什么会被"冻住"

在鸿蒙上,返回主界面、锁屏、切应用,都会让应用退至后台。系统为了省电和流畅,会对后台应用做挂起或终止。关键在挂起的定义:进程挂起后,无法使用软件资源(公共事件、定时器)和硬件资源(CPU、网络、GPS、蓝牙)——我那 32 分钟的空白,就是定时器和 GPS 同时被掐了。

不是不能在后台干活,是要按规矩申请。Background Tasks Kit 给了四种规范内的后台任务,先对号入座:

类型适合举例关键约束
短时任务实时性高、耗时短退后台前快速保存笔记单次最大 3 分钟,单日配额默认 10 分钟,低电量单次 1 分钟
长时任务长时间运行、用户可感知后台播放音乐、导航、骑行记录要申请对应类型,通知栏常驻,系统做一致性校验
延迟任务实时性不高、可推迟后台同步数据系统统一排队调度
代理提醒退后台/进程终止后也要提醒闹钟、倒计时、日历系统代理执行

骑行记录 = 长时间 + 用户可感知 + 持续定位 → 长时任务(location 类型),没有第二个选项。短时任务那 3 分钟配额,撑不起任何一次骑行。

二、完整 Demo:一个锁屏后还在骑的"骑行记录"

效果:点"开始骑行"→ 通知栏出现常驻通知 → 锁屏/切走任意久 → 回来,计时和距离连续无断层。删掉通知或点"结束",一切归位。

1. module.json5:声明权限 + 后台模式

{
  "module": {
    // ...
    "requestPermissions": [
      {
        // 申请长时任务的入场券,normal 级权限,声明即授予
        "name": "ohos.permission.KEEP_BACKGROUND_RUNNING"
      },
      {
        // 定位需要这两个 user_grant 权限,必须带 reason 和 usedScene
        "name": "ohos.permission.APPROXIMATELY_LOCATION",
        "reason": "$string:location_reason",
        "usedScene": { "abilities": ["EntryAbility"], "when": "inuse" }
      },
      {
        "name": "ohos.permission.LOCATION",
        "reason": "$string:location_reason",
        "usedScene": { "abilities": ["EntryAbility"], "when": "inuse" }
      }
    ],
    "abilities": [
      {
        // ...模板其余内容保持不变
        "backgroundModes": [
          "location"   // 长时任务类型,和代码里申请的类型一致
        ]
      }
    ]
  }
}

别忘了在 resources/base/element/string.json 里补一个 location_reason(比如"记录骑行轨迹需要获取您的位置")——user_grant 权限没 reason,应用直接装不上。

2. Index.ets:整个替换页面代码

import { backgroundTaskManager } from '@kit.BackgroundTasksKit';
import { geoLocationManager } from '@kit.LocationKit';
import { abilityAccessCtrl, Permissions, wantAgent, WantAgent, common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';

/** 球面两点间距离(米),骑行距离靠它累加 */
function distanceBetween(a: geoLocationManager.Location,
                         b: geoLocationManager.Location): number {
  const rad = Math.PI / 180;
  const dLat = (b.latitude - a.latitude) * rad;
  const dLon = (b.longitude - a.longitude) * rad;
  const s = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
    Math.cos(a.latitude * rad) * Math.cos(b.latitude * rad) *
    Math.sin(dLon / 2) * Math.sin(dLon / 2);
  return 6371000 * 2 * Math.asin(Math.sqrt(s));
}

function formatTime(sec: number): string {
  const h = Math.floor(sec / 3600);
  const m = Math.floor((sec % 3600) / 60);
  const s = sec % 60;
  const two = (n: number): string => (n < 10 ? '0' : '') + n;
  return `${two(h)}:${two(m)}:${two(s)}`;
}

@Entry
@Component
struct Index {
  @State elapsedText: string = '00:00:00';
  @State distanceText: string = '0.00';
  @State isRiding: boolean = false;

  private startTs: number = 0;                 // 时间戳计时法的锚点
  private timerId: number = -1;                // 只负责刷新 UI
  private totalMeters: number = 0;
  private lastLocation: geoLocationManager.Location | undefined = undefined;

  aboutToAppear(): void {
    // API 15+:监听长时任务被系统/用户取消(比如用户删了常驻通知)
    backgroundTaskManager.on('continuousTaskCancel',
      (info: backgroundTaskManager.ContinuousTaskCancelInfo) => {
        console.warn(`continuous task canceled, id=${info.id}, reason=${info.reason}`);
        this.getUIContext().getPromptAction()
          .showToast({ message: '后台保护已被取消,骑行记录可能中断' });
      });
  }

  aboutToDisappear(): void {
    backgroundTaskManager.off('continuousTaskCancel');
  }

  private requestLocationPermission = async (): Promise<boolean> => {
    const context = this.getUIContext().getHostContext() as common.UIAbilityContext;
    const atManager = abilityAccessCtrl.createAtManager();
    const perms: Array<Permissions> = [
      'ohos.permission.APPROXIMATELY_LOCATION',
      'ohos.permission.LOCATION'
    ];
    const result = await atManager.requestPermissionsFromUser(context, perms);
    return result.authResults.every((r: number) => r === 0);
  };

  /** 申请长时任务:通知栏会出现常驻通知,点击可拉回应用 */
  private startContinuousTask = async (context: common.UIAbilityContext): Promise<void> => {
    const wantAgentInfo: wantAgent.WantAgentInfo = {
      wants: [
        { bundleName: 'com.demo.ridetrack', abilityName: 'EntryAbility' }
      ],
      actionType: wantAgent.OperationType.START_ABILITY,
      requestCode: 0,
      actionFlags: [wantAgent.WantAgentFlags.UPDATE_PRESENT_FLAG]
    };
    const wantAgentObj: WantAgent = await wantAgent.getWantAgent(wantAgentInfo);
    const res: backgroundTaskManager.ContinuousTaskNotification =
      await backgroundTaskManager.startBackgroundRunning(context, ['location'], wantAgentObj);
    console.info(`continuous task started, notificationId=${res.notificationId}`);
  };

  private onLocation = (location: geoLocationManager.Location): void => {
    if (this.lastLocation !== undefined) {
      this.totalMeters += distanceBetween(this.lastLocation, location);
      this.distanceText = (this.totalMeters / 1000).toFixed(2);
    }
    this.lastLocation = location;
  };

  private startRide = async (): Promise<void> => {
    if (this.isRiding) {
      return;
    }
    const granted = await this.requestLocationPermission();
    if (!granted) {
      this.getUIContext().getPromptAction().showToast({ message: '需要定位权限才能记录骑行' });
      return;
    }
    const context = this.getUIContext().getHostContext() as common.UIAbilityContext;

    // 先申请长时任务,再开始定位——保证退后台时业务已经在跑(一致性校验)
    try {
      await this.startContinuousTask(context);
    } catch (err) {
      const e = err as BusinessError;
      console.error(`startBackgroundRunning failed: ${e.code}, ${e.message}`);
      return;
    }

    // 场景设为轨迹追踪,5 秒上报一次
    const request: geoLocationManager.LocationRequest = {
      scenario: geoLocationManager.LocationRequestScenario.TRAJECTORY_TRACKING,
      timeInterval: 5
    };
    geoLocationManager.on('locationChange', request, this.onLocation);

    // 时间戳计时法:锚点记一次,展示层只管刷 UI
    this.startTs = Date.now();
    this.timerId = setInterval(() => {
      const sec = Math.floor((Date.now() - this.startTs) / 1000);
      this.elapsedText = formatTime(sec);
    }, 1000);

    this.isRiding = true;
  };

  private stopRide = async (): Promise<void> => {
    if (!this.isRiding) {
      return;
    }
    geoLocationManager.off('locationChange', this.onLocation);
    this.lastLocation = undefined;
    clearInterval(this.timerId);

    const context = this.getUIContext().getHostContext() as common.UIAbilityContext;
    try {
      await backgroundTaskManager.stopBackgroundRunning(context);
      console.info('continuous task stopped');
    } catch (err) {
      const e = err as BusinessError;
      console.error(`stopBackgroundRunning failed: ${e.code}, ${e.message}`);
    }
    this.isRiding = false;
  };

  build() {
    Column({ space: 24 }) {
      Text('骑行记录').fontSize(24).fontWeight(FontWeight.Bold)

      Text(this.elapsedText)
        .fontSize(56)
        .fontWeight(FontWeight.Bold)
        .fontColor('#0A59F7')
      Text(`已骑 ${this.distanceText} 公里`)
        .fontSize(20)
        .fontColor('#666666')

      Button(this.isRiding ? '结束骑行' : '开始骑行')
        .width(220)
        .height(52)
        .fontSize(18)
        .type(ButtonType.Capsule)
        .backgroundColor(this.isRiding ? '#E84026' : '#0A59F7')
        .onClick(() => {
          if (this.isRiding) {
            this.stopRide();
          } else {
            this.startRide();
          }
        })

      Text('提示:开始后可锁屏或切到其他应用,\n回来后计时与距离应保持连续')
        .fontSize(13)
        .fontColor('#999999')
        .textAlign(TextAlign.Center)
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .backgroundColor('#F5F7FA')
  }
}

把代码里的 bundleName 换成你自己工程的包名(AppScope/app.json5 里那个)。

3. 三个设计点,比代码本身值钱

  • 时间戳计时,别靠 setInterval 累加。 startTs 记一次锚点,每次展示时用 Date.now() - startTs 算。挂起期间 setInterval 不跑没关系——锚点不会说谎,回到前台的第一秒就能算出真实时长。业务状态靠时间戳,UI 只是展示层,这一刀切干净,后台问题少一半。
  • 先申请长时任务,再开定位。 顺序反了的话,退后台瞬间系统做一致性校验:申请了 location 却没在定位,判定"申请未使用",照样挂起。
  • 监听 continuousTaskCancel(API 15+)。 用户把通知栏那条常驻通知往下一划,系统会自动停掉长时任务——用户不是在删通知,是在关你的后台保护。接住这个回调,要么提示用户,要么重新申请,别让记录悄悄断掉。

三、跑通清单(五步,对照组很重要)

  1. Run 到模拟器(Background Tasks Kit 支持模拟器)或真机,点"开始骑行",授权定位;
  2. 确认通知栏出现常驻通知——这就是长时任务的"身份证";
  3. 锁屏或退回桌面,等 2~3 分钟(真机出门骑一圈更好),解锁回来:计时连续、距离正常累加,没有断层;
  4. 对照组:把 startContinuousTask 那行注释掉重跑,锁屏几分钟再回来——断层重现,你就亲眼见到了那位骑友的 8 分钟;
  5. 下滑删掉常驻通知,看日志里 continuous task canceled 回调触发;再点通知本体,应用被拉回前台(wantAgent 生效)。

四、踩坑实录

坑 1:报 201 权限拒绝。 ohos.permission.KEEP_BACKGROUND_RUNNING 忘了在 requestPermissions 里声明。它是 normal 级,声明即得,但必须声明。

坑 2:backgroundModes 写成 "LOCATION"。 配置文件里填的是配置项小写驼峰(location、audioPlayback、dataTransfer……),不是枚举名大写。写错不会报错,但退后台照样被挂起,极难排查。

坑 3:报 9800005(一致性校验失败)。 申请了某类型长时任务,后台却没干对应的事。系统会检测"申请与实际业务是否匹配":申请了音视频播放却在后台沉默、申请了定位却没定位——都会被挂起甚至终止。申请什么,就做什么。

坑 4:后台播音乐没接 AVSession,被静音冻结。 STREAM_USAGE_MUSIC/MOVIE/AUDIOBOOK/GAME 类型的播放,必须同时接入 AVSession + 申请 audioPlayback 长时任务,缺一个,退后台就被静音并冻结,回前台才恢复。停止时还要先停音频再停长时任务,顺序反了会被强制终止。

坑 5:拿短时任务跑长业务。 短时任务单次最大 3 分钟、单日默认 10 分钟,低电量下单次只有 1 分钟,超时前 6 秒才有回调。它是"退后台前抢救保存"用的,不是保活工具。

坑 6:API 20 及之前,一个 UIAbility 同时只能有一个长时任务。 又要导航又要放歌的(location + audioPlayback),API 21+ 可以一个 UIAbility 申请最多 10 个;老版本要么合并场景,要么拆多个 UIAbility。

坑 7:托管给系统的下载,退后台还是会被挂起。 用 @ohos.request 托管的下载/上传,即使申请了 dataTransfer 长时任务也会被挂起;自己搬数据的传输才有效,且进度通知首次更新超过 10 分钟没更新,任务会被取消。

坑 8:任务做完了不取消。 用户点了暂停,长时任务还挂着——应用退后台会被系统挂起(判定"业务已结束")。规范动作:暂停/结束时同步 stopBackgroundRunning,下次开始再重新申请。

五、回头看

修完给那位骑友,之后在群里发了张新截图:42 分钟,一秒没丢。

这件事给我最大的冲击不是某个 API,而是鸿蒙把后台资源的逻辑摆得非常直白:前台是用户的,后台是公共的。你想在公共的资源上继续干活,就得亮明身份(申请对应类型)、名实相符(一致性校验)、用完归还(及时 stop)。违反了,轻则挂起,重则终止,还有一页明明白白的违规处罚标准。

从 Android 时代八仙过海的保活黑科技,到鸿蒙的"申请—使用—归还",一开始我是不适应的——少了很多"操作空间"。但写完这个 Demo 我想通了:保活大战里没有赢家,用户手机烫手、电量跳水,最后骂的是整个生态。把"后台能干什么"变成明规则,逼着开发者把功夫花在把前台体验做好——这才是这条规则真正想改变的事。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐