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 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 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 @State currentIndex: number = 0; 144 @State animationDuration: number = 300; 145 @State indicatorLeftMargin: number = 0; 146 @State indicatorWidth: number = 0; 147 private textInfos: [number, number][] = []; 148 private isStartAnimateTo: boolean = false; 149 150 @Builder 151 tabBuilder(index: number, name: string) { 152 Column() { 153 Text(name) 154 .fontSize(16) 155 .fontColor(this.currentIndex === index ? $r('sys.color.brand') : $r('sys.color.ohos_id_color_text_secondary')) 156 .fontWeight(this.currentIndex === index ? 500 : 400) 157 .id(index.toString()) 158 .onAreaChange((_oldValue: Area, newValue: Area) => { 159 this.textInfos[index] = [newValue.globalPosition.x as number, newValue.width as number]; 160 if (this.currentIndex === index && !this.isStartAnimateTo) { 161 this.indicatorLeftMargin = this.textInfos[index][0]; 162 this.indicatorWidth = this.textInfos[index][1]; 163 } 164 }) 165 }.width('100%') 166 } 167 168 build() { 169 Stack({ alignContent: Alignment.TopStart }) { 170 Tabs({ barPosition: BarPosition.Start }) { 171 TabContent() { 172 Column() 173 .width('100%') 174 .height('100%') 175 .backgroundColor(Color.Green) 176 .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) 177 } 178 .tabBar(this.tabBuilder(0, 'green')) 179 .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) 180 181 TabContent() { 182 Column() 183 .width('100%') 184 .height('100%') 185 .backgroundColor(Color.Blue) 186 .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) 187 } 188 .tabBar(this.tabBuilder(1, 'blue')) 189 .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) 190 191 TabContent() { 192 Column() 193 .width('100%') 194 .height('100%') 195 .backgroundColor(Color.Yellow) 196 .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) 197 } 198 .tabBar(this.tabBuilder(2, 'yellow')) 199 .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) 200 201 TabContent() { 202 Column() 203 .width('100%') 204 .height('100%') 205 .backgroundColor(Color.Pink) 206 .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) 207 } 208 .tabBar(this.tabBuilder(3, 'pink')) 209 .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) 210 } 211 .barWidth('100%') 212 .barHeight(56) 213 .width('100%') 214 .backgroundColor('#F1F3F5') 215 .animationDuration(this.animationDuration) 216 .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) 217 .onChange((index: number) => { 218 this.currentIndex = index; // 监听索引index的变化,实现页签内容的切换。 219 }) 220 221 Column() 222 .height(2) 223 .borderRadius(1) 224 .width(this.indicatorWidth) 225 .margin({ left: this.indicatorLeftMargin, top: 48 }) 226 .backgroundColor($r('sys.color.brand')) 227 }.width('100%') 228 } 229} 230``` 231 232**可能原因** 233 234在Tabs左右翻页动画的结束回调中,刷新了选中页面的index值。造成当页面左右转场动画结束时,页签栏中index对应页签的样式(字体大小、下划线等)立刻发生改变,导致产生闪屏。 235 236**解决措施** 237 238在左右跟手翻页过程中,通过TabsAnimationEvent事件获取手指滑动距离,改变下划线在前后两个子页签之间的位置。在离手触发翻页动画时,一并触发下划线动画,保证下划线与页面左右转场动画同步进行。 239 240```ts 241build() { 242 Stack({ alignContent: Alignment.TopStart }) { 243 Tabs({ barPosition: BarPosition.Start }) { 244 TabContent() { 245 Column() 246 .width('100%') 247 .height('100%') 248 .backgroundColor(Color.Green) 249 .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) 250 } 251 .tabBar(this.tabBuilder(0, 'green')) 252 .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) 253 254 TabContent() { 255 Column() 256 .width('100%') 257 .height('100%') 258 .backgroundColor(Color.Blue) 259 .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) 260 } 261 .tabBar(this.tabBuilder(1, 'blue')) 262 .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) 263 264 TabContent() { 265 Column() 266 .width('100%') 267 .height('100%') 268 .backgroundColor(Color.Yellow) 269 .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) 270 } 271 .tabBar(this.tabBuilder(2, 'yellow')) 272 .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) 273 274 TabContent() { 275 Column() 276 .width('100%') 277 .height('100%') 278 .backgroundColor(Color.Pink) 279 .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) 280 } 281 .tabBar(this.tabBuilder(3, 'pink')) 282 .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) 283 } 284 .onAreaChange((_oldValue: Area, newValue: Area) => { 285 this.tabsWidth = newValue.width as number; 286 }) 287 .barWidth('100%') 288 .barHeight(56) 289 .width('100%') 290 .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) 291 .backgroundColor('#F1F3F5') 292 .animationDuration(this.animationDuration) 293 .onChange((index: number) => { 294 this.currentIndex = index; // 监听索引index的变化,实现页签内容的切换。 295 }) 296 .onAnimationStart((_index: number, targetIndex: number) => { 297 // 切换动画开始时触发该回调。下划线跟着页面一起滑动,同时宽度渐变。 298 this.currentIndex = targetIndex; 299 this.startAnimateTo(this.animationDuration, this.textInfos[targetIndex][0], this.textInfos[targetIndex][1]); 300 }) 301 .onAnimationEnd((index: number, event: TabsAnimationEvent) => { 302 // 切换动画结束时触发该回调。下划线动画停止。 303 let currentIndicatorInfo = this.getCurrentIndicatorInfo(index, event); 304 this.startAnimateTo(0, currentIndicatorInfo.left, currentIndicatorInfo.width); 305 }) 306 .onGestureSwipe((index: number, event: TabsAnimationEvent) => { 307 // 在页面跟手滑动过程中,逐帧触发该回调。 308 let currentIndicatorInfo = this.getCurrentIndicatorInfo(index, event); 309 this.currentIndex = currentIndicatorInfo.index; 310 this.indicatorLeftMargin = currentIndicatorInfo.left; 311 this.indicatorWidth = currentIndicatorInfo.width; 312 }) 313 314 Column() 315 .height(2) 316 .borderRadius(1) 317 .width(this.indicatorWidth) 318 .margin({ left: this.indicatorLeftMargin, top: 48 }) 319 .backgroundColor($r('sys.color.brand')) 320 } 321 .width('100%') 322} 323``` 324 325TabsAnimationEvent方法如下所示。 326 327```ts 328private getCurrentIndicatorInfo(index: number, event: TabsAnimationEvent): Record<string, number> { 329 let nextIndex = index; 330 if (index > 0 && event.currentOffset > 0) { 331 nextIndex--; 332 } else if (index < 3 && event.currentOffset < 0) { 333 nextIndex++; 334 } 335 let indexInfo = this.textInfos[index]; 336 let nextIndexInfo = this.textInfos[nextIndex]; 337 let swipeRatio = Math.abs(event.currentOffset / this.tabsWidth); 338 let currentIndex = swipeRatio > 0.5 ? nextIndex : index; // 页面滑动超过一半,tabBar切换到下一页。 339 let currentLeft = indexInfo[0] + (nextIndexInfo[0] - indexInfo[0]) * swipeRatio; 340 let currentWidth = indexInfo[1] + (nextIndexInfo[1] - indexInfo[1]) * swipeRatio; 341 return { 'index': currentIndex, 'left': currentLeft, 'width': currentWidth }; 342} 343private startAnimateTo(duration: number, leftMargin: number, width: number) { 344 this.isStartAnimateTo = true; 345 animateTo({ 346 duration: duration, // 动画时长 347 curve: Curve.Linear, // 动画曲线 348 iterations: 1, // 播放次数 349 playMode: PlayMode.Normal, // 动画模式 350 onFinish: () => { 351 this.isStartAnimateTo = false; 352 console.info('play end'); 353 } 354 }, () => { 355 this.indicatorLeftMargin = leftMargin; 356 this.indicatorWidth = width; 357 }) 358} 359``` 360 361运行效果如下图所示。 362 363 364 365### 刷新过程中,ForEach键值生成函数未设置导致的闪屏问题 366 367**问题现象** 368 369下拉刷新时,应用产生卡顿,出现闪屏问题。 370 371 372 373```ts 374@Builder 375private getListView() { 376 List({ 377 space: 12, scroller: this.scroller 378 }) { 379 // 使用懒加载组件渲染数据 380 ForEach(this.newsData, (item: NewsData) => { 381 ListItem() { 382 newsItem({ 383 newsTitle: item.newsTitle, 384 newsContent: item.newsContent, 385 newsTime: item.newsTime, 386 img: item.img 387 }) 388 } 389 .backgroundColor(Color.White) 390 .borderRadius(16) 391 }); 392 } 393 .width('100%') 394 .height('100%') 395 .padding({ 396 left: 16, 397 right: 16 398 }) 399 .backgroundColor('#F1F3F5') 400 // 必须设置列表为滑动到边缘无效果,否则无法触发pullToRefresh组件的上滑下拉方法。 401 .edgeEffect(EdgeEffect.None) 402} 403``` 404 405**可能原因** 406 407ForEach提供了一个名为keyGenerator的参数,这是一个函数,开发者可以通过它自定义键值的生成规则。如果开发者没有定义keyGenerator函数,则ArkUI框架会使用默认的键值生成函数,即(item: Object, index: number) => { return index + '__' + JSON.stringify(item); }。可参考[键值生成规则](../quick-start/arkts-rendering-control-foreach.md#键值生成规则)。 408 409在使用ForEach的过程中,若对于键值生成规则的理解不够充分,可能会出现错误的使用方式。错误使用一方面会导致功能层面问题,例如渲染结果非预期,另一方面会导致性能层面问题,例如渲染性能降低。 410 411**解决措施** 412 413在ForEach第三个参数中定义自定义键值的生成规则,即(item: NewsData, index?: number) => item.id,这样可以在渲染时降低重复组件的渲染开销,从而消除闪屏问题。可参考[ForEach组件使用建议](../quick-start/arkts-rendering-control-foreach.md#使用建议)。 414 415```ts 416@Builder 417private getListView() { 418 List({ 419 space: 12, scroller: this.scroller 420 }) { 421 // 使用懒加载组件渲染数据 422 ForEach(this.newsData, (item: NewsData) => { 423 ListItem() { 424 newsItem({ 425 newsTitle: item.newsTitle, 426 newsContent: item.newsContent, 427 newsTime: item.newsTime, 428 img: item.img 429 }) 430 } 431 .backgroundColor(Color.White) 432 .borderRadius(16) 433 }, (item: NewsData) => item.newsId); 434 } 435 .width('100%') 436 .height('100%') 437 .padding({ 438 left: 16, 439 right: 16 440 }) 441 .backgroundColor('#F1F3F5') 442 // 必须设置列表为滑动到边缘无效果,否则无法触发pullToRefresh组件的上滑下拉方法。 443 .edgeEffect(EdgeEffect.None) 444} 445``` 446 447运行效果如下图所示。 448 449 450 451## 总结 452 453当出现应用闪屏相关问题时,首先定位可能出现的原因,分别测试是否为当前原因导致。定位到问题后尝试使用对应解决方案,从而消除对应问题现象。 454 455- 应用连续点击场景下,通过计数器优化动画逻辑。 456- Tabs页签切换场景下,完善动画细粒度,提高流畅表现。 457- ForEach刷新内容过程中,根据业务场景调整键值生成函数。 458