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 539在resources/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 556在module.json5配置文件的module标签中定义routerMap字段,指向路由表配置文件route_map.json。 557 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