示例项目:DualMetric
页面:ReadingWorkspacePage

平行视界把列表和详情同时摆到大屏上之后,页面可见性不再等同于一次完整曝光。列表中的文章卡片还露着一角,右侧详情已经打开;用户拖动分隔比例时,两侧组件又会连续触发可见区域变化。如果把 aboutToAppear 或每一次可见回调都当成曝光,统计会比真实阅读次数膨胀许多。

DualMetric 关注的不是如何把两个页面分栏,而是分栏完成后的观测语义:什么叫“看见”,停留多久才算一次,列表卡片与详情页能否分别统计,窗口缩放和路由重用又该如何去重。本文数据均为演示快照,不是线上埋点结果,也不冒充真机测量。

一、双窗把“页面出现”拆成了多种事实

在单页应用里,页面进入往往可以近似理解为一次浏览开始。平行视界同时展示主窗和辅窗后,这个近似失效了。左侧 FEED 里 article_2048 的卡片可能只有 40% 可见,右侧 DETAIL 已显示同一篇文章的正文;用户拖动分隔线时,左侧比例从 0.62 降到 0.58,再回到 0.65,短时间内产生多个边界回调。

官方多设备适配资料说明,平行视界面向折叠屏、平板等大屏设备提供应用内分屏体验;ArkUI 的可见区域变化事件可以报告组件可见状态及比例。两者结合后,开发者仍需自行定义业务曝光,系统不会替产品决定“多少比例、停留多久、同一内容是否合并”。

本文把事实分为四层:组件存在、可见比例达标、停留时间达标、逻辑曝光已提交。它们对应 HIDDEN → CANDIDATE → DWELLING → COMMITTED。任何窗口变化都可以让候选退回 HIDDEN,但已经提交的曝光不会因为一次尺寸抖动重复发送。

本次演示任务是 EXPOSURE-DUAL-0074,会话为 pv-s29,内容 ID 为 article_2048。可见阈值设为 0.60,停留门槛为 1200 ms。原始回调 14 次,最终提交 2 次:FEED 卡片一次、DETAIL 正文一次;9 次重复被丢弃,3 次因停留不足被取消。

二、曝光键必须包含“位置语义”

若只以 article_2048 去重,列表卡片和详情正文会互相吞掉;若只以组件实例去重,窗口重组后新实例又会重复计数。DualMetric 的曝光键由会话、窗格、内容、放置位和修订号组成。窗格是 FEED 或 DETAIL,放置位描述 card 或 reader,revision 用来区分内容实质变化。

这段代码解决什么问题。 它定义稳定曝光身份,避免组件实例 ID、数组下标和路由对象被误当成业务主键。

type Pane = 'FEED' | 'DETAIL'
type ExposureState = 'HIDDEN' | 'CANDIDATE' | 'DWELLING' | 'COMMITTED'

interface ExposureIdentity {
  sessionId: string
  pane: Pane
  placement: 'card' | 'reader'
  contentId: string
  revision: number
}

function exposureKey(id: ExposureIdentity): string {
  return [id.sessionId, id.pane, id.placement, id.contentId, id.revision].join('|')
}

const feedIdentity: ExposureIdentity = {
  sessionId: 'pv-s29', pane: 'FEED', placement: 'card',
  contentId: 'article_2048', revision: 29
}

sessionId 的范围是一次工作区会话,不是永久用户标识;页面重新冷启动时可生成新会话。revision 只在正文或卡片语义发生变化时递增,不能跟着每次 UI 重组变化,否则去重失去意义。

实际项目还要明确统计口径。如果产品只关心详情阅读,可以完全不提交 FEED 卡片;如果需要漏斗,就让 card 与 reader 成为两个 placement。关键是把口径写成配置,而不是在埋点平台里事后猜测两个同名事件为什么数量不同。

三、可见比例只是候选,不是提交条件

ArkUI 的 onVisibleAreaChange 很适合捕捉比例跨越。示例监听 0、0.6、1.0 三个阈值,但回调到达时只把事实交给协调器。组件不直接发埋点,也不持有长期计时器逻辑,避免列表复用和页面重组造成多套状态。

