1# 应用闪屏问题解决方案 2 3## 概述 4 5在开发调试过程中,有时会遇到应用出现非预期的闪动,这些闪动现象统称为闪屏问题。这些闪屏问题触发原因不同,表现形式不同,但都会对应用的体验性和流畅度产生影响。 6 7本文将概述如下几种常见的闪屏场景,对其成因进行深入分析,并提供针对性解决方案,以帮助开发者有效地应对这些问题。 8 9- 动画过程闪屏 10- 刷新过程闪屏 11 12## 常见问题 13 14### 动画过程中,应用连续点击场景下的闪屏问题 15 16**问题现象** 17 18在经过连续点击后,图标大小会出现不正常的放大缩小,产生闪屏问题。 19 20 21 22```ts 23@Entry 24@Component 25struct ClickError { 26 @State scaleValue: number = 0.5; // 缩放比 27 @State animated: boolean = true; // 控制放大缩小 28 29 build() { 30 Stack() { 31 Stack() { 32 Text('click') 33 .fontSize(45) 34 .fontColor(Color.White) 35 } 36 .borderRadius(50) 37 .width(100) 38 .height(100) 39 .backgroundColor('#e6cfe6') 40 .scale({ x: this.scaleValue, y: this.scaleValue }) 41 .onClick(() => { 42 this.getUIContext().animateTo({ 43 curve: Curve.EaseInOut, 44 duration: 350, 45 onFinish: () => { 46 // 动画结束判断最后缩放大小 47 const EPSILON: number = 1e-6; 48 if (Math.abs(this.scaleValue - 0.5) < EPSILON) { 49 this.scaleValue = 1; 50 } else { 51 this.scaleValue = 2; 52 } 53 } 54 }, () => { 55 this.animated = !this.animated; 56 this.scaleValue = this.animated ? 0.5 : 2.5; 57 }) 58 }) 59 } 60 .height('100%') 61 .width('100%') 62 } 63} 64``` 65 66**可能原因** 67 68应用在动画结束回调中,修改了属性的值。在图标连续放大缩小过程中,既有动画连续地改变属性的值,又有结束回调直接改变属性的值,造成过程中的值异常,效果不符合预期。一般在所有动画结束后可恢复正常,但会有跳变。 69 70**解决措施** 71 72- 尽量不在动画结束回调中设值,所有的设值都通过动画下发,让系统自动处理动画的衔接; 73- 如果一定要在动画结束回调中设值,可以通过计数器等方法,判断属性上是否还有动画。只有属性上最后一个动画结束时,结束回调中才设值,避免因动画打断造成异常。 74 75```ts 76@Entry 77@Component 78struct ClickRight { 79 @State scaleValue: number = 0.5; // 缩放比 80 @State animated: boolean = true; // 控制放大缩小 81 @State cnt: number = 0; // 执行次数计数器 82 83 build() { 84 Stack() { 85 Stack() { 86 Text('click') 87 .fontSize(45) 88 .fontColor(Color.White) 89 } 90 .borderRadius(50) 91 .width(100) 92 .height(100) 93 .backgroundColor('#e6cfe6') 94 .scale({ x: this.scaleValue, y: this.scaleValue }) 95 .onClick(() => { 96 // 下发动画时,计数加1 97 this.cnt = this.cnt + 1; 98 this.getUIContext().animateTo({ 99 curve: Curve.EaseInOut, 100 duration: 350, 101 onFinish: () => { 102 // 动画结束时,计数减1 103 this.cnt = this.cnt - 1; 104 // 计数为0表示当前最后一次动画结束 105 if (this.cnt === 0) { 106 // 动画结束判断最后缩放大小 107 const EPSILON: number = 1e-6; 108 if (Math.abs(this.scaleValue - 0.5) < EPSILON) { 109 this.scaleValue = 1; 110 } else { 111 this.scaleValue = 2; 112 } 113 } 114 } 115 }, () => { 116 this.animated = !this.animated; 117 this.scaleValue = this.animated ? 0.5 : 2.5; 118 }) 119 }) 120 } 121 .height('100%') 122 .width('100%') 123 } 124} 125``` 126 127运行效果如下图所示。 128 129 130 131### 动画过程中,Tabs页签切换场景下的闪屏问题 132 133**问题现象** 134 135滑动Tabs组件时,上方标签不能同步更新,在下方内容完全切换后才会闪动跳转,产生闪屏问题。 136 137 138 139```ts 140@Entry 141@Component 142struct TabsError { 143 tabsWidth: number = 100; 144 @State currentIndex: number = 0; 145 @State animationDuration: number = 300; 146 @State indicatorLeftMargin: number = 0; 147 @State indicatorWidth: number = 0; 148 private textInfos: [number, number][] = []; 149 private isStartAnimateTo: boolean = false; 150 151 @Builder 152 tabBuilder(index: number, name: string) { 153 Column() { 154 Text(name) 155 .fontSize(16) 156 .fontColor(this.currentIndex === index ? $r('sys.color.brand') : $r('sys.color.ohos_id_color_text_secondary')) 157 .fontWeight(this.currentIndex === index ? 500 : 400) 158 .id(index.toString()) 159 .onAreaChange((_oldValue: Area, newValue: Area) => { 160 this.textInfos[index] = [newValue.globalPosition.x as number, newValue.width as number]; 161 if (this.currentIndex === index && !this.isStartAnimateTo) { 162 this.indicatorLeftMargin = this.textInfos[index][0]; 163 this.indicatorWidth = this.textInfos[index][1]; 164 } 165 }) 166 }.width('100%') 167 } 168 169 build() { 170 Stack({ alignContent: Alignment.TopStart }) { 171 Tabs({ barPosition: BarPosition.Start }) { 172 TabContent() { 173 Column() 174 .width('100%') 175 .height('100%') 176 .backgroundColor(Color.Green) 177 .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) 178 } 179 .tabBar(this.tabBuilder(0, 'green')) 180 .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) 181 182 TabContent() { 183 Column() 184 .width('100%') 185 .height('100%') 186 .backgroundColor(Color.Blue) 187 .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) 188 } 189 .tabBar(this.tabBuilder(1, 'blue')) 190 .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) 191 192 TabContent() { 193 Column() 194 .width('100%') 195 .height('100%') 196 .backgroundColor(Color.Yellow) 197 .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) 198 } 199 .tabBar(this.tabBuilder(2, 'yellow')) 200 .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) 201 202 TabContent() { 203 Column() 204 .width('100%') 205 .height('100%') 206 .backgroundColor(Color.Pink) 207 .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) 208 } 209 .tabBar(this.tabBuilder(3, 'pink')) 210 .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) 211 } 212 .barWidth('100%') 213 .barHeight(56) 214 .width('100%') 215 .backgroundColor('#F1F3F5') 216 .animationDuration(this.animationDuration) 217 .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) 218 .onChange((index: number) => { 219 this.currentIndex = index; // 监听索引index的变化,实现页签内容的切换。 220 }) 221 222 Column() 223 .height(2) 224 .borderRadius(1) 225 .width(this.indicatorWidth) 226 .margin({ left: this.indicatorLeftMargin, top: 48 }) 227 .backgroundColor($r('sys.color.brand')) 228 }.width('100%') 229 } 230} 231``` 232 233**可能原因** 234 235在Tabs左右翻页动画的结束回调中,刷新了选中页面的index值。造成当页面左右转场动画结束时,页签栏中index对应页签的样式(字体大小、下划线等)立刻发生改变,导致产生闪屏。 236 237**解决措施** 238 239在左右跟手翻页过程中,通过TabsAnimationEvent事件获取手指滑动距离,改变下划线在前后两个子页签之间的位置。在离手触发翻页动画时,一并触发下划线动画,保证下划线与页面左右转场动画同步进行。 240 241```ts 242build() { 243 Stack({ alignContent: Alignment.TopStart }) { 244 Tabs({ barPosition: BarPosition.Start }) { 245 TabContent() { 246 Column() 247 .width('100%') 248 .height('100%') 249 .backgroundColor(Color.Green) 250 .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) 251 } 252 .tabBar(this.tabBuilder(0, 'green')) 253 .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) 254 255 TabContent() { 256 Column() 257 .width('100%') 258 .height('100%') 259 .backgroundColor(Color.Blue) 260 .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) 261 } 262 .tabBar(this.tabBuilder(1, 'blue')) 263 .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) 264 265 TabContent() { 266 Column() 267 .width('100%') 268 .height('100%') 269 .backgroundColor(Color.Yellow) 270 .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) 271 } 272 .tabBar(this.tabBuilder(2, 'yellow')) 273 .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) 274 275 TabContent() { 276 Column() 277 .width('100%') 278 .height('100%') 279 .backgroundColor(Color.Pink) 280 .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) 281 } 282 .tabBar(this.tabBuilder(3, 'pink')) 283 .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) 284 } 285 .onAreaChange((_oldValue: Area, newValue: Area) => { 286 this.tabsWidth = newValue.width as number; 287 }) 288 .barWidth('100%') 289 .barHeight(56) 290 .width('100%') 291 .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) 292 .backgroundColor('#F1F3F5') 293 .animationDuration(this.animationDuration) 294 .onChange((index: number) => { 295 this.currentIndex = index; // 监听索引index的变化,实现页签内容的切换。 296 }) 297 .onAnimationStart((_index: number, targetIndex: number) => { 298 // 切换动画开始时触发该回调。下划线跟着页面一起滑动,同时宽度渐变。 299 this.currentIndex = targetIndex; 300 this.startAnimateTo(this.animationDuration, this.textInfos[targetIndex][0], this.textInfos[targetIndex][1]); 301 }) 302 .onAnimationEnd((index: number, event: TabsAnimationEvent) => { 303 // 切换动画结束时触发该回调。下划线动画停止。 304 let currentIndicatorInfo = this.getCurrentIndicatorInfo(index, event); 305 this.startAnimateTo(0, currentIndicatorInfo.left, currentIndicatorInfo.width); 306 }) 307 .onGestureSwipe((index: number, event: TabsAnimationEvent) => { 308 // 在页面跟手滑动过程中,逐帧触发该回调。 309 let currentIndicatorInfo = this.getCurrentIndicatorInfo(index, event); 310 this.currentIndex = currentIndicatorInfo.index; 311 this.indicatorLeftMargin = currentIndicatorInfo.left; 312 this.indicatorWidth = currentIndicatorInfo.width; 313 }) 314 315 Column() 316 .height(2) 317 .borderRadius(1) 318 .width(this.indicatorWidth) 319 .margin({ left: this.indicatorLeftMargin, top: 48 }) 320 .backgroundColor($r('sys.color.brand')) 321 } 322 .width('100%') 323} 324``` 325 326TabsAnimationEvent方法如下所示。 327 328```ts 329private getCurrentIndicatorInfo(index: number, event: TabsAnimationEvent): Record<string, number> { 330 let nextIndex = index; 331 if (index > 0 && event.currentOffset > 0) { 332 nextIndex--; 333 } else if (index < 3 && event.currentOffset < 0) { 334 nextIndex++; 335 } 336 let indexInfo = this.textInfos[index]; 337 let nextIndexInfo = this.textInfos[nextIndex]; 338 let swipeRatio = Math.abs(event.currentOffset / this.tabsWidth); 339 let currentIndex = swipeRatio > 0.5 ? nextIndex : index; // 页面滑动超过一半,tabBar切换到下一页。 340 let currentLeft = indexInfo[0] + (nextIndexInfo[0] - indexInfo[0]) * swipeRatio; 341 let currentWidth = indexInfo[1] + (nextIndexInfo[1] - indexInfo[1]) * swipeRatio; 342 return { 'index': currentIndex, 'left': currentLeft, 'width': currentWidth }; 343} 344private startAnimateTo(duration: number, leftMargin: number, width: number) { 345 this.isStartAnimateTo = true; 346 this.getUIContext().animateTo({ 347 duration: duration, // 动画时长 348 curve: Curve.Linear, // 动画曲线 349 iterations: 1, // 播放次数 350 playMode: PlayMode.Normal, // 动画模式 351 onFinish: () => { 352 this.isStartAnimateTo = false; 353 console.info('play end'); 354 } 355 }, () => { 356 this.indicatorLeftMargin = leftMargin; 357 this.indicatorWidth = width; 358 }) 359} 360``` 361 362运行效果如下图所示。 363 364 365 366### 刷新过程中,ForEach键值生成函数未设置导致的闪屏问题 367 368**问题现象** 369 370下拉刷新时,应用产生卡顿,出现闪屏问题。 371 372 373 374```ts 375@Builder 376private getListView() { 377 List({ 378 space: 12, scroller: this.scroller 379 }) { 380 // 使用懒加载组件渲染数据 381 ForEach(this.newsData, (item: NewsData) => { 382 ListItem() { 383 newsItem({ 384 newsTitle: item.newsTitle, 385 newsContent: item.newsContent, 386 newsTime: item.newsTime, 387 img: item.img 388 }) 389 } 390 .backgroundColor(Color.White) 391 .borderRadius(16) 392 }); 393 } 394 .width('100%') 395 .height('100%') 396 .padding({ 397 left: 16, 398 right: 16 399 }) 400 .backgroundColor('#F1F3F5') 401 // 必须设置列表为滑动到边缘无效果,否则无法触发pullToRefresh组件的上滑下拉方法。 402 .edgeEffect(EdgeEffect.None) 403} 404``` 405 406**可能原因** 407 408ForEach提供了一个名为keyGenerator的参数,这是一个函数,开发者可以通过它自定义键值的生成规则。如果开发者没有定义keyGenerator函数,则ArkUI框架会使用默认的键值生成函数,即(item: Object, index: number) => { return index + '__' + JSON.stringify(item); }。可参考[键值生成规则](../ui/state-management/arkts-rendering-control-foreach.md#键值生成规则)。 409 410在使用ForEach的过程中,若对于键值生成规则的理解不够充分,可能会出现错误的使用方式。错误使用一方面会导致功能层面问题,例如渲染结果非预期,另一方面会导致性能层面问题,例如渲染性能降低。 411 412**解决措施** 413 414在ForEach第三个参数中定义自定义键值的生成规则,即(item: NewsData, index?: number) => item.id,这样可以在渲染时降低重复组件的渲染开销,从而消除闪屏问题。可参考[ForEach组件使用建议](../ui/state-management/arkts-rendering-control-foreach.md#使用建议)。 415 416```ts 417@Builder 418private getListView() { 419 List({ 420 space: 12, scroller: this.scroller 421 }) { 422 // 使用懒加载组件渲染数据 423 ForEach(this.newsData, (item: NewsData) => { 424 ListItem() { 425 newsItem({ 426 newsTitle: item.newsTitle, 427 newsContent: item.newsContent, 428 newsTime: item.newsTime, 429 img: item.img 430 }) 431 } 432 .backgroundColor(Color.White) 433 .borderRadius(16) 434 }, (item: NewsData) => item.newsId); 435 } 436 .width('100%') 437 .height('100%') 438 .padding({ 439 left: 16, 440 right: 16 441 }) 442 .backgroundColor('#F1F3F5') 443 // 必须设置列表为滑动到边缘无效果,否则无法触发pullToRefresh组件的上滑下拉方法。 444 .edgeEffect(EdgeEffect.None) 445} 446``` 447 448运行效果如下图所示。 449 450 451 452## 总结 453 454当出现应用闪屏相关问题时,首先定位可能出现的原因,分别测试是否为当前原因导致。定位到问题后尝试使用对应解决方案,从而消除对应问题现象。 455 456- 应用连续点击场景下,通过计数器优化动画逻辑。 457- Tabs页签切换场景下,完善动画细粒度,提高流畅表现。 458- ForEach刷新内容过程中,根据业务场景调整键值生成函数。 459