【OpenHarmony/HarmonyOs 】利用 UIAbility 生命周期实现应用使用时长与访问统计

前言

使用统计可以帮助用户理解自己的习惯,也能为推荐排序与成就系统提供基础。HarmonyOS Stage 模型中,UIAbility 的前后台回调提供了自然的计时边界。本文以 LinkOS 链界为例,实现前台时长累计、站点访问计数和“我的”页面展示,并分析当前实现中的精度问题。📊

一、前台计时的基本思路

应用进入前台时记下时间戳,进入后台时计算差值:

private lastForegroundTime: number = 0;

onForeground(): void {
  this.lastForegroundTime = Date.now();
}

async onBackground(): Promise<void> {
  if (this.lastForegroundTime <= 0) return;

  const duration = Date.now() - this.lastForegroundTime;
  const minutes = Math.floor(duration / 60000);
  if (minutes > 0) {
    const storage = StorageUtil.getInstance();
    const total = await storage.get(
      StorageKeys.USAGE_TIME_TODAY, 0
    ) as number;
    await storage.put(StorageKeys.USAGE_TIME_TODAY, total + minutes);
  }
}

这套方式简单、功耗低,不需要每秒启动定时器。统计发生在生命周期边界,也不会因为页面切换而重复累计。

二、单位必须从数据层到 UI 保持一致

当前 Key 注释和 Ability 按“分钟”存储,但页面显示为“秒”。这会导致数据语义错误。建议定义明确单位:

static readonly USAGE_DURATION_MS = 'usage_duration_ms';

统一保存毫秒,展示时再转换:

function formatDuration(ms: number): string {
  const totalMinutes = Math.floor(ms / 60000);
  const hours = Math.floor(totalMinutes / 60);
  const minutes = totalMinutes % 60;
  return hours > 0 ? `${hours}小时${minutes}分钟` : `${minutes}分钟`;
}

保存原始精度还能避免每次切后台不足一分钟都被舍弃。

三、避免重复累计

完成一次后台结算后,应重置时间戳:

async onBackground(): Promise<void> {
  const startedAt = this.lastForegroundTime;
  this.lastForegroundTime = 0;
  if (startedAt <= 0) return;

  const delta = Math.max(0, Date.now() - startedAt);
  await this.addUsageDuration(delta);
}

如果生命周期回调异常重复触发,重置可以防止同一段前台时间被累计两次。使用 Math.max 则能防御系统时间回拨产生负数。

四、“今日使用”需要日期维度

只使用 usage_time_today 一个 Key 并不会自动在午夜清零。更可靠的结构是同时保存统计日期:

interface DailyUsage {
  date: string;       // 例如 2026-07-20
  durationMs: number;
  visitCount: number;
}

每次写入前比较当前本地日期:日期相同则累加,日期变化则创建新记录。如果需要历史趋势,应使用 RDB 按日期保存,而不是覆盖单个 Key。

还要考虑跨午夜场景:用户 23:50 打开应用,00:10 切到后台,20 分钟不能全部算到第二天。精确方案会在午夜边界拆分区间。

五、站点访问计数放在统一出口

项目在安全校验通过后、进入 WebView 前更新计数:

const safe = await SecurityUtil.checkUrlSafety(url);
if (!safe) return;

const storage = StorageUtil.getInstance();
const count = await storage.get(StorageKeys.SITE_VISIT_COUNT, 0) as number;
await storage.put(StorageKeys.SITE_VISIT_COUNT, count + 1);

router.pushUrl({
  url: 'pages/v2/WebViewPage',
  params: { url }
});

计数放在统一 openUrl() 中,可以覆盖推荐卡片、搜索结果和自定义收藏等入口。危险链接不计数,符合“成功发起访问”的定义。

若要统计“页面真正加载成功”,则应在 WebView 的页面完成事件中上报,并用访问 ID 去重。

六、在页面重新出现时加载最新统计

async onPageShow() {
  const storage = StorageUtil.getInstance();
  this.usageTime = await storage.get(
    StorageKeys.USAGE_TIME_TODAY, 0
  ) as number;
  this.siteCount = await storage.get(
    StorageKeys.SITE_VISIT_COUNT, 0
  ) as number;
}

使用页面可见回调而不是只在组件创建时读取,能够确保用户从 WebView 返回或切换 Tab 后看到最新值。

七、并发写入与数据竞争

“先 get 再 put”不是原子操作。若多个异步任务同时增加计数,可能都读到 10,最后都写成 11,丢失一次更新。单 UI 线程原型中概率较低,但云同步或多窗口后需要认真处理。

可选方案包括:

  • 在 Service 内串行化写操作;
  • 使用 RDB 事务执行 count = count + 1
  • 云端使用原子增量;
  • 以事件形式追加记录,再异步聚合。

八、隐私与产品边界

统计应遵循最小化原则。用户不需要为了一个本地使用报告上传完整浏览历史。若未来做云端分析,应明确告知目的、征得授权、允许清除,并避免记录完整敏感 URL 查询参数。

本地“清除所有数据”功能应同步删除统计、收藏、身份与语言设置,并在操作前进行二次确认。🔒

九、总结

生命周期统计的基本实现并不复杂,但精确性取决于单位、日期边界、重复回调和并发写入。使用毫秒作为存储单位、在后台结算后重置起点、为“今日”增加日期字段,再把访问计数集中到统一出口,就能建立一套可靠的本地统计基础。

img

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