这段代码解决什么问题。 它把 FEED 卡片的可见比例送入统一协调器,并在组件退出时显式撤销候选。

@Component
struct FeedExposureCard {
  @Prop contentId: string = ''
  private identity: ExposureIdentity = feedIdentity

  build() {
    Column() {
      Text('组件可见性不是曝光')
      Text(this.contentId)
    }
    .onVisibleAreaChange([0.0, 0.6, 1.0],
      (_isVisible: boolean, currentRatio: number) => {
        exposureCoordinator.observe(this.identity, currentRatio, Date.now())
      })
  }

  aboutToDisappear(): void {
    exposureCoordinator.hide(this.identity, Date.now())
  }
}

当比例从 0.59 变为 0.61,状态进入 CANDIDATE,随后开启 1200 ms 停留检查。比例跌回阈值以下时,候选立即撤销。aboutToDisappear 作为资源收口,不代表曝光提交;它只确保组件消失时不会遗留计时器。

容易出错的是把 isVisible=true 直接等同于比例超过 0.60。回调同时给出 currentRatio,业务应按自己的阈值判断。列表滚动、窗口遮挡和分栏拖动都可能让比例来回穿越,协调器必须能处理频繁的候选创建和取消。

四、停留门禁需要一份可撤销的计时器

定时器最常见的问题不是“不触发”,而是旧候选已经失效,回调仍在 1200 ms 后提交。DualMetric 为每个 exposureKey 保存 token。每次重新候选都会生成新 token;计时器醒来时除了检查比例,还核对 token,只有仍属于当前候选才有提交权。

这段代码解决什么问题。 它把短暂可见与有效停留分开,并让过期计时器无法提交。

interface Candidate {
  state: ExposureState
  ratio: number
  since: number
  token: number
  timer?: number
}

class ExposureCoordinator {
  private readonly threshold: number = 0.60
  private readonly dwellMs: number = 1200
  private candidates: Map<string, Candidate> = new Map()
  private tokenSeed: number = 0

  observe(id: ExposureIdentity, ratio: number, now: number): void {
    const key = exposureKey(id)
    if (ratio < this.threshold) {
      this.hide(id, now)
      return
    }
    const current = this.candidates.get(key)
    if (current?.state === 'COMMITTED' || current?.timer !== undefined) return

    const token = ++this.tokenSeed
    const timer = setTimeout(() => this.tryCommit(id, token), this.dwellMs)
    this.candidates.set(key, {
      state: 'DWELLING', ratio, since: now, token, timer
    })
  }

  hide(id: ExposureIdentity, _now: number): void {
    const key = exposureKey(id)
    const current = this.candidates.get(key)
    if (current?.timer !== undefined) clearTimeout(current.timer)
    if (current?.state !== 'COMMITTED') this.candidates.delete(key)
  }
}

状态先从 HIDDEN 进入 CANDIDATE,再到 DWELLING。为了压缩代码,示例在同一个 observe 中完成前两步;诊断快照仍会保留这两个业务状态。重复回调看到 timer 已存在就直接返回,因此 14 次原始回调不会制造 14 个计时器。

页面离开、列表项回收和工作区销毁都要调用 hide 或 dispose。clearTimeout 与 setTimeout 成对处理;仅依赖垃圾回收不能阻止定时器闭包继续持有 identity。真实埋点发送还应在网络失败时进入独立队列,不要用曝光计时器承担重试职责。

图中的 DevEco Studio 为演示配图,不是实际运行证据。左侧是 DualMetric 工程,中央代码圈出 threshold=0.60、dwellMs=1200 与 token 校验,右侧模拟器显示 FEED/DETAIL 两个窗格,底部 HiLog 记录 callbacks=14、committed=2、duplicateDropped=9、shortDropped=3。

五、提交时还要经过会话级去重

