• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1# 感知组件可见性
2<!--Kit: ArkUI-->
3<!--Subsystem: ArkUI-->
4<!--Owner: @jiangtao92-->
5<!--Designer: @piggyguy-->
6<!--Tester: @songyanhong-->
7<!--Adviser: @HelloCrease-->
8
9## 概述
10组件可见性是指组件在屏幕上的显示状态,通过感知可见性,应用能够实现以下典型场景:
11- 组件曝光统计与分析(例如,统计广告组件在屏幕上的显示时长);
12- 资源按需加载与释放(例如,组件不可见时,释放组件使用的图片、视频等资源);
13- 感知复杂视图切换(例如,在多层视图嵌套情况下,依据组件的显示状态,处理相关逻辑)。
14
15针对上述场景,建议按照以下策略进行选择:
16
17|场景描述 	|推荐接口 	|说明 |
18|----- 	|---- |--- |
19|[组件曝光统计与分析](#组件曝光统计与分析)  	| onVisibleAreaApproximateChange	|要监控的组件数量多,需要低频计算降低开销。 |
20|[资源按需加载与释放](#资源按需加载与释放) |	onVisibleAreaChange	|要监控的组件数量少,希望每帧检测确保状态及时更新。 |
21|[感知复杂视图切换](#感知复杂视图切换) |	nodeRenderState监听 |	适合感知页面或页切换导致的可见性变化。 |
22
23应用也可自行遍历计算组件可见性,但由于组件存在复杂的层次关系,自行计算涉及大量运算,通常不被推荐。
24
25## 组件曝光统计与分析
26
27使用[onVisibleAreaApproximateChange](../reference/apis-arkui/arkui-ts/ts-universal-component-visible-area-change-event.md#onvisibleareaapproximatechange17)监控关键组件(如广告、商品卡片)的曝光时长,用于用户行为分析和运营统计。
28
29该接口比onVisibleAreaChange性能更优,支持通过设置计算周期减少检测频率,适用于组件数量多、层级深的场景,可显著降低性能消耗。
30
31> **说明:**
32>
33> 该能力从API version 17开始支持。
34
35```typescript
36class ListDataSource implements IDataSource {
37  private list: number[] = [];
38  private listeners: DataChangeListener[] = [];
39
40  constructor(list: number[]) {
41    this.list = list;
42  }
43
44  totalCount(): number {
45    return this.list.length;
46  }
47
48  getData(index: number): number {
49    return this.list[index];
50  }
51
52  registerDataChangeListener(listener: DataChangeListener): void {
53    if (this.listeners.indexOf(listener) < 0) {
54      this.listeners.push(listener);
55    }
56  }
57
58  unregisterDataChangeListener(listener: DataChangeListener): void {
59    const pos = this.listeners.indexOf(listener);
60    if (pos >= 0) {
61      this.listeners.splice(pos, 1);
62    }
63  }
64
65  notifyDataDelete(index: number): void {
66    this.listeners.forEach(listener => {
67      listener.onDataDelete(index);
68    });
69  }
70
71  notifyDataAdd(index: number): void {
72    this.listeners.forEach(listener => {
73      listener.onDataAdd(index);
74    });
75  }
76
77  public deleteItem(index: number): void {
78    this.list.splice(index, 1);
79    this.notifyDataDelete(index);
80  }
81
82  public insertItem(index: number, data: number): void {
83    this.list.splice(index, 0, data);
84    this.notifyDataAdd(index);
85  }
86}
87
88class ExposureTrackingData {
89  // 使用一个map记录当前正在展示的广告位,以及它开始被展示的时间戳,以便在它不可见时可以计算在屏幕上的展示时长
90  private visibleAdvertisingInfos = new Map<string, number>();
91  // 使用一个map记录每个广告位的展示总时长
92  private exposureData = new Map<string, number>();
93
94  constructor() {
95  }
96
97  notifyAdvertisingSlotIsAppearing(slot: string): void {
98    // 广告位开始展示,记录起始时间戳
99    let startTimestamp = Date.now()
100    this.visibleAdvertisingInfos.set(slot, startTimestamp)
101  }
102
103  notifyAdvertisingSlotIsDisappearing(slot: string): void {
104    // 广告位开始消失,计算本次展示时长,并累加到总时长数据中
105    let endTimestamp: number = Date.now()
106    let advertisingInfo = this.visibleAdvertisingInfos.get(slot)
107    let duration: number = 0
108    if (advertisingInfo) {
109      duration = endTimestamp - advertisingInfo.valueOf()
110    }
111    // 刷新展示总时长
112    this.updateExposureData(slot, duration)
113    // 从当前可见的map中删除这个广告位信息
114    this.visibleAdvertisingInfos.delete(slot)
115  }
116
117  notifyPageHiding(): void {
118    // 页面正在退出,上报统计数据
119    this.reportData()
120  }
121
122  updateExposureData(slot: string, duration: number) {
123    if (duration <= 0) {
124      return
125    }
126    let oldDuration = 0
127    let dataItem = this.exposureData.get(slot)
128    if (dataItem) {
129      oldDuration = dataItem.valueOf()
130    }
131    this.exposureData.set(slot, oldDuration + duration)
132  }
133
134  consumeAllCurrentVisibleSlots(): void {
135    this.visibleAdvertisingInfos.forEach((value: number, key: string) => {
136      this.notifyAdvertisingSlotIsDisappearing(key)
137    });
138    this.visibleAdvertisingInfos.clear()
139  }
140
141  reportData(): void {
142    // 上报之前先将当前正在展示的广告位统计信息刷新到总时长
143    this.consumeAllCurrentVisibleSlots()
144    // 发送数据到分析平台
145    console.info(`曝光数据上报: ` + Array.from(this.exposureData))
146    // 上报后清空
147    this.exposureData.clear()
148  }
149}
150
151@Entry
152@ComponentV2
153struct ExposureTrackingPage {
154  private data: ListDataSource =
155    new ListDataSource([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]);
156  private exposureData = new ExposureTrackingData()
157
158  onPageHide(): void {
159    // 在页面退出时,上报统计数据到分析平台
160    this.exposureData.notifyPageHiding()
161  }
162
163  build() {
164    Column() {
165      List({ space: 20, initialIndex: 0 }) {
166        LazyForEach(this.data, (item: number) => {
167          ListItem() {
168            Text(this.getAdvertisingSlotInfo(item))
169              .width('100%')
170              .height(100)
171              .fontSize(20)
172              .fontColor(Color.White)
173              .textAlign(TextAlign.Center)
174              .borderRadius(10)
175              .backgroundColor(this.calculateItemBackgroundColor(item))
176          }
177          // 为每一个列表条目都增加一个可见性监听回调,给定阈值0.5,即如果广告位在屏幕上显示超过自身一半,就认为已经曝光;
178          // 尽管这里代码只写了一行,但实际会为每个显示出来的列表项都绑定一个回调,因此这里我们使用可控制计算频率的回调接口。
179          .onVisibleAreaApproximateChange({ ratios: [0.5], expectedUpdateInterval: 500 },
180            (isExpanding: boolean, currentRatio: number) => {
181              this.handleExposureTracking(item, isExpanding, currentRatio)
182            })
183        }, (item: number) => item.toString())
184      }
185      .listDirection(Axis.Vertical)
186      .scrollBar(BarState.Off)
187      .edgeEffect(EdgeEffect.Spring)
188      .width('90%')
189      .margin(5)
190    }
191    .width('100%')
192    .height('100%')
193    .backgroundColor(0xDCDCDC)
194    .padding({ top: 5 })
195  }
196
197  private isAdvertisingSlot(index: number): boolean {
198    // 假设每隔5个组件就是一个广告位
199    return (index % 5 == 0)
200  }
201
202  private calculateAdvertisingSlot(index: number): number | null {
203    if (this.isAdvertisingSlot(index)) {
204      return (index / 5)
205    }
206    return null
207  }
208
209  private calculateItemBackgroundColor(index: number): Color {
210    if (this.isAdvertisingSlot(index)) {
211      return Color.Green
212    }
213
214    return Color.Gray
215  }
216
217  private getAdvertisingSlotInfo(index: number): string {
218    let advertisingSlot = this.calculateAdvertisingSlot(index)
219    if (advertisingSlot) {
220      return advertisingSlot + " 号广告位"
221    }
222    return '普通内容 ' + index
223  }
224
225  private handleExposureTracking(index: number, isExpanding: boolean, currentRatio: number): void {
226    if (!this.isAdvertisingSlot(index)) {
227      // 不处理非广告位
228      return
229    }
230    let slot = this.getAdvertisingSlotInfo(index)
231    if (isExpanding) {
232      // 可见比例正在增大,说明组件正在出现
233      this.exposureData.notifyAdvertisingSlotIsAppearing(slot)
234      return
235    }
236    // 可见比例正在减小,说明组件正在消失
237    this.exposureData.notifyAdvertisingSlotIsDisappearing(slot)
238  }
239}
240```
241
242## 资源按需加载与释放
243
244使用[onVisibleAreaChange](../reference/apis-arkui/arkui-ts/ts-universal-component-visible-area-change-event.md#onvisibleareachange)监听组件可见面积占比的精细变化,当可见比例接近预设阈值时触发回调,根据可见比例的变化加载或释放资源。
245
246> **说明:**
247>
248> 该能力从API version 9开始支持。
249> - 可见面积以父组件边界为限,超出父组件的部分不会被计入可见面积比值计算;
250> - 由于存在浮点数比较,系统会在计算结果接近所设置的阈值时触发回调;
251> - 为确保可见性变化通知的及时性,系统在每帧进行计算可见比例的变化检测,为了减小系统负载,应尽可能少的使用这个接口。
252
253```typescript
254import { image } from '@kit.ImageKit';
255
256class ListDataSource implements IDataSource {
257  private list: number[] = [];
258  private listeners: DataChangeListener[] = [];
259
260  constructor(list: number[]) {
261    this.list = list;
262  }
263
264  totalCount(): number {
265    return this.list.length;
266  }
267
268  getData(index: number): number {
269    return this.list[index];
270  }
271
272  registerDataChangeListener(listener: DataChangeListener): void {
273    if (this.listeners.indexOf(listener) < 0) {
274      this.listeners.push(listener);
275    }
276  }
277
278  unregisterDataChangeListener(listener: DataChangeListener): void {
279    const pos = this.listeners.indexOf(listener);
280    if (pos >= 0) {
281      this.listeners.splice(pos, 1);
282    }
283  }
284
285  notifyDataDelete(index: number): void {
286    this.listeners.forEach(listener => {
287      listener.onDataDelete(index);
288    });
289  }
290
291  notifyDataAdd(index: number): void {
292    this.listeners.forEach(listener => {
293      listener.onDataAdd(index);
294    });
295  }
296
297  public deleteItem(index: number): void {
298    this.list.splice(index, 1);
299    this.notifyDataDelete(index);
300  }
301
302  public insertItem(index: number, data: number): void {
303    this.list.splice(index, 0, data);
304    this.notifyDataAdd(index);
305  }
306}
307
308@Entry
309@ComponentV2
310struct Index {
311  @Local headerImage: PixelMap | null = null
312  private data: ListDataSource =
313    new ListDataSource([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]);
314
315  build() {
316    Column() {
317      List({ space: 20, initialIndex: 0 }) {
318        ListItem() {
319          Image(this.headerImage)
320            .width('100%')
321            .height(300)
322            // 整个页面上只有这一个组件需要监听可见性,并且需要及时感知状态进行资源的及时加载
323            .onVisibleAreaChange([0.1], (isExpanding: boolean, currentRatio: number) => {
324              this.loadOrReleaseHeaderImage(isExpanding)
325            })
326        }
327
328        LazyForEach(this.data, (item: number) => {
329          ListItem() {
330            Text('' + item)
331              .width('100%')
332              .height(50)
333              .fontSize(20)
334              .fontColor(Color.White)
335              .textAlign(TextAlign.Center)
336              .borderRadius(10)
337              .backgroundColor(Color.Grey)
338          }
339        }, (item: number) => item.toString())
340      }
341      .listDirection(Axis.Vertical)
342      .scrollBar(BarState.Off)
343      .edgeEffect(EdgeEffect.Spring)
344      .width('90%')
345      .margin(5)
346    }
347    .width('100%')
348    .height('100%')
349    .backgroundColor(0xDCDCDC)
350    .padding({ top: 5 })
351  }
352
353  private loadOrReleaseHeaderImage(isExpanding: boolean): void {
354    if (!isExpanding) {
355      // 马上就不可见了,释放掉图片
356      this.headerImage = null
357      console.info('图片释放完成')
358      return
359    }
360
361    try {
362      this.getUIContext().getHostContext()!.resourceManager.getMediaContent($r('app.media.startIcon').id,
363        (error, value: ArrayBuffer) => {
364          let opts: image.InitializationOptions = {
365            editable: true,
366            pixelFormat: 3,
367            size: { height: 4, width: 6 }
368          };
369          let uint8Array: Uint8Array = new Uint8Array(value);
370          let buffer: ArrayBuffer = uint8Array.buffer.slice(0);
371
372          image.createPixelMap(buffer, opts).then((pixelMap) => {
373            this.headerImage = pixelMap
374            console.info('图片加载完成')
375          })
376        });
377    } catch (error) {
378      console.error(`callback getMediaContent failed, error code: ${error.code}, message: ${error.message}.`)
379    }
380  }
381}
382```
383
384## 感知复杂视图切换
385
386通过UIObserver提供的[on('nodeRenderState')](../reference/apis-arkui/arkts-apis-uicontext-uiobserver.md#onnoderenderstate20)方法,可以监听指定组件的渲染状态。此接口需要传入一个组件标识,以指定需要观察的组件,因此不适用于组件频繁创建和销毁的场景,适用于因页面变化导致的组件显隐变化,例如页面跳转、组件所在页面被压栈,如Swiper/Tabs组件当前显示页被划出的场景。
387
388渲染状态有两种:
389- ABOUT_TO_RENDER_IN:组件已挂载到渲染树,下一帧将被渲染;
390- ABOUT_TO_RENDER_OUT:组件已从渲染树移除,下一帧不再渲染。
391
392> **说明:**
393>
394> 该能力从API version 20开始支持。
395
396需要注意的是,ABOUT_TO_RENDER_IN仅表示组件进入渲染流程,下一帧将由系统送显到屏幕上,但组件可能因被其他组件遮挡而无法被看到,因此渲染状态并不完全等同于可见性。
397
398以下示例将一个被观测的Column组件置于Tabs、Navigation和Swiper的嵌套布局中,无论切换Tab页、页面跳转或Swiper页,均能准确感知组件是否显示于屏幕上。
399
400> **说明:**
401> 鉴于on('nodeRenderState')接口的特点,不建议将其用于列表项这种划出屏幕区域外节点就会被回收下树的场景。
402
403
404```typescript
405// Index.ets
406import { NodeRenderState } from '@kit.ArkUI';
407
408@Entry
409@ComponentV2
410struct Index {
411  private childNavStack: NavPathStack = new NavPathStack();
412  private tabController: TabsController = new TabsController();
413
414  build() {
415    Tabs({ barPosition: BarPosition.End, controller: this.tabController }) {
416      TabContent() {
417        Navigation() {
418          Stack({ alignContent: Alignment.Center }) {
419            Swiper() {
420              // swiper 第一页为一个子navigation
421              Navigation(this.childNavStack) {
422                Column() {
423                  Text('被监听的组件')
424                    .width('100%')
425                    .height('100%')
426                    .fontSize(26)
427                    .fontColor(Color.White)
428                    .textAlign(TextAlign.Center)
429                }
430                .width('90%')
431                .height(300)
432                .backgroundColor(Color.Blue)
433                .id('component_to_be_monitor')
434                .onAttach(() => {
435                  // 10ms后再注册监听回调,避免挂载还未完全完成
436                  setTimeout(()=>{
437                    // 在被监听的组件挂载的时候开启对该组件的状态监听
438                    this.getUIContext()
439                      .getUIObserver()
440                      .on('nodeRenderState', 'component_to_be_monitor', (state: NodeRenderState, node?: FrameNode) => {
441                        if (state == NodeRenderState.ABOUT_TO_RENDER_IN) {
442                          console.info('组件将显示')
443                        } else {
444                          console.info('组件将消失')
445                        }
446                      })
447                  }, 10)
448                })
449                .onDetach(() => {
450                  // 在被监听的组件从组件树上下树时取消监听
451                  this.getUIContext().getUIObserver().off('nodeRenderState', 'component_to_be_monitor')
452                })
453
454                Button('跳转下一页', { stateEffect: true, type: ButtonType.Capsule })
455                  .width('80%')
456                  .height(40)
457                  .margin(20)
458                  .onClick(() => {
459                    let parentStack = this.childNavStack.getParent();
460                    parentStack?.pushPath({ name: "pageTwo" });
461                  })
462              }
463              .clip(true)
464              .backgroundColor(Color.Orange)
465              .width('90%')
466              .height('90%')
467              .title('ChildNavigation')
468
469              // swiper 第二页
470              Text('swiper 第二页')
471                .width('90%')
472                .height('90%')
473                .fontSize(20)
474                .fontColor(Color.Black)
475                .backgroundColor(Color.Orange)
476                .textAlign(TextAlign.Center)
477              // swiper 第三页
478              Text('swiper 第三页')
479                .width('90%')
480                .height('90%')
481                .fontSize(20)
482                .fontColor(Color.Black)
483                .backgroundColor(Color.Orange)
484                .textAlign(TextAlign.Center)
485            }
486            .itemSpace(10)
487          }
488          .width('100%')
489          .height('100%')
490        }
491        .backgroundColor(Color.Green)
492        .width('100%')
493        .height('100%')
494        .title('ParentNavigation')
495      }.tabBar('首页')
496
497      TabContent() {
498        Text('推荐')
499          .width('100%')
500          .height('100%')
501          .fontSize(20)
502          .fontColor(Color.Black)
503          .backgroundColor(Color.Pink)
504          .textAlign(TextAlign.Center)
505      }.tabBar('推荐')
506
507      TabContent() {
508        Text('我的')
509          .width('100%')
510          .height('100%')
511          .fontSize(20)
512          .fontColor(Color.Black)
513          .backgroundColor(Color.Yellow)
514          .textAlign(TextAlign.Center)
515      }.tabBar('我的')
516    }
517    .backgroundColor(Color.Gray)
518  }
519}
520```
521
522```typescript
523// PageTwo.ets
524@Builder
525export function PageTwoBuilder(name: string) {
526  NavDestination() {
527    Text("this is " + name)
528      .width('100%')
529      .height('100%')
530      .textAlign(TextAlign.Center)
531      .fontSize(20)
532      .fontColor(Color.White)
533      .backgroundColor(Color.Red)
534  }
535  .title(name)
536}
537```
538
539resources/base/profile中创建route_map.json文件,并添加以下配置信息。
540
541```json
542{
543  "routerMap": [
544    {
545      "name": "pageTwo",
546      "pageSourceFile": "src/main/ets/pages/PageTwo.ets",
547      "buildFunction": "PageTwoBuilder",
548      "data": {
549        "description": "this is pageTwo"
550      }
551    }
552  ]
553}
554```
555
556module.json5配置文件的module标签中定义routerMap字段,指向路由表配置文件route_map.json557
558```json
559"routerMap": "$profile:route_map"
560```
561
562## 常见问题
563
564### 可见性计算与实际视觉不符
565
566**问题现象**
567
568组件已进入屏幕但回调未触发,或可见比例与视觉感知不一致。
569
570**解决措施**
571- 检查父组件是否设置clip属性,裁剪可能导致可见面积计算偏差。
572- 考虑组件透明度影响,即使 opacity为0也会被计入可见面积。
573- 结合nodeRenderState监听交叉验证。
574
575### 高频回调导致性能下降
576
577**问题现象**
578
579滚动时界面卡顿,日志显示可见性回调频繁执行。
580
581**解决措施**
582- 切换到onVisibleAreaApproximateChange并将expectedUpdateInterval设置为一个更大的值。
583- 减少注册可见性回调的组件数量。
584
585### RenderState监听数量超限
586**问题现象**
587
588nodeRenderState监听失败,日志提示超出最大监听数量。
589
590**解决措施**
591- 替换为使用局部监听接口onVisibleAreaApproximateChange。
592- 替换为对显示范围较大的父容器组件进行监听。
593- 及时移除不再需要的监听off方法。
594