计时器解决了抖动,却不能解决组件重建。平行视界改变显示比例、详情路由被复用或状态恢复后,同一个逻辑曝光可能对应新的组件实例和新协调器。最终提交前还需要一个会话级集合,记录已经成功交付的 exposureKey。

这段代码解决什么问题。 它在计时器回调与实际事件发送之间增加幂等门禁,并输出可核对的丢弃原因。

class ExposureSink {
  private committed: Set<string> = new Set()

  async commit(id: ExposureIdentity, ratio: number, dwellMs: number): Promise<boolean> {
    const key = exposureKey(id)
    if (this.committed.has(key)) {
      hilog.info(0x0000, 'DualMetric', 'duplicateDropped key=%{public}s', key)
      return false
    }
    await analyticsAdapter.send('parallel_exposure', {
      pane: id.pane,
      placement: id.placement,
      contentId: id.contentId,
      revision: id.revision,
      ratio,
      dwellMs,
      sessionId: id.sessionId
    })
    this.committed.add(key)
    return true
  }
}

只有发送成功后才写入 committed。如果适配器失败,事件留在待重试队列;但重试队列必须有自己的 eventId,避免“发送成功但响应丢失”造成重复。示例把 analyticsAdapter 明确为项目适配层,不把它包装成 HarmonyOS 系统接口。

若用户在同一会话里离开文章再回来,是否允许第二次曝光,需要产品口径决定。本文选择会话内去重,因此仍为一次;若要统计重访,可以在内容真正退出 30 秒后生成新的 visitId,并把它纳入 key。不要暗中用组件重建替代重访定义。

六、右侧详情与左侧卡片要分别计时

同一内容同时出现在 FEED 和 DETAIL,最容易出现两种极端:全部合并成一个事件,漏掉漏斗;全部当成详情阅读,又把卡片露出算作深度阅读。DualMetric 让窗格与 placement 进入 identity,因此 card 达到 60% 并停留 1200 ms 后提交 FEED/card,正文达到门槛后提交 DETAIL/reader。

运行页时间为 12:18,电量 76%,任务 EXPOSURE-DUAL-0074、会话 pv-s29、内容 article_2048。页面显示 FEED 比例 0.72、DETAIL 比例 1.00,两个 placement 都已 COMMITTED;原始回调 14、最终提交 2、重复丢弃 9、短停留丢弃 3,与日志完全一致。

这里的 2 次不是重复数据,而是两个事先定义好的漏斗节点。如果业务只需要一次“文章阅读”,则可以让 reader 提交时关联 cardEventId,并在报表层统计转化,而不是把两个事件强行压成一个无法解释的名称。

七、分栏拖动期间先冻结判断,避免边界振荡

用户拖动平行视界分隔区域时,可见比例可能在几百毫秒内频繁跨过 0.60。若每次都重新启动完整计时器,会产生大量对象和难读日志。DualMetric 在检测到高频变化后进入 180 ms 稳定窗口:只保存最后比例,窗口结束再决定是否创建候选。

这个稳定窗口不是延长曝光门槛。真正的 dwell 仍从稳定后的达标时刻计算,不能把拖动期间零散的 300 ms、400 ms 拼成 1200 ms。用户没有连续看到内容,就不应被算作有效停留。

稳定逻辑同样属于项目层。平行视界提供的是布局与显示体验,onVisibleAreaChange 提供可见事实;曝光语义、时间窗口和去重策略由应用承担。将责任边界写清楚,能避免后续把统计差异误判成系统回调缺陷。

八、诊断页应该还原一次曝光的证据链

只输出“事件已发送”不足以调试。DualMetric 为每个 key 保留最近一次诊断:最高比例、首次达标时间、累计连续停留、token、状态变化、提交结果和丢弃原因。它不保存用户阅读文本,也不记录永久标识,只保留定位这次会话所需的短期事实。

详情页展示 HIDDEN → CANDIDATE → DWELLING → COMMITTED,并列出 threshold 0.60、dwell 1200 ms、稳定窗口 180 ms。红圈分别标出 duplicateDropped=9 和 shortDropped=3,旁边说明前者来自回调重复与组件重建,后者来自不足 1200 ms 的短暂可见。

诊断数据应有容量上限,例如只保留最近 50 个 key,并在工作区结束时释放。开发版可以展示完整时间线,发布版日志要控制字段和等级,避免把内容标题、用户账号或可识别信息写入公共日志。

九、验证重点不是“回调有没有来”

第一组测试让 FEED 比例在 0.58 与 0.62 之间往返 10 次,最后稳定在 0.72;预期只有一个 timer,且 1200 ms 后提交一次。第二组让 DETAIL 达到 1.0 后仅停留 700 ms,预期进入 shortDropped,不得提交。第三组在计时期间重建组件,旧 token 回调必须失效。

第四组模拟发送失败。会话级 committed 不能提前写入,重试成功后才固定 key。第五组改变 revision 为 30,同一 article_2048 可以形成新曝光,证明修订语义有效。第六组结束 pv-s29,所有未完成 timer 都要被清理。

还应分别在折叠屏展开态、平板横屏、自由窗口缩放下观察比例回调。模拟器或预览可以验证 UI 和状态机,但不能据此声称所有目标设备上的窗口行为已经通过;正式统计前需要在目标设备和真实 EasyGo 配置下核对。

1. 停留时间要基于单调时钟,而不是墙上时间

示例为了便于阅读使用 Date.now(),真实停留计算更适合基于单调时钟。用户修改系统时间、网络校时或时区变化,都可能让墙上时间向前或向后跳;一段连续可见如果因此得到负数或异常大的 dwell,会污染统计。事件中的展示时间可以使用墙上时间,持续时长则应由不会随校时回拨的计时来源计算。

如果项目当前只能使用毫秒时间戳,也要增加防御:结束值小于开始值时不提交;单次 dwell 超过合理上限时截断为诊断异常,而不是当成深度阅读。since、committedAt 与 duration 三者应分别保存,不要只传一个模糊的 timestamp。

计时精度也不需要无限高。曝光判断使用 1200 ms 门槛,毫秒级足够;把纳秒级值写入日志只会让跨端比较更困难。更重要的是所有窗格使用同一计时来源,否则 FEED 与 DETAIL 的漏斗顺序可能出现颠倒。

2. 前后台切换必须暂停候选,而不是继续累计

应用进入后台时,组件树可能仍存在,旧计时器也可能继续等待。用户已经看不到内容,停留却累积到 1200 ms,回到前台后立刻提交,这是一类很隐蔽的假曝光。DualMetric 在应用不可见时统一暂停所有 DWELLING 候选,并记录 APP_HIDDEN 原因。

恢复前台后不续上之前的零散时间,而是重新读取当前可见比例,达到 0.60 才创建新候选。这与分栏拖动规则一致:曝光要求连续、真实可见,不能把后台前后的两个片段拼接。已经 COMMITTED 的 key 保持不变,避免恢复时重复发送。

系统弹窗、通知面板和多窗口遮挡是否算不可见,需要结合可获取的窗口事实和产品口径。onVisibleAreaChange 反映组件自身可见区域,不一定覆盖所有外部遮挡语义,因此文章不声称一个回调能证明“用户眼睛真的看到了”。可测的是组件可见和停留,用户注意力仍是统计推断。

3. 事件投递要接受“至少一次”,再靠 eventId 去重

网络发送存在一个经典灰区:服务端已经收到事件,客户端却在收到响应前断网。客户端重试会产生重复,不重试又可能丢失。仅在本地 Set 中记录 exposureKey 不能解决跨进程和服务端重试,因此每次逻辑曝光还应生成稳定 eventId,并让接收端按 eventId 幂等。

eventId 可以由 sessionId、exposureKey 与提交序号计算,不需要包含账号明文。事件写入本地待发送队列后,再由网络模块按顺序投递;服务端确认后删除。页面销毁不清空已落盘事件,但必须取消仍未达到 dwell 的候选。这样“可见性生命周期”和“网络投递生命周期”各自收口,不互相绑死。

队列还需要容量和过期时间。曝光数据失去时效后继续重试,价值可能很低,却会占用存储并影响新事件。示例没有实现真实队列,只把 analyticsAdapter 当作边界;实际项目应在适配器文档中写明最大条数、过期策略、重试退避和清理时机。

4. 策略版本决定一条数据能否与历史比较

把阈值从 0.60 改成 0.50,或把停留从 1200 ms 改成 800 ms,事件数量一定会变化。若上报中没有 policyVersion,数据平台会把口径变化误判成产品增长。DualMetric 因此给本次规则标记 visible-dwell-v3,与 pane、placement 一起发送。

策略调整要先用回放样本比较。可以把一段可见比例时间线喂给旧策略和新策略,观察 committed、duplicateDropped、shortDropped 的变化。本例的固定时间线在 v3 下得到 14→2;如果新规则得到 14→5,就必须解释多出的三次来自哪里,而不是只看总体数量上升。

策略版本不是应用版本。一次应用发布可能不改变曝光规则,也可能通过远程配置调整规则。真正用于报表分组的是能唯一描述阈值、停留、稳定窗口和重访定义的版本;应用版本则作为运行环境事实保留。

5. 埋点字段应比诊断界面更克制

诊断页可以显示内容 ID、窗格和状态链,但正式事件不应顺手携带完整标题、正文片段或用户输入。对曝光去重真正必要的是业务 ID、placement、revision、比例、停留、策略和会话;能从服务端关联的展示文案不必重复上传。

日志同样需要分级。开发构建可以输出完整 exposureKey,发布构建至少要避免可识别账号、URL 查询参数和正文。若内容 ID 本身包含敏感业务信息,可以在本地映射为短期不可逆标识,同时保留排查所需的 revision 与 placement。

数据最小化不是统计精度的对立面。字段越明确,越容易发现某个值其实没有决策用途。把“可能以后会用”的大对象塞进事件,既增加隐私风险,也让版本演进和服务端解析更困难。

十、结论:曝光是一次带证据的提交

平行视界让信息并列呈现,也让传统“页面出现即曝光”的假设失效。可靠的统计需要四个条件同时成立:稳定业务身份、明确可见比例、连续停留门槛和会话级幂等提交。组件生命周期只是证据来源,不是业务结论。

DualMetric 的演示结果是 14 次原始回调收敛为 2 次有效曝光,9 次重复和 3 次短停留被解释性丢弃。这个数字不代表平台性能,只说明状态机在固定输入下的输出。真正上线时,阈值、停留时间和重访口径都应由产品与数据团队共同确认,并写入版本化策略。

真正值得复用的不是 0.60 或 1200 ms 这两个数,而是“事实采集、候选计时、幂等提交、可解释诊断”四层分离。以后即使平行视界增加新的显示模式,或者同一内容同时出现在更多区域,也可以通过扩展 pane 与 placement 继续沿用,而不必把回调逻辑重新散落到每个组件里。

上线后的首轮观察也应克制:同时查看原始回调数、候选数、提交数和丢弃分布,而不是只盯曝光总量。若某类设备 duplicateDropped 激增,优先核对窗口重组和组件复用;若 shortDropped 激增,再检查阈值与交互节奏。状态机让这些判断有证据可循。

当数据口径需要调整时,保留旧策略的回放结果与诊断样本。新旧规则在同一时间线上并行计算,差异经过确认后再切换,能避免一次看似微小的阈值调整让历史趋势失去可比性。

参考资料:

  • HarmonyOS 多设备通用适配指南:https://developer.huawei.com/consumer/cn/multidevice/adaptive-apps/
  • HarmonyOS 7 平行视界能力说明:https://developer.huawei.com/consumer/cn/forum/topic/0201221235973021541
  • ArkUI onVisibleAreaChange 相关官方 FAQ:https://developer.huawei.com/consumer/cn/doc/doccenter-dev-faq/faqs-arkui-1603
Logo

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

更多推荐